juno-code 1.0.49 → 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.
Files changed (33) hide show
  1. package/README.md +508 -202
  2. package/dist/bin/cli.d.mts +1 -1
  3. package/dist/bin/cli.d.ts +1 -1
  4. package/dist/bin/cli.js +3332 -1421
  5. package/dist/bin/cli.js.map +1 -1
  6. package/dist/bin/cli.mjs +3316 -1405
  7. package/dist/bin/cli.mjs.map +1 -1
  8. package/dist/bin/feedback-collector.js.map +1 -1
  9. package/dist/bin/feedback-collector.mjs.map +1 -1
  10. package/dist/index.d.mts +56 -19
  11. package/dist/index.d.ts +56 -19
  12. package/dist/index.js +240 -36
  13. package/dist/index.js.map +1 -1
  14. package/dist/index.mjs +240 -36
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/templates/scripts/install_requirements.sh +55 -5
  17. package/dist/templates/scripts/kanban.sh +11 -0
  18. package/dist/templates/services/README.md +23 -4
  19. package/dist/templates/services/__pycache__/pi.cpython-313.pyc +0 -0
  20. package/dist/templates/services/pi.py +1933 -262
  21. package/dist/templates/skills/claude/kanban-workflow/SKILL.md +138 -0
  22. package/dist/templates/skills/claude/plan-kanban-tasks/SKILL.md +1 -1
  23. package/dist/templates/skills/claude/ralph-loop/scripts/kanban.sh +11 -0
  24. package/dist/templates/skills/claude/understand-project/SKILL.md +1 -1
  25. package/dist/templates/skills/codex/kanban-workflow/SKILL.md +139 -0
  26. package/dist/templates/skills/codex/plan-kanban-tasks/SKILL.md +32 -0
  27. package/dist/templates/skills/codex/ralph-loop/scripts/kanban.sh +11 -0
  28. package/dist/templates/skills/codex/understand-project/SKILL.md +46 -0
  29. package/dist/templates/skills/pi/kanban-workflow/SKILL.md +139 -0
  30. package/dist/templates/skills/pi/plan-kanban-tasks/SKILL.md +1 -1
  31. package/dist/templates/skills/pi/ralph-loop/SKILL.md +4 -0
  32. package/dist/templates/skills/pi/understand-project/SKILL.md +1 -1
  33. package/package.json +7 -5
@@ -7,13 +7,15 @@ Headless wrapper around the Pi coding agent CLI with JSON streaming and shorthan
7
7
  import argparse
8
8
  import json
9
9
  import os
10
+ import re
10
11
  import subprocess
11
12
  import sys
13
+ import tempfile
12
14
  import threading
13
15
  import time
14
16
  from datetime import datetime
15
17
  from pathlib import Path
16
- from typing import Dict, List, Optional, Tuple
18
+ from typing import Dict, List, Optional, Set, TextIO, Tuple
17
19
 
18
20
 
19
21
  class PiService:
@@ -35,7 +37,10 @@ class PiService:
35
37
  ":gpt-5": "openai/gpt-5",
36
38
  ":gpt-4o": "openai/gpt-4o",
37
39
  ":o3": "openai/o3",
38
- ":codex": "openai/gpt-5.3-codex",
40
+ ":codex": "openai-codex/gpt-5.3-codex",
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",
39
44
  # Google
40
45
  ":gemini-pro": "google/gemini-2.5-pro",
41
46
  ":gemini-flash": "google/gemini-2.5-flash",
@@ -74,6 +79,17 @@ class PiService:
74
79
  PRETTIFIER_CODEX = "codex"
75
80
  PRETTIFIER_LIVE = "live"
76
81
 
82
+ # ANSI colors for tool prettifier output.
83
+ # - command/args blocks are green for readability
84
+ # - error results are red
85
+ ANSI_GREEN = "\x1b[38;5;40m"
86
+ ANSI_RED = "\x1b[38;5;203m"
87
+ ANSI_RESET = "\x1b[0m"
88
+
89
+ # Keep tool args readable while preventing giant inline payloads.
90
+ TOOL_ARG_STRING_MAX_CHARS = 400
91
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
92
+
77
93
  def __init__(self):
78
94
  self.model_name = self.DEFAULT_MODEL
79
95
  self.project_path = os.getcwd()
@@ -83,6 +99,18 @@ class PiService:
83
99
  self.session_id: Optional[str] = None
84
100
  self.message_counter = 0
85
101
  self.prettifier_mode = self.PRETTIFIER_PI
102
+ # Tool call grouping: buffer toolcall_end until tool_execution_end arrives
103
+ self._pending_tool_calls: Dict[str, dict] = {} # toolCallId -> {tool, args/command}
104
+ # Buffer tool_execution_start data for fallback + timing (when toolcall_end arrives late)
105
+ self._pending_exec_starts: Dict[str, dict] = {} # toolCallId -> {tool, args/command, started_at}
106
+ # Track whether we're inside a tool execution
107
+ self._in_tool_execution: bool = False
108
+ # Buffer raw non-JSON tool stdout so it doesn't interleave with structured events
109
+ self._buffered_tool_stdout_lines: List[str] = []
110
+ # Per-run usage/cost accumulation (used for result + agent_end total cost visibility)
111
+ self._run_usage_totals: Optional[dict] = None
112
+ self._run_total_cost_usd: Optional[float] = None
113
+ self._run_seen_usage_keys: Set[str] = set()
86
114
  # Claude prettifier state
87
115
  self.user_message_truncate = int(os.environ.get("CLAUDE_USER_MESSAGE_PRETTY_TRUNCATE", "4"))
88
116
  # Codex prettifier state
@@ -92,6 +120,114 @@ class PiService:
92
120
  # Keys to hide from intermediate assistant messages in Codex mode
93
121
  self._codex_metadata_keys = {"api", "provider", "model", "usage", "stopReason", "timestamp"}
94
122
 
123
+ def _color_enabled(self) -> bool:
124
+ """Check if ANSI color output is appropriate (TTY + NO_COLOR not set)."""
125
+ if os.environ.get("NO_COLOR") is not None:
126
+ return False
127
+ return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
128
+
129
+ def _colorize_lines(self, text: str, color_code: str) -> str:
130
+ """Apply ANSI coloring per line so line-based renderers keep colors stable."""
131
+ if "\n" not in text:
132
+ return f"{color_code}{text}{self.ANSI_RESET}"
133
+ return "\n".join(f"{color_code}{line}{self.ANSI_RESET}" for line in text.split("\n"))
134
+
135
+ def _colorize_result(self, text: str, is_error: bool = False) -> str:
136
+ """Colorize tool output only for errors; success stays terminal-default."""
137
+ if not self._color_enabled():
138
+ return text
139
+ if not is_error:
140
+ return text
141
+ return self._colorize_lines(text, self.ANSI_RED)
142
+
143
+ def _colorize_command(self, text: str) -> str:
144
+ """Colorize tool command/args blocks in green when ANSI color is enabled."""
145
+ if not self._color_enabled():
146
+ return text
147
+ return self._colorize_lines(text, self.ANSI_GREEN)
148
+
149
+ def _normalize_multiline_tool_text(self, text: str) -> str:
150
+ """Render escaped newline sequences as real newlines for tool command/args blocks."""
151
+ if "\n" in text:
152
+ return text
153
+ if "\\n" in text:
154
+ return text.replace("\\n", "\n")
155
+ return text
156
+
157
+ def _format_tool_invocation_header(self, header: Dict) -> str:
158
+ """Serialize a tool header and render multiline command/args as separate readable blocks."""
159
+ metadata = dict(header)
160
+ block_label: Optional[str] = None
161
+ block_text: Optional[str] = None
162
+
163
+ command_val = metadata.get("command")
164
+ if isinstance(command_val, str) and command_val.strip():
165
+ command_text = self._normalize_multiline_tool_text(command_val)
166
+ if "\n" in command_text:
167
+ metadata.pop("command", None)
168
+ block_label = "command:"
169
+ block_text = self._colorize_command(command_text)
170
+
171
+ if block_text is None:
172
+ args_val = metadata.get("args")
173
+ if isinstance(args_val, str) and args_val.strip():
174
+ args_text = self._normalize_multiline_tool_text(args_val)
175
+ if "\n" in args_text:
176
+ metadata.pop("args", None)
177
+ block_label = "args:"
178
+ block_text = self._colorize_command(args_text)
179
+
180
+ output = json.dumps(metadata, ensure_ascii=False)
181
+ if block_text is None:
182
+ return output
183
+ return output + "\n" + block_label + "\n" + block_text
184
+
185
+ def _strip_ansi_sequences(self, text: str) -> str:
186
+ """Remove ANSI escape sequences to prevent color bleed in prettified output."""
187
+ if not isinstance(text, str) or "\x1b" not in text:
188
+ return text
189
+ return self._ANSI_ESCAPE_RE.sub("", text)
190
+
191
+ def _sanitize_tool_argument_value(self, value):
192
+ """Recursively sanitize tool args while preserving JSON structure."""
193
+ if isinstance(value, str):
194
+ clean = self._strip_ansi_sequences(value)
195
+ if len(clean) > self.TOOL_ARG_STRING_MAX_CHARS:
196
+ return clean[:self.TOOL_ARG_STRING_MAX_CHARS] + "..."
197
+ return clean
198
+ if isinstance(value, dict):
199
+ return {k: self._sanitize_tool_argument_value(v) for k, v in value.items()}
200
+ if isinstance(value, list):
201
+ return [self._sanitize_tool_argument_value(v) for v in value]
202
+ return value
203
+
204
+ def _format_execution_time(self, payload: dict, pending: Optional[dict] = None) -> Optional[str]:
205
+ """Return execution time string (e.g. 0.12s) from payload or measured start time."""
206
+ seconds: Optional[float] = None
207
+
208
+ # Prefer explicit durations if Pi adds them in future versions.
209
+ for key in ("executionTimeSeconds", "durationSeconds", "elapsedSeconds"):
210
+ value = payload.get(key)
211
+ if isinstance(value, (int, float)):
212
+ seconds = float(value)
213
+ break
214
+
215
+ if seconds is None:
216
+ for key in ("executionTimeMs", "durationMs", "elapsedMs"):
217
+ value = payload.get(key)
218
+ if isinstance(value, (int, float)):
219
+ seconds = float(value) / 1000.0
220
+ break
221
+
222
+ if seconds is None and isinstance(pending, dict):
223
+ started_at = pending.get("started_at")
224
+ if isinstance(started_at, (int, float)):
225
+ seconds = max(0.0, time.perf_counter() - started_at)
226
+
227
+ if seconds is None:
228
+ return None
229
+ return f"{seconds:.2f}s"
230
+
95
231
  def expand_model_shorthand(self, model: str) -> str:
96
232
  """Expand shorthand model names (colon-prefixed) to full identifiers."""
97
233
  if model.startswith(":"):
@@ -103,13 +239,15 @@ class PiService:
103
239
 
104
240
  Pi CLI always uses its own event protocol (message, turn_end,
105
241
  message_update, agent_end, etc.) regardless of the underlying LLM.
106
- The exception is Codex models where Pi wraps Codex-format events
107
- (agent_reasoning, agent_message, exec_command_end).
242
+ Codex models also use Pi's event protocol but may additionally emit
243
+ native Codex events (agent_reasoning, agent_message, exec_command_end).
244
+ The LIVE prettifier handles both Pi-native and Codex-native events,
245
+ giving real-time streaming output for all model types.
108
246
  Claude models still use Pi's event protocol, NOT Claude CLI events.
