zwarm 1.3.10__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 ADDED
@@ -0,0 +1,2052 @@
1
+ """
2
+ CLI for zwarm orchestration.
3
+
4
+ Commands:
5
+ - zwarm orchestrate: Start an orchestrator session
6
+ - zwarm exec: Run a single executor directly (for testing)
7
+ - zwarm status: Show current state
8
+ - zwarm history: Show event history
9
+ - zwarm configs: Manage configurations
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import os
16
+ import sys
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Annotated, Optional
20
+
21
+ import typer
22
+ from rich import print as rprint
23
+ from rich.console import Console
24
+ from rich.panel import Panel
25
+ from rich.table import Table
26
+ from rich.text import Text
27
+
28
+ # Create console for rich output
29
+ console = Console()
30
+
31
+
32
+ def _resolve_task(task: str | None, task_file: Path | None) -> str | None:
33
+ """
34
+ Resolve task from multiple sources (priority order):
35
+ 1. --task flag
36
+ 2. --task-file flag
37
+ 3. stdin (if not a tty)
38
+ """
39
+ # Direct task takes priority
40
+ if task:
41
+ return task
42
+
43
+ # Then file
44
+ if task_file:
45
+ if not task_file.exists():
46
+ console.print(f"[red]Error:[/] Task file not found: {task_file}")
47
+ raise typer.Exit(1)
48
+ return task_file.read_text().strip()
49
+
50
+ # Finally stdin (only if piped, not interactive)
51
+ if not sys.stdin.isatty():
52
+ stdin_content = sys.stdin.read().strip()
53
+ if stdin_content:
54
+ return stdin_content
55
+
56
+ return None
57
+
58
+ # Main app with rich help
59
+ app = typer.Typer(
60
+ name="zwarm",
61
+ help="""
62
+ [bold cyan]zwarm[/] - Multi-Agent CLI Orchestration Research Platform
63
+
64
+ [bold]DESCRIPTION[/]
65
+ Orchestrate multiple CLI coding agents (Codex, Claude Code) with
66
+ delegation, conversation, and trajectory alignment (watchers).
67
+
68
+ [bold]QUICK START[/]
69
+ [dim]# Initialize zwarm in your project[/]
70
+ $ zwarm init
71
+
72
+ [dim]# Run the orchestrator[/]
73
+ $ zwarm orchestrate --task "Build a hello world function"
74
+
75
+ [dim]# Check state after running[/]
76
+ $ zwarm status
77
+
78
+ [bold]COMMANDS[/]
79
+ [cyan]init[/] Initialize zwarm (creates .zwarm/ with config)
80
+ [cyan]reset[/] Reset state and optionally config files
81
+ [cyan]orchestrate[/] Start orchestrator to delegate tasks to executors
82
+ [cyan]exec[/] Run a single executor directly (for testing)
83
+ [cyan]status[/] Show current state (sessions, tasks, events)
84
+ [cyan]history[/] Show event history log
85
+ [cyan]configs[/] Manage configuration files
86
+
87
+ [bold]CONFIGURATION[/]
88
+ Config lives in [cyan].zwarm/config.toml[/] (created by init).
89
+ Use [cyan]--config[/] flag for YAML files.
90
+ See [cyan]zwarm configs list[/] for available configurations.
91
+
92
+ [bold]ADAPTERS[/]
93
+ [cyan]codex_mcp[/] Codex via MCP server (sync conversations)
94
+ [cyan]claude_code[/] Claude Code CLI
95
+
96
+ [bold]WATCHERS[/] (trajectory aligners)
97
+ [cyan]progress[/] Detects stuck/spinning agents
98
+ [cyan]budget[/] Monitors step/session limits
99
+ [cyan]scope[/] Detects scope creep
100
+ [cyan]pattern[/] Custom regex pattern matching
101
+ [cyan]quality[/] Code quality checks
102
+ """,
103
+ rich_markup_mode="rich",
104
+ no_args_is_help=True,
105
+ add_completion=False,
106
+ )
107
+
108
+ # Configs subcommand group
109
+ configs_app = typer.Typer(
110
+ name="configs",
111
+ help="""
112
+ Manage zwarm configurations.
113
+
114
+ [bold]SUBCOMMANDS[/]
115
+ [cyan]list[/] List available configuration files
116
+ [cyan]show[/] Display a configuration file's contents
117
+ """,
118
+ rich_markup_mode="rich",
119
+ no_args_is_help=True,
120
+ )
121
+ app.add_typer(configs_app, name="configs")
122
+
123
+
124
+ class AdapterType(str, Enum):
125
+ codex_mcp = "codex_mcp"
126
+ claude_code = "claude_code"
127
+
128
+
129
+ class ModeType(str, Enum):
130
+ sync = "sync"
131
+ async_ = "async"
132
+
133
+
134
+ @app.command()
135
+ def orchestrate(
136
+ task: Annotated[Optional[str], typer.Option("--task", "-t", help="The task to accomplish")] = None,
137
+ task_file: Annotated[Optional[Path], typer.Option("--task-file", "-f", help="Read task from file")] = None,
138
+ config: Annotated[Optional[Path], typer.Option("--config", "-c", help="Path to config YAML")] = None,
139
+ overrides: Annotated[Optional[list[str]], typer.Option("--set", help="Override config (key=value)")] = None,
140
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
141
+ resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
142
+ max_steps: Annotated[Optional[int], typer.Option("--max-steps", help="Maximum orchestrator steps")] = None,
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,
146
+ ):
147
+ """
148
+ Start an orchestrator session.
149
+
150
+ The orchestrator breaks down tasks and delegates to executor agents
151
+ (Codex, Claude Code). It can have sync conversations or fire-and-forget
152
+ async delegations.
153
+
154
+ Each run creates an isolated instance to prevent conflicts when running
155
+ multiple orchestrators in the same directory.
156
+
157
+ [bold]Examples:[/]
158
+ [dim]# Simple task[/]
159
+ $ zwarm orchestrate --task "Add a logout button to the navbar"
160
+
161
+ [dim]# Task from file[/]
162
+ $ zwarm orchestrate -f task.md
163
+
164
+ [dim]# Task from stdin[/]
165
+ $ cat task.md | zwarm orchestrate
166
+ $ zwarm orchestrate < task.md
167
+
168
+ [dim]# With config file[/]
169
+ $ zwarm orchestrate -c configs/base.yaml --task "Refactor auth"
170
+
171
+ [dim]# Override settings[/]
172
+ $ zwarm orchestrate --task "Fix bug" --set executor.adapter=claude_code
173
+
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
182
+ """
183
+ from zwarm.orchestrator import build_orchestrator
184
+
185
+ # Resolve task from: --task, --task-file, or stdin
186
+ resolved_task = _resolve_task(task, task_file)
187
+ if not resolved_task:
188
+ console.print("[red]Error:[/] No task provided. Use --task, --task-file, or pipe from stdin.")
189
+ raise typer.Exit(1)
190
+
191
+ task = resolved_task
192
+
193
+ # Build overrides list
194
+ override_list = list(overrides or [])
195
+ if max_steps:
196
+ override_list.append(f"orchestrator.max_steps={max_steps}")
197
+
198
+ console.print(f"[bold]Starting orchestrator...[/]")
199
+ console.print(f" Task: {task}")
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 ""))
203
+ console.print()
204
+
205
+ # Output handler to show orchestrator messages
206
+ def output_handler(msg: str) -> None:
207
+ if msg.strip():
208
+ console.print(f"[dim][orchestrator][/] {msg}")
209
+
210
+ orchestrator = None
211
+ try:
212
+ orchestrator = build_orchestrator(
213
+ config_path=config,
214
+ task=task,
215
+ working_dir=working_dir.absolute(),
216
+ overrides=override_list,
217
+ resume=resume,
218
+ output_handler=output_handler,
219
+ instance_id=instance,
220
+ instance_name=instance_name,
221
+ )
222
+
223
+ if resume:
224
+ console.print(" [dim]Resuming from previous state...[/]")
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
+
230
+ # Run the orchestrator loop
231
+ console.print("[bold]--- Orchestrator running ---[/]\n")
232
+ result = orchestrator.run(task=task)
233
+
234
+ console.print(f"\n[bold green]--- Orchestrator finished ---[/]")
235
+ console.print(f" Steps: {result.get('steps', 'unknown')}")
236
+
237
+ # Show exit message if any
238
+ exit_msg = getattr(orchestrator, "_exit_message", "")
239
+ if exit_msg:
240
+ console.print(f" Exit: {exit_msg[:200]}")
241
+
242
+ # Save state for potential resume
243
+ orchestrator.save_state()
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
+
255
+ except KeyboardInterrupt:
256
+ console.print("\n\n[yellow]Interrupted.[/]")
257
+ if orchestrator:
258
+ orchestrator.save_state()
259
+ console.print("[dim]State saved. Use --resume to continue.[/]")
260
+ # Keep instance as "active" so it can be resumed
261
+ sys.exit(1)
262
+ except Exception as e:
263
+ console.print(f"\n[red]Error:[/] {e}")
264
+ if verbose:
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
+ )
274
+ sys.exit(1)
275
+
276
+
277
+ @app.command()
278
+ def exec(
279
+ task: Annotated[str, typer.Option("--task", "-t", help="Task to execute")],
280
+ adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Executor adapter")] = AdapterType.codex_mcp,
281
+ mode: Annotated[ModeType, typer.Option("--mode", "-m", help="Execution mode")] = ModeType.sync,
282
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
283
+ model: Annotated[Optional[str], typer.Option("--model", help="Model override")] = None,
284
+ ):
285
+ """
286
+ Run a single executor directly (for testing).
287
+
288
+ Useful for testing adapters without the full orchestrator loop.
289
+
290
+ [bold]Examples:[/]
291
+ [dim]# Test Codex[/]
292
+ $ zwarm exec --task "What is 2+2?"
293
+
294
+ [dim]# Test Claude Code[/]
295
+ $ zwarm exec -a claude_code --task "List files in current dir"
296
+
297
+ [dim]# Async mode[/]
298
+ $ zwarm exec --task "Build feature" --mode async
299
+ """
300
+ from zwarm.adapters import get_adapter
301
+
302
+ console.print(f"[bold]Running executor directly...[/]")
303
+ console.print(f" Adapter: [cyan]{adapter.value}[/]")
304
+ console.print(f" Mode: {mode.value}")
305
+ console.print(f" Task: {task}")
306
+
307
+ try:
308
+ executor = get_adapter(adapter.value, model=model)
309
+ except ValueError as e:
310
+ console.print(f"[red]Error:[/] {e}")
311
+ sys.exit(1)
312
+
313
+ async def run():
314
+ try:
315
+ session = await executor.start_session(
316
+ task=task,
317
+ working_dir=working_dir.absolute(),
318
+ mode=mode.value,
319
+ model=model,
320
+ )
321
+
322
+ console.print(f"\n[green]Session started:[/] {session.id[:8]}")
323
+
324
+ if mode == ModeType.sync:
325
+ response = session.messages[-1].content if session.messages else "(no response)"
326
+ console.print(f"\n[bold]Response:[/]\n{response}")
327
+
328
+ # Interactive loop for sync mode
329
+ while True:
330
+ try:
331
+ user_input = console.input("\n[dim]> (type message or 'exit')[/] ")
332
+ if user_input.lower() == "exit" or not user_input:
333
+ break
334
+
335
+ response = await executor.send_message(session, user_input)
336
+ console.print(f"\n[bold]Response:[/]\n{response}")
337
+ except KeyboardInterrupt:
338
+ break
339
+ else:
340
+ console.print("[dim]Async mode - session running in background.[/]")
341
+ console.print("Use 'zwarm status' to check progress.")
342
+
343
+ finally:
344
+ await executor.cleanup()
345
+
346
+ asyncio.run(run())
347
+
348
+
349
+ @app.command()
350
+ def status(
351
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
352
+ ):
353
+ """
354
+ Show current state (sessions, tasks, events).
355
+
356
+ Displays active sessions, pending tasks, and recent events
357
+ from the .zwarm state directory.
358
+
359
+ [bold]Example:[/]
360
+ $ zwarm status
361
+ """
362
+ from zwarm.core.state import StateManager
363
+
364
+ state_dir = working_dir / ".zwarm"
365
+ if not state_dir.exists():
366
+ console.print("[yellow]No zwarm state found in this directory.[/]")
367
+ console.print("[dim]Run 'zwarm orchestrate' to start.[/]")
368
+ return
369
+
370
+ state = StateManager(state_dir)
371
+ state.load()
372
+
373
+ # Sessions table
374
+ sessions = state.list_sessions()
375
+ console.print(f"\n[bold]Sessions[/] ({len(sessions)})")
376
+ if sessions:
377
+ table = Table(show_header=True, header_style="bold")
378
+ table.add_column("ID", style="dim")
379
+ table.add_column("Mode")
380
+ table.add_column("Status")
381
+ table.add_column("Task")
382
+
383
+ for s in sessions:
384
+ status_style = {"active": "green", "completed": "blue", "failed": "red"}.get(s.status.value, "white")
385
+ table.add_row(
386
+ s.id[:8],
387
+ s.mode.value,
388
+ f"[{status_style}]{s.status.value}[/]",
389
+ s.task_description[:50] + "..." if len(s.task_description) > 50 else s.task_description,
390
+ )
391
+ console.print(table)
392
+ else:
393
+ console.print(" [dim](none)[/]")
394
+
395
+ # Tasks table
396
+ tasks = state.list_tasks()
397
+ console.print(f"\n[bold]Tasks[/] ({len(tasks)})")
398
+ if tasks:
399
+ table = Table(show_header=True, header_style="bold")
400
+ table.add_column("ID", style="dim")
401
+ table.add_column("Status")
402
+ table.add_column("Description")
403
+
404
+ for t in tasks:
405
+ status_style = {"pending": "yellow", "in_progress": "cyan", "completed": "green", "failed": "red"}.get(t.status.value, "white")
406
+ table.add_row(
407
+ t.id[:8],
408
+ f"[{status_style}]{t.status.value}[/]",
409
+ t.description[:50] + "..." if len(t.description) > 50 else t.description,
410
+ )
411
+ console.print(table)
412
+ else:
413
+ console.print(" [dim](none)[/]")
414
+
415
+ # Recent events
416
+ events = state.get_events(limit=5)
417
+ console.print(f"\n[bold]Recent Events[/]")
418
+ if events:
419
+ for e in events:
420
+ console.print(f" [dim]{e.timestamp.strftime('%H:%M:%S')}[/] {e.kind}")
421
+ else:
422
+ console.print(" [dim](none)[/]")
423
+
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
+
482
+ @app.command()
483
+ def history(
484
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
485
+ kind: Annotated[Optional[str], typer.Option("--kind", "-k", help="Filter by event kind")] = None,
486
+ limit: Annotated[int, typer.Option("--limit", "-n", help="Number of events")] = 20,
487
+ ):
488
+ """
489
+ Show event history.
490
+
491
+ Displays the append-only event log with timestamps and details.
492
+
493
+ [bold]Examples:[/]
494
+ [dim]# Show last 20 events[/]
495
+ $ zwarm history
496
+
497
+ [dim]# Show more events[/]
498
+ $ zwarm history --limit 50
499
+
500
+ [dim]# Filter by kind[/]
501
+ $ zwarm history --kind session_started
502
+ """
503
+ from zwarm.core.state import StateManager
504
+
505
+ state_dir = working_dir / ".zwarm"
506
+ if not state_dir.exists():
507
+ console.print("[yellow]No zwarm state found.[/]")
508
+ return
509
+
510
+ state = StateManager(state_dir)
511
+ events = state.get_events(kind=kind, limit=limit)
512
+
513
+ console.print(f"\n[bold]Event History[/] (last {limit})\n")
514
+
515
+ if not events:
516
+ console.print("[dim]No events found.[/]")
517
+ return
518
+
519
+ table = Table(show_header=True, header_style="bold")
520
+ table.add_column("Time", style="dim")
521
+ table.add_column("Event")
522
+ table.add_column("Session/Task")
523
+ table.add_column("Details")
524
+
525
+ for e in events:
526
+ details = ""
527
+ if e.payload:
528
+ details = ", ".join(f"{k}={str(v)[:30]}" for k, v in list(e.payload.items())[:2])
529
+
530
+ table.add_row(
531
+ e.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
532
+ e.kind,
533
+ (e.session_id or e.task_id or "-")[:8],
534
+ details[:60],
535
+ )
536
+
537
+ console.print(table)
538
+
539
+
540
+ @configs_app.command("list")
541
+ def configs_list(
542
+ config_dir: Annotated[Optional[Path], typer.Option("--dir", "-d", help="Directory to search")] = None,
543
+ ):
544
+ """
545
+ List available agent/experiment configuration files (YAML).
546
+
547
+ Note: config.toml is for user environment settings and is loaded
548
+ automatically - use YAML files for agent configurations.
549
+
550
+ [bold]Example:[/]
551
+ $ zwarm configs list
552
+ """
553
+ search_dirs = [
554
+ config_dir or Path.cwd(),
555
+ Path.cwd() / "configs",
556
+ Path.cwd() / ".zwarm",
557
+ ]
558
+
559
+ console.print("\n[bold]Available Configurations[/]\n")
560
+ found = False
561
+
562
+ for d in search_dirs:
563
+ if not d.exists():
564
+ continue
565
+ for pattern in ["*.yaml", "*.yml"]:
566
+ for f in d.glob(pattern):
567
+ found = True
568
+ try:
569
+ rel = f.relative_to(Path.cwd())
570
+ console.print(f" [cyan]{rel}[/]")
571
+ except ValueError:
572
+ console.print(f" [cyan]{f}[/]")
573
+
574
+ if not found:
575
+ console.print(" [dim]No configuration files found.[/]")
576
+ console.print("\n [dim]Create a YAML config in configs/ to get started.[/]")
577
+
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)[/]")
585
+
586
+
587
+ @configs_app.command("show")
588
+ def configs_show(
589
+ config_path: Annotated[Path, typer.Argument(help="Path to configuration file")],
590
+ ):
591
+ """
592
+ Show a configuration file's contents.
593
+
594
+ Loads and displays the resolved configuration including
595
+ any inherited values from 'extends:' directives.
596
+
597
+ [bold]Example:[/]
598
+ $ zwarm configs show configs/base.yaml
599
+ """
600
+ from zwarm.core.config import load_config
601
+ import json
602
+
603
+ if not config_path.exists():
604
+ console.print(f"[red]File not found:[/] {config_path}")
605
+ raise typer.Exit(1)
606
+
607
+ try:
608
+ config = load_config(config_path=config_path)
609
+ console.print(f"\n[bold]Configuration:[/] {config_path}\n")
610
+ console.print_json(json.dumps(config.to_dict(), indent=2))
611
+ except Exception as e:
612
+ console.print(f"[red]Error loading config:[/] {e}")
613
+ raise typer.Exit(1)
614
+
615
+
616
+ @app.command()
617
+ def init(
618
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
619
+ non_interactive: Annotated[bool, typer.Option("--yes", "-y", help="Accept defaults, no prompts")] = False,
620
+ with_project: Annotated[bool, typer.Option("--with-project", help="Create zwarm.yaml project config")] = False,
621
+ ):
622
+ """
623
+ Initialize zwarm in the current directory.
624
+
625
+ Creates configuration files and the .zwarm state directory.
626
+ Run this once per project to set up zwarm.
627
+
628
+ [bold]Creates:[/]
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)
632
+
633
+ [bold]Examples:[/]
634
+ [dim]# Interactive setup[/]
635
+ $ zwarm init
636
+
637
+ [dim]# Quick setup with defaults[/]
638
+ $ zwarm init --yes
639
+
640
+ [dim]# Full setup with project config[/]
641
+ $ zwarm init --with-project
642
+ """
643
+ console.print("\n[bold cyan]zwarm init[/] - Initialize zwarm configuration\n")
644
+
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/")
660
+
661
+ # Check for existing files
662
+ if config_toml_path.exists():
663
+ console.print(f"[yellow]Warning:[/] .zwarm/config.toml already exists")
664
+ if not non_interactive:
665
+ overwrite = typer.confirm("Overwrite?", default=False)
666
+ if not overwrite:
667
+ console.print("[dim]Skipping config.toml[/]")
668
+ config_toml_path = None
669
+ else:
670
+ config_toml_path = None
671
+
672
+ # Gather settings
673
+ weave_project = ""
674
+ adapter = "codex_mcp"
675
+ watchers_enabled = ["progress", "budget", "delegation", "delegation_reminder"]
676
+ create_project_config = with_project
677
+ project_description = ""
678
+ project_context = ""
679
+
680
+ if not non_interactive:
681
+ console.print("[bold]Configuration[/]\n")
682
+
683
+ # Weave project
684
+ weave_project = typer.prompt(
685
+ " Weave project (entity/project, blank to skip)",
686
+ default="",
687
+ show_default=False,
688
+ )
689
+
690
+ # Adapter
691
+ adapter = typer.prompt(
692
+ " Default adapter",
693
+ default="codex_mcp",
694
+ type=str,
695
+ )
696
+
697
+ # Watchers
698
+ console.print("\n [bold]Watchers[/] (trajectory aligners)")
699
+ available_watchers = ["progress", "budget", "delegation", "delegation_reminder", "scope", "pattern", "quality"]
700
+ watchers_enabled = []
701
+ for w in available_watchers:
702
+ default = w in ["progress", "budget", "delegation", "delegation_reminder"]
703
+ if typer.confirm(f" Enable {w}?", default=default):
704
+ watchers_enabled.append(w)
705
+
706
+ # Project config
707
+ console.print()
708
+ create_project_config = typer.confirm(
709
+ " Create zwarm.yaml project config?",
710
+ default=with_project,
711
+ )
712
+
713
+ if create_project_config:
714
+ project_description = typer.prompt(
715
+ " Project description",
716
+ default="",
717
+ show_default=False,
718
+ )
719
+ console.print(" [dim]Project context (optional, press Enter twice to finish):[/]")
720
+ context_lines = []
721
+ while True:
722
+ line = typer.prompt(" ", default="", show_default=False)
723
+ if not line:
724
+ break
725
+ context_lines.append(line)
726
+ project_context = "\n".join(context_lines)
727
+
728
+ # Create .zwarm directory
729
+ console.print("\n[bold]Creating files...[/]\n")
730
+
731
+ state_dir.mkdir(parents=True, exist_ok=True)
732
+ (state_dir / "sessions").mkdir(exist_ok=True)
733
+ (state_dir / "orchestrator").mkdir(exist_ok=True)
734
+ console.print(f" [green]✓[/] Created .zwarm/")
735
+
736
+ # Create config.toml inside .zwarm/
737
+ if config_toml_path:
738
+ toml_content = _generate_config_toml(
739
+ weave_project=weave_project,
740
+ adapter=adapter,
741
+ watchers=watchers_enabled,
742
+ )
743
+ config_toml_path.write_text(toml_content)
744
+ console.print(f" [green]✓[/] Created .zwarm/config.toml")
745
+
746
+ # Create zwarm.yaml
747
+ if create_project_config:
748
+ if zwarm_yaml_path.exists() and not non_interactive:
749
+ overwrite = typer.confirm(" zwarm.yaml exists. Overwrite?", default=False)
750
+ if not overwrite:
751
+ create_project_config = False
752
+
753
+ if create_project_config:
754
+ yaml_content = _generate_zwarm_yaml(
755
+ description=project_description,
756
+ context=project_context,
757
+ watchers=watchers_enabled,
758
+ )
759
+ zwarm_yaml_path.write_text(yaml_content)
760
+ console.print(f" [green]✓[/] Created zwarm.yaml")
761
+
762
+ # Summary
763
+ console.print("\n[bold green]Done![/] zwarm is ready.\n")
764
+ console.print("[bold]Next steps:[/]")
765
+ console.print(" [dim]# Run the orchestrator[/]")
766
+ console.print(" $ zwarm orchestrate --task \"Your task here\"\n")
767
+ console.print(" [dim]# Or test an executor directly[/]")
768
+ console.print(" $ zwarm exec --task \"What is 2+2?\"\n")
769
+
770
+
771
+ def _generate_config_toml(
772
+ weave_project: str = "",
773
+ adapter: str = "codex_mcp",
774
+ watchers: list[str] | None = None,
775
+ ) -> str:
776
+ """Generate config.toml content."""
777
+ watchers = watchers or []
778
+
779
+ lines = [
780
+ "# zwarm configuration",
781
+ "# Generated by 'zwarm init'",
782
+ "",
783
+ "[weave]",
784
+ ]
785
+
786
+ if weave_project:
787
+ lines.append(f'project = "{weave_project}"')
788
+ else:
789
+ lines.append("# project = \"your-entity/your-project\" # Uncomment to enable Weave tracing")
790
+
791
+ lines.extend([
792
+ "",
793
+ "[orchestrator]",
794
+ "max_steps = 50",
795
+ "",
796
+ "[executor]",
797
+ f'adapter = "{adapter}"',
798
+ "# model = \"\" # Optional model override",
799
+ "",
800
+ "[watchers]",
801
+ f"enabled = {watchers}",
802
+ "",
803
+ "# Watcher-specific configuration",
804
+ "# [watchers.budget]",
805
+ "# max_steps = 50",
806
+ "# warn_at_percent = 80",
807
+ "",
808
+ "# [watchers.pattern]",
809
+ "# patterns = [\"DROP TABLE\", \"rm -rf\"]",
810
+ "",
811
+ ])
812
+
813
+ return "\n".join(lines)
814
+
815
+
816
+ def _generate_zwarm_yaml(
817
+ description: str = "",
818
+ context: str = "",
819
+ watchers: list[str] | None = None,
820
+ ) -> str:
821
+ """Generate zwarm.yaml project config."""
822
+ watchers = watchers or []
823
+
824
+ lines = [
825
+ "# zwarm project configuration",
826
+ "# Customize the orchestrator for this specific project",
827
+ "",
828
+ f'description: "{description}"' if description else 'description: ""',
829
+ "",
830
+ "# Project-specific context injected into the orchestrator",
831
+ "# This helps the orchestrator understand your codebase",
832
+ "context: |",
833
+ ]
834
+
835
+ if context:
836
+ for line in context.split("\n"):
837
+ lines.append(f" {line}")
838
+ else:
839
+ lines.extend([
840
+ " # Describe your project here. For example:",
841
+ " # - Tech stack (FastAPI, React, PostgreSQL)",
842
+ " # - Key directories (src/api/, src/components/)",
843
+ " # - Coding conventions to follow",
844
+ ])
845
+
846
+ lines.extend([
847
+ "",
848
+ "# Project-specific constraints",
849
+ "# The orchestrator will be reminded to follow these",
850
+ "constraints:",
851
+ " # - \"Never modify migration files directly\"",
852
+ " # - \"All new endpoints need tests\"",
853
+ " # - \"Use existing patterns from src/api/\"",
854
+ "",
855
+ "# Default watchers for this project",
856
+ "watchers:",
857
+ ])
858
+
859
+ for w in watchers:
860
+ lines.append(f" - {w}")
861
+
862
+ if not watchers:
863
+ lines.append(" # - progress")
864
+ lines.append(" # - budget")
865
+
866
+ lines.append("")
867
+
868
+ return "\n".join(lines)
869
+
870
+
871
+ @app.command()
872
+ def reset(
873
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
874
+ state: Annotated[bool, typer.Option("--state", "-s", help="Reset .zwarm/ state directory")] = True,
875
+ config: Annotated[bool, typer.Option("--config", "-c", help="Also delete config.toml")] = False,
876
+ project: Annotated[bool, typer.Option("--project", "-p", help="Also delete zwarm.yaml")] = False,
877
+ all_files: Annotated[bool, typer.Option("--all", "-a", help="Delete everything (state + config + project)")] = False,
878
+ force: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
879
+ ):
880
+ """
881
+ Reset zwarm state and optionally configuration files.
882
+
883
+ By default, only clears the .zwarm/ state directory (sessions, events, orchestrator history).
884
+ Use flags to also remove configuration files.
885
+
886
+ [bold]Examples:[/]
887
+ [dim]# Reset state only (default)[/]
888
+ $ zwarm reset
889
+
890
+ [dim]# Reset everything, no confirmation[/]
891
+ $ zwarm reset --all --yes
892
+
893
+ [dim]# Reset state and config.toml[/]
894
+ $ zwarm reset --config
895
+ """
896
+ import shutil
897
+
898
+ console.print("\n[bold cyan]zwarm reset[/] - Reset zwarm state\n")
899
+
900
+ state_dir = working_dir / ".zwarm"
901
+ config_toml_path = state_dir / "config.toml" # New location
902
+ old_config_toml_path = working_dir / "config.toml" # Legacy location
903
+ zwarm_yaml_path = working_dir / "zwarm.yaml"
904
+
905
+ # Expand --all flag
906
+ if all_files:
907
+ state = True
908
+ config = True
909
+ project = True
910
+
911
+ # Collect what will be deleted
912
+ to_delete = []
913
+ if state and state_dir.exists():
914
+ to_delete.append((".zwarm/", state_dir))
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))
921
+ if project and zwarm_yaml_path.exists():
922
+ to_delete.append(("zwarm.yaml", zwarm_yaml_path))
923
+
924
+ if not to_delete:
925
+ console.print("[yellow]Nothing to reset.[/] No matching files found.")
926
+ raise typer.Exit(0)
927
+
928
+ # Show what will be deleted
929
+ console.print("[bold]Will delete:[/]")
930
+ for name, path in to_delete:
931
+ if path.is_dir():
932
+ # Count contents
933
+ files = list(path.rglob("*"))
934
+ file_count = len([f for f in files if f.is_file()])
935
+ console.print(f" [red]✗[/] {name} ({file_count} files)")
936
+ else:
937
+ console.print(f" [red]✗[/] {name}")
938
+
939
+ # Confirm
940
+ if not force:
941
+ console.print()
942
+ confirm = typer.confirm("Proceed with reset?", default=False)
943
+ if not confirm:
944
+ console.print("[dim]Aborted.[/]")
945
+ raise typer.Exit(0)
946
+
947
+ # Delete
948
+ console.print("\n[bold]Deleting...[/]")
949
+ for name, path in to_delete:
950
+ try:
951
+ if path.is_dir():
952
+ shutil.rmtree(path)
953
+ else:
954
+ path.unlink()
955
+ console.print(f" [green]✓[/] Deleted {name}")
956
+ except Exception as e:
957
+ console.print(f" [red]✗[/] Failed to delete {name}: {e}")
958
+
959
+ console.print("\n[bold green]Reset complete.[/]")
960
+ console.print("\n[dim]Run 'zwarm init' to set up again.[/]\n")
961
+
962
+
963
+ @app.command()
964
+ def clean(
965
+ force: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
966
+ dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show what would be killed without killing")] = False,
967
+ ):
968
+ """
969
+ Clean up orphaned processes from zwarm sessions.
970
+
971
+ Finds and kills:
972
+ - Orphaned codex mcp-server processes
973
+ - Orphaned codex exec processes
974
+ - Orphaned claude CLI processes
975
+
976
+ [bold]Examples:[/]
977
+ [dim]# See what would be cleaned[/]
978
+ $ zwarm clean --dry-run
979
+
980
+ [dim]# Clean without confirmation[/]
981
+ $ zwarm clean --yes
982
+ """
983
+ import subprocess
984
+ import signal
985
+
986
+ console.print("\n[bold cyan]zwarm clean[/] - Clean up orphaned processes\n")
987
+
988
+ # Patterns to search for
989
+ patterns = [
990
+ ("codex mcp-server", "Codex MCP server"),
991
+ ("codex exec", "Codex exec"),
992
+ ("claude.*--permission-mode", "Claude CLI"),
993
+ ]
994
+
995
+ found_processes = []
996
+
997
+ for pattern, description in patterns:
998
+ try:
999
+ # Use pgrep to find matching processes
1000
+ result = subprocess.run(
1001
+ ["pgrep", "-f", pattern],
1002
+ capture_output=True,
1003
+ text=True,
1004
+ )
1005
+ if result.returncode == 0 and result.stdout.strip():
1006
+ pids = result.stdout.strip().split("\n")
1007
+ for pid in pids:
1008
+ pid = pid.strip()
1009
+ if pid and pid.isdigit():
1010
+ # Get process info
1011
+ try:
1012
+ ps_result = subprocess.run(
1013
+ ["ps", "-p", pid, "-o", "pid,ppid,etime,command"],
1014
+ capture_output=True,
1015
+ text=True,
1016
+ )
1017
+ if ps_result.returncode == 0:
1018
+ lines = ps_result.stdout.strip().split("\n")
1019
+ if len(lines) > 1:
1020
+ # Skip header, get process line
1021
+ proc_info = lines[1].strip()
1022
+ found_processes.append((int(pid), description, proc_info))
1023
+ except Exception:
1024
+ found_processes.append((int(pid), description, "(unknown)"))
1025
+ except FileNotFoundError:
1026
+ # pgrep not available, try ps with grep
1027
+ try:
1028
+ result = subprocess.run(
1029
+ f"ps aux | grep '{pattern}' | grep -v grep",
1030
+ shell=True,
1031
+ capture_output=True,
1032
+ text=True,
1033
+ )
1034
+ if result.returncode == 0 and result.stdout.strip():
1035
+ for line in result.stdout.strip().split("\n"):
1036
+ parts = line.split()
1037
+ if len(parts) >= 2:
1038
+ pid = parts[1]
1039
+ if pid.isdigit():
1040
+ found_processes.append((int(pid), description, line[:80]))
1041
+ except Exception:
1042
+ pass
1043
+ except Exception as e:
1044
+ console.print(f"[yellow]Warning:[/] Error searching for {description}: {e}")
1045
+
1046
+ if not found_processes:
1047
+ console.print("[green]No orphaned processes found.[/] Nothing to clean.\n")
1048
+ raise typer.Exit(0)
1049
+
1050
+ # Show what was found
1051
+ console.print(f"[bold]Found {len(found_processes)} process(es):[/]\n")
1052
+ for pid, description, info in found_processes:
1053
+ console.print(f" [yellow]PID {pid}[/] - {description}")
1054
+ console.print(f" [dim]{info[:100]}{'...' if len(info) > 100 else ''}[/]")
1055
+
1056
+ if dry_run:
1057
+ console.print("\n[dim]Dry run - no processes killed.[/]\n")
1058
+ raise typer.Exit(0)
1059
+
1060
+ # Confirm
1061
+ if not force:
1062
+ console.print()
1063
+ confirm = typer.confirm(f"Kill {len(found_processes)} process(es)?", default=False)
1064
+ if not confirm:
1065
+ console.print("[dim]Aborted.[/]")
1066
+ raise typer.Exit(0)
1067
+
1068
+ # Kill processes
1069
+ console.print("\n[bold]Cleaning up...[/]")
1070
+ killed = 0
1071
+ failed = 0
1072
+
1073
+ for pid, description, _ in found_processes:
1074
+ try:
1075
+ # First try SIGTERM
1076
+ os.kill(pid, signal.SIGTERM)
1077
+ console.print(f" [green]✓[/] Killed PID {pid} ({description})")
1078
+ killed += 1
1079
+ except ProcessLookupError:
1080
+ console.print(f" [dim]○[/] PID {pid} already gone")
1081
+ except PermissionError:
1082
+ console.print(f" [red]✗[/] PID {pid} - permission denied (try sudo)")
1083
+ failed += 1
1084
+ except Exception as e:
1085
+ console.print(f" [red]✗[/] PID {pid} - {e}")
1086
+ failed += 1
1087
+
1088
+ console.print(f"\n[bold green]Cleanup complete.[/] Killed {killed}, failed {failed}.\n")
1089
+
1090
+
1091
+ @app.command()
1092
+ def interactive(
1093
+ default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
1094
+ model: Annotated[Optional[str], typer.Option("--model", help="Default model override")] = None,
1095
+ state_dir: Annotated[Path, typer.Option("--state-dir", help="State directory for persistence")] = Path(".zwarm"),
1096
+ ):
1097
+ """
1098
+ Universal multi-agent CLI for commanding coding agents.
1099
+
1100
+ Spawn multiple agents across different directories, manage them interactively,
1101
+ and view their outputs. You are the orchestrator.
1102
+
1103
+ [bold]Commands:[/]
1104
+ [cyan]spawn[/] "task" [opts] Start a coding agent session
1105
+ [cyan]orchestrate[/] "task" Start an orchestrator agent
1106
+ [cyan]ls[/] / [cyan]list[/] Dashboard of all sessions
1107
+ [cyan]?[/] / [cyan]show[/] ID Check session status & response
1108
+ [cyan]c[/] / [cyan]continue[/] ID "msg" Continue a completed session
1109
+ [cyan]logs[/] ID Show raw JSONL output
1110
+ [cyan]kill[/] ID Stop a session
1111
+ [cyan]killall[/] Stop all running sessions
1112
+ [cyan]q[/] / [cyan]quit[/] Exit
1113
+
1114
+ [bold]Spawn Options:[/]
1115
+ spawn "task" --dir ~/project --model gpt-5.1-codex-max
1116
+
1117
+ [bold]Examples:[/]
1118
+ $ zwarm interactive
1119
+ > spawn "Build auth module" --dir ~/api
1120
+ > spawn "Fix tests" --dir ~/api
1121
+ > orchestrate "Refactor the entire API layer"
1122
+ > ls
1123
+ > ? abc123
1124
+ """
1125
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1126
+ import argparse
1127
+ import subprocess as sp
1128
+
1129
+ # Initialize session manager
1130
+ manager = CodexSessionManager(state_dir)
1131
+ default_model = model or "gpt-5.1-codex-mini"
1132
+
1133
+ console.print("\n[bold cyan]zwarm interactive[/] - Multi-Agent Command Center\n")
1134
+ console.print(f" Working dir: {default_dir.absolute()}")
1135
+ console.print(f" Model: [cyan]{default_model}[/]")
1136
+ console.print(f" Sessions: {state_dir / 'sessions'}")
1137
+ console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
1138
+
1139
+ def show_help():
1140
+ help_table = Table(show_header=False, box=None, padding=(0, 2))
1141
+ help_table.add_column("Command", style="cyan", width=35)
1142
+ help_table.add_column("Description")
1143
+ help_table.add_row('spawn "task" [options]', "Start a coding agent")
1144
+ help_table.add_row(" --dir PATH", "Working directory")
1145
+ help_table.add_row(" --model NAME", "Model override")
1146
+ help_table.add_row("", "")
1147
+ help_table.add_row('orchestrate "task" [options]', "Start an orchestrator agent")
1148
+ help_table.add_row(" --dir PATH", "Working directory")
1149
+ help_table.add_row("", "")
1150
+ help_table.add_row("ls / list [--all]", "Dashboard of sessions")
1151
+ help_table.add_row("? / show ID", "Show session messages")
1152
+ help_table.add_row("c / continue ID \"msg\"", "Continue a completed session")
1153
+ help_table.add_row("logs ID [-f]", "Show raw JSONL output")
1154
+ help_table.add_row("kill ID", "Stop a running session")
1155
+ help_table.add_row("killall", "Stop all running sessions")
1156
+ help_table.add_row("clean", "Remove completed sessions")
1157
+ help_table.add_row("q / quit", "Exit")
1158
+ console.print(help_table)
1159
+
1160
+ def show_sessions(show_all: bool = False):
1161
+ sessions = manager.list_sessions()
1162
+
1163
+ if not show_all:
1164
+ # Show running + recent completed
1165
+ sessions = [s for s in sessions if s.status == SessionStatus.RUNNING or s.status == SessionStatus.COMPLETED][:20]
1166
+
1167
+ if not sessions:
1168
+ console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
1169
+ return
1170
+
1171
+ # Status summary
1172
+ all_sessions = manager.list_sessions()
1173
+ running = sum(1 for s in all_sessions if s.status == SessionStatus.RUNNING)
1174
+ completed = sum(1 for s in all_sessions if s.status == SessionStatus.COMPLETED)
1175
+ failed = sum(1 for s in all_sessions if s.status == SessionStatus.FAILED)
1176
+
1177
+ summary_parts = []
1178
+ if running:
1179
+ summary_parts.append(f"[yellow]{running} running[/]")
1180
+ if completed:
1181
+ summary_parts.append(f"[green]{completed} done[/]")
1182
+ if failed:
1183
+ summary_parts.append(f"[red]{failed} failed[/]")
1184
+ if summary_parts:
1185
+ console.print(" | ".join(summary_parts))
1186
+ console.print()
1187
+
1188
+ table = Table(box=None, show_header=True, header_style="bold dim")
1189
+ table.add_column("ID", style="cyan", width=10)
1190
+ table.add_column("", width=2) # Status icon
1191
+ table.add_column("Source", width=10)
1192
+ table.add_column("Task", max_width=40)
1193
+ table.add_column("Runtime", justify="right", style="dim", width=8)
1194
+ table.add_column("Tokens", justify="right", style="dim", width=8)
1195
+
1196
+ status_icons = {
1197
+ SessionStatus.RUNNING: "[yellow]⟳[/]",
1198
+ SessionStatus.COMPLETED: "[green]✓[/]",
1199
+ SessionStatus.FAILED: "[red]✗[/]",
1200
+ SessionStatus.KILLED: "[dim]⊘[/]",
1201
+ SessionStatus.PENDING: "[dim]○[/]",
1202
+ }
1203
+
1204
+ for s in sessions:
1205
+ icon = status_icons.get(s.status, "?")
1206
+ task_preview = s.task[:37] + "..." if len(s.task) > 40 else s.task
1207
+ tokens = s.token_usage.get("total_tokens", 0)
1208
+ tokens_str = f"{tokens:,}" if tokens else "-"
1209
+
1210
+ # Source display with styling
1211
+ source = s.source_display
1212
+ if source == "you":
1213
+ source_styled = "[blue]you[/]"
1214
+ elif source.startswith("orch:"):
1215
+ source_styled = f"[magenta]{source}[/]"
1216
+ else:
1217
+ source_styled = f"[dim]{source}[/]"
1218
+
1219
+ table.add_row(
1220
+ s.short_id,
1221
+ icon,
1222
+ source_styled,
1223
+ task_preview,
1224
+ s.runtime,
1225
+ tokens_str,
1226
+ )
1227
+
1228
+ console.print(table)
1229
+
1230
+ def parse_spawn_args(args: list[str]) -> dict:
1231
+ """Parse spawn command arguments."""
1232
+ parser = argparse.ArgumentParser(add_help=False)
1233
+ parser.add_argument("task", nargs="*")
1234
+ parser.add_argument("--dir", "-d", type=Path, default=None)
1235
+ parser.add_argument("--model", "-m", default=None)
1236
+
1237
+ try:
1238
+ parsed, _ = parser.parse_known_args(args)
1239
+ return {
1240
+ "task": " ".join(parsed.task) if parsed.task else "",
1241
+ "dir": parsed.dir,
1242
+ "model": parsed.model,
1243
+ }
1244
+ except SystemExit:
1245
+ return {"error": "Invalid spawn arguments"}
1246
+
1247
+ def do_spawn(args: list[str]):
1248
+ """Spawn a new coding agent session."""
1249
+ parsed = parse_spawn_args(args)
1250
+
1251
+ if "error" in parsed:
1252
+ console.print(f" [red]{parsed['error']}[/]")
1253
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME][/]")
1254
+ return
1255
+
1256
+ if not parsed["task"]:
1257
+ console.print(" [red]Task required[/]")
1258
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME][/]")
1259
+ return
1260
+
1261
+ task = parsed["task"]
1262
+ work_dir = (parsed["dir"] or default_dir).absolute()
1263
+ session_model = parsed["model"] or default_model
1264
+
1265
+ if not work_dir.exists():
1266
+ console.print(f" [red]Directory not found:[/] {work_dir}")
1267
+ return
1268
+
1269
+ console.print(f"\n[dim]Spawning session...[/]")
1270
+ console.print(f" [dim]Dir: {work_dir}[/]")
1271
+ console.print(f" [dim]Model: {session_model}[/]")
1272
+
1273
+ try:
1274
+ session = manager.start_session(
1275
+ task=task,
1276
+ working_dir=work_dir,
1277
+ model=session_model,
1278
+ source="user",
1279
+ adapter="codex",
1280
+ )
1281
+
1282
+ console.print(f"\n[green]✓[/] Session: [cyan]{session.short_id}[/]")
1283
+ console.print(f" PID: {session.pid}")
1284
+ console.print(f"\n[dim]Use '? {session.short_id}' to check status[/]")
1285
+
1286
+ except Exception as e:
1287
+ console.print(f" [red]Error:[/] {e}")
1288
+ import traceback
1289
+ console.print(f" [dim]{traceback.format_exc()}[/]")
1290
+
1291
+ def do_orchestrate(args: list[str]):
1292
+ """Spawn an orchestrator agent that delegates to sub-sessions."""
1293
+ parsed = parse_spawn_args(args)
1294
+
1295
+ if "error" in parsed:
1296
+ console.print(f" [red]{parsed['error']}[/]")
1297
+ console.print(" [dim]Usage: orchestrate \"task\" [--dir PATH][/]")
1298
+ return
1299
+
1300
+ if not parsed["task"]:
1301
+ console.print(" [red]Task required[/]")
1302
+ console.print(" [dim]Usage: orchestrate \"task\" [--dir PATH][/]")
1303
+ return
1304
+
1305
+ task = parsed["task"]
1306
+ work_dir = (parsed["dir"] or default_dir).absolute()
1307
+
1308
+ if not work_dir.exists():
1309
+ console.print(f" [red]Directory not found:[/] {work_dir}")
1310
+ return
1311
+
1312
+ console.print(f"\n[dim]Starting orchestrator...[/]")
1313
+ console.print(f" [dim]Dir: {work_dir}[/]")
1314
+ console.print(f" [dim]Task: {task[:60]}{'...' if len(task) > 60 else ''}[/]")
1315
+
1316
+ import subprocess
1317
+ from uuid import uuid4
1318
+
1319
+ # Generate instance ID for tracking
1320
+ instance_id = str(uuid4())[:8]
1321
+
1322
+ # Build command to run orchestrator in background
1323
+ cmd = [
1324
+ sys.executable, "-m", "zwarm.cli.main", "orchestrate",
1325
+ "--task", task,
1326
+ "--working-dir", str(work_dir),
1327
+ "--instance", instance_id,
1328
+ ]
1329
+
1330
+ # Create log file for orchestrator output
1331
+ log_dir = state_dir / "orchestrators"
1332
+ log_dir.mkdir(parents=True, exist_ok=True)
1333
+ log_file = log_dir / f"{instance_id}.log"
1334
+
1335
+ try:
1336
+ with open(log_file, "w") as f:
1337
+ proc = subprocess.Popen(
1338
+ cmd,
1339
+ cwd=work_dir,
1340
+ stdout=f,
1341
+ stderr=subprocess.STDOUT,
1342
+ start_new_session=True,
1343
+ )
1344
+
1345
+ console.print(f"\n[green]✓[/] Orchestrator started: [magenta]{instance_id}[/]")
1346
+ console.print(f" PID: {proc.pid}")
1347
+ console.print(f" Log: {log_file}")
1348
+ console.print(f"\n[dim]Delegated sessions will appear with source 'orch:{instance_id[:4]}'[/]")
1349
+ console.print(f"[dim]Use 'ls' to monitor progress[/]")
1350
+
1351
+ except Exception as e:
1352
+ console.print(f" [red]Error starting orchestrator:[/] {e}")
1353
+
1354
+ def do_show(session_id: str):
1355
+ """Show session details and messages."""
1356
+ session = manager.get_session(session_id)
1357
+ if not session:
1358
+ console.print(f" [red]Session not found:[/] {session_id}")
1359
+ return
1360
+
1361
+ # Get messages
1362
+ messages = manager.get_messages(session.id)
1363
+
1364
+ # Status styling
1365
+ status_display = {
1366
+ "running": "[yellow]⟳ running[/]",
1367
+ "completed": "[green]✓ completed[/]",
1368
+ "failed": "[red]✗ failed[/]",
1369
+ "killed": "[dim]⊘ killed[/]",
1370
+ "pending": "[dim]○ pending[/]",
1371
+ }.get(session.status.value, session.status.value)
1372
+
1373
+ console.print()
1374
+ console.print(f"[bold cyan]Session {session.short_id}[/] {status_display}")
1375
+ console.print(f"[dim]Source:[/] {session.source_display} [dim]│[/] [dim]Model:[/] {session.model}")
1376
+ console.print(f"[dim]Task:[/] {session.task}")
1377
+ console.print(f"[dim]Dir:[/] {session.working_dir} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
1378
+ console.print()
1379
+
1380
+ if not messages:
1381
+ if session.status == SessionStatus.RUNNING:
1382
+ console.print("[yellow]Session is still running...[/]")
1383
+ else:
1384
+ console.print("[dim]No messages captured.[/]")
1385
+ return
1386
+
1387
+ # Display messages
1388
+ for msg in messages:
1389
+ if msg.role == "user":
1390
+ console.print(Panel(msg.content, title="[bold blue]User[/]", border_style="blue"))
1391
+ elif msg.role == "assistant":
1392
+ content = msg.content
1393
+ if len(content) > 2000:
1394
+ content = content[:2000] + "\n\n[dim]... (truncated)[/]"
1395
+ console.print(Panel(content, title="[bold green]Assistant[/]", border_style="green"))
1396
+ elif msg.role == "tool":
1397
+ if msg.content.startswith("[Calling:"):
1398
+ console.print(f" [dim]⚙[/] {msg.content}")
1399
+ else:
1400
+ console.print(f" [dim]└─ {msg.content[:100]}[/]")
1401
+
1402
+ console.print()
1403
+ if session.token_usage:
1404
+ tokens = session.token_usage
1405
+ console.print(f"[dim]Tokens: {tokens.get('input_tokens', 0):,} in / {tokens.get('output_tokens', 0):,} out[/]")
1406
+
1407
+ if session.error:
1408
+ console.print(f"[red]Error:[/] {session.error}")
1409
+
1410
+ def do_continue(session_id: str, message: str):
1411
+ """Continue a completed session with a follow-up message."""
1412
+ session = manager.get_session(session_id)
1413
+ if not session:
1414
+ console.print(f" [red]Session not found:[/] {session_id}")
1415
+ return
1416
+
1417
+ if session.status == SessionStatus.RUNNING:
1418
+ console.print("[yellow]Session is still running.[/]")
1419
+ console.print("[dim]Wait for it to complete, then continue.[/]")
1420
+ return
1421
+
1422
+ console.print(f"\n[dim]Injecting message into {session.short_id}...[/]")
1423
+
1424
+ updated = manager.inject_message(session.id, message)
1425
+ if updated:
1426
+ console.print(f"[green]✓[/] Turn {updated.turn} started")
1427
+ console.print(f" PID: {updated.pid}")
1428
+ console.print(f"\n[dim]Use '? {session.short_id}' to check status[/]")
1429
+ else:
1430
+ console.print("[red]Failed to inject message.[/]")
1431
+
1432
+ def do_logs(session_id: str, follow: bool = False):
1433
+ """Show raw JSONL logs for a session."""
1434
+ session = manager.get_session(session_id)
1435
+ if not session:
1436
+ console.print(f" [red]Session not found:[/] {session_id}")
1437
+ return
1438
+
1439
+ if follow and session.status == SessionStatus.RUNNING:
1440
+ import time
1441
+ output_path = manager._output_path(session.id, session.turn)
1442
+ console.print(f"[dim]Following {output_path}... (Ctrl+C to stop)[/]\n")
1443
+
1444
+ try:
1445
+ with open(output_path, "r") as f:
1446
+ for line in f:
1447
+ console.print(line.rstrip())
1448
+ while session.is_running:
1449
+ line = f.readline()
1450
+ if line:
1451
+ console.print(line.rstrip())
1452
+ else:
1453
+ time.sleep(0.5)
1454
+ session = manager.get_session(session_id)
1455
+ except KeyboardInterrupt:
1456
+ console.print("\n[dim]Stopped.[/]")
1457
+ else:
1458
+ output = manager.get_output(session.id)
1459
+ if output:
1460
+ console.print(output)
1461
+ else:
1462
+ console.print("[dim]No output yet.[/]")
1463
+
1464
+ def do_kill(session_id: str):
1465
+ """Kill a running session."""
1466
+ session = manager.get_session(session_id)
1467
+ if not session:
1468
+ console.print(f" [red]Session not found:[/] {session_id}")
1469
+ return
1470
+
1471
+ if not session.is_running:
1472
+ console.print(f"[yellow]Session {session.short_id} is not running.[/]")
1473
+ return
1474
+
1475
+ if manager.kill_session(session.id):
1476
+ console.print(f"[green]✓[/] Killed session {session.short_id}")
1477
+ else:
1478
+ console.print(f"[red]Failed to kill session[/]")
1479
+
1480
+ def do_killall():
1481
+ """Kill all running sessions."""
1482
+ sessions = manager.list_sessions(status=SessionStatus.RUNNING)
1483
+ killed = 0
1484
+ for session in sessions:
1485
+ if manager.kill_session(session.id):
1486
+ killed += 1
1487
+ console.print(f"[green]✓[/] Killed {killed} sessions")
1488
+
1489
+ def do_clean():
1490
+ """Remove completed sessions."""
1491
+ cleaned = manager.cleanup_completed(keep_days=0)
1492
+ console.print(f"[green]✓[/] Cleaned {cleaned} sessions")
1493
+
1494
+ # REPL loop
1495
+ import shlex
1496
+
1497
+ while True:
1498
+ try:
1499
+ raw_input = console.input("[bold cyan]>[/] ").strip()
1500
+ if not raw_input:
1501
+ continue
1502
+
1503
+ # Parse command
1504
+ try:
1505
+ parts = shlex.split(raw_input)
1506
+ except ValueError:
1507
+ parts = raw_input.split()
1508
+
1509
+ cmd = parts[0].lower()
1510
+ args = parts[1:]
1511
+
1512
+ if cmd in ("q", "quit", "exit"):
1513
+ running = manager.list_sessions(status=SessionStatus.RUNNING)
1514
+ if running:
1515
+ console.print(f" [yellow]Warning:[/] {len(running)} sessions still running")
1516
+ console.print(" [dim]Use 'killall' first, or they'll continue in background[/]")
1517
+ console.print("\n[dim]Goodbye![/]\n")
1518
+ break
1519
+
1520
+ elif cmd in ("h", "help"):
1521
+ show_help()
1522
+
1523
+ elif cmd in ("ls", "list"):
1524
+ show_all = "--all" in args or "-a" in args
1525
+ show_sessions(show_all)
1526
+
1527
+ elif cmd == "spawn":
1528
+ do_spawn(args)
1529
+
1530
+ elif cmd == "orchestrate":
1531
+ do_orchestrate(args)
1532
+
1533
+ elif cmd in ("?", "show"):
1534
+ if not args:
1535
+ console.print(" [red]Usage:[/] ? SESSION_ID")
1536
+ else:
1537
+ do_show(args[0])
1538
+
1539
+ elif cmd in ("c", "continue"):
1540
+ if len(args) < 2:
1541
+ console.print(" [red]Usage:[/] c SESSION_ID \"message\"")
1542
+ else:
1543
+ do_continue(args[0], " ".join(args[1:]))
1544
+
1545
+ elif cmd == "logs":
1546
+ if not args:
1547
+ console.print(" [red]Usage:[/] logs SESSION_ID [-f]")
1548
+ else:
1549
+ follow = "-f" in args or "--follow" in args
1550
+ session_id = [a for a in args if not a.startswith("-")][0]
1551
+ do_logs(session_id, follow)
1552
+
1553
+ elif cmd == "kill":
1554
+ if not args:
1555
+ console.print(" [red]Usage:[/] kill SESSION_ID")
1556
+ else:
1557
+ do_kill(args[0])
1558
+
1559
+ elif cmd == "killall":
1560
+ do_killall()
1561
+
1562
+ elif cmd == "clean":
1563
+ do_clean()
1564
+
1565
+ else:
1566
+ console.print(f" [yellow]Unknown command:[/] {cmd}")
1567
+ console.print(" [dim]Type 'help' for available commands[/]")
1568
+
1569
+ except KeyboardInterrupt:
1570
+ console.print("\n[dim](Ctrl+C to exit, or type 'quit')[/]")
1571
+ except EOFError:
1572
+ console.print("\n[dim]Goodbye![/]\n")
1573
+ break
1574
+ except Exception as e:
1575
+ console.print(f" [red]Error:[/] {e}")
1576
+
1577
+
1578
+ # =============================================================================
1579
+ # Session Manager Commands (background Codex processes)
1580
+ # =============================================================================
1581
+
1582
+ session_app = typer.Typer(
1583
+ name="session",
1584
+ help="""
1585
+ [bold cyan]Codex Session Manager[/]
1586
+
1587
+ Manage background Codex sessions. Run multiple codex tasks in parallel,
1588
+ monitor their progress, and inject follow-up messages.
1589
+
1590
+ [bold]COMMANDS[/]
1591
+ [cyan]start[/] Start a new session in the background
1592
+ [cyan]ls[/] List all sessions
1593
+ [cyan]show[/] Show messages for a session
1594
+ [cyan]logs[/] Show raw JSONL output
1595
+ [cyan]inject[/] Inject a follow-up message
1596
+ [cyan]kill[/] Kill a running session
1597
+ [cyan]clean[/] Remove old completed sessions
1598
+
1599
+ [bold]EXAMPLES[/]
1600
+ [dim]# Start a background session[/]
1601
+ $ zwarm session start "Add tests for auth module"
1602
+
1603
+ [dim]# List all sessions[/]
1604
+ $ zwarm session ls
1605
+
1606
+ [dim]# View session messages[/]
1607
+ $ zwarm session show abc123
1608
+
1609
+ [dim]# Continue a completed session[/]
1610
+ $ zwarm session inject abc123 "Also add edge case tests"
1611
+ """,
1612
+ )
1613
+ app.add_typer(session_app, name="session")
1614
+
1615
+
1616
+ @session_app.command("start")
1617
+ def session_start(
1618
+ task: Annotated[str, typer.Argument(help="Task description")],
1619
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1620
+ model: Annotated[str, typer.Option("--model", "-m", help="Model to use")] = "gpt-5.1-codex-mini",
1621
+ ):
1622
+ """
1623
+ Start a new Codex session in the background.
1624
+
1625
+ The session runs independently and you can check on it later.
1626
+
1627
+ [bold]Examples:[/]
1628
+ [dim]# Simple task[/]
1629
+ $ zwarm session start "Fix the bug in auth.py"
1630
+
1631
+ [dim]# With specific model[/]
1632
+ $ zwarm session start "Refactor the API" --model gpt-5.1-codex-max
1633
+ """
1634
+ from zwarm.sessions import CodexSessionManager
1635
+
1636
+ manager = CodexSessionManager(working_dir / ".zwarm")
1637
+ session = manager.start_session(
1638
+ task=task,
1639
+ working_dir=working_dir,
1640
+ model=model,
1641
+ )
1642
+
1643
+ console.print()
1644
+ console.print(f"[green]✓ Session started[/] [bold cyan]{session.short_id}[/]")
1645
+ console.print()
1646
+ console.print(f" [dim]Task:[/] {task[:70]}{'...' if len(task) > 70 else ''}")
1647
+ console.print(f" [dim]Model:[/] {model}")
1648
+ console.print(f" [dim]PID:[/] {session.pid}")
1649
+ console.print()
1650
+ console.print("[dim]Commands:[/]")
1651
+ console.print(f" [cyan]zwarm session ls[/] [dim]List all sessions[/]")
1652
+ console.print(f" [cyan]zwarm session show {session.short_id}[/] [dim]View messages[/]")
1653
+ console.print(f" [cyan]zwarm session logs {session.short_id} -f[/] [dim]Follow live output[/]")
1654
+
1655
+
1656
+ @session_app.command("ls")
1657
+ def session_list(
1658
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1659
+ all_sessions: Annotated[bool, typer.Option("--all", "-a", help="Show all sessions including completed")] = False,
1660
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
1661
+ ):
1662
+ """
1663
+ List all sessions.
1664
+
1665
+ Shows running sessions by default. Use --all to include completed.
1666
+
1667
+ [bold]Examples:[/]
1668
+ $ zwarm session ls
1669
+ $ zwarm session ls --all
1670
+ """
1671
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1672
+
1673
+ manager = CodexSessionManager(working_dir / ".zwarm")
1674
+ sessions = manager.list_sessions()
1675
+
1676
+ if not all_sessions:
1677
+ sessions = [s for s in sessions if s.status == SessionStatus.RUNNING]
1678
+
1679
+ if json_output:
1680
+ import json
1681
+ console.print(json.dumps([s.to_dict() for s in sessions], indent=2))
1682
+ return
1683
+
1684
+ if not sessions:
1685
+ if all_sessions:
1686
+ console.print("[dim]No sessions found.[/]")
1687
+ else:
1688
+ console.print("[dim]No running sessions.[/]")
1689
+ console.print("[dim]Use --all to see completed sessions, or start one with:[/]")
1690
+ console.print(" zwarm session start \"your task here\"")
1691
+ return
1692
+
1693
+ # Show status summary
1694
+ running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
1695
+ completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
1696
+ failed_count = sum(1 for s in sessions if s.status == SessionStatus.FAILED)
1697
+
1698
+ summary_parts = []
1699
+ if running_count:
1700
+ summary_parts.append(f"[yellow]⟳ {running_count} running[/]")
1701
+ if completed_count:
1702
+ summary_parts.append(f"[green]✓ {completed_count} completed[/]")
1703
+ if failed_count:
1704
+ summary_parts.append(f"[red]✗ {failed_count} failed[/]")
1705
+
1706
+ if summary_parts:
1707
+ console.print(" │ ".join(summary_parts))
1708
+ console.print()
1709
+
1710
+ # Build table
1711
+ table = Table(box=None, show_header=True, header_style="bold dim")
1712
+ table.add_column("ID", style="cyan")
1713
+ table.add_column("", width=2) # Status icon
1714
+ table.add_column("Task", max_width=50)
1715
+ table.add_column("Runtime", justify="right", style="dim")
1716
+ table.add_column("Tokens", justify="right", style="dim")
1717
+
1718
+ status_icons = {
1719
+ SessionStatus.RUNNING: "[yellow]⟳[/]",
1720
+ SessionStatus.COMPLETED: "[green]✓[/]",
1721
+ SessionStatus.FAILED: "[red]✗[/]",
1722
+ SessionStatus.KILLED: "[dim]⊘[/]",
1723
+ SessionStatus.PENDING: "[dim]○[/]",
1724
+ }
1725
+
1726
+ for session in sessions:
1727
+ status_icon = status_icons.get(session.status, "?")
1728
+ task_preview = session.task[:47] + "..." if len(session.task) > 50 else session.task
1729
+ tokens = session.token_usage.get("total_tokens", 0)
1730
+ tokens_str = f"{tokens:,}" if tokens else "-"
1731
+
1732
+ table.add_row(
1733
+ session.short_id,
1734
+ status_icon,
1735
+ task_preview,
1736
+ session.runtime,
1737
+ tokens_str,
1738
+ )
1739
+
1740
+ console.print()
1741
+ console.print(table)
1742
+ console.print()
1743
+
1744
+
1745
+ @session_app.command("show")
1746
+ def session_show(
1747
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1748
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1749
+ raw: Annotated[bool, typer.Option("--raw", "-r", help="Show raw messages without formatting")] = False,
1750
+ ):
1751
+ """
1752
+ Show messages for a session.
1753
+
1754
+ Displays the conversation history with nice formatting.
1755
+
1756
+ [bold]Examples:[/]
1757
+ $ zwarm session show abc123
1758
+ $ zwarm session show abc123 --raw
1759
+ """
1760
+ from zwarm.sessions import CodexSessionManager
1761
+
1762
+ manager = CodexSessionManager(working_dir / ".zwarm")
1763
+ session = manager.get_session(session_id)
1764
+
1765
+ if not session:
1766
+ console.print(f"[red]Session not found:[/] {session_id}")
1767
+ raise typer.Exit(1)
1768
+
1769
+ # Get messages
1770
+ messages = manager.get_messages(session.id)
1771
+
1772
+ # Status styling
1773
+ status_display = {
1774
+ "running": "[yellow]⟳ running[/]",
1775
+ "completed": "[green]✓ completed[/]",
1776
+ "failed": "[red]✗ failed[/]",
1777
+ "killed": "[dim]⊘ killed[/]",
1778
+ "pending": "[dim]○ pending[/]",
1779
+ }.get(session.status.value, session.status.value)
1780
+
1781
+ console.print()
1782
+ console.print(f"[bold cyan]Session {session.short_id}[/] {status_display}")
1783
+ console.print(f"[dim]Task:[/] {session.task}")
1784
+ console.print(f"[dim]Model:[/] {session.model} [dim]│[/] [dim]Turn:[/] {session.turn} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
1785
+ console.print()
1786
+
1787
+ if not messages:
1788
+ if session.status.value == "running":
1789
+ console.print("[yellow]Session is still running...[/]")
1790
+ console.print("[dim]Check back later for output.[/]")
1791
+ else:
1792
+ console.print("[dim]No messages captured.[/]")
1793
+ return
1794
+
1795
+ # Display messages
1796
+ for msg in messages:
1797
+ if msg.role == "user":
1798
+ if raw:
1799
+ console.print(f"[bold blue]USER:[/] {msg.content}")
1800
+ else:
1801
+ console.print(Panel(msg.content, title="[bold blue]User[/]", border_style="blue"))
1802
+
1803
+ elif msg.role == "assistant":
1804
+ if raw:
1805
+ console.print(f"[bold green]ASSISTANT:[/] {msg.content}")
1806
+ else:
1807
+ # Truncate very long messages
1808
+ content = msg.content
1809
+ if len(content) > 2000:
1810
+ content = content[:2000] + "\n\n[dim]... (truncated, use --raw for full output)[/]"
1811
+ console.print(Panel(content, title="[bold green]Assistant[/]", border_style="green"))
1812
+
1813
+ elif msg.role == "tool":
1814
+ if raw:
1815
+ console.print(f"[dim]TOOL: {msg.content}[/]")
1816
+ else:
1817
+ # Extract function name if present
1818
+ content = msg.content
1819
+ if content.startswith("[Calling:"):
1820
+ console.print(f" [dim]⚙[/] {content}")
1821
+ elif content.startswith("[Output]"):
1822
+ console.print(f" [dim]└─ {content[9:]}[/]") # Skip "[Output]:"
1823
+ else:
1824
+ console.print(f" [dim]{content}[/]")
1825
+
1826
+ console.print()
1827
+
1828
+ # Show token usage
1829
+ if session.token_usage:
1830
+ tokens = session.token_usage
1831
+ console.print(f"[dim]Tokens: {tokens.get('input_tokens', 0):,} in / {tokens.get('output_tokens', 0):,} out[/]")
1832
+
1833
+ # Show error if any
1834
+ if session.error:
1835
+ console.print(f"[red]Error:[/] {session.error}")
1836
+
1837
+ # Helpful tip
1838
+ console.print()
1839
+ if session.status.value == "running":
1840
+ console.print(f"[dim]Tip: Use 'zwarm session logs {session.short_id} --follow' to watch live output[/]")
1841
+ elif session.status.value == "completed":
1842
+ console.print(f"[dim]Tip: Use 'zwarm session inject {session.short_id} \"your message\"' to continue the conversation[/]")
1843
+
1844
+
1845
+ @session_app.command("logs")
1846
+ def session_logs(
1847
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1848
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1849
+ turn: Annotated[Optional[int], typer.Option("--turn", "-t", help="Specific turn number")] = None,
1850
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow output (like tail -f)")] = False,
1851
+ ):
1852
+ """
1853
+ Show raw JSONL logs for a session.
1854
+
1855
+ [bold]Examples:[/]
1856
+ $ zwarm session logs abc123
1857
+ $ zwarm session logs abc123 --follow
1858
+ """
1859
+ from zwarm.sessions import CodexSessionManager
1860
+
1861
+ manager = CodexSessionManager(working_dir / ".zwarm")
1862
+ session = manager.get_session(session_id)
1863
+
1864
+ if not session:
1865
+ console.print(f"[red]Session not found:[/] {session_id}")
1866
+ raise typer.Exit(1)
1867
+
1868
+ if follow and session.status.value == "running":
1869
+ # Follow mode - tail the file
1870
+ import time
1871
+ output_path = manager._output_path(session.id, turn or session.turn)
1872
+
1873
+ console.print(f"[dim]Following {output_path}... (Ctrl+C to stop)[/]")
1874
+ console.print()
1875
+
1876
+ try:
1877
+ with open(output_path, "r") as f:
1878
+ # Print existing content
1879
+ for line in f:
1880
+ console.print(line.rstrip())
1881
+
1882
+ # Follow new content
1883
+ while session.is_running:
1884
+ line = f.readline()
1885
+ if line:
1886
+ console.print(line.rstrip())
1887
+ else:
1888
+ time.sleep(0.5)
1889
+ # Refresh session status
1890
+ session = manager.get_session(session_id)
1891
+
1892
+ except KeyboardInterrupt:
1893
+ console.print("\n[dim]Stopped following.[/]")
1894
+
1895
+ else:
1896
+ # Just print the output
1897
+ output = manager.get_output(session.id, turn)
1898
+ if output:
1899
+ console.print(output)
1900
+ else:
1901
+ console.print("[dim]No output yet.[/]")
1902
+
1903
+
1904
+ @session_app.command("inject")
1905
+ def session_inject(
1906
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1907
+ message: Annotated[str, typer.Argument(help="Follow-up message to inject")],
1908
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1909
+ ):
1910
+ """
1911
+ Inject a follow-up message into a completed session.
1912
+
1913
+ This continues the conversation with context from the previous turn.
1914
+ Can only be used on completed (not running) sessions.
1915
+
1916
+ [bold]Examples:[/]
1917
+ $ zwarm session inject abc123 "Also add edge case tests"
1918
+ $ zwarm session inject abc123 "Good, now refactor the code"
1919
+ """
1920
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1921
+
1922
+ manager = CodexSessionManager(working_dir / ".zwarm")
1923
+ session = manager.get_session(session_id)
1924
+
1925
+ if not session:
1926
+ console.print(f"[red]Session not found:[/] {session_id}")
1927
+ raise typer.Exit(1)
1928
+
1929
+ if session.status == SessionStatus.RUNNING:
1930
+ console.print("[yellow]Session is still running.[/]")
1931
+ console.print("[dim]Wait for it to complete, then inject a follow-up.[/]")
1932
+ raise typer.Exit(1)
1933
+
1934
+ # Inject the message
1935
+ updated_session = manager.inject_message(session.id, message)
1936
+
1937
+ if not updated_session:
1938
+ console.print("[red]Failed to inject message.[/]")
1939
+ raise typer.Exit(1)
1940
+
1941
+ console.print()
1942
+ console.print(f"[green]✓ Message injected[/] Turn {updated_session.turn} started")
1943
+ console.print()
1944
+ console.print(f" [dim]Message:[/] {message[:70]}{'...' if len(message) > 70 else ''}")
1945
+ console.print(f" [dim]PID:[/] {updated_session.pid}")
1946
+ console.print()
1947
+ console.print("[dim]Commands:[/]")
1948
+ console.print(f" [cyan]zwarm session show {session.short_id}[/] [dim]View messages[/]")
1949
+ console.print(f" [cyan]zwarm session logs {session.short_id} -f[/] [dim]Follow live output[/]")
1950
+
1951
+
1952
+ @session_app.command("kill")
1953
+ def session_kill(
1954
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1955
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1956
+ ):
1957
+ """
1958
+ Kill a running session.
1959
+
1960
+ [bold]Examples:[/]
1961
+ $ zwarm session kill abc123
1962
+ """
1963
+ from zwarm.sessions import CodexSessionManager
1964
+
1965
+ manager = CodexSessionManager(working_dir / ".zwarm")
1966
+ session = manager.get_session(session_id)
1967
+
1968
+ if not session:
1969
+ console.print(f"[red]Session not found:[/] {session_id}")
1970
+ raise typer.Exit(1)
1971
+
1972
+ if not session.is_running:
1973
+ console.print(f"[yellow]Session {session.short_id} is not running.[/]")
1974
+ console.print(f" [dim]Status:[/] {session.status.value}")
1975
+ return
1976
+
1977
+ killed = manager.kill_session(session.id)
1978
+
1979
+ if killed:
1980
+ console.print(f"[green]Killed session {session.short_id}[/]")
1981
+ else:
1982
+ console.print(f"[red]Failed to kill session {session.short_id}[/]")
1983
+
1984
+
1985
+ @session_app.command("clean")
1986
+ def session_clean(
1987
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1988
+ keep_days: Annotated[int, typer.Option("--keep-days", "-k", help="Keep sessions newer than N days")] = 7,
1989
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
1990
+ ):
1991
+ """
1992
+ Remove old completed sessions.
1993
+
1994
+ [bold]Examples:[/]
1995
+ $ zwarm session clean
1996
+ $ zwarm session clean --keep-days 1
1997
+ """
1998
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1999
+
2000
+ manager = CodexSessionManager(working_dir / ".zwarm")
2001
+ sessions = manager.list_sessions()
2002
+
2003
+ # Count cleanable sessions
2004
+ cleanable = [s for s in sessions if s.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED)]
2005
+
2006
+ if not cleanable:
2007
+ console.print("[dim]No sessions to clean.[/]")
2008
+ return
2009
+
2010
+ console.print(f"Found {len(cleanable)} completed/failed sessions.")
2011
+
2012
+ if not yes:
2013
+ confirm = typer.confirm(f"Remove sessions older than {keep_days} days?")
2014
+ if not confirm:
2015
+ console.print("[dim]Cancelled.[/]")
2016
+ return
2017
+
2018
+ cleaned = manager.cleanup_completed(keep_days)
2019
+ console.print(f"[green]Cleaned {cleaned} sessions.[/]")
2020
+
2021
+
2022
+ # =============================================================================
2023
+ # Main callback and entry point
2024
+ # =============================================================================
2025
+
2026
+ def _get_version() -> str:
2027
+ """Get version from package metadata."""
2028
+ try:
2029
+ from importlib.metadata import version as get_pkg_version
2030
+ return get_pkg_version("zwarm")
2031
+ except Exception:
2032
+ return "0.0.0"
2033
+
2034
+
2035
+ @app.callback(invoke_without_command=True)
2036
+ def main_callback(
2037
+ ctx: typer.Context,
2038
+ version: Annotated[bool, typer.Option("--version", "-V", help="Show version")] = False,
2039
+ ):
2040
+ """Main callback for version flag."""
2041
+ if version:
2042
+ console.print(f"[bold cyan]zwarm[/] version [green]{_get_version()}[/]")
2043
+ raise typer.Exit()
2044
+
2045
+
2046
+ def main():
2047
+ """Entry point for the CLI."""
2048
+ app()
2049
+
2050
+
2051
+ if __name__ == "__main__":
2052
+ main()