juno-code 1.0.46 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +44 -8
  2. package/dist/bin/cli.d.mts +17 -0
  3. package/dist/bin/cli.d.ts +17 -0
  4. package/dist/bin/cli.js +5601 -17505
  5. package/dist/bin/cli.js.map +1 -1
  6. package/dist/bin/cli.mjs +5640 -17542
  7. package/dist/bin/cli.mjs.map +1 -1
  8. package/dist/bin/feedback-collector.d.mts +2 -0
  9. package/dist/bin/feedback-collector.d.ts +2 -0
  10. package/dist/bin/feedback-collector.js.map +1 -1
  11. package/dist/bin/feedback-collector.mjs.map +1 -1
  12. package/dist/index.d.mts +2107 -0
  13. package/dist/index.d.ts +2107 -0
  14. package/dist/index.js +3760 -14728
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +3761 -14536
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/templates/extensions/pi/juno-skill-preprocessor.ts +239 -0
  19. package/dist/templates/scripts/__pycache__/github.cpython-313.pyc +0 -0
  20. package/dist/templates/scripts/__pycache__/parallel_runner.cpython-313.pyc +0 -0
  21. package/dist/templates/scripts/__pycache__/slack_respond.cpython-313.pyc +0 -0
  22. package/dist/templates/scripts/kanban.sh +18 -4
  23. package/dist/templates/scripts/parallel_runner.sh +2242 -0
  24. package/dist/templates/services/README.md +61 -1
  25. package/dist/templates/services/__pycache__/claude.cpython-313.pyc +0 -0
  26. package/dist/templates/services/__pycache__/codex.cpython-313.pyc +0 -0
  27. package/dist/templates/services/__pycache__/pi.cpython-313.pyc +0 -0
  28. package/dist/templates/services/claude.py +132 -33
  29. package/dist/templates/services/codex.py +179 -66
  30. package/dist/templates/services/gemini.py +117 -27
  31. package/dist/templates/services/pi.py +1753 -0
  32. package/dist/templates/skills/claude/plan-kanban-tasks/SKILL.md +14 -7
  33. package/dist/templates/skills/claude/ralph-loop/SKILL.md +18 -22
  34. package/dist/templates/skills/claude/ralph-loop/references/first_check.md +15 -14
  35. package/dist/templates/skills/claude/ralph-loop/references/implement.md +17 -17
  36. package/dist/templates/skills/claude/ralph-loop/scripts/kanban.sh +18 -4
  37. package/dist/templates/skills/claude/understand-project/SKILL.md +14 -7
  38. package/dist/templates/skills/codex/ralph-loop/SKILL.md +18 -22
  39. package/dist/templates/skills/codex/ralph-loop/references/first_check.md +15 -14
  40. package/dist/templates/skills/codex/ralph-loop/references/implement.md +17 -17
  41. package/dist/templates/skills/codex/ralph-loop/scripts/kanban.sh +18 -4
  42. package/dist/templates/skills/pi/.gitkeep +0 -0
  43. package/dist/templates/skills/pi/plan-kanban-tasks/SKILL.md +32 -0
  44. package/dist/templates/skills/pi/ralph-loop/SKILL.md +39 -0
  45. package/dist/templates/skills/pi/ralph-loop/references/first_check.md +21 -0
  46. package/dist/templates/skills/pi/ralph-loop/references/implement.md +99 -0
  47. package/dist/templates/skills/pi/understand-project/SKILL.md +46 -0
  48. package/package.json +20 -42
  49. package/dist/templates/scripts/__pycache__/attachment_downloader.cpython-38.pyc +0 -0
  50. package/dist/templates/scripts/__pycache__/github.cpython-38.pyc +0 -0
  51. package/dist/templates/scripts/__pycache__/slack_fetch.cpython-38.pyc +0 -0
  52. package/dist/templates/scripts/__pycache__/slack_state.cpython-38.pyc +0 -0
  53. package/dist/templates/services/__pycache__/claude.cpython-38.pyc +0 -0
  54. package/dist/templates/services/__pycache__/codex.cpython-38.pyc +0 -0
