juno-code 1.0.51 → 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.
@@ -407,6 +407,13 @@ Model shorthands:
407
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
408
  )
409
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
+
410
417
  parser.add_argument(
411
418
  "--pretty",
412
419
  type=str,
@@ -500,21 +507,32 @@ Model shorthands:
500
507
 
501
508
  # Build prompt with optional auto-instruction
502
509
  full_prompt = self.prompt
510
+ live_manual = bool(getattr(args, "live_manual", False))
511
+
503
512
  if args.auto_instruction:
504
- 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
505
517
 
506
518
  stdin_prompt: Optional[str] = None
507
519
 
508
520
  if is_live_mode:
509
521
  # Live mode uses positional prompt input (no -p and no stdin piping).
510
- cmd.append(full_prompt)
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)
511
526
 
512
527
  # Additional raw arguments should still be honored; place before the
513
528
  # positional prompt so flags remain flags.
514
529
  if args.additional_args:
515
530
  extra = args.additional_args.strip().split()
516
531
  if extra:
517
- cmd = cmd[:-1] + extra + [cmd[-1]]
532
+ if full_prompt:
533
+ cmd = cmd[:-1] + extra + [cmd[-1]]
534
+ else:
535
+ cmd.extend(extra)
518
536
  return cmd, None
519
537
 
520
538
  # For multiline or large prompts, pipe via stdin to avoid command-line
@@ -2245,6 +2263,46 @@ Model shorthands:
2245
2263
 
2246
2264
  return None
2247
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
+
2248
2306
  @staticmethod
2249
2307
  def _is_error_result_event(event: Optional[dict]) -> bool:
2250
2308
  """Return True when event represents a terminal error payload."""
@@ -2287,12 +2345,89 @@ Model shorthands:
2287
2345
 
2288
2346
  return False
2289
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
+
2290
2418
  @staticmethod
2291
2419
  def _extract_error_message_from_event(event: dict) -> Optional[str]:
2292
2420
  """Extract a human-readable message from Pi/Codex error event shapes."""
2293
2421
  if not isinstance(event, dict):
2294
2422
  return None
2295
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
+
2296
2431
  if not PiService._is_error_result_event(event):
2297
2432
  return None
2298
2433
 
@@ -2328,7 +2463,7 @@ Model shorthands:
2328
2463
  if not isinstance(raw_text, str):
2329
2464
  return None
2330
2465
 
2331
- text = raw_text.strip()
2466
+ text = PiService._normalize_error_text(raw_text).strip()
2332
2467
  if not text:
2333
2468
  return None
2334
2469
 
@@ -2354,6 +2489,17 @@ Model shorthands:
2354
2489
  pass
2355
2490
 
2356
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
+
2357
2503
  if lowered.startswith("error:"):
2358
2504
  message = text.split(":", 1)[1].strip()
2359
2505
  return message or text
@@ -2384,6 +2530,8 @@ Model shorthands:
2384
2530
  "too many requests",
2385
2531
  "codex error",
2386
2532
  "server_error",
2533
+ "please retry this request later",
2534
+ "occurred while processing your request",
2387
2535
  )
2388
2536
 
2389
2537
  if lowered.startswith("error:"):
@@ -2416,6 +2564,10 @@ Model shorthands:
2416
2564
  "sub_agent_response": self._sanitize_sub_agent_response(event),
2417
2565
  }
2418
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
+
2419
2571
  if isinstance(usage, dict):
2420
2572
  result_event["usage"] = usage
2421
2573
  if total_cost_usd is not None:
@@ -2486,6 +2638,58 @@ Model shorthands:
2486
2638
  except Exception as e:
2487
2639
  print(f"Warning: Could not write capture file: {e}", file=sys.stderr)
2488
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
+
2489
2693
  def _build_live_auto_exit_extension_source(self, capture_path: Optional[str]) -> str:
