juno-code 1.0.51 → 1.0.53

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.
@@ -13,10 +13,12 @@ Usage:
13
13
  ./parallel_runner.sh --kanban TASK1 TASK2 TASK3 --parallel 2
14
14
  ./parallel_runner.sh --kanban "TASK1 TASK2 TASK3"
15
15
  ./parallel_runner.sh --kanban T1 T2 --prompt-file instructions.md
16
+ ./parallel_runner.sh --kanban T1 T2 --prompt "Handle ## {{task_id}} with {{item}}"
17
+ echo "Handle ## {{task_id}}" | ./parallel_runner.sh --kanban T1 --prompt -
16
18
 
17
- # Generic items mode (any list, requires --prompt-file with {{item}})
19
+ # Generic items mode (any list, requires --prompt-file/--prompt with {{item}})
18
20
  ./parallel_runner.sh --items "url1,url2,url3" --prompt-file crawl.md
19
- ./parallel_runner.sh --items shop1 shop2 shop3 --prompt-file analyze.md
21
+ ./parallel_runner.sh --items shop1 shop2 shop3 --prompt "Analyze {{item}}"
20
22
 
21
23
  # File mode (structured data)
22
24
  ./parallel_runner.sh --items-file data.jsonl --prompt-file analyze.md
@@ -28,6 +30,7 @@ Usage:
28
30
  ./parallel_runner.sh --tmux --kanban T1 T2 --name my-batch
29
31
  ./parallel_runner.sh -s codex --kanban T1 T2
30
32
  ./parallel_runner.sh -s pi -m gpt-5 --kanban T1 T2
33
+ ./parallel_runner.sh -s pi --subagent-args "--live" --kanban T1 T2
31
34
  ./parallel_runner.sh --stop # stop only running session
32
35
  ./parallel_runner.sh --stop --name my-batch # stop specific session
33
36
  ./parallel_runner.sh --stop-all # stop all sessions
@@ -58,7 +61,12 @@ Arguments:
58
61
  -m, --model Model override. Env: JUNO_MODEL.
59
62
  --env Environment overrides. KEY=VALUE pairs or path to .env file.
60
63
  --prompt-file Path to a file whose content is appended to the prompt.
61
- Re-read per task. Placeholders: {{task_id}}, {{item}}, {{file_format}}.
64
+ Loaded once at startup; per-task prompt files are materialized under logs/tmp.
65
+ Placeholders: {{task_id}}, {{item}}, {{file_format}}.
66
+ --prompt Inline prompt template content (same placeholders as --prompt-file).
67
+ Use --prompt - to read template content from stdin/heredoc.
68
+ --subagent-args Extra raw args appended to each juno-code invocation.
69
+ Example: --subagent-args "--live --thinking high"
62
70
  --tmux Run in tmux mode. 'windows' (default) or 'panes' (side-by-side).
63
71
  --name Session name (default: auto-generated batch-N). Tmux session = pc-{name}.
64
72
  --output-dir Structured output directory. Default: /tmp/juno-code-sessions/{date}/{run_id}.
@@ -102,6 +110,10 @@ COMBINED_LOG = LOG_DIR / "parallel_runner.log" # overwritten in main()
102
110
  # 5-char alphanumeric run ID, generated once at startup in main()
103
111
  _run_id = ""
104
112
 
113
+ # Temporary runtime artifacts are stored under logs/tmp and purged periodically.
114
+ _TMP_DIR_NAME = "tmp"
115
+ _TMP_STALE_MAX_AGE_SECONDS = 48 * 60 * 60
116
+
105
117
  # Thread-safe lock for writing to the shared combined log
106
118
  _log_lock = threading.Lock()
107
119
 
@@ -203,16 +215,36 @@ def _session_name_to_tmux(name):
203
215
  return f"pc-{name}"
204
216
 
205
217
 
218
+ def _tmp_root():
219
+ return _log_base / _TMP_DIR_NAME
220
+
221
+
222
+ def _session_state_root():
223
+ """Shared state/lock files (dashboard, pause, pid) under logs/tmp."""
224
+ root = _tmp_root() / ".session_state"
225
+ root.mkdir(parents=True, exist_ok=True)
226
+ return root
227
+
228
+
206
229
  def _dashboard_file(name):
207
- return _log_base / f".dashboard_{name}"
230
+ return _session_state_root() / f"dashboard_{name}"
208
231
 
209
232
 
210
233
  def _pause_file(name):
211
- return _log_base / f".pause_{name}"
234
+ return _session_state_root() / f"pause_{name}"
212
235
 
213
236
 
214
237
  def _pid_file(name):
215
- return _log_base / f".orchestrator_pid_{name}"
238
+ return _session_state_root() / f"orchestrator_pid_{name}"
239
+
240
+
241
+ def _legacy_session_state_files(name):
242
+ """Legacy state/lock files under logs/ root from older runner versions."""
243
+ return [
244
+ _log_base / f".dashboard_{name}",
245
+ _log_base / f".pause_{name}",
246
+ _log_base / f".orchestrator_pid_{name}",
247
+ ]
216
248
 
217
249
 
218
250
  def _orchestrator_log(name):
@@ -220,11 +252,61 @@ def _orchestrator_log(name):
220
252
 
221
253
 
222
254
  def _tmp_dir(name):
223
- return Path(f"/tmp/pc-{name}")
255
+ # Keep runtime artifacts under logs/tmp so long-running sessions are
256
+ # resilient to OS cleanup jobs and temp lifecycle is centralized.
257
+ return _tmp_root() / name
258
+
259
+
260
+ def _legacy_tmp_paths():
261
+ """Legacy temporary paths from older runner versions (.tmp_*)."""
262
+ return list(_log_base.glob(".tmp_*"))
263
+
264
+
265
+ def _cleanup_tmp_path(path):
266
+ """Remove a tmp file/dir path safely."""
267
+ try:
268
+ if path.is_dir():
269
+ shutil.rmtree(str(path), ignore_errors=True)
270
+ else:
271
+ path.unlink(missing_ok=True)
272
+ return True
273
+ except OSError:
274
+ return False
275
+
276
+
277
+ def cleanup_stale_tmp_artifacts(max_age_seconds=_TMP_STALE_MAX_AGE_SECONDS):
278
+ """Remove stale tmp artifacts older than max_age_seconds.
279
+
280
+ Returns number of removed paths.
281
+ """
282
+ now = time.time()
283
+ removed = 0
284
+
285
+ candidates = []
286
+ tmp_root = _tmp_root()
287
+ if tmp_root.exists():
288
+ for path in tmp_root.iterdir():
289
+ if path.name.startswith('.'):
290
+ continue
291
+ candidates.append(path)
292
+
293
+ candidates.extend(_legacy_tmp_paths())
294
+
295
+ for path in candidates:
296
+ try:
297
+ age = now - path.stat().st_mtime
298
+ except OSError:
299
+ continue
300
+ if age < max_age_seconds:
301
+ continue
302
+ if _cleanup_tmp_path(path):
303
+ removed += 1
304
+
305
+ return removed
224
306
 
