zwarm 2.3.5__py3-none-any.whl → 3.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zwarm/cli/main.py CHANGED
@@ -79,6 +79,7 @@ app = typer.Typer(
79
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
+ [cyan]pilot[/] Conversational orchestrator REPL (interactive)
82
83
  [cyan]exec[/] Run a single executor directly (for testing)
83
84
  [cyan]status[/] Show current state (sessions, tasks, events)
84
85
  [cyan]history[/] Show event history log
@@ -121,16 +122,6 @@ Manage zwarm configurations.
121
122
  app.add_typer(configs_app, name="configs")
122
123
 
123
124
 
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
125
  @app.command()
135
126
  def orchestrate(
136
127
  task: Annotated[Optional[str], typer.Option("--task", "-t", help="The task to accomplish")] = None,
@@ -227,6 +218,26 @@ def orchestrate(
227
218
  if orchestrator.instance_id and not instance:
228
219
  console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
229
220
 
221
+ # Set up step callback for live progress display
222
+ def step_callback(step_num: int, tool_results: list) -> None:
223
+ """Print tool calls and results as they happen."""
224
+ if not tool_results:
225
+ return
226
+ for tool_info, result in tool_results:
227
+ name = tool_info.get("name", "?")
228
+ # Truncate args for display
229
+ args_str = str(tool_info.get("args", {}))
230
+ if len(args_str) > 80:
231
+ args_str = args_str[:77] + "..."
232
+ # Truncate result for display
233
+ result_str = str(result)
234
+ if len(result_str) > 100:
235
+ result_str = result_str[:97] + "..."
236
+ console.print(f"[dim]step {step_num}[/] → [cyan]{name}[/]({args_str})")
237
+ console.print(f" └ {result_str}")
238
+
239
+ orchestrator._step_callback = step_callback
240
+
230
241
  # Run the orchestrator loop
231
242
  console.print("[bold]--- Orchestrator running ---[/]\n")
232
243
  result = orchestrator.run(task=task)
@@ -274,81 +285,195 @@ def orchestrate(
274
285
  sys.exit(1)
275
286
 
276
287
 
288
+ class PilotLM(str, Enum):
289
+ """LM options for pilot mode."""
290
+ gpt5_mini = "gpt5-mini" # GPT5MiniTester - fast, cheap, good for testing
291
+ gpt5 = "gpt5" # GPT5Large - standard
292
+ gpt5_verbose = "gpt5-verbose" # GPT5LargeVerbose - with extended thinking
293
+
294
+
277
295
  @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,
296
+ def pilot(
297
+ task: Annotated[Optional[str], typer.Option("--task", "-t", help="Initial task (optional)")] = None,
298
+ task_file: Annotated[Optional[Path], typer.Option("--task-file", "-f", help="Read task from file")] = None,
299
+ config: Annotated[Optional[Path], typer.Option("--config", "-c", help="Path to config YAML")] = None,
300
+ overrides: Annotated[Optional[list[str]], typer.Option("--set", help="Override config (key=value)")] = None,
282
301
  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,
302
+ resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
303
+ instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Instance ID (for isolation/resume)")] = None,
304
+ instance_name: Annotated[Optional[str], typer.Option("--name", "-n", help="Human-readable instance name")] = None,
305
+ model: Annotated[PilotLM, typer.Option("--model", "-m", help="LM to use")] = PilotLM.gpt5_verbose,
284
306
  ):
285
307
  """
286
- Run a single executor directly (for testing).
308
+ Interactive conversational orchestrator REPL.
309
+
310
+ Like 'orchestrate' but conversational: give instructions, watch the
311
+ orchestrator work, course-correct in real-time, time-travel to checkpoints.
287
312
 
288
- Useful for testing adapters without the full orchestrator loop.
313
+ [bold]Features:[/]
314
+ - Streaming display of orchestrator thinking and tool calls
315
+ - Turn-by-turn execution with checkpoints
316
+ - Time travel (:goto T1) to return to previous states
317
+ - Session visibility (:sessions) and state inspection (:state)
318
+
319
+ [bold]Commands:[/]
320
+ :help Show help
321
+ :history [N|all] Show turn checkpoints
322
+ :goto <turn|root> Time travel (e.g., :goto T1)
323
+ :state Show orchestrator state
324
+ :sessions Show active executor sessions
325
+ :reasoning on|off Toggle reasoning display
326
+ :quit Exit
327
+
328
+ [bold]LM Options:[/]
329
+ gpt5-mini GPT5MiniTester - fast/cheap, good for testing
330
+ gpt5 GPT5Large - standard model
331
+ gpt5-verbose GPT5LargeVerbose - with extended thinking (default)
289
332
 
290
333
  [bold]Examples:[/]
291
- [dim]# Test Codex[/]
292
- $ zwarm exec --task "What is 2+2?"
334
+ [dim]# Start fresh, give instructions interactively[/]
335
+ $ zwarm pilot
336
+
337
+ [dim]# Start with an initial task[/]
338
+ $ zwarm pilot --task "Build user authentication"
293
339
 
294
- [dim]# Test Claude Code[/]
295
- $ zwarm exec -a claude_code --task "List files in current dir"
340
+ [dim]# Use faster model for testing[/]
341
+ $ zwarm pilot --model gpt5-mini
296
342
 
297
- [dim]# Async mode[/]
298
- $ zwarm exec --task "Build feature" --mode async
343
+ [dim]# Named instance[/]
344
+ $ zwarm pilot --name my-feature
345
+
346
+ [dim]# Resume a previous session[/]
347
+ $ zwarm pilot --resume --instance abc123
299
348
  """
300
- from zwarm.adapters import get_adapter
349
+ from zwarm.cli.pilot import run_pilot, build_pilot_orchestrator
301
350
 
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}")
351
+ # Resolve task (optional for pilot)
352
+ resolved_task = _resolve_task(task, task_file)
306
353
 
307
- # Use isolated codex config if available
308
- config_path = working_dir / ".zwarm" / "codex.toml"
309
- if not config_path.exists():
310
- config_path = None
354
+ # Validate resume requirements
355
+ if resume and not instance:
356
+ console.print("[red]Error:[/] --resume requires --instance to specify which session to resume")
357
+ console.print(" [dim]Use 'zwarm instances' to list available instances[/]")
358
+ raise typer.Exit(1)
311
359
 
360
+ console.print(f"[bold]{'Resuming' if resume else 'Starting'} pilot session...[/]")
361
+ console.print(f" Working dir: {working_dir.absolute()}")
362
+ console.print(f" Model: {model.value}")
363
+ if resolved_task:
364
+ console.print(f" Initial task: {resolved_task[:60]}...")
365
+ if instance:
366
+ console.print(f" Instance: {instance}" + (f" ({instance_name})" if instance_name else ""))
367
+ if resume:
368
+ console.print(f" [yellow]Resuming from saved state...[/]")
369
+ console.print()
370
+
371
+ orchestrator = None
312
372
  try:
313
- executor = get_adapter(adapter.value, model=model, config_path=config_path)
314
- except ValueError as e:
315
- console.print(f"[red]Error:[/] {e}")
373
+ orchestrator = build_pilot_orchestrator(
374
+ config_path=config,
375
+ working_dir=working_dir.absolute(),
376
+ overrides=list(overrides or []),
377
+ instance_id=instance,
378
+ instance_name=instance_name,
379
+ lm_choice=model.value,
380
+ )
381
+
382
+ # Show instance ID if auto-generated
383
+ if orchestrator.instance_id and not instance:
384
+ console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
385
+
386
+ # Resume from saved state if requested
387
+ if resume:
388
+ orchestrator.load_state()
389
+ msg_count = len(orchestrator.messages)
390
+ console.print(f" [green]✓[/] Resumed with {msg_count} messages")
391
+
392
+ # Run the pilot REPL
393
+ run_pilot(orchestrator, initial_task=resolved_task)
394
+
395
+ # Save state on exit
396
+ orchestrator.save_state()
397
+ console.print("\n[dim]State saved.[/]")
398
+
399
+ except KeyboardInterrupt:
400
+ console.print("\n\n[yellow]Interrupted.[/]")
401
+ if orchestrator:
402
+ orchestrator.save_state()
403
+ console.print("[dim]State saved.[/]")
404
+ sys.exit(1)
405
+ except Exception as e:
406
+ console.print(f"\n[red]Error:[/] {e}")
407
+ import traceback
408
+ traceback.print_exc()
316
409
  sys.exit(1)
317
410
 
318
- async def run():
319
- try:
320
- session = await executor.start_session(
321
- task=task,
322
- working_dir=working_dir.absolute(),
323
- mode=mode.value,
324
- model=model,
325
- )
326
411
 
327
- console.print(f"\n[green]Session started:[/] {session.id[:8]}")
412
+ @app.command()
413
+ def exec(
414
+ task: Annotated[str, typer.Option("--task", "-t", help="Task to execute")],
415
+ working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
416
+ model: Annotated[Optional[str], typer.Option("--model", help="Model override")] = None,
417
+ wait: Annotated[bool, typer.Option("--wait", help="Wait for completion and show result")] = False,
418
+ ):
419
+ """
420
+ Run a single Codex session directly (for testing).
421
+
422
+ Spawns a session using CodexSessionManager - same as interactive/pilot.
423
+ Web search is always enabled via .codex/config.toml (set up by `zwarm init`).
328
424
 
329
- if mode == ModeType.sync:
330
- response = session.messages[-1].content if session.messages else "(no response)"
331
- console.print(f"\n[bold]Response:[/]\n{response}")
425
+ [bold]Examples:[/]
426
+ [dim]# Quick test[/]
427
+ $ zwarm exec --task "What is 2+2?" --wait
332
428
 
333
- # Interactive loop for sync mode
334
- while True:
335
- try:
336
- user_input = console.input("\n[dim]> (type message or 'exit')[/] ")
337
- if user_input.lower() == "exit" or not user_input:
338
- break
429
+ [dim]# Run in background[/]
430
+ $ zwarm exec --task "Build feature"
339
431
 
340
- response = await executor.send_message(session, user_input)
341
- console.print(f"\n[bold]Response:[/]\n{response}")
342
- except KeyboardInterrupt:
343
- break
344
- else:
345
- console.print("[dim]Async mode - session running in background.[/]")
346
- console.print("Use 'zwarm status' to check progress.")
432
+ [dim]# Web search is always available[/]
433
+ $ zwarm exec --task "Find latest FastAPI docs" --wait
434
+ """
435
+ import time
436
+ from zwarm.sessions import CodexSessionManager, SessionStatus
347
437
 
348
- finally:
349
- await executor.cleanup()
438
+ console.print(f"[bold]Running Codex session...[/]")
439
+ console.print(f" Task: {task[:60]}{'...' if len(task) > 60 else ''}")
440
+ if model:
441
+ console.print(f" Model: {model}")
350
442
 
351
- asyncio.run(run())
443
+ manager = CodexSessionManager(working_dir / ".zwarm")
444
+ effective_model = model or "gpt-5.1-codex-mini"
445
+
446
+ session = manager.start_session(
447
+ task=task,
448
+ working_dir=working_dir.absolute(),
449
+ model=effective_model,
450
+ )
451
+
452
+ console.print(f"\n[green]Session started:[/] {session.short_id}")
453
+
454
+ if wait:
455
+ console.print("[dim]Waiting for completion...[/]")
456
+ while True:
457
+ time.sleep(2)
458
+ session = manager.get_session(session.id)
459
+ if session.status != SessionStatus.RUNNING:
460
+ break
461
+
462
+ if session.status == SessionStatus.COMPLETED:
463
+ console.print(f"\n[green]✓ Completed[/]")
464
+ # Show last assistant message
465
+ for msg in reversed(session.messages):
466
+ if msg.role == "assistant":
467
+ console.print(f"\n[bold]Response:[/]\n{msg.content}")
468
+ break
469
+ else:
470
+ console.print(f"\n[red]Status:[/] {session.status.value}")
471
+ if session.error:
472
+ console.print(f"[red]Error:[/] {session.error}")
473
+ else:
474
+ console.print("[dim]Running in background. Check with:[/]")
475
+ console.print(f" zwarm sessions")
476
+ console.print(f" zwarm session show {session.short_id}")
352
477
 
353
478
 
354
479
  @app.command()
@@ -633,8 +758,14 @@ def init(
633
758
  [bold]Creates:[/]
634
759
  [cyan].zwarm/[/] State directory for sessions and events
635
760
  [cyan].zwarm/config.toml[/] Runtime settings (weave, adapter, watchers)
761
+ [cyan].zwarm/codex.toml[/] Codex CLI settings (model, web search, etc.)
636
762
  [cyan]zwarm.yaml[/] Project config (optional, with --with-project)
637
763
 
764
+ [bold]Configuration relationship:[/]
765
+ config.toml → Controls zwarm itself (tracing, which watchers run)
766
+ codex.toml → Codex settings, parsed by zwarm and passed via -c overrides
767
+ zwarm.yaml → Project-specific context injected into orchestrator
768
+
638
769
  [bold]Examples:[/]
639
770
  [dim]# Interactive setup[/]
640
771
  $ zwarm init
@@ -681,6 +812,9 @@ def init(
681
812
  create_project_config = with_project
682
813
  project_description = ""
683
814
  project_context = ""
815
+ # Codex settings
816
+ codex_model = "gpt-5.1-codex-mini"
817
+ codex_reasoning = "high"
684
818
 
685
819
  if not non_interactive:
686
820
  console.print("[bold]Configuration[/]\n")
@@ -699,6 +833,42 @@ def init(
699
833
  type=str,
700
834
  )
701
835
 
836
+ # Codex model settings
837
+ console.print("\n [bold]Codex Model Settings[/] (.zwarm/codex.toml)")
838
+ console.print(" [dim]These control the underlying Codex CLI that runs executor sessions[/]\n")
839
+
840
+ console.print(" Available models:")
841
+ console.print(" [cyan]1[/] gpt-5.1-codex-mini [dim]- Fast, cheap, good for most tasks (Recommended)[/]")
842
+ console.print(" [cyan]2[/] gpt-5.1-codex [dim]- Balanced speed and capability[/]")
843
+ console.print(" [cyan]3[/] gpt-5.1-codex-max [dim]- Most capable, 400k context, expensive[/]")
844
+
845
+ model_choice = typer.prompt(
846
+ " Select model (1-3)",
847
+ default="1",
848
+ type=str,
849
+ )
850
+ model_map = {
851
+ "1": "gpt-5.1-codex-mini",
852
+ "2": "gpt-5.1-codex",
853
+ "3": "gpt-5.1-codex-max",
854
+ }
855
+ codex_model = model_map.get(model_choice, model_choice)
856
+ if model_choice not in model_map:
857
+ console.print(f" [dim]Using custom model: {codex_model}[/]")
858
+
859
+ console.print("\n Reasoning effort (how much the model \"thinks\" before responding):")
860
+ console.print(" [cyan]1[/] low [dim]- Minimal reasoning, fastest responses[/]")
861
+ console.print(" [cyan]2[/] medium [dim]- Balanced reasoning[/]")
862
+ console.print(" [cyan]3[/] high [dim]- Maximum reasoning, best for complex tasks (Recommended)[/]")
863
+
864
+ reasoning_choice = typer.prompt(
865
+ " Select reasoning effort (1-3)",
866
+ default="3",
867
+ type=str,
868
+ )
869
+ reasoning_map = {"1": "low", "2": "medium", "3": "high"}
870
+ codex_reasoning = reasoning_map.get(reasoning_choice, "high")
871
+
702
872
  # Watchers
703
873
  console.print("\n [bold]Watchers[/] (trajectory aligners)")
704
874
  available_watchers = ["progress", "budget", "delegation", "delegation_reminder", "scope", "pattern", "quality"]
@@ -750,10 +920,37 @@ def init(
750
920
 
751
921
  # Create codex.toml for isolated codex configuration
752
922
  codex_toml_path = state_dir / "codex.toml"
753
- if not codex_toml_path.exists():
754
- codex_content = _generate_codex_toml()
923
+ write_codex_toml = True
924
+ if codex_toml_path.exists():
925
+ if not non_interactive:
926
+ overwrite_codex = typer.confirm(" .zwarm/codex.toml exists. Overwrite?", default=False)
927
+ if not overwrite_codex:
928
+ write_codex_toml = False
929
+ console.print(" [dim]Skipping codex.toml[/]")
930
+ else:
931
+ write_codex_toml = False # Don't overwrite in non-interactive mode
932
+
933
+ if write_codex_toml:
934
+ codex_content = _generate_codex_toml(model=codex_model, reasoning_effort=codex_reasoning)
755
935
  codex_toml_path.write_text(codex_content)
756
- console.print(f" [green]✓[/] Created .zwarm/codex.toml (isolated codex config)")
936
+ console.print(f" [green]✓[/] Created .zwarm/codex.toml")
937
+
938
+ # Create claude.toml for isolated Claude Code configuration
939
+ claude_toml_path = state_dir / "claude.toml"
940
+ write_claude_toml = True
941
+ if claude_toml_path.exists():
942
+ if not non_interactive:
943
+ overwrite_claude = typer.confirm(" .zwarm/claude.toml exists. Overwrite?", default=False)
944
+ if not overwrite_claude:
945
+ write_claude_toml = False
946
+ console.print(" [dim]Skipping claude.toml[/]")
947
+ else:
948
+ write_claude_toml = False # Don't overwrite in non-interactive mode
949
+
950
+ if write_claude_toml:
951
+ claude_content = _generate_claude_toml(model="sonnet")
952
+ claude_toml_path.write_text(claude_content)
953
+ console.print(f" [green]✓[/] Created .zwarm/claude.toml")
757
954
 
758
955
  # Create zwarm.yaml
759
956
  if create_project_config:
@@ -773,11 +970,23 @@ def init(
773
970
 
774
971
  # Summary
775
972
  console.print("\n[bold green]Done![/] zwarm is ready.\n")
973
+
974
+ # Explain config files
975
+ console.print("[bold]Configuration files:[/]")
976
+ console.print(" [cyan].zwarm/config.toml[/] - Runtime settings (Weave tracing, watchers)")
977
+ console.print(" [cyan].zwarm/codex.toml[/] - Codex CLI settings (model, web search, sandbox)")
978
+ if create_project_config:
979
+ console.print(" [cyan]zwarm.yaml[/] - Project context and constraints")
980
+ console.print()
981
+ console.print(" [dim]Edit these files to customize behavior. Run 'zwarm init' again to reconfigure.[/]\n")
982
+
776
983
  console.print("[bold]Next steps:[/]")
777
984
  console.print(" [dim]# Run the orchestrator[/]")
778
985
  console.print(" $ zwarm orchestrate --task \"Your task here\"\n")
779
986
  console.print(" [dim]# Or test an executor directly[/]")
780
987
  console.print(" $ zwarm exec --task \"What is 2+2?\"\n")
988
+ console.print(" [dim]# Interactive session management[/]")
989
+ console.print(" $ zwarm interactive\n")
781
990
 
782
991
 
783
992
  def _generate_config_toml(
@@ -785,40 +994,87 @@ def _generate_config_toml(
785
994
  adapter: str = "codex_mcp",
786
995
  watchers: list[str] | None = None,
787
996
  ) -> str:
788
- """Generate config.toml content."""
997
+ """Generate config.toml content with all options at their defaults."""
789
998
  watchers = watchers or []
790
999
 
791
1000
  lines = [
792
1001
  "# zwarm configuration",
793
1002
  "# Generated by 'zwarm init'",
1003
+ "# All values shown are defaults - uncomment and modify as needed",
794
1004
  "",
1005
+ "# ============================================================================",
1006
+ "# Weave Integration (optional tracing/observability)",
1007
+ "# ============================================================================",
795
1008
  "[weave]",
796
1009
  ]
797
1010
 
798
1011
  if weave_project:
799
1012
  lines.append(f'project = "{weave_project}"')
800
1013
  else:
801
- lines.append("# project = \"your-entity/your-project\" # Uncomment to enable Weave tracing")
1014
+ lines.append('# project = "your-entity/your-project" # Uncomment to enable Weave tracing')
802
1015
 
803
1016
  lines.extend([
1017
+ "enabled = true",
804
1018
  "",
1019
+ "# ============================================================================",
1020
+ "# Orchestrator Settings",
1021
+ "# ============================================================================",
805
1022
  "[orchestrator]",
806
- "max_steps = 50",
1023
+ '# lm = "gpt-5-mini" # LLM for orchestrator (gpt-5-mini, gpt-5, claude-sonnet-4)',
1024
+ "max_steps = 50 # Max steps for orchestrate command",
1025
+ "max_steps_per_turn = 60 # Max steps per turn in pilot mode",
1026
+ "parallel_delegations = 4 # Max concurrent delegations",
1027
+ '# prompt = "path/to/prompt.yaml" # Custom prompt file (optional)',
1028
+ '# allowed_dirs = ["*"] # Directories agent can delegate to (default: working_dir only)',
1029
+ "",
1030
+ "# Context window compaction (prevents overflow on long tasks)",
1031
+ "[orchestrator.compaction]",
1032
+ "enabled = true",
1033
+ "max_tokens = 100000 # Trigger compaction above this",
1034
+ "threshold_pct = 0.85 # Compact when at this % of max_tokens",
1035
+ "target_pct = 0.7 # Target this % after compaction",
1036
+ "keep_first_n = 2 # Always keep first N messages (system + task)",
1037
+ "keep_last_n = 10 # Always keep last N messages (recent context)",
807
1038
  "",
1039
+ "# ============================================================================",
1040
+ "# Executor Settings (codex agent configuration)",
1041
+ "# ============================================================================",
808
1042
  "[executor]",
809
- f'adapter = "{adapter}"',
810
- "# model = \"\" # Optional model override",
1043
+ f'adapter = "{adapter}" # codex_mcp | codex_exec | claude_code',
1044
+ '# model = "gpt-5.1-codex-mini" # Model for delegated sessions (uses codex.toml default if not set)',
1045
+ 'sandbox = "workspace-write" # read-only | workspace-write | danger-full-access',
1046
+ "timeout = 3600 # Session timeout in seconds",
1047
+ 'reasoning_effort = "high" # low | medium | high',
811
1048
  "",
1049
+ "# ============================================================================",
1050
+ "# Watchers (automated monitoring and nudges)",
1051
+ "# ============================================================================",
812
1052
  "[watchers]",
813
- f"enabled = {watchers}",
1053
+ f"enabled = {str(bool(watchers)).lower()}",
1054
+ 'message_role = "user" # Role for nudge messages: user | assistant | system',
1055
+ "",
1056
+ "# Default watchers: progress, budget, delegation_reminder",
1057
+ "# Uncomment below to customize:",
814
1058
  "",
815
- "# Watcher-specific configuration",
816
- "# [watchers.budget]",
817
- "# max_steps = 50",
1059
+ "# [[watchers.watchers]]",
1060
+ '# name = "progress"',
1061
+ "# enabled = true",
1062
+ "",
1063
+ "# [[watchers.watchers]]",
1064
+ '# name = "budget"',
1065
+ "# enabled = true",
1066
+ "# [watchers.watchers.config]",
1067
+ "# max_sessions = 10",
818
1068
  "# warn_at_percent = 80",
819
1069
  "",
820
- "# [watchers.pattern]",
821
- "# patterns = [\"DROP TABLE\", \"rm -rf\"]",
1070
+ "# [[watchers.watchers]]",
1071
+ '# name = "delegation_reminder"',
1072
+ "# enabled = true",
1073
+ "",
1074
+ "# ============================================================================",
1075
+ "# State Directory",
1076
+ "# ============================================================================",
1077
+ '# state_dir = ".zwarm" # Where to store session data',
822
1078
  "",
823
1079
  ])
824
1080
 
@@ -832,28 +1088,73 @@ def _generate_codex_toml(
832
1088
  """
833
1089
  Generate codex.toml for isolated codex configuration.
834
1090
 
835
- This file is used by zwarm instead of ~/.codex/config.toml to ensure
836
- consistent behavior across different environments.
1091
+ This file is parsed by zwarm and settings are passed to codex via -c overrides.
1092
+ Each .zwarm directory has its own codex config, independent of ~/.codex/config.toml.
837
1093
  """
838
1094
  lines = [
839
1095
  "# Codex configuration for zwarm",
840
- "# This file isolates zwarm's codex settings from your global ~/.codex/config.toml",
1096
+ "# zwarm parses this file and passes settings to codex via -c overrides",
1097
+ "# Each .zwarm dir has its own config, independent of ~/.codex/config.toml",
841
1098
  "# Generated by 'zwarm init'",
842
1099
  "",
843
1100
  "# Model settings",
844
1101
  f'model = "{model}"',
845
1102
  f'model_reasoning_effort = "{reasoning_effort}" # low | medium | high',
846
1103
  "",
847
- "# Approval settings - zwarm manages these automatically",
848
- "# disable_response_storage = false",
1104
+ "# DANGER MODE - bypasses all safety controls",
1105
+ "# Set to true to use --dangerously-bypass-approvals-and-sandbox",
1106
+ "full_danger = true",
849
1107
  "",
850
- "# You can override any codex setting here",
1108
+ "# Web search - enables web_search tool for agents",
1109
+ "[features]",
1110
+ "web_search_request = true",
1111
+ "",
1112
+ "# Sandbox settings - network access required for web search",
1113
+ "[sandbox_workspace_write]",
1114
+ "network_access = true",
1115
+ "",
1116
+ "# Approval policy - 'never' means no human approval needed",
1117
+ "# approval_policy = \"never\"",
1118
+ "",
1119
+ "# You can add any codex config key here",
851
1120
  "# See: https://github.com/openai/codex#configuration",
852
1121
  "",
853
1122
  ]
854
1123
  return "\n".join(lines)
855
1124
 
856
1125
 
1126
+ def _generate_claude_toml(
1127
+ model: str = "sonnet",
1128
+ ) -> str:
1129
+ """
1130
+ Generate claude.toml for isolated Claude Code configuration.
1131
+
1132
+ This file is parsed by zwarm and settings are passed to claude via CLI flags.
1133
+ Each .zwarm directory has its own claude config.
1134
+ """
1135
+ lines = [
1136
+ "# Claude Code configuration for zwarm",
1137
+ "# zwarm parses this file and passes settings to claude via CLI flags",
1138
+ "# Each .zwarm dir has its own config",
1139
+ "# Generated by 'zwarm init'",
1140
+ "",
1141
+ "# Model settings",
1142
+ f'model = "{model}" # sonnet | opus | haiku',
1143
+ "",
1144
+ "# DANGER MODE - bypasses all permission checks",
1145
+ "# Set to true to use --dangerously-skip-permissions",
1146
+ "full_danger = true",
1147
+ "",
1148
+ "# Note: Claude Code uses different CLI flags than Codex",
1149
+ "# Common options:",
1150
+ "# --model <model> Model to use (sonnet, opus, haiku)",
1151
+ "# --add-dir <path> Additional directories to allow",
1152
+ "# --allowed-tools <tools> Restrict available tools",
1153
+ "",
1154
+ ]
1155
+ return "\n".join(lines)
1156
+
1157
+
857
1158
  def _generate_zwarm_yaml(
858
1159
  description: str = "",
859
1160
  context: str = "",
@@ -1133,897 +1434,183 @@ def clean(
1133
1434
  def interactive(
1134
1435
  default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
1135
1436
  model: Annotated[Optional[str], typer.Option("--model", help="Default model override")] = None,
1136
- adapter: Annotated[str, typer.Option("--adapter", "-a", help="Executor adapter")] = "codex_mcp",
1137
- state_dir: Annotated[Path, typer.Option("--state-dir", help="State directory for persistence")] = Path(".zwarm"),
1138
1437
  ):
1139
1438
  """
1140
- Universal multi-agent CLI for commanding coding agents.
1141
-
1142
- Spawn multiple agents across different directories, manage them interactively,
1143
- and view their outputs. You are the orchestrator.
1439
+ Interactive REPL for session management.
1144
1440
 
1145
- This uses the SAME code path as `zwarm orchestrate` - the adapter layer.
1146
- Interactive is the human-in-the-loop version, orchestrate is the LLM version.
1441
+ A clean, autocomplete-enabled interface for managing codex sessions.
1442
+ Tab-complete session IDs, watch live output, and manage the session lifecycle.
1147
1443
 
1148
1444
  [bold]Commands:[/]
1149
- [cyan]spawn[/] "task" [opts] Start a coding agent session (sync mode)
1150
- [cyan]async[/] "task" [opts] Start async session (fire-and-forget)
1151
- [cyan]ls[/] / [cyan]list[/] Dashboard of all sessions
1152
- [cyan]?[/] ID Quick peek (status + latest message)
1153
- [cyan]show[/] ID Full session details & history
1154
- [cyan]traj[/] ID Show trajectory (all steps taken)
1155
- [cyan]c[/] / [cyan]continue[/] ID "msg" Continue a sync conversation
1156
- [cyan]kill[/] ID Stop a session (keeps in history)
1157
- [cyan]rm[/] ID Delete session entirely
1158
- [cyan]killall[/] Stop all running sessions
1159
- [cyan]clean[/] Remove old sessions (>7 days)
1160
- [cyan]q[/] / [cyan]quit[/] Exit
1161
-
1162
- [bold]Spawn Options:[/]
1163
- spawn "task" --dir ~/project --model gpt-5.1-codex-max --async
1445
+ [cyan]spawn[/] "task" Start a new session
1446
+ [cyan]ls[/] Dashboard of all sessions
1447
+ [cyan]peek[/] ID / [cyan]?[/] ID Quick status check
1448
+ [cyan]show[/] ID Full session details
1449
+ [cyan]traj[/] ID Show trajectory (steps taken)
1450
+ [cyan]watch[/] ID Live follow session output
1451
+ [cyan]c[/] ID "msg" Continue conversation
1452
+ [cyan]kill[/] ID | all Stop session(s)
1453
+ [cyan]rm[/] ID | all Delete session(s)
1164
1454
 
1165
1455
  [bold]Examples:[/]
1166
1456
  $ zwarm interactive
1167
- > spawn "Build auth module" --dir ~/api
1168
- > spawn "Fix tests" --dir ~/api
1169
- > c abc123 "Now add error handling"
1457
+ > spawn "Build auth module"
1458
+ > watch abc123
1459
+ > c abc123 "Now add tests"
1170
1460
  > ls
1171
- > ? abc123
1172
1461
  """
1173
- from zwarm.adapters import get_adapter, list_adapters
1174
- from zwarm.core.models import ConversationSession, SessionStatus, SessionMode
1175
- import argparse
1462
+ from zwarm.cli.interactive import run_interactive
1176
1463
 
1177
- # Initialize adapter (same as orchestrator uses)
1178
1464
  default_model = model or "gpt-5.1-codex-mini"
1179
- default_adapter = adapter
1180
-
1181
- # Config path for isolated codex configuration
1182
- codex_config_path = state_dir / "codex.toml"
1183
- if not codex_config_path.exists():
1184
- # Try relative to working dir
1185
- codex_config_path = default_dir / ".zwarm" / "codex.toml"
1186
- if not codex_config_path.exists():
1187
- codex_config_path = None # Fall back to overrides
1188
-
1189
- # Session tracking - same pattern as orchestrator
1190
- sessions: dict[str, ConversationSession] = {}
1191
- adapters_cache: dict[str, Any] = {}
1192
-
1193
- def get_or_create_adapter(adapter_name: str, session_model: str | None = None) -> Any:
1194
- """Get or create an adapter instance with isolated config."""
1195
- cache_key = f"{adapter_name}:{session_model or default_model}"
1196
- if cache_key not in adapters_cache:
1197
- adapters_cache[cache_key] = get_adapter(
1198
- adapter_name,
1199
- model=session_model or default_model,
1200
- config_path=codex_config_path,
1201
- )
1202
- return adapters_cache[cache_key]
1203
-
1204
- def find_session(query: str) -> tuple[ConversationSession | None, str | None]:
1205
- """Find session by full or partial ID."""
1206
- if query in sessions:
1207
- return sessions[query], query
1208
- for sid, session in sessions.items():
1209
- if sid.startswith(query):
1210
- return session, sid
1211
- return None, None
1212
-
1213
- console.print("\n[bold cyan]zwarm interactive[/] - Multi-Agent Command Center\n")
1214
- console.print(f" Working dir: {default_dir.absolute()}")
1215
- console.print(f" Model: [cyan]{default_model}[/]")
1216
- console.print(f" Adapter: [cyan]{default_adapter}[/]")
1217
- if codex_config_path:
1218
- console.print(f" Config: [green]{codex_config_path}[/] (isolated)")
1219
- else:
1220
- console.print(f" Config: [yellow]using fallback overrides[/] (run 'zwarm init' for isolation)")
1221
- console.print(f" Available: {', '.join(list_adapters())}")
1222
- console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
1223
-
1224
- def show_help():
1225
- help_table = Table(show_header=False, box=None, padding=(0, 2))
1226
- help_table.add_column("Command", style="cyan", width=35)
1227
- help_table.add_column("Description")
1228
- help_table.add_row('spawn "task" [options]', "Start session (waits for completion)")
1229
- help_table.add_row(" --dir PATH", "Working directory")
1230
- help_table.add_row(" --model NAME", "Model override")
1231
- help_table.add_row(" --async", "Background mode (don't wait)")
1232
- help_table.add_row("", "")
1233
- help_table.add_row("ls / list", "Dashboard of all sessions")
1234
- help_table.add_row("? ID / peek ID", "Quick peek (status + latest message)")
1235
- help_table.add_row("show ID", "Full session details & messages")
1236
- help_table.add_row("traj ID [--full]", "Show trajectory (all steps taken)")
1237
- help_table.add_row('c ID "msg"', "Continue conversation (wait for response)")
1238
- help_table.add_row('ca ID "msg"', "Continue async (fire-and-forget)")
1239
- help_table.add_row("check ID", "Check session status")
1240
- help_table.add_row("kill ID", "Stop a running session")
1241
- help_table.add_row("rm ID", "Delete session entirely")
1242
- help_table.add_row("killall", "Stop all running sessions")
1243
- help_table.add_row("clean", "Remove old completed sessions")
1244
- help_table.add_row("q / quit", "Exit")
1245
- console.print(help_table)
1246
-
1247
- def show_sessions():
1248
- """List all sessions from CodexSessionManager (same as orchestrator)."""
1249
- from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1250
- from datetime import datetime
1251
-
1252
- manager = CodexSessionManager(default_dir / ".zwarm")
1253
- all_sessions = manager.list_sessions()
1254
-
1255
- if not all_sessions:
1256
- console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
1257
- return
1258
-
1259
- # Status summary
1260
- running = sum(1 for s in all_sessions if s.status == SessStatus.RUNNING)
1261
- completed = sum(1 for s in all_sessions if s.status == SessStatus.COMPLETED)
1262
- failed = sum(1 for s in all_sessions if s.status == SessStatus.FAILED)
1263
- killed = sum(1 for s in all_sessions if s.status == SessStatus.KILLED)
1264
-
1265
- summary_parts = []
1266
- if running:
1267
- summary_parts.append(f"[yellow]{running} running[/]")
1268
- if completed:
1269
- summary_parts.append(f"[green]{completed} done[/]")
1270
- if failed:
1271
- summary_parts.append(f"[red]{failed} failed[/]")
1272
- if killed:
1273
- summary_parts.append(f"[dim]{killed} killed[/]")
1274
- if summary_parts:
1275
- console.print(" | ".join(summary_parts))
1276
- console.print()
1277
-
1278
- def time_ago(iso_str: str) -> str:
1279
- """Convert ISO timestamp to human-readable 'X ago' format."""
1280
- try:
1281
- dt = datetime.fromisoformat(iso_str)
1282
- delta = datetime.now() - dt
1283
- secs = delta.total_seconds()
1284
- if secs < 60:
1285
- return f"{int(secs)}s"
1286
- elif secs < 3600:
1287
- return f"{int(secs/60)}m"
1288
- elif secs < 86400:
1289
- return f"{secs/3600:.1f}h"
1290
- else:
1291
- return f"{secs/86400:.1f}d"
1292
- except:
1293
- return "?"
1294
-
1295
- table = Table(box=None, show_header=True, header_style="bold dim")
1296
- table.add_column("ID", style="cyan", width=10)
1297
- table.add_column("", width=2) # Status icon
1298
- table.add_column("T", width=2) # Turn
1299
- table.add_column("Task", max_width=30)
1300
- table.add_column("Updated", justify="right", width=8)
1301
- table.add_column("Last Message", max_width=40)
1302
-
1303
- status_icons = {
1304
- "running": "[yellow]●[/]",
1305
- "completed": "[green]✓[/]",
1306
- "failed": "[red]✗[/]",
1307
- "killed": "[dim]○[/]",
1308
- "pending": "[dim]◌[/]",
1309
- }
1310
-
1311
- for s in all_sessions:
1312
- icon = status_icons.get(s.status.value, "?")
1313
- task_preview = s.task[:27] + "..." if len(s.task) > 30 else s.task
1314
- updated = time_ago(s.updated_at)
1315
-
1316
- # Get last assistant message preview
1317
- messages = manager.get_messages(s.id)
1318
- last_msg = ""
1319
- for msg in reversed(messages):
1320
- if msg.role == "assistant":
1321
- last_msg = msg.content.replace("\n", " ")[:37]
1322
- if len(msg.content) > 37:
1323
- last_msg += "..."
1324
- break
1325
-
1326
- # Style the last message based on recency
1327
- if s.status == SessStatus.RUNNING:
1328
- last_msg_styled = f"[yellow]{last_msg or '(working...)'}[/]"
1329
- updated_styled = f"[yellow]{updated}[/]"
1330
- elif s.status == SessStatus.COMPLETED:
1331
- # Highlight if recently completed (< 60s)
1332
- try:
1333
- dt = datetime.fromisoformat(s.updated_at)
1334
- is_recent = (datetime.now() - dt).total_seconds() < 60
1335
- except:
1336
- is_recent = False
1337
- if is_recent:
1338
- last_msg_styled = f"[green bold]{last_msg or '(done)'}[/]"
1339
- updated_styled = f"[green bold]{updated} ★[/]"
1340
- else:
1341
- last_msg_styled = f"[green]{last_msg or '(done)'}[/]"
1342
- updated_styled = f"[dim]{updated}[/]"
1343
- elif s.status == SessStatus.FAILED:
1344
- last_msg_styled = f"[red]{s.error[:37] if s.error else '(failed)'}...[/]"
1345
- updated_styled = f"[red]{updated}[/]"
1346
- else:
1347
- last_msg_styled = f"[dim]{last_msg or '-'}[/]"
1348
- updated_styled = f"[dim]{updated}[/]"
1349
-
1350
- table.add_row(
1351
- s.short_id,
1352
- icon,
1353
- str(s.turn),
1354
- task_preview,
1355
- updated_styled,
1356
- last_msg_styled,
1357
- )
1358
-
1359
- console.print(table)
1360
-
1361
- def parse_spawn_args(args: list[str]) -> dict:
1362
- """Parse spawn command arguments."""
1363
- parser = argparse.ArgumentParser(add_help=False)
1364
- parser.add_argument("task", nargs="*")
1365
- parser.add_argument("--dir", "-d", type=Path, default=None)
1366
- parser.add_argument("--model", "-m", default=None)
1367
- parser.add_argument("--async", dest="async_mode", action="store_true", default=False)
1465
+ run_interactive(working_dir=default_dir.absolute(), model=default_model)
1368
1466
 
1369
- try:
1370
- parsed, _ = parser.parse_known_args(args)
1371
- return {
1372
- "task": " ".join(parsed.task) if parsed.task else "",
1373
- "dir": parsed.dir,
1374
- "model": parsed.model,
1375
- "async_mode": parsed.async_mode,
1376
- }
1377
- except SystemExit:
1378
- return {"error": "Invalid spawn arguments"}
1379
-
1380
- def do_spawn(args: list[str]):
1381
- """Spawn a new coding agent session using CodexSessionManager (same as orchestrator)."""
1382
- from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1383
- import time
1384
-
1385
- parsed = parse_spawn_args(args)
1386
-
1387
- if "error" in parsed:
1388
- console.print(f" [red]{parsed['error']}[/]")
1389
- console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME] [--async][/]")
1390
- return
1391
-
1392
- if not parsed["task"]:
1393
- console.print(" [red]Task required[/]")
1394
- console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME] [--async][/]")
1395
- return
1396
-
1397
- task = parsed["task"]
1398
- work_dir = (parsed["dir"] or default_dir).absolute()
1399
- session_model = parsed["model"] or default_model
1400
- is_async = parsed.get("async_mode", False)
1401
-
1402
- if not work_dir.exists():
1403
- console.print(f" [red]Directory not found:[/] {work_dir}")
1404
- return
1405
-
1406
- mode_str = "async" if is_async else "sync"
1407
- console.print(f"\n[dim]Spawning {mode_str} session...[/]")
1408
- console.print(f" [dim]Dir: {work_dir}[/]")
1409
- console.print(f" [dim]Model: {session_model}[/]")
1410
1467
 
1411
- try:
1412
- # Use CodexSessionManager - SAME as orchestrator's delegate()
1413
- manager = CodexSessionManager(work_dir / ".zwarm")
1414
- session = manager.start_session(
1415
- task=task,
1416
- working_dir=work_dir,
1417
- model=session_model,
1418
- sandbox="workspace-write",
1419
- source="user",
1420
- adapter="codex",
1421
- )
1422
-
1423
- console.print(f"\n[green]✓[/] Session: [cyan]{session.short_id}[/]")
1424
- console.print(f" [dim]PID: {session.pid}[/]")
1425
-
1426
- # For sync mode, wait for completion and show response
1427
- if not is_async:
1428
- console.print(f"\n[dim]Waiting for completion...[/]")
1429
- timeout = 300.0
1430
- start = time.time()
1431
- while time.time() - start < timeout:
1432
- # get_session() auto-updates status based on output completion
1433
- session = manager.get_session(session.id)
1434
- if session.status != SessStatus.RUNNING:
1435
- break
1436
- time.sleep(1.0)
1437
-
1438
- # Get all assistant responses
1439
- messages = manager.get_messages(session.id)
1440
- assistant_msgs = [m for m in messages if m.role == "assistant"]
1441
- if assistant_msgs:
1442
- console.print(f"\n[bold]Response ({len(assistant_msgs)} message{'s' if len(assistant_msgs) > 1 else ''}):[/]")
1443
- for msg in assistant_msgs:
1444
- preview = msg.content[:300]
1445
- if len(msg.content) > 300:
1446
- preview += "..."
1447
- console.print(preview)
1448
- if len(assistant_msgs) > 1:
1449
- console.print() # Blank line between multiple messages
1450
-
1451
- console.print(f"\n[dim]Use 'show {session.short_id}' to see full details[/]")
1452
- console.print(f"[dim]Use 'c {session.short_id} \"message\"' to continue[/]")
1453
-
1454
- except Exception as e:
1455
- console.print(f" [red]Error:[/] {e}")
1456
- import traceback
1457
- console.print(f" [dim]{traceback.format_exc()}[/]")
1458
-
1459
- def do_orchestrate(args: list[str]):
1460
- """Spawn an orchestrator agent that delegates to sub-sessions."""
1461
- parsed = parse_spawn_args(args)
1462
-
1463
- if "error" in parsed:
1464
- console.print(f" [red]{parsed['error']}[/]")
1465
- console.print(" [dim]Usage: orchestrate \"task\" [--dir PATH][/]")
1466
- return
1467
-
1468
- if not parsed["task"]:
1469
- console.print(" [red]Task required[/]")
1470
- console.print(" [dim]Usage: orchestrate \"task\" [--dir PATH][/]")
1471
- return
1472
-
1473
- task = parsed["task"]
1474
- work_dir = (parsed["dir"] or default_dir).absolute()
1475
-
1476
- if not work_dir.exists():
1477
- console.print(f" [red]Directory not found:[/] {work_dir}")
1478
- return
1479
-
1480
- console.print(f"\n[dim]Starting orchestrator...[/]")
1481
- console.print(f" [dim]Dir: {work_dir}[/]")
1482
- console.print(f" [dim]Task: {task[:60]}{'...' if len(task) > 60 else ''}[/]")
1468
+ @app.command()
1469
+ def sessions(
1470
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1471
+ clean: Annotated[bool, typer.Option("--clean", "-c", help="Delete all non-running sessions")] = False,
1472
+ kill_all: Annotated[bool, typer.Option("--kill-all", "-k", help="Kill all running sessions")] = False,
1473
+ rm: Annotated[Optional[str], typer.Option("--rm", help="Delete specific session ID")] = None,
1474
+ kill: Annotated[Optional[str], typer.Option("--kill", help="Kill specific session ID")] = None,
1475
+ ):
1476
+ """
1477
+ Quick session management.
1483
1478
 
1484
- import subprocess
1485
- from uuid import uuid4
1479
+ By default, lists all sessions. Use flags for quick actions:
1486
1480
 
1487
- # Generate instance ID for tracking
1488
- instance_id = str(uuid4())[:8]
1481
+ [bold]Examples:[/]
1482
+ [dim]# List all sessions[/]
1483
+ $ zwarm sessions
1489
1484
 
1490
- # Build command to run orchestrator in background
1491
- cmd = [
1492
- sys.executable, "-m", "zwarm.cli.main", "orchestrate",
1493
- "--task", task,
1494
- "--working-dir", str(work_dir),
1495
- "--instance", instance_id,
1496
- ]
1485
+ [dim]# Delete all completed/failed sessions[/]
1486
+ $ zwarm sessions --clean
1487
+ $ zwarm sessions -c
1497
1488
 
1498
- # Create log file for orchestrator output
1499
- log_dir = state_dir / "orchestrators"
1500
- log_dir.mkdir(parents=True, exist_ok=True)
1501
- log_file = log_dir / f"{instance_id}.log"
1489
+ [dim]# Kill all running sessions[/]
1490
+ $ zwarm sessions --kill-all
1491
+ $ zwarm sessions -k
1502
1492
 
1503
- try:
1504
- with open(log_file, "w") as f:
1505
- proc = subprocess.Popen(
1506
- cmd,
1507
- cwd=work_dir,
1508
- stdout=f,
1509
- stderr=subprocess.STDOUT,
1510
- start_new_session=True,
1511
- )
1493
+ [dim]# Delete a specific session[/]
1494
+ $ zwarm sessions --rm abc123
1512
1495
 
1513
- console.print(f"\n[green]✓[/] Orchestrator started: [magenta]{instance_id}[/]")
1514
- console.print(f" PID: {proc.pid}")
1515
- console.print(f" Log: {log_file}")
1516
- console.print(f"\n[dim]Delegated sessions will appear with source 'orch:{instance_id[:4]}'[/]")
1517
- console.print(f"[dim]Use 'ls' to monitor progress[/]")
1518
-
1519
- except Exception as e:
1520
- console.print(f" [red]Error starting orchestrator:[/] {e}")
1521
-
1522
- def do_peek(session_id: str):
1523
- """Quick peek at session - just status + latest message (for fast polling)."""
1524
- from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1496
+ [dim]# Kill a specific session[/]
1497
+ $ zwarm sessions --kill abc123
1498
+ """
1499
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1500
+ from zwarm.core.costs import estimate_session_cost, format_cost
1525
1501
 
1526
- manager = CodexSessionManager(default_dir / ".zwarm")
1527
- session = manager.get_session(session_id)
1502
+ manager = CodexSessionManager(working_dir / ".zwarm")
1528
1503
 
1504
+ # Handle --kill (specific session)
1505
+ if kill:
1506
+ session = manager.get_session(kill)
1529
1507
  if not session:
1530
- console.print(f" [red]Not found:[/] {session_id}")
1531
- return
1532
-
1533
- # Status icon
1534
- icon = {
1535
- "running": "[yellow]●[/]",
1536
- "completed": "[green]✓[/]",
1537
- "failed": "[red]✗[/]",
1538
- "killed": "[dim]○[/]",
1539
- }.get(session.status.value, "?")
1540
-
1541
- # Get latest assistant message
1542
- messages = manager.get_messages(session.id)
1543
- latest = None
1544
- for msg in reversed(messages):
1545
- if msg.role == "assistant":
1546
- latest = msg.content.replace("\n", " ")
1547
- break
1548
-
1549
- if session.status == SessStatus.RUNNING:
1550
- console.print(f"{icon} [cyan]{session.short_id}[/] [yellow](running...)[/]")
1551
- elif latest:
1552
- # Truncate for one-liner
1553
- preview = latest[:120] + "..." if len(latest) > 120 else latest
1554
- console.print(f"{icon} [cyan]{session.short_id}[/] {preview}")
1555
- elif session.status == SessStatus.FAILED:
1556
- error = (session.error or "unknown")[:80]
1557
- console.print(f"{icon} [cyan]{session.short_id}[/] [red]{error}[/]")
1508
+ console.print(f"[red]Session not found:[/] {kill}")
1509
+ raise typer.Exit(1)
1510
+ if manager.kill_session(session.id):
1511
+ console.print(f"[green]✓[/] Killed {session.short_id}")
1558
1512
  else:
1559
- console.print(f"{icon} [cyan]{session.short_id}[/] [dim](no response)[/]")
1560
-
1561
- def do_show(session_id: str):
1562
- """Show full session details and messages using CodexSessionManager."""
1563
- from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1564
-
1565
- manager = CodexSessionManager(default_dir / ".zwarm")
1566
- session = manager.get_session(session_id)
1567
-
1568
- if not session:
1569
- console.print(f" [red]Session not found:[/] {session_id}")
1570
- console.print(f" [dim]Use 'ls' to see available sessions[/]")
1571
- return
1572
-
1573
- # Status styling
1574
- status_display = {
1575
- "running": "[yellow]● running[/]",
1576
- "completed": "[green]✓ completed[/]",
1577
- "failed": "[red]✗ failed[/]",
1578
- "killed": "[dim]○ killed[/]",
1579
- "pending": "[dim]◌ pending[/]",
1580
- }.get(session.status.value, str(session.status.value))
1581
-
1582
- console.print()
1583
- console.print(f"[bold cyan]Session {session.short_id}[/] {status_display}")
1584
- console.print(f"[dim]Adapter:[/] {session.adapter} [dim]│[/] [dim]Model:[/] {session.model}")
1585
- console.print(f"[dim]Task:[/] {session.task}")
1586
- console.print(f"[dim]Dir:[/] {session.working_dir} [dim]│[/] [dim]Turn:[/] {session.turn}")
1587
- console.print(f"[dim]Source:[/] {session.source_display} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
1588
- if session.pid:
1589
- console.print(f"[dim]PID:[/] {session.pid}")
1590
-
1591
- # Show log file path
1592
- log_path = default_dir / ".zwarm" / "sessions" / session.id / "turns" / f"turn_{session.turn}.jsonl"
1593
- console.print(f"[dim]Log:[/] {log_path}")
1594
- console.print()
1595
-
1596
- # Get messages from manager
1597
- messages = manager.get_messages(session.id)
1598
-
1599
- if not messages:
1600
- if session.is_running:
1601
- console.print("[yellow]Session is still running...[/]")
1602
- else:
1603
- console.print("[dim]No messages captured.[/]")
1604
- return
1605
-
1606
- # Display messages
1607
- for msg in messages:
1608
- if msg.role == "user":
1609
- console.print(Panel(msg.content, title="[bold blue]User[/]", border_style="blue"))
1610
- elif msg.role == "assistant":
1611
- content = msg.content
1612
- if len(content) > 2000:
1613
- content = content[:2000] + "\n\n[dim]... (truncated)[/]"
1614
- console.print(Panel(content, title="[bold green]Assistant[/]", border_style="green"))
1615
- elif msg.role == "tool":
1616
- console.print(f" [dim]Tool: {msg.content[:100]}[/]")
1617
-
1618
- console.print()
1619
- if session.token_usage:
1620
- tokens = session.token_usage
1621
- console.print(f"[dim]Tokens: {tokens.get('input_tokens', 0):,} in / {tokens.get('output_tokens', 0):,} out[/]")
1622
-
1623
- if session.error:
1624
- console.print(f"[red]Error:[/] {session.error}")
1625
-
1626
- def do_trajectory(session_id: str, full: bool = False):
1627
- """Show the full trajectory of a session - all steps in order."""
1628
- from zwarm.sessions import CodexSessionManager
1629
-
1630
- manager = CodexSessionManager(default_dir / ".zwarm")
1631
- session = manager.get_session(session_id)
1632
-
1633
- if not session:
1634
- console.print(f" [red]Session not found:[/] {session_id}")
1635
- return
1636
-
1637
- trajectory = manager.get_trajectory(session_id, full=full)
1638
-
1639
- if not trajectory:
1640
- console.print("[dim]No trajectory data available.[/]")
1641
- return
1642
-
1643
- mode = "[bold](full)[/] " if full else ""
1644
- console.print(f"\n[bold cyan]Trajectory: {session.short_id}[/] {mode}({len(trajectory)} steps)")
1645
- console.print(f"[dim]Task: {session.task[:60]}{'...' if len(session.task) > 60 else ''}[/]")
1646
- console.print()
1647
-
1648
- # Display each step
1649
- for step in trajectory:
1650
- turn = step.get("turn", 1)
1651
- step_num = step.get("step", 0)
1652
- step_type = step.get("type", "unknown")
1653
-
1654
- prefix = f"[dim]T{turn}.{step_num:02d}[/]"
1655
-
1656
- if step_type == "reasoning":
1657
- if full and step.get("full_text"):
1658
- console.print(f"{prefix} [yellow]thinking:[/]")
1659
- console.print(f" {step['full_text']}")
1660
- else:
1661
- summary = step.get("summary", "")
1662
- console.print(f"{prefix} [yellow]thinking:[/] {summary}")
1663
-
1664
- elif step_type == "command":
1665
- cmd = step.get("command", "")
1666
- output = step.get("output", "")
1667
- exit_code = step.get("exit_code", "?")
1668
- # Show command
1669
- console.print(f"{prefix} [cyan]$ {cmd}[/]")
1670
- if output:
1671
- if full:
1672
- # Show all output
1673
- for line in output.split("\n"):
1674
- console.print(f" [dim]{line}[/]")
1675
- else:
1676
- # Indent output, max 5 lines
1677
- for line in output.split("\n")[:5]:
1678
- console.print(f" [dim]{line}[/]")
1679
- if output.count("\n") > 5:
1680
- console.print(f" [dim]... ({output.count(chr(10))} lines)[/]")
1681
- if exit_code != 0 and exit_code is not None:
1682
- console.print(f" [red]exit: {exit_code}[/]")
1683
-
1684
- elif step_type == "tool_call":
1685
- tool = step.get("tool", "unknown")
1686
- if full and step.get("full_args"):
1687
- import json
1688
- console.print(f"{prefix} [magenta]tool:[/] {tool}")
1689
- console.print(f" {json.dumps(step['full_args'], indent=2)}")
1690
- else:
1691
- args = step.get("args_preview", "")
1692
- console.print(f"{prefix} [magenta]tool:[/] {tool}({args})")
1693
-
1694
- elif step_type == "tool_output":
1695
- output = step.get("output", "")
1696
- if not full:
1697
- output = output[:100]
1698
- console.print(f"{prefix} [dim]→ {output}[/]")
1699
-
1700
- elif step_type == "message":
1701
- if full and step.get("full_text"):
1702
- console.print(f"{prefix} [green]response:[/]")
1703
- console.print(f" {step['full_text']}")
1704
- else:
1705
- summary = step.get("summary", "")
1706
- full_len = step.get("full_length", 0)
1707
- console.print(f"{prefix} [green]response:[/] {summary}")
1708
- if full_len > 200:
1709
- console.print(f" [dim]({full_len} chars total)[/]")
1710
-
1711
- console.print()
1712
-
1713
- def do_continue(session_id: str, message: str, wait: bool = True):
1714
- """
1715
- Continue a conversation using CodexSessionManager.inject_message().
1716
-
1717
- Works for both sync and async sessions:
1718
- - If session was sync (or wait=True): wait for response
1719
- - If session was async (or wait=False): fire-and-forget, check later with '?'
1720
- """
1721
- from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1722
- import time
1723
-
1724
- manager = CodexSessionManager(default_dir / ".zwarm")
1725
- session = manager.get_session(session_id)
1726
-
1727
- if not session:
1728
- console.print(f" [red]Session not found:[/] {session_id}")
1729
- console.print(f" [dim]Use 'ls' to see available sessions[/]")
1730
- return
1731
-
1732
- if session.status == SessStatus.RUNNING:
1733
- console.print("[yellow]Session is still running.[/]")
1734
- console.print("[dim]Wait for it to complete, or use '?' to check progress.[/]")
1735
- return
1736
-
1737
- if session.status == SessStatus.KILLED:
1738
- console.print("[yellow]Session was killed.[/]")
1739
- console.print("[dim]Start a new session with 'spawn'.[/]")
1740
- return
1741
-
1742
- # Determine if we should wait based on session source
1743
- # Sessions from 'user' with --async flag have source='user'
1744
- # We'll use wait parameter to control this
1745
- should_wait = wait
1746
-
1747
- console.print(f"\n[dim]Sending message to {session.short_id}...[/]")
1748
-
1749
- try:
1750
- # Inject message - spawns new background process
1751
- updated_session = manager.inject_message(session.id, message)
1752
-
1753
- if not updated_session:
1754
- console.print(f" [red]Failed to inject message[/]")
1755
- return
1756
-
1757
- console.print(f"[green]✓[/] Message sent (turn {updated_session.turn})")
1758
-
1759
- if should_wait:
1760
- # Sync mode: wait for completion
1761
- console.print(f"[dim]Waiting for response...[/]")
1762
- timeout = 300.0
1763
- start = time.time()
1764
- while time.time() - start < timeout:
1765
- # get_session() auto-updates status based on output completion
1766
- session = manager.get_session(session.id)
1767
- if session.status != SessStatus.RUNNING:
1768
- break
1769
- time.sleep(1.0)
1770
-
1771
- # Get the response (last assistant message)
1772
- messages = manager.get_messages(session.id)
1773
- response = ""
1774
- for msg in reversed(messages):
1775
- if msg.role == "assistant":
1776
- response = msg.content
1777
- break
1778
-
1779
- console.print(f"\n[bold]Response:[/]")
1780
- if len(response) > 500:
1781
- console.print(response[:500] + "...")
1782
- console.print(f"\n[dim]Use 'show {session.short_id}' to see full response[/]")
1783
- else:
1784
- console.print(response or "(no response captured)")
1785
- else:
1786
- # Async mode: return immediately
1787
- console.print(f"[dim]Running in background (PID: {updated_session.pid})[/]")
1788
- console.print(f"[dim]Use '? {session.short_id}' to check progress[/]")
1789
-
1790
- except Exception as e:
1791
- console.print(f" [red]Error:[/] {e}")
1792
- import traceback
1793
- console.print(f" [dim]{traceback.format_exc()}[/]")
1794
-
1795
- def do_check(session_id: str):
1796
- """Check status of a session using CodexSessionManager (same as orchestrator)."""
1797
- from zwarm.sessions import CodexSessionManager
1798
-
1799
- manager = CodexSessionManager(default_dir / ".zwarm")
1800
- session = manager.get_session(session_id)
1801
-
1802
- if not session:
1803
- console.print(f" [red]Session not found:[/] {session_id}")
1804
- console.print(f" [dim]Use 'ls' to see available sessions[/]")
1805
- return
1806
-
1807
- status_icon = {
1808
- "running": "●",
1809
- "completed": "✓",
1810
- "failed": "✗",
1811
- "killed": "○",
1812
- "pending": "◌",
1813
- }.get(session.status.value, "?")
1814
-
1815
- console.print(f"\n[bold]Session {session.short_id}[/]: {status_icon} {session.status.value}")
1816
- console.print(f" [dim]Task: {session.task[:60]}{'...' if len(session.task) > 60 else ''}[/]")
1817
- console.print(f" [dim]Turn: {session.turn} | Runtime: {session.runtime}[/]")
1818
-
1819
- if session.status.value == "completed":
1820
- messages = manager.get_messages(session.id)
1821
- for msg in reversed(messages):
1822
- if msg.role == "assistant":
1823
- console.print(f"\n[bold]Response:[/]")
1824
- if len(msg.content) > 500:
1825
- console.print(msg.content[:500] + "...")
1826
- else:
1827
- console.print(msg.content)
1828
- break
1829
- elif session.status.value == "failed":
1830
- console.print(f"[red]Error:[/] {session.error or 'Unknown error'}")
1831
- elif session.status.value == "running":
1832
- console.print("[dim]Session is still running...[/]")
1833
- console.print(f" [dim]PID: {session.pid}[/]")
1834
-
1835
- def do_kill(session_id: str):
1836
- """Kill a running session using CodexSessionManager (same as orchestrator)."""
1837
- from zwarm.sessions import CodexSessionManager
1838
-
1839
- manager = CodexSessionManager(default_dir / ".zwarm")
1840
- session = manager.get_session(session_id)
1513
+ console.print(f"[yellow]Session not running or already stopped[/]")
1514
+ return
1841
1515
 
1516
+ # Handle --rm (specific session)
1517
+ if rm:
1518
+ session = manager.get_session(rm)
1842
1519
  if not session:
1843
- console.print(f" [red]Session not found:[/] {session_id}")
1844
- return
1845
-
1846
- if not session.is_running:
1847
- console.print(f"[yellow]Session {session.short_id} is not running ({session.status.value}).[/]")
1848
- return
1849
-
1850
- try:
1851
- killed = manager.kill_session(session.id)
1852
- if killed:
1853
- console.print(f"[green]✓[/] Stopped session {session.short_id}")
1854
- else:
1855
- console.print(f"[red]Failed to stop session[/]")
1856
- except Exception as e:
1857
- console.print(f"[red]Failed to stop session:[/] {e}")
1858
-
1859
- def do_killall():
1860
- """Kill all running sessions using CodexSessionManager."""
1861
- from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1862
-
1863
- manager = CodexSessionManager(default_dir / ".zwarm")
1864
- running_sessions = manager.list_sessions(status=SessStatus.RUNNING)
1520
+ console.print(f"[red]Session not found:[/] {rm}")
1521
+ raise typer.Exit(1)
1522
+ if manager.delete_session(session.id):
1523
+ console.print(f"[green]✓[/] Deleted {session.short_id}")
1524
+ else:
1525
+ console.print(f"[red]Failed to delete[/]")
1526
+ return
1865
1527
 
1866
- if not running_sessions:
1867
- console.print("[dim]No running sessions to kill.[/]")
1528
+ # Handle --kill-all
1529
+ if kill_all:
1530
+ running = manager.list_sessions(status=SessionStatus.RUNNING)
1531
+ if not running:
1532
+ console.print("[dim]No running sessions[/]")
1868
1533
  return
1869
-
1870
1534
  killed = 0
1871
- for session in running_sessions:
1872
- try:
1873
- if manager.kill_session(session.id):
1874
- killed += 1
1875
- except Exception as e:
1876
- console.print(f"[red]Failed to stop {session.short_id}:[/] {e}")
1877
-
1878
- console.print(f"[green]✓[/] Killed {killed} sessions")
1879
-
1880
- def do_clean():
1881
- """Remove old completed/failed sessions from disk."""
1882
- from zwarm.sessions import CodexSessionManager
1883
-
1884
- manager = CodexSessionManager(default_dir / ".zwarm")
1885
- cleaned = manager.cleanup_completed(keep_days=7)
1886
- console.print(f"[green]✓[/] Cleaned {cleaned} old sessions")
1887
-
1888
- def do_delete(session_id: str):
1889
- """Delete a session entirely (removes from disk and ls)."""
1890
- from zwarm.sessions import CodexSessionManager
1891
-
1892
- manager = CodexSessionManager(default_dir / ".zwarm")
1893
- session = manager.get_session(session_id)
1535
+ for s in running:
1536
+ if manager.kill_session(s.id):
1537
+ console.print(f" [green]✓[/] Killed {s.short_id}")
1538
+ killed += 1
1539
+ console.print(f"\n[green]Killed {killed} session(s)[/]")
1540
+ return
1894
1541
 
1895
- if not session:
1896
- console.print(f" [red]Session not found:[/] {session_id}")
1542
+ # Handle --clean
1543
+ if clean:
1544
+ all_sessions = manager.list_sessions()
1545
+ to_delete = [s for s in all_sessions if s.status != SessionStatus.RUNNING]
1546
+ if not to_delete:
1547
+ console.print("[dim]Nothing to clean[/]")
1897
1548
  return
1549
+ deleted = 0
1550
+ for s in to_delete:
1551
+ if manager.delete_session(s.id):
1552
+ deleted += 1
1553
+ console.print(f"[green]✓[/] Deleted {deleted} session(s)")
1554
+ return
1898
1555
 
1899
- if manager.delete_session(session.id):
1900
- console.print(f"[green]✓[/] Deleted session {session.short_id}")
1901
- else:
1902
- console.print(f"[red]Failed to delete session[/]")
1903
-
1904
- # REPL loop
1905
- import shlex
1906
-
1907
- while True:
1908
- try:
1909
- raw_input = console.input("[bold cyan]>[/] ").strip()
1910
- if not raw_input:
1911
- continue
1912
-
1913
- # Parse command
1914
- try:
1915
- parts = shlex.split(raw_input)
1916
- except ValueError:
1917
- parts = raw_input.split()
1918
-
1919
- cmd = parts[0].lower()
1920
- args = parts[1:]
1921
-
1922
- if cmd in ("q", "quit", "exit"):
1923
- active = [s for s in sessions.values() if s.status == SessionStatus.ACTIVE]
1924
- if active:
1925
- console.print(f" [yellow]Warning:[/] {len(active)} sessions still active")
1926
- console.print(" [dim]Use 'killall' first, or they'll continue in background[/]")
1927
-
1928
- # Cleanup adapters
1929
- for adapter_instance in adapters_cache.values():
1930
- try:
1931
- asyncio.run(adapter_instance.cleanup())
1932
- except Exception:
1933
- pass
1934
-
1935
- console.print("\n[dim]Goodbye![/]\n")
1936
- break
1937
-
1938
- elif cmd in ("h", "help"):
1939
- show_help()
1940
-
1941
- elif cmd in ("ls", "list"):
1942
- show_sessions()
1943
-
1944
- elif cmd == "spawn":
1945
- do_spawn(args)
1946
-
1947
- elif cmd == "async":
1948
- # Async spawn shorthand
1949
- do_spawn(args + ["--async"])
1950
-
1951
- elif cmd == "orchestrate":
1952
- do_orchestrate(args)
1953
-
1954
- elif cmd in ("?", "peek"):
1955
- if not args:
1956
- console.print(" [red]Usage:[/] ? SESSION_ID")
1957
- else:
1958
- do_peek(args[0])
1959
-
1960
- elif cmd in ("show", "check"):
1961
- if not args:
1962
- console.print(" [red]Usage:[/] show SESSION_ID")
1963
- else:
1964
- do_show(args[0])
1965
-
1966
- elif cmd in ("traj", "trajectory"):
1967
- if not args:
1968
- console.print(" [red]Usage:[/] traj SESSION_ID [--full]")
1969
- else:
1970
- full_mode = "--full" in args
1971
- session_arg = [a for a in args if a != "--full"]
1972
- if session_arg:
1973
- do_trajectory(session_arg[0], full=full_mode)
1974
- else:
1975
- console.print(" [red]Usage:[/] traj SESSION_ID [--full]")
1976
-
1977
- elif cmd in ("c", "continue"):
1978
- # Sync continue - waits for response
1979
- if len(args) < 2:
1980
- console.print(" [red]Usage:[/] c SESSION_ID \"message\"")
1981
- else:
1982
- do_continue(args[0], " ".join(args[1:]), wait=True)
1556
+ # Default: list all sessions
1557
+ all_sessions = manager.list_sessions()
1983
1558
 
1984
- elif cmd in ("ca", "async"):
1985
- # Async continue - fire and forget
1986
- if len(args) < 2:
1987
- console.print(" [red]Usage:[/] ca SESSION_ID \"message\"")
1988
- console.print(" [dim]Sends message and returns immediately (check with '?')[/]")
1989
- else:
1990
- do_continue(args[0], " ".join(args[1:]), wait=False)
1559
+ if not all_sessions:
1560
+ console.print("[dim]No sessions found.[/]")
1561
+ console.print("[dim]Start one with:[/] zwarm session start \"your task\"")
1562
+ return
1991
1563
 
1992
- elif cmd == "check":
1993
- if not args:
1994
- console.print(" [red]Usage:[/] check SESSION_ID")
1995
- else:
1996
- do_check(args[0])
1564
+ # Summary counts
1565
+ running = sum(1 for s in all_sessions if s.status == SessionStatus.RUNNING)
1566
+ completed = sum(1 for s in all_sessions if s.status == SessionStatus.COMPLETED)
1567
+ failed = sum(1 for s in all_sessions if s.status == SessionStatus.FAILED)
1568
+ killed_count = sum(1 for s in all_sessions if s.status == SessionStatus.KILLED)
1569
+
1570
+ parts = []
1571
+ if running:
1572
+ parts.append(f"[yellow]⟳ {running} running[/]")
1573
+ if completed:
1574
+ parts.append(f"[green]✓ {completed} completed[/]")
1575
+ if failed:
1576
+ parts.append(f"[red]✗ {failed} failed[/]")
1577
+ if killed_count:
1578
+ parts.append(f"[dim]⊘ {killed_count} killed[/]")
1579
+
1580
+ console.print(" │ ".join(parts))
1581
+ console.print()
1997
1582
 
1998
- elif cmd == "kill":
1999
- if not args:
2000
- console.print(" [red]Usage:[/] kill SESSION_ID")
2001
- else:
2002
- do_kill(args[0])
1583
+ # Table
1584
+ table = Table(box=None, show_header=True, header_style="bold dim")
1585
+ table.add_column("ID", style="cyan")
1586
+ table.add_column("", width=2)
1587
+ table.add_column("Task", max_width=45)
1588
+ table.add_column("Tokens", justify="right", style="dim")
1589
+ table.add_column("Cost", justify="right", style="dim")
2003
1590
 
2004
- elif cmd == "killall":
2005
- do_killall()
1591
+ status_icons = {
1592
+ SessionStatus.RUNNING: "[yellow]⟳[/]",
1593
+ SessionStatus.COMPLETED: "[green]✓[/]",
1594
+ SessionStatus.FAILED: "[red]✗[/]",
1595
+ SessionStatus.KILLED: "[dim]⊘[/]",
1596
+ SessionStatus.PENDING: "[dim]○[/]",
1597
+ }
2006
1598
 
2007
- elif cmd == "clean":
2008
- do_clean()
1599
+ for session in all_sessions:
1600
+ icon = status_icons.get(session.status, "?")
1601
+ task = session.task[:42] + "..." if len(session.task) > 45 else session.task
1602
+ tokens = session.token_usage.get("total_tokens", 0)
1603
+ tokens_str = f"{tokens:,}" if tokens else "-"
2009
1604
 
2010
- elif cmd in ("rm", "delete", "remove"):
2011
- if not args:
2012
- console.print(" [red]Usage:[/] rm SESSION_ID")
2013
- else:
2014
- do_delete(args[0])
1605
+ # Cost estimate
1606
+ cost_info = estimate_session_cost(session.model, session.token_usage)
1607
+ cost_str = format_cost(cost_info.get("cost"))
2015
1608
 
2016
- else:
2017
- console.print(f" [yellow]Unknown command:[/] {cmd}")
2018
- console.print(" [dim]Type 'help' for available commands[/]")
1609
+ table.add_row(session.short_id, icon, task, tokens_str, cost_str)
2019
1610
 
2020
- except KeyboardInterrupt:
2021
- console.print("\n[dim](Ctrl+C to exit, or type 'quit')[/]")
2022
- except EOFError:
2023
- console.print("\n[dim]Goodbye![/]\n")
2024
- break
2025
- except Exception as e:
2026
- console.print(f" [red]Error:[/] {e}")
1611
+ console.print(table)
1612
+ console.print()
1613
+ console.print("[dim]Quick actions: --clean (-c) delete old │ --kill-all (-k) stop running[/]")
2027
1614
 
2028
1615
 
2029
1616
  # =============================================================================
@@ -2074,6 +1661,7 @@ def session_start(
2074
1661
  Start a new Codex session in the background.
2075
1662
 
2076
1663
  The session runs independently and you can check on it later.
1664
+ Web search is always enabled via .codex/config.toml (set up by `zwarm init`).
2077
1665
 
2078
1666
  [bold]Examples:[/]
2079
1667
  [dim]# Simple task[/]
@@ -2081,6 +1669,9 @@ def session_start(
2081
1669
 
2082
1670
  [dim]# With specific model[/]
2083
1671
  $ zwarm session start "Refactor the API" --model gpt-5.1-codex-max
1672
+
1673
+ [dim]# Web search is always available[/]
1674
+ $ zwarm session start "Research latest OAuth2 best practices"
2084
1675
  """
2085
1676
  from zwarm.sessions import CodexSessionManager
2086
1677