zwarm 1.1.1__py3-none-any.whl → 1.3.2__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[/]
@@ -140,6 +141,8 @@ def orchestrate(
140
141
  resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
141
142
  max_steps: Annotated[Optional[int], typer.Option("--max-steps", help="Maximum orchestrator steps")] = None,
142
143
  verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed output")] = False,
144
+ instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Instance ID (for isolation/resume)")] = None,
145
+ instance_name: Annotated[Optional[str], typer.Option("--name", "-n", help="Human-readable instance name")] = None,
143
146
  ):
144
147
  """
145
148
  Start an orchestrator session.
@@ -148,6 +151,9 @@ def orchestrate(
148
151
  (Codex, Claude Code). It can have sync conversations or fire-and-forget
149
152
  async delegations.
150
153
 
154
+ Each run creates an isolated instance to prevent conflicts when running
155
+ multiple orchestrators in the same directory.
156
+
151
157
  [bold]Examples:[/]
152
158
  [dim]# Simple task[/]
153
159
  $ zwarm orchestrate --task "Add a logout button to the navbar"
@@ -165,8 +171,14 @@ def orchestrate(
165
171
  [dim]# Override settings[/]
166
172
  $ zwarm orchestrate --task "Fix bug" --set executor.adapter=claude_code
167
173
 
168
- [dim]# Resume interrupted session[/]
169
- $ zwarm orchestrate --task "Continue work" --resume
174
+ [dim]# Named instance (easier to track)[/]
175
+ $ zwarm orchestrate --task "Add tests" --name test-work
176
+
177
+ [dim]# Resume a specific instance[/]
178
+ $ zwarm orchestrate --resume --instance abc123
179
+
180
+ [dim]# List all instances[/]
181
+ $ zwarm instances
170
182
  """
171
183
  from zwarm.orchestrator import build_orchestrator
172
184
 
