From 0365ffe826c0e30e171e1faf21e9193881c93b9c Mon Sep 17 00:00:00 2001 From: Agents Agent Date: Fri, 26 Jun 2026 18:22:31 +0200 Subject: [PATCH] fix(agents-login): detect the ANSI-fused Claude login chooser/REPL (\s* not \s+) Claude login still hung at "starting": after /login the CLI shows the "Select login method" chooser, but the worker never pressed Enter to confirm it. The TUI renders that menu with ANSI cursor-column moves (ESC[NNG) instead of spaces, so stripAnsi() fuses the words ("Selectloginmethod", "Claudeaccountwithsubscription"). The detector regexes used \s+ (requires whitespace) and never matched the fused text -> no chooser Enter -> stall. (forceLoginMethod=claudeai does not auto-skip the chooser on claude 2.1.186.) Make detectClaudeLoginChooser and detectClaudeLoggedOutRepl whitespace- insensitive (\s*), matching both spaced and fused forms. Add regression tests that feed real ANSI cursor-column bytes (fail on the old \s+ patterns). Co-Authored-By: Claude Opus 4.8 (1M context) --- services/agents-login/src/worker/parse.ts | 9 +++++++-- services/agents-login/test/parse.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) 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', () => {