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