juno-code 1.0.33 → 1.0.34

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)
@@ -128,6 +128,77 @@ Environment Variables:
128
128
 
129
129
  return parser.parse_args()
130
130
 
131
+ def _first_nonempty_str(self, *values: Optional[str]) -> str:
132
+ """Return the first non-empty string value."""
133
+ for val in values:
134
+ if isinstance(val, str) and val != "":
135
+ return val
136
+ return ""
137
+
138
+ def _extract_content_text(self, payload: dict) -> str:
139
+ """Join text-like fields from content arrays (item.* schema)."""
140
+ content = payload.get("content") if isinstance(payload, dict) else None
141
+ parts: List[str] = []
142
+ if isinstance(content, list):
143
+ for entry in content:
144
+ if not isinstance(entry, dict):
145
+ continue
146
+ text_val = (
147
+ entry.get("text")
148
+ or entry.get("message")
149
+ or entry.get("output_text")
150
+ or entry.get("input_text")
151
+ )
152
+ if isinstance(text_val, str) and text_val != "":
153
+ parts.append(text_val)
154
+ return "\n".join(parts) if parts else ""
155
+
156
+ def _extract_command_output_text(self, payload: dict) -> str:
157
+ """Extract aggregated/command output from various item.* layouts."""
158
+ if not isinstance(payload, dict):
159
+ return ""
160
+ result = payload.get("result") if isinstance(payload.get("result"), dict) else None
161
+ content_text = self._extract_content_text(payload)
162
+ return self._first_nonempty_str(
163
+ payload.get("aggregated_output"),
164
+ payload.get("output"),
165
+ payload.get("formatted_output"),
166
+ result.get("aggregated_output") if result else None,
167
+ result.get("output") if result else None,
168
+ result.get("formatted_output") if result else None,
169
+ content_text,
170
+ )
171
+
172
+ def _extract_reasoning_text(self, payload: dict) -> str:
173
+ """Extract reasoning text from legacy and item.* schemas."""
174
+ if not isinstance(payload, dict):
175
+ return ""
176
+ reasoning_obj = payload.get("reasoning") if isinstance(payload.get("reasoning"), dict) else None
177
+ result_obj = payload.get("result") if isinstance(payload.get("result"), dict) else None
178
+ content_text = self._extract_content_text(payload)
179
+ return self._first_nonempty_str(
180
+ payload.get("text"),
181
+ payload.get("reasoning_text"),
182
+ reasoning_obj.get("text") if reasoning_obj else None,
183
+ result_obj.get("text") if result_obj else None,
184
+ content_text,
185
+ )
186
+
187
+ def _extract_message_text(self, payload: dict) -> str:
188
+ """Extract final/assistant message text from item.* schemas."""
189
+ if not isinstance(payload, dict):
190
+ return ""
191
+ result_obj = payload.get("result") if isinstance(payload.get("result"), dict) else None
192
+ content_text = self._extract_content_text(payload)
193
+ return self._first_nonempty_str(
194
+ payload.get("message"),
195
+ payload.get("text"),
196
+ payload.get("final"),
197
+ result_obj.get("message") if result_obj else None,
198
+ result_obj.get("text") if result_obj else None,
199
+ content_text,
200
+ )
201
+
131
202
  def read_prompt_file(self, file_path: str) -> str:
132
203
  """Read prompt from a file"""
133
204
  try:
@@ -190,12 +261,17 @@ Environment Variables:
190
261
 
191
262
  return cmd
192
263
 
193
- def _format_msg_pretty(self, obj: dict) -> Optional[str]:
264
+ def _format_msg_pretty(
265
+ self,
266
+ msg_type: str,
267
+ payload: dict,
268
+ outer_type: str = "",
269
+ ) -> Optional[str]:
194
270
  """
195
271
  Pretty format for specific msg types to be human readable while
196
272
  preserving a compact JSON header line that includes the msg.type.
197
273
 
198
- - agent_message: render 'message' field as multi-line text
274
+ - agent_message/message/assistant: render message text as multi-line block
199
275
  - agent_reasoning: render 'text' field as multi-line text
200
276
  - exec_command_end: only output 'formatted_output' (suppress other fields)
201
277
  - token_count: fully suppressed (no final summary emission)
@@ -203,41 +279,91 @@ Environment Variables:
203
279
  Returns a string to print, or None to fall back to raw printing.
204
280
  """
205
281
  try:
206
- msg = obj.get("msg") or {}
207
- msg_type = (msg.get("type") or "").strip()
208
282
  now = datetime.now().strftime("%I:%M:%S %p")
283
+ msg_type = (msg_type or "").strip()
284
+ header_type = (outer_type or msg_type).strip()
285
+ header = {"type": header_type or msg_type or "message", "datetime": now}
209
286
 
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)
287
+ if outer_type and msg_type and outer_type != msg_type:
288
+ header["item_type"] = msg_type
289
+
290
+ if isinstance(payload, dict):
291
+ if payload.get("command"):
292
+ header["command"] = payload.get("command")
293
+ if payload.get("status"):
294
+ header["status"] = payload.get("status")
295
+ if payload.get("state") and not header.get("status"):
296
+ header["status"] = payload.get("state")
218
297
 
