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.
- zwarm/adapters/claude_code.py +55 -3
- zwarm/adapters/codex_mcp.py +433 -122
- zwarm/adapters/test_codex_mcp.py +26 -26
- zwarm/cli/main.py +464 -3
- zwarm/core/compact.py +312 -0
- zwarm/core/config.py +51 -9
- zwarm/core/environment.py +104 -33
- zwarm/core/models.py +16 -0
- zwarm/core/test_compact.py +266 -0
- zwarm/orchestrator.py +222 -39
- zwarm/prompts/orchestrator.py +128 -146
- zwarm/test_orchestrator_watchers.py +23 -0
- zwarm/tools/delegation.py +23 -4
- zwarm/watchers/builtin.py +90 -4
- zwarm/watchers/manager.py +46 -8
- zwarm/watchers/test_watchers.py +42 -0
- {zwarm-0.1.0.dist-info → zwarm-1.0.0.dist-info}/METADATA +162 -36
- zwarm-1.0.0.dist-info/RECORD +33 -0
- zwarm-0.1.0.dist-info/RECORD +0 -30
- {zwarm-0.1.0.dist-info → zwarm-1.0.0.dist-info}/WHEEL +0 -0
- {zwarm-0.1.0.dist-info → zwarm-1.0.0.dist-info}/entry_points.txt +0 -0
zwarm/adapters/test_codex_mcp.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
20
|
-
client = MCPClient(
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
},
|
|
70
|
-
|
|
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]#
|
|
69
|
-
$ zwarm
|
|
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,
|