225
307
 
226
308
  def _write_log_pipe_helper(name):
227
- """Write the Python log-pipe helper to /tmp (once per session)."""
309
+ """Write the Python log-pipe helper under logs/tmp (once per session)."""
228
310
  tmp = _tmp_dir(name)
229
311
  tmp.mkdir(parents=True, exist_ok=True)
230
312
  helper_path = tmp / "log_pipe.py"
@@ -310,6 +392,51 @@ def _generate_env_exports():
310
392
  return "\n".join(lines)
311
393
 
312
394
 
395
+ def _resolve_subagent_args(raw_args):
396
+ """Resolve repeatable --subagent-args strings into argv tokens."""
397
+ resolved = []
398
+ if not raw_args:
399
+ return resolved
400
+ for raw in raw_args:
401
+ try:
402
+ resolved.extend(shlex.split(raw))
403
+ except ValueError as exc:
404
+ print(f"ERROR: Invalid --subagent-args value '{raw}': {exc}", file=sys.stderr)
405
+ sys.exit(2)
406
+ return resolved
407
+
408
+
409
+ def _normalize_subagent_args_argv(argv):
410
+ """Normalize common `subagent-args` invocation shapes before argparse.
411
+
412
+ Supports:
413
+ - `--subagent-args --live` (dash-prefixed value)
414
+ - `subagent-args --live` (missing leading `--` typo)
415
+ """
416
+ normalized = []
417
+ i = 0
418
+ while i < len(argv):
419
+ token = argv[i]
420
+ if token == "subagent-args":
421
+ token = "--subagent-args"
422
+ if token == "--subagent-args" and i + 1 < len(argv):
423
+ next_token = argv[i + 1]
424
+ normalized.append(f"--subagent-args={next_token}")
425
+ i += 2
426
+ continue
427
+ normalized.append(token)
428
+ i += 1
429
+ return normalized
430
+
431
+
432
+ def _contains_live_subagent_flag(subagent_args):
433
+ """Detect Pi live-mode flags in resolved --subagent-args tokens."""
434
+ for token in subagent_args or []:
435
+ if token == "--live" or token.startswith("--live="):
436
+ return True
437
+ return False
438
+
439
+
313
440
  # ---------------------------------------------------------------------------
314
441
  # File parsing — multi-format pipeline
315
442
  # ---------------------------------------------------------------------------
@@ -544,17 +671,8 @@ def _task_output_path(output_dir, task_id):
544
671
  return output_dir / f"{task_id}.json"
545
672
 
546
673
 
547
- def _parse_result_from_log(task_log_path):
548
- """Parse the juno-code result event from a task log file.
549
-
550
- juno-code prints a JSON line with {"type":"result",...} to stdout.
551
- We walk backwards to find it near the end.
552
- """
553
- try:
554
- lines = Path(task_log_path).read_text(encoding="utf-8").splitlines()
555
- except (OSError, UnicodeDecodeError):
556
- return None
557
-
674
+ def _parse_result_from_lines(lines):
675
+ """Parse the most recent juno-code result event from text lines."""
558
676
  for line in reversed(lines):
559
677
  idx = line.find('{"type":')
560
678
  if idx == -1:
@@ -569,6 +687,147 @@ def _parse_result_from_log(task_log_path):
569
687
  return None
570
688
 
571
689
 
690
+ def _parse_result_from_cli_summary_text(clean_text):
691
+ """Build a synthetic result payload from juno-code CLI summary text.
692
+
693
+ This is a fallback when no structured {"type":"result"} object is present in logs.
694
+ """
695
+ if not clean_text:
696
+ return None
697
+
698
+ lines = clean_text.splitlines()
699
+
700
+ total_cost_usd = None
701
+ total_cost_match = re.search(r"Total Cost:\s*\$([0-9]+(?:\.[0-9]+)?)", clean_text)
702
+ if total_cost_match:
703
+ total_cost_usd = _to_number(total_cost_match.group(1))
704
+
705
+ session_id = None
706
+ # Handles both:
707
+ # "Iteration 1: <session> cost: $..."
708
+ # "<session> cost: $..."
709
+ session_cost_patterns = [
710
+ r"Iteration\s+\d+:\s*([^\s]+)\s+cost:\s*\$([0-9]+(?:\.[0-9]+)?)",
711
+ r"^\s*([^\s]+)\s+cost:\s*\$([0-9]+(?:\.[0-9]+)?)\s*$",
712
+ ]
713
+
714
+ per_session_cost = None
715
+ for line in lines:
716
+ for pattern in session_cost_patterns:
717
+ m = re.search(pattern, line)
718
+ if not m:
719
+ continue
720
+ session_id = m.group(1).strip()
721
+ per_session_cost = _to_number(m.group(2))
722
+
723
+ # Fallback for "Session ID:" section without inline cost.
724
+ if not session_id:
725
+ for idx, line in enumerate(lines):
726
+ if "Session ID" not in line:
727
+ continue
728
+ for nxt in lines[idx + 1: idx + 4]:
729
+ token = nxt.strip()
730
+ if not token or token.startswith("🔑") or token.startswith("-"):
731
+ continue
732
+ session_id = token.split()[0]
733
+ break
734
+ if session_id:
735
+ break
736
+
737
+ result_text = None
738
+ for idx, line in enumerate(lines):
739
+ if "📄 Result:" in line or line.strip() == "Result:":
740
+ collected = []
741
+ for nxt in lines[idx + 1:]:
742
+ if (
743
+ "📊 Statistics:" in nxt
744
+ or nxt.strip() == "Statistics:"
745
+ or "🔑 Session ID" in nxt
746
+ ):
747
+ break
748
+ if nxt.strip():
749
+ collected.append(nxt)
750
+ if collected:
751
+ result_text = "\n".join(collected).strip()
752
+ break
753
+
754
+ if total_cost_usd is None and per_session_cost is not None:
755
+ total_cost_usd = per_session_cost
756
+
757
+ if session_id is None and total_cost_usd is None and not result_text:
758
+ return None
759
+
760
+ is_error = ("Execution failed" in clean_text) or ("❌" in clean_text)
761
+
762
+ payload = {
763
+ "type": "result",
764
+ "subtype": "error" if is_error else "success",
765
+ "is_error": is_error,
766
+ }
767
+ if session_id:
768
+ payload["session_id"] = session_id
769
+ if total_cost_usd is not None:
770
+ payload["total_cost_usd"] = total_cost_usd
771
+ if result_text:
772
+ payload["result"] = result_text
773
+
774
+ return payload
775
+
776
+
777
+ def _parse_result_from_text(text):
778
+ """Parse latest juno-code result event from text output.
779
+
780
+ Supports:
781
+ - single-line JSON event logs
782
+ - pretty/multi-line JSON blocks
783
+ - fallback CLI summary lines (session id / cost / result text)
784
+ """
785
+ if not text:
786
+ return None
787
+
788
+ # Fast path for compact line-based JSON logs.
789
+ parsed = _parse_result_from_lines(text.splitlines())
790
+ if parsed is not None:
791
+ return parsed
792
+
793
+ ansi_re = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
794
+ clean = ansi_re.sub('', text)
795
+
796
+ decoder = json.JSONDecoder()
797
+ idx = 0
798
+ best = None
799
+
800
+ while True:
801
+ brace = clean.find('{', idx)
802
+ if brace == -1:
803
+ break
804
+
805
+ try:
806
+ obj, end = decoder.raw_decode(clean, brace)
807
+ except json.JSONDecodeError:
808
+ idx = brace + 1
809
+ continue
810
+
811
+ if isinstance(obj, dict) and obj.get("type") == "result":
812
+ best = obj
813
+ idx = max(end, brace + 1)
814
+
815
+ if best is not None:
816
+ return best
817
+
818
+ return _parse_result_from_cli_summary_text(clean)
819
+
820
+
821
+ def _parse_result_from_log(task_log_path):
822
+ """Parse the juno-code result event from a task log file."""
823
+ try:
824
+ text = Path(task_log_path).read_text(encoding="utf-8")
825
+ except (OSError, UnicodeDecodeError):
826
+ return None
827
+
828
+ return _parse_result_from_text(text)
829
+
830
+
572
831
  def _extract_response(backend_result, file_format):