@@ -87,6 +87,7 @@ A wrapper for OpenAI Codex CLI with configurable options.
87
87
  #### Default Configuration
88
88
 
89
89
  The script comes with these default codex configurations:
90
+
90
91
  - `include_apply_patch_tool=true`
91
92
  - `use_experimental_streamable_shell_tool=true`
92
93
  - `sandbox_mode=danger-full-access`
@@ -168,6 +169,7 @@ A wrapper for Anthropic Claude CLI with configurable options.
168
169
  #### Default Configuration
169
170
 
170
171
  The script comes with these default allowed tools:
172
+
171
173
  - Read, Write, Edit, MultiEdit
172
174
  - Bash, Glob, Grep
173
175
  - WebFetch, WebSearch
@@ -222,6 +224,63 @@ Headless wrapper for Gemini CLI with shorthand model support and JSON/text outpu
222
224
  - `--debug`: Enable Gemini CLI debug output
223
225
  - `--verbose`: Print the constructed command before execution
224
226
 
227
+ ### pi.py
228
+
229
+ A wrapper for the Pi coding agent CLI -- a multi-provider coding agent that supports Anthropic, OpenAI, Google, Groq, xAI, and more.
230
+
231
+ #### Prerequisites
232
+
233
+ Pi requires separate installation of the pi-coding-agent CLI:
234
+
235
+ ```bash
236
+ npm install -g @mariozechner/pi-coding-agent
237
+ ```
238
+
239
+ #### Features
240
+
241
+ - Multi-provider support (Anthropic, OpenAI, Google, Groq, xAI, etc.)
242
+ - Model shorthand aliases (`:pi`, `:sonnet`, `:opus`, `:gpt-5`, `:gemini-pro`, etc.)
243
+ - Support for inline prompts or prompt files
244
+ - JSON/text output normalization
245
+ - Verbose mode for debugging
246
+
247
+ #### Usage
248
+
249
+ ```bash
250
+ # Basic usage with Anthropic model
251
+ ~/.juno_code/services/pi.py -p "Write a hello world function" -m :sonnet
252
+
253
+ # Use with OpenAI model
254
+ ~/.juno_code/services/pi.py -p "Refactor code" -m :gpt-5
255
+
256
+ # Use with Gemini model
257
+ ~/.juno_code/services/pi.py -p "Add tests" -m :gemini-pro
258
+
259
+ # Specify project directory
260
+ ~/.juno_code/services/pi.py -p "Fix bugs" --cd /path/to/project
261
+
262
+ # Enable verbose output
263
+ ~/.juno_code/services/pi.py -p "Analyze code" --verbose
264
+ ```
265
+
266
+ #### Arguments
267
+
268
+ - `-p, --prompt <text>`: Prompt text (required, mutually exclusive with --prompt-file)
269
+ - `-pp, --prompt-file <path>`: Path to prompt file (required if no --prompt)
270
+ - `--cd <path>`: Project path (default: current directory)
271
+ - `-m, --model <name>`: Model name (supports shorthand aliases)
272
+ - `--verbose`: Enable verbose output
273
+
274
+ #### Via juno-code
275
+
276
+ ```bash
277
+ # Run Pi through juno-code
278
+ juno-code -b shell -s pi -m :sonnet -i 1 -v -p "your task"
279
+
280
+ # Quick shortcut
281
+ juno-code pi "your task"
282
+ ```
283
+
225
284
  ## Customization
226
285
 
227
286
  All service scripts installed in `~/.juno_code/services/` can be modified to suit your needs. This directory is designed for user customization.
@@ -281,6 +340,7 @@ Service scripts require Python 3.6+ to be installed on your system. Individual s
281
340
  - **codex.py**: Requires OpenAI Codex CLI to be installed
