daveloop 1.4.0__py3-none-any.whl → 1.5.1__py3-none-any.whl

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.
daveloop.py CHANGED
@@ -14,6 +14,8 @@ import itertools
14
14
  import json
15
15
  from datetime import datetime
16
16
  from pathlib import Path
17
+ from collections import deque
18
+ from concurrent.futures import ThreadPoolExecutor, as_completed
17
19
 
18
20
  # Configuration
19
21
  MAX_ITERATIONS = 20
@@ -29,6 +31,12 @@ SIGNAL_RESOLVED = "[DAVELOOP:RESOLVED]"
29
31
  SIGNAL_BLOCKED = "[DAVELOOP:BLOCKED]"
30
32
  SIGNAL_CLARIFY = "[DAVELOOP:CLARIFY]"
31
33
 
34
+ # Allowed tools for Claude Code CLI
35
+ # Default: no Task tool (prevents recursive sub-agent spawning)
36
+ ALLOWED_TOOLS_DEFAULT = "Bash,Read,Write,Edit,Glob,Grep"
37
+ # Swarm mode: Task tool enabled for controlled sub-agent spawning
38
+ ALLOWED_TOOLS_SWARM = "Bash,Read,Write,Edit,Glob,Grep,Task"
39
+
32
40
  # ============================================================================
33
41
  # ANSI Color Codes
34
42
  # ============================================================================
@@ -268,6 +276,508 @@ class TaskQueue:
268
276
  print()
269
277
 
270
278
 
