@@ -454,7 +454,86 @@ final class ChatCLIProvider {
454454
455455 // MARK: - Parsing
456456
457+ /// Strip OSC (Operating System Command) escape sequences from CLI output.
458+ /// These are injected by terminal integrations like iTerm2 and pollute JSON responses.
459+ /// Examples: ]1337;RemoteHost=user@host, ]9;4;0;, ]1337;CurrentDir=/path
460+ /// Safety: Only strips if semicolon appears within first 5 chars (real OSC always has it)
461+ private func stripOSCEscapes( _ input: String ) -> String {
462+ var result = " "
463+ var i = input. startIndex
464+ while i < input. endIndex {
465+ if input [ i] == " ] " {
466+ let next = input. index ( after: i)
467+ if next < input. endIndex, input [ next] . isNumber {
468+ // Look ahead to see if there's a semicolon within first 5 chars (OSC signature)
469+ var hasSemicolon = false
470+ var lookAhead = next
471+ var lookCount = 0
472+ while lookAhead < input. endIndex, lookCount < 5 {
473+ if input [ lookAhead] == " ; " {
474+ hasSemicolon = true
475+ break
476+ }
477+ if !input[ lookAhead] . isNumber { break }
478+ lookAhead = input. index ( after: lookAhead)
479+ lookCount += 1
480+ }
481+
482+ if hasSemicolon {
483+ // This is a real OSC sequence - skip it
484+ var j = next
485+ while j < input. endIndex {
486+ let c = input [ j]
487+ if c. isNumber || c == " ; " || c == " = " || c. isLetter || c == " @ " || c == " . " || c == " - " || c == " _ " || c == " / " {
488+ j = input. index ( after: j)
489+ } else {
490+ break
491+ }
492+ }
493+ i = j
494+ continue
495+ }
496+ }
497+ }
498+ result. append ( input [ i] )
499+ i = input. index ( after: i)
500+ }
501+ return result
502+ }
503+
504+ /// Extract user-facing error message from CLI stderr/stdout.
505+ /// Returns the actual error message from the CLI tool if found, nil otherwise.
506+ private func extractCLIError( stdout: String , stderr: String ) -> String ? {
507+ // Check stderr for ERROR: lines (Codex format)
508+ // e.g. "ERROR: You've hit your usage limit..."
509+ // e.g. "ERROR: Your access token could not be refreshed..."
510+ for line in stderr. components ( separatedBy: . newlines) {
511+ let trimmed = line. trimmingCharacters ( in: . whitespaces)
512+ if trimmed. hasPrefix ( " ERROR: " ) {
513+ return trimmed
514+ }
515+ }
516+
517+ // Check stdout for API Error messages (Claude format)
518+ // e.g. "API Error: The SSO session associated with this profile has expired..."
519+ // e.g. "You've hit your limit · resets 3pm (Asia/Shanghai)"
520+ // e.g. "Invalid API key · Please run /login"
521+ for line in stdout. components ( separatedBy: . newlines) {
522+ let trimmed = line. trimmingCharacters ( in: . whitespaces)
523+ if trimmed. hasPrefix ( " API Error: " ) ||
524+ trimmed. hasPrefix ( " Invalid API key " ) ||
525+ trimmed. hasPrefix ( " You've hit your limit " ) {
526+ // Strip trailing escape sequences like ]9;4;0;
527+ let cleaned = trimmed. replacingOccurrences ( of: #"\][\d;]+$"# , with: " " , options: . regularExpression)
528+ return cleaned
529+ }
530+ }
531+
532+ return nil
533+ }
534+
457535 private func parseCards( from output: String , stderr: String ) throws -> [ ActivityCardData ] {
536+ // Try parsing without modifications first, OSC stripping is a fallback
458537 guard let data = output. data ( using: . utf8) else {
459538 throw NSError ( domain: " ChatCLI " , code: - 31 , userInfo: [ NSLocalizedDescriptionKey: " No stdout to parse " ] )
460539 }
@@ -541,6 +620,22 @@ final class ChatCLIProvider {
541620 }
542621 }
543622
623+ // Strategy 4 (fallback): Strip OSC escapes and retry bracket extraction
624+ let oscCleaned = stripOSCEscapes ( output)
625+ if let lastBracket = oscCleaned. lastIndex ( of: " ] " ) ,
626+ let firstBracket = findBalancedArrayStart ( oscCleaned, endBracket: lastBracket) {
627+ let sliced = String ( oscCleaned [ firstBracket... lastBracket] )
628+ . replacingOccurrences ( of: " ```json " , with: " " )
629+ . replacingOccurrences ( of: " ``` " , with: " " )
630+ . trimmingCharacters ( in: . whitespacesAndNewlines)
631+
632+ if let slicedData = sliced. data ( using: . utf8) {
633+ if let arrayCards = try ? decoder. decode ( [ ActivityCardData ] . self, from: slicedData) {
634+ return arrayCards
635+ }
636+ }
637+ }
638+
544639 // Log full raw output to PostHog for debugging decode failures
545640 AnalyticsService . shared. capture ( " llm_decode_failed " , [
546641 " provider " : " chat_cli " ,
@@ -552,6 +647,11 @@ final class ChatCLIProvider {
552647 " stderr_length " : stderr. count
553648 ] )
554649
650+ // Surface CLI error messages to the user if available
651+ if let cliError = extractCLIError ( stdout: output, stderr: stderr) {
652+ throw NSError ( domain: " ChatCLI " , code: - 33 , userInfo: [ NSLocalizedDescriptionKey: cliError] )
653+ }
654+
555655 throw NSError ( domain: " ChatCLI " , code: - 32 , userInfo: [ NSLocalizedDescriptionKey: " Failed to decode activity cards " ] )
556656 }
557657
@@ -934,34 +1034,58 @@ final class ChatCLIProvider {
9341034 }
9351035
9361036 private func parseSegments( from output: String , stderr: String ) throws -> [ SegmentMergeResponse . Segment ] {
937- let cleaned = output
1037+ // First try parsing without any modifications
1038+ let basicCleaned = output
9381039 . replacingOccurrences ( of: " ```json " , with: " " )
9391040 . replacingOccurrences ( of: " ``` " , with: " " )
9401041 . trimmingCharacters ( in: . whitespacesAndNewlines)
9411042
942- if let data = cleaned. data ( using: . utf8) ,
1043+ var lastDecodeError : String ?
1044+
1045+ // Strategy 1: Direct decode
1046+ if let data = basicCleaned. data ( using: . utf8) ,
9431047 let parsed = try ? JSONDecoder ( ) . decode ( SegmentMergeResponse . self, from: data) ,
9441048 !parsed. segments. isEmpty {
9451049 return parsed. segments
9461050 }
9471051
948- if let data = cleaned. data ( using: . utf8) ,
1052+ // Strategy 2: Array decode
1053+ if let data = basicCleaned. data ( using: . utf8) ,
9491054 let parsed = try ? JSONDecoder ( ) . decode ( [ SegmentMergeResponse . Segment ] . self, from: data) ,
9501055 !parsed. isEmpty {
9511056 return parsed
9521057 }
9531058
954- if let firstBrace = cleaned. firstIndex ( of: " { " ) ,
955- let lastBrace = cleaned. lastIndex ( of: " } " ) ,
1059+ // Strategy 3: Brace extraction
1060+ if let firstBrace = basicCleaned. firstIndex ( of: " { " ) ,
1061+ let lastBrace = basicCleaned. lastIndex ( of: " } " ) ,
9561062 firstBrace < lastBrace {
957- let slice = String ( cleaned [ firstBrace... lastBrace] )
1063+ let slice = String ( basicCleaned [ firstBrace... lastBrace] )
9581064 if let data = slice. data ( using: . utf8) ,
9591065 let parsed = try ? JSONDecoder ( ) . decode ( SegmentMergeResponse . self, from: data) ,
9601066 !parsed. segments. isEmpty {
9611067 return parsed. segments
9621068 }
9631069 }
9641070
1071+ // Strategy 4 (fallback): Strip OSC escapes and retry brace extraction
1072+ let oscCleaned = stripOSCEscapes ( basicCleaned)
1073+ if let firstBrace = oscCleaned. firstIndex ( of: " { " ) ,
1074+ let lastBrace = oscCleaned. lastIndex ( of: " } " ) ,
1075+ firstBrace < lastBrace {
1076+ let slice = String ( oscCleaned [ firstBrace... lastBrace] )
1077+ if let data = slice. data ( using: . utf8) {
1078+ do {
1079+ let parsed = try JSONDecoder ( ) . decode ( SegmentMergeResponse . self, from: data)
1080+ if !parsed. segments. isEmpty { return parsed. segments }
1081+ } catch {
1082+ lastDecodeError = " Strategy 4 (OSC strip + brace): \( error. localizedDescription) "
1083+ }
1084+ }
1085+ } else {
1086+ lastDecodeError = " No JSON object found in output "
1087+ }
1088+
9651089 // Log full raw output to PostHog for debugging decode failures
9661090 AnalyticsService . shared. capture ( " llm_decode_failed " , [
9671091 " provider " : " chat_cli " ,
@@ -970,9 +1094,15 @@ final class ChatCLIProvider {
9701094 " raw_output " : output,
9711095 " output_length " : output. count,
9721096 " stderr " : stderr,
973- " stderr_length " : stderr. count
1097+ " stderr_length " : stderr. count,
1098+ " decode_error " : lastDecodeError ?? " no JSON found "
9741099 ] )
9751100
1101+ // Surface CLI error messages to the user if available
1102+ if let cliError = extractCLIError ( stdout: output, stderr: stderr) {
1103+ throw NSError ( domain: " ChatCLI " , code: - 33 , userInfo: [ NSLocalizedDescriptionKey: cliError] )
1104+ }
1105+
9761106 throw NSError ( domain: " ChatCLI " , code: - 31 , userInfo: [ NSLocalizedDescriptionKey: " Failed to decode segments JSON " ] )
9771107 }
9781108
0 commit comments