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/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 (create config.toml, .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
- Create [cyan]config.toml[/] or use [cyan]--config[/] flag with YAML files.
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.codex_mcp import CodexMCPAdapter
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
- if adapter == AdapterType.codex_mcp:
270
- executor = CodexMCPAdapter(model=model)
271
- elif adapter == AdapterType.claude_code:
272
- executor = ClaudeCodeAdapter(model=model)
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
- config_toml = Path.cwd() / "config.toml"
487
- if config_toml.exists():
488
- console.print(f"\n[dim]Environment: config.toml (loaded automatically)[/]")
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]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)
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 = working_dir / "config.toml"
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
- if config and config_toml_path.exists():
807
- to_delete.append(("config.toml", config_toml_path))
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,