109
247
  """
110
248
  model_lower = model.lower()
111
249
  if "codex" in model_lower:
112
- return self.PRETTIFIER_CODEX
250
+ return self.PRETTIFIER_LIVE
113
251
  # All non-Codex models (including Claude) use Pi's native event protocol
114
252
  return self.PRETTIFIER_PI
115
253
 
@@ -147,7 +285,10 @@ Model shorthands:
147
285
  :gpt-5 -> openai/gpt-5
148
286
  :gpt-4o -> openai/gpt-4o
149
287
  :o3 -> openai/o3
150
- :codex -> openai/gpt-5.3-codex
288
+ :codex -> openai-codex/gpt-5.3-codex
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
151
292
  :gemini-pro -> google/gemini-2.5-pro
152
293
  :gemini-flash -> google/gemini-2.5-flash
153
294
  :groq -> groq/llama-4-scout-17b-16e-instruct
@@ -259,6 +400,13 @@ Model shorthands:
259
400
  help="Space-separated additional pi CLI arguments to append.",
260
401
  )
261
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
+
262
410
  parser.add_argument(
263
411
  "--pretty",
264
412
  type=str,
@@ -287,15 +435,21 @@ Model shorthands:
287
435
  print(f"Error reading prompt file: {e}", file=sys.stderr)
288
436
  sys.exit(1)
289
437
 
290
- def build_pi_command(self, args: argparse.Namespace) -> Tuple[List[str], Optional[str]]:
291
- """Construct the Pi CLI command for headless JSON streaming execution.
438
+ def build_pi_command(
439
+ self,
440
+ args: argparse.Namespace,
441
+ live_extension_path: Optional[str] = None,
442
+ ) -> Tuple[List[str], Optional[str]]:
443
+ """Construct the Pi CLI command.
292
444
 
293
- Returns (cmd, stdin_prompt): cmd is the argument list, stdin_prompt is
294
- the prompt text to pipe via stdin (or None to pass as positional arg).
295
- For multiline or large prompts we pipe via stdin so Pi reads it
296
- 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.
297
448
  """
298
- cmd = ["pi", "--mode", "json"]
449
+ is_live_mode = bool(getattr(args, "live", False))
450
+ cmd = ["pi"]
451
+ if not is_live_mode:
452
+ cmd.extend(["--mode", "json"])
299
453
 
300
454
  # Model: if provider/model format, split and pass separately
301
455
  model = self.model_name
@@ -340,16 +494,33 @@ Model shorthands:
340
494
  elif args.no_session:
341
495
  cmd.append("--no-session")
342
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
+
343
501
  # Build prompt with optional auto-instruction
344
502
  full_prompt = self.prompt
345
503
  if args.auto_instruction:
346
504
  full_prompt = f"{args.auto_instruction}\n\n{full_prompt}"
347
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
+
348
520
  # For multiline or large prompts, pipe via stdin to avoid command-line
349
521
  # argument issues. Pi CLI reads stdin when isTTY is false and
350
522
  # automatically prepends it to messages in print mode.
351
523
  # For simple single-line prompts, pass as positional arg + -p flag.
352
- stdin_prompt: Optional[str] = None
353
524
  if "\n" in full_prompt or len(full_prompt) > 4096:
354
525
  # Pipe via stdin — Pi auto-enables print mode when stdin has data
355
526
  stdin_prompt = full_prompt
@@ -544,6 +715,7 @@ Model shorthands:
544
715
  return text
545
716
  # Unescape JSON-escaped newlines for human-readable display
546
717
  display_text = text.replace("\\n", "\n").replace("\\t", "\t")
718
+ display_text = self._strip_ansi_sequences(display_text)
547
719
  lines = display_text.split("\n")
548
720
  max_lines = self._codex_tool_result_max_lines
549
721
  if len(lines) <= max_lines:
@@ -643,12 +815,11 @@ Model shorthands:
643
815
  args = item.get("arguments", {})
644
816
  if isinstance(args, dict):
645
817
  cmd = args.get("command", "")
646
- if cmd:
647
- parts.append(f"[toolCall] {name}: {cmd}")
818
+ if isinstance(cmd, str) and cmd:
819
+ parts.append(f"[toolCall] {name}: {self._sanitize_tool_argument_value(cmd)}")
648
820
  else:
649
- args_str = json.dumps(args, ensure_ascii=False)
650
- if len(args_str) > 200:
651
- args_str = args_str[:200] + "..."
821
+ args_clean = self._sanitize_tool_argument_value(args)
822
+ args_str = json.dumps(args_clean, ensure_ascii=False)
652
823
  parts.append(f"[toolCall] {name}: {args_str}")
653
824
  else:
654
825
  parts.append(f"[toolCall] {name}")
@@ -734,10 +905,13 @@ Model shorthands:
734
905
  header["thinking"] = thinking_text
735
906
  return json.dumps(header, ensure_ascii=False)
736
907
 
737
- # toolcall_end: show tool name and arguments
908
+ # toolcall_end: buffer for grouping with tool_execution_end
738
909
  if ame_type == "toolcall_end":
739
- self.message_counter += 1
740
910
  tool_call = ame.get("toolCall", {})
911
+ if self._buffer_tool_call_end(tool_call, now):
912
+ return "" # suppress — will emit combined event on tool_execution_end
913
+ # No toolCallId — fallback to original format
914
+ self.message_counter += 1
741
915
  header = {
742
916
  "type": "toolcall_end",
743
917
  "datetime": now,
@@ -748,14 +922,13 @@ Model shorthands:
748
922
  args = tool_call.get("arguments", {})
749
923
  if isinstance(args, dict):
750
924
  cmd = args.get("command", "")
751
- if cmd:
752
- header["command"] = cmd
925
+ if isinstance(cmd, str) and cmd:
926
+ header["command"] = self._sanitize_tool_argument_value(cmd)
753
927
  else:
754
- args_str = json.dumps(args, ensure_ascii=False)
755
- if len(args_str) > 200:
756
- args_str = args_str[:200] + "..."
757
- header["args"] = args_str if isinstance(args_str, str) else args
758
- return json.dumps(header, ensure_ascii=False)
928
+ header["args"] = self._sanitize_tool_argument_value(args)
929
+ elif isinstance(args, str) and args.strip():
930
+ header["args"] = self._sanitize_tool_argument_value(args)
931
+ return self._format_tool_invocation_header(header)
759
932
 
760
933
  # Other message_update subtypes: suppress by default
761
934
  return ""
@@ -773,14 +946,12 @@ Model shorthands:
773
946
  header["tool_results_count"] = len(tool_results)
774
947
  return json.dumps(header, ensure_ascii=False)
775
948
 
776
- # --- message_start: minimal header ---
949
+ # --- message_start: minimal header (no counter — only *_end events get counters) ---
777
950
  if event_type == "message_start":
778
- self.message_counter += 1
779
951
  message = parsed.get("message", {})
780
952
  header = {
781
953
  "type": "message_start",
782
954
  "datetime": now,
783
- "counter": f"#{self.message_counter}",
784
955
  }
785
956
  if isinstance(message, dict):
786
957
  role = message.get("role")
@@ -798,58 +969,104 @@ Model shorthands:
798
969
  }
799
970
  return json.dumps(header, ensure_ascii=False)
800
971
 
801
- # --- tool_execution_start ---
972
+ # --- tool_execution_start: always suppress, buffer args ---
802
973
  if event_type == "tool_execution_start":
803
- self.message_counter += 1
804
- header = {
805
- "type": "tool_execution_start",
806
- "datetime": now,
807
- "counter": f"#{self.message_counter}",
808
- "tool": parsed.get("toolName", ""),
809
- }
810
- args_val = parsed.get("args")
811
- if isinstance(args_val, dict):
812
- args_str = json.dumps(args_val, ensure_ascii=False)
813
- if len(args_str) > 200:
814
- header["args"] = args_str[:200] + "..."
815
- else:
816
- header["args"] = args_val
817
- return json.dumps(header, ensure_ascii=False)
974
+ self._buffer_exec_start(parsed)
975
+ self._in_tool_execution = True
976
+ return "" # suppress
818
977
 
819
- # --- tool_execution_end ---
978
+ # --- tool_execution_end: combine with buffered data ---
820
979
  if event_type == "tool_execution_end":
980
+ self._in_tool_execution = False
981
+ tool_call_id = parsed.get("toolCallId")
982
+
983
+ pending_tool = self._pending_tool_calls.pop(tool_call_id, None) if tool_call_id else None
984
+ pending_exec = self._pending_exec_starts.pop(tool_call_id, None) if tool_call_id else None
985
+ if pending_tool and pending_exec and "started_at" in pending_exec:
986
+ pending_tool["started_at"] = pending_exec["started_at"]
987
+ pending = pending_tool or pending_exec
988
+
989
+ if pending:
990
+ return self._build_combined_tool_event(pending, parsed, now)
991
+
992
+ # No buffered data — minimal fallback
821
993
  self.message_counter += 1
822
994
  header = {
823
- "type": "tool_execution_end",
995
+ "type": "tool",
824
996
  "datetime": now,
825
997
  "counter": f"#{self.message_counter}",
826
998
  "tool": parsed.get("toolName", ""),
827
999
  }
1000
+ execution_time = self._format_execution_time(parsed)
1001
+ if execution_time:
1002
+ header["execution_time"] = execution_time
1003
+
828
1004
  is_error = parsed.get("isError", False)
829
1005
  if is_error:
830
1006
  header["isError"] = True
1007
+
831
1008
  result_val = parsed.get("result")
1009
+ colorize_error = self._color_enabled() and bool(is_error)
1010
+
1011
+ if isinstance(result_val, str) and result_val.strip():
1012
+ truncated = self._truncate_tool_result_text(result_val)
1013
+ if "\n" in truncated or colorize_error:
1014
+ label = "result:"
1015
+ colored = self._colorize_result(truncated, is_error=bool(is_error))
1016
+ if colorize_error:
1017
+ label = self._colorize_result(label, is_error=True)
1018
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
1019
+ header["result"] = truncated
1020
+ return self._format_tool_invocation_header(header)
1021
+
832
1022
  if isinstance(result_val, dict):
833
- # Extract text content from result
834
1023
  result_content = result_val.get("content")
835
1024
  if isinstance(result_content, list):
836
1025
  for rc_item in result_content:
837
1026
  if isinstance(rc_item, dict) and rc_item.get("type") == "text":
838
1027
  text = rc_item.get("text", "")
839
1028
  truncated = self._truncate_tool_result_text(text)
840
- if "\n" in truncated:
841
- return json.dumps(header, ensure_ascii=False) + "\nresult:\n" + truncated
1029
+ if "\n" in truncated or colorize_error:
1030
+ label = "result:"
1031
+ colored = self._colorize_result(truncated, is_error=bool(is_error))
1032
+ if colorize_error:
1033
+ label = self._colorize_result(label, is_error=True)
1034
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
842
1035
  header["result"] = truncated
843
- return json.dumps(header, ensure_ascii=False)
844
- return json.dumps(header, ensure_ascii=False)
1036
+ return self._format_tool_invocation_header(header)
1037
+
1038
+ result_json = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1039
+ if "\n" in result_json or colorize_error:
1040
+ label = "result:"
1041
+ colored = self._colorize_result(result_json, is_error=bool(is_error))
1042
+ if colorize_error:
1043
+ label = self._colorize_result(label, is_error=True)
1044
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
1045
+ header["result"] = result_json
1046
+ return self._format_tool_invocation_header(header)
1047
+
1048
+ if isinstance(result_val, list):
1049
+ result_json = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1050
+ if "\n" in result_json or colorize_error:
1051
+ label = "result:"
1052
+ colored = self._colorize_result(result_json, is_error=bool(is_error))
1053
+ if colorize_error:
1054
+ label = self._colorize_result(label, is_error=True)
1055
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
1056
+ header["result"] = result_json
1057
+ return self._format_tool_invocation_header(header)
1058
+
1059
+ return self._format_tool_invocation_header(header)
1060
+
1061
+ # --- turn_start: suppress (no user-visible value) ---
1062
+ if event_type == "turn_start":
1063
+ return ""
845
1064
 
846
- # --- agent_start, turn_start: simple headers ---
847
- if event_type in ("agent_start", "turn_start"):
848
- self.message_counter += 1
1065
+ # --- agent_start: simple header (no counter — only *_end events get counters) ---
1066
+ if event_type == "agent_start":
849
1067
  return json.dumps({
850
1068
  "type": event_type,
851
1069
  "datetime": now,
852
- "counter": f"#{self.message_counter}",
853
1070
  }, ensure_ascii=False)
854
1071
 
855
1072
  # --- agent_end: capture and show summary ---
@@ -863,6 +1080,9 @@ Model shorthands:
863
1080
  messages = parsed.get("messages")
864
1081
  if isinstance(messages, list):
865
1082
  header["message_count"] = len(messages)
1083
+ total_cost_usd = self._extract_total_cost_usd(parsed)
1084
+ if total_cost_usd is not None:
1085
+ header["total_cost_usd"] = total_cost_usd
866
1086
  return json.dumps(header, ensure_ascii=False)
867
1087
 
868
1088
  # Not a Pi-wrapped event type we handle
