juno-code 1.0.50 → 1.0.51

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,13 @@ 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
+
398
410
  parser.add_argument(
399
411
  "--pretty",
400
412
  type=str,
@@ -423,15 +435,21 @@ Model shorthands:
423
435
  print(f"Error reading prompt file: {e}", file=sys.stderr)
424
436
  sys.exit(1)
425
437
 
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.
438
+ def build_pi_command(
439
+ self,
440
+ args: argparse.Namespace,
441
+ live_extension_path: Optional[str] = None,
442
+ ) -> Tuple[List[str], Optional[str]]:
443
+ """Construct the Pi CLI command.
428
444
 
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.
445
+ Non-live mode keeps the existing headless JSON contract.
446
+ Live mode switches to Pi interactive defaults (no --mode json, no -p)
447
+ and passes the initial prompt positionally.
433
448
  """
434
- cmd = ["pi", "--mode", "json"]
449
+ is_live_mode = bool(getattr(args, "live", False))
450
+ cmd = ["pi"]
451
+ if not is_live_mode:
452
+ cmd.extend(["--mode", "json"])
435
453
 
436
454
  # Model: if provider/model format, split and pass separately
437
455
  model = self.model_name
@@ -476,16 +494,33 @@ Model shorthands:
476
494
  elif args.no_session:
477
495
  cmd.append("--no-session")
478
496
 
497
+ # Attach live auto-exit extension when requested.
498
+ if is_live_mode and live_extension_path:
499
+ cmd.extend(["-e", live_extension_path])
500
+
479
501
  # Build prompt with optional auto-instruction
480
502
  full_prompt = self.prompt
481
503
  if args.auto_instruction:
482
504
  full_prompt = f"{args.auto_instruction}\n\n{full_prompt}"
483
505
 
506
+ stdin_prompt: Optional[str] = None
507
+
508
+ if is_live_mode:
509
+ # Live mode uses positional prompt input (no -p and no stdin piping).
510
+ cmd.append(full_prompt)
511
+
512
+ # Additional raw arguments should still be honored; place before the
513
+ # positional prompt so flags remain flags.
514
+ if args.additional_args:
515
+ extra = args.additional_args.strip().split()
516
+ if extra:
517
+ cmd = cmd[:-1] + extra + [cmd[-1]]
518
+ return cmd, None
519
+
484
520
  # For multiline or large prompts, pipe via stdin to avoid command-line
485
521
  # argument issues. Pi CLI reads stdin when isTTY is false and
486
522
  # automatically prepends it to messages in print mode.
487
523
  # For simple single-line prompts, pass as positional arg + -p flag.
488
- stdin_prompt: Optional[str] = None
489
524
  if "\n" in full_prompt or len(full_prompt) > 4096:
490
525
  # Pipe via stdin — Pi auto-enables print mode when stdin has data
491
526
  stdin_prompt = full_prompt
@@ -2210,6 +2245,157 @@ Model shorthands:
2210
2245
 
2211
2246
  return None
2212
2247
 
2248
+ @staticmethod
2249
+ def _is_error_result_event(event: Optional[dict]) -> bool:
2250
+ """Return True when event represents a terminal error payload."""
2251
+ if not isinstance(event, dict):
2252
+ return False
2253
+
2254
+ if event.get("is_error") is True:
2255
+ return True
2256
+
2257
+ subtype = event.get("subtype")
2258
+ if isinstance(subtype, str) and subtype.lower() == "error":
2259
+ return True
2260
+
2261
+ event_type = event.get("type")
2262
+ if isinstance(event_type, str) and event_type.lower() in {"error", "turn.failed", "turn_failed"}:
2263
+ return True
2264
+
2265
+ return False
2266
+
2267
+ @staticmethod
2268
+ def _is_success_result_event(event: Optional[dict]) -> bool:
2269
+ """Return True when event is an explicit successful result envelope."""
2270
+ if not isinstance(event, dict):
2271
+ return False
2272
+
2273
+ if PiService._is_error_result_event(event):
2274
+ return False
2275
+
2276
+ subtype = event.get("subtype")
2277
+ if isinstance(subtype, str) and subtype.lower() == "success":
2278
+ return True
2279
+
2280
+ event_type = event.get("type")
2281
+ if isinstance(event_type, str) and event_type.lower() == "result" and event.get("is_error") is False:
2282
+ result_value = event.get("result")
2283
+ if isinstance(result_value, str):
2284
+ return bool(result_value.strip())
2285
+ if result_value not in (None, "", [], {}):
2286
+ return True
2287
+
2288
+ return False
2289
+
2290
+ @staticmethod
2291
+ def _extract_error_message_from_event(event: dict) -> Optional[str]:
2292
+ """Extract a human-readable message from Pi/Codex error event shapes."""
2293
+ if not isinstance(event, dict):
2294
+ return None
2295
+
2296
+ if not PiService._is_error_result_event(event):
2297
+ return None
2298
+
2299
+ def _stringify_error(value: object) -> Optional[str]:
2300
+ if isinstance(value, str):
2301
+ text = value.strip()
2302
+ return text if text else None
2303
+ if isinstance(value, dict):
2304
+ nested_message = value.get("message")
2305
+ if isinstance(nested_message, str) and nested_message.strip():
2306
+ return nested_message.strip()
2307
+ nested_error = value.get("error")
2308
+ if isinstance(nested_error, str) and nested_error.strip():
2309
+ return nested_error.strip()
2310
+ try:
2311
+ return json.dumps(value, ensure_ascii=False)
2312
+ except Exception:
2313
+ return str(value)
2314
+ if value is not None:
2315
+ return str(value)
2316
+ return None
2317
+
2318
+ for key in ("error", "message", "errorMessage", "result"):
2319
+ extracted = _stringify_error(event.get(key))
2320
+ if extracted:
2321
+ return extracted
2322
+
2323
+ return "Unknown Pi error"
2324
+
2325
+ @staticmethod
2326
+ def _extract_error_message_from_text(raw_text: str) -> Optional[str]:
2327
+ """Extract an error message from stderr/plaintext lines."""
2328
+ if not isinstance(raw_text, str):
2329
+ return None
2330
+
2331
+ text = raw_text.strip()
2332
+ if not text:
2333
+ return None
2334
+
2335
+ # Direct JSON line
2336
+ try:
2337
+ parsed = json.loads(text)
2338
+ extracted = PiService._extract_error_message_from_event(parsed)
2339
+ if extracted:
2340
+ return extracted
2341
+ except Exception:
2342
+ pass
2343
+
2344
+ # Prefix + JSON payload pattern (e.g. "Error: Codex error: {...}")
2345
+ json_start = text.find("{")
2346
+ if json_start > 0:
2347
+ json_candidate = text[json_start:]
2348
+ try:
2349
+ parsed = json.loads(json_candidate)
2350
+ extracted = PiService._extract_error_message_from_event(parsed)
2351
+ if extracted:
2352
+ return extracted
2353
+ except Exception:
2354
+ pass
2355
+
2356
+ lowered = text.lower()
2357
+ if lowered.startswith("error:"):
2358
+ message = text.split(":", 1)[1].strip()
2359
+ return message or text
2360
+
2361
+ if "server_error" in lowered or "codex error" in lowered:
2362
+ return text
2363
+
2364
+ return None
2365
+
2366
+ @staticmethod
2367
+ def _extract_provider_error_from_result_text(result_text: str) -> Optional[str]:
2368
+ """Detect provider-level failures that leaked into assistant result text."""
2369
+ if not isinstance(result_text, str):
2370
+ return None
2371
+
2372
+ text = result_text.strip()
2373
+ if not text:
2374
+ return None
2375
+
2376
+ normalized = " ".join(text.split())
2377
+ lowered = normalized.lower()
2378
+
2379
+ provider_signatures = (
2380
+ "chatgpt usage limit",
2381
+ "usage limit",
2382
+ "rate limit",
2383
+ "insufficient_quota",
2384
+ "too many requests",
2385
+ "codex error",
2386
+ "server_error",
2387
+ )
2388
+
2389
+ if lowered.startswith("error:"):
2390
+ payload = normalized.split(":", 1)[1].strip() if ":" in normalized else ""
2391
+ if any(signature in lowered for signature in provider_signatures) or "try again in" in lowered:
2392
+ return payload or normalized
2393
+
2394
+ if any(signature in lowered for signature in provider_signatures):
2395
+ return normalized
2396
+
2397
+ return None
2398
+
2213
2399
  def _build_success_result_event(self, text: str, event: dict) -> dict:
2214
2400
  """Build standardized success envelope for shell-backend capture."""
2215
2401
  usage = self._extract_usage_from_event(event)
@@ -2237,18 +2423,321 @@ Model shorthands:
2237
2423
 
2238
2424
  return result_event
2239
2425
 
2426
+ def _build_error_result_event(self, error_message: str, event: Optional[dict] = None) -> dict:
2427
+ """Build standardized error envelope for shell-backend capture."""
2428
+ message = error_message.strip() if isinstance(error_message, str) else str(error_message)
2429
+
2430
+ result_event: Dict = {
2431
+ "type": "result",
2432
+ "subtype": "error",
2433
+ "is_error": True,
2434
+ "result": message,
2435
+ "error": message,
2436
+ "session_id": self.session_id,
2437
+ }
2438
+
2439
+ if isinstance(event, dict):
2440
+ result_event["sub_agent_response"] = self._sanitize_sub_agent_response(event)
2441
+
2442
+ return result_event
2443
+
2240
2444
  def _write_capture_file(self, capture_path: Optional[str]) -> None:
2241
2445
  """Write final result event to capture file for shell backend."""
2242
2446
  if not capture_path or not self.last_result_event:
2243
2447
  return
2448
+
2449
+ payload = dict(self.last_result_event)
2450
+
2451
+ if not payload.get("session_id"):
2452
+ existing_capture: Optional[dict] = None
2453
+ try:
2454
+ capture_file = Path(capture_path)
2455
+ if capture_file.exists():
2456
+ raw_existing = capture_file.read_text(encoding="utf-8").strip()
2457
+ if raw_existing:
2458
+ parsed_existing = json.loads(raw_existing)
2459
+ if isinstance(parsed_existing, dict):
2460
+ existing_capture = parsed_existing
2461
+ except Exception:
2462
+ existing_capture = None
2463
+
2464
+ existing_session_id: Optional[str] = None
2465
+ if isinstance(existing_capture, dict):
2466
+ candidate = existing_capture.get("session_id")
2467
+ if isinstance(candidate, str) and candidate.strip():
2468
+ existing_session_id = candidate.strip()
2469
+ elif isinstance(existing_capture.get("sub_agent_response"), dict):
2470
+ nested = existing_capture["sub_agent_response"].get("session_id")
2471
+ if isinstance(nested, str) and nested.strip():
2472
+ existing_session_id = nested.strip()
2473
+
2474
+ if existing_session_id:
2475
+ payload["session_id"] = existing_session_id
2476
+ if not self.session_id:
2477
+ self.session_id = existing_session_id
2478
+
2479
+ self.last_result_event = payload
2480
+
2244
2481
  try:
2245
2482
  Path(capture_path).write_text(
2246
- json.dumps(self.last_result_event, ensure_ascii=False),
2483
+ json.dumps(payload, ensure_ascii=False),
2247
2484
  encoding="utf-8",
2248
2485
  )
2249
2486
  except Exception as e:
2250
2487
  print(f"Warning: Could not write capture file: {e}", file=sys.stderr)
2251
2488
 
2489
+ def _build_live_auto_exit_extension_source(self, capture_path: Optional[str]) -> str:
2490
+ """Build a temporary Pi extension source used by --live mode.
2491
+
2492
+ The extension listens for agent_end, writes a compact result envelope to
2493
+ JUNO_SUBAGENT_CAPTURE_PATH-compatible location, then requests
2494
+ graceful shutdown via ctx.shutdown().
2495
+ """
2496
+ capture_literal = json.dumps(capture_path or "")
2497
+ source = """import type { ExtensionAPI } from \"@mariozechner/pi-coding-agent\";
2498
+ import * as fs from \"node:fs\";
2499
+
2500
+ const capturePath = __CAPTURE_PATH__;
2501
+
2502
+ function extractTextFromMessages(messages: any[]): string {
2503
+ for (let i = messages.length - 1; i >= 0; i--) {
2504
+ const msg = messages[i];
2505
+ if (!msg || msg.role !== \"assistant\") continue;
2506
+
2507
+ const content = msg.content;
2508
+ if (typeof content === \"string\") {
2509
+ if (content.trim()) return content;
2510
+ continue;
2511
+ }
2512
+
2513
+ if (Array.isArray(content)) {
2514
+ const parts: string[] = [];
2515
+ for (const item of content) {
2516
+ if (typeof item === \"string\" && item.trim()) {
2517
+ parts.push(item);
2518
+ continue;
2519
+ }
2520
+ if (item && item.type === \"text\" && typeof item.text === \"string\" && item.text.trim()) {
2521
+ parts.push(item.text);
2522
+ }
2523
+ }
2524
+ if (parts.length > 0) return parts.join(\"\\n\");
2525
+ }
2526
+ }
2527
+ return \"\";
2528
+ }
2529
+
2530
+ function isFiniteNumber(value: any): value is number {
2531
+ return typeof value === \"number\" && Number.isFinite(value);
2532
+ }
2533
+
2534
+ function normalizeUsage(usage: any): any | undefined {
2535
+ if (!usage || typeof usage !== \"object\") return undefined;
2536
+
2537
+ const cost = usage.cost && typeof usage.cost === \"object\" ? usage.cost : {};
2538
+
2539
+ const input = isFiniteNumber(usage.input) ? usage.input : 0;
2540
+ const output = isFiniteNumber(usage.output) ? usage.output : 0;
2541
+ const cacheRead = isFiniteNumber(usage.cacheRead) ? usage.cacheRead : 0;
2542
+ const cacheWrite = isFiniteNumber(usage.cacheWrite) ? usage.cacheWrite : 0;
2543
+ const totalTokens = isFiniteNumber(usage.totalTokens)
2544
+ ? usage.totalTokens
2545
+ : input + output + cacheRead + cacheWrite;
2546
+
2547
+ const costInput = isFiniteNumber(cost.input) ? cost.input : 0;
2548
+ const costOutput = isFiniteNumber(cost.output) ? cost.output : 0;
2549
+ const costCacheRead = isFiniteNumber(cost.cacheRead) ? cost.cacheRead : 0;
2550
+ const costCacheWrite = isFiniteNumber(cost.cacheWrite) ? cost.cacheWrite : 0;
2551
+ const costTotal = isFiniteNumber(cost.total)
2552
+ ? cost.total
2553
+ : costInput + costOutput + costCacheRead + costCacheWrite;
2554
+
2555
+ const hasAnyValue =
2556
+ isFiniteNumber(usage.input) ||
2557
+ isFiniteNumber(usage.output) ||
2558
+ isFiniteNumber(usage.cacheRead) ||
2559
+ isFiniteNumber(usage.cacheWrite) ||
2560
+ isFiniteNumber(usage.totalTokens) ||
2561
+ isFiniteNumber(cost.input) ||
2562
+ isFiniteNumber(cost.output) ||
2563
+ isFiniteNumber(cost.cacheRead) ||
2564
+ isFiniteNumber(cost.cacheWrite) ||
2565
+ isFiniteNumber(cost.total);
2566
+
2567
+ if (!hasAnyValue) return undefined;
2568
+
2569
+ return {
2570
+ input,
2571
+ output,
2572
+ cacheRead,
2573
+ cacheWrite,
2574
+ totalTokens,
2575
+ cost: {
2576
+ input: costInput,
2577
+ output: costOutput,
2578
+ cacheRead: costCacheRead,
2579
+ cacheWrite: costCacheWrite,
2580
+ total: costTotal,
2581
+ },
2582
+ };
2583
+ }
2584
+
2585
+ function mergeUsage(base: any | undefined, delta: any | undefined): any | undefined {
2586
+ if (!base) return delta;
2587
+ if (!delta) return base;
2588
+
2589
+ const baseCost = base.cost && typeof base.cost === \"object\" ? base.cost : {};
2590
+ const deltaCost = delta.cost && typeof delta.cost === \"object\" ? delta.cost : {};
2591
+
2592
+ return {
2593
+ input: (base.input ?? 0) + (delta.input ?? 0),
2594
+ output: (base.output ?? 0) + (delta.output ?? 0),
2595
+ cacheRead: (base.cacheRead ?? 0) + (delta.cacheRead ?? 0),
2596
+ cacheWrite: (base.cacheWrite ?? 0) + (delta.cacheWrite ?? 0),
2597
+ totalTokens: (base.totalTokens ?? 0) + (delta.totalTokens ?? 0),
2598
+ cost: {
2599
+ input: (baseCost.input ?? 0) + (deltaCost.input ?? 0),
2600
+ output: (baseCost.output ?? 0) + (deltaCost.output ?? 0),
2601
+ cacheRead: (baseCost.cacheRead ?? 0) + (deltaCost.cacheRead ?? 0),
2602
+ cacheWrite: (baseCost.cacheWrite ?? 0) + (deltaCost.cacheWrite ?? 0),
2603
+ total: (baseCost.total ?? 0) + (deltaCost.total ?? 0),
2604
+ },
2605
+ };
2606
+ }
2607
+
2608
+ function extractAssistantUsage(messages: any[]): any | undefined {
2609
+ let totals: any | undefined;
2610
+
2611
+ for (const msg of messages) {
2612
+ if (!msg || msg.role !== \"assistant\") {
2613
+ continue;
2614
+ }
2615
+
2616
+ const normalized = normalizeUsage(msg.usage);
2617
+ if (!normalized) {
2618
+ continue;
2619
+ }
2620
+
2621
+ totals = mergeUsage(totals, normalized);
2622
+ }
2623
+
2624
+ return totals;
2625
+ }
2626
+
2627
+ function extractLatestAssistantStopReason(messages: any[]): string | undefined {
2628
+ for (let i = messages.length - 1; i >= 0; i--) {
2629
+ const msg = messages[i];
2630
+ if (!msg || msg.role !== \"assistant\") {
2631
+ continue;
2632
+ }
2633
+
2634
+ const reason = msg.stopReason;
2635
+ return typeof reason === \"string\" && reason ? reason : undefined;
2636
+ }
2637
+
2638
+ return undefined;
2639
+ }
2640
+
2641
+ function writeCapturePayload(payload: any): void {
2642
+ if (!capturePath) {
2643
+ return;
2644
+ }
2645
+
2646
+ fs.writeFileSync(capturePath, JSON.stringify(payload), \"utf-8\");
2647
+ }
2648
+
2649
+ function persistSessionSnapshot(sessionId: unknown): void {
2650
+ if (typeof sessionId !== \"string\" || !sessionId) {
2651
+ return;
2652
+ }
2653
+
2654
+ try {
2655
+ writeCapturePayload({
2656
+ type: \"result\",
2657
+ subtype: \"session\",
2658
+ is_error: false,
2659
+ session_id: sessionId,
2660
+ });
2661
+ } catch {
2662
+ // Non-fatal: runtime capture should continue even if snapshot write fails.
2663
+ }
2664
+ }
2665
+
2666
+ export default function (pi: ExtensionAPI) {
2667
+ let completed = false;
2668
+
2669
+ pi.on(\"session\", (event, ctx) => {
2670
+ const eventSessionId = typeof event?.id === \"string\" ? event.id : undefined;
2671
+ const managerSessionId =
2672
+ typeof ctx?.sessionManager?.getSessionId === \"function\"
2673
+ ? ctx.sessionManager.getSessionId()
2674
+ : undefined;
2675
+
2676
+ persistSessionSnapshot(managerSessionId || eventSessionId);
2677
+ });
2678
+
2679
+ pi.on(\"agent_end\", async (event, ctx) => {
2680
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
2681
+ const stopReason = extractLatestAssistantStopReason(messages);
2682
+
2683
+ // Esc-aborted runs should keep Pi open for user interaction.
2684
+ if (stopReason === \"aborted\") {
2685
+ return;
2686
+ }
2687
+
2688
+ if (completed) return;
2689
+ completed = true;
2690
+
2691
+ try {
2692
+ const usage = extractAssistantUsage(messages);
2693
+ const totalCost = typeof usage?.cost?.total === \"number\" ? usage.cost.total : undefined;
2694
+ const sessionId =
2695
+ typeof ctx?.sessionManager?.getSessionId === \"function\"
2696
+ ? ctx.sessionManager.getSessionId()
2697
+ : undefined;
2698
+ const payload: any = {
2699
+ type: \"result\",
2700
+ subtype: \"success\",
2701
+ is_error: false,
2702
+ result: extractTextFromMessages(messages),
2703
+ usage,
2704
+ total_cost_usd: totalCost,
2705
+ sub_agent_response: event,
2706
+ };
2707
+
2708
+ if (typeof sessionId === \"string\" && sessionId) {
2709
+ payload.session_id = sessionId;
2710
+ }
2711
+
2712
+ writeCapturePayload(payload);
2713
+ } catch {
2714
+ // Keep shutdown behavior even when capture writing fails.
2715
+ } finally {
2716
+ ctx.shutdown();
2717
+ }
2718
+ });
2719
+ }
2720
+ """
2721
+ return source.replace("__CAPTURE_PATH__", capture_literal)
2722
+
2723
+ def _create_live_auto_exit_extension_file(self, capture_path: Optional[str]) -> Optional[Path]:
2724
+ """Create a temporary live-mode extension file and return its path."""
2725
+ try:
2726
+ fd, temp_path = tempfile.mkstemp(prefix="juno-pi-live-auto-exit-", suffix=".ts")
2727
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
2728
+ handle.write(self._build_live_auto_exit_extension_source(capture_path))
2729
+ return Path(temp_path)
2730
+ except Exception as exc:
2731
+ print(f"Warning: Failed to create live auto-exit extension: {exc}", file=sys.stderr)
2732
+ return None
2733
+
2734
+ def _open_live_tty_stdin(self) -> Optional[TextIO]:
2735
+ """Open /dev/tty for live-mode stdin fallback when stdin is redirected."""
2736
+ try:
2737
+ return open("/dev/tty", "r", encoding="utf-8", errors="ignore")
2738
+ except OSError:
2739
+ return None
2740
+
2252
2741
  def run_pi(self, cmd: List[str], args: argparse.Namespace,
2253
2742
  stdin_prompt: Optional[str] = None) -> int:
2254
2743
  """Execute the Pi CLI and stream/format its JSON output.
@@ -2266,6 +2755,11 @@ Model shorthands:
2266
2755
  self._buffered_tool_stdout_lines.clear()
2267
2756
  self._reset_run_cost_tracking()
2268
2757
  cancel_delayed_toolcalls = lambda: None
2758
+ stderr_error_messages: List[str] = []
2759
+
2760
+ resume_session = getattr(args, "resume", None)
2761
+ if isinstance(resume_session, str) and resume_session.strip():
2762
+ self.session_id = resume_session.strip()
2269
2763
 
2270
2764
  if verbose:
2271
2765
  # Truncate prompt in display to avoid confusing multi-line output
@@ -2297,7 +2791,80 @@ Model shorthands:
2297
2791
  print(f"Executing: {' '.join(display_cmd)}", file=sys.stderr)
2298
2792
  print("-" * 80, file=sys.stderr)
2299
2793
 
2794
+ process: Optional[subprocess.Popen] = None
2795
+ live_mode_requested = bool(getattr(args, "live", False))
2796
+ stdin_has_tty = (
2797
+ hasattr(sys.stdin, "isatty")
2798
+ and sys.stdin.isatty()
2799
+ )
2800
+ stdout_has_tty = (
2801
+ hasattr(sys.stdout, "isatty")
2802
+ and sys.stdout.isatty()
2803
+ )
2804
+ live_tty_stdin: Optional[TextIO] = None
2805
+ if live_mode_requested and stdout_has_tty and not stdin_has_tty:
2806
+ live_tty_stdin = self._open_live_tty_stdin()
2807
+
2808
+ is_live_tty_passthrough = (
2809
+ live_mode_requested
2810
+ and stdout_has_tty
2811
+ and (stdin_has_tty or live_tty_stdin is not None)
2812
+ )
2813
+
2300
2814
  try:
2815
+ if is_live_tty_passthrough:
2816
+ # Interactive live mode: attach Pi directly to the current terminal.
2817
+ # Keep stdout inherited for full-screen TUI rendering/input, but
2818
+ # capture stderr so terminal provider errors can still propagate.
2819
+ popen_kwargs = {
2820
+ "cwd": self.project_path,
2821
+ "stderr": subprocess.PIPE,
2822
+ "text": True,
2823
+ "universal_newlines": True,
2824
+ }
2825
+ if live_tty_stdin is not None:
2826
+ popen_kwargs["stdin"] = live_tty_stdin
2827
+
2828
+ try:
2829
+ process = subprocess.Popen(cmd, **popen_kwargs)
2830
+
2831
+ def _live_tty_stderr_reader():
2832
+ """Read stderr during live TTY mode and capture terminal failures."""
2833
+ try:
2834
+ if process.stderr:
2835
+ for stderr_line in process.stderr:
2836
+ print(stderr_line, end="", file=sys.stderr, flush=True)
2837
+ extracted_error = self._extract_error_message_from_text(stderr_line)
2838
+ if extracted_error:
2839
+ stderr_error_messages.append(extracted_error)
2840
+ if not self._is_success_result_event(self.last_result_event):
2841
+ self.last_result_event = self._build_error_result_event(extracted_error)
2842
+ except (ValueError, OSError):
2843
+ pass
2844
+
2845
+ stderr_thread = threading.Thread(target=_live_tty_stderr_reader, daemon=True)
2846
+ stderr_thread.start()
2847
+
2848
+ process.wait()
2849
+ stderr_thread.join(timeout=3)
2850
+
2851
+ if stderr_error_messages and not self._is_success_result_event(self.last_result_event):
2852
+ self.last_result_event = self._build_error_result_event(stderr_error_messages[-1])
2853
+
2854
+ self._write_capture_file(capture_path)
2855
+
2856
+ final_return_code = process.returncode or 0
2857
+ if final_return_code == 0 and self._is_error_result_event(self.last_result_event):
2858
+ final_return_code = 1
2859
+
2860
+ return final_return_code
2861
+ finally:
2862
+ if live_tty_stdin is not None:
2863
+ try:
2864
+ live_tty_stdin.close()
2865
+ except OSError:
2866
+ pass
2867
+
2301
2868
  process = subprocess.Popen(
2302
2869
  cmd,
2303
2870
  stdin=subprocess.PIPE if stdin_prompt else subprocess.DEVNULL,
@@ -2360,11 +2927,16 @@ Model shorthands:
2360
2927
 
2361
2928
  # Stream stderr in a separate thread so Pi diagnostic output is visible
2362
2929
  def _stderr_reader():
2363
- """Read stderr and forward to our stderr for visibility."""
2930
+ """Read stderr, forward to stderr, and capture terminal error signals."""
2364
2931
  try:
2365
2932
  if process.stderr:
2366
2933
  for stderr_line in process.stderr:
2367
2934
  print(stderr_line, end="", file=sys.stderr, flush=True)
2935
+ extracted_error = self._extract_error_message_from_text(stderr_line)
2936
+ if extracted_error:
2937
+ stderr_error_messages.append(extracted_error)
2938
+ if not self._is_success_result_event(self.last_result_event):
2939
+ self.last_result_event = self._build_error_result_event(extracted_error)
2368
2940
  except (ValueError, OSError):
2369
2941
  pass
2370
2942
 
@@ -2487,6 +3059,18 @@ Model shorthands:
2487
3059
  # Capture session ID from the session event (sent at stream start)
2488
3060
  if event_type == "session":
2489
3061
  self.session_id = parsed_event.get("id")
3062
+ if (
3063
+ isinstance(self.last_result_event, dict)
3064
+ and not self.last_result_event.get("session_id")
3065
+ and isinstance(self.session_id, str)
3066
+ and self.session_id.strip()
3067
+ ):
3068
+ self.last_result_event["session_id"] = self.session_id
3069
+
3070
+ # Capture terminal error events even when upstream exits with code 0.
3071
+ error_message = self._extract_error_message_from_event(parsed_event)
3072
+ if error_message:
3073
+ self.last_result_event = self._build_error_result_event(error_message, parsed_event)
2490
3074
 
2491
3075
  # Track per-run assistant usage from stream events.
2492
3076
  self._track_assistant_usage_from_event(parsed_event)
@@ -2512,8 +3096,12 @@ Model shorthands:
2512
3096
  if text:
2513
3097
  break
2514
3098
  if text:
2515
- self.last_result_event = self._build_success_result_event(text, parsed_event)
2516
- else:
3099
+ provider_error = self._extract_provider_error_from_result_text(text)
3100
+ if provider_error:
3101
+ self.last_result_event = self._build_error_result_event(provider_error, parsed_event)
3102
+ else:
3103
+ self.last_result_event = self._build_success_result_event(text, parsed_event)
3104
+ elif not self._is_error_result_event(self.last_result_event):
2517
3105
  self.last_result_event = parsed_event
2518
3106
  elif event_type == "message":
2519
3107
  # OpenAI-compatible format: capture last assistant message
@@ -2521,14 +3109,22 @@ Model shorthands:
2521
3109
  if isinstance(msg, dict) and msg.get("role") == "assistant":
2522
3110
  text = self._extract_text_from_message(msg)
2523
3111
  if text:
2524
- self.last_result_event = self._build_success_result_event(text, parsed_event)
3112
+ provider_error = self._extract_provider_error_from_result_text(text)
3113
+ if provider_error:
3114
+ self.last_result_event = self._build_error_result_event(provider_error, parsed_event)
3115
+ else:
3116
+ self.last_result_event = self._build_success_result_event(text, parsed_event)
2525
3117
  elif event_type == "turn_end":
2526
3118
  # turn_end may contain the final assistant message
2527
3119
  msg = parsed_event.get("message", {})
2528
3120
  if isinstance(msg, dict):
2529
3121
  text = self._extract_text_from_message(msg)
2530
3122
  if text:
2531
- self.last_result_event = self._build_success_result_event(text, parsed_event)
3123
+ provider_error = self._extract_provider_error_from_result_text(text)
3124
+ if provider_error:
3125
+ self.last_result_event = self._build_error_result_event(provider_error, parsed_event)
3126
+ else:
3127
+ self.last_result_event = self._build_success_result_event(text, parsed_event)
2532
3128
 
2533
3129
  # Filter hidden stream types (live mode handles its own filtering)
2534
3130
  if event_type in hide_types and self.prettifier_mode != self.PRETTIFIER_LIVE:
@@ -2699,30 +3295,40 @@ Model shorthands:
2699
3295
  output_done.set()
2700
3296
  cancel_delayed_toolcalls()
2701
3297
 
2702
- # Write capture file for shell backend
2703
- self._write_capture_file(capture_path)
2704
-
2705
3298
  # Wait for process cleanup
2706
3299
  try:
2707
3300
  process.wait(timeout=5)
2708
3301
  except subprocess.TimeoutExpired:
2709
3302
  pass
2710
3303
 
2711
- # Wait for stderr thread to finish
3304
+ # Wait for stderr thread to finish before deriving fallback errors.
2712
3305
  stderr_thread.join(timeout=3)
2713
3306
 
2714
- return process.returncode or 0
3307
+ # If stderr surfaced a terminal error and we do not already have an
3308
+ # explicit success envelope, persist the failure for shell-backend consumers.
3309
+ if stderr_error_messages and not self._is_success_result_event(self.last_result_event):
3310
+ self.last_result_event = self._build_error_result_event(stderr_error_messages[-1])
3311
+
3312
+ # Write capture file for shell backend
3313
+ self._write_capture_file(capture_path)
3314
+
3315
+ final_return_code = process.returncode or 0
3316
+ if final_return_code == 0 and self._is_error_result_event(self.last_result_event):
3317
+ final_return_code = 1
3318
+
3319
+ return final_return_code
2715
3320
 
2716
3321
  except KeyboardInterrupt:
2717
3322
  print("\nInterrupted by user", file=sys.stderr)
2718
3323
  cancel_delayed_toolcalls()
2719
3324
  try:
2720
- process.terminate()
2721
- try:
2722
- process.wait(timeout=5)
2723
- except subprocess.TimeoutExpired:
2724
- process.kill()
2725
- process.wait(timeout=5)
3325
+ if process is not None:
3326
+ process.terminate()
3327
+ try:
3328
+ process.wait(timeout=5)
3329
+ except subprocess.TimeoutExpired:
3330
+ process.kill()
3331
+ process.wait(timeout=5)
2726
3332
  except Exception:
2727
3333
  pass
2728
3334
  self._write_capture_file(capture_path)
@@ -2732,7 +3338,7 @@ Model shorthands:
2732
3338
  print(f"Error executing pi: {e}", file=sys.stderr)
2733
3339
  cancel_delayed_toolcalls()
2734
3340
  try:
2735
- if process.poll() is None:
3341
+ if process is not None and process.poll() is None:
2736
3342
  process.terminate()
2737
3343
  process.wait(timeout=5)
2738
3344
  except Exception:
@@ -2783,8 +3389,30 @@ Model shorthands:
2783
3389
  else:
2784
3390
  self.prompt = prompt_value
2785
3391
 
2786
- cmd, stdin_prompt = self.build_pi_command(args)
2787
- return self.run_pi(cmd, args, stdin_prompt=stdin_prompt)
3392
+ if args.live and args.no_extensions:
3393
+ print("Error: --live requires extensions enabled (remove --no-extensions).", file=sys.stderr)
3394
+ return 1
3395
+
3396
+ live_extension_file: Optional[Path] = None
3397
+ if args.live:
3398
+ capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
3399
+ live_extension_file = self._create_live_auto_exit_extension_file(capture_path)
3400
+ if not live_extension_file:
3401
+ print("Error: Could not create live auto-exit extension.", file=sys.stderr)
3402
+ return 1
3403
+
3404
+ try:
3405
+ cmd, stdin_prompt = self.build_pi_command(
3406
+ args,
3407
+ live_extension_path=str(live_extension_file) if live_extension_file else None,
3408
+ )
3409
+ return self.run_pi(cmd, args, stdin_prompt=stdin_prompt)
3410
+ finally:
3411
+ if live_extension_file is not None:
3412
+ try:
3413
+ live_extension_file.unlink(missing_ok=True)
3414
+ except Exception as e:
3415
+ print(f"Warning: Failed to remove temp live extension: {e}", file=sys.stderr)
2788
3416
 
2789
3417
 
2790
3418
  def main():