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.
- package/README.md +508 -202
- package/dist/bin/cli.d.mts +1 -1
- package/dist/bin/cli.d.ts +1 -1
- package/dist/bin/cli.js +3332 -1421
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/cli.mjs +3316 -1405
- package/dist/bin/cli.mjs.map +1 -1
- package/dist/bin/feedback-collector.js.map +1 -1
- package/dist/bin/feedback-collector.mjs.map +1 -1
- package/dist/index.d.mts +56 -19
- package/dist/index.d.ts +56 -19
- package/dist/index.js +240 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +240 -36
- package/dist/index.mjs.map +1 -1
- package/dist/templates/scripts/install_requirements.sh +55 -5
- package/dist/templates/scripts/kanban.sh +11 -0
- package/dist/templates/services/README.md +23 -4
- package/dist/templates/services/__pycache__/pi.cpython-313.pyc +0 -0
- package/dist/templates/services/pi.py +1933 -262
- package/dist/templates/skills/claude/kanban-workflow/SKILL.md +138 -0
- package/dist/templates/skills/claude/plan-kanban-tasks/SKILL.md +1 -1
- package/dist/templates/skills/claude/ralph-loop/scripts/kanban.sh +11 -0
- package/dist/templates/skills/claude/understand-project/SKILL.md +1 -1
- package/dist/templates/skills/codex/kanban-workflow/SKILL.md +139 -0
- package/dist/templates/skills/codex/plan-kanban-tasks/SKILL.md +32 -0
- package/dist/templates/skills/codex/ralph-loop/scripts/kanban.sh +11 -0
- package/dist/templates/skills/codex/understand-project/SKILL.md +46 -0
- package/dist/templates/skills/pi/kanban-workflow/SKILL.md +139 -0
- package/dist/templates/skills/pi/plan-kanban-tasks/SKILL.md +1 -1
- package/dist/templates/skills/pi/ralph-loop/SKILL.md +4 -0
- package/dist/templates/skills/pi/understand-project/SKILL.md +1 -1
- 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
|
-
|
|
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.
|
|
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(
|
|
291
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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:
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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.
|
|
804
|
-
|
|
805
|
-
|
|
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": "
|
|
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
|
-
|
|
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
|
|
844
|
-
|
|
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
|
|
847
|
-
if event_type
|
|
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
|
-
# ---
|
|
1117
|
-
if event_type
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
1555
|
+
self._in_tool_execution = False
|
|
1206
1556
|
tool_call_id = payload.get("toolCallId")
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
return
|
|
1221
|
-
header["result"] =
|
|
1222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1371
|
-
if event_type
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
1549
|
-
|
|
1550
|
-
self.
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
#
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
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
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
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
|
-
|
|
1744
|
-
|
|
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():
|