573
832
  """Extract the clean response from a backend result.
574
833
 
@@ -596,6 +855,65 @@ def _extract_response(backend_result, file_format):
596
855
  return raw_result, error_msg
597
856
 
598
857
 
858
+ def _to_number(value):
859
+ if isinstance(value, bool):
860
+ return None
861
+ if isinstance(value, (int, float)):
862
+ return float(value)
863
+ if isinstance(value, str):
864
+ s = value.strip()
865
+ if not s:
866
+ return None
867
+ try:
868
+ return float(s)
869
+ except ValueError:
870
+ return None
871
+ return None
872
+
873
+
874
+ def _extract_total_cost_usd(payload):
875
+ """Extract total USD cost from structured backend payload variants."""
876
+ if not isinstance(payload, dict):
877
+ return None
878
+
879
+ for key in ("total_cost_usd", "totalCostUsd", "totalCostUSD"):
880
+ direct = _to_number(payload.get(key))
881
+ if direct is not None:
882
+ return direct
883
+
884
+ usage = payload.get("usage")
885
+ if isinstance(usage, dict):
886
+ usage_cost = usage.get("cost")
887
+ if isinstance(usage_cost, dict):
888
+ nested = _to_number(usage_cost.get("total"))
889
+ if nested is not None:
890
+ return nested
891
+
892
+ return None
893
+
894
+
895
+ def _extract_session_id(payload):
896
+ """Extract session id from structured backend payload variants."""
897
+ if not isinstance(payload, dict):
898
+ return None
899
+
900
+ direct = payload.get("session_id")
901
+ if isinstance(direct, str) and direct.strip():
902
+ return direct.strip()
903
+
904
+ camel = payload.get("sessionId")
905
+ if isinstance(camel, str) and camel.strip():
906
+ return camel.strip()
907
+
908
+ nested = payload.get("sub_agent_response")
909
+ if isinstance(nested, dict):
910
+ sub_id = nested.get("session_id")
911
+ if isinstance(sub_id, str) and sub_id.strip():
912
+ return sub_id.strip()
913
+
914
+ return None
915
+
916
+
599
917
  def _write_task_output(output_dir, task_id, exit_code, wall_time, start_time,
600
918
  end_time, worker_id=-1,
601
919
  extracted_response=None, extraction_error=None,
@@ -608,13 +926,13 @@ def _write_task_output(output_dir, task_id, exit_code, wall_time, start_time,
608
926
  backend_result, file_format,
609
927
  )
610
928
 
611
- session_id = None
612
- if isinstance(backend_result, dict):
613
- session_id = backend_result.get("session_id")
929
+ session_id = _extract_session_id(backend_result)
930
+ total_cost_usd = _extract_total_cost_usd(backend_result)
614
931
 
615
932
  task_output = {
616
933
  "task_id": task_id,
617
934
  "session_id": session_id,
935
+ "total_cost_usd": total_cost_usd,
618
936
  "exit_code": exit_code,
619
937
  "wall_time_seconds": round(wall_time, 2),
620
938
  "start_time": start_time,
@@ -695,11 +1013,27 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
695
1013
  merged_parts = []
696
1014
  failed_ids = []
697
1015
  failed_sessions = {}
1016
+ session_rows = []
1017
+ total_cost_usd = 0.0
698
1018
  for tid in sorted(task_outputs.keys()):
699
1019
  t = task_outputs[tid]
700
1020
  er = t.get("extracted_response")
701
1021
  br = t.get("backend_result") or {}
702
1022
  backend_ok = isinstance(br, dict) and br.get("exit_code", -1) == 0
1023
+ sid = t.get("session_id")
1024
+ task_cost = _to_number(t.get("total_cost_usd"))
1025
+ if task_cost is None:
1026
+ task_cost = _extract_total_cost_usd(br)
1027
+ if task_cost is not None:
1028
+ total_cost_usd += task_cost
1029
+
1030
+ session_rows.append({
1031
+ "task_id": tid,
1032
+ "session_id": sid,
1033
+ "total_cost_usd": task_cost,
1034
+ "exit_code": t.get("exit_code"),
1035
+ })
1036
+
703
1037
  if er and (t.get("exit_code") == 0 or backend_ok):
704
1038
  if file_format == "csv" and merged_parts:
705
1039
  lines = er.split("\n")
@@ -709,7 +1043,6 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
709
1043
  merged_parts.append(er)
710
1044
  else:
711
1045
  failed_ids.append(tid)
712
- sid = t.get("session_id")
713
1046
  if sid:
714
1047
  failed_sessions[tid] = sid
715
1048
 
@@ -727,6 +1060,11 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
727
1060
  "parallelism": parallelism,
728
1061
  "mode": mode,
729
1062
  "session_name": session_name,
1063
+ "total_cost_usd": round(total_cost_usd, 10),
1064
+ },
1065
+ "session_summary": {
1066
+ "total_cost_usd": round(total_cost_usd, 10),
1067
+ "tasks": session_rows,
730
1068
  },
731
1069
  "merged_extracted": merged_extracted,
732
1070
  "tasks": task_outputs,
@@ -750,6 +1088,7 @@ def _write_aggregation(output_dir, task_outputs, wall_time, parallelism,
750
1088
  "failed_ids": failed_ids,
751
1089
  "failed_sessions": failed_sessions,
752
1090
  "error_count": len(failed_ids),
1091
+ "total_cost_usd": round(total_cost_usd, 10),
753
1092
  }
754
1093
 
755
1094
 
@@ -763,6 +1102,9 @@ def _format_output_summary(agg_result):
763
1102
  lines.append(f" Aggregation: {agg_result['agg_path']}")
764
1103
  if agg_result["merged_path"]:
765
1104
  lines.append(f" Merged file: {agg_result['merged_path']}")
1105
+ total_cost_usd = agg_result.get("total_cost_usd")
1106
+ if total_cost_usd is not None:
1107
+ lines.append(f" Total cost: ${total_cost_usd:.6f} USD")
766
1108
  if agg_result["error_count"] > 0:
767
1109
  lines.append(f" Errors: {agg_result['error_count']} chunks failed extraction")
768
1110
  lines.append(f" Failed IDs: {', '.join(agg_result['failed_ids'])}")
@@ -790,6 +1132,14 @@ def _print_output_summary(agg_result):
790
1132
  # Auto-naming
791
1133
  # ---------------------------------------------------------------------------
792
1134
 
1135
+ def _iter_pid_files():
1136
+ """Yield current + legacy PID marker files for running-session discovery."""
1137
+ state_root = _session_state_root()
1138
+ if state_root.exists():
1139
+ yield from state_root.glob("orchestrator_pid_*")
1140
+ yield from _log_base.glob(".orchestrator_pid_*")
1141
+
1142
+
793
1143
  def _next_batch_name():
794
1144
  """Find the next available batch-N name."""
795
1145
  existing = set()
@@ -809,8 +1159,13 @@ def _next_batch_name():
809
1159
  except Exception:
810
1160
  pass
811
1161
  try:
812
- for f in _log_base.glob(".orchestrator_pid_batch-*"):
813
- suffix = f.name[len(".orchestrator_pid_batch-"):]
1162
+ for f in _iter_pid_files():
1163
+ if f.name.startswith("orchestrator_pid_batch-"):
1164
+ suffix = f.name[len("orchestrator_pid_batch-"):]
1165
+ elif f.name.startswith(".orchestrator_pid_batch-"):
1166
+ suffix = f.name[len(".orchestrator_pid_batch-"):]
1167
+ else:
1168
+ continue
814
1169
  try:
815
1170
  existing.add(int(suffix))
816
1171
  except ValueError:
@@ -830,13 +1185,22 @@ def _next_batch_name():
830
1185
  def _list_running_sessions():
831
1186
  """Return list of (name, pid) tuples for running sessions."""
832
1187
  sessions = []
1188
+ seen_names = set()
833
1189
  try:
834
- for f in _log_base.glob(".orchestrator_pid_*"):
835
- name = f.name[len(".orchestrator_pid_"):]
1190
+ for f in _iter_pid_files():
1191
+ if f.name.startswith("orchestrator_pid_"):
1192
+ name = f.name[len("orchestrator_pid_"):]
1193
+ elif f.name.startswith(".orchestrator_pid_"):
1194
+ name = f.name[len(".orchestrator_pid_"):]
1195
+ else:
1196
+ continue
1197
+ if name in seen_names:
1198
+ continue
836
1199
  try:
837
1200
  pid = int(f.read_text().strip())
838
1201
  os.kill(pid, 0)
839
1202
  sessions.append((name, pid))
1203
+ seen_names.add(name)
840
1204
  except (ValueError, ProcessLookupError, PermissionError):
841
1205
  pass
842
1206
  except Exception:
@@ -867,7 +1231,7 @@ def _stop_session(name):
867
1231
  print(f" Killed tmux session '{tmux_session}'")
868
1232
  stopped = True
869
1233
 
870
- for f in [pid_path, _dashboard_file(name), _pause_file(name)]:
1234
+ for f in [pid_path, _dashboard_file(name), _pause_file(name), *_legacy_session_state_files(name)]:
871
1235
  try:
872
1236
  f.unlink(missing_ok=True)
873
1237
  except OSError:
@@ -877,6 +1241,11 @@ def _stop_session(name):
877
1241
  if tmp.exists():
878
1242
  shutil.rmtree(str(tmp), ignore_errors=True)
879
1243
 
1244
+ # Backward-compat cleanup for legacy path style.
1245
+ legacy_tmp = _log_base / f".tmp_{name}"
1246
+ if legacy_tmp.exists():
1247
+ shutil.rmtree(str(legacy_tmp), ignore_errors=True)
1248
+
880
1249
  return stopped
881
1250
 
882
1251
 
@@ -1017,7 +1386,15 @@ def parse_args():
1017
1386
  )
1018
1387
  parser.add_argument(
1019
1388
  "--prompt-file", type=str, default=None,
1020
- help="Prompt template file. Re-read per task. Placeholders: {{task_id}}, {{item}}, {{file_format}}.",
1389
+ help="Prompt template file. Loaded once at startup. Placeholders: {{task_id}}, {{item}}, {{file_format}}.",
1390
+ )
1391
+ parser.add_argument(
1392
+ "--prompt", type=str, default=None,
1393
+ help="Inline prompt template content. Use '-' to read from stdin. Placeholders: {{task_id}}, {{item}}, {{file_format}}.",
1394
+ )
1395
+ parser.add_argument(
1396
+ "--subagent-args", action="append", default=None,
1397
+ help="Extra raw args appended to each juno-code invocation. Repeatable; values are shell-split.",
1021
1398
  )
1022
1399
  parser.add_argument(
1023
1400
  "--tmux", nargs="?", const="windows", default=None, choices=["windows", "panes"],
@@ -1039,7 +1416,8 @@ def parse_args():
1039
1416
  "--stop-all", action="store_true", default=False,
1040
1417
  help="Stop ALL running sessions.",
1041
1418
  )
1042
- args = parser.parse_args()
1419
+ normalized_argv = _normalize_subagent_args_argv(sys.argv[1:])
1420
+ args = parser.parse_args(normalized_argv)
1043
1421
 
1044
1422
  # Handle stop commands first
1045
1423
  if args.stop_all:
@@ -1047,6 +1425,9 @@ def parse_args():
1047
1425
  if args.stop:
1048
1426
  return args
1049
1427
 
1428
+ if args.prompt_file and args.prompt:
1429
+ parser.error("Use either --prompt-file or --prompt, not both")
1430
+
1050
1431
  # Resolve --kanban-filter -> --kanban
1051
1432
  if args.kanban_filter:
1052
1433
  if args.kanban:
@@ -1089,6 +1470,21 @@ def parse_args():
1089
1470
  global _env_overrides
1090
1471
  _env_overrides = _resolve_env_overrides(args.env)
1091
1472
 
1473
+ args.subagent_args_list = _resolve_subagent_args(args.subagent_args)
1474
+
1475
+ live_in_tmux = args.tmux and _contains_live_subagent_flag(args.subagent_args_list)
1476
+ if live_in_tmux and args.parallel > 1:
1477
+ parser.error(
1478
+ "--tmux with --subagent-args '--live' is interactive and only supports --parallel 1. "
1479
+ "Set --parallel 1, remove --live, or run headless mode."
1480
+ )
1481
+ if live_in_tmux:
1482
+ print(
1483
+ "WARNING: --tmux with --subagent-args '--live' enables interactive Pi TUI in the worker pane; "
1484
+ "batch progress resumes after you exit that live session.",
1485
+ file=sys.stderr,
1486
+ )
1487
+
1092
1488
  # Flatten --kanban
1093
1489
  if args.kanban:
1094
1490
  flat = []
@@ -1153,17 +1549,56 @@ def format_duration(seconds):
1153
1549
  return f"{hours}h {mins}m {secs:.0f}s"
1154
1550
 
1155
1551
 
1156
- def resolve_prompt_file(args, pwd):
1157
- """Resolve the prompt file path, exit if not found."""
1158
- if not args.prompt_file:
1159
- return None
1160
- prompt_path = Path(args.prompt_file)
1161
- if not prompt_path.is_absolute():
1162
- prompt_path = Path(pwd) / prompt_path
1163
- if not prompt_path.exists():
1164
- print(f"ERROR: Prompt file not found: {prompt_path}", file=sys.stderr)
1165
- sys.exit(2)
1166
- return str(prompt_path)
1552
+ def resolve_prompt_source(args, pwd):
1553
+ """Resolve prompt source metadata and return (source_label, template_text)."""
1554
+ if args.prompt_file:
1555
+ prompt_path = Path(args.prompt_file)
1556
+ if not prompt_path.is_absolute():
1557
+ prompt_path = Path(pwd) / prompt_path
1558
+ if not prompt_path.exists():
1559
+ print(f"ERROR: Prompt file not found: {prompt_path}", file=sys.stderr)
1560
+ sys.exit(2)
1561
+ try:
1562
+ template = prompt_path.read_text(encoding="utf-8")
1563
+ except Exception as exc:
1564
+ print(f"ERROR: Could not read prompt file at startup: {exc}", file=sys.stderr)
1565
+ sys.exit(2)
1566
+ return str(prompt_path), template
1567
+
1568
+ if args.prompt is not None:
1569
+ if args.prompt == "-":
1570
+ if sys.stdin.isatty():
1571
+ print("ERROR: --prompt - requires redirected stdin (pipe/heredoc)", file=sys.stderr)
1572
+ sys.exit(2)
1573
+ template = sys.stdin.read()
1574
+ if not template.strip():
1575
+ print("ERROR: --prompt - received empty stdin content", file=sys.stderr)
1576
+ sys.exit(2)
1577
+ return "stdin", template
1578
+ return "inline", args.prompt
1579
+
1580
+ return None, None
1581
+
1582
+
1583
+ def render_prompt(task_id, prompt_template, file_format=""):
1584
+ """Render prompt template placeholders for a task."""
1585
+ if not prompt_template:
1586
+ return ""
1587
+ rendered = prompt_template.replace("{{task_id}}", task_id)
1588
+ rendered = rendered.replace("{{item}}", _item_map.get(task_id, task_id))
1589
+ rendered = rendered.replace("{{file_format}}", file_format)
1590
+ return "\n\n---\n\n" + rendered
1591
+
1592
+
1593
+ def prepare_prompt_files(task_ids, prompt_template, prompt_dir, file_format=""):
1594
+ """Materialize all per-task prompt files at startup for long-running resilience."""
1595
+ prompt_paths = {}
1596
+ prompt_dir.mkdir(parents=True, exist_ok=True)
1597
+ for task_id in task_ids:
1598
+ prompt_path = prompt_dir / f"prompt_{task_id}.txt"
1599
+ prompt_path.write_text(render_prompt(task_id, prompt_template, file_format), encoding="utf-8")
1600
+ prompt_paths[task_id] = str(prompt_path)
1601
+ return prompt_paths
1167
1602
 
1168
1603
 
1169
1604
  def print_summary(task_ids, results, task_times, wall_elapsed, total_tasks):
@@ -1209,8 +1644,9 @@ def print_summary(task_ids, results, task_times, wall_elapsed, total_tasks):
1209
1644
  # Headless mode
1210
1645
  # ---------------------------------------------------------------------------
1211
1646
 
1212
- def run_task(task_id, semaphore, pwd, prompt_file_path=None, output_dir=None,
1213
- service="claude", model=":sonnet", file_format="", strict=False):
1647
+ def run_task(task_id, semaphore, pwd, prompt_path=None, output_dir=None,
1648
+ service="claude", model=":sonnet", file_format="", strict=False,
1649
+ subagent_args=None):
1214
1650
  """Run a single juno-code subprocess (called from its own thread)."""
1215
1651
  global _completed_count
1216
1652
 
@@ -1222,37 +1658,28 @@ def run_task(task_id, semaphore, pwd, prompt_file_path=None, output_dir=None,
1222
1658
 
1223
1659
  task_log_path = LOG_DIR / f"task_{task_id}.log"
1224
1660
 
1225
- prompt = ""
1226
-
1227
- if prompt_file_path:
1228
- try:
1229
- extra = Path(prompt_file_path).read_text(encoding="utf-8")
1230
- extra = extra.replace("{{task_id}}", task_id)
1231
- extra = extra.replace("{{item}}", _item_map.get(task_id, task_id))
1232
- extra = extra.replace("{{file_format}}", file_format)
1233
- prompt += "\n\n---\n\n" + extra
1234
- log_combined(f"Loaded prompt file ({len(extra)} chars)", task_id)
1235
- except Exception as e:
1236
- log_combined(f"WARNING: Could not read prompt file: {e}", task_id)
1237
-
1238
- tmp_prompt_dir = Path("/tmp/pc-headless")
1239
- tmp_prompt_dir.mkdir(parents=True, exist_ok=True)
1240
- tmp_prompt_path = tmp_prompt_dir / f"prompt_{task_id}.txt"
1241
- tmp_prompt_path.write_text(prompt, encoding="utf-8")
1661
+ if prompt_path and not Path(prompt_path).exists():
1662
+ log_combined("Prompt file missing at runtime; task cannot start", task_id)
1663
+ return task_id, 1
1242
1664
 
1243
1665
  env = _build_process_env()
1244
1666
 
1667
+ cmd = [
1668
+ "juno-code",
1669
+ "-b", "shell",
1670
+ "-s", service,
1671
+ "-m", model,
1672
+ "-i", "1",
1673
+ "-v",
1674
+ "--no-hooks",
1675
+ ]
1676
+ if subagent_args:
1677
+ cmd.extend(subagent_args)
1678
+ if prompt_path:
1679
+ cmd.extend(["-f", str(prompt_path)])
1680
+
1245
1681
  proc = subprocess.Popen(
1246
- [
1247
- "juno-code",
1248
- "-b", "shell",
1249
- "-s", service,
1250
- "-m", model,
1251
- "-i", "1",
1252
- "-v",
1253
- "--no-hooks",
1254
- "-f", str(tmp_prompt_path),
1255
- ],
1682
+ cmd,
1256
1683
  stdout=subprocess.PIPE,
1257
1684
  stderr=subprocess.STDOUT,
1258
1685
  cwd=pwd,
@@ -1270,11 +1697,6 @@ def run_task(task_id, semaphore, pwd, prompt_file_path=None, output_dir=None,
1270
1697
  proc.wait()
1271
1698
  reader.join()
1272
1699
 
1273
- try:
1274
- tmp_prompt_path.unlink(missing_ok=True)
1275
- except OSError:
1276
- pass
1277
-
1278
1700
  elapsed = time.monotonic() - start
1279
1701
 
1280
1702
  with _completed_lock:
@@ -1312,7 +1734,8 @@ def run_task(task_id, semaphore, pwd, prompt_file_path=None, output_dir=None,
1312
1734
  semaphore.release()
1313
1735
 
1314
1736
 
1315
- def run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model):
1737
+ def run_headless_mode(args, pwd, prompt_source_label, prompt_template,
1738
+ output_dir, service, model, subagent_args=None):
1316
1739
  """Run tasks in headless mode using ThreadPoolExecutor."""
1317
1740
  global _total_tasks
1318
1741
  _total_tasks = len(args.kanban)
@@ -1329,8 +1752,11 @@ def run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model):
1329
1752
  + (f"\n ... and {len(args.kanban) - 3} more" if len(args.kanban) > 3 else ""))
1330
1753
  log_combined(f"Parallelism: {args.parallel}")
1331
1754
  log_combined(f"Service: {service} | Model: {model}")
1332
- if prompt_file_path:
1333
- log_combined(f"Prompt file: {prompt_file_path} (re-read per task)")
1755
+ if subagent_args:
1756
+ log_combined(f"Subagent args: {' '.join(subagent_args)}")
1757
+ if prompt_source_label:
1758
+ source_type = "Prompt file" if prompt_source_label not in ("inline", "stdin") else "Prompt source"
1759
+ log_combined(f"{source_type}: {prompt_source_label} (materialized at startup)")
1334
1760
  if output_dir:
1335
1761
  log_combined(f"Output dir: {output_dir}")
1336
1762
  legend = " ".join(f"{_color_for(tid)} {tid}" for tid in args.kanban)
@@ -1345,10 +1771,16 @@ def run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model):
1345
1771
  file_format = getattr(args, "file_format", "") or ""
1346
1772
  strict = getattr(args, "strict", False)
1347
1773
 
1774
+ prompt_paths = {}
1775
+ if prompt_template is not None:
1776
+ prompt_dir = _tmp_dir(_run_id) / "prompts"
1777
+ prompt_paths = prepare_prompt_files(args.kanban, prompt_template, prompt_dir, file_format)
1778
+ log_combined(f"Prebuilt {len(prompt_paths)} prompt files in {prompt_dir}")
1779
+
1348
1780
  with ThreadPoolExecutor(max_workers=len(args.kanban)) as pool:
1349
1781
  futures = {
1350
- pool.submit(run_task, task_id, semaphore, pwd, prompt_file_path, output_dir,
1351
- service, model, file_format, strict): task_id
1782
+ pool.submit(run_task, task_id, semaphore, pwd, prompt_paths.get(task_id), output_dir,
1783
+ service, model, file_format, strict, subagent_args): task_id
1352
1784
  for task_id in args.kanban
1353
1785
  }
1354
1786
 
@@ -1384,6 +1816,7 @@ def run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model):
1384
1816
  )
1385
1817
  _print_output_summary(agg_result)
1386
1818
 
1819
+ shutil.rmtree(str(_tmp_dir(_run_id)), ignore_errors=True)
1387
1820
  sys.exit(1 if failed > 0 else 0)
1388
1821
 
1389
1822
 
@@ -1409,6 +1842,8 @@ class TaskState:
1409
1842
  sentinel_id: str = ""
1410
1843
  start_time_iso: str = ""
1411
1844
  end_time_iso: str = ""
1845
+ session_id: str = ""
1846
+ total_cost_usd: float = 0.0
1412
1847
 
1413
1848
 
1414
1849
  @dataclass
@@ -1525,62 +1960,57 @@ def create_tmux_session(session_name, mode, num_workers, pwd):
1525
1960
  # Tmux mode — command building & dispatch
1526
1961
  # ---------------------------------------------------------------------------
1527
1962
 
1528
- def write_runner_script(task_id, pwd, prompt_file_path, session_name_short,
1963
+ def write_runner_script(task_id, pwd, prompt_path, session_name_short,
1529
1964
  output_dir=None, service="claude", model=":sonnet",
1530
- file_format=""):
1531
- """Write prompt file + bash runner script for a task."""
1532
- prompt = ""
1533
-
1534
- if prompt_file_path:
1535
- try:
1536
- extra = Path(prompt_file_path).read_text(encoding="utf-8")
1537
- extra = extra.replace("{{task_id}}", task_id)
1538
- extra = extra.replace("{{item}}", _item_map.get(task_id, task_id))
1539
- extra = extra.replace("{{file_format}}", file_format)
1540
- prompt += "\n\n---\n\n" + extra
1541
- except Exception:
1542
- pass
1543
-
1965
+ file_format="", subagent_args=None):
1966
+ """Write bash runner script for a task."""
1544
1967
  sentinel_id = uuid.uuid4().hex[:12]
1545
1968
 
1546
1969
  tmp = _tmp_dir(session_name_short)
1547
1970
  tmp.mkdir(parents=True, exist_ok=True)
1548
1971
 
1549
- prompt_path = tmp / f"prompt_{task_id}.txt"
1550
- prompt_path.write_text(prompt)
1551
-
1552
1972
  env_exports = _generate_env_exports()
1553
1973
 
1554
1974
  env_path = tmp / f"env_{task_id}.sh"
1555
1975
  env_path.write_text(env_exports + "\n")
1556
1976
 
1977
+ subagent_args_shell = " ".join(shlex.quote(arg) for arg in (subagent_args or []))
1978
+ prompt_arg = f" -f {shlex.quote(str(prompt_path))}" if prompt_path else ""
1979
+
1557
1980
  runner_path = tmp / f"run_{task_id}.sh"
1558
1981
  runner_path.write_text(textwrap.dedent("""\