2490
2694
  """Build a temporary Pi extension source used by --live mode.
2491
2695
 
@@ -2498,6 +2702,12 @@ Model shorthands:
2498
2702
  import * as fs from \"node:fs\";
2499
2703
 
2500
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;
2501
2711
 
2502
2712
  function extractTextFromMessages(messages: any[]): string {
2503
2713
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -2665,46 +2875,61 @@ function persistSessionSnapshot(sessionId: unknown): void {
2665
2875
 
2666
2876
  export default function (pi: ExtensionAPI) {
2667
2877
  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;
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;
2686
2898
  }
2899
+ }
2687
2900
 
2688
- if (completed) return;
2689
- completed = true;
2690
-
2901
+ async function finalizeAndShutdown(event: any, ctx: any): Promise<void> {
2691
2902
  try {
2903
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
2692
2904
  const usage = extractAssistantUsage(messages);
2693
2905
  const totalCost = typeof usage?.cost?.total === \"number\" ? usage.cost.total : undefined;
2694
- const sessionId =
2906
+ const stopReason = extractLatestAssistantStopReason(messages);
2907
+ const managerSessionId =
2695
2908
  typeof ctx?.sessionManager?.getSessionId === \"function\"
2696
2909
  ? ctx.sessionManager.getSessionId()
2697
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;
2698
2919
  const payload: any = {
2699
2920
  type: \"result\",
2700
- subtype: \"success\",
2701
- is_error: false,
2702
- result: extractTextFromMessages(messages),
2921
+ subtype: isError ? \"error\" : \"success\",
2922
+ is_error: isError,
2923
+ result: resolvedResult,
2703
2924
  usage,
2704
2925
  total_cost_usd: totalCost,
2705
2926
  sub_agent_response: event,
2706
2927
  };
2707
2928
 
2929
+ if (isError) {
2930
+ payload.error = resolvedResult;
2931
+ }
2932
+
2708
2933
  if (typeof sessionId === \"string\" && sessionId) {
2709
2934
  payload.session_id = sessionId;
2710
2935
  }
@@ -2713,8 +2938,80 @@ export default function (pi: ExtensionAPI) {
2713
2938
  } catch {
2714
2939
  // Keep shutdown behavior even when capture writing fails.
2715
2940
  } finally {
2716
- ctx.shutdown();
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;
2717
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);
2718
3015
  });
2719
3016
  }
2720
3017
  """
@@ -2751,11 +3048,22 @@ export default function (pi: ExtensionAPI) {
2751
3048
  verbose = args.verbose
2752
3049
  pretty = args.pretty.lower() != "false"
2753
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
2754
3061
  hide_types = self._build_hide_types()
2755
3062
  self._buffered_tool_stdout_lines.clear()
2756
3063
  self._reset_run_cost_tracking()
2757
3064
  cancel_delayed_toolcalls = lambda: None
2758
3065
  stderr_error_messages: List[str] = []
3066
+ stderr_output_lines: List[str] = []
2759
3067
 
2760
3068
  resume_session = getattr(args, "resume", None)
2761
3069
  if isinstance(resume_session, str) and resume_session.strip():
@@ -2833,12 +3141,11 @@ export default function (pi: ExtensionAPI) {
2833
3141
  try:
2834
3142
  if process.stderr:
2835
3143
  for stderr_line in process.stderr:
3144
+ stderr_output_lines.append(stderr_line)
2836
3145
  print(stderr_line, end="", file=sys.stderr, flush=True)
2837
3146
  extracted_error = self._extract_error_message_from_text(stderr_line)
2838
3147
  if extracted_error:
2839
3148
  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
3149
  except (ValueError, OSError):
2843
3150
  pass
2844
3151
 
@@ -2848,8 +3155,19 @@ export default function (pi: ExtensionAPI) {
2848
3155
  process.wait()
2849
3156
  stderr_thread.join(timeout=3)
2850
3157
 
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])
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)
2853
3171
 
2854
3172
  self._write_capture_file(capture_path)
2855
3173
 