279
+ # ============================================================================
280
+ # Swarm Budget
281
+ # ============================================================================
282
+ class SwarmBudget:
283
+ """Tracks and enforces sub-agent spawn budget for swarm mode."""
284
+
285
+ def __init__(self, max_spawns: int = 5, max_depth: int = 1):
286
+ self.max_spawns = max_spawns
287
+ self.max_depth = max_depth
288
+ self.spawn_count = 0
289
+ self.active_agents = 0
290
+ self.completed_agents = 0
291
+
292
+ def can_spawn(self) -> bool:
293
+ """Check if spawning another sub-agent is within budget."""
294
+ return self.spawn_count < self.max_spawns
295
+
296
+ def record_spawn(self, description: str):
297
+ """Record a sub-agent spawn."""
298
+ self.spawn_count += 1
299
+ self.active_agents += 1
300
+ print(f" {C.BRIGHT_CYAN}[Swarm]{C.RESET} Sub-agent {self.spawn_count}/{self.max_spawns}: {description}")
301
+
302
+ def record_completion(self):
303
+ """Record a sub-agent completion."""
304
+ self.active_agents -= 1
305
+ self.completed_agents += 1
306
+
307
+ def budget_exhausted_message(self) -> str:
308
+ """Return message when budget is exhausted."""
309
+ return (
310
+ f"Sub-agent budget exhausted ({self.spawn_count}/{self.max_spawns}). "
311
+ f"Complete remaining work directly without spawning more sub-agents."
312
+ )
313
+
314
+ def summary(self) -> dict:
315
+ """Return budget tracking summary."""
316
+ return {
317
+ "total_spawned": self.spawn_count,
318
+ "completed": self.completed_agents,
319
+ "budget": self.max_spawns,
320
+ }
321
+
322
+
323
+ # ============================================================================
324
+ # Turbo Dashboard (Rich-based split-pane UI)
325
+ # ============================================================================
326
+ class TurboDashboard:
327
+ """Rich-based split-pane terminal dashboard for parallel DaveLoop instances.
328
+
329
+ Shows one panel per instance with: instance ID, current task name,
330
+ and a scrolling window of the last ~20 lines of output.
331
+ """
332
+
333
+ PANEL_COLORS = ["cyan", "green", "yellow", "magenta", "blue", "red"]
334
+
335
+ def __init__(self, instance_count: int):
336
+ from rich.live import Live
337
+ from rich.layout import Layout
338
+ from rich.panel import Panel
339
+ from rich.text import Text
340
+ from rich.console import Console
341
+
342
+ self._Live = Live
343
+ self._Layout = Layout
344
+ self._Panel = Panel
345
+ self._Text = Text
346
+ self._console = Console()
347
+
348
+ self.instance_count = instance_count
349
+ self._lock = threading.Lock()
350
+
351
+ # Per-instance state
352
+ self.task_names = ["Waiting..."] * instance_count
353
+ self.statuses = ["pending"] * instance_count # pending, running, done, failed
354
+ self.output_buffers = [deque(maxlen=20) for _ in range(instance_count)]
355
+ self.iterations = [0] * instance_count
356
+ self.max_iterations = [0] * instance_count
357
+ self.start_times = [None] * instance_count
358
+
359
+ self._live = None
360
+
361
+ def _build_layout(self):
362
+ """Build the Rich Layout with panels for each instance."""
363
+ layout = self._Layout()
364
+
365
+ # Create rows of 2 panels each
366
+ rows = []
367
+ for i in range(0, self.instance_count, 2):
368
+ row_name = f"row_{i // 2}"
369
+ row = self._Layout(name=row_name)
370
+ left = self._Layout(name=f"inst_{i}")
371
+
372
+ if i + 1 < self.instance_count:
373
+ right = self._Layout(name=f"inst_{i + 1}")
374
+ row.split_row(left, right)
375
+ else:
376
+ row.split_row(left)
377
+
378
+ rows.append(row)
379
+
380
+ if rows:
381
+ layout.split_column(*rows)
382
+
383
+ return layout
384
+
385
+ def _render_panel(self, idx: int):
386
+ """Render a single instance panel."""
387
+ color = self.PANEL_COLORS[idx % len(self.PANEL_COLORS)]
388
+ status = self.statuses[idx]
389
+
390
+ # Status indicator
391
+ if status == "running":
392
+ status_icon = "[bold bright_green]\u25cf RUNNING[/]"
393
+ elif status == "done":
394
+ status_icon = "[bold bright_green]\u2713 RESOLVED[/]"
395
+ elif status == "failed":
396
+ status_icon = "[bold bright_red]\u2717 FAILED[/]"
397
+ else:
398
+ status_icon = "[dim]\u25cb PENDING[/]"
399
+
400
+ # Iteration info
401
+ iter_info = ""
402
+ if self.max_iterations[idx] > 0:
403
+ iter_info = f" | Iter {self.iterations[idx]}/{self.max_iterations[idx]}"
404
+
405
+ # Elapsed time
406
+ elapsed = ""
407
+ if self.start_times[idx]:
408
+ secs = int(time.time() - self.start_times[idx])
409
+ mins, secs = divmod(secs, 60)
410
+ elapsed = f" | {mins}m{secs:02d}s"
411
+
412
+ # Build subtitle
413
+ subtitle = f"{status_icon}{iter_info}{elapsed}"
414
+
415
+ # Build output text
416
+ lines = list(self.output_buffers[idx])
417
+ if not lines:
418
+ lines = ["[dim]Waiting for output...[/]"]
419
+
420
+ output_text = "\n".join(lines)
421
+
422
+ task_name = self.task_names[idx]
423
+ if len(task_name) > 50:
424
+ task_name = task_name[:47] + "..."
425
+
426
+ panel = self._Panel(
427
+ output_text,
428
+ title=f"[bold {color}] Instance {idx + 1} [/] {task_name}",
429
+ subtitle=subtitle,
430
+ border_style=color if status == "running" else "dim" if status == "pending" else "green" if status == "done" else "red",
431
+ padding=(0, 1),
432
+ )
433
+ return panel
434
+
435
+ def _refresh(self):
436
+ """Rebuild and update the live display."""
437
+ layout = self._build_layout()
438
+ for i in range(self.instance_count):
439
+ try:
440
+ layout[f"inst_{i}"].update(self._render_panel(i))
441
+ except KeyError:
442
+ pass
443
+ return layout
444
+
445
+ def start(self):
446
+ """Start the live dashboard."""
447
+ self._live = self._Live(
448
+ self._refresh(),
449
+ console=self._console,
450
+ refresh_per_second=4,
451
+ screen=True,
452
+ )
453
+ self._live.start()
454
+
455
+ def stop(self):
456
+ """Stop the live dashboard."""
457
+ if self._live:
458
+ self._live.stop()
459
+
460
+ def update_task(self, idx: int, task_name: str, max_iterations: int):
461
+ """Set the task name and max iterations for an instance."""
462
+ with self._lock:
463
+ self.task_names[idx] = task_name
464
+ self.max_iterations[idx] = max_iterations
465
+ self.statuses[idx] = "running"
466
+ self.start_times[idx] = time.time()
467
+ if self._live:
468
+ self._live.update(self._refresh())
469
+
470
+ def update_iteration(self, idx: int, iteration: int):
471
+ """Update the current iteration number for an instance."""
472
+ with self._lock:
473
+ self.iterations[idx] = iteration
474
+ if self._live:
475
+ self._live.update(self._refresh())
476
+
477
+ def append_output(self, idx: int, line: str):
478
+ """Append a line to an instance's output buffer."""
479
+ with self._lock:
480
+ # Strip ANSI codes for cleaner display in Rich panels
481
+ clean = _strip_ansi(line)
482
+ if clean.strip():
483
+ self.output_buffers[idx].append(clean)
484
+ if self._live:
485
+ self._live.update(self._refresh())
486
+
487
+ def mark_done(self, idx: int, outcome: str = "done"):
488
+ """Mark an instance as completed."""
489
+ with self._lock:
490
+ self.statuses[idx] = outcome
491
+ if self._live:
492
+ self._live.update(self._refresh())
493
+
494
+
495
+ def _strip_ansi(text: str) -> str:
496
+ """Remove ANSI escape sequences from a string."""
497
+ import re
498
+ return re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
499
+
500
+
501
+ def run_claude_code_turbo(prompt: str, working_dir: str, instance_idx: int,
502
+ dashboard: TurboDashboard, timeout: int = DEFAULT_TIMEOUT,
503
+ swarm_mode: bool = False, swarm_budget_max: int = 5,
504
+ swarm_depth_max: int = 1) -> str:
505
+ """Execute Claude Code CLI for turbo mode, streaming output to a dashboard panel.
506
+
507
+ Similar to run_claude_code but routes output to a TurboDashboard panel
508
+ instead of printing directly to stdout.
509
+ """
510
+ claude_cmd = find_claude_cli()
511
+ if not claude_cmd:
512
+ return "[DAVELOOP:ERROR] Claude CLI not found"
513
+
514
+ cmd = [claude_cmd]
515
+ allowed = ALLOWED_TOOLS_SWARM if swarm_mode else ALLOWED_TOOLS_DEFAULT
516
+ cmd.extend(["-p", "--verbose", "--output-format", "stream-json", "--allowedTools", allowed])
517
+
518
+ try:
519
+ process = subprocess.Popen(
520
+ cmd,
521
+ stdin=subprocess.PIPE,
522
+ stdout=subprocess.PIPE,
523
+ stderr=subprocess.STDOUT,
524
+ text=True,
525
+ encoding='utf-8',
526
+ errors='replace',
527
+ cwd=working_dir,
528
+ bufsize=1
529
+ )
530
+
531
+ process.stdin.write(prompt)
532
+ process.stdin.close()
533
+
534
+ full_text = []
535
+
536
+ for line in process.stdout:
537
+ line = line.strip()
538
+ if not line:
539
+ continue
540
+
541
+ try:
542
+ data = json.loads(line)
543
+ msg_type = data.get("type", "")
544
+
545
+ if msg_type == "assistant":
546
+ content = data.get("message", {}).get("content", [])
547
+ for block in content:
548
+ if block.get("type") == "text":
549
+ text = block.get("text", "")
550
+ for text_line in text.split('\n'):
551
+ if text_line.strip():
552
+ dashboard.append_output(instance_idx, text_line)
553
+ full_text.append(text)
554
+ elif block.get("type") == "tool_use":
555
+ tool_name = block.get("name", "unknown")
556
+ tool_input = block.get("input", {})
557
+ tool_desc = _format_tool_short(tool_name, tool_input)
558
+ dashboard.append_output(instance_idx, f"\u25b6 {tool_desc}")
559
+
560
+ elif msg_type == "content_block_delta":
561
+ delta = data.get("delta", {})
562
+ if delta.get("type") == "text_delta":
563
+ text = delta.get("text", "")
564
+ full_text.append(text)
565
+ # Only push meaningful chunks to dashboard
566
+ for text_line in text.split('\n'):
567
+ if text_line.strip():
568
+ dashboard.append_output(instance_idx, text_line)
569
+
570
+ elif msg_type == "tool_use":
571
+ tool_name = data.get("name", "unknown")
572
+ tool_input = data.get("input", {})
573
+ tool_desc = _format_tool_short(tool_name, tool_input)
574
+ dashboard.append_output(instance_idx, f"\u25b6 {tool_desc}")
575
+
576
+ elif msg_type == "tool_result":
577
+ dashboard.append_output(instance_idx, "\u2514\u2500 \u2713 done")
578
+
579
+ elif msg_type == "result":
580
+ text = data.get("result", "")
581
+ if text:
582
+ full_text.append(text)
583
+
584
+ elif msg_type == "error":
585
+ error_msg = data.get("error", {}).get("message", "Unknown error")
586
+ dashboard.append_output(instance_idx, f"\u2717 ERROR: {error_msg}")
587
+
588
+ except json.JSONDecodeError:
589
+ dashboard.append_output(instance_idx, line)
590
+ full_text.append(line)
591
+
592
+ process.wait(timeout=timeout)
593
+ return '\n'.join(full_text)
594
+
595
+ except subprocess.TimeoutExpired:
596
+ return f"[DAVELOOP:TIMEOUT] Claude Code timed out after {timeout // 60} minutes"
597
+ except FileNotFoundError:
598
+ return "[DAVELOOP:ERROR] Claude Code CLI not found"
599
+ except Exception as e:
600
+ return f"[DAVELOOP:ERROR] {str(e)}"
601
+
602
+
603
+ def _format_tool_short(tool_name: str, tool_input: dict) -> str:
604
+ """Format a tool call for compact dashboard display."""
605
+ if tool_name == "Bash":
606
+ cmd = tool_input.get("command", "")
607
+ return f"Bash({cmd[:40]}{'...' if len(cmd) > 40 else ''})"
608
+ elif tool_name in ("Read", "Write", "Edit"):
609
+ fp = tool_input.get("file_path", "")
610
+ fname = fp.split("\\")[-1].split("/")[-1]
611
+ return f"{tool_name}({fname})"
612
+ elif tool_name == "Grep":
613
+ pat = tool_input.get("pattern", "")
614
+ return f"Grep({pat[:25]}{'...' if len(pat) > 25 else ''})"
615
+ elif tool_name == "Glob":
616
+ pat = tool_input.get("pattern", "")
617
+ return f"Glob({pat})"
618
+ elif tool_name == "Task":
619
+ desc = tool_input.get("description", "")
620
+ return f"Task({desc[:30]}{'...' if len(desc) > 30 else ''})"
621
+ return tool_name
622
+
623
+
624
+ def run_turbo_task(task_desc: str, task_idx: int, system_prompt: str,
625
+ working_dir: str, max_iterations: int, timeout: int,
626
+ dashboard: TurboDashboard, swarm_mode: bool = False,
627
+ swarm_budget_max: int = 5, swarm_depth_max: int = 1) -> dict:
628
+ """Run a single DaveLoop task inside the turbo dashboard.
629
+
630
+ Returns a dict with outcome, iterations, and output.
631
+ """
632
+ dashboard.update_task(task_idx, task_desc[:50], max_iterations)
633
+
634
+ context = f"""
635
+ ## Bug Report
636
+
637
+ {task_desc}
638
+
639
+ ## Instructions
640
+
641
+ Analyze this bug. Gather whatever logs/information you need to understand it.
642
+ Then fix it. Use the reasoning protocol before each action.
643
+ """
644
+ full_output = []
645
+
646
+ for iteration in range(1, max_iterations + 1):
647
+ dashboard.update_iteration(task_idx, iteration)
648
+ dashboard.append_output(task_idx, f"--- Iteration {iteration}/{max_iterations} ---")
649
+
650
+ if iteration == 1:
651
+ full_prompt = f"{system_prompt}\n\n---\n\n{context}"
652
+ else:
653
+ full_prompt = context
654
+
655
+ output = run_claude_code_turbo(
656
+ full_prompt, working_dir,
657
+ instance_idx=task_idx,
658
+ dashboard=dashboard,
659
+ timeout=timeout,
660
+ swarm_mode=swarm_mode,
661
+ swarm_budget_max=swarm_budget_max,
662
+ swarm_depth_max=swarm_depth_max,
663
+ )
664
+
665
+ full_output.append(output)
666
+
667
+ signal, should_exit = check_exit_condition(output)
668
+
669
+ if should_exit:
670
+ if signal == "RESOLVED":
671
+ dashboard.append_output(task_idx, "\u2713 BUG RESOLVED!")
672
+ dashboard.mark_done(task_idx, "done")
673
+ return {"outcome": "RESOLVED", "iterations": iteration, "output": '\n'.join(full_output)}
674
+ elif signal == "BLOCKED":
675
+ dashboard.append_output(task_idx, "\u2717 BLOCKED - needs human help")
676
+ dashboard.mark_done(task_idx, "failed")
677
+ return {"outcome": "BLOCKED", "iterations": iteration, "output": '\n'.join(full_output)}
678
+ elif signal == "CLARIFY":
679
+ dashboard.append_output(task_idx, "\u2717 NEEDS CLARIFICATION")
680
+ dashboard.mark_done(task_idx, "failed")
681
+ return {"outcome": "CLARIFY", "iterations": iteration, "output": '\n'.join(full_output)}
682
+ else:
683
+ dashboard.append_output(task_idx, f"\u2717 Error: {signal}")
684
+ dashboard.mark_done(task_idx, "failed")
685
+ return {"outcome": signal, "iterations": iteration, "output": '\n'.join(full_output)}
686
+
687
+ # Prepare next iteration context
688
+ context = f"""
689
+ ## Iteration {iteration + 1}
690
+
691
+ The bug is NOT yet resolved. You have full context from previous iterations.
692
+
693
+ Continue debugging. Analyze what happened, determine next steps, and proceed.
694
+ Use the reasoning protocol before each action.
695
+ """
696
+
697
+ # Max iterations reached
698
+ dashboard.append_output(task_idx, f"\u2717 Max iterations ({max_iterations}) reached")
699
+ dashboard.mark_done(task_idx, "failed")
700
+ return {"outcome": "MAX_ITERATIONS", "iterations": max_iterations, "output": '\n'.join(full_output)}
701
+
702
+
703
+ # ============================================================================
704
+ # Token Tracker
705
+ # ============================================================================
706
+ class TokenTracker:
707
+ """Tracks token usage across API turns in a DaveLoop session."""
708
+
709
+ def __init__(self):
710
+ self.total_input = 0
711
+ self.total_output = 0
712
+ self.turn_count = 0
713
+ self.peak_input = 0
714
+ self.peak_output = 0
715
+ self.peak_total = 0
716
+ self.per_tool = {} # tool_name -> {"input": int, "output": int, "count": int}
717
+ self._current_tool = None # Track which tool is active for per-tool attribution
718
+ self._turn_input = 0 # Accumulate within a turn for per-tool attribution
719
+ self._turn_output = 0
720
+
721
+ def set_current_tool(self, tool_name: str):
722
+ """Set the currently active tool for per-tool token attribution."""
723
+ self._current_tool = tool_name
724
+
725
+ def record_usage(self, input_tokens: int, output_tokens: int):
726
+ """Record token usage from an API turn."""
727
+ self.total_input += input_tokens
728
+ self.total_output += output_tokens
729
+ self.turn_count += 1
730
+
731
+ turn_total = input_tokens + output_tokens
732
+ if turn_total > self.peak_total:
733
+ self.peak_total = turn_total
734
+ self.peak_input = input_tokens
735
+ self.peak_output = output_tokens
736
+
737
+ # Attribute to current tool if one is active
738
+ if self._current_tool:
739
+ if self._current_tool not in self.per_tool:
740
+ self.per_tool[self._current_tool] = {"input": 0, "output": 0, "count": 0}
741
+ self.per_tool[self._current_tool]["input"] += input_tokens
742
+ self.per_tool[self._current_tool]["output"] += output_tokens
743
+ self.per_tool[self._current_tool]["count"] += 1
744
+
745
+ @property
746
+ def total_tokens(self) -> int:
747
+ return self.total_input + self.total_output
748
+
749
+ def summary(self) -> dict:
750
+ """Return a dict with all token stats."""
751
+ return {
752
+ "input_tokens": self.total_input,
753
+ "output_tokens": self.total_output,
754
+ "total_tokens": self.total_tokens,
755
+ "turn_count": self.turn_count,
756
+ "peak_turn": {
757
+ "input": self.peak_input,
758
+ "output": self.peak_output,
759
+ "total": self.peak_total,
760
+ },
761
+ "per_tool": dict(self.per_tool),
762
+ }
763
+
764
+ def summary_line(self) -> str:
765
+ """Return a one-line summary string for display."""
766
+ return (
767
+ f"Tokens: {self.total_input:,} in / {self.total_output:,} out / "
768
+ f"{self.total_tokens:,} total ({self.turn_count} turns)"
769
+ )
770
+
771
+ def verbose_turn_line(self, input_tokens: int, output_tokens: int) -> str:
772
+ """Return a per-turn detail line for --show-tokens mode."""
773
+ total = input_tokens + output_tokens
774
+ tool_info = f" [{self._current_tool}]" if self._current_tool else ""
775
+ return (
776
+ f" Turn {self.turn_count}: {input_tokens:,} in / {output_tokens:,} out / "
777
+ f"{total:,} total{tool_info}"
778
+ )
779
+
780
+
271
781
  # ============================================================================
