fix(lanes): stop the stuck "waiting for your input" banner and idle status #1
@@ -336,6 +336,77 @@ describe("hook → lane binding", () => {
|
|||||||
|
|
||||||
await request("DELETE", `/api/lanes/${id}`);
|
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", () => {
|
describe("hook → stage detection", () => {
|
||||||
@@ -705,7 +776,10 @@ describe("GET /api/lanes/branches", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("400s for a path that does not exist", async () => {
|
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.status, 400);
|
||||||
assert.equal(r.body.error.code, "EBADSOURCEREPO");
|
assert.equal(r.body.error.code, "EBADSOURCEREPO");
|
||||||
});
|
});
|
||||||
|
|||||||
+38
-4
@@ -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
|
* 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
|
* this is best-effort: the hook path must never fail because of lane
|
||||||
* bookkeeping, so everything here is inside one try/catch.
|
* bookkeeping, so everything here is inside one try/catch.
|
||||||
*
|
*
|
||||||
* `needs_action` mirrors Claude Code's Notification hook (a permission prompt or
|
* `needs_action` mirrors Claude Code's Notification hook, minus the idle nudge
|
||||||
* an idle nudge). The next non-Notification hook from the same session means the
|
* (see IDLE_NUDGE_RE). The next non-Notification hook from the same session
|
||||||
* agent is moving again, so the flag clears itself — no user click required.
|
* 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) {
|
function touchLaneFromHook(hookType, data) {
|
||||||
try {
|
try {
|
||||||
@@ -183,14 +204,27 @@ function touchLaneFromHook(hookType, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const patch = {};
|
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 (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";
|
patch.needs_action = data.message || "needs you";
|
||||||
} else if (lane.needs_action && data.session_id === lane.session_id) {
|
} 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
|
// 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)
|
// (evaluated before any rebinding, so a rebinding hook never clears in the same pass)
|
||||||
patch.needs_action = null;
|
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;
|
if (!Object.keys(patch).length) return;
|
||||||
lanesLib.updateLane(lane.id, patch);
|
lanesLib.updateLane(lane.id, patch);
|
||||||
broadcastLane(lane.id);
|
broadcastLane(lane.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user