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

Two lane-card symptoms, one source: touchLaneFromHook.

needs_action was stamped on every Notification hook, including Claude
Code's bare idle nudge ("Claude is waiting for your input"), which fires
~60s AFTER Stop. Nothing later arrives to clear it, so the card kept a
permanent amber "needs you" while the user was simply not typing. The
nudge now clears the flag instead of raising it - it proves the CLI is
parked at an idle prompt. Permission/AskUserQuestion messages are
unchanged.

lane.status only moved through the dashboard run lifecycle (run_id), so
a lane driven by `claude` in a terminal read "idle" for the whole time
Claude was working in it. For lanes the dashboard did not launch, the
turn hooks now mirror it: UserPromptSubmit/PreToolUse/PostToolUse ->
running, Stop/SessionEnd/idle-nudge -> idle. SubagentStop is excluded (a
subagent finishing is not the end of the turn) and provisioning/failed
are never stomped.

Side effect: classifyLiveness treats status=running as "expect live", so
a turn with no hook for more than LANE_DEAD_SEC (300s) now shows the dead
dot. That is the detector working as designed; raise LANE_DEAD_SEC if a
long single Bash trips it.
This commit is contained in:
2026-07-30 15:27:26 +07:00
parent 57dc91585d
commit 9b1fa67385
2 changed files with 113 additions and 5 deletions
+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);