1559
1982
  #!/bin/bash
1560
1983
  source %(env_path)s
1561
1984
  cd %(pwd)s
1562
- juno-code -b shell -s %(service)s -m %(model)s -i 1 -v --no-hooks \\
1563
- -f %(prompt_path)s
1985
+ juno-code -b shell -s %(service)s -m %(model)s -i 1 -v --no-hooks%(subagent_args)s%(prompt_arg)s
1564
1986
  echo "___DONE_%(sentinel_id)s_${?}___"
1565
1987
  """) % {
1566
1988
  "env_path": shlex.quote(str(env_path)),
1567
1989
  "pwd": shlex.quote(pwd),
1568
- "prompt_path": shlex.quote(str(prompt_path)),
1990
+ "prompt_arg": prompt_arg,
1569
1991
  "sentinel_id": sentinel_id,
1570
1992
  "service": shlex.quote(service),
1571
1993
  "model": shlex.quote(model),
1994
+ "subagent_args": f" {subagent_args_shell}" if subagent_args_shell else "",
1572
1995
  })
1573
1996
 
1574
1997
  return str(runner_path), sentinel_id
1575
1998
 
1576
1999
 
1577
- def dispatch_task(worker, task_id, task_state, pwd, prompt_file_path,
2000
+ def dispatch_task(worker, task_id, task_state, pwd, prompt_paths,
1578
2001
  session_name_short, output_dir=None, service="claude",
1579
- model=":sonnet", file_format=""):
2002
+ model=":sonnet", file_format="", subagent_args=None,
2003
+ prompt_template=None):
1580
2004
  """Send a task command to a worker's tmux pane/window."""
