juno-code 1.0.47 → 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.
- package/README.md +44 -8
- package/dist/bin/cli.d.mts +17 -0
- package/dist/bin/cli.d.ts +17 -0
- package/dist/bin/cli.js +5606 -17514
- package/dist/bin/cli.js.map +1 -1
- package/dist/bin/cli.mjs +5647 -17553
- package/dist/bin/cli.mjs.map +1 -1
- package/dist/bin/feedback-collector.d.mts +2 -0
- package/dist/bin/feedback-collector.d.ts +2 -0
- package/dist/bin/feedback-collector.js.map +1 -1
- package/dist/bin/feedback-collector.mjs.map +1 -1
- package/dist/index.d.mts +2107 -0
- package/dist/index.d.ts +2107 -0
- package/dist/index.js +3760 -14730
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3760 -14537
- package/dist/index.mjs.map +1 -1
- package/dist/templates/extensions/pi/juno-skill-preprocessor.ts +239 -0
- package/dist/templates/scripts/__pycache__/github.cpython-313.pyc +0 -0
- package/dist/templates/scripts/__pycache__/parallel_runner.cpython-313.pyc +0 -0
- package/dist/templates/scripts/__pycache__/slack_respond.cpython-313.pyc +0 -0
- package/dist/templates/scripts/kanban.sh +18 -4
- package/dist/templates/scripts/parallel_runner.sh +2242 -0
- package/dist/templates/services/README.md +61 -1
- package/dist/templates/services/__pycache__/claude.cpython-313.pyc +0 -0
- package/dist/templates/services/__pycache__/codex.cpython-313.pyc +0 -0
- package/dist/templates/services/__pycache__/pi.cpython-313.pyc +0 -0
- package/dist/templates/services/claude.py +132 -33
- package/dist/templates/services/codex.py +179 -66
- package/dist/templates/services/gemini.py +117 -27
- package/dist/templates/services/pi.py +1753 -0
- package/dist/templates/skills/claude/plan-kanban-tasks/SKILL.md +14 -7
- package/dist/templates/skills/claude/ralph-loop/SKILL.md +18 -22
- package/dist/templates/skills/claude/ralph-loop/references/first_check.md +15 -14
- package/dist/templates/skills/claude/ralph-loop/references/implement.md +17 -17
- package/dist/templates/skills/claude/ralph-loop/scripts/kanban.sh +18 -4
- package/dist/templates/skills/claude/understand-project/SKILL.md +14 -7
- package/dist/templates/skills/codex/ralph-loop/SKILL.md +18 -22
- package/dist/templates/skills/codex/ralph-loop/references/first_check.md +15 -14
- package/dist/templates/skills/codex/ralph-loop/references/implement.md +17 -17
- package/dist/templates/skills/codex/ralph-loop/scripts/kanban.sh +18 -4
- package/dist/templates/skills/pi/.gitkeep +0 -0
- package/dist/templates/skills/pi/plan-kanban-tasks/SKILL.md +32 -0
- package/dist/templates/skills/pi/ralph-loop/SKILL.md +39 -0
- package/dist/templates/skills/pi/ralph-loop/references/first_check.md +21 -0
- package/dist/templates/skills/pi/ralph-loop/references/implement.md +99 -0
- package/dist/templates/skills/pi/understand-project/SKILL.md +46 -0
- package/package.json +20 -42
- package/dist/templates/scripts/__pycache__/attachment_downloader.cpython-38.pyc +0 -0
- package/dist/templates/scripts/__pycache__/github.cpython-38.pyc +0 -0
- package/dist/templates/scripts/__pycache__/slack_fetch.cpython-38.pyc +0 -0
- package/dist/templates/scripts/__pycache__/slack_state.cpython-38.pyc +0 -0
- package/dist/templates/services/__pycache__/claude.cpython-38.pyc +0 -0
- 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/
|
|
374
|
+
https://github.com/askbudi/juno-code/issues
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
675
|
+
# No complete object found yet; keep buffering if likely in the middle of one
|
|
676
|
+
if pending:
|
|
677
|
+
continue
|
|
585
678
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
#
|
|
614
|
-
process.
|
|
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
|
-
|
|
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:
|