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.
- package/README.md +57 -0
- package/dist/bin/cli.js +678 -82
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/cli.mjs +675 -79
- package/dist/bin/cli.mjs.map +1 -1
- package/dist/index.d.mts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +369 -58
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +367 -56
- package/dist/index.mjs.map +1 -1
- package/dist/templates/scripts/__pycache__/parallel_runner.cpython-313.pyc +0 -0
- package/dist/templates/scripts/install_requirements.sh +21 -0
- package/dist/templates/scripts/kanban.sh +6 -2
- package/dist/templates/scripts/parallel_runner.sh +602 -131
- package/dist/templates/services/__pycache__/pi.cpython-313.pyc +0 -0
- package/dist/templates/services/__pycache__/pi.cpython-38.pyc +0 -0
- package/dist/templates/services/pi.py +418 -51
- package/dist/templates/skills/claude/ralph-loop/scripts/kanban.sh +6 -2
- package/dist/templates/skills/codex/ralph-loop/scripts/kanban.sh +6 -2
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
2702
|
-
result:
|
|
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
|
-
|
|
2852
|
-
|
|
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
|
|
3060
|
-
|
|
3061
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
3097
|
-
|
|
3098
|
-
if
|
|
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
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
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
|
|
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
|
-
|
|
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)
|