272
782
  # Session Memory
273
783
  # ============================================================================
@@ -294,16 +804,21 @@ def save_history(working_dir: str, history_data: dict):
294
804
  history_file.write_text(json.dumps(history_data, indent=2), encoding="utf-8")
295
805
 
296
806
 
297
- def summarize_session(bug: str, outcome: str, iterations: int) -> dict:
807
+ def summarize_session(bug: str, outcome: str, iterations: int, token_tracker: "TokenTracker" = None) -> dict:
298
808
  """Return a dict summarizing a session."""
299
809
  now = datetime.now()
300
- return {
810
+ entry = {
301
811
  "session_id": now.strftime("%Y%m%d_%H%M%S"),
302
812
  "bug": bug,
303
813
  "outcome": outcome,
304
814
  "iterations": iterations,
305
815
  "timestamp": now.isoformat()
306
816
  }
817
+ if token_tracker and token_tracker.turn_count > 0:
818
+ entry["tokens_in"] = token_tracker.total_input
819
+ entry["tokens_out"] = token_tracker.total_output
820
+ entry["tokens_total"] = token_tracker.total_tokens
821
+ return entry
307
822
 
308
823
 
309
824
  def format_history_context(sessions: list) -> str:
@@ -329,10 +844,12 @@ def print_history_box(sessions: list):
329
844
  outcome = s.get("outcome", "UNKNOWN")
330
845
  bug = s.get("bug", "unknown")[:55]
331
846
  iters = s.get("iterations", "?")
847
+ tokens_total = s.get("tokens_total")
848
+ token_str = f" · {tokens_total:,} tok" if tokens_total else ""
332
849
  if outcome == "RESOLVED":
333
- print(f" {C.BRIGHT_GREEN}✓{C.RESET} {C.WHITE}{bug}{C.RESET} {C.DIM}({iters} iter){C.RESET}")
850
+ print(f" {C.BRIGHT_GREEN}✓{C.RESET} {C.WHITE}{bug}{C.RESET} {C.DIM}({iters} iter{token_str}){C.RESET}")
334
851
  else:
335
- print(f" {C.BRIGHT_RED}✗{C.RESET} {C.WHITE}{bug}{C.RESET} {C.DIM}({iters} iter){C.RESET}")
852
+ print(f" {C.BRIGHT_RED}✗{C.RESET} {C.WHITE}{bug}{C.RESET} {C.DIM}({iters} iter{token_str}){C.RESET}")
336
853
  print()
337
854
 
338
855
 
@@ -426,7 +943,7 @@ class InputMonitor:
426
943
  Call resume_reading() after the main thread is done with input().
427
944
  """
428
945
 
429
- VALID_COMMANDS = ("wait", "pause", "add", "done")
946
+ VALID_COMMANDS = ("wait", "pause", "add", "done", "stop")
430
947
 
431
948
  def __init__(self):
432
949
  self._command = None
@@ -554,12 +1071,17 @@ def find_claude_cli():
554
1071
  return None
555
1072
 
556
1073
 
557
- def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool = False, stream: bool = True, timeout: int = DEFAULT_TIMEOUT, input_monitor=None) -> str:
1074
+ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool = False, stream: bool = True, timeout: int = DEFAULT_TIMEOUT, input_monitor=None, swarm_mode: bool = False, swarm_budget_max: int = 5, swarm_depth_max: int = 1, token_tracker: "TokenTracker" = None, show_tokens: bool = False) -> str:
558
1075
  """Execute Claude Code CLI with the given prompt.
559
1076
 
560
1077
  If stream=True, output is printed in real-time and also returned.
561
1078
  timeout is in seconds (default 600 = 10 minutes).
562
1079
  input_monitor: optional InputMonitor to check for user commands during execution.
1080
+ swarm_mode: if True, enables Task tool for sub-agent spawning.
1081
+ swarm_budget_max: max sub-agents per session in swarm mode.
1082
+ swarm_depth_max: max sub-agent depth in swarm mode.
1083
+ token_tracker: optional TokenTracker to accumulate token usage from the stream.
1084
+ show_tokens: if True, print per-turn token usage during execution.
563
1085
  """
564
1086
  claude_cmd = find_claude_cli()
565
1087
  if not claude_cmd:
@@ -578,7 +1100,8 @@ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool
578
1100
  if continue_session:
579
1101
  cmd.append("--continue")
580
1102
 
581
- cmd.extend(["-p", "--verbose", "--output-format", "stream-json", "--allowedTools", "Bash,Read,Write,Edit,Glob,Grep,Task"])
1103
+ allowed = ALLOWED_TOOLS_SWARM if swarm_mode else ALLOWED_TOOLS_DEFAULT
1104
+ cmd.extend(["-p", "--verbose", "--output-format", "stream-json", "--allowedTools", allowed])
582
1105
 
583
1106
  try:
584
1107
  if stream:
@@ -602,6 +1125,9 @@ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool
602
1125
  # Track start time
603
1126
  start_time = time.time()
604
1127
 
1128
+ # Swarm budget tracking (only active in swarm mode)
1129
+ swarm_budget = SwarmBudget(max_spawns=swarm_budget_max, max_depth=swarm_depth_max) if swarm_mode else None
1130
+
605
1131
  # Read and display JSON stream output
606
1132
  output_lines = []
607
1133
  full_text = []
@@ -617,6 +1143,19 @@ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool
617
1143
  msg_type = data.get("type", "")
618
1144
 
619
1145
 
1146
+ # Extract token usage from any message that has it
1147
+ if token_tracker:
1148
+ usage = (data.get("message", {}).get("usage")
1149
+ or data.get("usage")
1150
+ or None)
1151
+ if usage and isinstance(usage, dict):
1152
+ inp = usage.get("input_tokens", 0)
1153
+ outp = usage.get("output_tokens", 0)
1154
+ if inp or outp:
1155
+ token_tracker.record_usage(inp, outp)
1156
+ if show_tokens:
1157
+ print(f" {C.DIM}{token_tracker.verbose_turn_line(inp, outp)}{C.RESET}")
1158
+
620
1159
  # Handle different message types
621
1160
  if msg_type == "assistant":
622
1161
  # Assistant text message
@@ -631,6 +1170,8 @@ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool
631
1170
  elif block.get("type") == "tool_use":
632
1171
  # Tool being called - show what Claude is doing
633
1172
  tool_name = block.get("name", "unknown")
1173
+ if token_tracker:
1174
+ token_tracker.set_current_tool(tool_name)
634
1175
  tool_input = block.get("input", {})
635
1176
 
636
1177
  # Format tool call based on type
@@ -660,6 +1201,18 @@ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool
660
1201
  elif tool_name == "Task":
661
1202
  desc = tool_input.get("description", "")
662
1203
  tool_display = f"{C.BRIGHT_BLUE}Task{C.RESET}({C.WHITE}{desc}{C.RESET})"
1204
+ # Swarm budget enforcement
1205
+ if swarm_budget:
1206
+ if not swarm_budget.can_spawn():
1207
+ print(f" {C.BRIGHT_YELLOW}[Swarm] Budget exhausted. Terminating to restart without Task tool.{C.RESET}")
1208
+ process.terminate()
1209
+ try:
1210
+ process.wait(timeout=10)
1211
+ except Exception:
1212
+ process.kill()
1213
+ return '\n'.join(full_text) + "\n[DAVELOOP:SWARM_BUDGET_EXHAUSTED]"
1214
+ else:
1215
+ swarm_budget.record_spawn(desc)
663
1216
  else:
664
1217
  tool_display = f"{C.BRIGHT_BLUE}{tool_name}{C.RESET}"
665
1218
 
@@ -677,6 +1230,8 @@ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool
677
1230
  elif msg_type == "tool_use":
678
1231
  # Tool being used - show what Claude is doing
679
1232
  tool_name = data.get("name", "unknown")
1233
+ if token_tracker:
1234
+ token_tracker.set_current_tool(tool_name)
680
1235
  tool_input = data.get("input", {})
681
1236
 
682
1237
  # Format tool call based on type
@@ -706,6 +1261,18 @@ def run_claude_code(prompt: str, working_dir: str = None, continue_session: bool
706
1261
  elif tool_name == "Task":
707
1262
  desc = tool_input.get("description", "")
708
1263
  tool_display = f"{C.BRIGHT_BLUE}Task{C.RESET}({C.WHITE}{desc}{C.RESET})"
1264
+ # Swarm budget enforcement
1265
+ if swarm_budget:
1266
+ if not swarm_budget.can_spawn():
1267
+ print(f" {C.BRIGHT_YELLOW}[Swarm] Budget exhausted. Terminating to restart without Task tool.{C.RESET}")
1268
+ process.terminate()
1269
+ try:
1270
+ process.wait(timeout=10)
1271
+ except Exception:
1272
+ process.kill()
1273
+ return '\n'.join(full_text) + "\n[DAVELOOP:SWARM_BUDGET_EXHAUSTED]"
1274
+ else:
1275
+ swarm_budget.record_spawn(desc)
709
1276
  else:
710
1277
  tool_display = f"{C.BRIGHT_BLUE}{tool_name}{C.RESET}"
711
1278
 
@@ -859,6 +1426,18 @@ def main():
859
1426
  parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
860
1427
  parser.add_argument("--maestro", action="store_true", help="Enable Maestro mobile testing mode")
861
1428
  parser.add_argument("--web", action="store_true", help="Enable Playwright web UI testing mode")
1429
+ parser.add_argument("--swarm", action="store_true",
1430
+ help="Enable swarm mode: DaveLoop can spawn sub-agents via Task tool")
1431
+ parser.add_argument("--swarm-budget", type=int, default=5,
1432
+ help="Max sub-agents per DaveLoop worker in swarm mode (default: 5)")
1433
+ parser.add_argument("--swarm-depth", type=int, default=1, choices=[1, 2],
1434
+ help="Max sub-agent depth in swarm mode (default: 1, no recursive spawning)")
1435
+ parser.add_argument("--show-tokens", action="store_true",
1436
+ help="Show verbose per-turn token usage during execution")
1437
+ parser.add_argument("--turbo", action="store_true",
1438
+ help="Run all tasks in parallel with a Rich split-pane dashboard")
1439
+ parser.add_argument("--turbo-workers", type=int, default=None,
1440
+ help="Max parallel workers in turbo mode (default: number of tasks)")
862
1441
 
863
1442
  args = parser.parse_args()
864
1443
 
@@ -908,6 +1487,14 @@ def main():
908
1487
  print_status("Tasks", str(len(bug_descriptions)), C.WHITE)
909
1488
  mode_name = "Maestro Mobile Testing" if args.maestro else "Playwright Web Testing" if args.web else "Autonomous"
910
1489
  print_status("Mode", mode_name, C.WHITE)
1490
+ if args.swarm:
1491
+ print_status("Swarm", f"ENABLED (budget: {args.swarm_budget}, depth: {args.swarm_depth})", C.BRIGHT_CYAN)
1492
+ print_status("Tools", ALLOWED_TOOLS_SWARM, C.WHITE)
1493
+ else:
1494
+ print_status("Tools", ALLOWED_TOOLS_DEFAULT, C.WHITE)
1495
+ if args.turbo:
1496
+ workers = args.turbo_workers or len(bug_descriptions)
1497
+ print_status("Turbo", f"ENABLED ({workers} parallel workers)", C.BRIGHT_MAGENTA)
911
1498
  print(f"{C.BRIGHT_BLUE}└{'─' * 70}┘{C.RESET}")
912
1499
 
913
1500
  # Build task queue
@@ -917,18 +1504,103 @@ def main():
917
1504
 
918
1505
  # Print controls hint
919
1506
  print(f"\n{C.BRIGHT_BLUE}{C.BOLD}┌─ CONTROLS {'─' * 58}┐{C.RESET}")
920
- print(f"{C.BRIGHT_BLUE}│{C.RESET} Type while running: {C.BRIGHT_WHITE}wait{C.RESET} {C.DIM}·{C.RESET} {C.BRIGHT_WHITE}pause{C.RESET} {C.DIM}·{C.RESET} {C.BRIGHT_WHITE}add{C.RESET} {C.DIM}·{C.RESET} {C.BRIGHT_WHITE}done{C.RESET} {C.BRIGHT_BLUE}│{C.RESET}")
1507
+ print(f"{C.BRIGHT_BLUE}│{C.RESET} Type while running: {C.BRIGHT_WHITE}wait{C.RESET} {C.DIM}·{C.RESET} {C.BRIGHT_WHITE}pause{C.RESET} {C.DIM}·{C.RESET} {C.BRIGHT_WHITE}add{C.RESET} {C.DIM}·{C.RESET} {C.BRIGHT_WHITE}done{C.RESET} {C.DIM}·{C.RESET} {C.BRIGHT_WHITE}stop{C.RESET} {C.BRIGHT_BLUE}│{C.RESET}")
921
1508
  print(f"{C.BRIGHT_BLUE}└{'─' * 70}┘{C.RESET}")
922
1509
 
923
- # Start input monitor
924
- input_monitor = InputMonitor()
925
- input_monitor.start()
926
-
927
1510
  # Build history context for initial prompt
928
1511
  history_context = ""
929
1512
  if history_data["sessions"]:
930
1513
  history_context = "\n\n" + format_history_context(history_data["sessions"])
931
1514
 
1515
+ # ========================================================================
1516
+ # TURBO MODE: parallel execution with Rich dashboard
1517
+ # ========================================================================
1518
+ if args.turbo and len(bug_descriptions) >= 1:
1519
+ num_workers = args.turbo_workers or len(bug_descriptions)
1520
+ num_workers = min(num_workers, len(bug_descriptions))
1521
+
1522
+ print(f"\n {C.BRIGHT_MAGENTA}{C.BOLD}◆ Launching Turbo Mode with {num_workers} parallel workers...{C.RESET}\n")
1523
+ time.sleep(1) # Brief pause so user sees the message before dashboard takes over
1524
+
1525
+ dashboard = TurboDashboard(len(bug_descriptions))
1526
+ system_prompt_full = system_prompt + history_context
1527
+
1528
+ # Launch all tasks in parallel
1529
+ results = [None] * len(bug_descriptions)
1530
+
1531
+ dashboard.start()
1532
+ try:
1533
+ with ThreadPoolExecutor(max_workers=num_workers) as executor:
1534
+ futures = {}
1535
+ for idx, desc in enumerate(bug_descriptions):
1536
+ future = executor.submit(
1537
+ run_turbo_task,
1538
+ task_desc=desc,
1539
+ task_idx=idx,
1540
+ system_prompt=system_prompt_full,
1541
+ working_dir=working_dir,
1542
+ max_iterations=args.max_iterations,
1543
+ timeout=args.timeout,
1544
+ dashboard=dashboard,
1545
+ swarm_mode=args.swarm,
1546
+ swarm_budget_max=args.swarm_budget,
1547
+ swarm_depth_max=args.swarm_depth,
1548
+ )
1549
+ futures[future] = idx
1550
+
1551
+ for future in as_completed(futures):
1552
+ idx = futures[future]
1553
+ try:
1554
+ results[idx] = future.result()
1555
+ except Exception as e:
1556
+ results[idx] = {"outcome": "ERROR", "iterations": 0, "output": str(e)}
1557
+ dashboard.append_output(idx, f"\u2717 Exception: {e}")
1558
+ dashboard.mark_done(idx, "failed")
1559
+ finally:
1560
+ # Keep dashboard visible for a moment so user can see final state
1561
+ time.sleep(2)
1562
+ dashboard.stop()
1563
+
1564
+ # Print final summary
1565
+ print(f"\n{BANNER}\n")
1566
+ print(f"\n{C.BRIGHT_BLUE}{C.BOLD}◆ TURBO MODE COMPLETE{C.RESET}")
1567
+ print(f"{C.BRIGHT_BLUE}{'─' * 70}{C.RESET}")
1568
+ for idx, desc in enumerate(bug_descriptions):
1569
+ r = results[idx]
1570
+ outcome = r["outcome"] if r else "ERROR"
1571
+ iters = r["iterations"] if r else 0
1572
+ desc_short = desc[:55]
1573
+ if outcome == "RESOLVED":
1574
+ print(f" {C.BRIGHT_GREEN}✓{C.RESET} {C.WHITE}{desc_short}{C.RESET} {C.DIM}({iters} iter){C.RESET}")
1575
+ else:
1576
+ print(f" {C.BRIGHT_RED}✗{C.RESET} {C.RED}{desc_short}{C.RESET} {C.DIM}({outcome}, {iters} iter){C.RESET}")
1577
+
1578
+ # Save to history
1579
+ session_entry = summarize_session(desc, outcome, iters)
1580
+ history_data["sessions"].append(session_entry)
1581
+
1582
+ # Save log
1583
+ save_log(idx + 1, r.get("output", "") if r else "", session_id)
1584
+
1585
+ save_history(working_dir, history_data)
1586
+
1587
+ print(f"\n {C.DIM}Session: {session_id}{C.RESET}")
1588
+ print(f" {C.DIM}Logs: {LOG_DIR}{C.RESET}\n")
1589
+
1590
+ all_resolved = all(r and r["outcome"] == "RESOLVED" for r in results)
1591
+ return 0 if all_resolved else 1
1592
+
1593
+ # ========================================================================
1594
+ # SEQUENTIAL MODE (original behavior)
1595
+ # ========================================================================
1596
+
1597
+ # Start input monitor
1598
+ input_monitor = InputMonitor()
1599
+ input_monitor.start()
1600
+
1601
+ # Session-wide token tracking (aggregates across all tasks)
1602
+ session_token_tracker = TokenTracker()
1603
+
932
1604
  # === OUTER LOOP: iterate over tasks ===
933
1605
  while True:
934
1606
  task = task_queue.next()
@@ -1004,6 +1676,7 @@ Then fix it. Use the reasoning protocol before each action.
1004
1676
  """
1005
1677
 
1006
1678
  iteration_history = []
1679
+ task_token_tracker = TokenTracker()
1007
1680
 
1008
1681
  # === INNER LOOP: iterations for current task ===
1009
1682
  for iteration in range(1, args.max_iterations + 1):
@@ -1027,11 +1700,20 @@ Then fix it. Use the reasoning protocol before each action.
1027
1700
  full_prompt, working_dir,
1028
1701
  continue_session=continue_session,
1029
1702
  stream=True, timeout=args.timeout,
1030
- input_monitor=input_monitor
1703
+ input_monitor=input_monitor,
1704
+ swarm_mode=args.swarm,
1705
+ swarm_budget_max=args.swarm_budget,
1706
+ swarm_depth_max=args.swarm_depth,
1707
+ token_tracker=task_token_tracker,
1708
+ show_tokens=args.show_tokens
1031
1709
  )