282
341
  - **claude.py**: Requires Anthropic Claude CLI to be installed (see https://docs.anthropic.com/en/docs/agents-and-tools/claude-code)
283
342
  - **gemini.py**: Requires Gemini CLI to be installed (see https://geminicli.com/docs/cli/headless/)
343
+ - **pi.py**: Requires Pi coding agent CLI to be installed (`npm install -g @mariozechner/pi-coding-agent`)
284
344
 
285
345
  ## Troubleshooting
286
346
 
@@ -311,4 +371,4 @@ python3 --version
311
371
  ## Support
312
372
 
313
373
  For issues or feature requests related to service scripts, please visit:
314
- https://github.com/owner/juno-code/issues
374
+ https://github.com/askbudi/juno-code/issues
@@ -9,6 +9,8 @@ import json
9
9
  import os
10
10
  import subprocess
11
11
  import sys
12
+ import threading
13
+ import time
12
14
  from datetime import datetime
13
15
  from pathlib import Path
14
16
  from typing import Optional, List, Dict, Any
@@ -18,7 +20,7 @@ class ClaudeService:
18
20
  """Service wrapper for Anthropic Claude CLI"""
19
21
 
20
22
  # Default configuration
21
- DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
23
+ DEFAULT_MODEL = "claude-sonnet-4-6"
22
24
  DEFAULT_PERMISSION_MODE = "default"
23
25
  DEFAULT_AUTO_INSTRUCTION = """"""
24
26
 
@@ -26,11 +28,12 @@ class ClaudeService:
26
28
  MODEL_SHORTHANDS = {
27
29
  ":claude-haiku-4-5": "claude-haiku-4-5-20251001",
28
30
  ":claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
31
+ ":claude-sonnet-4-6": "claude-sonnet-4-6",
29
32
  ":claude-opus-4-5": "claude-opus-4-5-20251101",
30
33
  ":claude-opus-4-6": "claude-opus-4-6",
31
34
  ":claude-opus-4": "claude-opus-4-20250514",
32
35
  ":haiku": "claude-haiku-4-5-20251001",
33
- ":sonnet": "claude-sonnet-4-5-20250929",
36
+ ":sonnet": "claude-sonnet-4-6",
34
37
  ":opus": "claude-opus-4-6",
35
38
  }
36
39
 
@@ -64,7 +67,7 @@ class ClaudeService:
64
67
  Examples:
65
68
  :claude-haiku-4-5 -> claude-haiku-4-5-20251001
66
69
  :haiku -> claude-haiku-4-5-20251001
67
- claude-sonnet-4-5-20250929 -> claude-sonnet-4-5-20250929 (unchanged)
70
+ claude-sonnet-4-6 -> claude-sonnet-4-6 (unchanged)
68
71
  """
69
72
  if model.startswith(':'):
70
73
  return self.MODEL_SHORTHANDS.get(model, model)
@@ -106,7 +109,7 @@ Default Tools (enabled by default when no --allowed-tools specified):
106
109
 
107
110
  Environment Variables:
108
111
  CLAUDE_PROJECT_PATH Project path (default: current directory)
109
- CLAUDE_MODEL Model name (default: claude-sonnet-4-5-20250929)
112
+ CLAUDE_MODEL Model name (default: claude-sonnet-4-6)
110
113
  CLAUDE_AUTO_INSTRUCTION Auto instruction to prepend to prompt
111
114
  CLAUDE_PERMISSION_MODE Permission mode (default: default)
112
115
  CLAUDE_PRETTY Pretty print JSON output (default: true)
@@ -139,7 +142,7 @@ Environment Variables:
139
142
  "-m", "--model",
140
143
  type=str,
141
144
  default=os.environ.get("CLAUDE_MODEL", self.DEFAULT_MODEL),
142
- help=f"Model name. Supports shorthand (e.g., ':haiku', ':sonnet', ':opus', ':claude-haiku-4-5') or full model ID (e.g., 'claude-haiku-4-5-20251001'). Default: {self.DEFAULT_MODEL} (env: CLAUDE_MODEL)"
145
+ help=f"Model name. Supports shorthand (e.g., ':haiku', ':sonnet', ':opus', ':claude-sonnet-4-6') or full model ID (e.g., 'claude-sonnet-4-6'). Default: {self.DEFAULT_MODEL} (env: CLAUDE_MODEL)"
143
146
  )
144
147
 
145
148
  parser.add_argument(
@@ -575,12 +578,29 @@ Environment Variables:
575
578
 
576
579
  def run_claude(self, cmd: List[str], verbose: bool = False, pretty: bool = True) -> int:
577
580
  """Execute the claude command and stream output"""
578
- if verbose:
579
- print(f"Executing: {' '.join(cmd)}", file=sys.stderr)
580
- print("-" * 80, file=sys.stderr)
581
-
582
581
  capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
583
582
 
583
+ if verbose and not capture_path:
584
+ # Truncate prompt in display to avoid confusing multi-line output
585
+ display_cmd = []
586
+ skip_next = False
587
+ for i, part in enumerate(cmd):
588
+ if skip_next:
589
+ skip_next = False
590
+ continue
591
+ if part == "-p" and i + 1 < len(cmd):
592
+ prompt_val = cmd[i + 1]
593
+ if len(prompt_val) > 80 or "\n" in prompt_val:
594
+ first_line = prompt_val.split("\n")[0][:60]
595
+ display_cmd.append(f'-p "{first_line}..." ({len(prompt_val)} chars)')
596
+ else:
597
+ display_cmd.append(f"-p {prompt_val}")
598
+ skip_next = True
599
+ else:
600
+ display_cmd.append(part)
601
+ print(f"Executing: {' '.join(display_cmd)}", file=sys.stderr)
602
+ print("-" * 80, file=sys.stderr)
603
+
584
604
  def write_capture_file():
585
605
  """Persist the final result event for programmatic capture without affecting screen output."""
586
606
  if not capture_path or not self.last_result_event:
@@ -609,31 +629,100 @@ Environment Variables:
609
629
  universal_newlines=True
610
630
  )
611
631
 
632
+ # Watchdog thread: handles two scenarios where the stdout loop blocks:
633
+ # 1. Process exits but its stdout pipe stays open (inherited FDs)
634
+ # 2. Process itself never exits (hung Node.js event loop)
635
+ # The watchdog waits for the "result received" signal from the main
636
+ # thread (set when a type=result event is parsed). Once signaled, it
637
+ # gives the process a grace period to exit, then terminates it.
638
+ wait_timeout = int(os.environ.get("CLAUDE_WAIT_TIMEOUT", "30"))
639
+ result_received = threading.Event()
640
+
641
+ def _stdout_watchdog():
642
+ """Terminate process and close stdout pipe if it hangs after result."""
643
+ # Wait until the main thread signals that the result event was received,
644
+ # OR until the process exits on its own (poll every second).
645
+ while not result_received.is_set():
646
+ if process.poll() is not None:
647
+ # Process exited on its own before result event
648
+ break
649
+ result_received.wait(timeout=1)
650
+
651
+ if result_received.is_set():
652
+ # Result was received — give process grace period to exit cleanly.
653
+ try:
654
+ process.wait(timeout=wait_timeout)
655
+ except subprocess.TimeoutExpired:
656
+ print(
657
+ f"Warning: Claude process did not exit within {wait_timeout}s after result. Terminating.",
658
+ file=sys.stderr
659
+ )
660
+ process.terminate()
661
+ try:
662
+ process.wait(timeout=5)
663
+ except subprocess.TimeoutExpired:
664
+ print("Warning: Claude process did not respond to SIGTERM. Killing.", file=sys.stderr)
665
+ process.kill()
666
+ try:
667
+ process.wait(timeout=5)
668
+ except subprocess.TimeoutExpired:
669
+ pass
670
+
671
+ # Process has exited (naturally or forcefully).
672
+ # Grace period for remaining pipe data to flush, then close stdout.
673
+ time.sleep(2)
674
+ try:
675
+ if process.stdout and not process.stdout.closed:
676
+ process.stdout.close()
677
+ except Exception:
678
+ pass
679
+
680
+ watchdog = threading.Thread(target=_stdout_watchdog, daemon=True)
681
+ watchdog.start()
682
+
612
683
  # Stream stdout line by line (each line is a JSON object when using stream-json)
613
684
  # This allows users to pipe to jq and see output as it streams
614
685
  if process.stdout:
615
- for line in process.stdout:
616
- raw_line = line.strip()
617
- # Capture the raw final result event for programmatic consumption
618
- try:
619
- parsed_raw = json.loads(raw_line)
620
- if isinstance(parsed_raw, dict) and parsed_raw.get("type") == "result":
621
- self.last_result_event = parsed_raw
622
- except json.JSONDecodeError:
623
- # Ignore non-JSON lines here; pretty formatter will handle them
624
- pass
625
-
626
- # Apply pretty formatting if enabled
627
- if pretty:
628
- formatted_line = self.pretty_format_json(raw_line)
629
- if formatted_line:
630
- print(formatted_line, flush=True)
631
- else:
632
- # Raw output without formatting
633
- print(line, end='', flush=True)
634
-
635
- # Wait for process to complete
636
- process.wait()
686
+ try:
687
+ for line in process.stdout:
688
+ raw_line = line.strip()
689
+ # Capture the raw final result event for programmatic consumption
690
+ try:
691
+ parsed_raw = json.loads(raw_line)
692
+ if isinstance(parsed_raw, dict) and parsed_raw.get("type") == "result":
693
+ self.last_result_event = parsed_raw
694
+ # Signal the watchdog that the result has been received.
695
+ # The CLI is done; if it doesn't exit soon, watchdog
696
+ # will terminate it.
697
+ result_received.set()
698
+ except json.JSONDecodeError:
699
+ # Ignore non-JSON lines here; pretty formatter will handle them
700
+ pass
701
+
702
+ # Apply pretty formatting if enabled
703
+ if pretty:
704
+ formatted_line = self.pretty_format_json(raw_line)
705
+ if formatted_line:
706
+ print(formatted_line, flush=True)
707
+ else:
708
+ # Raw output without formatting
709
+ print(line, end='', flush=True)
710
+ except ValueError:
711
+ # Watchdog closed stdout because the process exited but the
712
+ # pipe stayed open (inherited FDs from child processes).
713
+ # This is expected — all output has been consumed.
714
+ pass
715
+
716
+ # Signal watchdog in case stdout closed without a result event
717
+ # (e.g., process crashed or was killed externally).
718
+ result_received.set()
719
+
720
+ # Ensure process has exited. The watchdog thread handles termination
721
+ # if the process hangs, so by this point it should already be dead.
722
+ try:
723
+ process.wait(timeout=5)
724
+ except subprocess.TimeoutExpired:
725
+ pass # Watchdog thread will handle cleanup
637
726
 
638
727
  # Print stderr if there were errors
639
728
  if process.stderr and process.returncode != 0:
@@ -653,7 +742,11 @@ Environment Variables:
653
742
  print("\nInterrupted by user", file=sys.stderr)
654
743
  if process:
655
744
  process.terminate()
656
- process.wait()
745
+ try:
746
+ process.wait(timeout=5)
747
+ except subprocess.TimeoutExpired:
748
+ process.kill()
749
+ process.wait(timeout=5)
657
750
  write_capture_file()
658
751
  # Restore original working directory
659
752
  if 'original_cwd' in locals():
@@ -661,6 +754,12 @@ Environment Variables:
661
754
  return 130
662
755
  except Exception as e:
663
756
  print(f"Error executing claude: {e}", file=sys.stderr)
757
+ if process and process.poll() is None:
758
+ process.terminate()
759
+ try:
760
+ process.wait(timeout=5)
761
+ except subprocess.TimeoutExpired:
762
+ process.kill()
664
763
  write_capture_file()
665
764
  # Restore original working directory
666
765
  if 'original_cwd' in locals():
@@ -689,7 +788,7 @@ Environment Variables:
689
788
  print("\nRun 'claude.py --help' for usage information.", file=sys.stderr)
690
789
  print("\nAvailable Environment Variables:", file=sys.stderr)
691
790
  print(" CLAUDE_PROJECT_PATH Project path (default: current directory)", file=sys.stderr)
692
- print(" CLAUDE_MODEL Model name (default: claude-sonnet-4-5-20250929)", file=sys.stderr)
791
+ print(" CLAUDE_MODEL Model name (default: claude-sonnet-4-6)", file=sys.stderr)
693
792
  print(" CLAUDE_AUTO_INSTRUCTION Auto instruction to prepend to prompt", file=sys.stderr)
694
793
  print(" CLAUDE_PERMISSION_MODE Permission mode (default: default)", file=sys.stderr)
695
794
  print(" CLAUDE_PRETTY Pretty print JSON output (default: true)", file=sys.stderr)
@@ -9,7 +9,10 @@ import os
9
9
  import subprocess
10
10
  import sys
11
11
  import json
12
+ import threading
13
+ import time
12
14
  from datetime import datetime
15
+ from pathlib import Path
13
16
  from typing import List, Optional
14
17
 
15
18
 
@@ -411,9 +414,43 @@ Environment Variables:
411
414
  - Falls back to string suppression for known noisy types if JSON parsing fails
412
415
  - Never emits token_count or exec_command_output_delta even on malformed lines
413
416
  """
417
+ # Capture file support for structured output (parity with claude.py/pi.py)
418
+ capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
419
+
414
420
  if verbose:
415
- print(f"Executing: {' '.join(cmd)}", file=sys.stderr)
416
- print("-" * 80, file=sys.stderr)
421
+ # Truncate prompt in display to avoid confusing multi-line output
422
+ display_cmd = []
423
+ skip_next = False
424
+ for i, part in enumerate(cmd):
425
+ if skip_next:
426
+ skip_next = False
427
+ continue
428
+ if part == "-p" and i + 1 < len(cmd):
429
+ prompt_val = cmd[i + 1]
430
+ if len(prompt_val) > 80 or "\n" in prompt_val:
431
+ first_line = prompt_val.split("\n")[0][:60]
432
+ display_cmd.append(f'-p "{first_line}..." ({len(prompt_val)} chars)')
433
+ else:
434
+ display_cmd.append(f"-p {prompt_val}")
435
+ skip_next = True
436
+ else:
437
+ display_cmd.append(part)
438
+ if not capture_path:
439
+ print(f"Executing: {' '.join(display_cmd)}", file=sys.stderr)
440
+ print("-" * 80, file=sys.stderr)
441
+ self.last_result_event = None
442
+
443
+ def write_capture_file():
444
+ """Persist the final result event for programmatic capture without affecting screen output."""
445
+ if not capture_path or not self.last_result_event:
446
+ return
447
+ try:
448
+ Path(capture_path).write_text(
449
+ json.dumps(self.last_result_event, ensure_ascii=False),
450
+ encoding="utf-8"
451
+ )
452
+ except Exception as e:
453
+ print(f"Warning: Failed to write capture file: {e}", file=sys.stderr)
417
454
 
418
455
  # Resolve hidden stream types (ENV configurable)
419
456
  default_hidden = {"turn_diff", "token_count", "exec_command_output_delta"}
@@ -442,6 +479,57 @@ Environment Variables:
442
479
  universal_newlines=True
443
480
  )
444
481
 
482
+ # Watchdog thread: handles two scenarios where the stdout loop blocks:
483
+ # 1. Process exits but its stdout pipe stays open (inherited FDs)
484
+ # 2. Process itself never exits (hung event loop)
485
+ # The watchdog waits for an "output done" signal from the main thread
486
+ # (set when the stdout loop finishes or a completion event is received).
487
+ # Once signaled, it gives the process a grace period to exit, then
488
+ # terminates it and closes stdout.
489
+ wait_timeout = int(os.environ.get("CODEX_WAIT_TIMEOUT", "30"))
490
+ output_done = threading.Event()
491
+
492
+ def _stdout_watchdog():
493
+ """Terminate process and close stdout pipe if it hangs after output."""
494
+ # Wait until the main thread signals output is done,
495
+ # OR until the process exits on its own (poll every second).
496
+ while not output_done.is_set():
497
+ if process.poll() is not None:
498
+ break
499
+ output_done.wait(timeout=1)
500
+
501
+ if output_done.is_set() and process.poll() is None:
502
+ # Output is done but process hasn't exited — give it grace period.
503
+ try:
504
+ process.wait(timeout=wait_timeout)
505
+ except subprocess.TimeoutExpired:
506
+ print(
507
+ f"Warning: Codex process did not exit within {wait_timeout}s after output. Terminating.",
508
+ file=sys.stderr
509
+ )
510
+ process.terminate()
511
+ try:
512
+ process.wait(timeout=5)
513
+ except subprocess.TimeoutExpired:
514
+ print("Warning: Codex process did not respond to SIGTERM. Killing.", file=sys.stderr)
515
+ process.kill()
516
+ try:
517
+ process.wait(timeout=5)
518
+ except subprocess.TimeoutExpired:
519
+ pass
520
+
521
+ # Process has exited (naturally or forcefully).
522
+ # Grace period for remaining pipe data to flush, then close stdout.
523
+ time.sleep(2)
524
+ try:
525
+ if process.stdout and not process.stdout.closed:
526
+ process.stdout.close()
527
+ except Exception:
528
+ pass
529
+
530
+ watchdog = threading.Thread(target=_stdout_watchdog, daemon=True)
531
+ watchdog.start()
532
+
445
533
  def split_json_stream(text: str):
446
534
  objs = []
447
535
  buf: List[str] = []
@@ -495,6 +583,10 @@ Environment Variables:
495
583
  ):
