juno-code 1.0.33 → 1.0.35

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.
@@ -51,6 +51,7 @@ class ClaudeService:
51
51
  self.verbose = False
52
52
  # User message truncation: -1 = no truncation, N = truncate to N lines
53
53
  self.user_message_truncate = int(os.environ.get("CLAUDE_USER_MESSAGE_PRETTY_TRUNCATE", "4"))
54
+ self.last_result_event: Optional[Dict[str, Any]] = None
54
55
 
55
56
  def expand_model_shorthand(self, model: str) -> str:
56
57
  """
@@ -529,6 +530,20 @@ Environment Variables:
529
530
  print(f"Executing: {' '.join(cmd)}", file=sys.stderr)
530
531
  print("-" * 80, file=sys.stderr)
531
532
 
533
+ capture_path = os.environ.get("JUNO_SUBAGENT_CAPTURE_PATH")
534
+
535
+ def write_capture_file():
536
+ """Persist the final result event for programmatic capture without affecting screen output."""
537
+ if not capture_path or not self.last_result_event:
538
+ return
539
+ try:
540
+ Path(capture_path).write_text(
541
+ json.dumps(self.last_result_event, ensure_ascii=False),
542
+ encoding="utf-8"
543
+ )
544
+ except Exception as e:
545
+ print(f"Warning: Failed to write capture file: {e}", file=sys.stderr)
546
+
532
547
  try:
533
548
  # Change to project directory before running
534
549
  original_cwd = os.getcwd()
@@ -549,9 +564,19 @@ Environment Variables:
549
564
  # This allows users to pipe to jq and see output as it streams
550
565
  if process.stdout:
551
566
  for line in process.stdout:
567
+ raw_line = line.strip()
568
+ # Capture the raw final result event for programmatic consumption
569
+ try:
570
+ parsed_raw = json.loads(raw_line)
571
+ if isinstance(parsed_raw, dict) and parsed_raw.get("type") == "result":
572
+ self.last_result_event = parsed_raw
573
+ except json.JSONDecodeError:
574
+ # Ignore non-JSON lines here; pretty formatter will handle them
575
+ pass
576
+
552
577
  # Apply pretty formatting if enabled
553
578
  if pretty:
554
- formatted_line = self.pretty_format_json(line.strip())
579
+ formatted_line = self.pretty_format_json(raw_line)
555
580
  if formatted_line:
556
581
  print(formatted_line, flush=True)
557
582
  else:
@@ -567,6 +592,9 @@ Environment Variables:
567
592
  if stderr_output:
568
593
  print(stderr_output, file=sys.stderr)
569
594
 
595
+ # Persist the raw final result event for programmatic capture
596
+ write_capture_file()
597
+
570
598
  # Restore original working directory
571
599
  os.chdir(original_cwd)
572
600
 
@@ -577,12 +605,14 @@ Environment Variables:
577
605
  if process:
578
606
  process.terminate()
579
607
  process.wait()
608
+ write_capture_file()
580
609
  # Restore original working directory
581
610
  if 'original_cwd' in locals():
582
611
  os.chdir(original_cwd)
583
612
  return 130
584
613
  except Exception as e:
585
614
  print(f"Error executing claude: {e}", file=sys.stderr)
615
+ write_capture_file()
586
616
  # Restore original working directory
587
617
  if 'original_cwd' in locals():
588
618
  os.chdir(original_cwd)
@@ -34,6 +34,7 @@ class CodexService:
34
34
  self.prompt = ""
35
35
  self.additional_args: List[str] = []
36
36
  self.verbose = False
37
+ self._item_counter = 0
37
38
 
38
39
  def expand_model_shorthand(self, model: str) -> str:
39
40
  """
@@ -128,6 +129,108 @@ Environment Variables:
128
129
 
129
130
  return parser.parse_args()
130
131
 
132
+ def _first_nonempty_str(self, *values: Optional[str]) -> str:
133
+ """Return the first non-empty string value."""
134
+ for val in values:
135
+ if isinstance(val, str) and val != "":
136
+ return val
137
+ return ""
138
+
139
+ def _extract_content_text(self, payload: dict) -> str:
140
+ """Join text-like fields from content arrays (item.* schema)."""
141
+ content = payload.get("content") if isinstance(payload, dict) else None
142
+ parts: List[str] = []
143
+ if isinstance(content, list):
144
+ for entry in content:
145
+ if not isinstance(entry, dict):
146
+ continue
147
+ text_val = (
148
+ entry.get("text")
149
+ or entry.get("message")
150
+ or entry.get("output_text")
151
+ or entry.get("input_text")
152
+ )
153
+ if isinstance(text_val, str) and text_val != "":
154
+ parts.append(text_val)
155
+ return "\n".join(parts) if parts else ""
156
+
157
+ def _extract_command_output_text(self, payload: dict) -> str:
158
+ """Extract aggregated/command output from various item.* layouts."""
159
+ if not isinstance(payload, dict):
160
+ return ""
161
+ result = payload.get("result") if isinstance(payload.get("result"), dict) else None
162
+ content_text = self._extract_content_text(payload)
163
+ return self._first_nonempty_str(
164
+ payload.get("aggregated_output"),
165
+ payload.get("output"),
166
+ payload.get("formatted_output"),
167
+ result.get("aggregated_output") if result else None,
168
+ result.get("output") if result else None,
169
+ result.get("formatted_output") if result else None,
170
+ content_text,
171
+ )
172
+
173
+ def _extract_reasoning_text(self, payload: dict) -> str:
174
+ """Extract reasoning text from legacy and item.* schemas."""
175
+ if not isinstance(payload, dict):
176
+ return ""
177
+ reasoning_obj = payload.get("reasoning") if isinstance(payload.get("reasoning"), dict) else None
178
+ result_obj = payload.get("result") if isinstance(payload.get("result"), dict) else None
179
+ content_text = self._extract_content_text(payload)
180
+ return self._first_nonempty_str(
181
+ payload.get("text"),
182
+ payload.get("reasoning_text"),
183
+ reasoning_obj.get("text") if reasoning_obj else None,
184
+ result_obj.get("text") if result_obj else None,
185
+ content_text,
186
+ )
187
+
188
+ def _extract_message_text(self, payload: dict) -> str:
189
+ """Extract final/assistant message text from item.* schemas."""
190
+ if not isinstance(payload, dict):
191
+ return ""
192
+ result_obj = payload.get("result") if isinstance(payload.get("result"), dict) else None
193
+ content_text = self._extract_content_text(payload)
194
+ return self._first_nonempty_str(
195
+ payload.get("message"),
196
+ payload.get("text"),
197
+ payload.get("final"),
198
+ result_obj.get("message") if result_obj else None,
199
+ result_obj.get("text") if result_obj else None,
200
+ content_text,
201
+ )
202
+
203
+ def _parse_item_number(self, item_id: str) -> Optional[int]:
204
+ """Return numeric component from item_{n} ids or None if unparseable."""
205
+ if not isinstance(item_id, str):
206
+ return None
207
+ item_id = item_id.strip()
208
+ if not item_id.startswith("item_"):
209
+ return None
210
+ try:
211
+ return int(item_id.split("item_", 1)[1])
212
+ except Exception:
213
+ return None
214
+
215
+ def _normalize_item_id(self, payload: dict, outer_type: str) -> Optional[str]:
216
+ """
217
+ Prefer the existing id on item.* payloads; otherwise synthesize sequential item_{n}.
218
+ Maintains a per-run counter so missing ids still expose turn counts.
219
+ """
220
+ item_id = payload.get("id") if isinstance(payload, dict) else None
221
+ if isinstance(item_id, str) and item_id.strip():
222
+ parsed = self._parse_item_number(item_id)
223
+ if parsed is not None and parsed + 1 > self._item_counter:
224
+ self._item_counter = parsed + 1
225
+ return item_id.strip()
226
+
227
+ if isinstance(outer_type, str) and outer_type.startswith("item."):
228
+ generated = f"item_{self._item_counter}"
229
+ self._item_counter += 1
230
+ return generated
231
+
232
+ return None
233
+
131
234
  def read_prompt_file(self, file_path: str) -> str:
132
235
  """Read prompt from a file"""
133
236
  try:
@@ -190,12 +293,18 @@ Environment Variables:
190
293
 
191
294
  return cmd
192
295
 
193
- def _format_msg_pretty(self, obj: dict) -> Optional[str]:
296
+ def _format_msg_pretty(
297
+ self,
298
+ msg_type: str,
299
+ payload: dict,
300
+ outer_type: str = "",
301
+ item_id: Optional[str] = None,
302
+ ) -> Optional[str]:
194
303
  """
195
304
  Pretty format for specific msg types to be human readable while
196
305
  preserving a compact JSON header line that includes the msg.type.
197
306
 
198
- - agent_message: render 'message' field as multi-line text
307
+ - agent_message/message/assistant: render message text as multi-line block
199
308
  - agent_reasoning: render 'text' field as multi-line text
200
309
  - exec_command_end: only output 'formatted_output' (suppress other fields)
201
310
  - token_count: fully suppressed (no final summary emission)
@@ -203,41 +312,96 @@ Environment Variables:
203
312
  Returns a string to print, or None to fall back to raw printing.
204
313
  """
205
314
  try:
206
- msg = obj.get("msg") or {}
207
- msg_type = (msg.get("type") or "").strip()
208
315
  now = datetime.now().strftime("%I:%M:%S %p")
209
-
210
- # agent_message show 'message' human-readable
211
- if msg_type == "agent_message":
212
- content = msg.get("message", "")
213
- header = {"type": msg_type, "datetime": now}
214
- if "\n" in content:
215
- return json.dumps(header, ensure_ascii=False) + "\nmessage:\n" + content
216
- header["message"] = content
217
- return json.dumps(header, ensure_ascii=False)
316
+ msg_type = (msg_type or "").strip()
317
+ header_type = (outer_type or msg_type).strip()
318
+ base_type = header_type or msg_type or "message"
319
+
320
+ def make_header(type_value: str):
321
+ hdr = {"type": type_value, "datetime": now}
322
+ if item_id:
323
+ hdr["id"] = item_id
324
+ if outer_type and msg_type and outer_type != msg_type:
325
+ hdr["item_type"] = msg_type
326
+ return hdr
327
+
328
+ header = make_header(base_type)
329
+
330
+ if isinstance(payload, dict):
331
+ if item_id and "id" not in payload:
332
+ payload["id"] = item_id
333
+ if payload.get("command"):
334
+ header["command"] = payload.get("command")
335
+ if payload.get("status"):
336
+ header["status"] = payload.get("status")
337
+ if payload.get("state") and not header.get("status"):
338
+ header["status"] = payload.get("state")
218
339
 
219
340
  # agent_reasoning → show 'text' human-readable
220
- if msg_type == "agent_reasoning":
221
- content = msg.get("text", "")
222
- header = {"type": msg_type, "datetime": now}
341
+ if msg_type in {"agent_reasoning", "reasoning"}:
342
+ content = self._extract_reasoning_text(payload)
343
+ header = make_header(header_type or msg_type)
223
344
  if "\n" in content:
224
345
  return json.dumps(header, ensure_ascii=False) + "\ntext:\n" + content
225
346
  header["text"] = content
226
347
  return json.dumps(header, ensure_ascii=False)
227
348
 
349
+ if msg_type in {"agent_message", "message", "assistant_message", "assistant"}:
350
+ content = self._extract_message_text(payload)
351
+ header = make_header(header_type or msg_type)
352
+ if "\n" in content:
353
+ return json.dumps(header, ensure_ascii=False) + "\nmessage:\n" + content
354
+ if content != "":
355
+ header["message"] = content
356
+ return json.dumps(header, ensure_ascii=False)
357
+ if header_type:
358
+ return json.dumps(header, ensure_ascii=False)
359
+
228
360
  # exec_command_end → only show 'formatted_output'
229
361
  if msg_type == "exec_command_end":
230
- formatted_output = msg.get("formatted_output", "")
362
+ formatted_output = payload.get("formatted_output", "") if isinstance(payload, dict) else ""
231
363
  header = {"type": msg_type, "datetime": now}
232
364
  if "\n" in formatted_output:
233
365
  return json.dumps(header, ensure_ascii=False) + "\nformatted_output:\n" + formatted_output
234
366
  header["formatted_output"] = formatted_output
235
367
  return json.dumps(header, ensure_ascii=False)
236
368
 
369
+ # item.* schema → command_execution blocks
370
+ if msg_type == "command_execution":
371
+ aggregated_output = self._extract_command_output_text(payload)
372
+ if "\n" in aggregated_output:
373
+ return json.dumps(header, ensure_ascii=False) + "\naggregated_output:\n" + aggregated_output
374
+ if aggregated_output:
375
+ header["aggregated_output"] = aggregated_output
376
+ return json.dumps(header, ensure_ascii=False)
377
+ # No output (likely item.started) – still show header if it carries context
378
+ if header_type:
379
+ return json.dumps(header, ensure_ascii=False)
380
+
237
381
  return None
238
382
  except Exception:
239
383
  return None
240
384
 
385
+ def _normalize_event(self, obj_dict: dict):
386
+ """
387
+ Normalize legacy (msg-based) and new item.* schemas into a common tuple.
388
+ Returns (msg_type, payload_dict, outer_type).
389
+ """
390
+ msg = obj_dict.get("msg") if isinstance(obj_dict.get("msg"), dict) else {}
391
+ outer_type = (obj_dict.get("type") or "").strip()
392
+ item = obj_dict.get("item") if isinstance(obj_dict.get("item"), dict) else None
393
+
394
+ msg_type = (msg.get("type") or "").strip() if isinstance(msg, dict) else ""
395
+ payload = msg if isinstance(msg, dict) else {}
396
+
397
+ if not msg_type and item is not None:
398
+ msg_type = (item.get("type") or "").strip() or outer_type
399
+ payload = item
400
+ elif not msg_type:
401
+ msg_type = outer_type
402
+
403
+ return msg_type, payload, outer_type
404
+
241
405
  def run_codex(self, cmd: List[str], verbose: bool = False) -> int:
242
406
  """Execute the codex command and stream output with filtering and pretty-printing
243
407
 
@@ -260,6 +424,9 @@ Environment Variables:
260
424
  parts = [p.strip() for p in env_val.split(",") if p.strip()]
261
425
  hide_types.update(parts)
262
426
 
427
+ # Reset per-run item counter for synthesized ids
428
+ self._item_counter = 0
429
+
263
430
  # We fully suppress all token_count events (do not emit even at end)
264
431
  last_token_count = None
265
432
 
@@ -274,119 +441,124 @@ Environment Variables:
274
441
  universal_newlines=True
275
442
  )
276
443
 
444
+ def split_json_stream(text: str):
445
+ objs = []
446
+ buf: List[str] = []
447
+ depth = 0
448
+ in_str = False
449
+ esc = False
450
+ started = False
451
+ for ch in text:
452
+ if in_str:
453
+ buf.append(ch)
454
+ if esc:
455
+ esc = False
456
+ elif ch == '\\':
457
+ esc = True
458
+ elif ch == '"':
459
+ in_str = False
460
+ continue
461
+ if ch == '"':
462
+ in_str = True
463
+ buf.append(ch)
464
+ continue
465
+ if ch == '{':
466
+ depth += 1
467
+ started = True
468
+ buf.append(ch)
469
+ continue
470
+ if ch == '}':
471
+ depth -= 1
472
+ buf.append(ch)
473
+ if started and depth == 0:
474
+ candidate = ''.join(buf).strip().strip("'\"")
475
+ if candidate:
476
+ objs.append(candidate)
477
+ buf = []
478
+ started = False
479
+ continue
480
+ if started:
481
+ buf.append(ch)
482
+ remainder = ''.join(buf) if buf else ""
483
+ return objs, remainder
484
+
485
+ def handle_obj(obj_dict: dict):
486
+ nonlocal last_token_count
487
+ msg_type_inner, payload_inner, outer_type_inner = self._normalize_event(obj_dict)
488
+ item_id_inner = self._normalize_item_id(payload_inner, outer_type_inner)
489
+
490
+ if (
491
+ item_id_inner
492
+ and isinstance(obj_dict.get("item"), dict)
493
+ and not obj_dict["item"].get("id")
494
+ ):
495
+ obj_dict["item"]["id"] = item_id_inner
496
+
497
+ if msg_type_inner == "token_count":
498
+ last_token_count = obj_dict
499
+ return # suppress
500
+
501
+ if msg_type_inner and msg_type_inner in hide_types:
502
+ return # suppress
503
+
504
+ pretty_line_inner = self._format_msg_pretty(
505
+ msg_type_inner,
506
+ payload_inner,
507
+ outer_type_inner,
508
+ item_id=item_id_inner,
509
+ )
510
+ if pretty_line_inner is not None:
511
+ print(pretty_line_inner, flush=True)
512
+ else:
513
+ # print normalized JSON
514
+ print(json.dumps(obj_dict, ensure_ascii=False), flush=True)
515
+
516
+ pending = ""
517
+
277
518
  if process.stdout:
278
519
  for raw_line in process.stdout:
279
- line = raw_line.rstrip("\n")
280
- if not line:
520
+ combined = pending + raw_line
521
+ if not combined.strip():
522
+ pending = ""
281
523
  continue
282
- # Try to parse NDJSON and filter by msg.type
283
- try:
284
- obj = None
285
- s = line.strip()
286
524
 
287
- # Direct JSON line
288
- if s.startswith("{") and s.endswith("}"):
289
- try:
290
- obj = json.loads(s)
291
- except Exception:
292
- obj = None
293
-
294
- # Attempt to extract JSON object substring if noise surrounds it
295
- if obj is None:
296
- lbrace = s.find("{")
297
- rbrace = s.rfind("}")
298
- if lbrace != -1 and rbrace != -1 and rbrace > lbrace:
299
- candidate = s[lbrace:rbrace + 1]
300
- try:
301
- obj = json.loads(candidate)
302
- s = candidate # normalized JSON text
303
- except Exception:
304
- obj = None
305
-
306
- def handle_obj(obj_dict: dict):
307
- nonlocal last_token_count
308
- msg = obj_dict.get("msg") or {}
309
- msg_type_inner = (msg.get("type") or "").strip()
310
-
311
- if msg_type_inner == "token_count":
312
- last_token_count = obj_dict
313
- return # suppress
314
-
315
- if msg_type_inner and msg_type_inner in hide_types:
316
- return # suppress
317
-
318
- pretty_line_inner = self._format_msg_pretty(obj_dict)
319
- if pretty_line_inner is not None:
320
- print(pretty_line_inner, flush=True)
321
- else:
322
- # print normalized JSON
323
- print(json.dumps(obj_dict, ensure_ascii=False), flush=True)
324
-
325
- if isinstance(obj, dict):
326
- handle_obj(obj)
525
+ # If no braces present at all, treat as plain text (with suppression)
526
+ if "{" not in combined and "}" not in combined:
527
+ lower = combined.lower()
528
+ if (
529
+ '"token_count"' in lower
530
+ or '"exec_command_output_delta"' in lower
531
+ or '"turn_diff"' in lower
532
+ ):
533
+ pending = ""
327
534
  continue
328
-
329
- # If line appears to contain multiple concatenated JSON objects,
330
- # split by top-level brace balancing while respecting strings
331
- def split_concatenated_json(text: str):
332
- objs = []
333
- buf = []
334
- depth = 0
335
- in_str = False
336
- esc = False
337
- started = False
338
- for ch in text:
339
- if in_str:
340
- buf.append(ch)
341
- if esc:
342
- esc = False
343
- elif ch == '\\':
344
- esc = True
345
- elif ch == '"':
346
- in_str = False
347
- continue
348
- # not in string
349
- if ch == '"':
350
- in_str = True
351
- buf.append(ch)
352
- continue
353
- if ch == '{':
354
- depth += 1
355
- started = True
356
- buf.append(ch)
357
- continue
358
- if ch == '}':
359
- depth -= 1
360
- buf.append(ch)
361
- if started and depth == 0:
362
- candidate = ''.join(buf).strip().strip("'\"")
363
- if candidate:
364
- objs.append(candidate)
365
- buf = []
366
- started = False
367
- continue
368
- # accumulate only if currently collecting an object
369
- if started:
370
- buf.append(ch)
371
- return objs
372
-
373
- parts = split_concatenated_json(s)
374
- if parts:
375
- for part in parts:
376
- try:
377
- sub = json.loads(part)
378
- if isinstance(sub, dict):
379
- handle_obj(sub)
380
- else:
381
- low = part.lower()
382
- if (
383
- '"token_count"' in low
384
- or '"exec_command_output_delta"' in low
385
- or '"turn_diff"' in low
386
- ):
387
- continue
388
- print(part, flush=True)
389
- except Exception:
535
+ print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
536
+ pending = ""
537
+ continue
538
+
539
+ # Preserve and emit any prefix before the first brace
540
+ first_brace = combined.find("{")
541
+ if first_brace > 0:
542
+ prefix = combined[:first_brace]
543
+ lower_prefix = prefix.lower()
544
+ if (
545
+ '"token_count"' not in lower_prefix
546
+ and '"exec_command_output_delta"' not in lower_prefix
547
+ and '"turn_diff"' not in lower_prefix
548
+ and prefix.strip()
549
+ ):
550
+ print(prefix, end="" if prefix.endswith("\n") else "\n", flush=True)
551
+ combined = combined[first_brace:]
552
+
553
+ parts, pending = split_json_stream(combined)
554
+
555
+ if parts:
556
+ for part in parts:
557
+ try:
558
+ sub = json.loads(part)
559
+ if isinstance(sub, dict):
560
+ handle_obj(sub)
561
+ else:
390
562
  low = part.lower()
391
563
  if (
392
564
  '"token_count"' in low
@@ -395,31 +567,47 @@ Environment Variables:
395
567
  ):
396
568
  continue
397
569
  print(part, flush=True)
398
- continue
399
- else:
400
- # If not valid JSON, suppress known noisy tokens by string search
401
- lower = s.lower()
402
- if (
403
- '"type"' in lower
404
- and (
405
- '"token_count"' in lower
406
- or '"exec_command_output_delta"' in lower
407
- or '"turn_diff"' in lower
408
- )
409
- ):
410
- # Best-effort suppression for malformed lines containing noisy types
411
- continue
412
- # Not JSON and not noisy → passthrough
413
- print(raw_line, end="", flush=True)
414
- except Exception:
415
- # On parsing error, last-gasp suppression by substring match
416
- if (
417
- '"token_count"' in raw_line
418
- or '"exec_command_output_delta"' in raw_line
419
- or '"turn_diff"' in raw_line
420
- ):
421
- continue
422
- print(raw_line, end="", flush=True)
570
+ except Exception:
571
+ low = part.lower()
572
+ if (
573
+ '"token_count"' in low
574
+ or '"exec_command_output_delta"' in low
575
+ or '"turn_diff"' in low
576
+ ):
577
+ continue
578
+ print(part, flush=True)
579
+ continue
580
+
581
+ # No complete object found yet; keep buffering if likely in the middle of one
582
+ if pending:
583
+ continue
584
+
585
+ # Fallback for malformed/non-JSON lines that still contain braces
586
+ lower = combined.lower()
587
+ if (
588
+ '"token_count"' in lower
589
+ or '"exec_command_output_delta"' in lower
590
+ or '"turn_diff"' in lower
591
+ ):
592
+ continue
593
+ print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
594
+
595
+ # Flush any pending buffered content after the stream ends
596
+ if pending.strip():
597
+ try:
598
+ tail_obj = json.loads(pending)
599
+ if isinstance(tail_obj, dict):
600
+ handle_obj(tail_obj)
601
+ else:
602
+ print(pending, flush=True)
603
+ except Exception:
604
+ low_tail = pending.lower()
605
+ if (
606
+ '"token_count"' not in low_tail
607
+ and '"exec_command_output_delta"' not in low_tail
608
+ and '"turn_diff"' not in low_tail
609
+ ):
610
+ print(pending, flush=True)
423
611
 
424
612
  # Wait for process completion
425
613
  process.wait()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juno-code",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "description": "TypeScript CLI tool for AI subagent orchestration with code automation",
5
5
  "keywords": [
6
6
  "ai",