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:
parent
1bb74c8b90
commit
cb6a547bb9
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue