fix(lanes): stop the stuck "waiting for your input" banner and idle status #1

Merged
nntrivi2001 merged 3 commits from fix/lane-waiting-and-running-status into main 2026-07-31 04:13:04 +00:00
2 changed files with 113 additions and 5 deletions
Showing only changes of commit 9b1fa67385 - Show all commits
+75 -1
View File
@@ -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");
});
+38 -4
View File
@@ -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);