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/interactive.py +1065 -0
- zwarm/cli/main.py +525 -934
- zwarm/cli/pilot.py +1240 -0
- zwarm/core/__init__.py +20 -0
- zwarm/core/checkpoints.py +216 -0
- zwarm/core/config.py +26 -9
- zwarm/core/costs.py +71 -0
- zwarm/core/registry.py +329 -0
- zwarm/core/test_config.py +2 -3
- zwarm/orchestrator.py +17 -43
- zwarm/prompts/__init__.py +3 -0
- zwarm/prompts/orchestrator.py +36 -29
- zwarm/prompts/pilot.py +147 -0
- zwarm/sessions/__init__.py +48 -9
- zwarm/sessions/base.py +501 -0
- zwarm/sessions/claude.py +481 -0
- zwarm/sessions/manager.py +233 -486
- zwarm/tools/delegation.py +150 -187
- zwarm-3.6.0.dist-info/METADATA +445 -0
- zwarm-3.6.0.dist-info/RECORD +39 -0
- zwarm/adapters/__init__.py +0 -21
- zwarm/adapters/base.py +0 -109
- zwarm/adapters/claude_code.py +0 -357
- zwarm/adapters/codex_mcp.py +0 -1262
- zwarm/adapters/registry.py +0 -69
- zwarm/adapters/test_codex_mcp.py +0 -274
- zwarm/adapters/test_registry.py +0 -68
- zwarm-2.3.5.dist-info/METADATA +0 -309
- zwarm-2.3.5.dist-info/RECORD +0 -38
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/WHEEL +0 -0
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/entry_points.txt +0 -0
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
|
|
279
|
-
task: Annotated[str, typer.Option("--task", "-t", help="
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]#
|
|
292
|
-
$ zwarm
|
|
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]#
|
|
295
|
-
$ zwarm
|
|
340
|
+
[dim]# Use faster model for testing[/]
|
|
341
|
+
$ zwarm pilot --model gpt5-mini
|
|
296
342
|
|
|
297
|
-
[dim]#
|
|
298
|
-
$ zwarm
|
|
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.
|
|
349
|
+
from zwarm.cli.pilot import run_pilot, build_pilot_orchestrator
|
|
301
350
|
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
425
|
+
[bold]Examples:[/]
|
|
426
|
+
[dim]# Quick test[/]
|
|
427
|
+
$ zwarm exec --task "What is 2+2?" --wait
|
|
332
428
|
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"#
|
|
816
|
-
|
|
817
|
-
"#
|
|
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.
|
|
821
|
-
|
|
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
|
|
836
|
-
|
|
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
|
-
"#
|
|
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
|
-
"#
|
|
848
|
-
"#
|
|
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
|
-
"#
|
|
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
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
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"
|
|
1150
|
-
[cyan]
|
|
1151
|
-
[cyan]
|
|
1152
|
-
[cyan]
|
|
1153
|
-
[cyan]
|
|
1154
|
-
[cyan]
|
|
1155
|
-
[cyan]c[/]
|
|
1156
|
-
[cyan]kill[/] ID
|
|
1157
|
-
[cyan]rm[/] ID
|
|
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"
|
|
1168
|
-
>
|
|
1169
|
-
> c abc123 "Now add
|
|
1457
|
+
> spawn "Build auth module"
|
|
1458
|
+
> watch abc123
|
|
1459
|
+
> c abc123 "Now add tests"
|
|
1170
1460
|
> ls
|
|
1171
|
-
> ? abc123
|
|
1172
1461
|
"""
|
|
1173
|
-
from zwarm.
|
|
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
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
1485
|
-
from uuid import uuid4
|
|
1479
|
+
By default, lists all sessions. Use flags for quick actions:
|
|
1486
1480
|
|
|
1487
|
-
|
|
1488
|
-
|
|
1481
|
+
[bold]Examples:[/]
|
|
1482
|
+
[dim]# List all sessions[/]
|
|
1483
|
+
$ zwarm sessions
|
|
1489
1484
|
|
|
1490
|
-
#
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
#
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1504
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
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"
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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"
|
|
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"
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
1867
|
-
|
|
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
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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
|
-
|
|
1896
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
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
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
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
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
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
|
-
|
|
2008
|
-
|
|
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
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|