diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index 3aac561..b89f27e 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -336,6 +336,77 @@ describe("hook → lane binding", () => { await request("DELETE", `/api/lanes/${id}`); }); + + it("the bare idle nudge never raises needs_action and clears a stale one", async () => { + const created = await request("POST", "/api/lanes", { + cwd: "/tmp/lane-idle-nudge", + title: "Idle Nudge", + }); + const id = created.body.lane.id; + + await request("POST", "/api/hooks/event", { + hook_type: "SessionStart", + data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" }, + }); + await request("POST", "/api/hooks/event", { + hook_type: "Notification", + data: { + session_id: "sess-nudge", + cwd: "/tmp/lane-idle-nudge", + message: "Claude needs your permission to use Bash", + }, + }); + assert.equal( + (await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, + "Claude needs your permission to use Bash" + ); + + // The 60s idle nudge fires after Stop: it proves the CLI is parked at an + // idle prompt, so it must clear rather than pin the banner. + await request("POST", "/api/hooks/event", { + hook_type: "Notification", + data: { + session_id: "sess-nudge", + cwd: "/tmp/lane-idle-nudge", + message: "Claude is waiting for your input", + }, + }); + const lane = (await request("GET", `/api/lanes/${id}`)).body.lane; + assert.equal(lane.needs_action, null); + assert.equal(lane.status, "idle"); + + await request("DELETE", `/api/lanes/${id}`); + }); + + it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => { + const created = await request("POST", "/api/lanes", { + cwd: "/tmp/lane-turn-status", + title: "Turn Status", + }); + const id = created.body.lane.id; + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle"); + + await request("POST", "/api/hooks/event", { + hook_type: "UserPromptSubmit", + data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" }, + }); + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running"); + + // A subagent finishing is not the end of the turn. + await request("POST", "/api/hooks/event", { + hook_type: "SubagentStop", + data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" }, + }); + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running"); + + await request("POST", "/api/hooks/event", { + hook_type: "Stop", + data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" }, + }); + assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle"); + + await request("DELETE", `/api/lanes/${id}`); + }); }); describe("hook → stage detection", () => { @@ -705,7 +776,10 @@ describe("GET /api/lanes/branches", () => { }); it("400s for a path that does not exist", async () => { - const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`); + const r = await request( + "GET", + `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}` + ); assert.equal(r.status, 400); assert.equal(r.body.error.code, "EBADSOURCEREPO"); }); diff --git a/server/routes/hooks.js b/server/routes/hooks.js index 169a542..2c1093e 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -136,14 +136,35 @@ function recoverInterruptedSession(sessionId, fullSess, mainAgentId, reasonSuffi }); } +// Claude Code fires Notification for two unrelated things: a real block +// (permission prompt / AskUserQuestion) and a bare idle nudge ~60 s after a +// turn ended. The nudge arrives AFTER Stop, so no later hook is coming to +// clear it — a lane stamped from it sat on a permanent "⚠ Claude is waiting +// for your input" while the user was simply not typing. The nudge is not a +// blocking event: it proves the opposite (the CLI is parked at an idle +// prompt), so it clears the lane instead of stamping it. +const IDLE_NUDGE_RE = /^\s*claude is waiting for your input[.!]?\s*$/i; + +// Hooks that prove the CLI is mid-turn / between turns for the lane's session. +// Only these move lane.status; anything else (SubagentStop, SessionStart, +// Notification) leaves it alone — a subagent finishing does not end the turn. +const LANE_WORKING_HOOKS = new Set(["UserPromptSubmit", "PreToolUse", "PostToolUse"]); +const LANE_DONE_HOOKS = new Set(["Stop", "SessionEnd"]); + /** * Attach an incoming hook to the lane that owns its cwd. Lanes are optional and * this is best-effort: the hook path must never fail because of lane * bookkeeping, so everything here is inside one try/catch. * - * `needs_action` mirrors Claude Code's Notification hook (a permission prompt or - * an idle nudge). The next non-Notification hook from the same session means the - * agent is moving again, so the flag clears itself — no user click required. + * `needs_action` mirrors Claude Code's Notification hook, minus the idle nudge + * (see IDLE_NUDGE_RE). The next non-Notification hook from the same session + * means the agent is moving again, so the flag clears itself — no user click + * required. + * + * `status` is mirrored the same way for lanes the dashboard did NOT launch + * (run_id null). Those never pass through the run lifecycle that sets + * running/idle, so without this an adopted lane read "idle" for the entire + * time Claude was working in it. */ function touchLaneFromHook(hookType, data) { try { @@ -183,14 +204,27 @@ function touchLaneFromHook(hookType, data) { } const patch = {}; + const idleNudge = hookType === "Notification" && IDLE_NUDGE_RE.test(data.message || ""); if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id; - if (hookType === "Notification") { + if (hookType === "Notification" && !idleNudge) { patch.needs_action = data.message || "needs you"; } else if (lane.needs_action && data.session_id === lane.session_id) { // Clear only when the hook comes from the session currently bound to the lane // (evaluated before any rebinding, so a rebinding hook never clears in the same pass) patch.needs_action = null; } + + // Only idle↔running is mirrored: provisioning and failed are lifecycle + // states this path must never stomp on. + if (!lane.run_id && (lane.status === "idle" || lane.status === "running")) { + const next = LANE_WORKING_HOOKS.has(hookType) + ? "running" + : LANE_DONE_HOOKS.has(hookType) || idleNudge + ? "idle" + : null; + if (next && next !== lane.status) patch.status = next; + } + if (!Object.keys(patch).length) return; lanesLib.updateLane(lane.id, patch); broadcastLane(lane.id);