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