@@ -880,7 +1100,7 @@ Model shorthands:
880
1100
  base_type = header_type or msg_type or "message"
881
1101
 
882
1102
  def make_header(type_value: str):
883
- hdr: Dict = {"type": type_value, "datetime": now}
1103
+ hdr: Dict = {"type": type_value, "datetime": now, "counter": f"#{self.message_counter}"}
884
1104
  if item_id:
885
1105
  hdr["id"] = item_id
886
1106
  if outer_type and msg_type and outer_type != msg_type:
@@ -1091,6 +1311,107 @@ Model shorthands:
1091
1311
 
1092
1312
  return ""
1093
1313
 
1314
+ def _buffer_tool_call_end(self, tool_call: dict, now: str) -> bool:
1315
+ """Buffer toolcall_end info for grouping with tool_execution_end.
1316
+
1317
+ Returns True if successfully buffered (caller should suppress output),
1318
+ False if no toolCallId present (caller should emit normally).
1319
+ """
1320
+ tc_id = tool_call.get("toolCallId", "") if isinstance(tool_call, dict) else ""
1321
+ if not tc_id:
1322
+ return False
1323
+
1324
+ pending: Dict = {"tool": tool_call.get("name", ""), "datetime": now}
1325
+ args = tool_call.get("arguments", {})
1326
+
1327
+ if isinstance(args, dict):
1328
+ cmd = args.get("command", "")
1329
+ if isinstance(cmd, str) and cmd:
1330
+ pending["command"] = self._sanitize_tool_argument_value(cmd)
1331
+ else:
1332
+ pending["args"] = self._sanitize_tool_argument_value(args)
1333
+ elif isinstance(args, str) and args.strip():
1334
+ pending["args"] = self._sanitize_tool_argument_value(args)
1335
+
1336
+ self._pending_tool_calls[tc_id] = pending
1337
+ return True
1338
+
1339
+ def _buffer_exec_start(self, payload: dict) -> None:
1340
+ """Buffer tool_execution_start data for tool_execution_end fallback + timing."""
1341
+ tc_id = payload.get("toolCallId", "")
1342
+ if not tc_id:
1343
+ return
1344
+
1345
+ pending: Dict = {
1346
+ "tool": payload.get("toolName", ""),
1347
+ "started_at": time.perf_counter(),
1348
+ }
1349
+ args_val = payload.get("args")
1350
+ if isinstance(args_val, dict):
1351
+ cmd = args_val.get("command", "")
1352
+ if isinstance(cmd, str) and cmd:
1353
+ pending["command"] = self._sanitize_tool_argument_value(cmd)
1354
+ else:
1355
+ pending["args"] = self._sanitize_tool_argument_value(args_val)
1356
+ elif isinstance(args_val, str) and args_val.strip():
1357
+ pending["args"] = self._sanitize_tool_argument_value(args_val)
1358
+
1359
+ self._pending_exec_starts[tc_id] = pending
1360
+
1361
+ def _build_combined_tool_event(self, pending: dict, payload: dict, now: str) -> str:
1362
+ """Build a combined 'tool' event from buffered toolcall_end + tool_execution_end."""
1363
+ self.message_counter += 1
1364
+ header: Dict = {
1365
+ "type": "tool",
1366
+ "datetime": now,
1367
+ "counter": f"#{self.message_counter}",
1368
+ "tool": pending.get("tool", payload.get("toolName", "")),
1369
+ }
1370
+
1371
+ # Args from buffered toolcall/tool_execution_start
1372
+ if "command" in pending:
1373
+ header["command"] = pending["command"]
1374
+ elif "args" in pending:
1375
+ header["args"] = pending["args"]
1376
+
1377
+ # Execution time (source of truth: tool_execution_start -> tool_execution_end)
1378
+ execution_time = self._format_execution_time(payload, pending)
1379
+ if execution_time:
1380
+ header["execution_time"] = execution_time
1381
+
1382
+ is_error = payload.get("isError", False)
1383
+ if is_error:
1384
+ header["isError"] = True
1385
+
1386
+ # Result extraction (handles string, dict with content array, and list)
1387
+ result_val = payload.get("result")
1388
+ result_text = None
1389
+ if isinstance(result_val, str) and result_val.strip():
1390
+ result_text = self._truncate_tool_result_text(result_val)
1391
+ elif isinstance(result_val, dict):
1392
+ result_content = result_val.get("content")
1393
+ if isinstance(result_content, list):
1394
+ for rc_item in result_content:
1395
+ if isinstance(rc_item, dict) and rc_item.get("type") == "text":
1396
+ result_text = self._truncate_tool_result_text(rc_item.get("text", ""))
1397
+ break
1398
+ if result_text is None:
1399
+ result_text = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1400
+ elif isinstance(result_val, list):
1401
+ result_text = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1402
+
1403
+ if result_text:
1404
+ colorize_error = self._color_enabled() and bool(is_error)
1405
+ if "\n" in result_text or colorize_error:
1406
+ label = "result:"
1407
+ colored_text = self._colorize_result(result_text, is_error=bool(is_error))
1408
+ if colorize_error:
1409
+ label = self._colorize_result(label, is_error=True)
1410
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored_text
1411
+ header["result"] = result_text
1412
+
1413
+ return self._format_tool_invocation_header(header)
1414
+
1094
1415
  def _format_event_pretty(self, payload: dict) -> Optional[str]:
1095
1416
  """
1096
1417
  Format a Pi JSON streaming event for human-readable output.
@@ -1099,31 +1420,41 @@ Model shorthands:
1099
1420
  try:
1100
1421
  event_type = payload.get("type", "")
1101
1422
  now = datetime.now().strftime("%I:%M:%S %p")
1102
- self.message_counter += 1
1103
1423
 
1424
+ # Counter is only added to *_end events (below, per-branch)
1104
1425
  header: Dict = {
1105
1426
  "type": event_type,
1106
1427
  "datetime": now,
1107
- "counter": f"#{self.message_counter}",
1108
1428
  }
1109
1429
 
1110
- # --- Session header ---
1430
+ # --- Session header (no counter) ---
1111
1431
  if event_type == "session":
1112
1432
  header["version"] = payload.get("version")
1113
1433
  header["id"] = payload.get("id")
1114
1434
  return json.dumps(header, ensure_ascii=False)
1115
1435
 
1116
- # --- Agent lifecycle events ---
1117
- if event_type in ("agent_start", "turn_start"):
1436
+ # --- turn_start: suppress (no user-visible value) ---
1437
+ if event_type == "turn_start":
1438
+ return None
1439
+
1440
+ # --- agent_start: simple header (no counter) ---
1441
+ if event_type == "agent_start":
1118
1442
  return json.dumps(header, ensure_ascii=False)
1119
1443
 
1120
1444
  if event_type == "agent_end":
1445
+ self.message_counter += 1
1446
+ header["counter"] = f"#{self.message_counter}"
1121
1447
  messages = payload.get("messages")
1122
1448
  if isinstance(messages, list):
1123
1449
  header["message_count"] = len(messages)
1450
+ total_cost_usd = self._extract_total_cost_usd(payload)
1451
+ if total_cost_usd is not None:
1452
+ header["total_cost_usd"] = total_cost_usd
1124
1453
  return json.dumps(header, ensure_ascii=False)
1125
1454
 
1126
1455
  if event_type == "turn_end":
1456
+ self.message_counter += 1
1457
+ header["counter"] = f"#{self.message_counter}"
1127
1458
  tool_results = payload.get("toolResults")
1128
1459
  if isinstance(tool_results, list):
1129
1460
  header["tool_results_count"] = len(tool_results)
@@ -1146,6 +1477,43 @@ Model shorthands:
1146
1477
  if event_subtype in self._PI_HIDDEN_MESSAGE_UPDATE_EVENTS:
1147
1478
  return None # Suppress noisy streaming deltas
1148
1479
 
1480
+ # toolcall_end: buffer for grouping with tool_execution_end
1481
+ if isinstance(ame, dict) and ame_type == "toolcall_end":
1482
+ tool_call = ame.get("toolCall", {})
1483
+ if self._buffer_tool_call_end(tool_call, now):
1484
+ return None # suppress — will emit combined event on tool_execution_end
1485
+ # No toolCallId — fallback to original format
1486
+ self.message_counter += 1
1487
+ header["counter"] = f"#{self.message_counter}"
1488
+ header["event"] = ame_type
1489
+ if isinstance(tool_call, dict):
1490
+ header["tool"] = tool_call.get("name", "")
1491
+ args = tool_call.get("arguments", {})
1492
+ if isinstance(args, dict):
1493
+ cmd = args.get("command", "")
1494
+ if isinstance(cmd, str) and cmd:
1495
+ header["command"] = self._sanitize_tool_argument_value(cmd)
1496
+ else:
1497
+ header["args"] = self._sanitize_tool_argument_value(args)
1498
+ elif isinstance(args, str) and args.strip():
1499
+ header["args"] = self._sanitize_tool_argument_value(args)
1500
+ return self._format_tool_invocation_header(header)
1501
+
1502
+ # thinking_end: show thinking content (*_end → gets counter)
1503
+ if isinstance(ame, dict) and ame_type == "thinking_end":
1504
+ self.message_counter += 1
1505
+ header["counter"] = f"#{self.message_counter}"
1506
+ header["event"] = ame_type
1507
+ thinking_text = ame.get("thinking", "") or ame.get("content", "") or ame.get("text", "")
1508
+ if isinstance(thinking_text, str) and thinking_text.strip():
1509
+ header["thinking"] = thinking_text
1510
+ return json.dumps(header, ensure_ascii=False)
1511
+
1512
+ # Any other *_end subtypes (e.g. text_end) get counter
1513
+ if isinstance(ame, dict) and ame_type and ame_type.endswith("_end"):
1514
+ self.message_counter += 1
1515
+ header["counter"] = f"#{self.message_counter}"
1516
+
1149
1517
  message = payload.get("message", {})
1150
1518
  text = self._extract_text_from_message(message) if isinstance(message, dict) else ""
1151
1519
 
@@ -1165,61 +1533,103 @@ Model shorthands:
1165
1533
  return json.dumps(header, ensure_ascii=False)
1166
1534
 
1167
1535
  if event_type == "message_end":
1536
+ self.message_counter += 1
1537
+ header["counter"] = f"#{self.message_counter}"
1168
1538
  # Skip message text - already displayed by text_end/thinking_end/toolcall_end
1169
1539
  return json.dumps(header, ensure_ascii=False)
1170
1540
 
1171
1541
  # --- Tool execution events ---
1542
+ # Always suppress tool_execution_start: buffer its args for
1543
+ # tool_execution_end to use. The user sees nothing until the
1544
+ # tool finishes, then gets a single combined "tool" event.
1172
1545
  if event_type == "tool_execution_start":
1173
- header["tool"] = payload.get("toolName", "")
1174
- tool_call_id = payload.get("toolCallId")
1175
- if tool_call_id:
1176
- header["id"] = tool_call_id
1177
- args_val = payload.get("args")
1178
- if isinstance(args_val, dict):
1179
- # Show abbreviated args inline
1180
- args_str = json.dumps(args_val, ensure_ascii=False)
1181
- if len(args_str) > 200:
1182
- # Truncate for readability
1183
- header["args"] = args_str[:200] + "..."
1184
- else:
1185
- header["args"] = args_val
1186
- elif isinstance(args_val, str) and args_val.strip():
1187
- if "\n" in args_val:
1188
- return json.dumps(header, ensure_ascii=False) + "\nargs:\n" + args_val
1189
- header["args"] = args_val
1190
- return json.dumps(header, ensure_ascii=False)
1546
+ self._buffer_exec_start(payload)
1547
+ self._in_tool_execution = True
1548
+ return None
1191
1549
 
1192
1550
  if event_type == "tool_execution_update":
1193
- header["tool"] = payload.get("toolName", "")
1194
- tool_call_id = payload.get("toolCallId")
1195
- if tool_call_id:
1196
- header["id"] = tool_call_id
1197
- partial = payload.get("partialResult")
1198
- if isinstance(partial, str) and partial.strip():
1199
- if "\n" in partial:
1200
- return json.dumps(header, ensure_ascii=False) + "\npartialResult:\n" + partial
1201
- header["partialResult"] = partial
1202
- return json.dumps(header, ensure_ascii=False)
1551
+ # Suppress updates — result will arrive in tool_execution_end
1552
+ return None
1203
1553
 
1204
1554
  if event_type == "tool_execution_end":
1205
- header["tool"] = payload.get("toolName", "")
1555
+ self._in_tool_execution = False
1206
1556
  tool_call_id = payload.get("toolCallId")
1207
- if tool_call_id:
1208
- header["id"] = tool_call_id
1557
+
1558
+ pending_tool = self._pending_tool_calls.pop(tool_call_id, None) if tool_call_id else None
1559
+ pending_exec = self._pending_exec_starts.pop(tool_call_id, None) if tool_call_id else None
1560
+ if pending_tool and pending_exec and "started_at" in pending_exec:
1561
+ pending_tool["started_at"] = pending_exec["started_at"]
1562
+ pending = pending_tool or pending_exec
1563
+
1564
+ if pending:
1565
+ return self._build_combined_tool_event(pending, payload, now)
1566
+
1567
+ # No buffered data at all — minimal fallback
1568
+ self.message_counter += 1
1569
+ header["type"] = "tool"
1570
+ header["counter"] = f"#{self.message_counter}"
1571
+ header["tool"] = payload.get("toolName", "")
1572
+
1573
+ execution_time = self._format_execution_time(payload)
1574
+ if execution_time:
1575
+ header["execution_time"] = execution_time
1576
+
1209
1577
  is_error = payload.get("isError", False)
1210
1578
  if is_error:
1211
1579
  header["isError"] = True
1580
+
1212
1581
  result_val = payload.get("result")
1582
+ colorize_error = self._color_enabled() and bool(is_error)
1583
+
1213
1584
  if isinstance(result_val, str) and result_val.strip():
1214
- if "\n" in result_val:
1215
- return json.dumps(header, ensure_ascii=False) + "\nresult:\n" + result_val
1216
- header["result"] = result_val
1217
- elif isinstance(result_val, (dict, list)):
1218
- result_str = json.dumps(result_val, ensure_ascii=False)
1219
- if "\n" in result_str or len(result_str) > 200:
1220
- return json.dumps(header, ensure_ascii=False) + "\nresult:\n" + result_str
1221
- header["result"] = result_val
1222
- return json.dumps(header, ensure_ascii=False)
1585
+ truncated = self._truncate_tool_result_text(result_val)
1586
+ if "\n" in truncated or colorize_error:
1587
+ label = "result:"
1588
+ colored = self._colorize_result(truncated, is_error=bool(is_error))
1589
+ if colorize_error:
1590
+ label = self._colorize_result(label, is_error=True)
1591
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
1592
+ header["result"] = truncated
1593
+ return self._format_tool_invocation_header(header)
1594
+
1595
+ if isinstance(result_val, dict):
1596
+ result_content = result_val.get("content")
1597
+ if isinstance(result_content, list):
1598
+ for rc_item in result_content:
1599
+ if isinstance(rc_item, dict) and rc_item.get("type") == "text":
1600
+ text = rc_item.get("text", "")
1601
+ truncated = self._truncate_tool_result_text(text)
1602
+ if "\n" in truncated or colorize_error:
1603
+ label = "result:"
1604
+ colored = self._colorize_result(truncated, is_error=bool(is_error))
1605
+ if colorize_error:
1606
+ label = self._colorize_result(label, is_error=True)
1607
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
1608
+ header["result"] = truncated
1609
+ return self._format_tool_invocation_header(header)
1610
+
1611
+ result_str = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1612
+ if "\n" in result_str or len(result_str) > 200 or colorize_error:
1613
+ label = "result:"
1614
+ colored = self._colorize_result(result_str, is_error=bool(is_error))
1615
+ if colorize_error:
1616
+ label = self._colorize_result(label, is_error=True)
1617
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
1618
+ header["result"] = result_str
1619
+ return self._format_tool_invocation_header(header)
1620
+
1621
+ if isinstance(result_val, list):
1622
+ result_str = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1623
+ if "\n" in result_str or len(result_str) > 200 or colorize_error:
1624
+ label = "result:"
1625
+ colored = self._colorize_result(result_str, is_error=bool(is_error))
1626
+ if colorize_error:
1627
+ label = self._colorize_result(label, is_error=True)
1628
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored
1629
+ header["result"] = result_str
1630
+ return self._format_tool_invocation_header(header)
1631
+
1632
+ return self._format_tool_invocation_header(header)
1223
1633
 
1224
1634
  # --- Retry/compaction events ---
1225
1635
  if event_type == "auto_retry_start":
@@ -1232,6 +1642,8 @@ Model shorthands:
1232
1642
  return json.dumps(header, ensure_ascii=False)
1233
1643
 
1234
1644
  if event_type == "auto_retry_end":
1645
+ self.message_counter += 1
1646
+ header["counter"] = f"#{self.message_counter}"
1235
1647
  header["success"] = payload.get("success")
1236
1648
  header["attempt"] = payload.get("attempt")
1237
1649
  final_err = payload.get("finalError")
@@ -1277,7 +1689,7 @@ Model shorthands:
1277
1689
  return delta
1278
1690
  return ""
1279
1691
 
1280
- # Section start markers
1692
+ # Section start markers (no counter — only *_end events get counters)
1281
1693
  if ame_type == "text_start":
1282
1694
  return json.dumps({"type": "text_start", "datetime": now}) + "\n"
1283
1695
 
@@ -1286,26 +1698,33 @@ Model shorthands:
1286
1698
 
1287
1699
  # Section end markers (text was already streamed)
1288
1700
  if ame_type == "text_end":
1289
- return "\n" + json.dumps({"type": "text_end", "datetime": now}) + "\n"
1701
+ self.message_counter += 1
1702
+ return "\n" + json.dumps({"type": "text_end", "datetime": now, "counter": f"#{self.message_counter}"}) + "\n"
1290
1703
 
1291
1704
  if ame_type == "thinking_end":
1292
- return "\n" + json.dumps({"type": "thinking_end", "datetime": now}) + "\n"
1705
+ self.message_counter += 1
1706
+ return "\n" + json.dumps({"type": "thinking_end", "datetime": now, "counter": f"#{self.message_counter}"}) + "\n"
1293
1707
 
1294
- # Tool call end: show tool info
1708
+ # Tool call end: buffer for grouping with tool_execution_end
1295
1709
  if ame_type == "toolcall_end":
1296
1710
  tc = ame.get("toolCall", {})
1297
- header = {"type": "toolcall_end", "datetime": now}
1711
+ if self._buffer_tool_call_end(tc, now):
1712
+ return "" # suppress — will emit combined event on tool_execution_end
1713
+ # No toolCallId — fallback to original format
1714
+ self.message_counter += 1
1715
+ header = {"type": "toolcall_end", "datetime": now, "counter": f"#{self.message_counter}"}
1298
1716
  if isinstance(tc, dict):
1299
1717
  header["tool"] = tc.get("name", "")
1300
1718
  args = tc.get("arguments", {})
1301
1719
  if isinstance(args, dict):
1302
1720
  cmd = args.get("command", "")
1303
- if cmd:
1304
- header["command"] = cmd
1721
+ if isinstance(cmd, str) and cmd:
1722
+ header["command"] = self._sanitize_tool_argument_value(cmd)
1305
1723
  else:
1306
- args_str = json.dumps(args, ensure_ascii=False)
1307
- header["args"] = args_str[:200] + "..." if len(args_str) > 200 else args
1308
- return json.dumps(header, ensure_ascii=False) + "\n"
1724
+ header["args"] = self._sanitize_tool_argument_value(args)
1725
+ elif isinstance(args, str) and args.strip():
1726
+ header["args"] = self._sanitize_tool_argument_value(args)
1727
+ return self._format_tool_invocation_header(header) + "\n"
1309
1728
 
1310
1729
  # Suppress all other message_update subtypes (toolcall_start, toolcall_delta, etc.)
1311
1730
  return ""
@@ -1314,69 +1733,224 @@ Model shorthands:
1314
1733
  if event_type in ("message_start", "message_end"):
1315
1734
  return ""
1316
1735
 
1317
- # tool_execution_start
1736
+ # tool_execution_start: always suppress, buffer args
1318
1737
  if event_type == "tool_execution_start":
1319
- header = {
1320
- "type": "tool_execution_start",
1321
- "datetime": now,
1322
- "tool": parsed.get("toolName", ""),
1323
- }
1324
- args_val = parsed.get("args")
1325
- if isinstance(args_val, dict):
1326
- args_str = json.dumps(args_val, ensure_ascii=False)
1327
- if len(args_str) > 200:
1328
- header["args"] = args_str[:200] + "..."
1329
- else:
1330
- header["args"] = args_val
1331
- return json.dumps(header, ensure_ascii=False) + "\n"
1738
+ self._buffer_exec_start(parsed)
1739
+ self._in_tool_execution = True
1740
+ return "" # suppress
1332
1741
 
1333
- # tool_execution_end
1742
+ # tool_execution_end: combine with buffered data
1334
1743
  if event_type == "tool_execution_end":
1744
+ self._in_tool_execution = False
1745
+ tool_call_id = parsed.get("toolCallId")
1746
+
1747
+ pending_tool = self._pending_tool_calls.pop(tool_call_id, None) if tool_call_id else None
1748
+ pending_exec = self._pending_exec_starts.pop(tool_call_id, None) if tool_call_id else None
1749
+ if pending_tool and pending_exec and "started_at" in pending_exec:
1750
+ pending_tool["started_at"] = pending_exec["started_at"]
1751
+ pending = pending_tool or pending_exec
1752
+
1753
+ if pending:
1754
+ return self._build_combined_tool_event(pending, parsed, now) + "\n"
1755
+
1756
+ # No buffered data — minimal fallback
1757
+ self.message_counter += 1
1335
1758
  header = {
1336
- "type": "tool_execution_end",
1759
+ "type": "tool",
1337
1760
  "datetime": now,
1761
+ "counter": f"#{self.message_counter}",
1338
1762
  "tool": parsed.get("toolName", ""),
1339
1763
  }
1764
+ execution_time = self._format_execution_time(parsed)
1765
+ if execution_time:
1766
+ header["execution_time"] = execution_time
1767
+
1340
1768
  is_error = parsed.get("isError", False)
1341
1769
  if is_error:
1342
1770
  header["isError"] = True
1771
+
1343
1772
  result_val = parsed.get("result")
1773
+ colorize_error = self._color_enabled() and bool(is_error)
1774
+
1344
1775
  if isinstance(result_val, str) and result_val.strip():
1345
1776
  truncated = self._truncate_tool_result_text(result_val)
1346
- if "\n" in truncated:
1347
- return json.dumps(header, ensure_ascii=False) + "\nresult:\n" + truncated + "\n"
1777
+ if "\n" in truncated or colorize_error:
1778
+ label = "result:"
1779
+ colored = self._colorize_result(truncated, is_error=bool(is_error))
1780
+ if colorize_error:
1781
+ label = self._colorize_result(label, is_error=True)
1782
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored + "\n"
1348
1783
  header["result"] = truncated
1349
- elif isinstance(result_val, dict):
1784
+ return self._format_tool_invocation_header(header) + "\n"
1785
+
1786
+ if isinstance(result_val, dict):
1350
1787
  result_content = result_val.get("content")
1351
1788
  if isinstance(result_content, list):
1352
1789
  for rc_item in result_content:
1353
1790
  if isinstance(rc_item, dict) and rc_item.get("type") == "text":
1354
1791
  text = rc_item.get("text", "")
1355
1792
  truncated = self._truncate_tool_result_text(text)
1356
- if "\n" in truncated:
1357
- return json.dumps(header, ensure_ascii=False) + "\nresult:\n" + truncated + "\n"
1793
+ if "\n" in truncated or colorize_error:
1794
+ label = "result:"
1795
+ colored = self._colorize_result(truncated, is_error=bool(is_error))
1796
+ if colorize_error:
1797
+ label = self._colorize_result(label, is_error=True)
1798
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored + "\n"
1358
1799
  header["result"] = truncated
1359
- break
1360
- return json.dumps(header, ensure_ascii=False) + "\n"
1800
+ return self._format_tool_invocation_header(header) + "\n"
1801
+
1802
+ result_json = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1803
+ if "\n" in result_json or colorize_error:
1804
+ label = "result:"
1805
+ colored = self._colorize_result(result_json, is_error=bool(is_error))
1806
+ if colorize_error:
1807
+ label = self._colorize_result(label, is_error=True)
1808
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored + "\n"
1809
+ header["result"] = result_json
1810
+ return self._format_tool_invocation_header(header) + "\n"
1811
+
1812
+ if isinstance(result_val, list):
1813
+ result_json = self._strip_ansi_sequences(json.dumps(result_val, ensure_ascii=False))
1814
+ if "\n" in result_json or colorize_error:
1815
+ label = "result:"
1816
+ colored = self._colorize_result(result_json, is_error=bool(is_error))
1817
+ if colorize_error:
1818
+ label = self._colorize_result(label, is_error=True)
1819
+ return self._format_tool_invocation_header(header) + "\n" + label + "\n" + colored + "\n"
1820
+ header["result"] = result_json
1821
+ return self._format_tool_invocation_header(header) + "\n"
1822
+
1823
+ return self._format_tool_invocation_header(header) + "\n"
1361
1824
 
1362
1825
  # turn_end: metadata only
1363
1826
  if event_type == "turn_end":
1364
- header = {"type": "turn_end", "datetime": now}
1827
+ self.message_counter += 1
1828
+ header = {"type": "turn_end", "datetime": now, "counter": f"#{self.message_counter}"}
1365
1829
  tool_results = parsed.get("toolResults")
1366
1830
  if isinstance(tool_results, list):
1367
1831
  header["tool_results_count"] = len(tool_results)
1368
1832
  return json.dumps(header, ensure_ascii=False) + "\n"
1369
1833
 
1370
- # agent_start, turn_start
1371
- if event_type in ("agent_start", "turn_start"):
1834
+ # turn_start: suppress (no user-visible value)
1835
+ if event_type == "turn_start":
1836
+ return ""
1837
+
1838
+ # agent_start (no counter — only *_end events get counters)
1839
+ if event_type == "agent_start":
1372
1840
  return json.dumps({"type": event_type, "datetime": now}) + "\n"
1373
1841
 
1374
1842
  # agent_end
1375
1843
  if event_type == "agent_end":
1376
- header = {"type": "agent_end", "datetime": now}
1844
+ self.message_counter += 1
1845
+ header = {"type": "agent_end", "datetime": now, "counter": f"#{self.message_counter}"}
1377
1846
  messages = parsed.get("messages")
1378
1847
  if isinstance(messages, list):
1379
1848
  header["message_count"] = len(messages)
1849
+ total_cost_usd = self._extract_total_cost_usd(parsed)
1850
+ if total_cost_usd is not None:
1851
+ header["total_cost_usd"] = total_cost_usd
1852
+ return json.dumps(header, ensure_ascii=False) + "\n"
1853
+
1854
+ # --- Role-based messages (Pi-wrapped Codex messages) ---
1855
+ role = parsed.get("role", "")
1856
+ if role == "toolResult":
1857
+ self.message_counter += 1
1858
+ header = {
1859
+ "type": "toolResult",
1860
+ "datetime": now,
1861
+ "counter": f"#{self.message_counter}",
1862
+ "toolName": parsed.get("toolName", ""),
1863
+ }
1864
+ is_error = parsed.get("isError", False)
1865
+ if is_error:
1866
+ header["isError"] = True
1867
+ content = parsed.get("content")
1868
+ if isinstance(content, list):
1869
+ for item in content:
1870
+ if isinstance(item, dict) and item.get("type") == "text":
1871
+ text_val = item.get("text", "")
1872
+ truncated = self._truncate_tool_result_text(text_val)
1873
+ use_color = self._color_enabled()
1874
+ if "\n" in truncated or use_color:
1875
+ colored = self._colorize_result(truncated, is_error=bool(is_error))
1876
+ label = self._colorize_result("content:", is_error=bool(is_error))
1877
+ return json.dumps(header, ensure_ascii=False) + "\n" + label + "\n" + colored + "\n"
1878
+ header["content"] = truncated
1879
+ return json.dumps(header, ensure_ascii=False) + "\n"
1880
+ return json.dumps(header, ensure_ascii=False) + "\n"
1881
+
1882
+ if role == "assistant":
1883
+ self.message_counter += 1
1884
+ content = parsed.get("content")
1885
+ if isinstance(content, list):
1886
+ self._strip_thinking_signature(content)
1887
+ header = {"type": "assistant", "datetime": now, "counter": f"#{self.message_counter}"}
1888
+ text_parts = []
1889
+ if isinstance(content, list):
1890
+ for item in content:
1891
+ if isinstance(item, dict):
1892
+ if item.get("type") == "text":
1893
+ text_parts.append(item.get("text", ""))
1894
+ elif item.get("type") == "thinking":
1895
+ text_parts.append(f"[thinking] {item.get('thinking', '')}")
1896
+ elif item.get("type") == "toolCall":
1897
+ name = item.get("name", "")
1898
+ args = item.get("arguments", {})
1899
+ cmd = args.get("command", "") if isinstance(args, dict) else ""
1900
+ text_parts.append(f"[toolCall] {name}: {cmd}" if cmd else f"[toolCall] {name}")
1901
+ if text_parts:
1902
+ combined = "\n".join(text_parts)
1903
+ if "\n" in combined:
1904
+ return json.dumps(header, ensure_ascii=False) + "\n" + combined + "\n"
1905
+ header["content"] = combined
1906
+ return json.dumps(header, ensure_ascii=False) + "\n"
1907
+
1908
+ if role:
1909
+ # Other roles — minimal JSON header
1910
+ self.message_counter += 1
1911
+ return json.dumps({"type": role, "datetime": now, "counter": f"#{self.message_counter}"}, ensure_ascii=False) + "\n"
1912
+
1913
+ # --- Native Codex events (agent_reasoning, agent_message, exec_command_end, etc.) ---
1914
+ msg_type, payload, outer_type = self._normalize_codex_event(parsed)
1915
+
1916
+ if msg_type in ("agent_reasoning", "reasoning"):
1917
+ self.message_counter += 1
1918
+ content = self._extract_reasoning_text(payload)
1919
+ header = {"type": msg_type, "datetime": now, "counter": f"#{self.message_counter}"}
1920
+ if "\n" in content:
1921
+ return json.dumps(header, ensure_ascii=False) + "\ntext:\n" + content + "\n"
1922
+ if content:
1923
+ header["text"] = content
1924
+ return json.dumps(header, ensure_ascii=False) + "\n"
1925
+
1926
+ if msg_type in ("agent_message", "assistant_message"):
1927
+ self.message_counter += 1
1928
+ content = self._extract_message_text_codex(payload)
1929
+ header = {"type": msg_type, "datetime": now, "counter": f"#{self.message_counter}"}
1930
+ if "\n" in content:
1931
+ return json.dumps(header, ensure_ascii=False) + "\nmessage:\n" + content + "\n"
1932
+ if content:
1933
+ header["message"] = content
1934
+ return json.dumps(header, ensure_ascii=False) + "\n"
1935
+
1936
+ if msg_type == "exec_command_end":
1937
+ self.message_counter += 1
1938
+ formatted_output = payload.get("formatted_output", "") if isinstance(payload, dict) else ""
1939
+ header = {"type": msg_type, "datetime": now, "counter": f"#{self.message_counter}"}
1940
+ if "\n" in formatted_output:
1941
+ return json.dumps(header, ensure_ascii=False) + "\nformatted_output:\n" + formatted_output + "\n"
1942
+ if formatted_output:
1943
+ header["formatted_output"] = formatted_output
1944
+ return json.dumps(header, ensure_ascii=False) + "\n"
1945
+
1946
+ if msg_type == "command_execution":
1947
+ self.message_counter += 1
1948
+ aggregated_output = self._extract_command_output_text(payload)
1949
+ header = {"type": msg_type, "datetime": now, "counter": f"#{self.message_counter}"}
1950
+ if "\n" in aggregated_output:
1951
+ return json.dumps(header, ensure_ascii=False) + "\naggregated_output:\n" + aggregated_output + "\n"
1952
+ if aggregated_output:
1953
+ header["aggregated_output"] = aggregated_output
1380
1954
  return json.dumps(header, ensure_ascii=False) + "\n"
1381
1955
 
1382
1956
  # Fallback: not handled
@@ -1392,23 +1966,778 @@ Model shorthands:
1392
1966
  hide_types.update(parts)
1393
1967
  return hide_types
1394
1968
 
1969
+ @staticmethod
1970
+ def _toolcall_end_delay_seconds() -> float:
1971
+ """Return delay for fallback toolcall_end visibility (default 3s)."""
1972
+ raw = os.environ.get("PI_TOOLCALL_END_DELAY_SECONDS", "3")
1973
+ try:
1974
+ delay = float(raw)
1975
+ except (TypeError, ValueError):
1976
+ delay = 3.0
1977
+ return max(0.0, delay)
1978
+
1395
1979
  @staticmethod
1396
1980
  def _sanitize_sub_agent_response(event: dict) -> dict:
1397
1981
  """Strip bulky fields (messages, type) from sub_agent_response to reduce token usage."""
1398
1982
  return {k: v for k, v in event.items() if k not in ("messages", "type")}
1399
1983
 
1984
+ def _reset_run_cost_tracking(self) -> None:
1985
+ """Reset per-run usage/cost accumulation state."""
1986
+ self._run_usage_totals = None
1987
+ self._run_total_cost_usd = None
1988
+ self._run_seen_usage_keys.clear()
1989
+
1990
+ @staticmethod
1991
+ def _is_numeric_value(value: object) -> bool:
1992
+ """True for int/float values (excluding bool)."""
1993
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
1994
+
1995
+ @staticmethod
1996
+ def _normalize_usage_payload(usage: dict) -> Optional[dict]:
1997
+ """Normalize usage payload into numeric totals for accumulation."""
1998
+ if not isinstance(usage, dict):
1999
+ return None
2000
+
2001
+ usage_cost = usage.get("cost")
2002
+ cost_payload = usage_cost if isinstance(usage_cost, dict) else {}
2003
+
2004
+ input_tokens = float(usage.get("input")) if PiService._is_numeric_value(usage.get("input")) else 0.0
2005
+ output_tokens = float(usage.get("output")) if PiService._is_numeric_value(usage.get("output")) else 0.0
2006
+ cache_read_tokens = float(usage.get("cacheRead")) if PiService._is_numeric_value(usage.get("cacheRead")) else 0.0
2007
+ cache_write_tokens = float(usage.get("cacheWrite")) if PiService._is_numeric_value(usage.get("cacheWrite")) else 0.0
2008
+
2009
+ total_tokens_raw = usage.get("totalTokens")
2010
+ total_tokens = (
2011
+ float(total_tokens_raw)
2012
+ if PiService._is_numeric_value(total_tokens_raw)
2013
+ else input_tokens + output_tokens + cache_read_tokens + cache_write_tokens
2014
+ )
2015
+
2016
+ cost_input = float(cost_payload.get("input")) if PiService._is_numeric_value(cost_payload.get("input")) else 0.0
2017
+ cost_output = float(cost_payload.get("output")) if PiService._is_numeric_value(cost_payload.get("output")) else 0.0
2018
+ cost_cache_read = (
2019
+ float(cost_payload.get("cacheRead")) if PiService._is_numeric_value(cost_payload.get("cacheRead")) else 0.0
2020
+ )
2021
+ cost_cache_write = (
2022
+ float(cost_payload.get("cacheWrite")) if PiService._is_numeric_value(cost_payload.get("cacheWrite")) else 0.0
2023
+ )
2024
+
2025
+ cost_total_raw = cost_payload.get("total")
2026
+ cost_total = (
2027
+ float(cost_total_raw)
2028
+ if PiService._is_numeric_value(cost_total_raw)
2029
+ else cost_input + cost_output + cost_cache_read + cost_cache_write
2030
+ )
2031
+
2032
+ has_any_value = any(
2033
+ PiService._is_numeric_value(v)
2034
+ for v in (
2035
+ usage.get("input"),
2036
+ usage.get("output"),
2037
+ usage.get("cacheRead"),
2038
+ usage.get("cacheWrite"),
2039
+ usage.get("totalTokens"),
2040
+ cost_payload.get("input"),
2041
+ cost_payload.get("output"),
2042
+ cost_payload.get("cacheRead"),
2043
+ cost_payload.get("cacheWrite"),
2044
+ cost_payload.get("total"),
2045
+ )
2046
+ )
2047
+
2048
+ if not has_any_value:
2049
+ return None
2050
+
2051
+ return {
2052
+ "input": input_tokens,
2053
+ "output": output_tokens,
2054
+ "cacheRead": cache_read_tokens,
2055
+ "cacheWrite": cache_write_tokens,
2056
+ "totalTokens": total_tokens,
2057
+ "cost": {
2058
+ "input": cost_input,
2059
+ "output": cost_output,
2060
+ "cacheRead": cost_cache_read,
2061
+ "cacheWrite": cost_cache_write,
2062
+ "total": cost_total,
2063
+ },
2064
+ }
2065
+
2066
+ @staticmethod
2067
+ def _merge_usage_payloads(base: Optional[dict], delta: Optional[dict]) -> Optional[dict]:
2068
+ """Merge normalized usage payloads by summing token/cost fields."""
2069
+ if not isinstance(base, dict):
2070
+ return delta
2071
+ if not isinstance(delta, dict):
2072
+ return base
2073
+
2074
+ base_cost = base.get("cost") if isinstance(base.get("cost"), dict) else {}
2075
+ delta_cost = delta.get("cost") if isinstance(delta.get("cost"), dict) else {}
2076
+
2077
+ return {
2078
+ "input": float(base.get("input", 0.0)) + float(delta.get("input", 0.0)),
2079
+ "output": float(base.get("output", 0.0)) + float(delta.get("output", 0.0)),
2080
+ "cacheRead": float(base.get("cacheRead", 0.0)) + float(delta.get("cacheRead", 0.0)),
2081
+ "cacheWrite": float(base.get("cacheWrite", 0.0)) + float(delta.get("cacheWrite", 0.0)),
2082
+ "totalTokens": float(base.get("totalTokens", 0.0)) + float(delta.get("totalTokens", 0.0)),
2083
+ "cost": {
2084
+ "input": float(base_cost.get("input", 0.0)) + float(delta_cost.get("input", 0.0)),
2085
+ "output": float(base_cost.get("output", 0.0)) + float(delta_cost.get("output", 0.0)),
2086
+ "cacheRead": float(base_cost.get("cacheRead", 0.0)) + float(delta_cost.get("cacheRead", 0.0)),
2087
+ "cacheWrite": float(base_cost.get("cacheWrite", 0.0)) + float(delta_cost.get("cacheWrite", 0.0)),
2088
+ "total": float(base_cost.get("total", 0.0)) + float(delta_cost.get("total", 0.0)),
2089
+ },
2090
+ }
2091
+
2092
+ @staticmethod
2093
+ def _aggregate_assistant_usages(messages: list) -> Optional[dict]:
2094
+ """Aggregate assistant usage payloads from an event messages array."""
2095
+ if not isinstance(messages, list):
2096
+ return None
2097
+
2098
+ assistant_usages: List[dict] = []
2099
+ for msg in messages:
2100
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
2101
+ usage = msg.get("usage")
2102
+ if isinstance(usage, dict):
2103
+ assistant_usages.append(usage)
2104
+
2105
+ if not assistant_usages:
2106
+ return None
2107
+ if len(assistant_usages) == 1:
2108
+ return assistant_usages[0]
2109
+
2110
+ totals: Optional[dict] = None
2111
+ for usage in assistant_usages:
2112
+ normalized = PiService._normalize_usage_payload(usage)
2113
+ totals = PiService._merge_usage_payloads(totals, normalized)
2114
+
2115
+ return totals
2116
+
2117
+ def _assistant_usage_dedupe_key(self, message: dict, usage: dict) -> Optional[str]:
2118
+ """Build a stable dedupe key for assistant usage seen across message/turn_end events."""
2119
+ if not isinstance(message, dict) or not isinstance(usage, dict):
2120
+ return None
2121
+
2122
+ for id_key in ("id", "messageId", "message_id"):
2123
+ value = message.get(id_key)
2124
+ if isinstance(value, str) and value.strip():
2125
+ return f"id:{value.strip()}"
2126
+
2127
+ timestamp = message.get("timestamp")
2128
+ if self._is_numeric_value(timestamp):
2129
+ return f"ts:{int(float(timestamp))}"
2130
+ if isinstance(timestamp, str) and timestamp.strip():
2131
+ return f"ts:{timestamp.strip()}"
2132
+
2133
+ usage_cost = usage.get("cost") if isinstance(usage.get("cost"), dict) else {}
2134
+ signature: Dict[str, object] = {
2135
+ "stopReason": message.get("stopReason") if isinstance(message.get("stopReason"), str) else "",
2136
+ "input": usage.get("input", 0.0),
2137
+ "output": usage.get("output", 0.0),
2138
+ "cacheRead": usage.get("cacheRead", 0.0),
2139
+ "cacheWrite": usage.get("cacheWrite", 0.0),
2140
+ "totalTokens": usage.get("totalTokens", 0.0),
2141
+ "costTotal": usage_cost.get("total", 0.0),
2142
+ }
2143
+
2144
+ text = self._extract_text_from_message(message)
2145
+ if text:
2146
+ signature["text"] = text[:120]
2147
+
2148
+ return "sig:" + json.dumps(signature, sort_keys=True, ensure_ascii=False)
2149
+
2150
+ def _track_assistant_usage_from_event(self, event: dict) -> None:
2151
+ """Accumulate per-run assistant usage from stream events."""
2152
+ if not isinstance(event, dict):
2153
+ return
2154
+
2155
+ event_type = event.get("type")
2156
+ if event_type not in ("message", "message_end", "turn_end"):
2157
+ return
2158
+
2159
+ message = event.get("message")
2160
+ if not isinstance(message, dict) or message.get("role") != "assistant":
2161
+ return
2162
+
2163
+ normalized_usage = self._normalize_usage_payload(message.get("usage"))
2164
+ if not isinstance(normalized_usage, dict):
2165
+ return
2166
+
2167
+ usage_key = self._assistant_usage_dedupe_key(message, normalized_usage)
2168
+ if usage_key and usage_key in self._run_seen_usage_keys:
2169
+ return
2170
+ if usage_key:
2171
+ self._run_seen_usage_keys.add(usage_key)
2172
+
2173
+ self._run_usage_totals = self._merge_usage_payloads(self._run_usage_totals, normalized_usage)
2174
+ self._run_total_cost_usd = self._extract_total_cost_usd(
2175
+ {"usage": self._run_usage_totals},
2176
+ self._run_usage_totals,
2177
+ )
2178
+
2179
+ def _get_accumulated_total_cost_usd(self) -> Optional[float]:
2180
+ """Return accumulated per-run total cost when available."""
2181
+ if self._is_numeric_value(self._run_total_cost_usd):
2182
+ return float(self._run_total_cost_usd)
2183
+ if isinstance(self._run_usage_totals, dict):
2184
+ return self._extract_total_cost_usd({"usage": self._run_usage_totals}, self._run_usage_totals)
2185
+ return None
2186
+
2187
+ @staticmethod
2188
+ def _extract_usage_from_event(event: dict) -> Optional[dict]:
2189
+ """Extract usage payload from Pi event shapes (event/message/messages)."""
2190
+ if not isinstance(event, dict):
2191
+ return None
2192
+
2193
+ messages = event.get("messages")
2194
+ if event.get("type") == "agent_end" and isinstance(messages, list):
2195
+ aggregated = PiService._aggregate_assistant_usages(messages)
2196
+ if isinstance(aggregated, dict):
2197
+ return aggregated
2198
+
2199
+ direct_usage = event.get("usage")
2200
+ if isinstance(direct_usage, dict):
2201
+ return direct_usage
2202
+
2203
+ message = event.get("message")
2204
+ if isinstance(message, dict):
2205
+ message_usage = message.get("usage")
2206
+ if isinstance(message_usage, dict):
2207
+ return message_usage
2208
+
2209
+ if isinstance(messages, list):
2210
+ aggregated = PiService._aggregate_assistant_usages(messages)
2211
+ if isinstance(aggregated, dict):
2212
+ return aggregated
2213
+
2214
+ return None
2215
+
2216
+ @staticmethod
2217
+ def _extract_total_cost_usd(event: dict, usage: Optional[dict] = None) -> Optional[float]:
2218
+ """Extract total USD cost from explicit fields or usage.cost.total."""
2219
+ if not isinstance(event, dict):
2220
+ return None
2221
+
2222
+ for key in ("total_cost_usd", "totalCostUsd", "totalCostUSD"):
2223
+ value = event.get(key)
2224
+ if PiService._is_numeric_value(value):
2225
+ return float(value)
2226
+
2227
+ direct_cost = event.get("cost")
2228
+ if PiService._is_numeric_value(direct_cost):
2229
+ return float(direct_cost)
2230
+ if isinstance(direct_cost, dict):
2231
+ total = direct_cost.get("total")
2232
+ if PiService._is_numeric_value(total):
2233
+ return float(total)
2234
+
2235
+ usage_payload = usage if isinstance(usage, dict) else None
2236
+ if usage_payload is None:
2237
+ usage_payload = PiService._extract_usage_from_event(event)
2238
+
2239
+ if isinstance(usage_payload, dict):
2240
+ usage_cost = usage_payload.get("cost")
2241
+ if isinstance(usage_cost, dict):
2242
+ total = usage_cost.get("total")
2243
+ if PiService._is_numeric_value(total):
2244
+ return float(total)
2245
+
2246
+ return None
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
+
2399
+ def _build_success_result_event(self, text: str, event: dict) -> dict:
2400
+ """Build standardized success envelope for shell-backend capture."""
2401
+ usage = self._extract_usage_from_event(event)
2402
+ if isinstance(self._run_usage_totals, dict):
2403
+ usage = self._run_usage_totals
2404
+
2405
+ total_cost_usd = self._extract_total_cost_usd(event, usage)
2406
+ accumulated_total_cost = self._get_accumulated_total_cost_usd()
2407
+ if accumulated_total_cost is not None:
2408
+ total_cost_usd = accumulated_total_cost
2409
+
2410
+ result_event: Dict = {
2411
+ "type": "result",
2412
+ "subtype": "success",
2413
+ "is_error": False,
2414
+ "result": text,
2415
+ "session_id": self.session_id,
2416
+ "sub_agent_response": self._sanitize_sub_agent_response(event),
2417
+ }
2418
+
2419
+ if isinstance(usage, dict):
2420
+ result_event["usage"] = usage
2421
+ if total_cost_usd is not None:
2422
+ result_event["total_cost_usd"] = total_cost_usd
2423
+
2424
+ return result_event
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
+
1400
2444
  def _write_capture_file(self, capture_path: Optional[str]) -> None:
1401
2445
  """Write final result event to capture file for shell backend."""
1402
2446
  if not capture_path or not self.last_result_event:
1403
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
+
1404
2481
  try:
1405
2482
  Path(capture_path).write_text(
1406
- json.dumps(self.last_result_event, ensure_ascii=False),
2483
+ json.dumps(payload, ensure_ascii=False),
1407
2484
  encoding="utf-8",
1408
2485
  )
1409
2486
  except Exception as e:
1410
2487
  print(f"Warning: Could not write capture file: {e}", file=sys.stderr)
1411
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
+
1412
2741
  def run_pi(self, cmd: List[str], args: argparse.Namespace,
1413
2742
  stdin_prompt: Optional[str] = None) -> int:
1414
2743
  """Execute the Pi CLI and stream/format its JSON output.