2005
+ prompt_path = prompt_paths.get(task_id)
2006
+ if prompt_path and not Path(prompt_path).exists():
2007
+ prompt_path_obj = Path(prompt_path)
2008
+ prompt_path_obj.parent.mkdir(parents=True, exist_ok=True)
2009
+ prompt_path_obj.write_text(render_prompt(task_id, prompt_template, file_format), encoding="utf-8")
2010
+
1581
2011
  runner_path, sentinel_id = write_runner_script(
1582
- task_id, pwd, prompt_file_path, session_name_short, output_dir,
1583
- service, model, file_format)
2012
+ task_id, pwd, prompt_path, session_name_short, output_dir,
2013
+ service, model, file_format, subagent_args)
1584
2014
 
1585
2015
  # FIX-003: Stop old pipe-pane explicitly before starting new one
1586
2016
  tmux_run(["pipe-pane", "-t", worker.tmux_target], check=False)
@@ -1756,7 +2186,9 @@ def update_dashboard_file(task_states, workers, paused, wall_start, session_name
1756
2186
  lines.append("")
1757
2187
  lines.append("-" * 58)
1758
2188
  elapsed = format_duration(now - wall_start)
2189
+ total_cost_usd = sum(t.total_cost_usd for t in task_states.values() if t.total_cost_usd > 0)
1759
2190
  lines.append(f" Pending: {pending} | Running: {running} | Done: {done} | Failed: {failed} | Total: {total}")
2191
+ lines.append(f" Total Cost (USD): ${total_cost_usd:.6f}")
1760
2192
  if total > 0:
1761
2193
  completed = done + failed
1762
2194
  remaining = pending + running
@@ -1777,7 +2209,11 @@ def update_dashboard_file(task_states, workers, paused, wall_start, session_name
1777
2209
  lines.append(f" [{bar}] {pct}% ({completed}/{total}) Wall: {elapsed}{eta_str}")
1778
2210
  lines.append("-" * 58)
1779
2211
 
1780
- dashboard_path.write_text("\n".join(lines) + "\n")
2212
+ # Write atomically so tmux dashboard readers never see a partially-written frame.
2213
+ dashboard_content = "\n".join(lines) + "\n"
2214
+ dashboard_tmp_path = dashboard_path.parent / f"{dashboard_path.name}.tmp"
2215
+ dashboard_tmp_path.write_text(dashboard_content, encoding="utf-8")
2216
+ os.replace(dashboard_tmp_path, dashboard_path)
1781
2217
  update_tmux_status_bar(task_states, paused, wall_start, session_name)
1782
2218
 
1783
2219
 
@@ -1785,10 +2221,10 @@ def update_dashboard_file(task_states, workers, paused, wall_start, session_name
1785
2221
  # Tmux mode — orchestration loop
1786
2222
  # ---------------------------------------------------------------------------
1787
2223
 
1788
- def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
1789
- wall_start, session_name_short, session_name,
2224
+ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_paths,
2225
+ prompt_template, wall_start, session_name_short, session_name,
1790
2226
  output_dir=None, service="claude", model=":sonnet",
1791
- file_format="", strict=False):
2227
+ file_format="", strict=False, subagent_args=None):
1792
2228
  """Main orchestration loop — polls workers, dispatches tasks, updates dashboard."""
1793
2229
  all_task_ids = list(task_states.keys())
1794
2230
  pause_path = _pause_file(session_name_short)
@@ -1839,9 +2275,9 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
1839
2275
 
1840
2276
  # FIX-002: Parse result from log, with capture-pane fallback
1841
2277
  task_log_path = LOG_DIR / f"task_{task_id}.log"
1842
- backend_result = _parse_result_from_log(task_log_path) if output_dir else None
2278
+ backend_result = _parse_result_from_log(task_log_path)
1843
2279
 
1844
- if output_dir and backend_result is None:
2280
+ if backend_result is None:
1845
2281
  try:
1846
2282
  scrollback = tmux_run([
1847
2283
  "capture-pane", "-t", worker.tmux_target,
@@ -1850,11 +2286,13 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
1850
2286
  if scrollback:
1851
2287
  ansi_re = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]')
1852
2288
  clean = ansi_re.sub('', scrollback)
1853
- task_log_path.write_text(clean, encoding="utf-8")
1854
- backend_result = _parse_result_from_log(task_log_path)
2289
+ backend_result = _parse_result_from_text(clean)
1855
2290
  except Exception:
1856
2291
  pass
1857
2292
 
2293
+ ts.session_id = _extract_session_id(backend_result) or ""
2294
+ ts.total_cost_usd = _extract_total_cost_usd(backend_result) or 0.0
2295
+
1858
2296
  if strict and file_format and output_dir:
1859
2297
  exit_code = _extract_strict_output(
1860
2298
  task_id, task_log_path, output_dir, file_format, exit_code,
@@ -1897,8 +2335,9 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
1897
2335
  next_task_id = task_queue.popleft()
1898
2336
  dispatch_task(
1899
2337
  worker, next_task_id, task_states[next_task_id],
1900
- pwd, prompt_file_path, session_name_short, output_dir,
1901
- service, model, file_format,
2338
+ pwd, prompt_paths, session_name_short, output_dir,
2339
+ service, model, file_format, subagent_args,
2340
+ prompt_template,
1902
2341
  )
1903
2342
 
1904
2343
  # Update dashboard
@@ -1974,12 +2413,27 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
1974
2413
  f" Fastest task: {format_duration(fastest)}",
1975
2414
  f" Slowest task: {format_duration(slowest)}",
1976
2415
  ])
2416
+ total_cost_usd = sum(ts.total_cost_usd for ts in task_states.values() if ts.total_cost_usd > 0)
1977
2417
  summary_lines.extend([
2418
+ f" Total cost: ${total_cost_usd:.6f} USD",
1978
2419
  f" Run ID: {_run_id}",
1979
2420
  f" Per-task logs: {LOG_DIR}/task_<TASK_ID>.log",
1980
- "=" * 60,
1981
2421
  ])
1982
2422
 
2423
+ session_rows = []
2424
+ for tid in all_task_ids:
2425
+ ts = task_states[tid]
2426
+ if not ts.session_id and ts.total_cost_usd <= 0:
2427
+ continue
2428
+ session_rows.append((tid, ts.session_id or "-", ts.total_cost_usd))
2429
+
2430
+ if session_rows:
2431
+ summary_lines.append(" Session summary:")
2432
+ for tid, sid, cost in session_rows:
2433
+ summary_lines.append(f" {tid}: session_id={sid}, cost=${cost:.6f}")
2434
+
2435
+ summary_lines.append("=" * 60)
2436
+
1983
2437
  with open(COMBINED_LOG, "a") as f:
1984
2438
  for line in summary_lines:
1985
2439
  f.write(f"[{timestamp}] {line}\n")
@@ -2031,7 +2485,8 @@ def orchestration_loop(task_states, workers, task_queue, pwd, prompt_file_path,
2031
2485
  # Tmux mode — entry point
2032
2486
  # ---------------------------------------------------------------------------
2033
2487
 
2034
- def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
2488
+ def run_tmux_mode(args, pwd, prompt_source_label, prompt_template, output_dir,
2489
+ service, model, subagent_args=None):
2035
2490
  """Set up tmux session and run orchestrator."""
2036
2491
  num_workers = args.parallel
2037
2492
  mode = args.tmux
@@ -2060,7 +2515,7 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
2060
2515
  except (ValueError, ProcessLookupError, PermissionError):
2061
2516
  pass
2062
2517
 
2063
- for f in [_dashboard_file(session_name_short), _pause_file(session_name_short), pid_path]:
2518
+ for f in [_dashboard_file(session_name_short), _pause_file(session_name_short), pid_path, *_legacy_session_state_files(session_name_short)]:
2064
2519
  if f.exists():
2065
2520
  f.unlink()
2066
2521
 
@@ -2080,8 +2535,11 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
2080
2535
  f.write(f"[{timestamp}] ... and {len(args.kanban) - 3} more\n")
2081
2536
  f.write(f"[{timestamp}] Parallelism: {num_workers}\n")
2082
2537
  f.write(f"[{timestamp}] Service: {service} | Model: {model}\n")
2083
- if prompt_file_path:
2084
- f.write(f"[{timestamp}] Prompt file: {prompt_file_path} (re-read per task)\n")
2538
+ if subagent_args:
2539
+ f.write(f"[{timestamp}] Subagent args: {' '.join(subagent_args)}\n")
2540
+ if prompt_source_label:
2541
+ source_type = "Prompt file" if prompt_source_label not in ("inline", "stdin") else "Prompt source"
2542
+ f.write(f"[{timestamp}] {source_type}: {prompt_source_label} (materialized at startup)\n")
2085
2543
  if output_dir:
2086
2544
  f.write(f"[{timestamp}] Output dir: {output_dir}\n")
2087
2545
  f.write(f"[{timestamp}] {'=' * 60}\n")
@@ -2097,15 +2555,25 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
2097
2555
  task_states[tid] = TaskState(task_id=tid)
2098
2556
  task_queue = deque(args.kanban)
2099
2557
 
2558
+ file_format = getattr(args, "file_format", "") or ""
2559
+ prompt_paths = {}
2560
+ if prompt_template is not None:
2561
+ prompt_dir = _tmp_dir(session_name_short) / "prompts"
2562
+ prompt_paths = prepare_prompt_files(args.kanban, prompt_template, prompt_dir, file_format)
2563
+
2100
2564
  wall_start = time.monotonic()
2101
2565
 
2102
2566
  # Start coordinator dashboard
2567
+ # NOTE (PfU2s8): Clear BEFORE printing each frame.
2568
+ # If clear happens after `cat`, tmux can leave stale prompt/command tails
2569
+ # (e.g. parts of this dashboard_cmd string) stitched into dashboard rows.
2570
+ # Keep: printf '\033[H\033[J'; cat ...
2103
2571
  pid_path_str = shlex.quote(str(pid_path))
2104
2572
  dashboard_file_str = shlex.quote(str(_dashboard_file(session_name_short)))
2105
2573
  dashboard_cmd = (
2106
2574
  f"trap 'kill $(cat {pid_path_str}) 2>/dev/null; exit' INT; "
2107
- f"while true; do printf '\\033[H'; cat {dashboard_file_str} 2>/dev/null "
2108
- f"|| echo 'Waiting for dashboard...'; printf '\\033[J'; sleep 2; done"
2575
+ f"while true; do printf '\\033[H\\033[J'; cat {dashboard_file_str} 2>/dev/null "
2576
+ f"|| echo 'Waiting for dashboard...'; sleep 2; done"
2109
2577
  )
2110
2578
  tmux_run(["send-keys", "-t", coordinator_target, dashboard_cmd, "Enter"])
2111
2579
 
@@ -2148,23 +2616,22 @@ def run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model):
2148
2616
  if not task_queue:
2149
2617
  break
2150
2618
  next_task_id = task_queue.popleft()
2151
- file_format = getattr(args, "file_format", "") or ""
2152
2619
  dispatch_task(
2153
2620
  worker, next_task_id, task_states[next_task_id],
2154
- pwd, prompt_file_path, session_name_short, output_dir,
2155
- service, model, file_format,
2621
+ pwd, prompt_paths, session_name_short, output_dir,
2622
+ service, model, file_format, subagent_args,
2623
+ prompt_template,
2156
2624
  )
2157
2625
 
2158
2626
  update_dashboard_file(task_states, workers, False, wall_start,
2159
2627
  session_name_short, session_name)
2160
2628
 
2161
- file_format = getattr(args, "file_format", "") or ""
2162
2629
  strict = getattr(args, "strict", False)
2163
2630
  exit_code = orchestration_loop(
2164
2631
  task_states, workers, task_queue,
2165
- pwd, prompt_file_path, wall_start,
2632
+ pwd, prompt_paths, prompt_template, wall_start,
2166
2633
  session_name_short, session_name, output_dir,
2167
- service, model, file_format, strict,
2634
+ service, model, file_format, strict, subagent_args,
2168
2635
  )
2169
2636
  except Exception:
2170
2637
  import traceback
@@ -2226,16 +2693,20 @@ def main():
2226
2693
  _task_color_map[tid] = _TASK_COLORS[i % len(_TASK_COLORS)]
2227
2694
 
2228
2695
  service, model = _resolve_service_model(args)
2229
- prompt_file_path = resolve_prompt_file(args, pwd)
2696
+ prompt_source_label, prompt_template = resolve_prompt_source(args, pwd)
2230
2697
  output_dir = _resolve_output_dir(args)
2698
+ subagent_args = getattr(args, "subagent_args_list", [])
2231
2699
 
2232
2700
  _log_base.mkdir(parents=True, exist_ok=True)
2701
+ removed_tmp = cleanup_stale_tmp_artifacts()
2702
+ if removed_tmp > 0:
2703
+ print(f"[parallel_runner] Removed {removed_tmp} stale tmp artifact(s) older than 48h")
2233
2704
  LOG_DIR.mkdir(parents=True, exist_ok=True)
2234
2705
 
2235
2706
  if args.tmux:
2236
- run_tmux_mode(args, pwd, prompt_file_path, output_dir, service, model)
2707
+ run_tmux_mode(args, pwd, prompt_source_label, prompt_template, output_dir, service, model, subagent_args)
2237
2708
  else:
2238
- run_headless_mode(args, pwd, prompt_file_path, output_dir, service, model)
2709
+ run_headless_mode(args, pwd, prompt_source_label, prompt_template, output_dir, service, model, subagent_args)
2239
2710
 
2240
2711
 
2241
2712
  if __name__ == "__main__":