zwarm 0.1.0__py3-none-any.whl → 1.0.0__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.
@@ -1,10 +1,9 @@
1
1
  """Tests for Codex MCP adapter."""
2
2
 
3
- import asyncio
4
3
  import subprocess
5
4
  import tempfile
6
5
  from pathlib import Path
7
- from unittest.mock import AsyncMock, MagicMock, patch
6
+ from unittest.mock import MagicMock, patch
8
7
 
9
8
  import pytest
10
9
 
@@ -16,8 +15,8 @@ class TestMCPClient:
16
15
  """Tests for the MCP client."""
17
16
 
18
17
  def test_next_id_increments(self):
19
- proc = MagicMock()
20
- client = MCPClient(proc)
18
+ """Test that request IDs increment properly."""
19
+ client = MCPClient()
21
20
  assert client._next_id() == 1
22
21
  assert client._next_id() == 2
23
22
  assert client._next_id() == 3
@@ -34,14 +33,14 @@ class TestCodexMCPAdapter:
34
33
  async def test_start_session_creates_session(self, adapter):
35
34
  """Test that start_session creates a proper session object."""
36
35
  with tempfile.TemporaryDirectory() as tmpdir:
37
- # Mock the MCP client
38
- mock_client = AsyncMock()
39
- mock_client.call_tool = AsyncMock(return_value={
40
- "conversationId": "conv-123",
41
- "content": [{"text": "Hello! I'll help you with that."}],
42
- })
43
-
44
- with patch.object(adapter, "_ensure_server", return_value=mock_client):
36
+ # Mock the _call_codex method (now synchronous)
37
+ with patch.object(adapter, "_call_codex", return_value={
38
+ "conversation_id": "conv-123",
39
+ "response": "Hello! I'll help you with that.",
40
+ "raw_messages": [],
41
+ "usage": {},
42
+ "total_usage": {},
43
+ }):
45
44
  session = await adapter.start_session(
46
45
  task="Say hello",
47
46
  working_dir=Path(tmpdir),
@@ -60,26 +59,27 @@ class TestCodexMCPAdapter:
60
59
  async def test_send_message_continues_conversation(self, adapter):
61
60
  """Test that send_message continues an existing conversation."""
62
61
  with tempfile.TemporaryDirectory() as tmpdir:
63
- mock_client = AsyncMock()
64
- mock_client.call_tool = AsyncMock(side_effect=[
65
- # First call: start session
66
- {
67
- "conversationId": "conv-123",
68
- "content": [{"text": "Initial response"}],
69
- },
70
- # Second call: reply
71
- {
72
- "content": [{"text": "Follow-up response"}],
73
- },
74
- ])
75
-
76
- with patch.object(adapter, "_ensure_server", return_value=mock_client):
62
+ # Mock _call_codex for start_session
63
+ with patch.object(adapter, "_call_codex", return_value={
64
+ "conversation_id": "conv-123",
65
+ "response": "Initial response",
66
+ "raw_messages": [],
67
+ "usage": {},
68
+ "total_usage": {},
69
+ }):
77
70
  session = await adapter.start_session(
78
71
  task="Start task",
79
72
  working_dir=Path(tmpdir),
80
73
  mode="sync",
81
74
  )
82
75
 
76
+ # Mock _call_codex_reply for send_message
77
+ with patch.object(adapter, "_call_codex_reply", return_value={
78
+ "response": "Follow-up response",
79
+ "raw_messages": [],
80
+ "usage": {},
81
+ "total_usage": {},
82
+ }):
83
83
  response = await adapter.send_message(session, "Continue please")
84
84
 
85
85
  assert response == "Follow-up response"
zwarm/cli/main.py CHANGED
@@ -12,6 +12,7 @@ Commands:
12
12
  from __future__ import annotations
13
13
 
14
14
  import asyncio
15
+ import os
15
16
  import sys
16
17
  from enum import Enum
17
18
  from pathlib import Path
@@ -65,8 +66,8 @@ app = typer.Typer(
65
66
  delegation, conversation, and trajectory alignment (watchers).
66
67
 
67
68
  [bold]QUICK START[/]
68
- [dim]# Test an executor directly[/]
69
- $ zwarm exec --task "What is 2+2?"
69
+ [dim]# Initialize zwarm in your project[/]
70
+ $ zwarm init
70
71
 
71
72
  [dim]# Run the orchestrator[/]
72
73
  $ zwarm orchestrate --task "Build a hello world function"
@@ -75,6 +76,8 @@ app = typer.Typer(
75
76
  $ zwarm status
76
77
 
77
78
  [bold]COMMANDS[/]
79
+ [cyan]init[/] Initialize zwarm (create config.toml, .zwarm/)
80
+ [cyan]reset[/] Reset state and optionally config files
78
81
  [cyan]orchestrate[/] Start orchestrator to delegate tasks to executors
79
82
  [cyan]exec[/] Run a single executor directly (for testing)
80
83
  [cyan]status[/] Show current state (sessions, tasks, events)
@@ -264,7 +267,7 @@ def exec(
264
267
  console.print(f" Task: {task}")
265
268
 
266
269
  if adapter == AdapterType.codex_mcp:
267
- executor = CodexMCPAdapter()
270
+ executor = CodexMCPAdapter(model=model)
268
271
  elif adapter == AdapterType.claude_code:
269
272
  executor = ClaudeCodeAdapter(model=model)
270
273
  else:
@@ -514,6 +517,464 @@ def configs_show(
514
517
  raise typer.Exit(1)
515
518
 
516
519
 
520
+ @app.command()
521
+ def init(
522
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
523
+ non_interactive: Annotated[bool, typer.Option("--yes", "-y", help="Accept defaults, no prompts")] = False,
524
+ with_project: Annotated[bool, typer.Option("--with-project", help="Create zwarm.yaml project config")] = False,
525
+ ):
526
+ """
527
+ Initialize zwarm in the current directory.
528
+
529
+ Creates configuration files and the .zwarm state directory.
530
+ Run this once per project to set up zwarm.
531
+
532
+ [bold]Creates:[/]
533
+ [cyan]config.toml[/] Runtime settings (weave, adapter, watchers)
534
+ [cyan].zwarm/[/] State directory for sessions and events
535
+ [cyan]zwarm.yaml[/] Project config (optional, with --with-project)
536
+
537
+ [bold]Examples:[/]
538
+ [dim]# Interactive setup[/]
539
+ $ zwarm init
540
+
541
+ [dim]# Quick setup with defaults[/]
542
+ $ zwarm init --yes
543
+
544
+ [dim]# Full setup with project config[/]
545
+ $ zwarm init --with-project
546
+ """
547
+ console.print("\n[bold cyan]zwarm init[/] - Initialize zwarm configuration\n")
548
+
549
+ config_toml_path = working_dir / "config.toml"
550
+ zwarm_yaml_path = working_dir / "zwarm.yaml"
551
+ state_dir = working_dir / ".zwarm"
552
+
553
+ # Check for existing files
554
+ if config_toml_path.exists():
555
+ console.print(f"[yellow]Warning:[/] config.toml already exists")
556
+ if not non_interactive:
557
+ overwrite = typer.confirm("Overwrite?", default=False)
558
+ if not overwrite:
559
+ console.print("[dim]Skipping config.toml[/]")
560
+ config_toml_path = None
561
+ else:
562
+ config_toml_path = None
563
+
564
+ # Gather settings
565
+ weave_project = ""
566
+ adapter = "codex_mcp"
567
+ watchers_enabled = ["progress", "budget", "delegation"]
568
+ create_project_config = with_project
569
+ project_description = ""
570
+ project_context = ""
571
+
572
+ if not non_interactive:
573
+ console.print("[bold]Configuration[/]\n")
574
+
575
+ # Weave project
576
+ weave_project = typer.prompt(
577
+ " Weave project (entity/project, blank to skip)",
578
+ default="",
579
+ show_default=False,
580
+ )
581
+
582
+ # Adapter
583
+ adapter = typer.prompt(
584
+ " Default adapter",
585
+ default="codex_mcp",
586
+ type=str,
587
+ )
588
+
589
+ # Watchers
590
+ console.print("\n [bold]Watchers[/] (trajectory aligners)")
591
+ available_watchers = ["progress", "budget", "delegation", "scope", "pattern", "quality"]
592
+ watchers_enabled = []
593
+ for w in available_watchers:
594
+ default = w in ["progress", "budget", "delegation"]
595
+ if typer.confirm(f" Enable {w}?", default=default):
596
+ watchers_enabled.append(w)
597
+
598
+ # Project config
599
+ console.print()
600
+ create_project_config = typer.confirm(
601
+ " Create zwarm.yaml project config?",
602
+ default=with_project,
603
+ )
604
+
605
+ if create_project_config:
606
+ project_description = typer.prompt(
607
+ " Project description",
608
+ default="",
609
+ show_default=False,
610
+ )
611
+ console.print(" [dim]Project context (optional, press Enter twice to finish):[/]")
612
+ context_lines = []
613
+ while True:
614
+ line = typer.prompt(" ", default="", show_default=False)
615
+ if not line:
616
+ break
617
+ context_lines.append(line)
618
+ project_context = "\n".join(context_lines)
619
+
620
+ # Create .zwarm directory
621
+ console.print("\n[bold]Creating files...[/]\n")
622
+
623
+ state_dir.mkdir(parents=True, exist_ok=True)
624
+ (state_dir / "sessions").mkdir(exist_ok=True)
625
+ (state_dir / "orchestrator").mkdir(exist_ok=True)
626
+ console.print(f" [green]✓[/] Created .zwarm/")
627
+
628
+ # Create config.toml
629
+ if config_toml_path:
630
+ toml_content = _generate_config_toml(
631
+ weave_project=weave_project,
632
+ adapter=adapter,
633
+ watchers=watchers_enabled,
634
+ )
635
+ config_toml_path.write_text(toml_content)
636
+ console.print(f" [green]✓[/] Created config.toml")
637
+
638
+ # Create zwarm.yaml
639
+ if create_project_config:
640
+ if zwarm_yaml_path.exists() and not non_interactive:
641
+ overwrite = typer.confirm(" zwarm.yaml exists. Overwrite?", default=False)
642
+ if not overwrite:
643
+ create_project_config = False
644
+
645
+ if create_project_config:
646
+ yaml_content = _generate_zwarm_yaml(
647
+ description=project_description,
648
+ context=project_context,
649
+ watchers=watchers_enabled,
650
+ )
651
+ zwarm_yaml_path.write_text(yaml_content)
652
+ console.print(f" [green]✓[/] Created zwarm.yaml")
653
+
654
+ # Summary
655
+ console.print("\n[bold green]Done![/] zwarm is ready.\n")
656
+ console.print("[bold]Next steps:[/]")
657
+ console.print(" [dim]# Run the orchestrator[/]")
658
+ console.print(" $ zwarm orchestrate --task \"Your task here\"\n")
659
+ console.print(" [dim]# Or test an executor directly[/]")
660
+ console.print(" $ zwarm exec --task \"What is 2+2?\"\n")
661
+
662
+
663
+ def _generate_config_toml(
664
+ weave_project: str = "",
665
+ adapter: str = "codex_mcp",
666
+ watchers: list[str] | None = None,
667
+ ) -> str:
668
+ """Generate config.toml content."""
669
+ watchers = watchers or []
670
+
671
+ lines = [
672
+ "# zwarm configuration",
673
+ "# Generated by 'zwarm init'",
674
+ "",
675
+ "[weave]",
676
+ ]
677
+
678
+ if weave_project:
679
+ lines.append(f'project = "{weave_project}"')
680
+ else:
681
+ lines.append("# project = \"your-entity/your-project\" # Uncomment to enable Weave tracing")
682
+
683
+ lines.extend([
684
+ "",
685
+ "[orchestrator]",
686
+ "max_steps = 50",
687
+ "",
688
+ "[executor]",
689
+ f'adapter = "{adapter}"',
690
+ "# model = \"\" # Optional model override",
691
+ "",
692
+ "[watchers]",
693
+ f"enabled = {watchers}",
694
+ "",
695
+ "# Watcher-specific configuration",
696
+ "# [watchers.budget]",
697
+ "# max_steps = 50",
698
+ "# warn_at_percent = 80",
699
+ "",
700
+ "# [watchers.pattern]",
701
+ "# patterns = [\"DROP TABLE\", \"rm -rf\"]",
702
+ "",
703
+ ])
704
+
705
+ return "\n".join(lines)
706
+
707
+
708
+ def _generate_zwarm_yaml(
709
+ description: str = "",
710
+ context: str = "",
711
+ watchers: list[str] | None = None,
712
+ ) -> str:
713
+ """Generate zwarm.yaml project config."""
714
+ watchers = watchers or []
715
+
716
+ lines = [
717
+ "# zwarm project configuration",
718
+ "# Customize the orchestrator for this specific project",
719
+ "",
720
+ f'description: "{description}"' if description else 'description: ""',
721
+ "",
722
+ "# Project-specific context injected into the orchestrator",
723
+ "# This helps the orchestrator understand your codebase",
724
+ "context: |",
725
+ ]
726
+
727
+ if context:
728
+ for line in context.split("\n"):
729
+ lines.append(f" {line}")
730
+ else:
731
+ lines.extend([
732
+ " # Describe your project here. For example:",
733
+ " # - Tech stack (FastAPI, React, PostgreSQL)",
734
+ " # - Key directories (src/api/, src/components/)",
735
+ " # - Coding conventions to follow",
736
+ ])
737
+
738
+ lines.extend([
739
+ "",
740
+ "# Project-specific constraints",
741
+ "# The orchestrator will be reminded to follow these",
742
+ "constraints:",
743
+ " # - \"Never modify migration files directly\"",
744
+ " # - \"All new endpoints need tests\"",
745
+ " # - \"Use existing patterns from src/api/\"",
746
+ "",
747
+ "# Default watchers for this project",
748
+ "watchers:",
749
+ ])
750
+
751
+ for w in watchers:
752
+ lines.append(f" - {w}")
753
+
754
+ if not watchers:
755
+ lines.append(" # - progress")
756
+ lines.append(" # - budget")
757
+
758
+ lines.append("")
759
+
760
+ return "\n".join(lines)
761
+
762
+
763
+ @app.command()
764
+ def reset(
765
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
766
+ state: Annotated[bool, typer.Option("--state", "-s", help="Reset .zwarm/ state directory")] = True,
767
+ config: Annotated[bool, typer.Option("--config", "-c", help="Also delete config.toml")] = False,
768
+ project: Annotated[bool, typer.Option("--project", "-p", help="Also delete zwarm.yaml")] = False,
769
+ all_files: Annotated[bool, typer.Option("--all", "-a", help="Delete everything (state + config + project)")] = False,
770
+ force: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
771
+ ):
772
+ """
773
+ Reset zwarm state and optionally configuration files.
774
+
775
+ By default, only clears the .zwarm/ state directory (sessions, events, orchestrator history).
776
+ Use flags to also remove configuration files.
777
+
778
+ [bold]Examples:[/]
779
+ [dim]# Reset state only (default)[/]
780
+ $ zwarm reset
781
+
782
+ [dim]# Reset everything, no confirmation[/]
783
+ $ zwarm reset --all --yes
784
+
785
+ [dim]# Reset state and config.toml[/]
786
+ $ zwarm reset --config
787
+ """
788
+ import shutil
789
+
790
+ console.print("\n[bold cyan]zwarm reset[/] - Reset zwarm state\n")
791
+
792
+ state_dir = working_dir / ".zwarm"
793
+ config_toml_path = working_dir / "config.toml"
794
+ zwarm_yaml_path = working_dir / "zwarm.yaml"
795
+
796
+ # Expand --all flag
797
+ if all_files:
798
+ state = True
799
+ config = True
800
+ project = True
801
+
802
+ # Collect what will be deleted
803
+ to_delete = []
804
+ if state and state_dir.exists():
805
+ to_delete.append((".zwarm/", state_dir))
806
+ if config and config_toml_path.exists():
807
+ to_delete.append(("config.toml", config_toml_path))
808
+ if project and zwarm_yaml_path.exists():
809
+ to_delete.append(("zwarm.yaml", zwarm_yaml_path))
810
+
811
+ if not to_delete:
812
+ console.print("[yellow]Nothing to reset.[/] No matching files found.")
813
+ raise typer.Exit(0)
814
+
815
+ # Show what will be deleted
816
+ console.print("[bold]Will delete:[/]")
817
+ for name, path in to_delete:
818
+ if path.is_dir():
819
+ # Count contents
820
+ files = list(path.rglob("*"))
821
+ file_count = len([f for f in files if f.is_file()])
822
+ console.print(f" [red]✗[/] {name} ({file_count} files)")
823
+ else:
824
+ console.print(f" [red]✗[/] {name}")
825
+
826
+ # Confirm
827
+ if not force:
828
+ console.print()
829
+ confirm = typer.confirm("Proceed with reset?", default=False)
830
+ if not confirm:
831
+ console.print("[dim]Aborted.[/]")
832
+ raise typer.Exit(0)
833
+
834
+ # Delete
835
+ console.print("\n[bold]Deleting...[/]")
836
+ for name, path in to_delete:
837
+ try:
838
+ if path.is_dir():
839
+ shutil.rmtree(path)
840
+ else:
841
+ path.unlink()
842
+ console.print(f" [green]✓[/] Deleted {name}")
843
+ except Exception as e:
844
+ console.print(f" [red]✗[/] Failed to delete {name}: {e}")
845
+
846
+ console.print("\n[bold green]Reset complete.[/]")
847
+ console.print("\n[dim]Run 'zwarm init' to set up again.[/]\n")
848
+
849
+
850
+ @app.command()
851
+ def clean(
852
+ force: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
853
+ dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show what would be killed without killing")] = False,
854
+ ):
855
+ """
856
+ Clean up orphaned processes from zwarm sessions.
857
+
858
+ Finds and kills:
859
+ - Orphaned codex mcp-server processes
860
+ - Orphaned codex exec processes
861
+ - Orphaned claude CLI processes
862
+
863
+ [bold]Examples:[/]
864
+ [dim]# See what would be cleaned[/]
865
+ $ zwarm clean --dry-run
866
+
867
+ [dim]# Clean without confirmation[/]
868
+ $ zwarm clean --yes
869
+ """
870
+ import subprocess
871
+ import signal
872
+
873
+ console.print("\n[bold cyan]zwarm clean[/] - Clean up orphaned processes\n")
874
+
875
+ # Patterns to search for
876
+ patterns = [
877
+ ("codex mcp-server", "Codex MCP server"),
878
+ ("codex exec", "Codex exec"),
879
+ ("claude.*--permission-mode", "Claude CLI"),
880
+ ]
881
+
882
+ found_processes = []
883
+
884
+ for pattern, description in patterns:
885
+ try:
886
+ # Use pgrep to find matching processes
887
+ result = subprocess.run(
888
+ ["pgrep", "-f", pattern],
889
+ capture_output=True,
890
+ text=True,
891
+ )
892
+ if result.returncode == 0 and result.stdout.strip():
893
+ pids = result.stdout.strip().split("\n")
894
+ for pid in pids:
895
+ pid = pid.strip()
896
+ if pid and pid.isdigit():
897
+ # Get process info
898
+ try:
899
+ ps_result = subprocess.run(
900
+ ["ps", "-p", pid, "-o", "pid,ppid,etime,command"],
901
+ capture_output=True,
902
+ text=True,
903
+ )
904
+ if ps_result.returncode == 0:
905
+ lines = ps_result.stdout.strip().split("\n")
906
+ if len(lines) > 1:
907
+ # Skip header, get process line
908
+ proc_info = lines[1].strip()
909
+ found_processes.append((int(pid), description, proc_info))
910
+ except Exception:
911
+ found_processes.append((int(pid), description, "(unknown)"))
912
+ except FileNotFoundError:
913
+ # pgrep not available, try ps with grep
914
+ try:
915
+ result = subprocess.run(
916
+ f"ps aux | grep '{pattern}' | grep -v grep",
917
+ shell=True,
918
+ capture_output=True,
919
+ text=True,
920
+ )
921
+ if result.returncode == 0 and result.stdout.strip():
922
+ for line in result.stdout.strip().split("\n"):
923
+ parts = line.split()
924
+ if len(parts) >= 2:
925
+ pid = parts[1]
926
+ if pid.isdigit():
927
+ found_processes.append((int(pid), description, line[:80]))
928
+ except Exception:
929
+ pass
930
+ except Exception as e:
931
+ console.print(f"[yellow]Warning:[/] Error searching for {description}: {e}")
932
+
933
+ if not found_processes:
934
+ console.print("[green]No orphaned processes found.[/] Nothing to clean.\n")
935
+ raise typer.Exit(0)
936
+
937
+ # Show what was found
938
+ console.print(f"[bold]Found {len(found_processes)} process(es):[/]\n")
939
+ for pid, description, info in found_processes:
940
+ console.print(f" [yellow]PID {pid}[/] - {description}")
941
+ console.print(f" [dim]{info[:100]}{'...' if len(info) > 100 else ''}[/]")
942
+
943
+ if dry_run:
944
+ console.print("\n[dim]Dry run - no processes killed.[/]\n")
945
+ raise typer.Exit(0)
946
+
947
+ # Confirm
948
+ if not force:
949
+ console.print()
950
+ confirm = typer.confirm(f"Kill {len(found_processes)} process(es)?", default=False)
951
+ if not confirm:
952
+ console.print("[dim]Aborted.[/]")
953
+ raise typer.Exit(0)
954
+
955
+ # Kill processes
956
+ console.print("\n[bold]Cleaning up...[/]")
957
+ killed = 0
958
+ failed = 0
959
+
960
+ for pid, description, _ in found_processes:
961
+ try:
962
+ # First try SIGTERM
963
+ os.kill(pid, signal.SIGTERM)
964
+ console.print(f" [green]✓[/] Killed PID {pid} ({description})")
965
+ killed += 1
966
+ except ProcessLookupError:
967
+ console.print(f" [dim]○[/] PID {pid} already gone")
968
+ except PermissionError:
969
+ console.print(f" [red]✗[/] PID {pid} - permission denied (try sudo)")
970
+ failed += 1
971
+ except Exception as e:
972
+ console.print(f" [red]✗[/] PID {pid} - {e}")
973
+ failed += 1
974
+
975
+ console.print(f"\n[bold green]Cleanup complete.[/] Killed {killed}, failed {failed}.\n")
976
+
977
+
517
978
  @app.callback(invoke_without_command=True)
518
979
  def main_callback(
519
980
  ctx: typer.Context,