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:
+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
|
||||
* 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);
|
||||
|
||||
Reference in New Issue
Block a user