juno-code 1.0.50 → 1.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,11 +10,12 @@ import os
10
10
  import re
11
11
  import subprocess
12
12
  import sys
13
+ import tempfile
13
14
  import threading
14
15
  import time
15
16
  from datetime import datetime
16
17
  from pathlib import Path
17
- from typing import Dict, List, Optional, Set, Tuple
18
+ from typing import Dict, List, Optional, Set, TextIO, Tuple
18
19
 
19
20
 
20
21
  class PiService:
@@ -38,6 +39,8 @@ class PiService:
38
39
  ":o3": "openai/o3",
39
40
  ":codex": "openai-codex/gpt-5.3-codex",
40
41
  ":api-codex": "openai/gpt-5.3-codex",
42
+ ":codex-spark": "openai-codex/gpt-5.3-codex-spark",
43
+ ":api-codex-spark": "openai/gpt-5.3-codex-spark",
41
44
  # Google
42
45
  ":gemini-pro": "google/gemini-2.5-pro",
43
46
  ":gemini-flash": "google/gemini-2.5-flash",
@@ -284,6 +287,8 @@ Model shorthands:
284
287
  :o3 -> openai/o3
285
288
  :codex -> openai-codex/gpt-5.3-codex
286
289
  :api-codex -> openai/gpt-5.3-codex
290
+ :codex-spark -> openai-codex/gpt-5.3-codex-spark
291
+ :api-codex-spark -> openai/gpt-5.3-codex-spark
287
292
  :gemini-pro -> google/gemini-2.5-pro
288
293
  :gemini-flash -> google/gemini-2.5-flash
289
294
  :groq -> groq/llama-4-scout-17b-16e-instruct
@@ -395,6 +400,20 @@ Model shorthands:
395
400
  help="Space-separated additional pi CLI arguments to append.",
396
401
  )
397
402
 