219
298
  # agent_reasoning → show 'text' human-readable
220
- if msg_type == "agent_reasoning":
221
- content = msg.get("text", "")
222
- header = {"type": msg_type, "datetime": now}
299
+ if msg_type in {"agent_reasoning", "reasoning"}:
300
+ content = self._extract_reasoning_text(payload)
301
+ header = {"type": header_type or msg_type, "datetime": now}
302
+ if outer_type and msg_type and outer_type != msg_type:
303
+ header["item_type"] = msg_type
223
304
  if "\n" in content:
224
305
  return json.dumps(header, ensure_ascii=False) + "\ntext:\n" + content
225
306
  header["text"] = content
226
307
  return json.dumps(header, ensure_ascii=False)
227
308
 
309
+ if msg_type in {"agent_message", "message", "assistant_message", "assistant"}:
310
+ content = self._extract_message_text(payload)
311
+ header = {"type": header_type or msg_type, "datetime": now}
312
+ if outer_type and msg_type and outer_type != msg_type:
313
+ header["item_type"] = msg_type
314
+ if "\n" in content:
315
+ return json.dumps(header, ensure_ascii=False) + "\nmessage:\n" + content
316
+ if content != "":
317
+ header["message"] = content
318
+ return json.dumps(header, ensure_ascii=False)
319
+ if header_type:
320
+ return json.dumps(header, ensure_ascii=False)
321
+
228
322
  # exec_command_end → only show 'formatted_output'
229
323
  if msg_type == "exec_command_end":
230
- formatted_output = msg.get("formatted_output", "")
324
+ formatted_output = payload.get("formatted_output", "") if isinstance(payload, dict) else ""
231
325
  header = {"type": msg_type, "datetime": now}
232
326
  if "\n" in formatted_output:
233
327
  return json.dumps(header, ensure_ascii=False) + "\nformatted_output:\n" + formatted_output
234
328
  header["formatted_output"] = formatted_output
235
329
  return json.dumps(header, ensure_ascii=False)
236
330
 
331
+ # item.* schema → command_execution blocks
332
+ if msg_type == "command_execution":
333
+ aggregated_output = self._extract_command_output_text(payload)
334
+ if "\n" in aggregated_output:
335
+ return json.dumps(header, ensure_ascii=False) + "\naggregated_output:\n" + aggregated_output
336
+ if aggregated_output:
337
+ header["aggregated_output"] = aggregated_output
338
+ return json.dumps(header, ensure_ascii=False)
339
+ # No output (likely item.started) – still show header if it carries context
340
+ if header_type:
341
+ return json.dumps(header, ensure_ascii=False)
342
+
237
343
  return None
238
344
  except Exception:
239
345
  return None
240
346
 
347
+ def _normalize_event(self, obj_dict: dict):
348
+ """
349
+ Normalize legacy (msg-based) and new item.* schemas into a common tuple.
350
+ Returns (msg_type, payload_dict, outer_type).
351
+ """
352
+ msg = obj_dict.get("msg") if isinstance(obj_dict.get("msg"), dict) else {}
353
+ outer_type = (obj_dict.get("type") or "").strip()
354
+ item = obj_dict.get("item") if isinstance(obj_dict.get("item"), dict) else None
355
+
356
+ msg_type = (msg.get("type") or "").strip() if isinstance(msg, dict) else ""
357
+ payload = msg if isinstance(msg, dict) else {}
358
+
359
+ if not msg_type and item is not None:
360
+ msg_type = (item.get("type") or "").strip() or outer_type
361
+ payload = item
362
+ elif not msg_type:
363
+ msg_type = outer_type
364
+
365
+ return msg_type, payload, outer_type
366
+
241
367
  def run_codex(self, cmd: List[str], verbose: bool = False) -> int:
242
368
  """Execute the codex command and stream output with filtering and pretty-printing
243
369
 
@@ -274,119 +400,111 @@ Environment Variables:
274
400
  universal_newlines=True
275
401
  )
276
402
 
403
+ def split_json_stream(text: str):
404
+ objs = []
405
+ buf: List[str] = []
406
+ depth = 0
407
+ in_str = False
408
+ esc = False
409
+ started = False
410
+ for ch in text:
411
+ if in_str:
412
+ buf.append(ch)
413
+ if esc:
414
+ esc = False
415
+ elif ch == '\\':
416
+ esc = True
417
+ elif ch == '"':
418
+ in_str = False
419
+ continue
420
+ if ch == '"':
421
+ in_str = True
422
+ buf.append(ch)
423
+ continue
424
+ if ch == '{':
425
+ depth += 1
426
+ started = True
427
+ buf.append(ch)
428
+ continue
429
+ if ch == '}':
430
+ depth -= 1
431
+ buf.append(ch)
432
+ if started and depth == 0:
433
+ candidate = ''.join(buf).strip().strip("'\"")
434
+ if candidate:
435
+ objs.append(candidate)
436
+ buf = []
437
+ started = False
438
+ continue
439
+ if started:
440
+ buf.append(ch)
441
+ remainder = ''.join(buf) if buf else ""
442
+ return objs, remainder
443
+
444
+ def handle_obj(obj_dict: dict):
445
+ nonlocal last_token_count
446
+ msg_type_inner, payload_inner, outer_type_inner = self._normalize_event(obj_dict)
447
+
448
+ if msg_type_inner == "token_count":
449
+ last_token_count = obj_dict
450
+ return # suppress
451
+
452
+ if msg_type_inner and msg_type_inner in hide_types:
453
+ return # suppress
454
+
455
+ pretty_line_inner = self._format_msg_pretty(msg_type_inner, payload_inner, outer_type_inner)
456
+ if pretty_line_inner is not None:
457
+ print(pretty_line_inner, flush=True)
458
+ else:
459
+ # print normalized JSON
460
+ print(json.dumps(obj_dict, ensure_ascii=False), flush=True)
461
+
462
+ pending = ""
463
+
277
464
  if process.stdout:
278
465
  for raw_line in process.stdout:
279
- line = raw_line.rstrip("\n")
280
- if not line:
466
+ combined = pending + raw_line
467
+ if not combined.strip():
468
+ pending = ""
281
469
  continue
282
- # Try to parse NDJSON and filter by msg.type
283
- try:
284
- obj = None
285
- s = line.strip()
286
470
 
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)
471
+ # If no braces present at all, treat as plain text (with suppression)
472
+ if "{" not in combined and "}" not in combined:
473
+ lower = combined.lower()
474
+ if (
475
+ '"token_count"' in lower
476
+ or '"exec_command_output_delta"' in lower
477
+ or '"turn_diff"' in lower
478
+ ):
479
+ pending = ""
327
480
  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:
481
+ print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
482
+ pending = ""
483
+ continue
484
+
485
+ # Preserve and emit any prefix before the first brace
486
+ first_brace = combined.find("{")
487
+ if first_brace > 0:
488
+ prefix = combined[:first_brace]
489
+ lower_prefix = prefix.lower()
490
+ if (
491
+ '"token_count"' not in lower_prefix
492
+ and '"exec_command_output_delta"' not in lower_prefix
493
+ and '"turn_diff"' not in lower_prefix
494
+ and prefix.strip()
495
+ ):
496
+ print(prefix, end="" if prefix.endswith("\n") else "\n", flush=True)
497
+ combined = combined[first_brace:]
498
+
499
+ parts, pending = split_json_stream(combined)
500
+
501
+ if parts:
502
+ for part in parts:
503
+ try:
504
+ sub = json.loads(part)
505
+ if isinstance(sub, dict):
506
+ handle_obj(sub)
507
+ else:
390
508
  low = part.lower()
391
509
  if (
392
510
  '"token_count"' in low
@@ -395,31 +513,47 @@ Environment Variables:
395
513
  ):
396
514
  continue
397
515
  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)
516
+ except Exception:
517
+ low = part.lower()
518
+ if (
519
+ '"token_count"' in low
520
+ or '"exec_command_output_delta"' in low
521
+ or '"turn_diff"' in low
522
+ ):
523
+ continue
524
+ print(part, flush=True)
525
+ continue
526
+
527
+ # No complete object found yet; keep buffering if likely in the middle of one
528
+ if pending:
529
+ continue
530
+
531
+ # Fallback for malformed/non-JSON lines that still contain braces
532
+ lower = combined.lower()
533
+ if (
534
+ '"token_count"' in lower
535
+ or '"exec_command_output_delta"' in lower
536
+ or '"turn_diff"' in lower
537
+ ):
538
+ continue
539
+ print(combined, end="" if combined.endswith("\n") else "\n", flush=True)
540
+
541
+ # Flush any pending buffered content after the stream ends
542
+ if pending.strip():
543
+ try:
544
+ tail_obj = json.loads(pending)
545
+ if isinstance(tail_obj, dict):
546
+ handle_obj(tail_obj)
547
+ else:
548
+ print(pending, flush=True)
549
+ except Exception:
550
+ low_tail = pending.lower()
551
+ if (
552
+ '"token_count"' not in low_tail
553
+ and '"exec_command_output_delta"' not in low_tail
554
+ and '"turn_diff"' not in low_tail
555
+ ):
556
+ print(pending, flush=True)
423
557
 
424
558
  # Wait for process completion
425
559
  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.34",
4
4
  "description": "TypeScript CLI tool for AI subagent orchestration with code automation",
5
5
  "keywords": [
6
6
  "ai",