@@ -1423,6 +2752,14 @@ Model shorthands:
1423
2752
  pretty = args.pretty.lower() != "false"
1424
2753
  capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
1425
2754
  hide_types = self._build_hide_types()
2755
+ self._buffered_tool_stdout_lines.clear()
2756
+ self._reset_run_cost_tracking()
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()
1426
2763
 
1427
2764
  if verbose:
1428
2765
  # Truncate prompt in display to avoid confusing multi-line output
@@ -1454,7 +2791,80 @@ Model shorthands:
1454
2791
  print(f"Executing: {' '.join(display_cmd)}", file=sys.stderr)
1455
2792
  print("-" * 80, file=sys.stderr)
1456
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
+
1457
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
+
1458
2868
  process = subprocess.Popen(
1459
2869
  cmd,
1460
2870
  stdin=subprocess.PIPE if stdin_prompt else subprocess.DEVNULL,
@@ -1517,18 +2927,304 @@ Model shorthands:
1517
2927
 
1518
2928
  # Stream stderr in a separate thread so Pi diagnostic output is visible
1519
2929
  def _stderr_reader():
1520
- """Read stderr and forward to our stderr for visibility."""
2930
+ """Read stderr, forward to stderr, and capture terminal error signals."""
1521
2931
  try:
1522
2932
  if process.stderr:
1523
2933
  for stderr_line in process.stderr:
1524
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)
1525
2940
  except (ValueError, OSError):
1526
2941
  pass
1527
2942
 
1528
2943
  stderr_thread = threading.Thread(target=_stderr_reader, daemon=True)
1529
2944
  stderr_thread.start()
1530
2945
 
2946
+ cancel_delayed_toolcalls = lambda: None
2947
+
1531
2948
  if process.stdout:
2949
+ pending_tool_execution_end: Optional[dict] = None
2950
+ pending_turn_end_after_tool: Optional[dict] = None
2951
+ toolcall_end_delay_seconds = self._toolcall_end_delay_seconds()
2952
+ pending_delayed_toolcalls: Dict[int, dict] = {}
2953
+ delayed_toolcalls_lock = threading.Lock()
2954
+ delayed_toolcall_seq = 0
2955
+
2956
+ def _extract_fallback_toolcall_name(parsed_event: dict) -> Optional[str]:
2957
+ if parsed_event.get("type") != "message_update":
2958
+ return None
2959
+ assistant_event = parsed_event.get("assistantMessageEvent")
2960
+ if not isinstance(assistant_event, dict) or assistant_event.get("type") != "toolcall_end":
2961
+ return None
2962
+ tool_call = assistant_event.get("toolCall")
2963
+ if not isinstance(tool_call, dict):
2964
+ return None
2965
+ tool_call_id = tool_call.get("toolCallId")
2966
+ if isinstance(tool_call_id, str) and tool_call_id.strip():
2967
+ return None
2968
+ name = tool_call.get("name", "")
2969
+ return name if isinstance(name, str) else ""
2970
+
2971
+ def _format_deferred_toolcall(parsed_event: dict, mode: str) -> Optional[str]:
2972
+ if mode == self.PRETTIFIER_LIVE:
2973
+ return self._format_event_live(parsed_event)
2974
+ if mode == self.PRETTIFIER_CODEX:
2975
+ return self._format_pi_codex_event(parsed_event)
2976
+ if mode == self.PRETTIFIER_CLAUDE:
2977
+ return self._format_event_pretty_claude(parsed_event)
2978
+ return self._format_event_pretty(parsed_event)
2979
+
2980
+ def _emit_stdout(formatted: str, raw: bool = False) -> None:
2981
+ if raw:
2982
+ sys.stdout.write(formatted)
2983
+ sys.stdout.flush()
2984
+ return
2985
+ print(formatted, flush=True)
2986
+
2987
+ def _schedule_delayed_toolcall(parsed_event: dict, tool_name: str, mode: str) -> None:
2988
+ nonlocal delayed_toolcall_seq
2989
+
2990
+ def _emit_delayed_toolcall(event_payload: dict, event_mode: str) -> None:
2991
+ formatted = _format_deferred_toolcall(event_payload, event_mode)
2992
+ if not formatted:
2993
+ return
2994
+ _emit_stdout(formatted, raw=event_mode == self.PRETTIFIER_LIVE)
2995
+
2996
+ if toolcall_end_delay_seconds <= 0:
2997
+ _emit_delayed_toolcall(parsed_event, mode)
2998
+ return
2999
+
3000
+ delayed_toolcall_seq += 1
3001
+ entry_id = delayed_toolcall_seq
3002
+ entry: Dict = {
3003
+ "id": entry_id,
3004
+ "tool": tool_name,
3005
+ "event": parsed_event,
3006
+ "mode": mode,
3007
+ }
3008
+
3009
+ def _timer_emit() -> None:
3010
+ with delayed_toolcalls_lock:
3011
+ pending = pending_delayed_toolcalls.pop(entry_id, None)
3012
+ if not pending:
3013
+ return
3014
+ _emit_delayed_toolcall(pending["event"], pending["mode"])
3015
+
3016
+ timer = threading.Timer(toolcall_end_delay_seconds, _timer_emit)
3017
+ timer.daemon = True
3018
+ entry["timer"] = timer
3019
+ with delayed_toolcalls_lock:
3020
+ pending_delayed_toolcalls[entry_id] = entry
3021
+ timer.start()
3022
+
3023
+ def _cancel_delayed_toolcall(tool_name: str) -> None:
3024
+ with delayed_toolcalls_lock:
3025
+ if not pending_delayed_toolcalls:
3026
+ return
3027
+
3028
+ selected_id: Optional[int] = None
3029
+ if tool_name:
3030
+ for entry_id, entry in pending_delayed_toolcalls.items():
3031
+ if entry.get("tool") == tool_name:
3032
+ selected_id = entry_id
3033
+ break
3034
+
3035
+ if selected_id is None:
3036
+ selected_id = min(pending_delayed_toolcalls.keys())
3037
+
3038
+ pending = pending_delayed_toolcalls.pop(selected_id, None)
3039
+
3040
+ if pending:
3041
+ timer = pending.get("timer")
3042
+ if timer:
3043
+ timer.cancel()
3044
+
3045
+ def _cancel_all_delayed_toolcalls() -> None:
3046
+ with delayed_toolcalls_lock:
3047
+ pending = list(pending_delayed_toolcalls.values())
3048
+ pending_delayed_toolcalls.clear()
3049
+ for entry in pending:
3050
+ timer = entry.get("timer")
3051
+ if timer:
3052
+ timer.cancel()
3053
+
3054
+ cancel_delayed_toolcalls = _cancel_all_delayed_toolcalls
3055
+
3056
+ def _emit_parsed_event(parsed_event: dict, raw_json_line: Optional[str] = None) -> None:
3057
+ event_type = parsed_event.get("type", "")
3058
+
3059
+ # Capture session ID from the session event (sent at stream start)
3060
+ if event_type == "session":
3061
+ self.session_id = parsed_event.get("id")
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)
3074
+
3075
+ # Track per-run assistant usage from stream events.
3076
+ self._track_assistant_usage_from_event(parsed_event)
3077
+
3078
+ # Ensure agent_end reflects cumulative per-run totals when available.
3079
+ if event_type == "agent_end":
3080
+ accumulated_total_cost = self._get_accumulated_total_cost_usd()
3081
+ if accumulated_total_cost is not None:
3082
+ parsed_event["total_cost_usd"] = accumulated_total_cost
3083
+ if isinstance(self._run_usage_totals, dict):
3084
+ parsed_event["usage"] = self._run_usage_totals
3085
+
3086
+ # Capture result event for shell backend
3087
+ if event_type == "agent_end":
3088
+ # agent_end has a 'messages' array; extract final assistant text
3089
+ messages = parsed_event.get("messages", [])
3090
+ text = ""
3091
+ if isinstance(messages, list):
3092
+ # Walk messages in reverse to find last assistant message with text
3093
+ for m in reversed(messages):
3094
+ if isinstance(m, dict) and m.get("role") == "assistant":
3095
+ text = self._extract_text_from_message(m)
3096
+ if text:
3097
+ break
3098
+ if text:
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):
3105
+ self.last_result_event = parsed_event
3106
+ elif event_type == "message":
3107
+ # OpenAI-compatible format: capture last assistant message
3108
+ msg = parsed_event.get("message", {})
3109
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
3110
+ text = self._extract_text_from_message(msg)
3111
+ if text:
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)
3117
+ elif event_type == "turn_end":
3118
+ # turn_end may contain the final assistant message
3119
+ msg = parsed_event.get("message", {})
3120
+ if isinstance(msg, dict):
3121
+ text = self._extract_text_from_message(msg)
3122
+ if text:
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)
3128
+
3129
+ # Filter hidden stream types (live mode handles its own filtering)
3130
+ if event_type in hide_types and self.prettifier_mode != self.PRETTIFIER_LIVE:
3131
+ return
3132
+
3133
+ # Fallback toolcall_end events (without toolCallId) are delayed so
3134
+ # short tool executions only show the final combined tool event.
3135
+ if pretty:
3136
+ fallback_tool_name = _extract_fallback_toolcall_name(parsed_event)
3137
+ if fallback_tool_name is not None:
3138
+ _schedule_delayed_toolcall(parsed_event, fallback_tool_name, self.prettifier_mode)
3139
+ return
3140
+
3141
+ # Live stream mode: stream deltas in real-time
3142
+ if self.prettifier_mode == self.PRETTIFIER_LIVE:
3143
+ if event_type in hide_types:
3144
+ # In live mode, still suppress session/compaction/retry events
3145
+ # but NOT message_start/message_end (handled by _format_event_live)
3146
+ if event_type not in ("message_start", "message_end"):
3147
+ return
3148
+ formatted_live = self._format_event_live(parsed_event)
3149
+ if formatted_live is not None:
3150
+ if formatted_live == "":
3151
+ return
3152
+ sys.stdout.write(formatted_live)
3153
+ sys.stdout.flush()
3154
+ else:
3155
+ # Fallback: print raw JSON for unhandled event types
3156
+ print(json.dumps(parsed_event, ensure_ascii=False), flush=True)
3157
+ return
3158
+
3159
+ # Format and print using model-appropriate prettifier
3160
+ if pretty:
3161
+ if self.prettifier_mode == self.PRETTIFIER_CODEX:
3162
+ # Try Pi-wrapped Codex format first (role-based messages)
3163
+ if "role" in parsed_event:
3164
+ formatted = self._format_pi_codex_message(parsed_event)
3165
+ else:
3166
+ # Try Pi event handler (message_update, turn_end, etc.)
3167
+ formatted = self._format_pi_codex_event(parsed_event)
3168
+ if formatted is None:
3169
+ # Try native Codex event handler
3170
+ formatted = self._format_event_pretty_codex(parsed_event)
3171
+ if formatted is None:
3172
+ # Sanitize before raw JSON fallback: strip thinkingSignature,
3173
+ # encrypted_content, and metadata from nested Codex events.
3174
+ self._sanitize_codex_event(parsed_event, strip_metadata=True)
3175
+ formatted = json.dumps(parsed_event, ensure_ascii=False)
3176
+ elif formatted == "":
3177
+ return
3178
+ elif self.prettifier_mode == self.PRETTIFIER_CLAUDE:
3179
+ formatted = self._format_event_pretty_claude(parsed_event)
3180
+ else:
3181
+ formatted = self._format_event_pretty(parsed_event)
3182
+ if formatted is not None:
3183
+ print(formatted, flush=True)
3184
+ else:
3185
+ if raw_json_line is not None:
3186
+ print(raw_json_line, flush=True)
3187
+ else:
3188
+ print(json.dumps(parsed_event, ensure_ascii=False), flush=True)
3189
+
3190
+ def _merge_buffered_tool_stdout_into(event_payload: dict) -> None:
3191
+ buffered_text = "\n".join(self._buffered_tool_stdout_lines).strip()
3192
+ if not buffered_text:
3193
+ self._buffered_tool_stdout_lines.clear()
3194
+ return
3195
+
3196
+ result_val = event_payload.get("result")
3197
+ if result_val in (None, "", [], {}):
3198
+ event_payload["result"] = buffered_text
3199
+ elif isinstance(result_val, str):
3200
+ existing = self._strip_ansi_sequences(result_val)
3201
+ if existing:
3202
+ if not existing.endswith("\n"):
3203
+ existing += "\n"
3204
+ event_payload["result"] = existing + buffered_text
3205
+ else:
3206
+ event_payload["result"] = buffered_text
3207
+ else:
3208
+ # Keep complex result structures untouched; print trailing raw lines
3209
+ # before the next structured event for stable transcript ordering.
3210
+ print(buffered_text, flush=True)
3211
+
3212
+ self._buffered_tool_stdout_lines.clear()
3213
+
3214
+ def _flush_pending_tool_events() -> None:
3215
+ nonlocal pending_tool_execution_end, pending_turn_end_after_tool
3216
+ if pending_tool_execution_end is not None:
3217
+ _merge_buffered_tool_stdout_into(pending_tool_execution_end)
3218
+ _emit_parsed_event(pending_tool_execution_end)
3219
+ pending_tool_execution_end = None
3220
+
3221
+ if pending_turn_end_after_tool is not None:
3222
+ if self._buffered_tool_stdout_lines:
3223
+ print("\n".join(self._buffered_tool_stdout_lines), flush=True)
3224
+ self._buffered_tool_stdout_lines.clear()
3225
+ _emit_parsed_event(pending_turn_end_after_tool)
3226
+ pending_turn_end_after_tool = None
3227
+
1532
3228
  try:
1533
3229
  for raw_line in process.stdout:
1534
3230
  line = raw_line.rstrip("\n\r")
@@ -1539,119 +3235,57 @@ Model shorthands:
1539
3235
  try:
1540
3236
  parsed = json.loads(line)
1541
3237
  except json.JSONDecodeError:
1542
- # Non-JSON output print as-is
3238
+ # Non-JSON output (raw tool stdout). In pretty mode, buffer raw
3239
+ # lines while tool execution events are pending to avoid
3240
+ # interleaving with structured events (e.g. turn_end).
3241
+ if pretty and (
3242
+ self._in_tool_execution
3243
+ or pending_tool_execution_end is not None
3244
+ or pending_turn_end_after_tool is not None
3245
+ ):
3246
+ self._buffered_tool_stdout_lines.append(self._strip_ansi_sequences(line))
3247
+ continue
1543
3248
  print(line, flush=True)
1544
3249
  continue
1545
3250
 
1546
3251
  event_type = parsed.get("type", "")
1547
3252
 
1548
- # Capture session ID from the session event (sent at stream start)
1549
- if event_type == "session":
1550
- self.session_id = parsed.get("id")
1551
-
1552
- # Capture result event for shell backend
1553
- if event_type == "agent_end":
1554
- # agent_end has a 'messages' array; extract final assistant text
1555
- messages = parsed.get("messages", [])
1556
- text = ""
1557
- if isinstance(messages, list):
1558
- # Walk messages in reverse to find last assistant message with text
1559
- for m in reversed(messages):
1560
- if isinstance(m, dict) and m.get("role") == "assistant":
1561
- text = self._extract_text_from_message(m)
1562
- if text:
1563
- break
1564
- if text:
1565
- self.last_result_event = {
1566
- "type": "result",
1567
- "subtype": "success",
1568
- "is_error": False,
1569
- "result": text,
1570
- "session_id": self.session_id,
1571
- "sub_agent_response": self._sanitize_sub_agent_response(parsed),
1572
- }
1573
- else:
1574
- self.last_result_event = parsed
1575
- elif event_type == "message":
1576
- # OpenAI-compatible format: capture last assistant message
1577
- msg = parsed.get("message", {})
1578
- if isinstance(msg, dict) and msg.get("role") == "assistant":
1579
- text = self._extract_text_from_message(msg)
1580
- if text:
1581
- self.last_result_event = {
1582
- "type": "result",
1583
- "subtype": "success",
1584
- "is_error": False,
1585
- "result": text,
1586
- "session_id": self.session_id,
1587
- "sub_agent_response": self._sanitize_sub_agent_response(parsed),
1588
- }
1589
- elif event_type == "turn_end":
1590
- # turn_end may contain the final assistant message
1591
- msg = parsed.get("message", {})
1592
- if isinstance(msg, dict):
1593
- text = self._extract_text_from_message(msg)
1594
- if text:
1595
- self.last_result_event = {
1596
- "type": "result",
1597
- "subtype": "success",
1598
- "is_error": False,
1599
- "result": text,
1600
- "session_id": self.session_id,
1601
- "sub_agent_response": self._sanitize_sub_agent_response(parsed),
1602
- }
1603
-
1604
- # Filter hidden stream types (live mode handles its own filtering)
1605
- if event_type in hide_types and self.prettifier_mode != self.PRETTIFIER_LIVE:
3253
+ if pretty and event_type == "tool_execution_start":
3254
+ # Reset raw tool stdout buffer per tool execution.
3255
+ self._buffered_tool_stdout_lines.clear()
3256
+
3257
+ if pretty and event_type == "tool_execution_end":
3258
+ # Tool finished before the delayed fallback timer fired — suppress
3259
+ # the pending fallback toolcall_end preview.
3260
+ tool_name = parsed.get("toolName", "")
3261
+ _cancel_delayed_toolcall(tool_name if isinstance(tool_name, str) else "")
3262
+
3263
+ # Defer emission so any trailing raw stdout can be grouped before
3264
+ # downstream structured metadata like turn_end.
3265
+ pending_tool_execution_end = parsed
1606
3266
  continue