403
+ parser.add_argument(
404
+ "--live",
405
+ action="store_true",
406
+ default=os.environ.get("PI_LIVE", "false").lower() == "true",
407
+ help="Run Pi in interactive/live mode (no --mode json). Uses an auto-exit extension to capture agent_end and shutdown cleanly. (env: PI_LIVE)",
408
+ )
409
+
410
+ parser.add_argument(
411
+ "--live-manual",
412
+ action="store_true",
413
+ default=False,
414
+ help="Internal: live session start without an initial prompt (used by continue flows).",
415
+ )
416
+
398
417
  parser.add_argument(
399
418
  "--pretty",
400
419
  type=str,
@@ -423,15 +442,21 @@ Model shorthands:
423
442
  print(f"Error reading prompt file: {e}", file=sys.stderr)
424
443
  sys.exit(1)
425
444
 
426
- def build_pi_command(self, args: argparse.Namespace) -> Tuple[List[str], Optional[str]]:
427
- """Construct the Pi CLI command for headless JSON streaming execution.
445
+ def build_pi_command(
446
+ self,
447
+ args: argparse.Namespace,
448
+ live_extension_path: Optional[str] = None,
449
+ ) -> Tuple[List[str], Optional[str]]:
450
+ """Construct the Pi CLI command.
428
451
 
429
- Returns (cmd, stdin_prompt): cmd is the argument list, stdin_prompt is
430
- the prompt text to pipe via stdin (or None to pass as positional arg).
431
- For multiline or large prompts we pipe via stdin so Pi reads it
432
- naturally without command-line quoting issues.
452
+ Non-live mode keeps the existing headless JSON contract.
453
+ Live mode switches to Pi interactive defaults (no --mode json, no -p)
454
+ and passes the initial prompt positionally.
433
455
  """
434
- cmd = ["pi", "--mode", "json"]
456
+ is_live_mode = bool(getattr(args, "live", False))
457
+ cmd = ["pi"]
458
+ if not is_live_mode:
459
+ cmd.extend(["--mode", "json"])
435
460
 
436
461
  # Model: if provider/model format, split and pass separately
437
462
  model = self.model_name
@@ -476,16 +501,44 @@ Model shorthands:
476
501
  elif args.no_session:
477
502
  cmd.append("--no-session")
478
503
 
504
+ # Attach live auto-exit extension when requested.
505
+ if is_live_mode and live_extension_path:
506
+ cmd.extend(["-e", live_extension_path])
507
+
479
508
  # Build prompt with optional auto-instruction
480
509
  full_prompt = self.prompt
510
+ live_manual = bool(getattr(args, "live_manual", False))
511
+
481
512
  if args.auto_instruction:
482
- full_prompt = f"{args.auto_instruction}\n\n{full_prompt}"
513
+ if full_prompt:
514
+ full_prompt = f"{args.auto_instruction}\n\n{full_prompt}"
515
+ elif not (is_live_mode and live_manual):
516
+ full_prompt = args.auto_instruction
517
+
518
+ stdin_prompt: Optional[str] = None
519
+
520
+ if is_live_mode:
521
+ # Live mode uses positional prompt input (no -p and no stdin piping).
522
+ # For manual continue sessions we intentionally omit the prompt so Pi
523
+ # opens directly into interactive TUI input.
524
+ if full_prompt:
525
+ cmd.append(full_prompt)
526
+
527
+ # Additional raw arguments should still be honored; place before the
528
+ # positional prompt so flags remain flags.
529
+ if args.additional_args:
530
+ extra = args.additional_args.strip().split()
531
+ if extra:
532
+ if full_prompt:
533
+ cmd = cmd[:-1] + extra + [cmd[-1]]
534
+ else:
535
+ cmd.extend(extra)
536
+ return cmd, None
483
537
 
484
538
  # For multiline or large prompts, pipe via stdin to avoid command-line
485
539
  # argument issues. Pi CLI reads stdin when isTTY is false and
486
540
  # automatically prepends it to messages in print mode.
487
541
  # For simple single-line prompts, pass as positional arg + -p flag.
488
- stdin_prompt: Optional[str] = None
489
542
  if "\n" in full_prompt or len(full_prompt) > 4096:
490
543
  # Pipe via stdin — Pi auto-enables print mode when stdin has data
491
544
  stdin_prompt = full_prompt
@@ -2210,6 +2263,287 @@ Model shorthands:
2210
2263
 
2211
2264
  return None
2212
2265
 
2266
+ @staticmethod
2267
+ def _extract_session_id_from_event(event: Optional[dict]) -> Optional[str]:
2268
+ """Extract session id from common Pi/Codex payload shapes."""
2269
+ if not isinstance(event, dict):
2270
+ return None
2271
+
2272
+ candidates: List[object] = [
2273
+ event.get("session_id"),
2274
+ event.get("sessionId"),
2275
+ ]
2276
+
2277
+ if event.get("type") == "session":
2278
+ candidates.append(event.get("id"))
2279
+
2280
+ message = event.get("message")
2281
+ if isinstance(message, dict):
2282
+ candidates.extend(
2283
+ [
2284
+ message.get("session_id"),
2285
+ message.get("sessionId"),
2286
+ ]
2287
+ )
2288
+
2289
+ nested = event.get("sub_agent_response")
2290
+ if isinstance(nested, dict):
2291
+ candidates.extend(
2292
+ [
2293
+ nested.get("session_id"),
2294
+ nested.get("sessionId"),
2295
+ ]
2296
+ )
2297
+ if nested.get("type") == "session":
2298
+ candidates.append(nested.get("id"))
2299
+
2300
+ for candidate in candidates:
2301
+ if isinstance(candidate, str) and candidate.strip():
2302
+ return candidate.strip()
2303
+
2304
+ return None
2305
+
2306
+ @staticmethod
2307
+ def _is_error_result_event(event: Optional[dict]) -> bool:
2308
+ """Return True when event represents a terminal error payload."""
2309
+ if not isinstance(event, dict):
2310
+ return False
2311
+
2312
+ if event.get("is_error") is True:
2313
+ return True
2314
+
2315
+ subtype = event.get("subtype")
2316
+ if isinstance(subtype, str) and subtype.lower() == "error":
2317
+ return True
2318
+
2319
+ event_type = event.get("type")
2320
+ if isinstance(event_type, str) and event_type.lower() in {"error", "turn.failed", "turn_failed"}:
2321
+ return True
2322
+
2323
+ return False
2324
+
2325
+ @staticmethod
2326
+ def _is_success_result_event(event: Optional[dict]) -> bool:
2327
+ """Return True when event is an explicit successful result envelope."""
2328
+ if not isinstance(event, dict):
2329
+ return False
2330
+
2331
+ if PiService._is_error_result_event(event):
2332
+ return False
2333
+
2334
+ subtype = event.get("subtype")
2335
+ if isinstance(subtype, str) and subtype.lower() == "success":
2336
+ return True
2337
+
2338
+ event_type = event.get("type")
2339
+ if isinstance(event_type, str) and event_type.lower() == "result" and event.get("is_error") is False:
2340
+ result_value = event.get("result")
2341
+ if isinstance(result_value, str):
2342
+ return bool(result_value.strip())
2343
+ if result_value not in (None, "", [], {}):
2344
+ return True
2345
+
2346
+ return False
2347
+
2348
+ @staticmethod
2349
+ def _is_assistant_text_success_result_event(event: Optional[dict]) -> bool:
2350
+ """Return True when success came from assistant text-only stream events."""
2351
+ if not PiService._is_success_result_event(event):
2352
+ return False
2353
+
2354
+ if not isinstance(event, dict):
2355
+ return False
2356
+
2357
+ response_type = event.get("sub_agent_response_type")
2358
+ if isinstance(response_type, str):
2359
+ return response_type.lower() in {"message", "turn_end", "agent_end"}
2360
+
2361
+ sub_agent_response = event.get("sub_agent_response")
2362
+ if not isinstance(sub_agent_response, dict):
2363
+ return False
2364
+
2365
+ response_type = sub_agent_response.get("type")
2366
+ if not isinstance(response_type, str):
2367
+ return False
2368
+
2369
+ return response_type.lower() in {"message", "turn_end", "agent_end"}
2370
+
2371
+ @staticmethod
2372
+ def _should_promote_stderr_error(last_result_event: Optional[dict]) -> bool:
2373
+ """Return True when stderr errors should override the current result event."""
2374
+ if not PiService._is_success_result_event(last_result_event):
2375
+ return True
2376
+
2377
+ # Assistant-text-derived success payloads are not authoritative when
2378
+ # provider stderr already surfaced a terminal Codex/Pi error.
2379
+ return PiService._is_assistant_text_success_result_event(last_result_event)
2380
+
2381
+ @staticmethod
2382
+ def _normalize_error_text(raw_text: str) -> str:
2383
+ """Normalize stderr/plaintext by stripping ANSI and carriage controls."""
2384
+ if not isinstance(raw_text, str):
2385
+ return ""
2386
+
2387
+ text = PiService._ANSI_ESCAPE_RE.sub("", raw_text)
2388
+ text = text.replace("\x08", "")
2389
+ text = text.replace("\r", "\n")
2390
+ return text
2391
+
2392
+ @staticmethod
2393
+ def _extract_error_message_from_stderr_output(stderr_output: str) -> Optional[str]:
2394
+ """Extract terminal provider errors from full stderr output blocks."""
2395
+ if not isinstance(stderr_output, str):
2396
+ return None
2397
+
2398
+ text = PiService._normalize_error_text(stderr_output).strip()
2399
+ if not text:
2400
+ return None
2401
+
2402
+ extracted = PiService._extract_error_message_from_text(text)
2403
+ if extracted:
2404
+ return extracted
2405
+
2406
+ for line in text.splitlines():
2407
+ extracted_line = PiService._extract_error_message_from_text(line)
2408
+ if extracted_line:
2409
+ return extracted_line
2410
+
2411
+ normalized = " ".join(text.split())
2412
+ lowered = normalized.lower()
2413
+ if "error: codex error" in lowered or "codex error" in lowered or "server_error" in lowered:
2414
+ return normalized
2415
+
2416
+ return None
2417
+
2418
+ @staticmethod
2419
+ def _extract_error_message_from_event(event: dict) -> Optional[str]:
2420
+ """Extract a human-readable message from Pi/Codex error event shapes."""
2421
+ if not isinstance(event, dict):
2422
+ return None
2423
+
2424
+ event_type = event.get("type")
2425
+ if event_type == "auto_retry_end" and event.get("success") is False:
2426
+ final_error = event.get("finalError")
2427
+ if isinstance(final_error, str) and final_error.strip():
2428
+ return final_error.strip()
2429
+ return "Auto-retry failed after maximum attempts"
2430
+
2431
+ if not PiService._is_error_result_event(event):
2432
+ return None
2433
+
2434
+ def _stringify_error(value: object) -> Optional[str]:
2435
+ if isinstance(value, str):
2436
+ text = value.strip()
2437
+ return text if text else None
2438
+ if isinstance(value, dict):
2439
+ nested_message = value.get("message")
2440
+ if isinstance(nested_message, str) and nested_message.strip():
2441
+ return nested_message.strip()
2442
+ nested_error = value.get("error")
2443
+ if isinstance(nested_error, str) and nested_error.strip():
2444
+ return nested_error.strip()
2445
+ try:
2446
+ return json.dumps(value, ensure_ascii=False)
2447
+ except Exception:
2448
+ return str(value)
2449
+ if value is not None:
2450
+ return str(value)
2451
+ return None
2452
+
2453
+ for key in ("error", "message", "errorMessage", "result"):
2454
+ extracted = _stringify_error(event.get(key))
2455
+ if extracted:
2456
+ return extracted
2457
+
2458
+ return "Unknown Pi error"
2459
+
2460
+ @staticmethod
2461
+ def _extract_error_message_from_text(raw_text: str) -> Optional[str]:
2462
+ """Extract an error message from stderr/plaintext lines."""
2463
+ if not isinstance(raw_text, str):
2464
+ return None
2465
+
2466
+ text = PiService._normalize_error_text(raw_text).strip()
2467
+ if not text:
2468
+ return None
2469
+
2470
+ # Direct JSON line
2471
+ try:
2472
+ parsed = json.loads(text)
2473
+ extracted = PiService._extract_error_message_from_event(parsed)
2474
+ if extracted:
2475
+ return extracted
2476
+ except Exception:
2477
+ pass
2478
+
2479
+ # Prefix + JSON payload pattern (e.g. "Error: Codex error: {...}")
2480
+ json_start = text.find("{")
2481
+ if json_start > 0:
2482
+ json_candidate = text[json_start:]
2483
+ try:
2484
+ parsed = json.loads(json_candidate)
2485
+ extracted = PiService._extract_error_message_from_event(parsed)
2486
+ if extracted:
2487
+ return extracted
2488
+ except Exception:
2489
+ pass
2490
+
2491
+ lowered = text.lower()
2492
+
2493
+ # Sometimes progress spinners and carriage updates prefix the same line.
2494
+ # Detect embedded `Error: Codex error:` signatures even when not line-start.
2495
+ embedded_error_index = lowered.find("error:")
2496
+ if embedded_error_index >= 0:
2497
+ embedded_text = text[embedded_error_index:].strip()
2498
+ embedded_lowered = embedded_text.lower()
2499
+ if "codex error" in embedded_lowered or "server_error" in embedded_lowered:
2500
+ message = embedded_text.split(":", 1)[1].strip() if embedded_lowered.startswith("error:") else embedded_text
2501
+ return message or embedded_text
2502
+
2503
+ if lowered.startswith("error:"):
2504
+ message = text.split(":", 1)[1].strip()
2505
+ return message or text
2506
+
2507
+ if "server_error" in lowered or "codex error" in lowered:
2508
+ return text
2509
+
2510
+ return None
2511
+
2512
+ @staticmethod
2513
+ def _extract_provider_error_from_result_text(result_text: str) -> Optional[str]:
2514
+ """Detect provider-level failures that leaked into assistant result text."""
2515
+ if not isinstance(result_text, str):
2516
+ return None
2517
+
2518
+ text = result_text.strip()
2519
+ if not text:
2520
+ return None
2521
+
2522
+ normalized = " ".join(text.split())
2523
+ lowered = normalized.lower()
2524
+
2525
+ provider_signatures = (
2526
+ "chatgpt usage limit",
2527
+ "usage limit",
2528
+ "rate limit",
2529
+ "insufficient_quota",
2530
+ "too many requests",
2531
+ "codex error",
2532
+ "server_error",
2533
+ "please retry this request later",
2534
+ "occurred while processing your request",
2535
+ )
2536
+
2537
+ if lowered.startswith("error:"):
2538
+ payload = normalized.split(":", 1)[1].strip() if ":" in normalized else ""
2539
+ if any(signature in lowered for signature in provider_signatures) or "try again in" in lowered:
2540
+ return payload or normalized
2541
+
2542
+ if any(signature in lowered for signature in provider_signatures):
2543
+ return normalized
2544
+
2545
+ return None
2546
+
2213
2547
  def _build_success_result_event(self, text: str, event: dict) -> dict:
2214
2548
  """Build standardized success envelope for shell-backend capture."""
2215
2549
  usage = self._extract_usage_from_event(event)
@@ -2230,6 +2564,10 @@ Model shorthands:
2230
2564
  "sub_agent_response": self._sanitize_sub_agent_response(event),
2231
2565
  }
2232
2566
 
2567
+ event_type = event.get("type") if isinstance(event, dict) else None
2568
+ if isinstance(event_type, str) and event_type.strip():
2569
+ result_event["sub_agent_response_type"] = event_type
2570
+
2233
2571
  if isinstance(usage, dict):
2234
2572
  result_event["usage"] = usage
2235
2573
  if total_cost_usd is not None:
@@ -2237,18 +2575,466 @@ Model shorthands:
2237
2575
 
2238
2576
  return result_event
2239
2577
 
2578
+ def _build_error_result_event(self, error_message: str, event: Optional[dict] = None) -> dict:
2579
+ """Build standardized error envelope for shell-backend capture."""
2580
+ message = error_message.strip() if isinstance(error_message, str) else str(error_message)
2581
+
2582
+ result_event: Dict = {
2583
+ "type": "result",
2584
+ "subtype": "error",
2585
+ "is_error": True,
2586
+ "result": message,
2587
+ "error": message,
2588
+ "session_id": self.session_id,
2589
+ }
2590
+
2591
+ if isinstance(event, dict):
2592
+ result_event["sub_agent_response"] = self._sanitize_sub_agent_response(event)
2593
+
2594
+ return result_event
2595
+
2240
2596
  def _write_capture_file(self, capture_path: Optional[str]) -> None:
2241
2597
  """Write final result event to capture file for shell backend."""
2242
2598
  if not capture_path or not self.last_result_event:
2243
2599
  return
2600
+
2601
+ payload = dict(self.last_result_event)
2602
+
2603
+ if not payload.get("session_id"):
2604
+ existing_capture: Optional[dict] = None
2605
+ try:
2606
+ capture_file = Path(capture_path)
2607
+ if capture_file.exists():
2608
+ raw_existing = capture_file.read_text(encoding="utf-8").strip()
2609
+ if raw_existing:
2610
+ parsed_existing = json.loads(raw_existing)
2611
+ if isinstance(parsed_existing, dict):
2612
+ existing_capture = parsed_existing
2613
+ except Exception:
2614
+ existing_capture = None
2615
+
2616
+ existing_session_id: Optional[str] = None
2617
+ if isinstance(existing_capture, dict):
2618
+ candidate = existing_capture.get("session_id")
2619
+ if isinstance(candidate, str) and candidate.strip():
2620
+ existing_session_id = candidate.strip()
2621
+ elif isinstance(existing_capture.get("sub_agent_response"), dict):
2622
+ nested = existing_capture["sub_agent_response"].get("session_id")
2623
+ if isinstance(nested, str) and nested.strip():
2624
+ existing_session_id = nested.strip()
2625
+
2626
+ if existing_session_id:
2627
+ payload["session_id"] = existing_session_id
2628
+ if not self.session_id:
2629
+ self.session_id = existing_session_id
2630
+
2631
+ self.last_result_event = payload
2632
+
2244
2633
  try:
2245
2634
  Path(capture_path).write_text(
2246
- json.dumps(self.last_result_event, ensure_ascii=False),
2635
+ json.dumps(payload, ensure_ascii=False),
2247
2636
  encoding="utf-8",
2248
2637
  )
2249
2638
  except Exception as e:
2250
2639
  print(f"Warning: Could not write capture file: {e}", file=sys.stderr)
2251
2640
 
2641
+ @staticmethod
2642
+ def _read_capture_result_event(capture_path: Optional[str]) -> Optional[dict]:
2643
+ """Read current capture file payload if present."""
2644
+ if not capture_path:
2645
+ return None
2646
+
2647
+ try:
2648
+ capture_file = Path(capture_path)
2649
+ if not capture_file.exists():
2650
+ return None
2651
+ raw_payload = capture_file.read_text(encoding="utf-8").strip()
2652
+ if not raw_payload:
2653
+ return None
2654
+ parsed = json.loads(raw_payload)
2655
+ if isinstance(parsed, dict):
2656
+ return parsed
2657
+ except Exception:
2658
+ return None
2659
+
2660
+ return None
2661
+
2662
+ def _apply_capture_result_event(self, capture_path: Optional[str]) -> Tuple[Optional[dict], bool]:
2663
+ """Hydrate final state from capture payload and report stderr-promotion suppression."""
2664
+ capture_event = self._read_capture_result_event(capture_path)
2665
+ if not isinstance(capture_event, dict):
2666
+ return None, False
2667
+
2668
+ capture_session_id = capture_event.get("session_id")
2669
+ if isinstance(capture_session_id, str) and capture_session_id.strip() and not self.session_id:
2670
+ self.session_id = capture_session_id.strip()
2671
+
2672
+ if self.last_result_event is None:
2673
+ self.last_result_event = capture_event
2674
+ elif (
2675
+ isinstance(self.last_result_event, dict)
2676
+ and not self.last_result_event.get("session_id")
2677
+ and isinstance(capture_session_id, str)
2678
+ and capture_session_id.strip()
2679
+ ):
2680
+ self.last_result_event["session_id"] = capture_session_id.strip()
2681
+
2682
+ capture_provider_error: Optional[str] = None
2683
+ if self._is_success_result_event(capture_event):
2684
+ capture_result = capture_event.get("result")
2685
+ if isinstance(capture_result, str):
2686
+ capture_provider_error = self._extract_provider_error_from_result_text(capture_result)
2687
+ if capture_provider_error:
2688
+ self.last_result_event = self._build_error_result_event(capture_provider_error, capture_event)
2689
+
2690
+ suppress_stderr_promotion = self._is_success_result_event(capture_event) and not capture_provider_error
2691
+ return capture_event, suppress_stderr_promotion
2692
+
2693
+ def _build_live_auto_exit_extension_source(self, capture_path: Optional[str]) -> str:
2694
+ """Build a temporary Pi extension source used by --live mode.
2695
+
2696
+ The extension listens for agent_end, writes a compact result envelope to
2697
+ JUNO_SUBAGENT_CAPTURE_PATH-compatible location, then requests
2698
+ graceful shutdown via ctx.shutdown().
2699
+ """
2700
+ capture_literal = json.dumps(capture_path or "")
2701
+ source = """import type { ExtensionAPI } from \"@mariozechner/pi-coding-agent\";
2702
+ import * as fs from \"node:fs\";
2703
+
2704
+ const capturePath = __CAPTURE_PATH__;
2705
+ const defaultShutdownDelayMs = 3000;
2706
+ const parsedShutdownDelayMs = Number(process.env.PI_LIVE_AGENT_END_DELAY_MS ?? defaultShutdownDelayMs);
2707
+ const shutdownDelayMs =
2708
+ Number.isFinite(parsedShutdownDelayMs) && parsedShutdownDelayMs >= 0
2709
+ ? parsedShutdownDelayMs
2710
+ : defaultShutdownDelayMs;
2711
+
2712
+ function extractTextFromMessages(messages: any[]): string {
2713
+ for (let i = messages.length - 1; i >= 0; i--) {
2714
+ const msg = messages[i];
2715
+ if (!msg || msg.role !== \"assistant\") continue;
2716
+
2717
+ const content = msg.content;
2718
+ if (typeof content === \"string\") {
2719
+ if (content.trim()) return content;
2720
+ continue;
2721
+ }
2722
+
2723
+ if (Array.isArray(content)) {
2724
+ const parts: string[] = [];
2725
+ for (const item of content) {
2726
+ if (typeof item === \"string\" && item.trim()) {
2727
+ parts.push(item);
2728
+ continue;
2729
+ }
2730
+ if (item && item.type === \"text\" && typeof item.text === \"string\" && item.text.trim()) {
2731
+ parts.push(item.text);
2732
+ }
2733
+ }
2734
+ if (parts.length > 0) return parts.join(\"\\n\");
2735
+ }
2736
+ }
2737
+ return \"\";
2738
+ }
2739
+
2740
+ function isFiniteNumber(value: any): value is number {
2741
+ return typeof value === \"number\" && Number.isFinite(value);
2742
+ }
2743
+
2744
+ function normalizeUsage(usage: any): any | undefined {
2745
+ if (!usage || typeof usage !== \"object\") return undefined;
2746
+
2747
+ const cost = usage.cost && typeof usage.cost === \"object\" ? usage.cost : {};
2748
+
2749
+ const input = isFiniteNumber(usage.input) ? usage.input : 0;
2750
+ const output = isFiniteNumber(usage.output) ? usage.output : 0;
2751
+ const cacheRead = isFiniteNumber(usage.cacheRead) ? usage.cacheRead : 0;
2752
+ const cacheWrite = isFiniteNumber(usage.cacheWrite) ? usage.cacheWrite : 0;
2753
+ const totalTokens = isFiniteNumber(usage.totalTokens)
2754
+ ? usage.totalTokens
2755
+ : input + output + cacheRead + cacheWrite;
2756
+
2757
+ const costInput = isFiniteNumber(cost.input) ? cost.input : 0;
2758
+ const costOutput = isFiniteNumber(cost.output) ? cost.output : 0;
2759
+ const costCacheRead = isFiniteNumber(cost.cacheRead) ? cost.cacheRead : 0;
2760
+ const costCacheWrite = isFiniteNumber(cost.cacheWrite) ? cost.cacheWrite : 0;
2761
+ const costTotal = isFiniteNumber(cost.total)
2762
+ ? cost.total
2763
+ : costInput + costOutput + costCacheRead + costCacheWrite;
2764
+
2765
+ const hasAnyValue =
2766
+ isFiniteNumber(usage.input) ||
2767
+ isFiniteNumber(usage.output) ||
2768
+ isFiniteNumber(usage.cacheRead) ||
2769
+ isFiniteNumber(usage.cacheWrite) ||
2770
+ isFiniteNumber(usage.totalTokens) ||
2771
+ isFiniteNumber(cost.input) ||
2772
+ isFiniteNumber(cost.output) ||
2773
+ isFiniteNumber(cost.cacheRead) ||
2774
+ isFiniteNumber(cost.cacheWrite) ||
2775
+ isFiniteNumber(cost.total);
2776
+
2777
+ if (!hasAnyValue) return undefined;
2778
+
2779
+ return {
2780
+ input,
2781
+ output,
2782
+ cacheRead,
2783
+ cacheWrite,
2784
+ totalTokens,
2785
+ cost: {
2786
+ input: costInput,
2787
+ output: costOutput,
2788
+ cacheRead: costCacheRead,
2789
+ cacheWrite: costCacheWrite,
2790
+ total: costTotal,
2791
+ },
2792
+ };
2793
+ }
2794
+
2795
+ function mergeUsage(base: any | undefined, delta: any | undefined): any | undefined {
2796
+ if (!base) return delta;
2797
+ if (!delta) return base;
2798
+
2799
+ const baseCost = base.cost && typeof base.cost === \"object\" ? base.cost : {};
2800
+ const deltaCost = delta.cost && typeof delta.cost === \"object\" ? delta.cost : {};
2801
+
2802
+ return {
2803
+ input: (base.input ?? 0) + (delta.input ?? 0),
2804
+ output: (base.output ?? 0) + (delta.output ?? 0),
2805
+ cacheRead: (base.cacheRead ?? 0) + (delta.cacheRead ?? 0),
2806
+ cacheWrite: (base.cacheWrite ?? 0) + (delta.cacheWrite ?? 0),
2807
+ totalTokens: (base.totalTokens ?? 0) + (delta.totalTokens ?? 0),
2808
+ cost: {
2809
+ input: (baseCost.input ?? 0) + (deltaCost.input ?? 0),
2810
+ output: (baseCost.output ?? 0) + (deltaCost.output ?? 0),
2811
+ cacheRead: (baseCost.cacheRead ?? 0) + (deltaCost.cacheRead ?? 0),
2812
+ cacheWrite: (baseCost.cacheWrite ?? 0) + (deltaCost.cacheWrite ?? 0),
2813
+ total: (baseCost.total ?? 0) + (deltaCost.total ?? 0),
2814
+ },
2815
+ };
2816
+ }
2817
+
2818
+ function extractAssistantUsage(messages: any[]): any | undefined {
2819
+ let totals: any | undefined;
2820
+
2821
+ for (const msg of messages) {
2822
+ if (!msg || msg.role !== \"assistant\") {
2823
+ continue;
2824
+ }
2825
+
2826
+ const normalized = normalizeUsage(msg.usage);
2827
+ if (!normalized) {
2828
+ continue;
2829
+ }
2830
+
2831
+ totals = mergeUsage(totals, normalized);
2832
+ }
2833
+
2834
+ return totals;
2835
+ }
2836
+
2837
+ function extractLatestAssistantStopReason(messages: any[]): string | undefined {
2838
+ for (let i = messages.length - 1; i >= 0; i--) {
2839
+ const msg = messages[i];
2840
+ if (!msg || msg.role !== \"assistant\") {
2841
+ continue;
2842
+ }
2843
+
2844
+ const reason = msg.stopReason;
2845
+ return typeof reason === \"string\" && reason ? reason : undefined;
2846
+ }
2847
+
2848
+ return undefined;
2849
+ }
2850
+
2851
+ function writeCapturePayload(payload: any): void {
2852
+ if (!capturePath) {
2853
+ return;
2854
+ }
2855
+
2856
+ fs.writeFileSync(capturePath, JSON.stringify(payload), \"utf-8\");
2857
+ }
2858
+
2859
+ function persistSessionSnapshot(sessionId: unknown): void {
2860
+ if (typeof sessionId !== \"string\" || !sessionId) {
2861
+ return;
2862
+ }
2863
+
2864
+ try {
2865
+ writeCapturePayload({
2866
+ type: \"result\",
2867
+ subtype: \"session\",
2868
+ is_error: false,
2869
+ session_id: sessionId,
2870
+ });
2871
+ } catch {
2872
+ // Non-fatal: runtime capture should continue even if snapshot write fails.
2873
+ }
2874
+ }
2875
+
2876
+ export default function (pi: ExtensionAPI) {
2877
+ let completed = false;
2878
+ let latestSessionId: string | undefined;
2879
+ let pendingShutdownTimer: ReturnType<typeof setTimeout> | undefined;
2880
+
2881
+ // When agent_end fires with stopReason=error, Pi may be about to auto-retry
2882
+ // internally. The Pi extension API does NOT expose auto_retry_start/end events
2883
+ // (those go through session.subscribe(), not the extension runner). We therefore
2884
+ // use a generous delay before treating an error agent_end as final, which gives
2885
+ // Pi time to complete its retry and emit a subsequent non-error agent_end. If a
2886
+ // successful agent_end arrives it cancels this timer via clearPendingShutdown().
2887
+ const defaultErrorAgentEndDelayMs = 30000;
2888
+ const parsedErrorDelayMs = Number(process.env.PI_LIVE_ERROR_AGENT_END_DELAY_MS ?? defaultErrorAgentEndDelayMs);
2889
+ const errorAgentEndDelayMs =
2890
+ Number.isFinite(parsedErrorDelayMs) && parsedErrorDelayMs >= 0
2891
+ ? parsedErrorDelayMs
2892
+ : defaultErrorAgentEndDelayMs;
2893
+
2894
+ function clearPendingShutdown(): void {
2895
+ if (pendingShutdownTimer) {
2896
+ clearTimeout(pendingShutdownTimer);
2897
+ pendingShutdownTimer = undefined;
2898
+ }
2899
+ }
2900
+
2901
+ async function finalizeAndShutdown(event: any, ctx: any): Promise<void> {
2902
+ try {
2903
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
2904
+ const usage = extractAssistantUsage(messages);
2905
+ const totalCost = typeof usage?.cost?.total === \"number\" ? usage.cost.total : undefined;
2906
+ const stopReason = extractLatestAssistantStopReason(messages);
2907
+ const managerSessionId =
2908
+ typeof ctx?.sessionManager?.getSessionId === \"function\"
2909
+ ? ctx.sessionManager.getSessionId()
2910
+ : undefined;
2911
+ const sessionId =
2912
+ (typeof managerSessionId === \"string\" && managerSessionId ? managerSessionId : undefined) ||
2913
+ latestSessionId;
2914
+ const resultText = extractTextFromMessages(messages);
2915
+ const isError = stopReason === \"error\";
2916
+ const resolvedResult = isError
2917
+ ? resultText || \"Request error\"
2918
+ : resultText;
2919
+ const payload: any = {
2920
+ type: \"result\",
2921
+ subtype: isError ? \"error\" : \"success\",
2922
+ is_error: isError,
2923
+ result: resolvedResult,
2924
+ usage,
2925
+ total_cost_usd: totalCost,
2926
+ sub_agent_response: event,
2927
+ };
2928
+
2929
+ if (isError) {
2930
+ payload.error = resolvedResult;
2931
+ }
2932
+
2933
+ if (typeof sessionId === \"string\" && sessionId) {
2934
+ payload.session_id = sessionId;
2935
+ }
2936
+
2937
+ writeCapturePayload(payload);
2938
+ } catch {
2939
+ // Keep shutdown behavior even when capture writing fails.
2940
+ } finally {
2941
+ await ctx.shutdown();
2942
+ }
2943
+ }
2944
+
2945
+ pi.on(\"session\", (event, ctx) => {
2946
+ const eventSessionId = typeof event?.id === \"string\" ? event.id : undefined;
2947
+ const managerSessionId =
2948
+ typeof ctx?.sessionManager?.getSessionId === \"function\"
2949
+ ? ctx.sessionManager.getSessionId()
2950
+ : undefined;
2951
+ const sessionId = managerSessionId || eventSessionId;
2952
+
2953
+ if (typeof sessionId === \"string\" && sessionId) {
2954
+ latestSessionId = sessionId;
2955
+ }
2956
+
2957
+ persistSessionSnapshot(sessionId);
2958
+ });
2959
+
2960
+ // Capture session_id as early as possible from session_start so it is available
2961
+ // as a fallback even when the agent_end handler fires during error/shutdown paths.
2962
+ pi.on(\"session_start\", (event, ctx) => {
2963
+ const managerSessionId =
2964
+ typeof ctx?.sessionManager?.getSessionId === \"function\"
2965
+ ? ctx.sessionManager.getSessionId()
2966
+ : undefined;
2967
+ if (typeof managerSessionId === \"string\" && managerSessionId) {
2968
+ latestSessionId = managerSessionId;
2969
+ }
2970
+ if (latestSessionId) {
2971
+ persistSessionSnapshot(latestSessionId);
2972
+ }
2973
+ });
2974
+
2975
+ // When Pi auto-retries a failed request, it calls agent.continue() internally which
2976
+ // emits a new agent_start event before the retry runs. By cancelling any pending
2977
+ // error-based shutdown timer here we ensure the retry has time to complete and emit
2978
+ // its own agent_end (success or error) before we finalize. This is the primary
2979
+ // mechanism for surviving multiple retries — the 30s error delay is only the last-
2980
+ // resort fallback when all retries are exhausted and no new agent_start arrives.
2981
+ pi.on(\"agent_start\", () => {
2982
+ clearPendingShutdown();
2983
+ });
2984
+
2985
+ pi.on(\"agent_end\", async (event, ctx) => {
2986
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
2987
+ const stopReason = extractLatestAssistantStopReason(messages);
2988
+
2989
+ // Esc-aborted runs should keep Pi open for user interaction.
2990
+ if (stopReason === \"aborted\") {
2991
+ return;
2992
+ }
2993
+
2994
+ if (completed) return;
2995
+
2996
+ // Cancel any previously scheduled shutdown (e.g. from a prior error agent_end).
2997
+ clearPendingShutdown();
2998
+
2999
+ // When stopReason is \"error\", Pi may internally auto-retry the request.
3000
+ // Pi fires agent_start before each retry attempt (via agent.continue() →
3001
+ // runAgentLoopContinue), so the agent_start handler above will cancel this
3002
+ // timer before it fires during an active retry. The long delay is therefore
3003
+ // only the fallback for when all retries are exhausted and no new agent_start
3004
+ // arrives within errorAgentEndDelayMs.
3005
+ const delay = stopReason === \"error\" ? errorAgentEndDelayMs : shutdownDelayMs;
3006
+
3007
+ pendingShutdownTimer = setTimeout(() => {
3008
+ pendingShutdownTimer = undefined;
3009
+ if (completed) {
3010
+ return;
3011
+ }
3012
+ completed = true;
3013
+ void finalizeAndShutdown(event, ctx);
3014
+ }, delay);
3015
+ });
3016
+ }
3017
+ """
3018
+ return source.replace("__CAPTURE_PATH__", capture_literal)
3019
+
3020
+ def _create_live_auto_exit_extension_file(self, capture_path: Optional[str]) -> Optional[Path]:
3021
+ """Create a temporary live-mode extension file and return its path."""
3022
+ try:
3023
+ fd, temp_path = tempfile.mkstemp(prefix="juno-pi-live-auto-exit-", suffix=".ts")
3024
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
3025
+ handle.write(self._build_live_auto_exit_extension_source(capture_path))
3026
+ return Path(temp_path)
3027
+ except Exception as exc:
3028
+ print(f"Warning: Failed to create live auto-exit extension: {exc}", file=sys.stderr)
3029
+ return None
3030
+
3031
+ def _open_live_tty_stdin(self) -> Optional[TextIO]:
3032
+ """Open /dev/tty for live-mode stdin fallback when stdin is redirected."""
3033
+ try:
3034
+ return open("/dev/tty", "r", encoding="utf-8", errors="ignore")
3035
+ except OSError:
3036
+ return None
3037
+
2252
3038
  def run_pi(self, cmd: List[str], args: argparse.Namespace,
2253
3039
  stdin_prompt: Optional[str] = None) -> int:
2254
3040
  """Execute the Pi CLI and stream/format its JSON output.
@@ -2262,10 +3048,26 @@ Model shorthands:
2262
3048
  verbose = args.verbose
2263
3049
  pretty = args.pretty.lower() != "false"
2264
3050
  capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
3051
+ if not os.environ.get("JUNO_TOOL_ID"):
3052
+ # Ignore inherited capture paths outside juno-code shell-backend execution.
3053
+ capture_path = None
3054
+ if capture_path:
3055
+ # Each invocation should start with a clean capture file. This avoids
3056
+ # stale inherited env values from previous runs poisoning status.
3057
+ try:
3058
+ Path(capture_path).unlink(missing_ok=True)
3059
+ except Exception:
3060
+ pass
2265
3061
  hide_types = self._build_hide_types()
2266
3062
  self._buffered_tool_stdout_lines.clear()
2267
3063
  self._reset_run_cost_tracking()
2268
3064
  cancel_delayed_toolcalls = lambda: None
3065
+ stderr_error_messages: List[str] = []
3066
+ stderr_output_lines: List[str] = []
3067
+
3068
+ resume_session = getattr(args, "resume", None)
3069
+ if isinstance(resume_session, str) and resume_session.strip():
3070
+ self.session_id = resume_session.strip()
2269
3071
 
2270
3072
  if verbose:
2271
3073
  # Truncate prompt in display to avoid confusing multi-line output
@@ -2297,7 +3099,90 @@ Model shorthands:
2297
3099
  print(f"Executing: {' '.join(display_cmd)}", file=sys.stderr)
2298
3100
  print("-" * 80, file=sys.stderr)
2299
3101
 
3102
+ process: Optional[subprocess.Popen] = None
3103
+ live_mode_requested = bool(getattr(args, "live", False))
3104
+ stdin_has_tty = (
3105
+ hasattr(sys.stdin, "isatty")
3106
+ and sys.stdin.isatty()
3107
+ )
3108
+ stdout_has_tty = (
3109
+ hasattr(sys.stdout, "isatty")
3110
+ and sys.stdout.isatty()
3111
+ )
3112
+ live_tty_stdin: Optional[TextIO] = None
3113
+ if live_mode_requested and stdout_has_tty and not stdin_has_tty:
3114
+ live_tty_stdin = self._open_live_tty_stdin()
3115
+
3116
+ is_live_tty_passthrough = (
3117
+ live_mode_requested
3118
+ and stdout_has_tty
3119
+ and (stdin_has_tty or live_tty_stdin is not None)
3120
+ )
3121
+
2300
3122
  try:
3123
+ if is_live_tty_passthrough:
3124
+ # Interactive live mode: attach Pi directly to the current terminal.
3125
+ # Keep stdout inherited for full-screen TUI rendering/input, but
3126
+ # capture stderr so terminal provider errors can still propagate.
3127
+ popen_kwargs = {
3128
+ "cwd": self.project_path,
3129
+ "stderr": subprocess.PIPE,
3130
+ "text": True,
3131
+ "universal_newlines": True,
3132
+ }
3133
+ if live_tty_stdin is not None:
3134
+ popen_kwargs["stdin"] = live_tty_stdin
3135
+
3136
+ try:
3137
+ process = subprocess.Popen(cmd, **popen_kwargs)
3138
+
3139
+ def _live_tty_stderr_reader():
3140
+ """Read stderr during live TTY mode and capture terminal failures."""
3141
+ try:
3142
+ if process.stderr:
3143
+ for stderr_line in process.stderr:
3144
+ stderr_output_lines.append(stderr_line)
3145
+ print(stderr_line, end="", file=sys.stderr, flush=True)
3146
+ extracted_error = self._extract_error_message_from_text(stderr_line)
3147
+ if extracted_error:
3148
+ stderr_error_messages.append(extracted_error)
3149
+ except (ValueError, OSError):
3150
+ pass
3151
+
3152
+ stderr_thread = threading.Thread(target=_live_tty_stderr_reader, daemon=True)
3153
+ stderr_thread.start()
3154
+
3155
+ process.wait()
3156
+ stderr_thread.join(timeout=3)
3157
+
3158
+ _, suppress_stderr_promotion = self._apply_capture_result_event(capture_path)
3159
+
3160
+ final_stderr_error_message = self._extract_error_message_from_stderr_output(
3161
+ "".join(stderr_output_lines)
3162
+ )
3163
+ if not final_stderr_error_message and stderr_error_messages:
3164
+ final_stderr_error_message = stderr_error_messages[-1]
3165
+ if (
3166
+ final_stderr_error_message
3167
+ and not suppress_stderr_promotion
3168
+ and self._should_promote_stderr_error(self.last_result_event)
3169
+ ):
3170
+ self.last_result_event = self._build_error_result_event(final_stderr_error_message)
3171
+
3172
+ self._write_capture_file(capture_path)
3173
+
3174
+ final_return_code = process.returncode or 0
3175
+ if final_return_code == 0 and self._is_error_result_event(self.last_result_event):
3176
+ final_return_code = 1
3177
+
3178
+ return final_return_code
3179
+ finally:
3180
+ if live_tty_stdin is not None:
3181
+ try:
3182
+ live_tty_stdin.close()
3183
+ except OSError:
3184
+ pass
3185
+
2301
3186
  process = subprocess.Popen(
2302
3187
  cmd,
2303
3188
  stdin=subprocess.PIPE if stdin_prompt else subprocess.DEVNULL,
@@ -2360,11 +3245,15 @@ Model shorthands:
2360
3245
 
2361
3246
  # Stream stderr in a separate thread so Pi diagnostic output is visible
2362
3247
  def _stderr_reader():
2363
- """Read stderr and forward to our stderr for visibility."""
3248
+ """Read stderr, forward to stderr, and capture terminal error signals."""
2364
3249
  try:
2365
3250
  if process.stderr:
2366
3251
  for stderr_line in process.stderr:
3252
+ stderr_output_lines.append(stderr_line)
2367
3253
  print(stderr_line, end="", file=sys.stderr, flush=True)
3254
+ extracted_error = self._extract_error_message_from_text(stderr_line)
3255
+ if extracted_error:
3256
+ stderr_error_messages.append(extracted_error)
2368
3257
  except (ValueError, OSError):
2369
3258
  pass
2370
3259
 
@@ -2484,9 +3373,22 @@ Model shorthands:
2484
3373
  def _emit_parsed_event(parsed_event: dict, raw_json_line: Optional[str] = None) -> None:
2485
3374
  event_type = parsed_event.get("type", "")
2486
3375
 
2487
- # Capture session ID from the session event (sent at stream start)
2488
- if event_type == "session":
2489
- self.session_id = parsed_event.get("id")
3376
+ # Capture session ID as early as possible from any event shape.
3377
+ event_session_id = self._extract_session_id_from_event(parsed_event)
3378
+ if event_session_id:
3379
+ self.session_id = event_session_id
3380
+ if (
3381
+ isinstance(self.last_result_event, dict)
3382
+ and not self.last_result_event.get("session_id")
3383
+ and isinstance(self.session_id, str)
3384
+ and self.session_id.strip()
3385
+ ):
3386
+ self.last_result_event["session_id"] = self.session_id
3387
+
3388
+ # Capture terminal error events even when upstream exits with code 0.
3389
+ error_message = self._extract_error_message_from_event(parsed_event)
3390
+ if error_message:
3391
+ self.last_result_event = self._build_error_result_event(error_message, parsed_event)
2490
3392
 
2491
3393
  # Track per-run assistant usage from stream events.
2492
3394
  self._track_assistant_usage_from_event(parsed_event)
@@ -2504,16 +3406,38 @@ Model shorthands:
2504
3406
  # agent_end has a 'messages' array; extract final assistant text
2505
3407
  messages = parsed_event.get("messages", [])
2506
3408
  text = ""
3409
+ assistant_stop_reason = ""
3410
+ assistant_error_message = ""
2507
3411
  if isinstance(messages, list):
2508
- # Walk messages in reverse to find last assistant message with text
3412
+ # Use the latest assistant message as source of truth.
2509
3413
  for m in reversed(messages):
2510
3414
  if isinstance(m, dict) and m.get("role") == "assistant":
3415
+ stop_reason = m.get("stopReason")
3416
+ if isinstance(stop_reason, str):
3417
+ assistant_stop_reason = stop_reason.strip()
3418
+ error_message = m.get("errorMessage")
3419
+ if isinstance(error_message, str):
3420
+ assistant_error_message = error_message.strip()
2511
3421
  text = self._extract_text_from_message(m)
2512
- if text:
2513
- break
2514
- if text:
2515
- self.last_result_event = self._build_success_result_event(text, parsed_event)
2516
- else:
3422
+ break
3423
+
3424
+ if assistant_stop_reason in {"error", "aborted"}:
3425
+ terminal_error = (
3426
+ assistant_error_message
3427
+ or text
3428
+ or f"Request {assistant_stop_reason}"
3429
+ )
3430
+ self.last_result_event = self._build_error_result_event(
3431
+ terminal_error,
3432
+ parsed_event,
3433
+ )
3434
+ elif text:
3435
+ provider_error = self._extract_provider_error_from_result_text(text)
3436
+ if provider_error:
3437
+ self.last_result_event = self._build_error_result_event(provider_error, parsed_event)
3438
+ else:
3439
+ self.last_result_event = self._build_success_result_event(text, parsed_event)
3440
+ elif not self._is_error_result_event(self.last_result_event):
2517
3441
  self.last_result_event = parsed_event
2518
3442
  elif event_type == "message":
2519
3443
  # OpenAI-compatible format: capture last assistant message
@@ -2521,14 +3445,22 @@ Model shorthands:
2521
3445
  if isinstance(msg, dict) and msg.get("role") == "assistant":
2522
3446
  text = self._extract_text_from_message(msg)
2523
3447
  if text:
2524
- self.last_result_event = self._build_success_result_event(text, parsed_event)
3448
+ provider_error = self._extract_provider_error_from_result_text(text)
3449
+ if provider_error:
3450
+ self.last_result_event = self._build_error_result_event(provider_error, parsed_event)
3451
+ else:
3452
+ self.last_result_event = self._build_success_result_event(text, parsed_event)
2525
3453
  elif event_type == "turn_end":
2526
3454
  # turn_end may contain the final assistant message
2527
3455
  msg = parsed_event.get("message", {})
2528
3456
  if isinstance(msg, dict):
2529
3457
  text = self._extract_text_from_message(msg)
2530
3458
  if text:
2531
- self.last_result_event = self._build_success_result_event(text, parsed_event)
3459
+ provider_error = self._extract_provider_error_from_result_text(text)
3460
+ if provider_error:
3461
+ self.last_result_event = self._build_error_result_event(provider_error, parsed_event)
3462
+ else:
3463
+ self.last_result_event = self._build_success_result_event(text, parsed_event)
2532
3464
 
2533
3465
  # Filter hidden stream types (live mode handles its own filtering)
2534
3466
  if event_type in hide_types and self.prettifier_mode != self.PRETTIFIER_LIVE:
@@ -2699,30 +3631,51 @@ Model shorthands:
2699
3631
  output_done.set()
2700
3632
  cancel_delayed_toolcalls()
2701
3633
 
2702
- # Write capture file for shell backend
2703
- self._write_capture_file(capture_path)
2704
-
2705
3634
  # Wait for process cleanup
2706
3635
  try:
2707
3636
  process.wait(timeout=5)
2708
3637
  except subprocess.TimeoutExpired:
2709
3638
  pass
2710
3639
 
2711
- # Wait for stderr thread to finish
3640
+ # Wait for stderr thread to finish before deriving fallback errors.
2712
3641
  stderr_thread.join(timeout=3)
2713
3642
 
2714
- return process.returncode or 0
3643
+ _, suppress_stderr_promotion = self._apply_capture_result_event(capture_path)
3644
+
3645
+ # If stderr surfaced a terminal error, persist the failure unless a
3646
+ # non-assistant-text success envelope is already authoritative.
3647
+ final_stderr_error_message = self._extract_error_message_from_stderr_output(
3648
+ "".join(stderr_output_lines)
3649
+ )
3650
+ if not final_stderr_error_message and stderr_error_messages:
3651
+ final_stderr_error_message = stderr_error_messages[-1]
3652
+ if (
3653
+ final_stderr_error_message
3654
+ and not suppress_stderr_promotion
3655
+ and self._should_promote_stderr_error(self.last_result_event)
3656
+ ):
3657
+ self.last_result_event = self._build_error_result_event(final_stderr_error_message)
3658
+
3659
+ # Write capture file for shell backend
3660
+ self._write_capture_file(capture_path)
3661
+
3662
+ final_return_code = process.returncode or 0
3663
+ if final_return_code == 0 and self._is_error_result_event(self.last_result_event):
3664
+ final_return_code = 1
3665
+
3666
+ return final_return_code
2715
3667
 
2716
3668
  except KeyboardInterrupt:
2717
3669
  print("\nInterrupted by user", file=sys.stderr)
2718
3670
  cancel_delayed_toolcalls()
2719
3671
  try:
2720
- process.terminate()
2721
- try:
2722
- process.wait(timeout=5)
2723
- except subprocess.TimeoutExpired:
2724
- process.kill()
2725
- process.wait(timeout=5)
3672
+ if process is not None:
3673
+ process.terminate()
3674
+ try:
3675
+ process.wait(timeout=5)
3676
+ except subprocess.TimeoutExpired:
3677
+ process.kill()
3678
+ process.wait(timeout=5)
2726
3679
  except Exception:
2727
3680
  pass
2728
3681
  self._write_capture_file(capture_path)
@@ -2732,7 +3685,7 @@ Model shorthands:
2732
3685
  print(f"Error executing pi: {e}", file=sys.stderr)
2733
3686
  cancel_delayed_toolcalls()
2734
3687
  try:
2735
- if process.poll() is None:
3688
+ if process is not None and process.poll() is None:
2736
3689
  process.terminate()
2737
3690
  process.wait(timeout=5)
2738
3691
  except Exception:
@@ -2746,7 +3699,23 @@ Model shorthands:
2746
3699
 
2747
3700
  # Prompt handling
2748
3701
  prompt_value = args.prompt or os.environ.get("JUNO_INSTRUCTION")
2749
- if not prompt_value and not args.prompt_file:
3702
+ resume_session = args.resume if isinstance(args.resume, str) and args.resume.strip() else None
3703
+ live_manual_session = bool(args.live and args.live_manual)
3704
+
3705
+ if (
3706
+ args.live
3707
+ and resume_session
3708
+ and not prompt_value
3709
+ and not args.prompt_file
3710
+ ):
3711
+ # Defensive fallback for direct wrapper usage: allow promptless live resume
3712
+ # even if --live-manual was not passed explicitly.
3713
+ live_manual_session = True
3714
+
3715
+ if live_manual_session:
3716
+ args.live_manual = True
3717
+
3718
+ if not prompt_value and not args.prompt_file and not live_manual_session:
2750
3719
  print("Error: Either -p/--prompt or -pp/--prompt-file is required.", file=sys.stderr)
2751
3720
  print("\nRun 'pi.py --help' for usage information.", file=sys.stderr)
2752
3721
  return 1
@@ -2780,11 +3749,37 @@ Model shorthands:
2780
3749
 
2781
3750
  if args.prompt_file:
2782
3751
  self.prompt = self.read_prompt_file(args.prompt_file)
2783
- else:
3752
+ elif prompt_value:
2784
3753
  self.prompt = prompt_value
3754
+ else:
3755
+ self.prompt = ""
3756
+
3757
+ if args.live and args.no_extensions and not live_manual_session:
3758
+ print("Error: --live requires extensions enabled (remove --no-extensions).", file=sys.stderr)
3759
+ return 1
3760
+
3761
+ live_extension_file: Optional[Path] = None
3762
+ if args.live and not live_manual_session:
3763
+ capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
3764
+ if not os.environ.get("JUNO_TOOL_ID"):
3765
+ capture_path = None
3766
+ live_extension_file = self._create_live_auto_exit_extension_file(capture_path)
3767
+ if not live_extension_file:
3768
+ print("Error: Could not create live auto-exit extension.", file=sys.stderr)
3769
+ return 1
2785
3770
 
2786
- cmd, stdin_prompt = self.build_pi_command(args)
2787
- return self.run_pi(cmd, args, stdin_prompt=stdin_prompt)
3771
+ try:
3772
+ cmd, stdin_prompt = self.build_pi_command(
3773
+ args,
3774
+ live_extension_path=str(live_extension_file) if live_extension_file else None,
3775
+ )
3776
+ return self.run_pi(cmd, args, stdin_prompt=stdin_prompt)
3777
+ finally:
3778
+ if live_extension_file is not None:
3779
+ try:
3780
+ live_extension_file.unlink(missing_ok=True)
3781
+ except Exception as e:
3782
+ print(f"Warning: Failed to remove temp live extension: {e}", file=sys.stderr)
2788
3783
 
2789
3784
 
2790
3785
  def main():