@@ -2931,12 +3249,11 @@ export default function (pi: ExtensionAPI) {
2931
3249
  try:
2932
3250
  if process.stderr:
2933
3251
  for stderr_line in process.stderr:
3252
+ stderr_output_lines.append(stderr_line)
2934
3253
  print(stderr_line, end="", file=sys.stderr, flush=True)
2935
3254
  extracted_error = self._extract_error_message_from_text(stderr_line)
2936
3255
  if extracted_error:
2937
3256
  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)
2940
3257
  except (ValueError, OSError):
2941
3258
  pass
2942
3259
 
@@ -3056,9 +3373,10 @@ export default function (pi: ExtensionAPI) {
3056
3373
  def _emit_parsed_event(parsed_event: dict, raw_json_line: Optional[str] = None) -> None:
3057
3374
  event_type = parsed_event.get("type", "")
3058
3375
 
3059
- # Capture session ID from the session event (sent at stream start)
3060
- if event_type == "session":
3061
- 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
3062
3380
  if (
3063
3381
  isinstance(self.last_result_event, dict)
3064
3382
  and not self.last_result_event.get("session_id")
@@ -3088,14 +3406,32 @@ export default function (pi: ExtensionAPI) {
3088
3406
  # agent_end has a 'messages' array; extract final assistant text
3089
3407
  messages = parsed_event.get("messages", [])
3090
3408
  text = ""
3409
+ assistant_stop_reason = ""
3410
+ assistant_error_message = ""
3091
3411
  if isinstance(messages, list):
3092
- # Walk messages in reverse to find last assistant message with text
3412
+ # Use the latest assistant message as source of truth.
3093
3413
  for m in reversed(messages):
3094
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()
3095
3421
  text = self._extract_text_from_message(m)
3096
- if text:
3097
- break
3098
- if text:
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:
3099
3435
  provider_error = self._extract_provider_error_from_result_text(text)
3100
3436
  if provider_error:
3101
3437
  self.last_result_event = self._build_error_result_event(provider_error, parsed_event)
@@ -3304,10 +3640,21 @@ export default function (pi: ExtensionAPI) {
3304
3640
  # Wait for stderr thread to finish before deriving fallback errors.
3305
3641
  stderr_thread.join(timeout=3)
3306
3642
 
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])
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)
3311
3658
 
3312
3659
  # Write capture file for shell backend
3313
3660
  self._write_capture_file(capture_path)
@@ -3352,7 +3699,23 @@ export default function (pi: ExtensionAPI) {
3352
3699
 
3353
3700
  # Prompt handling
3354
3701
  prompt_value = args.prompt or os.environ.get("JUNO_INSTRUCTION")
3355
- 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:
3356
3719
  print("Error: Either -p/--prompt or -pp/--prompt-file is required.", file=sys.stderr)
3357
3720
  print("\nRun 'pi.py --help' for usage information.", file=sys.stderr)
3358
3721
  return 1
@@ -3386,16 +3749,20 @@ export default function (pi: ExtensionAPI) {
3386
3749
 
3387
3750
  if args.prompt_file:
3388
3751
  self.prompt = self.read_prompt_file(args.prompt_file)
3389
- else:
3752
+ elif prompt_value:
3390
3753
  self.prompt = prompt_value
3754
+ else:
3755
+ self.prompt = ""
3391
3756
 
3392
- if args.live and args.no_extensions:
3757
+ if args.live and args.no_extensions and not live_manual_session:
3393
3758
  print("Error: --live requires extensions enabled (remove --no-extensions).", file=sys.stderr)
3394
3759
  return 1
3395
3760
 
3396
3761
  live_extension_file: Optional[Path] = None
3397
- if args.live:
3762
+ if args.live and not live_manual_session:
3398
3763
  capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
3764
+ if not os.environ.get("JUNO_TOOL_ID"):
3765
+ capture_path = None
3399
3766
  live_extension_file = self._create_live_auto_exit_extension_file(capture_path)
3400
3767
  if not live_extension_file:
3401
3768
  print("Error: Could not create live auto-exit extension.", file=sys.stderr)