496
584
  obj_dict["item"]["id"] = item_id_inner
497
585
 
586
+ # Track last agent_message for capture file (structured output)
587
+ if msg_type_inner in ("agent_message", "message", "assistant_message", "assistant"):
588
+ self.last_result_event = obj_dict
589
+
498
590
  if msg_type_inner == "token_count":
499
591
  last_token_count = obj_dict
500
592
  return # suppress
@@ -517,49 +609,59 @@ Environment Variables:
517
609
  pending = ""
518
610
 
519
611
  if process.stdout:
520
- for raw_line in process.stdout:
521
- combined = pending + raw_line
522
- if not combined.strip():
523
- pending = ""
524
- continue
525
-
526
- # If no braces present at all, treat as plain text (with suppression)
527
- if "{" not in combined and "}" not in combined:
528
- lower = combined.lower()
529
- if (
530
- '"token_count"' in lower
531
- or '"exec_command_output_delta"' in lower
532
- or '"turn_diff"' in lower
533
- ):
612
+ try:
613
+ for raw_line in process.stdout:
614
+ combined = pending + raw_line
615
+ if not combined.strip():
534
616
  pending = ""
535
617
  continue
536
- print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
537
- pending = ""
538
- continue
539
618
 
540
- # Preserve and emit any prefix before the first brace
541
- first_brace = combined.find("{")
542
- if first_brace > 0:
543
- prefix = combined[:first_brace]
544
- lower_prefix = prefix.lower()
545
- if (
546
- '"token_count"' not in lower_prefix
547
- and '"exec_command_output_delta"' not in lower_prefix
548
- and '"turn_diff"' not in lower_prefix
549
- and prefix.strip()
550
- ):
551
- print(prefix, end="" if prefix.endswith("\n") else "\n", flush=True)
552
- combined = combined[first_brace:]
553
-
554
- parts, pending = split_json_stream(combined)
619
+ # If no braces present at all, treat as plain text (with suppression)
620
+ if "{" not in combined and "}" not in combined:
621
+ lower = combined.lower()
622
+ if (
623
+ '"token_count"' in lower
624
+ or '"exec_command_output_delta"' in lower
625
+ or '"turn_diff"' in lower
626
+ ):
627
+ pending = ""
628
+ continue
629
+ print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
630
+ pending = ""
631
+ continue
555
632
 