1607
3267
 
1608
- # Live stream mode: stream deltas in real-time
1609
- if self.prettifier_mode == self.PRETTIFIER_LIVE:
1610
- if event_type in hide_types:
1611
- # In live mode, still suppress session/compaction/retry events
1612
- # but NOT message_start/message_end (handled by _format_event_live)
1613
- if event_type not in ("message_start", "message_end"):
1614
- continue
1615
- formatted = self._format_event_live(parsed)
1616
- if formatted is not None:
1617
- if formatted == "":
1618
- continue
1619
- sys.stdout.write(formatted)
1620
- sys.stdout.flush()
1621
- else:
1622
- # Fallback: print raw JSON for unhandled event types
1623
- print(json.dumps(parsed, ensure_ascii=False), flush=True)
3268
+ if pretty and event_type == "turn_end" and pending_tool_execution_end is not None:
3269
+ # Hold turn_end until buffered trailing raw stdout is flushed with
3270
+ # the pending tool event.
3271
+ pending_turn_end_after_tool = parsed
1624
3272
  continue
1625
3273
 
1626
- # Format and print using model-appropriate prettifier
1627
- if pretty:
1628
- if self.prettifier_mode == self.PRETTIFIER_CODEX:
1629
- # Try Pi-wrapped Codex format first (role-based messages)
1630
- if "role" in parsed:
1631
- formatted = self._format_pi_codex_message(parsed)
1632
- else:
1633
- # Try Pi event handler (message_update, turn_end, etc.)
1634
- formatted = self._format_pi_codex_event(parsed)
1635
- if formatted is not None:
1636
- # Empty string means "suppress this event"
1637
- if formatted == "":
1638
- continue
1639
- else:
1640
- # Try native Codex event handler
1641
- formatted = self._format_event_pretty_codex(parsed)
1642
- if formatted is None:
1643
- # Sanitize before raw JSON fallback: strip thinkingSignature,
1644
- # encrypted_content, and metadata from nested Codex events.
1645
- self._sanitize_codex_event(parsed, strip_metadata=True)
1646
- formatted = json.dumps(parsed, ensure_ascii=False)
1647
- elif self.prettifier_mode == self.PRETTIFIER_CLAUDE:
1648
- formatted = self._format_event_pretty_claude(parsed)
1649
- else:
1650
- formatted = self._format_event_pretty(parsed)
1651
- if formatted is not None:
1652
- print(formatted, flush=True)
1653
- else:
1654
- print(line, flush=True)
3274
+ if pretty and (
3275
+ pending_tool_execution_end is not None or pending_turn_end_after_tool is not None
3276
+ ):
3277
+ _flush_pending_tool_events()
3278
+
3279
+ _emit_parsed_event(parsed, raw_json_line=line)
3280
+
3281
+ # Flush any deferred tool/turn events at end-of-stream.
3282
+ if pretty and (
3283
+ pending_tool_execution_end is not None or pending_turn_end_after_tool is not None
3284
+ ):
3285
+ _flush_pending_tool_events()
3286
+ elif self._buffered_tool_stdout_lines:
3287
+ print("\n".join(self._buffered_tool_stdout_lines), flush=True)
3288
+ self._buffered_tool_stdout_lines.clear()
1655
3289
 
1656
3290
  except ValueError:
1657
3291
  # Watchdog closed stdout — expected when process exits but pipe stays open.
@@ -1659,9 +3293,7 @@ Model shorthands:
1659
3293
 
1660
3294
  # Signal watchdog that output loop is done
1661
3295
  output_done.set()
1662
-
1663
- # Write capture file for shell backend
1664
- self._write_capture_file(capture_path)
3296
+ cancel_delayed_toolcalls()
1665
3297
 
1666
3298
  # Wait for process cleanup
1667
3299
  try:
@@ -1669,20 +3301,34 @@ Model shorthands:
1669
3301
  except subprocess.TimeoutExpired:
1670
3302
  pass
1671
3303
 
1672
- # Wait for stderr thread to finish
3304
+ # Wait for stderr thread to finish before deriving fallback errors.
1673
3305
  stderr_thread.join(timeout=3)
1674
3306
 
1675
- return process.returncode or 0
3307
+ # If stderr surfaced a terminal error and we do not already have an
3308
+ # explicit success envelope, persist the failure for shell-backend consumers.
3309
+ if stderr_error_messages and not self._is_success_result_event(self.last_result_event):
3310
+ self.last_result_event = self._build_error_result_event(stderr_error_messages[-1])
3311
+
3312
+ # Write capture file for shell backend
3313
+ self._write_capture_file(capture_path)
3314
+
3315
+ final_return_code = process.returncode or 0
3316
+ if final_return_code == 0 and self._is_error_result_event(self.last_result_event):
3317
+ final_return_code = 1
3318
+
3319
+ return final_return_code
1676
3320
 
1677
3321
  except KeyboardInterrupt:
1678
3322
  print("\nInterrupted by user", file=sys.stderr)
3323
+ cancel_delayed_toolcalls()
1679
3324
  try:
1680
- process.terminate()
1681
- try:
1682
- process.wait(timeout=5)
1683
- except subprocess.TimeoutExpired:
1684
- process.kill()
1685
- process.wait(timeout=5)
3325
+ if process is not None:
3326
+ process.terminate()
3327
+ try:
3328
+ process.wait(timeout=5)
3329
+ except subprocess.TimeoutExpired:
3330
+ process.kill()
3331
+ process.wait(timeout=5)
1686
3332
  except Exception:
1687
3333
  pass
1688
3334
  self._write_capture_file(capture_path)
@@ -1690,8 +3336,9 @@ Model shorthands:
1690
3336
 
1691
3337
  except Exception as e:
1692
3338
  print(f"Error executing pi: {e}", file=sys.stderr)
3339
+ cancel_delayed_toolcalls()
1693
3340
  try:
1694
- if process.poll() is None:
3341
+ if process is not None and process.poll() is None:
1695
3342
  process.terminate()
1696
3343
  process.wait(timeout=5)
1697
3344
  except Exception:
@@ -1728,7 +3375,9 @@ Model shorthands:
1728
3375
  self.prettifier_mode = self._detect_prettifier_mode(self.model_name)
1729
3376
  self.verbose = args.verbose
1730
3377
 
1731
- # Verbose mode enables live stream prettifier for real-time output
3378
+ # Verbose mode enables live stream prettifier for real-time output.
3379
+ # Codex models already default to LIVE; this ensures all models get
3380
+ # real-time streaming when -v is used.
1732
3381
  if args.verbose:
1733
3382
  self.prettifier_mode = self.PRETTIFIER_LIVE
1734
3383
 
@@ -1740,8 +3389,30 @@ Model shorthands:
1740
3389
  else:
1741
3390
  self.prompt = prompt_value
1742
3391
 
1743
- cmd, stdin_prompt = self.build_pi_command(args)
1744
- return self.run_pi(cmd, args, stdin_prompt=stdin_prompt)
3392
+ if args.live and args.no_extensions:
3393
+ print("Error: --live requires extensions enabled (remove --no-extensions).", file=sys.stderr)
3394
+ return 1
3395
+
3396
+ live_extension_file: Optional[Path] = None
3397
+ if args.live:
3398
+ capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
3399
+ live_extension_file = self._create_live_auto_exit_extension_file(capture_path)
3400
+ if not live_extension_file:
3401
+ print("Error: Could not create live auto-exit extension.", file=sys.stderr)
3402
+ return 1
3403
+
3404
+ try:
3405
+ cmd, stdin_prompt = self.build_pi_command(
3406
+ args,
3407
+ live_extension_path=str(live_extension_file) if live_extension_file else None,
3408
+ )
3409
+ return self.run_pi(cmd, args, stdin_prompt=stdin_prompt)
3410
+ finally:
3411
+ if live_extension_file is not None:
3412
+ try:
3413
+ live_extension_file.unlink(missing_ok=True)
3414
+ except Exception as e:
3415
+ print(f"Warning: Failed to remove temp live extension: {e}", file=sys.stderr)
1745
3416
 
1746
3417
 
1747
3418
  def main():