@@ -186,6 +198,8 @@ def orchestrate(
186
198
  console.print(f"[bold]Starting orchestrator...[/]")
187
199
  console.print(f" Task: {task}")
188
200
  console.print(f" Working dir: {working_dir.absolute()}")
201
+ if instance:
202
+ console.print(f" Instance: {instance}" + (f" ({instance_name})" if instance_name else ""))
189
203
  console.print()
190
204
 
191
205
  # Output handler to show orchestrator messages
@@ -202,11 +216,17 @@ def orchestrate(
202
216
  overrides=override_list,
203
217
  resume=resume,
204
218
  output_handler=output_handler,
219
+ instance_id=instance,
220
+ instance_name=instance_name,
205
221
  )
206
222
 
207
223
  if resume:
208
224
  console.print(" [dim]Resuming from previous state...[/]")
209
225
 
226
+ # Show instance ID if auto-generated
227
+ if orchestrator.instance_id and not instance:
228
+ console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
229
+
210
230
  # Run the orchestrator loop
211
231
  console.print("[bold]--- Orchestrator running ---[/]\n")
212
232
  result = orchestrator.run(task=task)
@@ -222,16 +242,35 @@ def orchestrate(
222
242
  # Save state for potential resume
223
243
  orchestrator.save_state()
224
244
 
245
+ # Update instance status
246
+ if orchestrator.instance_id:
247
+ from zwarm.core.state import update_instance_status
248
+ update_instance_status(
249
+ orchestrator.instance_id,
250
+ "completed",
251
+ working_dir / ".zwarm",
252
+ )
253
+ console.print(f" [dim]Instance {orchestrator.instance_id[:8]} marked completed[/]")
254
+
225
255
  except KeyboardInterrupt:
226
256
  console.print("\n\n[yellow]Interrupted.[/]")
227
257
  if orchestrator:
228
258
  orchestrator.save_state()
229
259
  console.print("[dim]State saved. Use --resume to continue.[/]")
260
+ # Keep instance as "active" so it can be resumed
230
261
  sys.exit(1)
231
262
  except Exception as e:
232
263
  console.print(f"\n[red]Error:[/] {e}")
233
264
  if verbose:
234
265
  console.print_exception()
266
+ # Update instance status to failed
267
+ if orchestrator and orchestrator.instance_id:
268
+ from zwarm.core.state import update_instance_status
269
+ update_instance_status(
270
+ orchestrator.instance_id,
271
+ "failed",
272
+ working_dir / ".zwarm",
273
+ )
235
274
  sys.exit(1)
236
275
 
237
276
 
@@ -258,20 +297,17 @@ def exec(
258
297
  [dim]# Async mode[/]
259
298
  $ zwarm exec --task "Build feature" --mode async
260
299
  """
261
- from zwarm.adapters.codex_mcp import CodexMCPAdapter
262
- from zwarm.adapters.claude_code import ClaudeCodeAdapter
300
+ from zwarm.adapters import get_adapter
263
301
 
264
302
  console.print(f"[bold]Running executor directly...[/]")
265
303
  console.print(f" Adapter: [cyan]{adapter.value}[/]")
266
304
  console.print(f" Mode: {mode.value}")
267
305
  console.print(f" Task: {task}")
268
306
 
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}")
307
+ try:
308
+ executor = get_adapter(adapter.value, model=model)
309
+ except ValueError as e:
310
+ console.print(f"[red]Error:[/] {e}")
275
311
  sys.exit(1)
276
312
 
277
313
  async def run():
@@ -386,6 +422,63 @@ def status(
386
422
  console.print(" [dim](none)[/]")
387
423
 
388
424
 
425
+ @app.command()
426
+ def instances(
427
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
428
+ all_instances: Annotated[bool, typer.Option("--all", "-a", help="Show all instances (including completed)")] = False,
429
+ ):
430
+ """
431
+ List all orchestrator instances.
432
+
433
+ Shows instances that have been run in this directory. Use --all to include
434
+ completed instances.
435
+
436
+ [bold]Examples:[/]
437
+ [dim]# List active instances[/]
438
+ $ zwarm instances
439
+
440
+ [dim]# List all instances[/]
441
+ $ zwarm instances --all
442
+ """
443
+ from zwarm.core.state import list_instances as get_instances
444
+
445
+ state_dir = working_dir / ".zwarm"
446
+ all_inst = get_instances(state_dir)
447
+
448
+ if not all_inst:
449
+ console.print("[dim]No instances found.[/]")
450
+ console.print("[dim]Run 'zwarm orchestrate' to start a new instance.[/]")
451
+ return
452
+
453
+ # Filter if not showing all
454
+ if not all_instances:
455
+ all_inst = [i for i in all_inst if i.get("status") == "active"]
456
+
457
+ if not all_inst:
458
+ console.print("[dim]No active instances. Use --all to see completed ones.[/]")
459
+ return
460
+
461
+ console.print(f"[bold]Instances[/] ({len(all_inst)} total)\n")
462
+
463
+ for inst in all_inst:
464
+ status = inst.get("status", "unknown")
465
+ status_icon = {"active": "[green]●[/]", "completed": "[dim]✓[/]", "failed": "[red]✗[/]"}.get(status, "[dim]?[/]")
466
+
467
+ inst_id = inst.get("id", "unknown")[:8]
468
+ name = inst.get("name", "")
469
+ task = (inst.get("task") or "")[:60]
470
+ updated = inst.get("updated_at", "")[:19] if inst.get("updated_at") else ""
471
+
472
+ console.print(f" {status_icon} [bold]{inst_id}[/]" + (f" ({name})" if name and name != inst_id else ""))
473
+ if task:
474
+ console.print(f" [dim]{task}[/]")
475
+ if updated:
476
+ console.print(f" [dim]Updated: {updated}[/]")
477
+ console.print()
478
+
479
+ console.print("[dim]Use --instance <id> with 'orchestrate --resume' to resume an instance.[/]")
480
+
481
+
389
482
  @app.command()
390
483
  def history(
391
484
  working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
@@ -482,10 +575,13 @@ def configs_list(
482
575
  console.print(" [dim]No configuration files found.[/]")
483
576
  console.print("\n [dim]Create a YAML config in configs/ to get started.[/]")
484
577
 
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)[/]")
578
+ # Check for config.toml and mention it (check both locations)
579
+ new_config = Path.cwd() / ".zwarm" / "config.toml"
580
+ legacy_config = Path.cwd() / "config.toml"
581
+ if new_config.exists():
582
+ console.print(f"\n[dim]Environment: .zwarm/config.toml (loaded automatically)[/]")
583
+ elif legacy_config.exists():
584
+ console.print(f"\n[dim]Environment: config.toml (legacy location, loaded automatically)[/]")
489
585
 
490
586
 
491
587
  @configs_app.command("show")
@@ -530,9 +626,9 @@ def init(
530
626
  Run this once per project to set up zwarm.
531
627
 
532
628
  [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)
629
+ [cyan].zwarm/[/] State directory for sessions and events
630
+ [cyan].zwarm/config.toml[/] Runtime settings (weave, adapter, watchers)
631
+ [cyan]zwarm.yaml[/] Project config (optional, with --with-project)
536
632
 
537
633
  [bold]Examples:[/]
538
634
  [dim]# Interactive setup[/]
@@ -546,13 +642,25 @@ def init(
546
642
  """
547
643
  console.print("\n[bold cyan]zwarm init[/] - Initialize zwarm configuration\n")
548
644
 
549
- config_toml_path = working_dir / "config.toml"
550
- zwarm_yaml_path = working_dir / "zwarm.yaml"
551
645
  state_dir = working_dir / ".zwarm"
646
+ config_toml_path = state_dir / "config.toml"
647
+ zwarm_yaml_path = working_dir / "zwarm.yaml"
648
+
649
+ # Check for existing config (also check old location for migration)
650
+ old_config_path = working_dir / "config.toml"
651
+ if old_config_path.exists() and not config_toml_path.exists():
652
+ console.print(f"[yellow]Note:[/] Found config.toml in project root.")
653
+ console.print(f" Config now lives in .zwarm/config.toml")
654
+ if not non_interactive:
655
+ migrate = typer.confirm(" Move to new location?", default=True)
656
+ if migrate:
657
+ state_dir.mkdir(parents=True, exist_ok=True)
658
+ old_config_path.rename(config_toml_path)
659
+ console.print(f" [green]✓[/] Moved config.toml to .zwarm/")
552
660
 
553
661
  # Check for existing files
554
662
  if config_toml_path.exists():
555
- console.print(f"[yellow]Warning:[/] config.toml already exists")
663
+ console.print(f"[yellow]Warning:[/] .zwarm/config.toml already exists")
556
664
  if not non_interactive:
557
665
  overwrite = typer.confirm("Overwrite?", default=False)
558
666
  if not overwrite:
@@ -625,7 +733,7 @@ def init(
625
733
  (state_dir / "orchestrator").mkdir(exist_ok=True)
626
734
  console.print(f" [green]✓[/] Created .zwarm/")
627
735
 
628
- # Create config.toml
736
+ # Create config.toml inside .zwarm/
629
737
  if config_toml_path:
630
738
  toml_content = _generate_config_toml(
631
739
  weave_project=weave_project,
@@ -633,7 +741,7 @@ def init(
633
741
  watchers=watchers_enabled,
634
742
  )
635
743
  config_toml_path.write_text(toml_content)
636
- console.print(f" [green]✓[/] Created config.toml")
744
+ console.print(f" [green]✓[/] Created .zwarm/config.toml")
637
745
 
638
746
  # Create zwarm.yaml
639
747
  if create_project_config:
@@ -790,7 +898,8 @@ def reset(
790
898
  console.print("\n[bold cyan]zwarm reset[/] - Reset zwarm state\n")
791
899
 
792
900
  state_dir = working_dir / ".zwarm"
793
- config_toml_path = working_dir / "config.toml"
901
+ config_toml_path = state_dir / "config.toml" # New location
902
+ old_config_toml_path = working_dir / "config.toml" # Legacy location
794
903
  zwarm_yaml_path = working_dir / "zwarm.yaml"
795
904
 
796
905
  # Expand --all flag
@@ -803,8 +912,12 @@ def reset(
803
912
  to_delete = []
804
913
  if state and state_dir.exists():
805
914
  to_delete.append((".zwarm/", state_dir))
806
- if config and config_toml_path.exists():
807
- to_delete.append(("config.toml", config_toml_path))
915
+ # Config: check both new and legacy locations (but skip if state already deletes it)
916
+ if config and not state:
917
+ if config_toml_path.exists():
918
+ to_delete.append((".zwarm/config.toml", config_toml_path))
919
+ if old_config_toml_path.exists():
920
+ to_delete.append(("config.toml (legacy)", old_config_toml_path))
808
921
  if project and zwarm_yaml_path.exists():
809
922
  to_delete.append(("zwarm.yaml", zwarm_yaml_path))
810
923
 
@@ -975,6 +1088,516 @@ def clean(
975
1088
  console.print(f"\n[bold green]Cleanup complete.[/] Killed {killed}, failed {failed}.\n")
976
1089
 
977
1090
 
1091
+ @app.command()
1092
+ def interactive(
1093
+ default_adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Default executor adapter")] = AdapterType.codex_mcp,
1094
+ default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
1095
+ model: Annotated[Optional[str], typer.Option("--model", help="Default model override")] = None,
1096
+ state_dir: Annotated[Path, typer.Option("--state-dir", help="State directory for persistence")] = Path(".zwarm"),
1097
+ ):
1098
+ """
1099
+ Universal multi-agent CLI for commanding coding agents.
1100
+
1101
+ Spawn multiple agents (Codex, Claude Code) across different directories,
1102
+ manage them interactively, and view their outputs. You are the orchestrator.
1103
+
1104
+ [bold]Commands:[/]
1105
+ [cyan]spawn[/] "task" [opts] Start a session (see spawn --help)
1106
+ [cyan]ls[/] / [cyan]list[/] Dashboard of all sessions
1107
+ [cyan]?[/] / [cyan]check[/] ID Check session status & response
1108
+ [cyan]c[/] / [cyan]converse[/] ID "msg" Continue sync conversation
1109
+ [cyan]view[/] ID Open session output in $EDITOR
1110
+ [cyan]kill[/] ID Stop a session
1111
+ [cyan]killall[/] Stop all running sessions
1112
+ [cyan]refresh[/] Poll all async sessions
1113
+ [cyan]q[/] / [cyan]quit[/] Exit
1114
+
1115
+ [bold]Spawn Options:[/]
1116
+ spawn "task" --dir ~/project --adapter claude_code --async
1117
+
1118
+ [bold]Examples:[/]
1119
+ $ zwarm interactive
1120
+ > spawn "Build auth module" --dir ~/api --async
1121
+ > spawn "Fix tests" --dir ~/api --async
1122
+ > spawn "Add docs" --dir ~/docs
1123
+ > ls
1124
+ > ? abc123
1125
+ > view abc123
1126
+ """
1127
+ from zwarm.adapters import get_adapter, list_adapters
1128
+ from zwarm.core.models import ConversationSession
1129
+ import argparse
1130
+ import tempfile
1131
+ import subprocess as sp
1132
+
1133
+ console.print("\n[bold cyan]zwarm interactive[/] - Universal Multi-Agent CLI\n")
1134
+ console.print(f" Default adapter: [cyan]{default_adapter.value}[/]")
1135
+ console.print(f" Default dir: {default_dir.absolute()}")
1136
+ console.print(f" Available adapters: {', '.join(list_adapters())}")
1137
+ console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
1138
+
1139
+ # Adapter cache (lazy-loaded per adapter type)
1140
+ adapters: dict[str, Any] = {}
1141
+
1142
+ def get_or_create_adapter(adapter_name: str, adapter_model: str | None = None) -> Any:
1143
+ """Get cached adapter or create new one."""
1144
+ key = f"{adapter_name}:{adapter_model or 'default'}"
1145
+ if key not in adapters:
1146
+ adapters[key] = get_adapter(adapter_name, model=adapter_model)
1147
+ return adapters[key]
1148
+
1149
+ # Session tracking with metadata
1150
+ sessions: dict[str, ConversationSession] = {}
1151
+ session_dirs: dict[str, Path] = {} # session_id -> working_dir
1152
+ session_adapters: dict[str, str] = {} # session_id -> adapter_name
1153
+
1154
+ # Persistence directory
1155
+ interactive_state_dir = state_dir / "interactive"
1156
+ interactive_state_dir.mkdir(parents=True, exist_ok=True)
1157
+
1158
+ def short_id(session_id: str) -> str:
1159
+ """Get short display ID."""
1160
+ return session_id[:8]
1161
+
1162
+ def find_session(query: str) -> tuple[ConversationSession | None, str | None]:
1163
+ """Find session by full or partial ID. Returns (session, full_id)."""
1164
+ if query in sessions:
1165
+ return sessions[query], query
1166
+ for sid, session in sessions.items():
1167
+ if sid.startswith(query):
1168
+ return session, sid
1169
+ return None, None
1170
+
1171
+ def save_session_output(session: ConversationSession) -> Path:
1172
+ """Save session output to file for viewing."""
1173
+ session_dir = interactive_state_dir / short_id(session.id)
1174
+ session_dir.mkdir(exist_ok=True)
1175
+
1176
+ output_file = session_dir / "output.txt"
1177
+ with open(output_file, "w") as f:
1178
+ f.write(f"Session: {session.id}\n")
1179
+ f.write(f"Task: {session.task_description}\n")
1180
+ f.write(f"Adapter: {session.adapter}\n")
1181
+ f.write(f"Model: {session.model}\n")
1182
+ f.write(f"Status: {session.status.value}\n")
1183
+ f.write(f"Mode: {session.mode.value}\n")
1184
+ f.write(f"Working Dir: {session.working_dir}\n")
1185
+ f.write(f"Tokens: {session.token_usage}\n")
1186
+ f.write("\n" + "="*60 + "\n\n")
1187
+
1188
+ for msg in session.messages:
1189
+ f.write(f"[{msg.role.upper()}]\n")
1190
+ f.write(msg.content)
1191
+ f.write("\n\n" + "-"*40 + "\n\n")
1192
+
1193
+ return output_file
1194
+
1195
+ def show_help():
1196
+ help_table = Table(show_header=False, box=None, padding=(0, 2))
1197
+ help_table.add_column("Command", style="cyan", width=35)
1198
+ help_table.add_column("Description")
1199
+ help_table.add_row('spawn "task" [options]', "Start new session")
1200
+ help_table.add_row(" --dir PATH", "Working directory for this session")
1201
+ help_table.add_row(" --adapter NAME", f"Adapter ({', '.join(list_adapters())})")
1202
+ help_table.add_row(" --async / -a", "Fire-and-forget mode")
1203
+ help_table.add_row(" --model NAME", "Model override")
1204
+ help_table.add_row("", "")
1205
+ help_table.add_row("ls / list", "Dashboard of all sessions")
1206
+ help_table.add_row("? / check ID", "Check session status & messages")
1207
+ help_table.add_row("c / converse ID \"msg\"", "Continue sync conversation")
1208
+ help_table.add_row("view ID", "Open session output in $EDITOR")
1209
+ help_table.add_row("raw ID", "Show raw session JSON")
1210
+ help_table.add_row("kill ID", "Stop a session")
1211
+ help_table.add_row("killall", "Stop all running sessions")
1212
+ help_table.add_row("refresh", "Poll all async sessions for updates")
1213
+ help_table.add_row("q / quit", "Exit interactive mode")
1214
+ console.print(help_table)
1215
+
1216
+ def show_sessions():
1217
+ if not sessions:
1218
+ console.print(" [dim]No sessions yet. Use 'spawn \"task\"' to start one.[/]")
1219
+ return
1220
+
1221
+ table = Table(title="Sessions Dashboard", show_header=True)
1222
+ table.add_column("ID", style="cyan", width=10)
1223
+ table.add_column("Status", width=12)
1224
+ table.add_column("Adapter", width=12)
1225
+ table.add_column("Dir", width=20)
1226
+ table.add_column("Tokens", width=8, justify="right")
1227
+ table.add_column("Task", width=35)
1228
+
1229
+ for sid, s in sessions.items():
1230
+ status_icons = {
1231
+ "active": "[green]● active[/]",
1232
+ "completed": "[dim]✓ done[/]",
1233
+ "failed": "[red]✗ failed[/]",
1234
+ }
1235
+ status = status_icons.get(s.status.value, f"? {s.status.value}")
1236
+
1237
+ # Add mode indicator
1238
+ if s.mode.value == "async" and s.status.value == "active":
1239
+ status = "[yellow]⟳ running[/]"
1240
+
1241
+ work_dir = session_dirs.get(sid, s.working_dir)
1242
+ dir_display = str(work_dir.name) if work_dir else "."
1243
+
1244
+ table.add_row(
1245
+ short_id(sid),
1246
+ status,
1247
+ s.adapter,
1248
+ dir_display,
1249
+ str(s.token_usage.get("total_tokens", 0)),
1250
+ s.task_description[:32] + "..." if len(s.task_description) > 32 else s.task_description,
1251
+ )
1252
+
1253
+ console.print(table)
1254
+
1255
+ def parse_spawn_args(args: list[str]) -> dict:
1256
+ """Parse spawn command arguments."""
1257
+ parser = argparse.ArgumentParser(add_help=False)
1258
+ parser.add_argument("task", nargs="*")
1259
+ parser.add_argument("--dir", "-d", type=Path, default=None)
1260
+ parser.add_argument("--adapter", default=None)
1261
+ parser.add_argument("--model", "-m", default=None)
1262
+ parser.add_argument("--async", "-a", dest="is_async", action="store_true")
1263
+
1264
+ try:
1265
+ parsed, _ = parser.parse_known_args(args)
1266
+ return {
1267
+ "task": " ".join(parsed.task) if parsed.task else "",
1268
+ "dir": parsed.dir,
1269
+ "adapter": parsed.adapter,
1270
+ "model": parsed.model,
1271
+ "is_async": parsed.is_async,
1272
+ }
1273
+ except SystemExit:
1274
+ return {"error": "Invalid spawn arguments"}
1275
+
1276
+ def do_spawn(args: list[str]):
1277
+ """Spawn a new session with optional per-session config."""
1278
+ parsed = parse_spawn_args(args)
1279
+
1280
+ if "error" in parsed:
1281
+ console.print(f" [red]{parsed['error']}[/]")
1282
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
1283
+ return
1284
+
1285
+ if not parsed["task"]:
1286
+ console.print(" [red]Task required[/]")
1287
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
1288
+ return
1289
+
1290
+ task = parsed["task"]
1291
+ work_dir = (parsed["dir"] or default_dir).absolute()
1292
+ adapter_name = parsed["adapter"] or default_adapter.value
1293
+ adapter_model = parsed["model"] or model
1294
+ mode = "async" if parsed["is_async"] else "sync"
1295
+
1296
+ # Validate directory exists
1297
+ if not work_dir.exists():
1298
+ console.print(f" [red]Directory not found:[/] {work_dir}")
1299
+ return
1300
+
1301
+ console.print(f"\n[dim]Spawning {mode} session...[/]")
1302
+ console.print(f" [dim]Dir: {work_dir}[/]")
1303
+ console.print(f" [dim]Adapter: {adapter_name}[/]")
1304
+
1305
+ try:
1306
+ executor = get_or_create_adapter(adapter_name, adapter_model)
1307
+ session = asyncio.run(
1308
+ executor.start_session(
1309
+ task=task,
1310
+ working_dir=work_dir,
1311
+ mode=mode,
1312
+ model=adapter_model,
1313
+ )
1314
+ )
1315
+
1316
+ sessions[session.id] = session
1317
+ session_dirs[session.id] = work_dir
1318
+ session_adapters[session.id] = adapter_name
1319
+
1320
+ console.print(f" [green]✓[/] Session: [cyan]{short_id(session.id)}[/]")
1321
+ console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
1322
+
1323
+ if mode == "sync" and session.messages:
1324
+ for msg in session.messages:
1325
+ if msg.role == "assistant":
1326
+ console.print(f"\n[bold]Response:[/]")
1327
+ content = msg.content[:2000] + ("..." if len(msg.content) > 2000 else "")
1328
+ console.print(Panel(content, border_style="green"))
1329
+ else:
1330
+ console.print(f" [dim]Async session started. Use '? {short_id(session.id)}' to check status.[/]")
1331
+
1332
+ # Save output for viewing
1333
+ save_session_output(session)
1334
+
1335
+ except Exception as e:
1336
+ console.print(f" [red]Error:[/] {e}")
1337
+ import traceback
1338
+ console.print(f" [dim]{traceback.format_exc()}[/]")
1339
+
1340
+ def do_converse(session_id: str, message: str):
1341
+ session, sid = find_session(session_id)
1342
+ if not session:
1343
+ console.print(f" [red]Session not found:[/] {session_id}")
1344
+ console.print(f" [dim]Use 'ls' to see available sessions[/]")
1345
+ return
1346
+
1347
+ if session.mode.value != "sync":
1348
+ console.print(f" [yellow]Warning:[/] Session is async, can't converse")
1349
+ return
1350
+
1351
+ if session.status.value != "active":
1352
+ console.print(f" [yellow]Warning:[/] Session is {session.status.value}")
1353
+ return
1354
+
1355
+ console.print(f"\n[dim]Sending message to {short_id(session.id)}...[/]")
1356
+
1357
+ try:
1358
+ adapter_name = session_adapters.get(sid, default_adapter.value)
1359
+ executor = get_or_create_adapter(adapter_name)
1360
+ response = asyncio.run(executor.send_message(session, message))
1361
+ console.print(f" [green]✓[/] Response received")
1362
+ console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
1363
+ console.print(f"\n[bold]Response:[/]")
1364
+ console.print(Panel(response[:2000] + ("..." if len(response) > 2000 else ""), border_style="green"))
1365
+
1366
+ # Update saved output
1367
+ save_session_output(session)
1368
+
1369
+ except Exception as e:
1370
+ console.print(f" [red]Error:[/] {e}")
1371
+
1372
+ def do_check(session_id: str):
1373
+ session, sid = find_session(session_id)
1374
+ if not session:
1375
+ console.print(f" [red]Session not found:[/] {session_id}")
1376
+ return
1377
+
1378
+ work_dir = session_dirs.get(sid, session.working_dir)
1379
+
1380
+ console.print(f"\n[bold]Session {short_id(session.id)}[/]")
1381
+ console.print(f" Status: {session.status.value}")
1382
+ console.print(f" Mode: {session.mode.value}")
1383
+ console.print(f" Adapter: {session.adapter}")
1384
+ console.print(f" Model: {session.model}")
1385
+ console.print(f" Directory: {work_dir}")
1386
+ console.print(f" Messages: {len(session.messages)}")
1387
+ console.print(f" Tokens: {session.token_usage}")
1388
+ console.print(f" Task: {session.task_description}")
1389
+
1390
+ if session.mode.value == "async" and session.status.value == "active":
1391
+ try:
1392
+ adapter_name = session_adapters.get(sid, default_adapter.value)
1393
+ executor = get_or_create_adapter(adapter_name)
1394
+ status = asyncio.run(executor.check_status(session))
1395
+ console.print(f"\n[bold]Async status:[/]")
1396
+ for k, v in status.items():
1397
+ if k != "events": # Skip verbose events
1398
+ val_str = str(v)[:200] + "..." if len(str(v)) > 200 else str(v)
1399
+ console.print(f" {k}: {val_str}")
1400
+
1401
+ # Update saved output after status check
1402
+ save_session_output(session)
1403
+
1404
+ except Exception as e:
1405
+ console.print(f" [red]Status check error:[/] {e}")
1406
+
1407
+ # Show messages
1408
+ if session.messages:
1409
+ console.print(f"\n[bold]Messages:[/]")
1410
+ for i, msg in enumerate(session.messages):
1411
+ role_color = {"user": "blue", "assistant": "green", "system": "yellow"}.get(msg.role, "white")
1412
+ content_preview = msg.content[:500] + "..." if len(msg.content) > 500 else msg.content
1413
+ console.print(f" [{role_color}]{msg.role}[/]: {content_preview}")
1414
+
1415
+ def do_view(session_id: str):
1416
+ """Open session output in $EDITOR."""
1417
+ session, sid = find_session(session_id)
1418
+ if not session:
1419
+ console.print(f" [red]Session not found:[/] {session_id}")
1420
+ return
1421
+
1422
+ output_file = save_session_output(session)
1423
+ editor = os.environ.get("EDITOR", "less")
1424
+
1425
+ console.print(f" [dim]Opening {output_file} in {editor}...[/]")
1426
+ try:
1427
+ sp.run([editor, str(output_file)])
1428
+ except Exception as e:
1429
+ console.print(f" [red]Error opening editor:[/] {e}")
1430
+ console.print(f" [dim]File saved at: {output_file}[/]")
1431
+
1432
+ def do_raw(session_id: str):
1433
+ """Show raw session data for debugging."""
1434
+ session, _ = find_session(session_id)
1435
+ if not session:
1436
+ console.print(f" [red]Session not found:[/] {session_id}")
1437
+ return
1438
+
1439
+ import json
1440
+ console.print(f"\n[bold]Raw session data for {short_id(session.id)}:[/]")
1441
+ console.print(json.dumps(session.to_dict(), indent=2, default=str))
1442
+
1443
+ def do_kill(session_id: str):
1444
+ session, sid = find_session(session_id)
1445
+ if not session:
1446
+ console.print(f" [red]Session not found:[/] {session_id}")
1447
+ return
1448
+
1449
+ try:
1450
+ adapter_name = session_adapters.get(sid, default_adapter.value)
1451
+ executor = get_or_create_adapter(adapter_name)
1452
+ asyncio.run(executor.stop(session))
1453
+ console.print(f" [green]✓[/] Session {short_id(session.id)} killed")
1454
+ save_session_output(session)
1455
+ except Exception as e:
1456
+ console.print(f" [red]Error:[/] {e}")
1457
+
1458
+ def do_killall():
1459
+ """Kill all running sessions."""
1460
+ killed = 0
1461
+ for sid, session in sessions.items():
1462
+ if session.status.value == "active":
1463
+ try:
1464
+ adapter_name = session_adapters.get(sid, default_adapter.value)
1465
+ executor = get_or_create_adapter(adapter_name)
1466
+ asyncio.run(executor.stop(session))
1467
+ killed += 1
1468
+ except Exception:
1469
+ pass
1470
+ console.print(f" [green]✓[/] Killed {killed} sessions")
1471
+
1472
+ def do_refresh():
1473
+ """Poll all async sessions for status updates."""
1474
+ updated = 0
1475
+ for sid, session in sessions.items():
1476
+ if session.mode.value == "async" and session.status.value == "active":
1477
+ try:
1478
+ adapter_name = session_adapters.get(sid, default_adapter.value)
1479
+ executor = get_or_create_adapter(adapter_name)
1480
+ status = asyncio.run(executor.check_status(session))
1481
+ save_session_output(session)
1482
+ updated += 1
1483
+
1484
+ if status.get("status") in ("completed", "failed"):
1485
+ icon = "[green]✓[/]" if status["status"] == "completed" else "[red]✗[/]"
1486
+ console.print(f" {icon} {short_id(sid)}: {status['status']}")
1487
+ except Exception as e:
1488
+ console.print(f" [red]✗[/] {short_id(sid)}: {e}")
1489
+
1490
+ if updated == 0:
1491
+ console.print(" [dim]No async sessions to refresh[/]")
1492
+ else:
1493
+ console.print(f" [dim]Refreshed {updated} sessions[/]")
1494
+
1495
+ # REPL loop
1496
+ import shlex
1497
+
1498
+ while True:
1499
+ try:
1500
+ raw_input = console.input("[bold cyan]>[/] ").strip()
1501
+ if not raw_input:
1502
+ continue
1503
+
1504
+ # Parse command
1505
+ try:
1506
+ parts = shlex.split(raw_input)
1507
+ except ValueError:
1508
+ # Handle unclosed quotes
1509
+ parts = raw_input.split()
1510
+
1511
+ cmd = parts[0].lower()
1512
+ args = parts[1:]
1513
+
1514
+ if cmd in ("q", "quit", "exit"):
1515
+ # Offer to kill running sessions
1516
+ running = [s for s in sessions.values() if s.status.value == "active"]
1517
+ if running:
1518
+ console.print(f" [yellow]Warning:[/] {len(running)} sessions still running")
1519
+ console.print(" [dim]Use 'killall' first, or they'll continue in background[/]")
1520
+ console.print("\n[dim]Goodbye![/]\n")
1521
+ break
1522
+
1523
+ elif cmd in ("h", "help"):
1524
+ show_help()
1525
+
1526
+ elif cmd in ("ls", "list"):
1527
+ show_sessions()
1528
+
1529
+ elif cmd == "spawn":
1530
+ do_spawn(args)
1531
+
1532
+ # Keep old shortcuts for convenience
1533
+ elif cmd in ("d", "delegate"):
1534
+ if not args:
1535
+ console.print(" [red]Usage:[/] d \"task\" or spawn \"task\" [options]")
1536
+ else:
1537
+ do_spawn(args) # Sync by default
1538
+
1539
+ elif cmd in ("a", "async"):
1540
+ if not args:
1541
+ console.print(" [red]Usage:[/] a \"task\" or spawn \"task\" --async")
1542
+ else:
1543
+ do_spawn(args + ["--async"])
1544
+
1545
+ elif cmd in ("c", "converse"):
1546
+ if len(args) < 2:
1547
+ console.print(" [red]Usage:[/] c SESSION_ID \"message\"")
1548
+ else:
1549
+ do_converse(args[0], " ".join(args[1:]))
1550
+
1551
+ elif cmd in ("?", "check"):
1552
+ if not args:
1553
+ console.print(" [red]Usage:[/] ? SESSION_ID")
1554
+ else:
1555
+ do_check(args[0])
1556
+
1557
+ elif cmd == "view":
1558
+ if not args:
1559
+ console.print(" [red]Usage:[/] view SESSION_ID")
1560
+ else:
1561
+ do_view(args[0])
1562
+
1563
+ elif cmd == "raw":
1564
+ if not args:
1565
+ console.print(" [red]Usage:[/] raw SESSION_ID")
1566
+ else:
1567
+ do_raw(args[0])
1568
+
1569
+ elif cmd == "kill":
1570
+ if not args:
1571
+ console.print(" [red]Usage:[/] kill SESSION_ID")
1572
+ else:
1573
+ do_kill(args[0])
1574
+
1575
+ elif cmd == "killall":
1576
+ do_killall()
1577
+
1578
+ elif cmd == "refresh":
1579
+ do_refresh()
1580
+
1581
+ elif cmd in ("e", "end"):
1582
+ # Alias for kill
1583
+ if not args:
1584
+ console.print(" [red]Usage:[/] kill SESSION_ID")
1585
+ else:
1586
+ do_kill(args[0])
1587
+
1588
+ else:
1589
+ console.print(f" [yellow]Unknown command:[/] {cmd}")
1590
+ console.print(" [dim]Type 'help' for available commands[/]")
1591
+
1592
+ except KeyboardInterrupt:
1593
+ console.print("\n[dim](Ctrl+C to exit, or type 'quit')[/]")
1594
+ except EOFError:
1595
+ console.print("\n[dim]Goodbye![/]\n")
1596
+ break
1597
+ except Exception as e:
1598
+ console.print(f" [red]Error:[/] {e}")
1599
+
1600
+
978
1601
  @app.callback(invoke_without_command=True)
979
1602
  def main_callback(
980
1603
  ctx: typer.Context,