fix: activity detection improvements for claude-code plugin

- 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 (❯ <text>) 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 <noreply@anthropic.com>
This commit is contained in:
sjd9021 2026-02-16 02:15:44 +05:30
parent 1bb74c8b90
commit cb6a547bb9
6 changed files with 102 additions and 4 deletions

View File

@ -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");
});

View File

@ -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(),

View File

@ -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

View File

@ -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"]

View File

@ -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");
});
});
// =========================================================================

View File

@ -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