Skip to content

Commit 6adfa8b

Browse files
waterdrop86claude
andcommitted
refactor: centralize permission prompt detection and clean up resume state
Extract hasPermissionPrompt() into prompt-detect.ts and add permission detection polling to codex, copilot, and gemini runtimes. Reset stale last_tool, attention_level, and attention_reason fields on session resume. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 38c30bf commit 6adfa8b

11 files changed

Lines changed: 195 additions & 19 deletions

src/main/session/agent-runtimes/claude-runtime.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { basename, join } from 'node:path';
22
import { existsSync } from 'node:fs';
3-
import { stripAnsi } from '../../../shared/strip-ansi';
4-
import { isAtClaudePrompt, isAtUserChoice } from '../prompt-detect';
3+
import { isAtClaudePrompt, isAtUserChoice, hasPermissionPrompt } from '../prompt-detect';
54
import { USER_CHOICE_TOOLS } from '../session-state-machine';
65
import type {
76
AgentCreateContext,
@@ -13,12 +12,6 @@ import type {
1312
StateUpdate,
1413
} from '../agent-runtime';
1514

16-
const PERMISSION_PATTERNS = [
17-
/Allow\s+once/,
18-
/Deny\s+once/,
19-
/Allow\s+always/,
20-
];
21-
2215
/**
2316
* Poll-based state detection for Claude Code sessions.
2417
*
@@ -27,15 +20,13 @@ const PERMISSION_PATTERNS = [
2720
* unavailable) this is the only detection mechanism.
2821
*/
2922
export function claudePollState(ctx: PtyPollContext): StateUpdate | null {
30-
const tail = stripAnsi(ctx.buffer.slice(-2000));
3123
const rawTail = ctx.buffer.slice(-2000);
32-
const hasPermissionPrompt = PERMISSION_PATTERNS.some((re) => re.test(tail));
3324

3425
if (
3526
(ctx.status === 'active' || ctx.status === 'idle') &&
3627
ctx.attentionLevel !== 'action' &&
37-
hasPermissionPrompt &&
38-
ctx.isQuiescent
28+
ctx.isQuiescent &&
29+
hasPermissionPrompt(rawTail)
3930
) {
4031
// Permission prompt detected: quiescent + pattern visible
4132
return {

src/main/session/agent-runtimes/codex-runtime.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { basename } from 'node:path';
22
import { getDb } from '../../db';
33
import { logger } from '../../logger';
44
import { findCodexThreadMatch } from '../codex-session-store';
5+
import { hasPermissionPrompt } from '../prompt-detect';
56
import type {
67
AgentCreateContext,
78
AgentPostCreateContext,
@@ -140,6 +141,18 @@ export function buildCodexResumePlan(ctx: AgentPrepareResumeContext): PreparedRe
140141
* this polling is just a safety net.
141142
*/
142143
export function codexPollState(ctx: PtyPollContext): StateUpdate | null {
144+
if (
145+
(ctx.status === 'active' || ctx.status === 'idle') &&
146+
ctx.attentionLevel !== 'action' &&
147+
ctx.isQuiescent &&
148+
hasPermissionPrompt(ctx.buffer)
149+
) {
150+
return {
151+
status: 'waiting',
152+
attention: { level: 'action', reason: 'Permission prompt detected' },
153+
};
154+
}
155+
143156
if (ctx.status === 'active' && ctx.isQuiescent) {
144157
return {
145158
status: 'idle',
@@ -170,4 +183,4 @@ export function createCodexRuntimeAdapter(deps: {
170183
},
171184
pollState: codexPollState,
172185
};
173-
}
186+
}

src/main/session/agent-runtimes/copilot-runtime.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { basename } from 'node:path';
22
import { getDb } from '../../db';
33
import { logger } from '../../logger';
44
import { findCopilotSessionId } from '../copilot-session-store';
5+
import { hasPermissionPrompt } from '../prompt-detect';
56
import type {
67
AgentCreateContext,
78
AgentPostCreateContext,
@@ -133,6 +134,18 @@ export function scheduleCopilotSessionCapture(
133134
* In Phase 1, hooks are not available so this is the primary detection method.
134135
*/
135136
export function copilotPollState(ctx: PtyPollContext): StateUpdate | null {
137+
if (
138+
(ctx.status === 'active' || ctx.status === 'idle') &&
139+
ctx.attentionLevel !== 'action' &&
140+
ctx.isQuiescent &&
141+
hasPermissionPrompt(ctx.buffer)
142+
) {
143+
return {
144+
status: 'waiting',
145+
attention: { level: 'action', reason: 'Permission prompt detected' },
146+
};
147+
}
148+
136149
if (ctx.status === 'active' && ctx.isQuiescent) {
137150
return {
138151
status: 'idle',

src/main/session/agent-runtimes/gemini-runtime.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
selectGeminiSessionCandidate,
99
type GeminiListedSession,
1010
} from '../gemini-session-store';
11+
import { hasPermissionPrompt } from '../prompt-detect';
1112
import type {
1213
AgentCreateContext,
1314
AgentPostCreateContext,
@@ -176,6 +177,18 @@ export function buildGeminiCreatePlan(ctx: AgentCreateContext): PreparedCreate {
176177
* this polling is just a safety net.
177178
*/
178179
export function geminiPollState(ctx: PtyPollContext): StateUpdate | null {
180+
if (
181+
(ctx.status === 'active' || ctx.status === 'idle') &&
182+
ctx.attentionLevel !== 'action' &&
183+
ctx.isQuiescent &&
184+
hasPermissionPrompt(ctx.buffer)
185+
) {
186+
return {
187+
status: 'waiting',
188+
attention: { level: 'action', reason: 'Permission prompt detected' },
189+
};
190+
}
191+
179192
if (ctx.status === 'active' && ctx.isQuiescent) {
180193
return {
181194
status: 'idle',

src/main/session/prompt-detect.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { stripAnsi } from '../../shared/strip-ansi';
22

3+
const PERMISSION_PATTERNS = [
4+
/Allow\s+once/i,
5+
/Deny\s+once/i,
6+
/Allow\s+always/i,
7+
] as const;
8+
39
/**
410
* Check if the terminal buffer tail shows Claude Code's idle prompt (❯).
511
* The raw ring buffer is a linear stream — cursor-repositioned content
@@ -19,6 +25,17 @@ export function isAtClaudePrompt(rawBufferTail: string): boolean {
1925
return after.length < 800 && (after.match(/\n/g) || []).length <= 5;
2026
}
2127

28+
/**
29+
* Check whether the terminal buffer tail contains a known permission prompt.
30+
*
31+
* These prompts can surface when hook transport is unavailable, so PTY polling
32+
* needs a text-based fallback to put the session into `waiting`.
33+
*/
34+
export function hasPermissionPrompt(rawBufferTail: string): boolean {
35+
const clean = stripAnsi(rawBufferTail.slice(-2000));
36+
return PERMISSION_PATTERNS.some((re) => re.test(clean));
37+
}
38+
2239
/**
2340
* Check if the terminal buffer tail shows a Claude Code user-choice menu
2441
* (e.g. ExitPlanMode or AskUserQuestion). These menus use ❯ as a cursor

src/main/session/session-manager.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,16 @@ export class SessionManager {
417417
private resumeWithPreparedPlan(sessionId: string, prepared: PreparedResume): SessionInfo {
418418
const db = getDb();
419419
db.prepare(
420-
`UPDATE sessions SET status = 'starting', ended_at = NULL, hook_mode = ?, auto_close = 0 WHERE session_id = ?`,
420+
`UPDATE sessions
421+
SET status = 'starting',
422+
ended_at = NULL,
423+
hook_mode = ?,
424+
auto_close = 0,
425+
last_tool = NULL,
426+
last_event_at = NULL,
427+
attention_level = 'none',
428+
attention_reason = NULL
429+
WHERE session_id = ?`,
421430
).run(prepared.hookMode, sessionId);
422431

423432
try {
@@ -1400,4 +1409,3 @@ export function registerSessionIpc(sessionManager: SessionManager): void {
14001409
return sessionManager.importExternal(claudeSessionId, cwd, label);
14011410
});
14021411
}
1403-

tests/unit/main/codex-runtime.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,29 @@ describe('codexPollState', () => {
222222
});
223223
});
224224

225+
it('detects permission prompts before idle fallback', () => {
226+
expect(codexPollState(ctx({
227+
status: 'active',
228+
buffer: 'Allow once\nDeny once\nAllow always\n',
229+
isQuiescent: true,
230+
}))).toEqual({
231+
status: 'waiting',
232+
attention: { level: 'action', reason: 'Permission prompt detected' },
233+
});
234+
});
235+
236+
it('does not re-trigger permission detection when action attention is already set', () => {
237+
expect(codexPollState(ctx({
238+
status: 'active',
239+
attentionLevel: 'action',
240+
buffer: 'Allow once\nDeny once\nAllow always\n',
241+
isQuiescent: true,
242+
}))).toEqual({
243+
status: 'idle',
244+
attention: { level: 'action', reason: 'Codex finished — awaiting input' },
245+
});
246+
});
247+
225248
it('returns null when active but not quiescent', () => {
226249
expect(codexPollState(ctx({ status: 'active', isQuiescent: false }))).toBeNull();
227250
});
@@ -233,4 +256,4 @@ describe('codexPollState', () => {
233256
it('returns null when waiting', () => {
234257
expect(codexPollState(ctx({ status: 'waiting', isQuiescent: true }))).toBeNull();
235258
});
236-
});
259+
});

tests/unit/main/copilot-runtime.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,29 @@ describe('copilotPollState', () => {
228228
});
229229
});
230230

231+
it('detects permission prompts before idle fallback', () => {
232+
expect(copilotPollState(makePollCtx({
233+
status: 'active',
234+
buffer: 'Allow once\nDeny once\nAllow always\n',
235+
isQuiescent: true,
236+
}))).toEqual({
237+
status: 'waiting',
238+
attention: { level: 'action', reason: 'Permission prompt detected' },
239+
});
240+
});
241+
242+
it('does not re-trigger permission detection when action attention is already set', () => {
243+
expect(copilotPollState(makePollCtx({
244+
status: 'active',
245+
attentionLevel: 'action',
246+
buffer: 'Allow once\nDeny once\nAllow always\n',
247+
isQuiescent: true,
248+
}))).toEqual({
249+
status: 'idle',
250+
attention: { level: 'action', reason: 'Copilot finished — awaiting input' },
251+
});
252+
});
253+
231254
it('returns null when active but not quiescent', () => {
232255
expect(copilotPollState(makePollCtx({ status: 'active', isQuiescent: false }))).toBeNull();
233256
});

tests/unit/main/gemini-runtime.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,29 @@ describe('geminiPollState', () => {
347347
});
348348
});
349349

350+
it('detects permission prompts before idle fallback', () => {
351+
expect(geminiPollState(ctx({
352+
status: 'active',
353+
buffer: 'Allow once\nDeny once\nAllow always\n',
354+
isQuiescent: true,
355+
}))).toEqual({
356+
status: 'waiting',
357+
attention: { level: 'action', reason: 'Permission prompt detected' },
358+
});
359+
});
360+
361+
it('does not re-trigger permission detection when action attention is already set', () => {
362+
expect(geminiPollState(ctx({
363+
status: 'active',
364+
attentionLevel: 'action',
365+
buffer: 'Allow once\nDeny once\nAllow always\n',
366+
isQuiescent: true,
367+
}))).toEqual({
368+
status: 'idle',
369+
attention: { level: 'action', reason: 'Gemini finished — awaiting input' },
370+
});
371+
});
372+
350373
it('returns null when active but not quiescent', () => {
351374
expect(geminiPollState(ctx({ status: 'active', isQuiescent: false }))).toBeNull();
352375
});

tests/unit/main/prompt-detect.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect } from 'vitest';
2-
import { isAtClaudePrompt, isAtUserChoice, parseUserChoices } from '../../../src/main/session/prompt-detect';
2+
import {
3+
hasPermissionPrompt,
4+
isAtClaudePrompt,
5+
isAtUserChoice,
6+
parseUserChoices,
7+
} from '../../../src/main/session/prompt-detect';
38

49
describe('isAtClaudePrompt', () => {
510
it('detects a clean prompt', () => {
@@ -95,6 +100,20 @@ describe('isAtUserChoice', () => {
95100
});
96101
});
97102

103+
describe('hasPermissionPrompt', () => {
104+
it('detects the known permission prompt copy', () => {
105+
expect(hasPermissionPrompt('Allow once\nDeny once\nAllow always\n')).toBe(true);
106+
});
107+
108+
it('handles ANSI sequences in the prompt', () => {
109+
expect(hasPermissionPrompt('\x1b[32mAllow once\x1b[0m\nDeny once')).toBe(true);
110+
});
111+
112+
it('returns false for unrelated output', () => {
113+
expect(hasPermissionPrompt('regular model output\nno permission prompt here')).toBe(false);
114+
});
115+
});
116+
98117
describe('parseUserChoices', () => {
99118
it('parses numbered options from a menu', () => {
100119
const menu = `

0 commit comments

Comments
 (0)