diff --git a/services/agents-login/src/worker/parse.ts b/services/agents-login/src/worker/parse.ts index 3d50ed63..250e788c 100644 --- a/services/agents-login/src/worker/parse.ts +++ b/services/agents-login/src/worker/parse.ts @@ -77,12 +77,17 @@ export function detectClaudeCodePrompt(buffer: string): boolean { return /paste\s*code\s*here\s*if\s*prompted\s*>/i.test(stripAnsi(buffer)) } +// The TUI draws these prompts with ANSI cursor-column moves (e.g. ESC[10G) +// instead of spaces; stripAnsi() removes those, FUSING the words with no +// whitespace ("Selectloginmethod", "Claudeaccountwithsubscription", +// "Run/login"). Match with \s* (zero-or-more), never \s+, so detection works +// against both the spaced and the fused forms. export function detectClaudeLoggedOutRepl(buffer: string): boolean { - return /(?:run\s+\/login|\?\s*for\s*shortcuts|welcome\s+back)/i.test(stripAnsi(buffer)) + return /(?:run\s*\/login|\?\s*for\s*shortcuts|welcome\s*back)/i.test(stripAnsi(buffer)) } export function detectClaudeLoginChooser(buffer: string): boolean { - return /(?:select\s+login\s+method|claude\s+account\s+with\s+subscription)/i.test(stripAnsi(buffer)) + return /(?:select\s*login\s*method|claude\s*account\s*with\s*subscription)/i.test(stripAnsi(buffer)) } export interface ClaudeRedirectCode { diff --git a/services/agents-login/test/parse.test.ts b/services/agents-login/test/parse.test.ts index f12d6b60..f672f916 100644 --- a/services/agents-login/test/parse.test.ts +++ b/services/agents-login/test/parse.test.ts @@ -68,12 +68,20 @@ describe('PTY output parsing', () => { expect(detectClaudeLoggedOutRepl('Not logged in · Run /login')).toBe(true) expect(detectClaudeLoggedOutRepl('\x1b[2GWelcome back\r\n? for shortcuts')).toBe(true) expect(detectClaudeLoggedOutRepl('Paste code here if prompted >')).toBe(false) + // The real REPL uses ANSI cursor-column moves, not spaces: after stripAnsi + // the words fuse ("Run/login", "?forshortcuts"). Must still detect. + expect(detectClaudeLoggedOutRepl('Not\x1b[377Glogged\x1b[384Gin\x1b[387G·\x1b[389GRun\x1b[393G/login')).toBe(true) }) it('detects the Claude login-method chooser', () => { expect(detectClaudeLoginChooser('Select login method')).toBe(true) expect(detectClaudeLoginChooser('\x1b[36mClaude account with subscription\x1b[0m')).toBe(true) expect(detectClaudeLoginChooser('Not logged in · Run /login')).toBe(false) + // Real chooser output: ANSI cursor-column moves fuse the words to + // "Selectloginmethod"/"Claudeaccountwithsubscription" after stripAnsi. + // This regressed login (no Enter sent) until the detectors used \s* not \s+. + expect(detectClaudeLoginChooser('\x1b[3GSelect\x1b[10Glogin\x1b[16Gmethod:')).toBe(true) + expect(detectClaudeLoginChooser('\x1b[8G\x1b[97mClaude\x1b[15Gaccount\x1b[23Gwith\x1b[28Gsubscription')).toBe(true) }) it('extracts the Claude authorization code from a callback URL and preserves bare codes', () => {