@@ -48,6 +48,7 @@ const MCP_PROXY_REQUEST_LINE_TIMEOUT: Duration = Duration::from_millis(250);
4848const PTY_TERMINATE_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
4949const TTY_SESSION_LOCK_RETRY_TIMEOUT : Duration = Duration :: from_secs ( 8 ) ;
5050const TTY_SESSION_LOCK_RETRY_DELAY : Duration = Duration :: from_millis ( 300 ) ;
51+ const TTY_VISIBLE_COMPLETION_IDLE : Duration = Duration :: from_secs ( 3 ) ;
5152const RUN_TIMEOUT : Duration = Duration :: from_secs ( 3600 ) ;
5253
5354pub async fn run ( invocation : Invocation ) -> Result < i32 > {
@@ -1108,6 +1109,7 @@ async fn tail_until_complete(
11081109 let mut tty_debug = TtyDebugLogger :: new ( "text" ) ;
11091110 let mut tail_progress = TailProgressLogger :: new ( "text" ) ;
11101111 let mut questions = TtyQuestionBridge :: new ( false ) ;
1112+ let mut visible_progress = TtyVisibleProgress :: new ( process) ;
11111113
11121114 loop {
11131115 if started. elapsed ( ) > RUN_TIMEOUT {
@@ -1156,6 +1158,7 @@ async fn tail_until_complete(
11561158 {
11571159 last_activity = Instant :: now ( ) ;
11581160 }
1161+ visible_progress. observe ( process) ;
11591162 if !state. assistant_text . is_empty ( ) && last_activity. elapsed ( ) >= COMPLETION_IDLE {
11601163 if output_format == OutputFormat :: StreamJson && !state. saw_result {
11611164 let value = synthetic_result ( & state, started. elapsed ( ) ) ;
@@ -1167,6 +1170,20 @@ async fn tail_until_complete(
11671170 emit_idle_session_state_if_requested ( & mut state, output_format) ?;
11681171 return Ok ( state) ;
11691172 }
1173+ if state. assistant_text . is_empty ( )
1174+ && visible_progress. completed_without_transcript ( process)
1175+ && last_activity. elapsed ( ) >= COMPLETION_IDLE
1176+ {
1177+ if output_format == OutputFormat :: StreamJson && !state. saw_result {
1178+ let value = synthetic_prompt_ready_result ( & state, started. elapsed ( ) ) ;
1179+ logging:: event ( "tail_result source=synthetic_prompt_ready" ) ;
1180+ println ! ( "{}" , serde_json:: to_string( & value) ?) ;
1181+ std:: io:: stdout ( ) . flush ( ) ?;
1182+ state. apply ( & value) ;
1183+ }
1184+ emit_idle_session_state_if_requested ( & mut state, output_format) ?;
1185+ return Ok ( state) ;
1186+ }
11701187 tail_progress. maybe_log ( process, tail, & state, started. elapsed ( ) ) ;
11711188 tty_debug. maybe_log ( process, started. elapsed ( ) ) ;
11721189 tokio:: time:: sleep ( TRANSCRIPT_POLL ) . await ;
@@ -1189,6 +1206,7 @@ async fn tail_until_complete_stream(
11891206 let mut questions = TtyQuestionBridge :: new ( permission_prompt_tool_stdio) ;
11901207 let mut tty_debug = TtyDebugLogger :: new ( "stream" ) ;
11911208 let mut tail_progress = TailProgressLogger :: new ( "stream" ) ;
1209+ let mut visible_progress = TtyVisibleProgress :: new ( process) ;
11921210
11931211 loop {
11941212 if started. elapsed ( ) > RUN_TIMEOUT {
@@ -1243,6 +1261,7 @@ async fn tail_until_complete_stream(
12431261 permission. mark_ask_user_question_handled ( ) ;
12441262 last_activity = Instant :: now ( ) ;
12451263 }
1264+ visible_progress. observe ( process) ;
12461265
12471266 if state. saw_result {
12481267 logging:: event ( "tail_result source=transcript stream=true" ) ;
@@ -1271,6 +1290,21 @@ async fn tail_until_complete_stream(
12711290 emit_idle_session_state_if_requested ( & mut state, output_format) ?;
12721291 return Ok ( state) ;
12731292 }
1293+ if state. assistant_text . is_empty ( )
1294+ && !permission. denied_current_turn ( )
1295+ && visible_progress. completed_without_transcript ( process)
1296+ && last_activity. elapsed ( ) >= COMPLETION_IDLE
1297+ {
1298+ if output_format == OutputFormat :: StreamJson && !state. saw_result {
1299+ let value = synthetic_prompt_ready_result ( & state, started. elapsed ( ) ) ;
1300+ logging:: event ( "tail_result source=synthetic_prompt_ready stream=true" ) ;
1301+ println ! ( "{}" , serde_json:: to_string( & value) ?) ;
1302+ std:: io:: stdout ( ) . flush ( ) ?;
1303+ state. apply ( & value) ;
1304+ }
1305+ emit_idle_session_state_if_requested ( & mut state, output_format) ?;
1306+ return Ok ( state) ;
1307+ }
12741308 tail_progress. maybe_log ( process, tail, & state, started. elapsed ( ) ) ;
12751309 tty_debug. maybe_log ( process, started. elapsed ( ) ) ;
12761310 tokio:: time:: sleep ( TRANSCRIPT_POLL ) . await ;
@@ -1496,6 +1530,39 @@ impl TailProgressLogger {
14961530 }
14971531}
14981532
1533+ struct TtyVisibleProgress {
1534+ last_snapshot : String ,
1535+ last_change : Instant ,
1536+ saw_model_activity : bool ,
1537+ }
1538+
1539+ impl TtyVisibleProgress {
1540+ fn new ( process : & PtyProcess ) -> Self {
1541+ Self {
1542+ last_snapshot : tty_progress_snapshot ( process) ,
1543+ last_change : Instant :: now ( ) ,
1544+ saw_model_activity : false ,
1545+ }
1546+ }
1547+
1548+ fn observe ( & mut self , process : & PtyProcess ) {
1549+ let snapshot = tty_progress_snapshot ( process) ;
1550+ if snapshot != self . last_snapshot {
1551+ self . last_snapshot = snapshot;
1552+ self . last_change = Instant :: now ( ) ;
1553+ }
1554+ if tty_output_has_visible_model_activity ( & process. recent_output ( ) ) {
1555+ self . saw_model_activity = true ;
1556+ }
1557+ }
1558+
1559+ fn completed_without_transcript ( & self , process : & PtyProcess ) -> bool {
1560+ self . saw_model_activity
1561+ && tty_wait_class ( & process. recent_output ( ) ) == "prompt_ready"
1562+ && self . last_change . elapsed ( ) >= TTY_VISIBLE_COMPLETION_IDLE
1563+ }
1564+ }
1565+
14991566struct PermissionBridge {
15001567 enabled : bool ,
15011568 requested_tool_use_ids : HashSet < String > ,
@@ -3428,6 +3495,29 @@ fn tty_wait_class(output: &str) -> &'static str {
34283495 "other"
34293496}
34303497
3498+ fn tty_progress_snapshot ( process : & PtyProcess ) -> String {
3499+ compact_tty_output ( & plain_tty_output ( & process. recent_output ( ) ) )
3500+ }
3501+
3502+ fn tty_output_has_visible_model_activity ( output : & str ) -> bool {
3503+ let output = plain_tty_output ( output) ;
3504+ let compact = compact_tty_output ( & output) ;
3505+ output. contains ( "⏺" )
3506+ || output. contains ( "⎿" )
3507+ || output. contains ( "Bash (" )
3508+ || output. contains ( "Fetch (" )
3509+ || output. contains ( "Edit (" )
3510+ || output. contains ( "Write (" )
3511+ || output. contains ( "Read (" )
3512+ || output. contains ( "Wrote " )
3513+ || output. contains ( "thinking with" )
3514+ || output. contains ( "thought for" )
3515+ || output. contains ( "running stop hook" )
3516+ || compact. contains ( "thinkingwith" )
3517+ || compact. contains ( "thoughtfor" )
3518+ || compact. contains ( "runningstophook" )
3519+ }
3520+
34313521fn tty_plan_approval_prompt_has_feedback_choice ( output : & str ) -> bool {
34323522 let output = plain_tty_output ( output) ;
34333523 let compact = compact_tty_output ( & output) ;
@@ -3507,6 +3597,26 @@ fn synthetic_result(state: &TranscriptState, duration: Duration) -> Value {
35073597 } )
35083598}
35093599
3600+ fn synthetic_prompt_ready_result ( state : & TranscriptState , duration : Duration ) -> Value {
3601+ json ! ( {
3602+ "type" : "result" ,
3603+ "subtype" : "success" ,
3604+ "duration_ms" : duration. as_millis( ) as i64 ,
3605+ "duration_api_ms" : 0 ,
3606+ "is_error" : false ,
3607+ "num_turns" : 1 ,
3608+ "session_id" : state. session_id. clone( ) . unwrap_or_default( ) ,
3609+ "result" : "Claude returned to the terminal prompt without writing a transcript result." ,
3610+ "stop_reason" : "end_turn" ,
3611+ "usage" : zero_usage( ) ,
3612+ "total_cost_usd" : 0.0 ,
3613+ "modelUsage" : { } ,
3614+ "permission_denials" : [ ] ,
3615+ "terminal_reason" : "prompt_ready_without_transcript" ,
3616+ "fast_mode_state" : "off" ,
3617+ } )
3618+ }
3619+
35103620fn synthetic_permission_denied_result ( state : & TranscriptState , duration : Duration ) -> Value {
35113621 json ! ( {
35123622 "type" : "result" ,
0 commit comments