1032
1710
 
1033
1711
  print(f"\n{C.BRIGHT_BLUE} {'─' * 70}{C.RESET}")
1034
1712
 
1713
+ # Print token usage summary for this iteration
1714
+ if task_token_tracker.turn_count > 0:
1715
+ print(f" {C.BRIGHT_CYAN}⊛ {task_token_tracker.summary_line()}{C.RESET}")
1716
+
1035
1717
  # Save log
1036
1718
  save_log(iteration, output, session_id)
1037
1719
  iteration_history.append(output)
@@ -1085,22 +1767,34 @@ Continue the current debugging task. Use the reasoning protocol before each acti
1085
1767
  elif user_cmd == "done":
1086
1768
  # Clean exit
1087
1769
  input_monitor.stop()
1088
- session_entry = summarize_session(bug_input, "DONE_BY_USER", iteration)
1770
+ session_entry = summarize_session(bug_input, "DONE_BY_USER", iteration, task_token_tracker)
1089
1771
  history_data["sessions"].append(session_entry)
1090
1772
  save_history(working_dir, history_data)
1091
1773
  print(f"\n {C.GREEN}✓{C.RESET} Session saved. Exiting by user request.")
1092
1774
  return 0
1093
1775
 
1776
+ elif user_cmd == "stop":
1777
+ # Boris-commanded stop - terminate this iteration immediately
1778
+ print(f"\n {C.BRIGHT_RED}{C.BOLD} ■ STOPPED BY BORIS{C.RESET}")
1779
+ print(f"{C.BRIGHT_RED} {'─' * 70}{C.RESET}")
1780
+ input_monitor.stop()
1781
+ session_entry = summarize_session(bug_input, "STOPPED_BY_BORIS", iteration, task_token_tracker)
1782
+ history_data["sessions"].append(session_entry)
1783
+ save_history(working_dir, history_data)
1784
+ return 1
1785
+
1094
1786
  # Check exit condition
1095
1787
  signal, should_exit = check_exit_condition(output)
1096
1788
 
1097
1789
  if should_exit:
1098
1790
  if signal == "RESOLVED":
1099
1791
  print_success_box("")
1792
+ if task_token_tracker.turn_count > 0:
1793
+ print(f" {C.BRIGHT_CYAN}⊛ {task_token_tracker.summary_line()}{C.RESET}")
1100
1794
  print(f" {C.DIM}Session: {session_id}{C.RESET}")
1101
1795
  print(f" {C.DIM}Logs: {LOG_DIR}{C.RESET}\n")
1102
1796
  task_queue.mark_done()
1103
- session_entry = summarize_session(bug_input, "RESOLVED", iteration)
1797
+ session_entry = summarize_session(bug_input, "RESOLVED", iteration, task_token_tracker)
1104
1798
  history_data["sessions"].append(session_entry)
1105
1799
  save_history(working_dir, history_data)
1106
1800
  break # Move to next task
@@ -1126,14 +1820,14 @@ Continue debugging with this information. Use the reasoning protocol before each
1126
1820
  print_status("Logs", str(LOG_DIR), C.WHITE)
1127
1821
  print()
1128
1822
  task_queue.mark_failed()
1129
- session_entry = summarize_session(bug_input, "BLOCKED", iteration)
1823
+ session_entry = summarize_session(bug_input, "BLOCKED", iteration, task_token_tracker)
1130
1824
  history_data["sessions"].append(session_entry)
1131
1825
  save_history(working_dir, history_data)
1132
1826
  break # Move to next task
1133
1827
  else:
1134
1828
  print_error_box(f"Error occurred: {signal}")
1135
1829
  task_queue.mark_failed()
1136
- session_entry = summarize_session(bug_input, "ERROR", iteration)
1830
+ session_entry = summarize_session(bug_input, "ERROR", iteration, task_token_tracker)
1137
1831
  history_data["sessions"].append(session_entry)
1138
1832
  save_history(working_dir, history_data)
1139
1833
  break # Move to next task
@@ -1173,15 +1867,33 @@ Use the reasoning protocol before each action.
1173
1867
  # Max iterations reached for this task (for-else)
1174
1868
  print_warning_box(f"Max iterations ({args.max_iterations}) reached for current task")
1175
1869
  task_queue.mark_failed()
1176
- session_entry = summarize_session(bug_input, "MAX_ITERATIONS", args.max_iterations)
1870
+ session_entry = summarize_session(bug_input, "MAX_ITERATIONS", args.max_iterations, task_token_tracker)
1177
1871
  history_data["sessions"].append(session_entry)
1178
1872
  save_history(working_dir, history_data)
1179
1873
 
1874
+ # Aggregate task tokens into session-level tracker
1875
+ if task_token_tracker.turn_count > 0:
1876
+ session_token_tracker.total_input += task_token_tracker.total_input
1877
+ session_token_tracker.total_output += task_token_tracker.total_output
1878
+ session_token_tracker.turn_count += task_token_tracker.turn_count
1879
+ if task_token_tracker.peak_total > session_token_tracker.peak_total:
1880
+ session_token_tracker.peak_total = task_token_tracker.peak_total
1881
+ session_token_tracker.peak_input = task_token_tracker.peak_input
1882
+ session_token_tracker.peak_output = task_token_tracker.peak_output
1883
+ for tool, stats in task_token_tracker.per_tool.items():
1884
+ if tool not in session_token_tracker.per_tool:
1885
+ session_token_tracker.per_tool[tool] = {"input": 0, "output": 0, "count": 0}
1886
+ session_token_tracker.per_tool[tool]["input"] += stats["input"]
1887
+ session_token_tracker.per_tool[tool]["output"] += stats["output"]
1888
+ session_token_tracker.per_tool[tool]["count"] += stats["count"]
1889
+
1180
1890
  # Save iteration summary for this task
1181
1891
  LOG_DIR.mkdir(exist_ok=True)
1182
1892
  summary = f"# DaveLoop Session {session_id}\n\n"
1183
1893
  summary += f"Bug: {bug_input[:200]}...\n\n"
1184
1894
  summary += f"Iterations: {len(iteration_history)}\n\n"
1895
+ if task_token_tracker.turn_count > 0:
1896
+ summary += f"Token Usage: {task_token_tracker.summary_line()}\n\n"
1185
1897
  summary += "## Iteration History\n\n"
1186
1898
  for i, hist in enumerate(iteration_history, 1):
1187
1899
  summary += f"### Iteration {i}\n```\n{hist[:500]}...\n```\n\n"
@@ -1203,6 +1915,16 @@ Use the reasoning protocol before each action.
1203
1915
  print(f" {C.DIM}○ {desc}{C.RESET}")
1204
1916
  print()
1205
1917
 
1918
+ # Print session-wide token usage
1919
+ if session_token_tracker.turn_count > 0:
1920
+ print(f" {C.BRIGHT_CYAN}⊛ {session_token_tracker.summary_line()}{C.RESET}")
1921
+ if session_token_tracker.per_tool:
1922
+ print(f" {C.DIM} Per tool:{C.RESET}")
1923
+ for tool, stats in sorted(session_token_tracker.per_tool.items(), key=lambda x: x[1]["input"] + x[1]["output"], reverse=True):
1924
+ tool_total = stats["input"] + stats["output"]
1925
+ print(f" {C.DIM} {tool}: {stats['input']:,} in / {stats['output']:,} out / {tool_total:,} total ({stats['count']} calls){C.RESET}")
1926
+ print()
1927
+
1206
1928
  print(f" {C.DIM}Session: {session_id}{C.RESET}")
1207
1929
  print(f" {C.DIM}Logs: {LOG_DIR}{C.RESET}\n")
1208
1930