556
- if parts:
557
- for part in parts:
558
- try:
559
- sub = json.loads(part)
560
- if isinstance(sub, dict):
561
- handle_obj(sub)
562
- else:
633
+ # Preserve and emit any prefix before the first brace
634
+ first_brace = combined.find("{")
635
+ if first_brace > 0:
636
+ prefix = combined[:first_brace]
637
+ lower_prefix = prefix.lower()
638
+ if (
639
+ '"token_count"' not in lower_prefix
640
+ and '"exec_command_output_delta"' not in lower_prefix
641
+ and '"turn_diff"' not in lower_prefix
642
+ and prefix.strip()
643
+ ):
644
+ print(prefix, end="" if prefix.endswith("\n") else "\n", flush=True)
645
+ combined = combined[first_brace:]
646
+
647
+ parts, pending = split_json_stream(combined)
648
+
649
+ if parts:
650
+ for part in parts:
651
+ try:
652
+ sub = json.loads(part)
653
+ if isinstance(sub, dict):
654
+ handle_obj(sub)
655
+ else:
656
+ low = part.lower()
657
+ if (
658
+ '"token_count"' in low
659
+ or '"exec_command_output_delta"' in low
660
+ or '"turn_diff"' in low
661
+ ):
662
+ continue
663
+ print(part, flush=True)
664
+ except Exception:
563
665
  low = part.lower()
