From cb6a547bb9d01919b30698a310512f00db3060da Mon Sep 17 00:00:00 2001 From: sjd9021 Date: Mon, 16 Feb 2026 02:15:44 +0530 Subject: [PATCH] fix: activity detection improvements for claude-code plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip Claude Code TUI decorations (status bar, separators) before classifying terminal output, fixing false positives where bypass permissions status bar text was misidentified as a permission prompt - Use "esc to interrupt" in status bar as authoritative active-vs-idle signal: present = active, absent = idle - Handle inline suggestions (❯ ) as idle when no esc-to-interrupt - Persist detected activity to session metadata so the API serves real-time activity state instead of hardcoded "idle" - Write runtimeHandle to metadata during CLI spawn so lifecycle manager can look up sessions by runtime handle - Set initial spawn status to "working" instead of "spawning" Co-Authored-By: Claude Opus 4.6 --- packages/cli/__tests__/commands/spawn.test.ts | 2 +- packages/cli/src/commands/spawn.ts | 3 +- packages/core/src/lifecycle-manager.ts | 2 + packages/core/src/session-manager.ts | 3 +- .../agent-claude-code/src/index.test.ts | 66 +++++++++++++++++++ .../plugins/agent-claude-code/src/index.ts | 30 ++++++++- 6 files changed, 102 insertions(+), 4 deletions(-) diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 9b9df9af3..e16c9be6b 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -202,7 +202,7 @@ describe("spawn command", () => { const content = readFileSync(metaFile, "utf-8"); expect(content).toContain("branch=feat/INT-100"); - expect(content).toContain("status=spawning"); + expect(content).toContain("status=working"); expect(content).toContain("project=my-app"); expect(content).toContain("issue=INT-100"); }); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index e27c56946..d43bc403a 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -164,7 +164,8 @@ async function spawnSession( writeMetadata(join(sessionDir, sessionName), { worktree: worktreePath, branch: liveBranch || branch || "detached", - status: "spawning", + status: "working", + runtimeHandle: JSON.stringify({ id: sessionName, runtimeName: "tmux" }), project: projectId, ...(issueId ? { issue: issueId } : {}), createdAt: new Date().toISOString(), diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index b2930dc98..91291fc27 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -201,6 +201,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // empty output means the runtime probe failed, not that the agent exited. if (terminalOutput) { const activity = agent.detectActivity(terminalOutput); + // Persist detected activity to metadata so the API can serve it + updateMetadata(config.dataDir, session.id, { activity }); if (activity === "waiting_input") return "needs_input"; // Check whether the agent process is still alive. Some agents diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 464d9660d..ad3b8e164 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -19,6 +19,7 @@ import type { SessionId, SessionSpawnConfig, SessionStatus, + ActivityState, CleanupResult, OrchestratorConfig, ProjectConfig, @@ -106,7 +107,7 @@ function metadataToSession( id: sessionId, projectId: meta["project"] ?? "", status: validateStatus(meta["status"]), - activity: "idle", + activity: (meta["activity"] as ActivityState) || "idle", branch: meta["branch"] || null, issueId: meta["issue"] || null, pr: meta["pr"] diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 09a14658a..16391e599 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -390,6 +390,72 @@ describe("detectActivity", () => { it("returns active for non-empty output with no special patterns", () => { expect(agent.detectActivity("some random terminal output\n")).toBe("active"); }); + + // ----------------------------------------------------------------------- + // Status bar false-positive regression tests + // ----------------------------------------------------------------------- + it("does NOT return waiting_input for status bar bypass permissions text", () => { + // The status bar shows "⏵⏵ bypass permissions on (shift+tab to cycle)" + // when --dangerously-skip-permissions is enabled. This must NOT be + // mistaken for an interactive permission prompt. + expect( + agent.detectActivity("some output\n⏵⏵ bypass permissions on (shift+tab to cycle)\n"), + ).toBe("active"); + }); + + it("returns idle when prompt is followed by status bar", () => { + // When a session finishes and shows the idle ❯ prompt, the status bar + // sits below it. The detector must strip the status bar and see the prompt. + expect( + agent.detectActivity("Task completed successfully.\n❯ \n⏵⏵ bypass permissions on (shift+tab to cycle)\n"), + ).toBe("idle"); + }); + + it("still returns waiting_input for actual bypass permissions prompt", () => { + // Real interactive prompt should still be detected. + expect( + agent.detectActivity("bypass all future permissions for this session\n"), + ).toBe("waiting_input"); + }); + + it("returns idle when only status bar lines remain after stripping", () => { + expect( + agent.detectActivity("⏵⏵ bypass permissions on (shift+tab to cycle)\n"), + ).toBe("idle"); + }); + + it("returns idle for prompt with separator and status bar (no esc to interrupt)", () => { + // Full TUI frame: prompt + separator + status bar, idle state + expect( + agent.detectActivity("❯ \n────────────────────────────────────────────────────────────────────────────────\n ⏵⏵ bypass permissions on (shift+tab to cycle)"), + ).toBe("idle"); + }); + + it("returns idle for prompt with inline suggestion (no esc to interrupt)", () => { + // Claude Code shows suggestions like "❯ check CI status" when idle + expect( + agent.detectActivity("❯ check CI status on the PR\n────────────────────────────────────────────────────────────────────────────────\n ⏵⏵ bypass permissions on (shift+tab to cycle)"), + ).toBe("idle"); + }); + + it("returns active for prompt with input being processed (esc to interrupt)", () => { + // When Claude Code is actively processing, status bar has "esc to interrupt" + expect( + agent.detectActivity("❯ fix the bug\n────────────────────────────────────────────────────────────────────────────────\n ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt"), + ).toBe("active"); + }); + + it("returns idle for real finished session output with suggestion", () => { + const realOutput = [ + " - 18 new tests (12 for the command, 6 for plugin resolution) — all passing", + "✻ Churned for 10m 56s", + "────────────────────────────────────────────────────────────────────────────────", + "❯ check CI status on the PR", + "────────────────────────────────────────────────────────────────────────────────", + " ⏵⏵ bypass permissions on (shift+tab to cycle)", + ].join("\n"); + expect(agent.detectActivity(realOutput)).toBe("idle"); + }); }); // ========================================================================= diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index e96263011..b9d743a4c 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -431,12 +431,40 @@ function classifyTerminalOutput(terminalOutput: string): ActivityState { if (!terminalOutput.trim()) return "idle"; const lines = terminalOutput.trim().split("\n"); + + // Claude Code's status bar is the most reliable activity signal: + // - Active: "⏵⏵ bypass permissions on ... · esc to interrupt" + // - Idle: "⏵⏵ bypass permissions on ..." (no esc to interrupt) + // Check BEFORE stripping decorative lines. + const rawTail = lines.slice(-3).join("\n"); + const hasEscToInterrupt = /esc to interrupt/i.test(rawTail); + + // Strip trailing decorative lines from Claude Code's TUI. From bottom up, + // remove: status bar lines (contain ⏵), separator lines (all ─), and + // empty lines. This exposes the actual prompt or content line for detection. + while (lines.length > 0) { + const last = lines[lines.length - 1]?.trim() ?? ""; + if (/⏵/.test(last) || /^[─━]+$/.test(last) || last === "") { + lines.pop(); + } else { + break; + } + } + + if (!lines.length) return "idle"; + const lastLine = lines[lines.length - 1]?.trim() ?? ""; // Check the last line FIRST — if the prompt is visible, the agent is idle // regardless of historical output (e.g. "Reading file..." from earlier). // The ❯ is Claude Code's prompt character. - if (/^[❯>$#]\s*$/.test(lastLine)) return "idle"; + // Bare prompt is idle ONLY when not actively processing (no "esc to interrupt") + if (/^[❯>$#]\s*$/.test(lastLine) && !hasEscToInterrupt) return "idle"; + + // Claude Code shows inline suggestions after the prompt when idle, e.g. + // "❯ check CI status on the PR". Distinguish from active processing by + // checking the status bar: no "esc to interrupt" = idle with suggestion. + if (/^[❯>]\s+/.test(lastLine) && !hasEscToInterrupt) return "idle"; // Check the bottom of the buffer for permission prompts BEFORE checking // full-buffer active indicators. Historical "Thinking"/"Reading" text in