zwarm 1.0.1__py3-none-any.whl → 1.2.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.
- zwarm/adapters/__init__.py +21 -0
- zwarm/adapters/claude_code.py +5 -3
- zwarm/adapters/codex_mcp.py +98 -10
- zwarm/adapters/registry.py +69 -0
- zwarm/adapters/test_codex_mcp.py +50 -0
- zwarm/adapters/test_registry.py +68 -0
- zwarm/cli/main.py +553 -25
- zwarm/core/config.py +23 -2
- zwarm/orchestrator.py +3 -10
- zwarm/tools/delegation.py +78 -1
- {zwarm-1.0.1.dist-info → zwarm-1.2.1.dist-info}/METADATA +7 -5
- {zwarm-1.0.1.dist-info → zwarm-1.2.1.dist-info}/RECORD +14 -12
- {zwarm-1.0.1.dist-info → zwarm-1.2.1.dist-info}/WHEEL +0 -0
- {zwarm-1.0.1.dist-info → zwarm-1.2.1.dist-info}/entry_points.txt +0 -0
zwarm/cli/main.py
CHANGED
|
@@ -76,7 +76,7 @@ app = typer.Typer(
|
|
|
76
76
|
$ zwarm status
|
|
77
77
|
|
|
78
78
|
[bold]COMMANDS[/]
|
|
79
|
-
[cyan]init[/] Initialize zwarm (
|
|
79
|
+
[cyan]init[/] Initialize zwarm (creates .zwarm/ with config)
|
|
80
80
|
[cyan]reset[/] Reset state and optionally config files
|
|
81
81
|
[cyan]orchestrate[/] Start orchestrator to delegate tasks to executors
|
|
82
82
|
[cyan]exec[/] Run a single executor directly (for testing)
|
|
@@ -85,7 +85,8 @@ app = typer.Typer(
|
|
|
85
85
|
[cyan]configs[/] Manage configuration files
|
|
86
86
|
|
|
87
87
|
[bold]CONFIGURATION[/]
|
|
88
|
-
|
|
88
|
+
Config lives in [cyan].zwarm/config.toml[/] (created by init).
|
|
89
|
+
Use [cyan]--config[/] flag for YAML files.
|
|
89
90
|
See [cyan]zwarm configs list[/] for available configurations.
|
|
90
91
|
|
|
91
92
|
[bold]ADAPTERS[/]
|
|
@@ -258,20 +259,17 @@ def exec(
|
|
|
258
259
|
[dim]# Async mode[/]
|
|
259
260
|
$ zwarm exec --task "Build feature" --mode async
|
|
260
261
|
"""
|
|
261
|
-
from zwarm.adapters
|
|
262
|
-
from zwarm.adapters.claude_code import ClaudeCodeAdapter
|
|
262
|
+
from zwarm.adapters import get_adapter
|
|
263
263
|
|
|
264
264
|
console.print(f"[bold]Running executor directly...[/]")
|
|
265
265
|
console.print(f" Adapter: [cyan]{adapter.value}[/]")
|
|
266
266
|
console.print(f" Mode: {mode.value}")
|
|
267
267
|
console.print(f" Task: {task}")
|
|
268
268
|
|
|
269
|
-
|
|
270
|
-
executor =
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
else:
|
|
274
|
-
console.print(f"[red]Unknown adapter:[/] {adapter}")
|
|
269
|
+
try:
|
|
270
|
+
executor = get_adapter(adapter.value, model=model)
|
|
271
|
+
except ValueError as e:
|
|
272
|
+
console.print(f"[red]Error:[/] {e}")
|
|
275
273
|
sys.exit(1)
|
|
276
274
|
|
|
277
275
|
async def run():
|
|
@@ -482,10 +480,13 @@ def configs_list(
|
|
|
482
480
|
console.print(" [dim]No configuration files found.[/]")
|
|
483
481
|
console.print("\n [dim]Create a YAML config in configs/ to get started.[/]")
|
|
484
482
|
|
|
485
|
-
# Check for config.toml and mention it
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
483
|
+
# Check for config.toml and mention it (check both locations)
|
|
484
|
+
new_config = Path.cwd() / ".zwarm" / "config.toml"
|
|
485
|
+
legacy_config = Path.cwd() / "config.toml"
|
|
486
|
+
if new_config.exists():
|
|
487
|
+
console.print(f"\n[dim]Environment: .zwarm/config.toml (loaded automatically)[/]")
|
|
488
|
+
elif legacy_config.exists():
|
|
489
|
+
console.print(f"\n[dim]Environment: config.toml (legacy location, loaded automatically)[/]")
|
|
489
490
|
|
|
490
491
|
|
|
491
492
|
@configs_app.command("show")
|
|
@@ -530,9 +531,9 @@ def init(
|
|
|
530
531
|
Run this once per project to set up zwarm.
|
|
531
532
|
|
|
532
533
|
[bold]Creates:[/]
|
|
533
|
-
[cyan]
|
|
534
|
-
[cyan].zwarm/[/]
|
|
535
|
-
[cyan]zwarm.yaml[/]
|
|
534
|
+
[cyan].zwarm/[/] State directory for sessions and events
|
|
535
|
+
[cyan].zwarm/config.toml[/] Runtime settings (weave, adapter, watchers)
|
|
536
|
+
[cyan]zwarm.yaml[/] Project config (optional, with --with-project)
|
|
536
537
|
|
|
537
538
|
[bold]Examples:[/]
|
|
538
539
|
[dim]# Interactive setup[/]
|
|
@@ -546,13 +547,25 @@ def init(
|
|
|
546
547
|
"""
|
|
547
548
|
console.print("\n[bold cyan]zwarm init[/] - Initialize zwarm configuration\n")
|
|
548
549
|
|
|
549
|
-
config_toml_path = working_dir / "config.toml"
|
|
550
|
-
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
551
550
|
state_dir = working_dir / ".zwarm"
|
|
551
|
+
config_toml_path = state_dir / "config.toml"
|
|
552
|
+
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
553
|
+
|
|
554
|
+
# Check for existing config (also check old location for migration)
|
|
555
|
+
old_config_path = working_dir / "config.toml"
|
|
556
|
+
if old_config_path.exists() and not config_toml_path.exists():
|
|
557
|
+
console.print(f"[yellow]Note:[/] Found config.toml in project root.")
|
|
558
|
+
console.print(f" Config now lives in .zwarm/config.toml")
|
|
559
|
+
if not non_interactive:
|
|
560
|
+
migrate = typer.confirm(" Move to new location?", default=True)
|
|
561
|
+
if migrate:
|
|
562
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
563
|
+
old_config_path.rename(config_toml_path)
|
|
564
|
+
console.print(f" [green]✓[/] Moved config.toml to .zwarm/")
|
|
552
565
|
|
|
553
566
|
# Check for existing files
|
|
554
567
|
if config_toml_path.exists():
|
|
555
|
-
console.print(f"[yellow]Warning:[/] config.toml already exists")
|
|
568
|
+
console.print(f"[yellow]Warning:[/] .zwarm/config.toml already exists")
|
|
556
569
|
if not non_interactive:
|
|
557
570
|
overwrite = typer.confirm("Overwrite?", default=False)
|
|
558
571
|
if not overwrite:
|
|
@@ -625,7 +638,7 @@ def init(
|
|
|
625
638
|
(state_dir / "orchestrator").mkdir(exist_ok=True)
|
|
626
639
|
console.print(f" [green]✓[/] Created .zwarm/")
|
|
627
640
|
|
|
628
|
-
# Create config.toml
|
|
641
|
+
# Create config.toml inside .zwarm/
|
|
629
642
|
if config_toml_path:
|
|
630
643
|
toml_content = _generate_config_toml(
|
|
631
644
|
weave_project=weave_project,
|
|
@@ -633,7 +646,7 @@ def init(
|
|
|
633
646
|
watchers=watchers_enabled,
|
|
634
647
|
)
|
|
635
648
|
config_toml_path.write_text(toml_content)
|
|
636
|
-
console.print(f" [green]✓[/] Created config.toml")
|
|
649
|
+
console.print(f" [green]✓[/] Created .zwarm/config.toml")
|
|
637
650
|
|
|
638
651
|
# Create zwarm.yaml
|
|
639
652
|
if create_project_config:
|
|
@@ -790,7 +803,8 @@ def reset(
|
|
|
790
803
|
console.print("\n[bold cyan]zwarm reset[/] - Reset zwarm state\n")
|
|
791
804
|
|
|
792
805
|
state_dir = working_dir / ".zwarm"
|
|
793
|
-
config_toml_path =
|
|
806
|
+
config_toml_path = state_dir / "config.toml" # New location
|
|
807
|
+
old_config_toml_path = working_dir / "config.toml" # Legacy location
|
|
794
808
|
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
795
809
|
|
|
796
810
|
# Expand --all flag
|
|
@@ -803,8 +817,12 @@ def reset(
|
|
|
803
817
|
to_delete = []
|
|
804
818
|
if state and state_dir.exists():
|
|
805
819
|
to_delete.append((".zwarm/", state_dir))
|
|
806
|
-
|
|
807
|
-
|
|
820
|
+
# Config: check both new and legacy locations (but skip if state already deletes it)
|
|
821
|
+
if config and not state:
|
|
822
|
+
if config_toml_path.exists():
|
|
823
|
+
to_delete.append((".zwarm/config.toml", config_toml_path))
|
|
824
|
+
if old_config_toml_path.exists():
|
|
825
|
+
to_delete.append(("config.toml (legacy)", old_config_toml_path))
|
|
808
826
|
if project and zwarm_yaml_path.exists():
|
|
809
827
|
to_delete.append(("zwarm.yaml", zwarm_yaml_path))
|
|
810
828
|
|
|
@@ -975,6 +993,516 @@ def clean(
|
|
|
975
993
|
console.print(f"\n[bold green]Cleanup complete.[/] Killed {killed}, failed {failed}.\n")
|
|
976
994
|
|
|
977
995
|
|
|
996
|
+
@app.command()
|
|
997
|
+
def interactive(
|
|
998
|
+
default_adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Default executor adapter")] = AdapterType.codex_mcp,
|
|
999
|
+
default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
|
|
1000
|
+
model: Annotated[Optional[str], typer.Option("--model", help="Default model override")] = None,
|
|
1001
|
+
state_dir: Annotated[Path, typer.Option("--state-dir", help="State directory for persistence")] = Path(".zwarm"),
|
|
1002
|
+
):
|
|
1003
|
+
"""
|
|
1004
|
+
Universal multi-agent CLI for commanding coding agents.
|
|
1005
|
+
|
|
1006
|
+
Spawn multiple agents (Codex, Claude Code) across different directories,
|
|
1007
|
+
manage them interactively, and view their outputs. You are the orchestrator.
|
|
1008
|
+
|
|
1009
|
+
[bold]Commands:[/]
|
|
1010
|
+
[cyan]spawn[/] "task" [opts] Start a session (see spawn --help)
|
|
1011
|
+
[cyan]ls[/] / [cyan]list[/] Dashboard of all sessions
|
|
1012
|
+
[cyan]?[/] / [cyan]check[/] ID Check session status & response
|
|
1013
|
+
[cyan]c[/] / [cyan]converse[/] ID "msg" Continue sync conversation
|
|
1014
|
+
[cyan]view[/] ID Open session output in $EDITOR
|
|
1015
|
+
[cyan]kill[/] ID Stop a session
|
|
1016
|
+
[cyan]killall[/] Stop all running sessions
|
|
1017
|
+
[cyan]refresh[/] Poll all async sessions
|
|
1018
|
+
[cyan]q[/] / [cyan]quit[/] Exit
|
|
1019
|
+
|
|
1020
|
+
[bold]Spawn Options:[/]
|
|
1021
|
+
spawn "task" --dir ~/project --adapter claude_code --async
|
|
1022
|
+
|
|
1023
|
+
[bold]Examples:[/]
|
|
1024
|
+
$ zwarm interactive
|
|
1025
|
+
> spawn "Build auth module" --dir ~/api --async
|
|
1026
|
+
> spawn "Fix tests" --dir ~/api --async
|
|
1027
|
+
> spawn "Add docs" --dir ~/docs
|
|
1028
|
+
> ls
|
|
1029
|
+
> ? abc123
|
|
1030
|
+
> view abc123
|
|
1031
|
+
"""
|
|
1032
|
+
from zwarm.adapters import get_adapter, list_adapters
|
|
1033
|
+
from zwarm.core.models import ConversationSession
|
|
1034
|
+
import argparse
|
|
1035
|
+
import tempfile
|
|
1036
|
+
import subprocess as sp
|
|
1037
|
+
|
|
1038
|
+
console.print("\n[bold cyan]zwarm interactive[/] - Universal Multi-Agent CLI\n")
|
|
1039
|
+
console.print(f" Default adapter: [cyan]{default_adapter.value}[/]")
|
|
1040
|
+
console.print(f" Default dir: {default_dir.absolute()}")
|
|
1041
|
+
console.print(f" Available adapters: {', '.join(list_adapters())}")
|
|
1042
|
+
console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
|
|
1043
|
+
|
|
1044
|
+
# Adapter cache (lazy-loaded per adapter type)
|
|
1045
|
+
adapters: dict[str, Any] = {}
|
|
1046
|
+
|
|
1047
|
+
def get_or_create_adapter(adapter_name: str, adapter_model: str | None = None) -> Any:
|
|
1048
|
+
"""Get cached adapter or create new one."""
|
|
1049
|
+
key = f"{adapter_name}:{adapter_model or 'default'}"
|
|
1050
|
+
if key not in adapters:
|
|
1051
|
+
adapters[key] = get_adapter(adapter_name, model=adapter_model)
|
|
1052
|
+
return adapters[key]
|
|
1053
|
+
|
|
1054
|
+
# Session tracking with metadata
|
|
1055
|
+
sessions: dict[str, ConversationSession] = {}
|
|
1056
|
+
session_dirs: dict[str, Path] = {} # session_id -> working_dir
|
|
1057
|
+
session_adapters: dict[str, str] = {} # session_id -> adapter_name
|
|
1058
|
+
|
|
1059
|
+
# Persistence directory
|
|
1060
|
+
interactive_state_dir = state_dir / "interactive"
|
|
1061
|
+
interactive_state_dir.mkdir(parents=True, exist_ok=True)
|
|
1062
|
+
|
|
1063
|
+
def short_id(session_id: str) -> str:
|
|
1064
|
+
"""Get short display ID."""
|
|
1065
|
+
return session_id[:8]
|
|
1066
|
+
|
|
1067
|
+
def find_session(query: str) -> tuple[ConversationSession | None, str | None]:
|
|
1068
|
+
"""Find session by full or partial ID. Returns (session, full_id)."""
|
|
1069
|
+
if query in sessions:
|
|
1070
|
+
return sessions[query], query
|
|
1071
|
+
for sid, session in sessions.items():
|
|
1072
|
+
if sid.startswith(query):
|
|
1073
|
+
return session, sid
|
|
1074
|
+
return None, None
|
|
1075
|
+
|
|
1076
|
+
def save_session_output(session: ConversationSession) -> Path:
|
|
1077
|
+
"""Save session output to file for viewing."""
|
|
1078
|
+
session_dir = interactive_state_dir / short_id(session.id)
|
|
1079
|
+
session_dir.mkdir(exist_ok=True)
|
|
1080
|
+
|
|
1081
|
+
output_file = session_dir / "output.txt"
|
|
1082
|
+
with open(output_file, "w") as f:
|
|
1083
|
+
f.write(f"Session: {session.id}\n")
|
|
1084
|
+
f.write(f"Task: {session.task_description}\n")
|
|
1085
|
+
f.write(f"Adapter: {session.adapter}\n")
|
|
1086
|
+
f.write(f"Model: {session.model}\n")
|
|
1087
|
+
f.write(f"Status: {session.status.value}\n")
|
|
1088
|
+
f.write(f"Mode: {session.mode.value}\n")
|
|
1089
|
+
f.write(f"Working Dir: {session.working_dir}\n")
|
|
1090
|
+
f.write(f"Tokens: {session.token_usage}\n")
|
|
1091
|
+
f.write("\n" + "="*60 + "\n\n")
|
|
1092
|
+
|
|
1093
|
+
for msg in session.messages:
|
|
1094
|
+
f.write(f"[{msg.role.upper()}]\n")
|
|
1095
|
+
f.write(msg.content)
|
|
1096
|
+
f.write("\n\n" + "-"*40 + "\n\n")
|
|
1097
|
+
|
|
1098
|
+
return output_file
|
|
1099
|
+
|
|
1100
|
+
def show_help():
|
|
1101
|
+
help_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
1102
|
+
help_table.add_column("Command", style="cyan", width=35)
|
|
1103
|
+
help_table.add_column("Description")
|
|
1104
|
+
help_table.add_row('spawn "task" [options]', "Start new session")
|
|
1105
|
+
help_table.add_row(" --dir PATH", "Working directory for this session")
|
|
1106
|
+
help_table.add_row(" --adapter NAME", f"Adapter ({', '.join(list_adapters())})")
|
|
1107
|
+
help_table.add_row(" --async / -a", "Fire-and-forget mode")
|
|
1108
|
+
help_table.add_row(" --model NAME", "Model override")
|
|
1109
|
+
help_table.add_row("", "")
|
|
1110
|
+
help_table.add_row("ls / list", "Dashboard of all sessions")
|
|
1111
|
+
help_table.add_row("? / check ID", "Check session status & messages")
|
|
1112
|
+
help_table.add_row("c / converse ID \"msg\"", "Continue sync conversation")
|
|
1113
|
+
help_table.add_row("view ID", "Open session output in $EDITOR")
|
|
1114
|
+
help_table.add_row("raw ID", "Show raw session JSON")
|
|
1115
|
+
help_table.add_row("kill ID", "Stop a session")
|
|
1116
|
+
help_table.add_row("killall", "Stop all running sessions")
|
|
1117
|
+
help_table.add_row("refresh", "Poll all async sessions for updates")
|
|
1118
|
+
help_table.add_row("q / quit", "Exit interactive mode")
|
|
1119
|
+
console.print(help_table)
|
|
1120
|
+
|
|
1121
|
+
def show_sessions():
|
|
1122
|
+
if not sessions:
|
|
1123
|
+
console.print(" [dim]No sessions yet. Use 'spawn \"task\"' to start one.[/]")
|
|
1124
|
+
return
|
|
1125
|
+
|
|
1126
|
+
table = Table(title="Sessions Dashboard", show_header=True)
|
|
1127
|
+
table.add_column("ID", style="cyan", width=10)
|
|
1128
|
+
table.add_column("Status", width=12)
|
|
1129
|
+
table.add_column("Adapter", width=12)
|
|
1130
|
+
table.add_column("Dir", width=20)
|
|
1131
|
+
table.add_column("Tokens", width=8, justify="right")
|
|
1132
|
+
table.add_column("Task", width=35)
|
|
1133
|
+
|
|
1134
|
+
for sid, s in sessions.items():
|
|
1135
|
+
status_icons = {
|
|
1136
|
+
"active": "[green]● active[/]",
|
|
1137
|
+
"completed": "[dim]✓ done[/]",
|
|
1138
|
+
"failed": "[red]✗ failed[/]",
|
|
1139
|
+
}
|
|
1140
|
+
status = status_icons.get(s.status.value, f"? {s.status.value}")
|
|
1141
|
+
|
|
1142
|
+
# Add mode indicator
|
|
1143
|
+
if s.mode.value == "async" and s.status.value == "active":
|
|
1144
|
+
status = "[yellow]⟳ running[/]"
|
|
1145
|
+
|
|
1146
|
+
work_dir = session_dirs.get(sid, s.working_dir)
|
|
1147
|
+
dir_display = str(work_dir.name) if work_dir else "."
|
|
1148
|
+
|
|
1149
|
+
table.add_row(
|
|
1150
|
+
short_id(sid),
|
|
1151
|
+
status,
|
|
1152
|
+
s.adapter,
|
|
1153
|
+
dir_display,
|
|
1154
|
+
str(s.token_usage.get("total_tokens", 0)),
|
|
1155
|
+
s.task_description[:32] + "..." if len(s.task_description) > 32 else s.task_description,
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
console.print(table)
|
|
1159
|
+
|
|
1160
|
+
def parse_spawn_args(args: list[str]) -> dict:
|
|
1161
|
+
"""Parse spawn command arguments."""
|
|
1162
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
1163
|
+
parser.add_argument("task", nargs="*")
|
|
1164
|
+
parser.add_argument("--dir", "-d", type=Path, default=None)
|
|
1165
|
+
parser.add_argument("--adapter", default=None)
|
|
1166
|
+
parser.add_argument("--model", "-m", default=None)
|
|
1167
|
+
parser.add_argument("--async", "-a", dest="is_async", action="store_true")
|
|
1168
|
+
|
|
1169
|
+
try:
|
|
1170
|
+
parsed, _ = parser.parse_known_args(args)
|
|
1171
|
+
return {
|
|
1172
|
+
"task": " ".join(parsed.task) if parsed.task else "",
|
|
1173
|
+
"dir": parsed.dir,
|
|
1174
|
+
"adapter": parsed.adapter,
|
|
1175
|
+
"model": parsed.model,
|
|
1176
|
+
"is_async": parsed.is_async,
|
|
1177
|
+
}
|
|
1178
|
+
except SystemExit:
|
|
1179
|
+
return {"error": "Invalid spawn arguments"}
|
|
1180
|
+
|
|
1181
|
+
def do_spawn(args: list[str]):
|
|
1182
|
+
"""Spawn a new session with optional per-session config."""
|
|
1183
|
+
parsed = parse_spawn_args(args)
|
|
1184
|
+
|
|
1185
|
+
if "error" in parsed:
|
|
1186
|
+
console.print(f" [red]{parsed['error']}[/]")
|
|
1187
|
+
console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
|
|
1188
|
+
return
|
|
1189
|
+
|
|
1190
|
+
if not parsed["task"]:
|
|
1191
|
+
console.print(" [red]Task required[/]")
|
|
1192
|
+
console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
|
|
1193
|
+
return
|
|
1194
|
+
|
|
1195
|
+
task = parsed["task"]
|
|
1196
|
+
work_dir = (parsed["dir"] or default_dir).absolute()
|
|
1197
|
+
adapter_name = parsed["adapter"] or default_adapter.value
|
|
1198
|
+
adapter_model = parsed["model"] or model
|
|
1199
|
+
mode = "async" if parsed["is_async"] else "sync"
|
|
1200
|
+
|
|
1201
|
+
# Validate directory exists
|
|
1202
|
+
if not work_dir.exists():
|
|
1203
|
+
console.print(f" [red]Directory not found:[/] {work_dir}")
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
console.print(f"\n[dim]Spawning {mode} session...[/]")
|
|
1207
|
+
console.print(f" [dim]Dir: {work_dir}[/]")
|
|
1208
|
+
console.print(f" [dim]Adapter: {adapter_name}[/]")
|
|
1209
|
+
|
|
1210
|
+
try:
|
|
1211
|
+
executor = get_or_create_adapter(adapter_name, adapter_model)
|
|
1212
|
+
session = asyncio.run(
|
|
1213
|
+
executor.start_session(
|
|
1214
|
+
task=task,
|
|
1215
|
+
working_dir=work_dir,
|
|
1216
|
+
mode=mode,
|
|
1217
|
+
model=adapter_model,
|
|
1218
|
+
)
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
sessions[session.id] = session
|
|
1222
|
+
session_dirs[session.id] = work_dir
|
|
1223
|
+
session_adapters[session.id] = adapter_name
|
|
1224
|
+
|
|
1225
|
+
console.print(f" [green]✓[/] Session: [cyan]{short_id(session.id)}[/]")
|
|
1226
|
+
console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
|
|
1227
|
+
|
|
1228
|
+
if mode == "sync" and session.messages:
|
|
1229
|
+
for msg in session.messages:
|
|
1230
|
+
if msg.role == "assistant":
|
|
1231
|
+
console.print(f"\n[bold]Response:[/]")
|
|
1232
|
+
content = msg.content[:2000] + ("..." if len(msg.content) > 2000 else "")
|
|
1233
|
+
console.print(Panel(content, border_style="green"))
|
|
1234
|
+
else:
|
|
1235
|
+
console.print(f" [dim]Async session started. Use '? {short_id(session.id)}' to check status.[/]")
|
|
1236
|
+
|
|
1237
|
+
# Save output for viewing
|
|
1238
|
+
save_session_output(session)
|
|
1239
|
+
|
|
1240
|
+
except Exception as e:
|
|
1241
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1242
|
+
import traceback
|
|
1243
|
+
console.print(f" [dim]{traceback.format_exc()}[/]")
|
|
1244
|
+
|
|
1245
|
+
def do_converse(session_id: str, message: str):
|
|
1246
|
+
session, sid = find_session(session_id)
|
|
1247
|
+
if not session:
|
|
1248
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1249
|
+
console.print(f" [dim]Use 'ls' to see available sessions[/]")
|
|
1250
|
+
return
|
|
1251
|
+
|
|
1252
|
+
if session.mode.value != "sync":
|
|
1253
|
+
console.print(f" [yellow]Warning:[/] Session is async, can't converse")
|
|
1254
|
+
return
|
|
1255
|
+
|
|
1256
|
+
if session.status.value != "active":
|
|
1257
|
+
console.print(f" [yellow]Warning:[/] Session is {session.status.value}")
|
|
1258
|
+
return
|
|
1259
|
+
|
|
1260
|
+
console.print(f"\n[dim]Sending message to {short_id(session.id)}...[/]")
|
|
1261
|
+
|
|
1262
|
+
try:
|
|
1263
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1264
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1265
|
+
response = asyncio.run(executor.send_message(session, message))
|
|
1266
|
+
console.print(f" [green]✓[/] Response received")
|
|
1267
|
+
console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
|
|
1268
|
+
console.print(f"\n[bold]Response:[/]")
|
|
1269
|
+
console.print(Panel(response[:2000] + ("..." if len(response) > 2000 else ""), border_style="green"))
|
|
1270
|
+
|
|
1271
|
+
# Update saved output
|
|
1272
|
+
save_session_output(session)
|
|
1273
|
+
|
|
1274
|
+
except Exception as e:
|
|
1275
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1276
|
+
|
|
1277
|
+
def do_check(session_id: str):
|
|
1278
|
+
session, sid = find_session(session_id)
|
|
1279
|
+
if not session:
|
|
1280
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1281
|
+
return
|
|
1282
|
+
|
|
1283
|
+
work_dir = session_dirs.get(sid, session.working_dir)
|
|
1284
|
+
|
|
1285
|
+
console.print(f"\n[bold]Session {short_id(session.id)}[/]")
|
|
1286
|
+
console.print(f" Status: {session.status.value}")
|
|
1287
|
+
console.print(f" Mode: {session.mode.value}")
|
|
1288
|
+
console.print(f" Adapter: {session.adapter}")
|
|
1289
|
+
console.print(f" Model: {session.model}")
|
|
1290
|
+
console.print(f" Directory: {work_dir}")
|
|
1291
|
+
console.print(f" Messages: {len(session.messages)}")
|
|
1292
|
+
console.print(f" Tokens: {session.token_usage}")
|
|
1293
|
+
console.print(f" Task: {session.task_description}")
|
|
1294
|
+
|
|
1295
|
+
if session.mode.value == "async" and session.status.value == "active":
|
|
1296
|
+
try:
|
|
1297
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1298
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1299
|
+
status = asyncio.run(executor.check_status(session))
|
|
1300
|
+
console.print(f"\n[bold]Async status:[/]")
|
|
1301
|
+
for k, v in status.items():
|
|
1302
|
+
if k != "events": # Skip verbose events
|
|
1303
|
+
val_str = str(v)[:200] + "..." if len(str(v)) > 200 else str(v)
|
|
1304
|
+
console.print(f" {k}: {val_str}")
|
|
1305
|
+
|
|
1306
|
+
# Update saved output after status check
|
|
1307
|
+
save_session_output(session)
|
|
1308
|
+
|
|
1309
|
+
except Exception as e:
|
|
1310
|
+
console.print(f" [red]Status check error:[/] {e}")
|
|
1311
|
+
|
|
1312
|
+
# Show messages
|
|
1313
|
+
if session.messages:
|
|
1314
|
+
console.print(f"\n[bold]Messages:[/]")
|
|
1315
|
+
for i, msg in enumerate(session.messages):
|
|
1316
|
+
role_color = {"user": "blue", "assistant": "green", "system": "yellow"}.get(msg.role, "white")
|
|
1317
|
+
content_preview = msg.content[:500] + "..." if len(msg.content) > 500 else msg.content
|
|
1318
|
+
console.print(f" [{role_color}]{msg.role}[/]: {content_preview}")
|
|
1319
|
+
|
|
1320
|
+
def do_view(session_id: str):
|
|
1321
|
+
"""Open session output in $EDITOR."""
|
|
1322
|
+
session, sid = find_session(session_id)
|
|
1323
|
+
if not session:
|
|
1324
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1325
|
+
return
|
|
1326
|
+
|
|
1327
|
+
output_file = save_session_output(session)
|
|
1328
|
+
editor = os.environ.get("EDITOR", "less")
|
|
1329
|
+
|
|
1330
|
+
console.print(f" [dim]Opening {output_file} in {editor}...[/]")
|
|
1331
|
+
try:
|
|
1332
|
+
sp.run([editor, str(output_file)])
|
|
1333
|
+
except Exception as e:
|
|
1334
|
+
console.print(f" [red]Error opening editor:[/] {e}")
|
|
1335
|
+
console.print(f" [dim]File saved at: {output_file}[/]")
|
|
1336
|
+
|
|
1337
|
+
def do_raw(session_id: str):
|
|
1338
|
+
"""Show raw session data for debugging."""
|
|
1339
|
+
session, _ = find_session(session_id)
|
|
1340
|
+
if not session:
|
|
1341
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1342
|
+
return
|
|
1343
|
+
|
|
1344
|
+
import json
|
|
1345
|
+
console.print(f"\n[bold]Raw session data for {short_id(session.id)}:[/]")
|
|
1346
|
+
console.print(json.dumps(session.to_dict(), indent=2, default=str))
|
|
1347
|
+
|
|
1348
|
+
def do_kill(session_id: str):
|
|
1349
|
+
session, sid = find_session(session_id)
|
|
1350
|
+
if not session:
|
|
1351
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1352
|
+
return
|
|
1353
|
+
|
|
1354
|
+
try:
|
|
1355
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1356
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1357
|
+
asyncio.run(executor.stop(session))
|
|
1358
|
+
console.print(f" [green]✓[/] Session {short_id(session.id)} killed")
|
|
1359
|
+
save_session_output(session)
|
|
1360
|
+
except Exception as e:
|
|
1361
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1362
|
+
|
|
1363
|
+
def do_killall():
|
|
1364
|
+
"""Kill all running sessions."""
|
|
1365
|
+
killed = 0
|
|
1366
|
+
for sid, session in sessions.items():
|
|
1367
|
+
if session.status.value == "active":
|
|
1368
|
+
try:
|
|
1369
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1370
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1371
|
+
asyncio.run(executor.stop(session))
|
|
1372
|
+
killed += 1
|
|
1373
|
+
except Exception:
|
|
1374
|
+
pass
|
|
1375
|
+
console.print(f" [green]✓[/] Killed {killed} sessions")
|
|
1376
|
+
|
|
1377
|
+
def do_refresh():
|
|
1378
|
+
"""Poll all async sessions for status updates."""
|
|
1379
|
+
updated = 0
|
|
1380
|
+
for sid, session in sessions.items():
|
|
1381
|
+
if session.mode.value == "async" and session.status.value == "active":
|
|
1382
|
+
try:
|
|
1383
|
+
adapter_name = session_adapters.get(sid, default_adapter.value)
|
|
1384
|
+
executor = get_or_create_adapter(adapter_name)
|
|
1385
|
+
status = asyncio.run(executor.check_status(session))
|
|
1386
|
+
save_session_output(session)
|
|
1387
|
+
updated += 1
|
|
1388
|
+
|
|
1389
|
+
if status.get("status") in ("completed", "failed"):
|
|
1390
|
+
icon = "[green]✓[/]" if status["status"] == "completed" else "[red]✗[/]"
|
|
1391
|
+
console.print(f" {icon} {short_id(sid)}: {status['status']}")
|
|
1392
|
+
except Exception as e:
|
|
1393
|
+
console.print(f" [red]✗[/] {short_id(sid)}: {e}")
|
|
1394
|
+
|
|
1395
|
+
if updated == 0:
|
|
1396
|
+
console.print(" [dim]No async sessions to refresh[/]")
|
|
1397
|
+
else:
|
|
1398
|
+
console.print(f" [dim]Refreshed {updated} sessions[/]")
|
|
1399
|
+
|
|
1400
|
+
# REPL loop
|
|
1401
|
+
import shlex
|
|
1402
|
+
|
|
1403
|
+
while True:
|
|
1404
|
+
try:
|
|
1405
|
+
raw_input = console.input("[bold cyan]>[/] ").strip()
|
|
1406
|
+
if not raw_input:
|
|
1407
|
+
continue
|
|
1408
|
+
|
|
1409
|
+
# Parse command
|
|
1410
|
+
try:
|
|
1411
|
+
parts = shlex.split(raw_input)
|
|
1412
|
+
except ValueError:
|
|
1413
|
+
# Handle unclosed quotes
|
|
1414
|
+
parts = raw_input.split()
|
|
1415
|
+
|
|
1416
|
+
cmd = parts[0].lower()
|
|
1417
|
+
args = parts[1:]
|
|
1418
|
+
|
|
1419
|
+
if cmd in ("q", "quit", "exit"):
|
|
1420
|
+
# Offer to kill running sessions
|
|
1421
|
+
running = [s for s in sessions.values() if s.status.value == "active"]
|
|
1422
|
+
if running:
|
|
1423
|
+
console.print(f" [yellow]Warning:[/] {len(running)} sessions still running")
|
|
1424
|
+
console.print(" [dim]Use 'killall' first, or they'll continue in background[/]")
|
|
1425
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
1426
|
+
break
|
|
1427
|
+
|
|
1428
|
+
elif cmd in ("h", "help"):
|
|
1429
|
+
show_help()
|
|
1430
|
+
|
|
1431
|
+
elif cmd in ("ls", "list"):
|
|
1432
|
+
show_sessions()
|
|
1433
|
+
|
|
1434
|
+
elif cmd == "spawn":
|
|
1435
|
+
do_spawn(args)
|
|
1436
|
+
|
|
1437
|
+
# Keep old shortcuts for convenience
|
|
1438
|
+
elif cmd in ("d", "delegate"):
|
|
1439
|
+
if not args:
|
|
1440
|
+
console.print(" [red]Usage:[/] d \"task\" or spawn \"task\" [options]")
|
|
1441
|
+
else:
|
|
1442
|
+
do_spawn(args) # Sync by default
|
|
1443
|
+
|
|
1444
|
+
elif cmd in ("a", "async"):
|
|
1445
|
+
if not args:
|
|
1446
|
+
console.print(" [red]Usage:[/] a \"task\" or spawn \"task\" --async")
|
|
1447
|
+
else:
|
|
1448
|
+
do_spawn(args + ["--async"])
|
|
1449
|
+
|
|
1450
|
+
elif cmd in ("c", "converse"):
|
|
1451
|
+
if len(args) < 2:
|
|
1452
|
+
console.print(" [red]Usage:[/] c SESSION_ID \"message\"")
|
|
1453
|
+
else:
|
|
1454
|
+
do_converse(args[0], " ".join(args[1:]))
|
|
1455
|
+
|
|
1456
|
+
elif cmd in ("?", "check"):
|
|
1457
|
+
if not args:
|
|
1458
|
+
console.print(" [red]Usage:[/] ? SESSION_ID")
|
|
1459
|
+
else:
|
|
1460
|
+
do_check(args[0])
|
|
1461
|
+
|
|
1462
|
+
elif cmd == "view":
|
|
1463
|
+
if not args:
|
|
1464
|
+
console.print(" [red]Usage:[/] view SESSION_ID")
|
|
1465
|
+
else:
|
|
1466
|
+
do_view(args[0])
|
|
1467
|
+
|
|
1468
|
+
elif cmd == "raw":
|
|
1469
|
+
if not args:
|
|
1470
|
+
console.print(" [red]Usage:[/] raw SESSION_ID")
|
|
1471
|
+
else:
|
|
1472
|
+
do_raw(args[0])
|
|
1473
|
+
|
|
1474
|
+
elif cmd == "kill":
|
|
1475
|
+
if not args:
|
|
1476
|
+
console.print(" [red]Usage:[/] kill SESSION_ID")
|
|
1477
|
+
else:
|
|
1478
|
+
do_kill(args[0])
|
|
1479
|
+
|
|
1480
|
+
elif cmd == "killall":
|
|
1481
|
+
do_killall()
|
|
1482
|
+
|
|
1483
|
+
elif cmd == "refresh":
|
|
1484
|
+
do_refresh()
|
|
1485
|
+
|
|
1486
|
+
elif cmd in ("e", "end"):
|
|
1487
|
+
# Alias for kill
|
|
1488
|
+
if not args:
|
|
1489
|
+
console.print(" [red]Usage:[/] kill SESSION_ID")
|
|
1490
|
+
else:
|
|
1491
|
+
do_kill(args[0])
|
|
1492
|
+
|
|
1493
|
+
else:
|
|
1494
|
+
console.print(f" [yellow]Unknown command:[/] {cmd}")
|
|
1495
|
+
console.print(" [dim]Type 'help' for available commands[/]")
|
|
1496
|
+
|
|
1497
|
+
except KeyboardInterrupt:
|
|
1498
|
+
console.print("\n[dim](Ctrl+C to exit, or type 'quit')[/]")
|
|
1499
|
+
except EOFError:
|
|
1500
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
1501
|
+
break
|
|
1502
|
+
except Exception as e:
|
|
1503
|
+
console.print(f" [red]Error:[/] {e}")
|
|
1504
|
+
|
|
1505
|
+
|
|
978
1506
|
@app.callback(invoke_without_command=True)
|
|
979
1507
|
def main_callback(
|
|
980
1508
|
ctx: typer.Context,
|