564
666
  if (
565
667
  '"token_count"' in low
@@ -568,30 +670,29 @@ Environment Variables:
568
670
  ):
569
671
  continue
570
672
  print(part, flush=True)
571
- except Exception:
572
- low = part.lower()
573
- if (
574
- '"token_count"' in low
575
- or '"exec_command_output_delta"' in low
576
- or '"turn_diff"' in low
577
- ):
578
- continue
579
- print(part, flush=True)
580
- continue
673
+ continue
581
674
 
582
- # No complete object found yet; keep buffering if likely in the middle of one
583
- if pending:
584
- continue
675
+ # No complete object found yet; keep buffering if likely in the middle of one
676
+ if pending:
677
+ continue
585
678
 
586
- # Fallback for malformed/non-JSON lines that still contain braces
587
- lower = combined.lower()
588
- if (
589
- '"token_count"' in lower
590
- or '"exec_command_output_delta"' in lower
591
- or '"turn_diff"' in lower
592
- ):
593
- continue
594
- print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
679
+ # Fallback for malformed/non-JSON lines that still contain braces
680
+ lower = combined.lower()
681
+ if (
682
+ '"token_count"' in lower
683
+ or '"exec_command_output_delta"' in lower
684
+ or '"turn_diff"' in lower
685
+ ):
686
+ continue
687
+ print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
688
+ except ValueError:
689
+ # Watchdog closed stdout because the process exited but the
690
+ # pipe stayed open (inherited FDs from child processes).
691
+ # This is expected — all output has been consumed.
692
+ pass
693
+
694
+ # Signal watchdog that output is done (stdout loop has exited).
695
+ output_done.set()
595
696
 
596
697
  # Flush any pending buffered content after the stream ends
597
698
  if pending.strip():
@@ -610,8 +711,13 @@ Environment Variables:
610
711
  ):
611
712
  print(pending, flush=True)
612
713
 
613
- # Wait for process completion
614
- process.wait()
714
+ # Ensure process has exited. The watchdog thread handles termination
715
+ # if the process hangs, so by this point it should already be dead.
716
+ # Use a short timeout as a safety net.
717
+ try:
718
+ process.wait(timeout=5)
719
+ except subprocess.TimeoutExpired:
720
+ pass # Watchdog thread will handle cleanup
615
721
 
616
722
  # Do not emit token_count summary; fully suppressed per user feedback
617
723
 
@@ -621,18 +727,25 @@ Environment Variables:
621
727
  if stderr_output:
622
728
  print(stderr_output, file=sys.stderr)
623
729
 
730
+ write_capture_file()
624
731
  return process.returncode
625
732
 
626
733
  except KeyboardInterrupt:
627
734
  print("\nInterrupted by user", file=sys.stderr)
735
+ write_capture_file()
628
736
  try:
629
737
  process.terminate()
630
- process.wait()
738
+ try:
739
+ process.wait(timeout=5)
740
+ except subprocess.TimeoutExpired:
741
+ process.kill()
742
+ process.wait(timeout=5)
631
743
  except Exception:
632
744
  pass
633
745
  return 130
634
746
  except Exception as e:
635
747
  print(f"Error executing codex: {e}", file=sys.stderr)
748
+ write_capture_file()
636
749
  return 1
637
750
 
638
751
  def run(self) -> int: