zwarm 2.3.5__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/__init__.py +38 -0
- zwarm/adapters/__init__.py +21 -0
- zwarm/adapters/base.py +109 -0
- zwarm/adapters/claude_code.py +357 -0
- zwarm/adapters/codex_mcp.py +1262 -0
- zwarm/adapters/registry.py +69 -0
- zwarm/adapters/test_codex_mcp.py +274 -0
- zwarm/adapters/test_registry.py +68 -0
- zwarm/cli/__init__.py +0 -0
- zwarm/cli/main.py +2503 -0
- zwarm/core/__init__.py +0 -0
- zwarm/core/compact.py +329 -0
- zwarm/core/config.py +344 -0
- zwarm/core/environment.py +173 -0
- zwarm/core/models.py +315 -0
- zwarm/core/state.py +355 -0
- zwarm/core/test_compact.py +312 -0
- zwarm/core/test_config.py +160 -0
- zwarm/core/test_models.py +265 -0
- zwarm/orchestrator.py +683 -0
- zwarm/prompts/__init__.py +10 -0
- zwarm/prompts/orchestrator.py +230 -0
- zwarm/sessions/__init__.py +26 -0
- zwarm/sessions/manager.py +792 -0
- zwarm/test_orchestrator_watchers.py +23 -0
- zwarm/tools/__init__.py +17 -0
- zwarm/tools/delegation.py +784 -0
- zwarm/watchers/__init__.py +31 -0
- zwarm/watchers/base.py +131 -0
- zwarm/watchers/builtin.py +518 -0
- zwarm/watchers/llm_watcher.py +319 -0
- zwarm/watchers/manager.py +181 -0
- zwarm/watchers/registry.py +57 -0
- zwarm/watchers/test_watchers.py +237 -0
- zwarm-2.3.5.dist-info/METADATA +309 -0
- zwarm-2.3.5.dist-info/RECORD +38 -0
- zwarm-2.3.5.dist-info/WHEEL +4 -0
- zwarm-2.3.5.dist-info/entry_points.txt +2 -0
zwarm/cli/main.py
ADDED
|
@@ -0,0 +1,2503 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for zwarm orchestration.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
- zwarm orchestrate: Start an orchestrator session
|
|
6
|
+
- zwarm exec: Run a single executor directly (for testing)
|
|
7
|
+
- zwarm status: Show current state
|
|
8
|
+
- zwarm history: Show event history
|
|
9
|
+
- zwarm configs: Manage configurations
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Annotated, Optional
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich import print as rprint
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
|
|
28
|
+
# Create console for rich output
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_task(task: str | None, task_file: Path | None) -> str | None:
|
|
33
|
+
"""
|
|
34
|
+
Resolve task from multiple sources (priority order):
|
|
35
|
+
1. --task flag
|
|
36
|
+
2. --task-file flag
|
|
37
|
+
3. stdin (if not a tty)
|
|
38
|
+
"""
|
|
39
|
+
# Direct task takes priority
|
|
40
|
+
if task:
|
|
41
|
+
return task
|
|
42
|
+
|
|
43
|
+
# Then file
|
|
44
|
+
if task_file:
|
|
45
|
+
if not task_file.exists():
|
|
46
|
+
console.print(f"[red]Error:[/] Task file not found: {task_file}")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
return task_file.read_text().strip()
|
|
49
|
+
|
|
50
|
+
# Finally stdin (only if piped, not interactive)
|
|
51
|
+
if not sys.stdin.isatty():
|
|
52
|
+
stdin_content = sys.stdin.read().strip()
|
|
53
|
+
if stdin_content:
|
|
54
|
+
return stdin_content
|
|
55
|
+
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
# Main app with rich help
|
|
59
|
+
app = typer.Typer(
|
|
60
|
+
name="zwarm",
|
|
61
|
+
help="""
|
|
62
|
+
[bold cyan]zwarm[/] - Multi-Agent CLI Orchestration Research Platform
|
|
63
|
+
|
|
64
|
+
[bold]DESCRIPTION[/]
|
|
65
|
+
Orchestrate multiple CLI coding agents (Codex, Claude Code) with
|
|
66
|
+
delegation, conversation, and trajectory alignment (watchers).
|
|
67
|
+
|
|
68
|
+
[bold]QUICK START[/]
|
|
69
|
+
[dim]# Initialize zwarm in your project[/]
|
|
70
|
+
$ zwarm init
|
|
71
|
+
|
|
72
|
+
[dim]# Run the orchestrator[/]
|
|
73
|
+
$ zwarm orchestrate --task "Build a hello world function"
|
|
74
|
+
|
|
75
|
+
[dim]# Check state after running[/]
|
|
76
|
+
$ zwarm status
|
|
77
|
+
|
|
78
|
+
[bold]COMMANDS[/]
|
|
79
|
+
[cyan]init[/] Initialize zwarm (creates .zwarm/ with config)
|
|
80
|
+
[cyan]reset[/] Reset state and optionally config files
|
|
81
|
+
[cyan]orchestrate[/] Start orchestrator to delegate tasks to executors
|
|
82
|
+
[cyan]exec[/] Run a single executor directly (for testing)
|
|
83
|
+
[cyan]status[/] Show current state (sessions, tasks, events)
|
|
84
|
+
[cyan]history[/] Show event history log
|
|
85
|
+
[cyan]configs[/] Manage configuration files
|
|
86
|
+
|
|
87
|
+
[bold]CONFIGURATION[/]
|
|
88
|
+
Config lives in [cyan].zwarm/config.toml[/] (created by init).
|
|
89
|
+
Use [cyan]--config[/] flag for YAML files.
|
|
90
|
+
See [cyan]zwarm configs list[/] for available configurations.
|
|
91
|
+
|
|
92
|
+
[bold]ADAPTERS[/]
|
|
93
|
+
[cyan]codex_mcp[/] Codex via MCP server (sync conversations)
|
|
94
|
+
[cyan]claude_code[/] Claude Code CLI
|
|
95
|
+
|
|
96
|
+
[bold]WATCHERS[/] (trajectory aligners)
|
|
97
|
+
[cyan]progress[/] Detects stuck/spinning agents
|
|
98
|
+
[cyan]budget[/] Monitors step/session limits
|
|
99
|
+
[cyan]scope[/] Detects scope creep
|
|
100
|
+
[cyan]pattern[/] Custom regex pattern matching
|
|
101
|
+
[cyan]quality[/] Code quality checks
|
|
102
|
+
""",
|
|
103
|
+
rich_markup_mode="rich",
|
|
104
|
+
no_args_is_help=True,
|
|
105
|
+
add_completion=False,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Configs subcommand group
|
|
109
|
+
configs_app = typer.Typer(
|
|
110
|
+
name="configs",
|
|
111
|
+
help="""
|
|
112
|
+
Manage zwarm configurations.
|
|
113
|
+
|
|
114
|
+
[bold]SUBCOMMANDS[/]
|
|
115
|
+
[cyan]list[/] List available configuration files
|
|
116
|
+
[cyan]show[/] Display a configuration file's contents
|
|
117
|
+
""",
|
|
118
|
+
rich_markup_mode="rich",
|
|
119
|
+
no_args_is_help=True,
|
|
120
|
+
)
|
|
121
|
+
app.add_typer(configs_app, name="configs")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AdapterType(str, Enum):
|
|
125
|
+
codex_mcp = "codex_mcp"
|
|
126
|
+
claude_code = "claude_code"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ModeType(str, Enum):
|
|
130
|
+
sync = "sync"
|
|
131
|
+
async_ = "async"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def orchestrate(
|
|
136
|
+
task: Annotated[Optional[str], typer.Option("--task", "-t", help="The task to accomplish")] = None,
|
|
137
|
+
task_file: Annotated[Optional[Path], typer.Option("--task-file", "-f", help="Read task from file")] = None,
|
|
138
|
+
config: Annotated[Optional[Path], typer.Option("--config", "-c", help="Path to config YAML")] = None,
|
|
139
|
+
overrides: Annotated[Optional[list[str]], typer.Option("--set", help="Override config (key=value)")] = None,
|
|
140
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
141
|
+
resume: Annotated[bool, typer.Option("--resume", help="Resume from previous state")] = False,
|
|
142
|
+
max_steps: Annotated[Optional[int], typer.Option("--max-steps", help="Maximum orchestrator steps")] = None,
|
|
143
|
+
verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show detailed output")] = False,
|
|
144
|
+
instance: Annotated[Optional[str], typer.Option("--instance", "-i", help="Instance ID (for isolation/resume)")] = None,
|
|
145
|
+
instance_name: Annotated[Optional[str], typer.Option("--name", "-n", help="Human-readable instance name")] = None,
|
|
146
|
+
):
|
|
147
|
+
"""
|
|
148
|
+
Start an orchestrator session.
|
|
149
|
+
|
|
150
|
+
The orchestrator breaks down tasks and delegates to executor agents
|
|
151
|
+
(Codex, Claude Code). It can have sync conversations or fire-and-forget
|
|
152
|
+
async delegations.
|
|
153
|
+
|
|
154
|
+
Each run creates an isolated instance to prevent conflicts when running
|
|
155
|
+
multiple orchestrators in the same directory.
|
|
156
|
+
|
|
157
|
+
[bold]Examples:[/]
|
|
158
|
+
[dim]# Simple task[/]
|
|
159
|
+
$ zwarm orchestrate --task "Add a logout button to the navbar"
|
|
160
|
+
|
|
161
|
+
[dim]# Task from file[/]
|
|
162
|
+
$ zwarm orchestrate -f task.md
|
|
163
|
+
|
|
164
|
+
[dim]# Task from stdin[/]
|
|
165
|
+
$ cat task.md | zwarm orchestrate
|
|
166
|
+
$ zwarm orchestrate < task.md
|
|
167
|
+
|
|
168
|
+
[dim]# With config file[/]
|
|
169
|
+
$ zwarm orchestrate -c configs/base.yaml --task "Refactor auth"
|
|
170
|
+
|
|
171
|
+
[dim]# Override settings[/]
|
|
172
|
+
$ zwarm orchestrate --task "Fix bug" --set executor.adapter=claude_code
|
|
173
|
+
|
|
174
|
+
[dim]# Named instance (easier to track)[/]
|
|
175
|
+
$ zwarm orchestrate --task "Add tests" --name test-work
|
|
176
|
+
|
|
177
|
+
[dim]# Resume a specific instance[/]
|
|
178
|
+
$ zwarm orchestrate --resume --instance abc123
|
|
179
|
+
|
|
180
|
+
[dim]# List all instances[/]
|
|
181
|
+
$ zwarm instances
|
|
182
|
+
"""
|
|
183
|
+
from zwarm.orchestrator import build_orchestrator
|
|
184
|
+
|
|
185
|
+
# Resolve task from: --task, --task-file, or stdin
|
|
186
|
+
resolved_task = _resolve_task(task, task_file)
|
|
187
|
+
if not resolved_task:
|
|
188
|
+
console.print("[red]Error:[/] No task provided. Use --task, --task-file, or pipe from stdin.")
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
|
|
191
|
+
task = resolved_task
|
|
192
|
+
|
|
193
|
+
# Build overrides list
|
|
194
|
+
override_list = list(overrides or [])
|
|
195
|
+
if max_steps:
|
|
196
|
+
override_list.append(f"orchestrator.max_steps={max_steps}")
|
|
197
|
+
|
|
198
|
+
console.print(f"[bold]Starting orchestrator...[/]")
|
|
199
|
+
console.print(f" Task: {task}")
|
|
200
|
+
console.print(f" Working dir: {working_dir.absolute()}")
|
|
201
|
+
if instance:
|
|
202
|
+
console.print(f" Instance: {instance}" + (f" ({instance_name})" if instance_name else ""))
|
|
203
|
+
console.print()
|
|
204
|
+
|
|
205
|
+
# Output handler to show orchestrator messages
|
|
206
|
+
def output_handler(msg: str) -> None:
|
|
207
|
+
if msg.strip():
|
|
208
|
+
console.print(f"[dim][orchestrator][/] {msg}")
|
|
209
|
+
|
|
210
|
+
orchestrator = None
|
|
211
|
+
try:
|
|
212
|
+
orchestrator = build_orchestrator(
|
|
213
|
+
config_path=config,
|
|
214
|
+
task=task,
|
|
215
|
+
working_dir=working_dir.absolute(),
|
|
216
|
+
overrides=override_list,
|
|
217
|
+
resume=resume,
|
|
218
|
+
output_handler=output_handler,
|
|
219
|
+
instance_id=instance,
|
|
220
|
+
instance_name=instance_name,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if resume:
|
|
224
|
+
console.print(" [dim]Resuming from previous state...[/]")
|
|
225
|
+
|
|
226
|
+
# Show instance ID if auto-generated
|
|
227
|
+
if orchestrator.instance_id and not instance:
|
|
228
|
+
console.print(f" [dim]Instance: {orchestrator.instance_id[:8]}[/]")
|
|
229
|
+
|
|
230
|
+
# Run the orchestrator loop
|
|
231
|
+
console.print("[bold]--- Orchestrator running ---[/]\n")
|
|
232
|
+
result = orchestrator.run(task=task)
|
|
233
|
+
|
|
234
|
+
console.print(f"\n[bold green]--- Orchestrator finished ---[/]")
|
|
235
|
+
console.print(f" Steps: {result.get('steps', 'unknown')}")
|
|
236
|
+
|
|
237
|
+
# Show exit message if any
|
|
238
|
+
exit_msg = getattr(orchestrator, "_exit_message", "")
|
|
239
|
+
if exit_msg:
|
|
240
|
+
console.print(f" Exit: {exit_msg[:200]}")
|
|
241
|
+
|
|
242
|
+
# Save state for potential resume
|
|
243
|
+
orchestrator.save_state()
|
|
244
|
+
|
|
245
|
+
# Update instance status
|
|
246
|
+
if orchestrator.instance_id:
|
|
247
|
+
from zwarm.core.state import update_instance_status
|
|
248
|
+
update_instance_status(
|
|
249
|
+
orchestrator.instance_id,
|
|
250
|
+
"completed",
|
|
251
|
+
working_dir / ".zwarm",
|
|
252
|
+
)
|
|
253
|
+
console.print(f" [dim]Instance {orchestrator.instance_id[:8]} marked completed[/]")
|
|
254
|
+
|
|
255
|
+
except KeyboardInterrupt:
|
|
256
|
+
console.print("\n\n[yellow]Interrupted.[/]")
|
|
257
|
+
if orchestrator:
|
|
258
|
+
orchestrator.save_state()
|
|
259
|
+
console.print("[dim]State saved. Use --resume to continue.[/]")
|
|
260
|
+
# Keep instance as "active" so it can be resumed
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
console.print(f"\n[red]Error:[/] {e}")
|
|
264
|
+
if verbose:
|
|
265
|
+
console.print_exception()
|
|
266
|
+
# Update instance status to failed
|
|
267
|
+
if orchestrator and orchestrator.instance_id:
|
|
268
|
+
from zwarm.core.state import update_instance_status
|
|
269
|
+
update_instance_status(
|
|
270
|
+
orchestrator.instance_id,
|
|
271
|
+
"failed",
|
|
272
|
+
working_dir / ".zwarm",
|
|
273
|
+
)
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def exec(
|
|
279
|
+
task: Annotated[str, typer.Option("--task", "-t", help="Task to execute")],
|
|
280
|
+
adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Executor adapter")] = AdapterType.codex_mcp,
|
|
281
|
+
mode: Annotated[ModeType, typer.Option("--mode", "-m", help="Execution mode")] = ModeType.sync,
|
|
282
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
283
|
+
model: Annotated[Optional[str], typer.Option("--model", help="Model override")] = None,
|
|
284
|
+
):
|
|
285
|
+
"""
|
|
286
|
+
Run a single executor directly (for testing).
|
|
287
|
+
|
|
288
|
+
Useful for testing adapters without the full orchestrator loop.
|
|
289
|
+
|
|
290
|
+
[bold]Examples:[/]
|
|
291
|
+
[dim]# Test Codex[/]
|
|
292
|
+
$ zwarm exec --task "What is 2+2?"
|
|
293
|
+
|
|
294
|
+
[dim]# Test Claude Code[/]
|
|
295
|
+
$ zwarm exec -a claude_code --task "List files in current dir"
|
|
296
|
+
|
|
297
|
+
[dim]# Async mode[/]
|
|
298
|
+
$ zwarm exec --task "Build feature" --mode async
|
|
299
|
+
"""
|
|
300
|
+
from zwarm.adapters import get_adapter
|
|
301
|
+
|
|
302
|
+
console.print(f"[bold]Running executor directly...[/]")
|
|
303
|
+
console.print(f" Adapter: [cyan]{adapter.value}[/]")
|
|
304
|
+
console.print(f" Mode: {mode.value}")
|
|
305
|
+
console.print(f" Task: {task}")
|
|
306
|
+
|
|
307
|
+
# Use isolated codex config if available
|
|
308
|
+
config_path = working_dir / ".zwarm" / "codex.toml"
|
|
309
|
+
if not config_path.exists():
|
|
310
|
+
config_path = None
|
|
311
|
+
|
|
312
|
+
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}")
|
|
316
|
+
sys.exit(1)
|
|
317
|
+
|
|
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
|
+
|
|
327
|
+
console.print(f"\n[green]Session started:[/] {session.id[:8]}")
|
|
328
|
+
|
|
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}")
|
|
332
|
+
|
|
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
|
|
339
|
+
|
|
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.")
|
|
347
|
+
|
|
348
|
+
finally:
|
|
349
|
+
await executor.cleanup()
|
|
350
|
+
|
|
351
|
+
asyncio.run(run())
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@app.command()
|
|
355
|
+
def status(
|
|
356
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
357
|
+
):
|
|
358
|
+
"""
|
|
359
|
+
Show current state (sessions, tasks, events).
|
|
360
|
+
|
|
361
|
+
Displays active sessions, pending tasks, and recent events
|
|
362
|
+
from the .zwarm state directory.
|
|
363
|
+
|
|
364
|
+
[bold]Example:[/]
|
|
365
|
+
$ zwarm status
|
|
366
|
+
"""
|
|
367
|
+
from zwarm.core.state import StateManager
|
|
368
|
+
|
|
369
|
+
state_dir = working_dir / ".zwarm"
|
|
370
|
+
if not state_dir.exists():
|
|
371
|
+
console.print("[yellow]No zwarm state found in this directory.[/]")
|
|
372
|
+
console.print("[dim]Run 'zwarm orchestrate' to start.[/]")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
state = StateManager(state_dir)
|
|
376
|
+
state.load()
|
|
377
|
+
|
|
378
|
+
# Sessions table
|
|
379
|
+
sessions = state.list_sessions()
|
|
380
|
+
console.print(f"\n[bold]Sessions[/] ({len(sessions)})")
|
|
381
|
+
if sessions:
|
|
382
|
+
table = Table(show_header=True, header_style="bold")
|
|
383
|
+
table.add_column("ID", style="dim")
|
|
384
|
+
table.add_column("Mode")
|
|
385
|
+
table.add_column("Status")
|
|
386
|
+
table.add_column("Task")
|
|
387
|
+
|
|
388
|
+
for s in sessions:
|
|
389
|
+
status_style = {"active": "green", "completed": "blue", "failed": "red"}.get(s.status.value, "white")
|
|
390
|
+
table.add_row(
|
|
391
|
+
s.id[:8],
|
|
392
|
+
s.mode.value,
|
|
393
|
+
f"[{status_style}]{s.status.value}[/]",
|
|
394
|
+
s.task_description[:50] + "..." if len(s.task_description) > 50 else s.task_description,
|
|
395
|
+
)
|
|
396
|
+
console.print(table)
|
|
397
|
+
else:
|
|
398
|
+
console.print(" [dim](none)[/]")
|
|
399
|
+
|
|
400
|
+
# Tasks table
|
|
401
|
+
tasks = state.list_tasks()
|
|
402
|
+
console.print(f"\n[bold]Tasks[/] ({len(tasks)})")
|
|
403
|
+
if tasks:
|
|
404
|
+
table = Table(show_header=True, header_style="bold")
|
|
405
|
+
table.add_column("ID", style="dim")
|
|
406
|
+
table.add_column("Status")
|
|
407
|
+
table.add_column("Description")
|
|
408
|
+
|
|
409
|
+
for t in tasks:
|
|
410
|
+
status_style = {"pending": "yellow", "in_progress": "cyan", "completed": "green", "failed": "red"}.get(t.status.value, "white")
|
|
411
|
+
table.add_row(
|
|
412
|
+
t.id[:8],
|
|
413
|
+
f"[{status_style}]{t.status.value}[/]",
|
|
414
|
+
t.description[:50] + "..." if len(t.description) > 50 else t.description,
|
|
415
|
+
)
|
|
416
|
+
console.print(table)
|
|
417
|
+
else:
|
|
418
|
+
console.print(" [dim](none)[/]")
|
|
419
|
+
|
|
420
|
+
# Recent events
|
|
421
|
+
events = state.get_events(limit=5)
|
|
422
|
+
console.print(f"\n[bold]Recent Events[/]")
|
|
423
|
+
if events:
|
|
424
|
+
for e in events:
|
|
425
|
+
console.print(f" [dim]{e.timestamp.strftime('%H:%M:%S')}[/] {e.kind}")
|
|
426
|
+
else:
|
|
427
|
+
console.print(" [dim](none)[/]")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@app.command()
|
|
431
|
+
def instances(
|
|
432
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
433
|
+
all_instances: Annotated[bool, typer.Option("--all", "-a", help="Show all instances (including completed)")] = False,
|
|
434
|
+
):
|
|
435
|
+
"""
|
|
436
|
+
List all orchestrator instances.
|
|
437
|
+
|
|
438
|
+
Shows instances that have been run in this directory. Use --all to include
|
|
439
|
+
completed instances.
|
|
440
|
+
|
|
441
|
+
[bold]Examples:[/]
|
|
442
|
+
[dim]# List active instances[/]
|
|
443
|
+
$ zwarm instances
|
|
444
|
+
|
|
445
|
+
[dim]# List all instances[/]
|
|
446
|
+
$ zwarm instances --all
|
|
447
|
+
"""
|
|
448
|
+
from zwarm.core.state import list_instances as get_instances
|
|
449
|
+
|
|
450
|
+
state_dir = working_dir / ".zwarm"
|
|
451
|
+
all_inst = get_instances(state_dir)
|
|
452
|
+
|
|
453
|
+
if not all_inst:
|
|
454
|
+
console.print("[dim]No instances found.[/]")
|
|
455
|
+
console.print("[dim]Run 'zwarm orchestrate' to start a new instance.[/]")
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
# Filter if not showing all
|
|
459
|
+
if not all_instances:
|
|
460
|
+
all_inst = [i for i in all_inst if i.get("status") == "active"]
|
|
461
|
+
|
|
462
|
+
if not all_inst:
|
|
463
|
+
console.print("[dim]No active instances. Use --all to see completed ones.[/]")
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
console.print(f"[bold]Instances[/] ({len(all_inst)} total)\n")
|
|
467
|
+
|
|
468
|
+
for inst in all_inst:
|
|
469
|
+
status = inst.get("status", "unknown")
|
|
470
|
+
status_icon = {"active": "[green]●[/]", "completed": "[dim]✓[/]", "failed": "[red]✗[/]"}.get(status, "[dim]?[/]")
|
|
471
|
+
|
|
472
|
+
inst_id = inst.get("id", "unknown")[:8]
|
|
473
|
+
name = inst.get("name", "")
|
|
474
|
+
task = (inst.get("task") or "")[:60]
|
|
475
|
+
updated = inst.get("updated_at", "")[:19] if inst.get("updated_at") else ""
|
|
476
|
+
|
|
477
|
+
console.print(f" {status_icon} [bold]{inst_id}[/]" + (f" ({name})" if name and name != inst_id else ""))
|
|
478
|
+
if task:
|
|
479
|
+
console.print(f" [dim]{task}[/]")
|
|
480
|
+
if updated:
|
|
481
|
+
console.print(f" [dim]Updated: {updated}[/]")
|
|
482
|
+
console.print()
|
|
483
|
+
|
|
484
|
+
console.print("[dim]Use --instance <id> with 'orchestrate --resume' to resume an instance.[/]")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@app.command()
|
|
488
|
+
def history(
|
|
489
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
490
|
+
kind: Annotated[Optional[str], typer.Option("--kind", "-k", help="Filter by event kind")] = None,
|
|
491
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Number of events")] = 20,
|
|
492
|
+
):
|
|
493
|
+
"""
|
|
494
|
+
Show event history.
|
|
495
|
+
|
|
496
|
+
Displays the append-only event log with timestamps and details.
|
|
497
|
+
|
|
498
|
+
[bold]Examples:[/]
|
|
499
|
+
[dim]# Show last 20 events[/]
|
|
500
|
+
$ zwarm history
|
|
501
|
+
|
|
502
|
+
[dim]# Show more events[/]
|
|
503
|
+
$ zwarm history --limit 50
|
|
504
|
+
|
|
505
|
+
[dim]# Filter by kind[/]
|
|
506
|
+
$ zwarm history --kind session_started
|
|
507
|
+
"""
|
|
508
|
+
from zwarm.core.state import StateManager
|
|
509
|
+
|
|
510
|
+
state_dir = working_dir / ".zwarm"
|
|
511
|
+
if not state_dir.exists():
|
|
512
|
+
console.print("[yellow]No zwarm state found.[/]")
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
state = StateManager(state_dir)
|
|
516
|
+
events = state.get_events(kind=kind, limit=limit)
|
|
517
|
+
|
|
518
|
+
console.print(f"\n[bold]Event History[/] (last {limit})\n")
|
|
519
|
+
|
|
520
|
+
if not events:
|
|
521
|
+
console.print("[dim]No events found.[/]")
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
table = Table(show_header=True, header_style="bold")
|
|
525
|
+
table.add_column("Time", style="dim")
|
|
526
|
+
table.add_column("Event")
|
|
527
|
+
table.add_column("Session/Task")
|
|
528
|
+
table.add_column("Details")
|
|
529
|
+
|
|
530
|
+
for e in events:
|
|
531
|
+
details = ""
|
|
532
|
+
if e.payload:
|
|
533
|
+
details = ", ".join(f"{k}={str(v)[:30]}" for k, v in list(e.payload.items())[:2])
|
|
534
|
+
|
|
535
|
+
table.add_row(
|
|
536
|
+
e.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
|
537
|
+
e.kind,
|
|
538
|
+
(e.session_id or e.task_id or "-")[:8],
|
|
539
|
+
details[:60],
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
console.print(table)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@configs_app.command("list")
|
|
546
|
+
def configs_list(
|
|
547
|
+
config_dir: Annotated[Optional[Path], typer.Option("--dir", "-d", help="Directory to search")] = None,
|
|
548
|
+
):
|
|
549
|
+
"""
|
|
550
|
+
List available agent/experiment configuration files (YAML).
|
|
551
|
+
|
|
552
|
+
Note: config.toml is for user environment settings and is loaded
|
|
553
|
+
automatically - use YAML files for agent configurations.
|
|
554
|
+
|
|
555
|
+
[bold]Example:[/]
|
|
556
|
+
$ zwarm configs list
|
|
557
|
+
"""
|
|
558
|
+
search_dirs = [
|
|
559
|
+
config_dir or Path.cwd(),
|
|
560
|
+
Path.cwd() / "configs",
|
|
561
|
+
Path.cwd() / ".zwarm",
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
console.print("\n[bold]Available Configurations[/]\n")
|
|
565
|
+
found = False
|
|
566
|
+
|
|
567
|
+
for d in search_dirs:
|
|
568
|
+
if not d.exists():
|
|
569
|
+
continue
|
|
570
|
+
for pattern in ["*.yaml", "*.yml"]:
|
|
571
|
+
for f in d.glob(pattern):
|
|
572
|
+
found = True
|
|
573
|
+
try:
|
|
574
|
+
rel = f.relative_to(Path.cwd())
|
|
575
|
+
console.print(f" [cyan]{rel}[/]")
|
|
576
|
+
except ValueError:
|
|
577
|
+
console.print(f" [cyan]{f}[/]")
|
|
578
|
+
|
|
579
|
+
if not found:
|
|
580
|
+
console.print(" [dim]No configuration files found.[/]")
|
|
581
|
+
console.print("\n [dim]Create a YAML config in configs/ to get started.[/]")
|
|
582
|
+
|
|
583
|
+
# Check for config.toml and mention it (check both locations)
|
|
584
|
+
new_config = Path.cwd() / ".zwarm" / "config.toml"
|
|
585
|
+
legacy_config = Path.cwd() / "config.toml"
|
|
586
|
+
if new_config.exists():
|
|
587
|
+
console.print(f"\n[dim]Environment: .zwarm/config.toml (loaded automatically)[/]")
|
|
588
|
+
elif legacy_config.exists():
|
|
589
|
+
console.print(f"\n[dim]Environment: config.toml (legacy location, loaded automatically)[/]")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@configs_app.command("show")
|
|
593
|
+
def configs_show(
|
|
594
|
+
config_path: Annotated[Path, typer.Argument(help="Path to configuration file")],
|
|
595
|
+
):
|
|
596
|
+
"""
|
|
597
|
+
Show a configuration file's contents.
|
|
598
|
+
|
|
599
|
+
Loads and displays the resolved configuration including
|
|
600
|
+
any inherited values from 'extends:' directives.
|
|
601
|
+
|
|
602
|
+
[bold]Example:[/]
|
|
603
|
+
$ zwarm configs show configs/base.yaml
|
|
604
|
+
"""
|
|
605
|
+
from zwarm.core.config import load_config
|
|
606
|
+
import json
|
|
607
|
+
|
|
608
|
+
if not config_path.exists():
|
|
609
|
+
console.print(f"[red]File not found:[/] {config_path}")
|
|
610
|
+
raise typer.Exit(1)
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
config = load_config(config_path=config_path)
|
|
614
|
+
console.print(f"\n[bold]Configuration:[/] {config_path}\n")
|
|
615
|
+
console.print_json(json.dumps(config.to_dict(), indent=2))
|
|
616
|
+
except Exception as e:
|
|
617
|
+
console.print(f"[red]Error loading config:[/] {e}")
|
|
618
|
+
raise typer.Exit(1)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
@app.command()
|
|
622
|
+
def init(
|
|
623
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
624
|
+
non_interactive: Annotated[bool, typer.Option("--yes", "-y", help="Accept defaults, no prompts")] = False,
|
|
625
|
+
with_project: Annotated[bool, typer.Option("--with-project", help="Create zwarm.yaml project config")] = False,
|
|
626
|
+
):
|
|
627
|
+
"""
|
|
628
|
+
Initialize zwarm in the current directory.
|
|
629
|
+
|
|
630
|
+
Creates configuration files and the .zwarm state directory.
|
|
631
|
+
Run this once per project to set up zwarm.
|
|
632
|
+
|
|
633
|
+
[bold]Creates:[/]
|
|
634
|
+
[cyan].zwarm/[/] State directory for sessions and events
|
|
635
|
+
[cyan].zwarm/config.toml[/] Runtime settings (weave, adapter, watchers)
|
|
636
|
+
[cyan]zwarm.yaml[/] Project config (optional, with --with-project)
|
|
637
|
+
|
|
638
|
+
[bold]Examples:[/]
|
|
639
|
+
[dim]# Interactive setup[/]
|
|
640
|
+
$ zwarm init
|
|
641
|
+
|
|
642
|
+
[dim]# Quick setup with defaults[/]
|
|
643
|
+
$ zwarm init --yes
|
|
644
|
+
|
|
645
|
+
[dim]# Full setup with project config[/]
|
|
646
|
+
$ zwarm init --with-project
|
|
647
|
+
"""
|
|
648
|
+
console.print("\n[bold cyan]zwarm init[/] - Initialize zwarm configuration\n")
|
|
649
|
+
|
|
650
|
+
state_dir = working_dir / ".zwarm"
|
|
651
|
+
config_toml_path = state_dir / "config.toml"
|
|
652
|
+
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
653
|
+
|
|
654
|
+
# Check for existing config (also check old location for migration)
|
|
655
|
+
old_config_path = working_dir / "config.toml"
|
|
656
|
+
if old_config_path.exists() and not config_toml_path.exists():
|
|
657
|
+
console.print(f"[yellow]Note:[/] Found config.toml in project root.")
|
|
658
|
+
console.print(f" Config now lives in .zwarm/config.toml")
|
|
659
|
+
if not non_interactive:
|
|
660
|
+
migrate = typer.confirm(" Move to new location?", default=True)
|
|
661
|
+
if migrate:
|
|
662
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
663
|
+
old_config_path.rename(config_toml_path)
|
|
664
|
+
console.print(f" [green]✓[/] Moved config.toml to .zwarm/")
|
|
665
|
+
|
|
666
|
+
# Check for existing files
|
|
667
|
+
if config_toml_path.exists():
|
|
668
|
+
console.print(f"[yellow]Warning:[/] .zwarm/config.toml already exists")
|
|
669
|
+
if not non_interactive:
|
|
670
|
+
overwrite = typer.confirm("Overwrite?", default=False)
|
|
671
|
+
if not overwrite:
|
|
672
|
+
console.print("[dim]Skipping config.toml[/]")
|
|
673
|
+
config_toml_path = None
|
|
674
|
+
else:
|
|
675
|
+
config_toml_path = None
|
|
676
|
+
|
|
677
|
+
# Gather settings
|
|
678
|
+
weave_project = ""
|
|
679
|
+
adapter = "codex_mcp"
|
|
680
|
+
watchers_enabled = ["progress", "budget", "delegation", "delegation_reminder"]
|
|
681
|
+
create_project_config = with_project
|
|
682
|
+
project_description = ""
|
|
683
|
+
project_context = ""
|
|
684
|
+
|
|
685
|
+
if not non_interactive:
|
|
686
|
+
console.print("[bold]Configuration[/]\n")
|
|
687
|
+
|
|
688
|
+
# Weave project
|
|
689
|
+
weave_project = typer.prompt(
|
|
690
|
+
" Weave project (entity/project, blank to skip)",
|
|
691
|
+
default="",
|
|
692
|
+
show_default=False,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
# Adapter
|
|
696
|
+
adapter = typer.prompt(
|
|
697
|
+
" Default adapter",
|
|
698
|
+
default="codex_mcp",
|
|
699
|
+
type=str,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Watchers
|
|
703
|
+
console.print("\n [bold]Watchers[/] (trajectory aligners)")
|
|
704
|
+
available_watchers = ["progress", "budget", "delegation", "delegation_reminder", "scope", "pattern", "quality"]
|
|
705
|
+
watchers_enabled = []
|
|
706
|
+
for w in available_watchers:
|
|
707
|
+
default = w in ["progress", "budget", "delegation", "delegation_reminder"]
|
|
708
|
+
if typer.confirm(f" Enable {w}?", default=default):
|
|
709
|
+
watchers_enabled.append(w)
|
|
710
|
+
|
|
711
|
+
# Project config
|
|
712
|
+
console.print()
|
|
713
|
+
create_project_config = typer.confirm(
|
|
714
|
+
" Create zwarm.yaml project config?",
|
|
715
|
+
default=with_project,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
if create_project_config:
|
|
719
|
+
project_description = typer.prompt(
|
|
720
|
+
" Project description",
|
|
721
|
+
default="",
|
|
722
|
+
show_default=False,
|
|
723
|
+
)
|
|
724
|
+
console.print(" [dim]Project context (optional, press Enter twice to finish):[/]")
|
|
725
|
+
context_lines = []
|
|
726
|
+
while True:
|
|
727
|
+
line = typer.prompt(" ", default="", show_default=False)
|
|
728
|
+
if not line:
|
|
729
|
+
break
|
|
730
|
+
context_lines.append(line)
|
|
731
|
+
project_context = "\n".join(context_lines)
|
|
732
|
+
|
|
733
|
+
# Create .zwarm directory
|
|
734
|
+
console.print("\n[bold]Creating files...[/]\n")
|
|
735
|
+
|
|
736
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
737
|
+
(state_dir / "sessions").mkdir(exist_ok=True)
|
|
738
|
+
(state_dir / "orchestrator").mkdir(exist_ok=True)
|
|
739
|
+
console.print(f" [green]✓[/] Created .zwarm/")
|
|
740
|
+
|
|
741
|
+
# Create config.toml inside .zwarm/
|
|
742
|
+
if config_toml_path:
|
|
743
|
+
toml_content = _generate_config_toml(
|
|
744
|
+
weave_project=weave_project,
|
|
745
|
+
adapter=adapter,
|
|
746
|
+
watchers=watchers_enabled,
|
|
747
|
+
)
|
|
748
|
+
config_toml_path.write_text(toml_content)
|
|
749
|
+
console.print(f" [green]✓[/] Created .zwarm/config.toml")
|
|
750
|
+
|
|
751
|
+
# Create codex.toml for isolated codex configuration
|
|
752
|
+
codex_toml_path = state_dir / "codex.toml"
|
|
753
|
+
if not codex_toml_path.exists():
|
|
754
|
+
codex_content = _generate_codex_toml()
|
|
755
|
+
codex_toml_path.write_text(codex_content)
|
|
756
|
+
console.print(f" [green]✓[/] Created .zwarm/codex.toml (isolated codex config)")
|
|
757
|
+
|
|
758
|
+
# Create zwarm.yaml
|
|
759
|
+
if create_project_config:
|
|
760
|
+
if zwarm_yaml_path.exists() and not non_interactive:
|
|
761
|
+
overwrite = typer.confirm(" zwarm.yaml exists. Overwrite?", default=False)
|
|
762
|
+
if not overwrite:
|
|
763
|
+
create_project_config = False
|
|
764
|
+
|
|
765
|
+
if create_project_config:
|
|
766
|
+
yaml_content = _generate_zwarm_yaml(
|
|
767
|
+
description=project_description,
|
|
768
|
+
context=project_context,
|
|
769
|
+
watchers=watchers_enabled,
|
|
770
|
+
)
|
|
771
|
+
zwarm_yaml_path.write_text(yaml_content)
|
|
772
|
+
console.print(f" [green]✓[/] Created zwarm.yaml")
|
|
773
|
+
|
|
774
|
+
# Summary
|
|
775
|
+
console.print("\n[bold green]Done![/] zwarm is ready.\n")
|
|
776
|
+
console.print("[bold]Next steps:[/]")
|
|
777
|
+
console.print(" [dim]# Run the orchestrator[/]")
|
|
778
|
+
console.print(" $ zwarm orchestrate --task \"Your task here\"\n")
|
|
779
|
+
console.print(" [dim]# Or test an executor directly[/]")
|
|
780
|
+
console.print(" $ zwarm exec --task \"What is 2+2?\"\n")
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _generate_config_toml(
|
|
784
|
+
weave_project: str = "",
|
|
785
|
+
adapter: str = "codex_mcp",
|
|
786
|
+
watchers: list[str] | None = None,
|
|
787
|
+
) -> str:
|
|
788
|
+
"""Generate config.toml content."""
|
|
789
|
+
watchers = watchers or []
|
|
790
|
+
|
|
791
|
+
lines = [
|
|
792
|
+
"# zwarm configuration",
|
|
793
|
+
"# Generated by 'zwarm init'",
|
|
794
|
+
"",
|
|
795
|
+
"[weave]",
|
|
796
|
+
]
|
|
797
|
+
|
|
798
|
+
if weave_project:
|
|
799
|
+
lines.append(f'project = "{weave_project}"')
|
|
800
|
+
else:
|
|
801
|
+
lines.append("# project = \"your-entity/your-project\" # Uncomment to enable Weave tracing")
|
|
802
|
+
|
|
803
|
+
lines.extend([
|
|
804
|
+
"",
|
|
805
|
+
"[orchestrator]",
|
|
806
|
+
"max_steps = 50",
|
|
807
|
+
"",
|
|
808
|
+
"[executor]",
|
|
809
|
+
f'adapter = "{adapter}"',
|
|
810
|
+
"# model = \"\" # Optional model override",
|
|
811
|
+
"",
|
|
812
|
+
"[watchers]",
|
|
813
|
+
f"enabled = {watchers}",
|
|
814
|
+
"",
|
|
815
|
+
"# Watcher-specific configuration",
|
|
816
|
+
"# [watchers.budget]",
|
|
817
|
+
"# max_steps = 50",
|
|
818
|
+
"# warn_at_percent = 80",
|
|
819
|
+
"",
|
|
820
|
+
"# [watchers.pattern]",
|
|
821
|
+
"# patterns = [\"DROP TABLE\", \"rm -rf\"]",
|
|
822
|
+
"",
|
|
823
|
+
])
|
|
824
|
+
|
|
825
|
+
return "\n".join(lines)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _generate_codex_toml(
|
|
829
|
+
model: str = "gpt-5.1-codex-mini",
|
|
830
|
+
reasoning_effort: str = "high",
|
|
831
|
+
) -> str:
|
|
832
|
+
"""
|
|
833
|
+
Generate codex.toml for isolated codex configuration.
|
|
834
|
+
|
|
835
|
+
This file is used by zwarm instead of ~/.codex/config.toml to ensure
|
|
836
|
+
consistent behavior across different environments.
|
|
837
|
+
"""
|
|
838
|
+
lines = [
|
|
839
|
+
"# Codex configuration for zwarm",
|
|
840
|
+
"# This file isolates zwarm's codex settings from your global ~/.codex/config.toml",
|
|
841
|
+
"# Generated by 'zwarm init'",
|
|
842
|
+
"",
|
|
843
|
+
"# Model settings",
|
|
844
|
+
f'model = "{model}"',
|
|
845
|
+
f'model_reasoning_effort = "{reasoning_effort}" # low | medium | high',
|
|
846
|
+
"",
|
|
847
|
+
"# Approval settings - zwarm manages these automatically",
|
|
848
|
+
"# disable_response_storage = false",
|
|
849
|
+
"",
|
|
850
|
+
"# You can override any codex setting here",
|
|
851
|
+
"# See: https://github.com/openai/codex#configuration",
|
|
852
|
+
"",
|
|
853
|
+
]
|
|
854
|
+
return "\n".join(lines)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _generate_zwarm_yaml(
|
|
858
|
+
description: str = "",
|
|
859
|
+
context: str = "",
|
|
860
|
+
watchers: list[str] | None = None,
|
|
861
|
+
) -> str:
|
|
862
|
+
"""Generate zwarm.yaml project config."""
|
|
863
|
+
watchers = watchers or []
|
|
864
|
+
|
|
865
|
+
lines = [
|
|
866
|
+
"# zwarm project configuration",
|
|
867
|
+
"# Customize the orchestrator for this specific project",
|
|
868
|
+
"",
|
|
869
|
+
f'description: "{description}"' if description else 'description: ""',
|
|
870
|
+
"",
|
|
871
|
+
"# Project-specific context injected into the orchestrator",
|
|
872
|
+
"# This helps the orchestrator understand your codebase",
|
|
873
|
+
"context: |",
|
|
874
|
+
]
|
|
875
|
+
|
|
876
|
+
if context:
|
|
877
|
+
for line in context.split("\n"):
|
|
878
|
+
lines.append(f" {line}")
|
|
879
|
+
else:
|
|
880
|
+
lines.extend([
|
|
881
|
+
" # Describe your project here. For example:",
|
|
882
|
+
" # - Tech stack (FastAPI, React, PostgreSQL)",
|
|
883
|
+
" # - Key directories (src/api/, src/components/)",
|
|
884
|
+
" # - Coding conventions to follow",
|
|
885
|
+
])
|
|
886
|
+
|
|
887
|
+
lines.extend([
|
|
888
|
+
"",
|
|
889
|
+
"# Project-specific constraints",
|
|
890
|
+
"# The orchestrator will be reminded to follow these",
|
|
891
|
+
"constraints:",
|
|
892
|
+
" # - \"Never modify migration files directly\"",
|
|
893
|
+
" # - \"All new endpoints need tests\"",
|
|
894
|
+
" # - \"Use existing patterns from src/api/\"",
|
|
895
|
+
"",
|
|
896
|
+
"# Default watchers for this project",
|
|
897
|
+
"watchers:",
|
|
898
|
+
])
|
|
899
|
+
|
|
900
|
+
for w in watchers:
|
|
901
|
+
lines.append(f" - {w}")
|
|
902
|
+
|
|
903
|
+
if not watchers:
|
|
904
|
+
lines.append(" # - progress")
|
|
905
|
+
lines.append(" # - budget")
|
|
906
|
+
|
|
907
|
+
lines.append("")
|
|
908
|
+
|
|
909
|
+
return "\n".join(lines)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@app.command()
|
|
913
|
+
def reset(
|
|
914
|
+
working_dir: Annotated[Path, typer.Option("--working-dir", "-w", help="Working directory")] = Path("."),
|
|
915
|
+
state: Annotated[bool, typer.Option("--state", "-s", help="Reset .zwarm/ state directory")] = True,
|
|
916
|
+
config: Annotated[bool, typer.Option("--config", "-c", help="Also delete config.toml")] = False,
|
|
917
|
+
project: Annotated[bool, typer.Option("--project", "-p", help="Also delete zwarm.yaml")] = False,
|
|
918
|
+
all_files: Annotated[bool, typer.Option("--all", "-a", help="Delete everything (state + config + project)")] = False,
|
|
919
|
+
force: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
|
|
920
|
+
):
|
|
921
|
+
"""
|
|
922
|
+
Reset zwarm state and optionally configuration files.
|
|
923
|
+
|
|
924
|
+
By default, only clears the .zwarm/ state directory (sessions, events, orchestrator history).
|
|
925
|
+
Use flags to also remove configuration files.
|
|
926
|
+
|
|
927
|
+
[bold]Examples:[/]
|
|
928
|
+
[dim]# Reset state only (default)[/]
|
|
929
|
+
$ zwarm reset
|
|
930
|
+
|
|
931
|
+
[dim]# Reset everything, no confirmation[/]
|
|
932
|
+
$ zwarm reset --all --yes
|
|
933
|
+
|
|
934
|
+
[dim]# Reset state and config.toml[/]
|
|
935
|
+
$ zwarm reset --config
|
|
936
|
+
"""
|
|
937
|
+
import shutil
|
|
938
|
+
|
|
939
|
+
console.print("\n[bold cyan]zwarm reset[/] - Reset zwarm state\n")
|
|
940
|
+
|
|
941
|
+
state_dir = working_dir / ".zwarm"
|
|
942
|
+
config_toml_path = state_dir / "config.toml" # New location
|
|
943
|
+
old_config_toml_path = working_dir / "config.toml" # Legacy location
|
|
944
|
+
zwarm_yaml_path = working_dir / "zwarm.yaml"
|
|
945
|
+
|
|
946
|
+
# Expand --all flag
|
|
947
|
+
if all_files:
|
|
948
|
+
state = True
|
|
949
|
+
config = True
|
|
950
|
+
project = True
|
|
951
|
+
|
|
952
|
+
# Collect what will be deleted
|
|
953
|
+
to_delete = []
|
|
954
|
+
if state and state_dir.exists():
|
|
955
|
+
to_delete.append((".zwarm/", state_dir))
|
|
956
|
+
# Config: check both new and legacy locations (but skip if state already deletes it)
|
|
957
|
+
if config and not state:
|
|
958
|
+
if config_toml_path.exists():
|
|
959
|
+
to_delete.append((".zwarm/config.toml", config_toml_path))
|
|
960
|
+
if old_config_toml_path.exists():
|
|
961
|
+
to_delete.append(("config.toml (legacy)", old_config_toml_path))
|
|
962
|
+
if project and zwarm_yaml_path.exists():
|
|
963
|
+
to_delete.append(("zwarm.yaml", zwarm_yaml_path))
|
|
964
|
+
|
|
965
|
+
if not to_delete:
|
|
966
|
+
console.print("[yellow]Nothing to reset.[/] No matching files found.")
|
|
967
|
+
raise typer.Exit(0)
|
|
968
|
+
|
|
969
|
+
# Show what will be deleted
|
|
970
|
+
console.print("[bold]Will delete:[/]")
|
|
971
|
+
for name, path in to_delete:
|
|
972
|
+
if path.is_dir():
|
|
973
|
+
# Count contents
|
|
974
|
+
files = list(path.rglob("*"))
|
|
975
|
+
file_count = len([f for f in files if f.is_file()])
|
|
976
|
+
console.print(f" [red]✗[/] {name} ({file_count} files)")
|
|
977
|
+
else:
|
|
978
|
+
console.print(f" [red]✗[/] {name}")
|
|
979
|
+
|
|
980
|
+
# Confirm
|
|
981
|
+
if not force:
|
|
982
|
+
console.print()
|
|
983
|
+
confirm = typer.confirm("Proceed with reset?", default=False)
|
|
984
|
+
if not confirm:
|
|
985
|
+
console.print("[dim]Aborted.[/]")
|
|
986
|
+
raise typer.Exit(0)
|
|
987
|
+
|
|
988
|
+
# Delete
|
|
989
|
+
console.print("\n[bold]Deleting...[/]")
|
|
990
|
+
for name, path in to_delete:
|
|
991
|
+
try:
|
|
992
|
+
if path.is_dir():
|
|
993
|
+
shutil.rmtree(path)
|
|
994
|
+
else:
|
|
995
|
+
path.unlink()
|
|
996
|
+
console.print(f" [green]✓[/] Deleted {name}")
|
|
997
|
+
except Exception as e:
|
|
998
|
+
console.print(f" [red]✗[/] Failed to delete {name}: {e}")
|
|
999
|
+
|
|
1000
|
+
console.print("\n[bold green]Reset complete.[/]")
|
|
1001
|
+
console.print("\n[dim]Run 'zwarm init' to set up again.[/]\n")
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
@app.command()
|
|
1005
|
+
def clean(
|
|
1006
|
+
force: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
|
|
1007
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show what would be killed without killing")] = False,
|
|
1008
|
+
):
|
|
1009
|
+
"""
|
|
1010
|
+
Clean up orphaned processes from zwarm sessions.
|
|
1011
|
+
|
|
1012
|
+
Finds and kills:
|
|
1013
|
+
- Orphaned codex mcp-server processes
|
|
1014
|
+
- Orphaned codex exec processes
|
|
1015
|
+
- Orphaned claude CLI processes
|
|
1016
|
+
|
|
1017
|
+
[bold]Examples:[/]
|
|
1018
|
+
[dim]# See what would be cleaned[/]
|
|
1019
|
+
$ zwarm clean --dry-run
|
|
1020
|
+
|
|
1021
|
+
[dim]# Clean without confirmation[/]
|
|
1022
|
+
$ zwarm clean --yes
|
|
1023
|
+
"""
|
|
1024
|
+
import subprocess
|
|
1025
|
+
import signal
|
|
1026
|
+
|
|
1027
|
+
console.print("\n[bold cyan]zwarm clean[/] - Clean up orphaned processes\n")
|
|
1028
|
+
|
|
1029
|
+
# Patterns to search for
|
|
1030
|
+
patterns = [
|
|
1031
|
+
("codex mcp-server", "Codex MCP server"),
|
|
1032
|
+
("codex exec", "Codex exec"),
|
|
1033
|
+
("claude.*--permission-mode", "Claude CLI"),
|
|
1034
|
+
]
|
|
1035
|
+
|
|
1036
|
+
found_processes = []
|
|
1037
|
+
|
|
1038
|
+
for pattern, description in patterns:
|
|
1039
|
+
try:
|
|
1040
|
+
# Use pgrep to find matching processes
|
|
1041
|
+
result = subprocess.run(
|
|
1042
|
+
["pgrep", "-f", pattern],
|
|
1043
|
+
capture_output=True,
|
|
1044
|
+
text=True,
|
|
1045
|
+
)
|
|
1046
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1047
|
+
pids = result.stdout.strip().split("\n")
|
|
1048
|
+
for pid in pids:
|
|
1049
|
+
pid = pid.strip()
|
|
1050
|
+
if pid and pid.isdigit():
|
|
1051
|
+
# Get process info
|
|
1052
|
+
try:
|
|
1053
|
+
ps_result = subprocess.run(
|
|
1054
|
+
["ps", "-p", pid, "-o", "pid,ppid,etime,command"],
|
|
1055
|
+
capture_output=True,
|
|
1056
|
+
text=True,
|
|
1057
|
+
)
|
|
1058
|
+
if ps_result.returncode == 0:
|
|
1059
|
+
lines = ps_result.stdout.strip().split("\n")
|
|
1060
|
+
if len(lines) > 1:
|
|
1061
|
+
# Skip header, get process line
|
|
1062
|
+
proc_info = lines[1].strip()
|
|
1063
|
+
found_processes.append((int(pid), description, proc_info))
|
|
1064
|
+
except Exception:
|
|
1065
|
+
found_processes.append((int(pid), description, "(unknown)"))
|
|
1066
|
+
except FileNotFoundError:
|
|
1067
|
+
# pgrep not available, try ps with grep
|
|
1068
|
+
try:
|
|
1069
|
+
result = subprocess.run(
|
|
1070
|
+
f"ps aux | grep '{pattern}' | grep -v grep",
|
|
1071
|
+
shell=True,
|
|
1072
|
+
capture_output=True,
|
|
1073
|
+
text=True,
|
|
1074
|
+
)
|
|
1075
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1076
|
+
for line in result.stdout.strip().split("\n"):
|
|
1077
|
+
parts = line.split()
|
|
1078
|
+
if len(parts) >= 2:
|
|
1079
|
+
pid = parts[1]
|
|
1080
|
+
if pid.isdigit():
|
|
1081
|
+
found_processes.append((int(pid), description, line[:80]))
|
|
1082
|
+
except Exception:
|
|
1083
|
+
pass
|
|
1084
|
+
except Exception as e:
|
|
1085
|
+
console.print(f"[yellow]Warning:[/] Error searching for {description}: {e}")
|
|
1086
|
+
|
|
1087
|
+
if not found_processes:
|
|
1088
|
+
console.print("[green]No orphaned processes found.[/] Nothing to clean.\n")
|
|
1089
|
+
raise typer.Exit(0)
|
|
1090
|
+
|
|
1091
|
+
# Show what was found
|
|
1092
|
+
console.print(f"[bold]Found {len(found_processes)} process(es):[/]\n")
|
|
1093
|
+
for pid, description, info in found_processes:
|
|
1094
|
+
console.print(f" [yellow]PID {pid}[/] - {description}")
|
|
1095
|
+
console.print(f" [dim]{info[:100]}{'...' if len(info) > 100 else ''}[/]")
|
|
1096
|
+
|
|
1097
|
+
if dry_run:
|
|
1098
|
+
console.print("\n[dim]Dry run - no processes killed.[/]\n")
|
|
1099
|
+
raise typer.Exit(0)
|
|
1100
|
+
|
|
1101
|
+
# Confirm
|
|
1102
|
+
if not force:
|
|
1103
|
+
console.print()
|
|
1104
|
+
confirm = typer.confirm(f"Kill {len(found_processes)} process(es)?", default=False)
|
|
1105
|
+
if not confirm:
|
|
1106
|
+
console.print("[dim]Aborted.[/]")
|
|
1107
|
+
raise typer.Exit(0)
|
|
1108
|
+
|
|
1109
|
+
# Kill processes
|
|
1110
|
+
console.print("\n[bold]Cleaning up...[/]")
|
|
1111
|
+
killed = 0
|
|
1112
|
+
failed = 0
|
|
1113
|
+
|
|
1114
|
+
for pid, description, _ in found_processes:
|
|
1115
|
+
try:
|
|
1116
|
+
# First try SIGTERM
|
|
1117
|
+
os.kill(pid, signal.SIGTERM)
|
|
1118
|
+
console.print(f" [green]✓[/] Killed PID {pid} ({description})")
|
|
1119
|
+
killed += 1
|
|
1120
|
+
except ProcessLookupError:
|
|
1121
|
+
console.print(f" [dim]○[/] PID {pid} already gone")
|
|
1122
|
+
except PermissionError:
|
|
1123
|
+
console.print(f" [red]✗[/] PID {pid} - permission denied (try sudo)")
|
|
1124
|
+
failed += 1
|
|
1125
|
+
except Exception as e:
|
|
1126
|
+
console.print(f" [red]✗[/] PID {pid} - {e}")
|
|
1127
|
+
failed += 1
|
|
1128
|
+
|
|
1129
|
+
console.print(f"\n[bold green]Cleanup complete.[/] Killed {killed}, failed {failed}.\n")
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
@app.command()
|
|
1133
|
+
def interactive(
|
|
1134
|
+
default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
|
|
1135
|
+
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
|
+
):
|
|
1139
|
+
"""
|
|
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.
|
|
1144
|
+
|
|
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.
|
|
1147
|
+
|
|
1148
|
+
[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
|
|
1164
|
+
|
|
1165
|
+
[bold]Examples:[/]
|
|
1166
|
+
$ zwarm interactive
|
|
1167
|
+
> spawn "Build auth module" --dir ~/api
|
|
1168
|
+
> spawn "Fix tests" --dir ~/api
|
|
1169
|
+
> c abc123 "Now add error handling"
|
|
1170
|
+
> ls
|
|
1171
|
+
> ? abc123
|
|
1172
|
+
"""
|
|
1173
|
+
from zwarm.adapters import get_adapter, list_adapters
|
|
1174
|
+
from zwarm.core.models import ConversationSession, SessionStatus, SessionMode
|
|
1175
|
+
import argparse
|
|
1176
|
+
|
|
1177
|
+
# Initialize adapter (same as orchestrator uses)
|
|
1178
|
+
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)
|
|
1368
|
+
|
|
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
|
+
|
|
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 ''}[/]")
|
|
1483
|
+
|
|
1484
|
+
import subprocess
|
|
1485
|
+
from uuid import uuid4
|
|
1486
|
+
|
|
1487
|
+
# Generate instance ID for tracking
|
|
1488
|
+
instance_id = str(uuid4())[:8]
|
|
1489
|
+
|
|
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
|
+
]
|
|
1497
|
+
|
|
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"
|
|
1502
|
+
|
|
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
|
+
)
|
|
1512
|
+
|
|
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
|
|
1525
|
+
|
|
1526
|
+
manager = CodexSessionManager(default_dir / ".zwarm")
|
|
1527
|
+
session = manager.get_session(session_id)
|
|
1528
|
+
|
|
1529
|
+
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}[/]")
|
|
1558
|
+
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)
|
|
1841
|
+
|
|
1842
|
+
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)
|
|
1865
|
+
|
|
1866
|
+
if not running_sessions:
|
|
1867
|
+
console.print("[dim]No running sessions to kill.[/]")
|
|
1868
|
+
return
|
|
1869
|
+
|
|
1870
|
+
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)
|
|
1894
|
+
|
|
1895
|
+
if not session:
|
|
1896
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
1897
|
+
return
|
|
1898
|
+
|
|
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)
|
|
1983
|
+
|
|
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)
|
|
1991
|
+
|
|
1992
|
+
elif cmd == "check":
|
|
1993
|
+
if not args:
|
|
1994
|
+
console.print(" [red]Usage:[/] check SESSION_ID")
|
|
1995
|
+
else:
|
|
1996
|
+
do_check(args[0])
|
|
1997
|
+
|
|
1998
|
+
elif cmd == "kill":
|
|
1999
|
+
if not args:
|
|
2000
|
+
console.print(" [red]Usage:[/] kill SESSION_ID")
|
|
2001
|
+
else:
|
|
2002
|
+
do_kill(args[0])
|
|
2003
|
+
|
|
2004
|
+
elif cmd == "killall":
|
|
2005
|
+
do_killall()
|
|
2006
|
+
|
|
2007
|
+
elif cmd == "clean":
|
|
2008
|
+
do_clean()
|
|
2009
|
+
|
|
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])
|
|
2015
|
+
|
|
2016
|
+
else:
|
|
2017
|
+
console.print(f" [yellow]Unknown command:[/] {cmd}")
|
|
2018
|
+
console.print(" [dim]Type 'help' for available commands[/]")
|
|
2019
|
+
|
|
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}")
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
# =============================================================================
|
|
2030
|
+
# Session Manager Commands (background Codex processes)
|
|
2031
|
+
# =============================================================================
|
|
2032
|
+
|
|
2033
|
+
session_app = typer.Typer(
|
|
2034
|
+
name="session",
|
|
2035
|
+
help="""
|
|
2036
|
+
[bold cyan]Codex Session Manager[/]
|
|
2037
|
+
|
|
2038
|
+
Manage background Codex sessions. Run multiple codex tasks in parallel,
|
|
2039
|
+
monitor their progress, and inject follow-up messages.
|
|
2040
|
+
|
|
2041
|
+
[bold]COMMANDS[/]
|
|
2042
|
+
[cyan]start[/] Start a new session in the background
|
|
2043
|
+
[cyan]ls[/] List all sessions
|
|
2044
|
+
[cyan]show[/] Show messages for a session
|
|
2045
|
+
[cyan]logs[/] Show raw JSONL output
|
|
2046
|
+
[cyan]inject[/] Inject a follow-up message
|
|
2047
|
+
[cyan]kill[/] Kill a running session
|
|
2048
|
+
[cyan]clean[/] Remove old completed sessions
|
|
2049
|
+
|
|
2050
|
+
[bold]EXAMPLES[/]
|
|
2051
|
+
[dim]# Start a background session[/]
|
|
2052
|
+
$ zwarm session start "Add tests for auth module"
|
|
2053
|
+
|
|
2054
|
+
[dim]# List all sessions[/]
|
|
2055
|
+
$ zwarm session ls
|
|
2056
|
+
|
|
2057
|
+
[dim]# View session messages[/]
|
|
2058
|
+
$ zwarm session show abc123
|
|
2059
|
+
|
|
2060
|
+
[dim]# Continue a completed session[/]
|
|
2061
|
+
$ zwarm session inject abc123 "Also add edge case tests"
|
|
2062
|
+
""",
|
|
2063
|
+
)
|
|
2064
|
+
app.add_typer(session_app, name="session")
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
@session_app.command("start")
|
|
2068
|
+
def session_start(
|
|
2069
|
+
task: Annotated[str, typer.Argument(help="Task description")],
|
|
2070
|
+
working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
|
|
2071
|
+
model: Annotated[str, typer.Option("--model", "-m", help="Model to use")] = "gpt-5.1-codex-mini",
|
|
2072
|
+
):
|
|
2073
|
+
"""
|
|
2074
|
+
Start a new Codex session in the background.
|
|
2075
|
+
|
|
2076
|
+
The session runs independently and you can check on it later.
|
|
2077
|
+
|
|
2078
|
+
[bold]Examples:[/]
|
|
2079
|
+
[dim]# Simple task[/]
|
|
2080
|
+
$ zwarm session start "Fix the bug in auth.py"
|
|
2081
|
+
|
|
2082
|
+
[dim]# With specific model[/]
|
|
2083
|
+
$ zwarm session start "Refactor the API" --model gpt-5.1-codex-max
|
|
2084
|
+
"""
|
|
2085
|
+
from zwarm.sessions import CodexSessionManager
|
|
2086
|
+
|
|
2087
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
2088
|
+
session = manager.start_session(
|
|
2089
|
+
task=task,
|
|
2090
|
+
working_dir=working_dir,
|
|
2091
|
+
model=model,
|
|
2092
|
+
)
|
|
2093
|
+
|
|
2094
|
+
console.print()
|
|
2095
|
+
console.print(f"[green]✓ Session started[/] [bold cyan]{session.short_id}[/]")
|
|
2096
|
+
console.print()
|
|
2097
|
+
console.print(f" [dim]Task:[/] {task[:70]}{'...' if len(task) > 70 else ''}")
|
|
2098
|
+
console.print(f" [dim]Model:[/] {model}")
|
|
2099
|
+
console.print(f" [dim]PID:[/] {session.pid}")
|
|
2100
|
+
console.print()
|
|
2101
|
+
console.print("[dim]Commands:[/]")
|
|
2102
|
+
console.print(f" [cyan]zwarm session ls[/] [dim]List all sessions[/]")
|
|
2103
|
+
console.print(f" [cyan]zwarm session show {session.short_id}[/] [dim]View messages[/]")
|
|
2104
|
+
console.print(f" [cyan]zwarm session logs {session.short_id} -f[/] [dim]Follow live output[/]")
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
@session_app.command("ls")
|
|
2108
|
+
def session_list(
|
|
2109
|
+
working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
|
|
2110
|
+
all_sessions: Annotated[bool, typer.Option("--all", "-a", help="Show all sessions including completed")] = False,
|
|
2111
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
2112
|
+
):
|
|
2113
|
+
"""
|
|
2114
|
+
List all sessions.
|
|
2115
|
+
|
|
2116
|
+
Shows running sessions by default. Use --all to include completed.
|
|
2117
|
+
|
|
2118
|
+
[bold]Examples:[/]
|
|
2119
|
+
$ zwarm session ls
|
|
2120
|
+
$ zwarm session ls --all
|
|
2121
|
+
"""
|
|
2122
|
+
from zwarm.sessions import CodexSessionManager, SessionStatus
|
|
2123
|
+
|
|
2124
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
2125
|
+
sessions = manager.list_sessions()
|
|
2126
|
+
|
|
2127
|
+
if not all_sessions:
|
|
2128
|
+
sessions = [s for s in sessions if s.status == SessionStatus.RUNNING]
|
|
2129
|
+
|
|
2130
|
+
if json_output:
|
|
2131
|
+
import json
|
|
2132
|
+
console.print(json.dumps([s.to_dict() for s in sessions], indent=2))
|
|
2133
|
+
return
|
|
2134
|
+
|
|
2135
|
+
if not sessions:
|
|
2136
|
+
if all_sessions:
|
|
2137
|
+
console.print("[dim]No sessions found.[/]")
|
|
2138
|
+
else:
|
|
2139
|
+
console.print("[dim]No running sessions.[/]")
|
|
2140
|
+
console.print("[dim]Use --all to see completed sessions, or start one with:[/]")
|
|
2141
|
+
console.print(" zwarm session start \"your task here\"")
|
|
2142
|
+
return
|
|
2143
|
+
|
|
2144
|
+
# Show status summary
|
|
2145
|
+
running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
|
|
2146
|
+
completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
|
|
2147
|
+
failed_count = sum(1 for s in sessions if s.status == SessionStatus.FAILED)
|
|
2148
|
+
|
|
2149
|
+
summary_parts = []
|
|
2150
|
+
if running_count:
|
|
2151
|
+
summary_parts.append(f"[yellow]⟳ {running_count} running[/]")
|
|
2152
|
+
if completed_count:
|
|
2153
|
+
summary_parts.append(f"[green]✓ {completed_count} completed[/]")
|
|
2154
|
+
if failed_count:
|
|
2155
|
+
summary_parts.append(f"[red]✗ {failed_count} failed[/]")
|
|
2156
|
+
|
|
2157
|
+
if summary_parts:
|
|
2158
|
+
console.print(" │ ".join(summary_parts))
|
|
2159
|
+
console.print()
|
|
2160
|
+
|
|
2161
|
+
# Build table
|
|
2162
|
+
table = Table(box=None, show_header=True, header_style="bold dim")
|
|
2163
|
+
table.add_column("ID", style="cyan")
|
|
2164
|
+
table.add_column("", width=2) # Status icon
|
|
2165
|
+
table.add_column("Task", max_width=50)
|
|
2166
|
+
table.add_column("Runtime", justify="right", style="dim")
|
|
2167
|
+
table.add_column("Tokens", justify="right", style="dim")
|
|
2168
|
+
|
|
2169
|
+
status_icons = {
|
|
2170
|
+
SessionStatus.RUNNING: "[yellow]⟳[/]",
|
|
2171
|
+
SessionStatus.COMPLETED: "[green]✓[/]",
|
|
2172
|
+
SessionStatus.FAILED: "[red]✗[/]",
|
|
2173
|
+
SessionStatus.KILLED: "[dim]⊘[/]",
|
|
2174
|
+
SessionStatus.PENDING: "[dim]○[/]",
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
for session in sessions:
|
|
2178
|
+
status_icon = status_icons.get(session.status, "?")
|
|
2179
|
+
task_preview = session.task[:47] + "..." if len(session.task) > 50 else session.task
|
|
2180
|
+
tokens = session.token_usage.get("total_tokens", 0)
|
|
2181
|
+
tokens_str = f"{tokens:,}" if tokens else "-"
|
|
2182
|
+
|
|
2183
|
+
table.add_row(
|
|
2184
|
+
session.short_id,
|
|
2185
|
+
status_icon,
|
|
2186
|
+
task_preview,
|
|
2187
|
+
session.runtime,
|
|
2188
|
+
tokens_str,
|
|
2189
|
+
)
|
|
2190
|
+
|
|
2191
|
+
console.print()
|
|
2192
|
+
console.print(table)
|
|
2193
|
+
console.print()
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
@session_app.command("show")
|
|
2197
|
+
def session_show(
|
|
2198
|
+
session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
|
|
2199
|
+
working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
|
|
2200
|
+
raw: Annotated[bool, typer.Option("--raw", "-r", help="Show raw messages without formatting")] = False,
|
|
2201
|
+
):
|
|
2202
|
+
"""
|
|
2203
|
+
Show messages for a session.
|
|
2204
|
+
|
|
2205
|
+
Displays the conversation history with nice formatting.
|
|
2206
|
+
|
|
2207
|
+
[bold]Examples:[/]
|
|
2208
|
+
$ zwarm session show abc123
|
|
2209
|
+
$ zwarm session show abc123 --raw
|
|
2210
|
+
"""
|
|
2211
|
+
from zwarm.sessions import CodexSessionManager
|
|
2212
|
+
|
|
2213
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
2214
|
+
session = manager.get_session(session_id)
|
|
2215
|
+
|
|
2216
|
+
if not session:
|
|
2217
|
+
console.print(f"[red]Session not found:[/] {session_id}")
|
|
2218
|
+
raise typer.Exit(1)
|
|
2219
|
+
|
|
2220
|
+
# Get messages
|
|
2221
|
+
messages = manager.get_messages(session.id)
|
|
2222
|
+
|
|
2223
|
+
# Status styling
|
|
2224
|
+
status_display = {
|
|
2225
|
+
"running": "[yellow]⟳ running[/]",
|
|
2226
|
+
"completed": "[green]✓ completed[/]",
|
|
2227
|
+
"failed": "[red]✗ failed[/]",
|
|
2228
|
+
"killed": "[dim]⊘ killed[/]",
|
|
2229
|
+
"pending": "[dim]○ pending[/]",
|
|
2230
|
+
}.get(session.status.value, session.status.value)
|
|
2231
|
+
|
|
2232
|
+
console.print()
|
|
2233
|
+
console.print(f"[bold cyan]Session {session.short_id}[/] {status_display}")
|
|
2234
|
+
console.print(f"[dim]Task:[/] {session.task}")
|
|
2235
|
+
console.print(f"[dim]Model:[/] {session.model} [dim]│[/] [dim]Turn:[/] {session.turn} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
|
|
2236
|
+
console.print()
|
|
2237
|
+
|
|
2238
|
+
if not messages:
|
|
2239
|
+
if session.status.value == "running":
|
|
2240
|
+
console.print("[yellow]Session is still running...[/]")
|
|
2241
|
+
console.print("[dim]Check back later for output.[/]")
|
|
2242
|
+
else:
|
|
2243
|
+
console.print("[dim]No messages captured.[/]")
|
|
2244
|
+
return
|
|
2245
|
+
|
|
2246
|
+
# Display messages
|
|
2247
|
+
for msg in messages:
|
|
2248
|
+
if msg.role == "user":
|
|
2249
|
+
if raw:
|
|
2250
|
+
console.print(f"[bold blue]USER:[/] {msg.content}")
|
|
2251
|
+
else:
|
|
2252
|
+
console.print(Panel(msg.content, title="[bold blue]User[/]", border_style="blue"))
|
|
2253
|
+
|
|
2254
|
+
elif msg.role == "assistant":
|
|
2255
|
+
if raw:
|
|
2256
|
+
console.print(f"[bold green]ASSISTANT:[/] {msg.content}")
|
|
2257
|
+
else:
|
|
2258
|
+
# Truncate very long messages
|
|
2259
|
+
content = msg.content
|
|
2260
|
+
if len(content) > 2000:
|
|
2261
|
+
content = content[:2000] + "\n\n[dim]... (truncated, use --raw for full output)[/]"
|
|
2262
|
+
console.print(Panel(content, title="[bold green]Assistant[/]", border_style="green"))
|
|
2263
|
+
|
|
2264
|
+
elif msg.role == "tool":
|
|
2265
|
+
if raw:
|
|
2266
|
+
console.print(f"[dim]TOOL: {msg.content}[/]")
|
|
2267
|
+
else:
|
|
2268
|
+
# Extract function name if present
|
|
2269
|
+
content = msg.content
|
|
2270
|
+
if content.startswith("[Calling:"):
|
|
2271
|
+
console.print(f" [dim]⚙[/] {content}")
|
|
2272
|
+
elif content.startswith("[Output]"):
|
|
2273
|
+
console.print(f" [dim]└─ {content[9:]}[/]") # Skip "[Output]:"
|
|
2274
|
+
else:
|
|
2275
|
+
console.print(f" [dim]{content}[/]")
|
|
2276
|
+
|
|
2277
|
+
console.print()
|
|
2278
|
+
|
|
2279
|
+
# Show token usage
|
|
2280
|
+
if session.token_usage:
|
|
2281
|
+
tokens = session.token_usage
|
|
2282
|
+
console.print(f"[dim]Tokens: {tokens.get('input_tokens', 0):,} in / {tokens.get('output_tokens', 0):,} out[/]")
|
|
2283
|
+
|
|
2284
|
+
# Show error if any
|
|
2285
|
+
if session.error:
|
|
2286
|
+
console.print(f"[red]Error:[/] {session.error}")
|
|
2287
|
+
|
|
2288
|
+
# Helpful tip
|
|
2289
|
+
console.print()
|
|
2290
|
+
if session.status.value == "running":
|
|
2291
|
+
console.print(f"[dim]Tip: Use 'zwarm session logs {session.short_id} --follow' to watch live output[/]")
|
|
2292
|
+
elif session.status.value == "completed":
|
|
2293
|
+
console.print(f"[dim]Tip: Use 'zwarm session inject {session.short_id} \"your message\"' to continue the conversation[/]")
|
|
2294
|
+
|
|
2295
|
+
|
|
2296
|
+
@session_app.command("logs")
|
|
2297
|
+
def session_logs(
|
|
2298
|
+
session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
|
|
2299
|
+
working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
|
|
2300
|
+
turn: Annotated[Optional[int], typer.Option("--turn", "-t", help="Specific turn number")] = None,
|
|
2301
|
+
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow output (like tail -f)")] = False,
|
|
2302
|
+
):
|
|
2303
|
+
"""
|
|
2304
|
+
Show raw JSONL logs for a session.
|
|
2305
|
+
|
|
2306
|
+
[bold]Examples:[/]
|
|
2307
|
+
$ zwarm session logs abc123
|
|
2308
|
+
$ zwarm session logs abc123 --follow
|
|
2309
|
+
"""
|
|
2310
|
+
from zwarm.sessions import CodexSessionManager
|
|
2311
|
+
|
|
2312
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
2313
|
+
session = manager.get_session(session_id)
|
|
2314
|
+
|
|
2315
|
+
if not session:
|
|
2316
|
+
console.print(f"[red]Session not found:[/] {session_id}")
|
|
2317
|
+
raise typer.Exit(1)
|
|
2318
|
+
|
|
2319
|
+
if follow and session.status.value == "running":
|
|
2320
|
+
# Follow mode - tail the file
|
|
2321
|
+
import time
|
|
2322
|
+
output_path = manager._output_path(session.id, turn or session.turn)
|
|
2323
|
+
|
|
2324
|
+
console.print(f"[dim]Following {output_path}... (Ctrl+C to stop)[/]")
|
|
2325
|
+
console.print()
|
|
2326
|
+
|
|
2327
|
+
try:
|
|
2328
|
+
with open(output_path, "r") as f:
|
|
2329
|
+
# Print existing content
|
|
2330
|
+
for line in f:
|
|
2331
|
+
console.print(line.rstrip())
|
|
2332
|
+
|
|
2333
|
+
# Follow new content
|
|
2334
|
+
while session.is_running:
|
|
2335
|
+
line = f.readline()
|
|
2336
|
+
if line:
|
|
2337
|
+
console.print(line.rstrip())
|
|
2338
|
+
else:
|
|
2339
|
+
time.sleep(0.5)
|
|
2340
|
+
# Refresh session status
|
|
2341
|
+
session = manager.get_session(session_id)
|
|
2342
|
+
|
|
2343
|
+
except KeyboardInterrupt:
|
|
2344
|
+
console.print("\n[dim]Stopped following.[/]")
|
|
2345
|
+
|
|
2346
|
+
else:
|
|
2347
|
+
# Just print the output
|
|
2348
|
+
output = manager.get_output(session.id, turn)
|
|
2349
|
+
if output:
|
|
2350
|
+
console.print(output)
|
|
2351
|
+
else:
|
|
2352
|
+
console.print("[dim]No output yet.[/]")
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
@session_app.command("inject")
|
|
2356
|
+
def session_inject(
|
|
2357
|
+
session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
|
|
2358
|
+
message: Annotated[str, typer.Argument(help="Follow-up message to inject")],
|
|
2359
|
+
working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
|
|
2360
|
+
):
|
|
2361
|
+
"""
|
|
2362
|
+
Inject a follow-up message into a completed session.
|
|
2363
|
+
|
|
2364
|
+
This continues the conversation with context from the previous turn.
|
|
2365
|
+
Can only be used on completed (not running) sessions.
|
|
2366
|
+
|
|
2367
|
+
[bold]Examples:[/]
|
|
2368
|
+
$ zwarm session inject abc123 "Also add edge case tests"
|
|
2369
|
+
$ zwarm session inject abc123 "Good, now refactor the code"
|
|
2370
|
+
"""
|
|
2371
|
+
from zwarm.sessions import CodexSessionManager, SessionStatus
|
|
2372
|
+
|
|
2373
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
2374
|
+
session = manager.get_session(session_id)
|
|
2375
|
+
|
|
2376
|
+
if not session:
|
|
2377
|
+
console.print(f"[red]Session not found:[/] {session_id}")
|
|
2378
|
+
raise typer.Exit(1)
|
|
2379
|
+
|
|
2380
|
+
if session.status == SessionStatus.RUNNING:
|
|
2381
|
+
console.print("[yellow]Session is still running.[/]")
|
|
2382
|
+
console.print("[dim]Wait for it to complete, then inject a follow-up.[/]")
|
|
2383
|
+
raise typer.Exit(1)
|
|
2384
|
+
|
|
2385
|
+
# Inject the message
|
|
2386
|
+
updated_session = manager.inject_message(session.id, message)
|
|
2387
|
+
|
|
2388
|
+
if not updated_session:
|
|
2389
|
+
console.print("[red]Failed to inject message.[/]")
|
|
2390
|
+
raise typer.Exit(1)
|
|
2391
|
+
|
|
2392
|
+
console.print()
|
|
2393
|
+
console.print(f"[green]✓ Message injected[/] Turn {updated_session.turn} started")
|
|
2394
|
+
console.print()
|
|
2395
|
+
console.print(f" [dim]Message:[/] {message[:70]}{'...' if len(message) > 70 else ''}")
|
|
2396
|
+
console.print(f" [dim]PID:[/] {updated_session.pid}")
|
|
2397
|
+
console.print()
|
|
2398
|
+
console.print("[dim]Commands:[/]")
|
|
2399
|
+
console.print(f" [cyan]zwarm session show {session.short_id}[/] [dim]View messages[/]")
|
|
2400
|
+
console.print(f" [cyan]zwarm session logs {session.short_id} -f[/] [dim]Follow live output[/]")
|
|
2401
|
+
|
|
2402
|
+
|
|
2403
|
+
@session_app.command("kill")
|
|
2404
|
+
def session_kill(
|
|
2405
|
+
session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
|
|
2406
|
+
working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
|
|
2407
|
+
):
|
|
2408
|
+
"""
|
|
2409
|
+
Kill a running session.
|
|
2410
|
+
|
|
2411
|
+
[bold]Examples:[/]
|
|
2412
|
+
$ zwarm session kill abc123
|
|
2413
|
+
"""
|
|
2414
|
+
from zwarm.sessions import CodexSessionManager
|
|
2415
|
+
|
|
2416
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
2417
|
+
session = manager.get_session(session_id)
|
|
2418
|
+
|
|
2419
|
+
if not session:
|
|
2420
|
+
console.print(f"[red]Session not found:[/] {session_id}")
|
|
2421
|
+
raise typer.Exit(1)
|
|
2422
|
+
|
|
2423
|
+
if not session.is_running:
|
|
2424
|
+
console.print(f"[yellow]Session {session.short_id} is not running.[/]")
|
|
2425
|
+
console.print(f" [dim]Status:[/] {session.status.value}")
|
|
2426
|
+
return
|
|
2427
|
+
|
|
2428
|
+
killed = manager.kill_session(session.id)
|
|
2429
|
+
|
|
2430
|
+
if killed:
|
|
2431
|
+
console.print(f"[green]Killed session {session.short_id}[/]")
|
|
2432
|
+
else:
|
|
2433
|
+
console.print(f"[red]Failed to kill session {session.short_id}[/]")
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
@session_app.command("clean")
|
|
2437
|
+
def session_clean(
|
|
2438
|
+
working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
|
|
2439
|
+
keep_days: Annotated[int, typer.Option("--keep-days", "-k", help="Keep sessions newer than N days")] = 7,
|
|
2440
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
|
|
2441
|
+
):
|
|
2442
|
+
"""
|
|
2443
|
+
Remove old completed sessions.
|
|
2444
|
+
|
|
2445
|
+
[bold]Examples:[/]
|
|
2446
|
+
$ zwarm session clean
|
|
2447
|
+
$ zwarm session clean --keep-days 1
|
|
2448
|
+
"""
|
|
2449
|
+
from zwarm.sessions import CodexSessionManager, SessionStatus
|
|
2450
|
+
|
|
2451
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
2452
|
+
sessions = manager.list_sessions()
|
|
2453
|
+
|
|
2454
|
+
# Count cleanable sessions
|
|
2455
|
+
cleanable = [s for s in sessions if s.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED)]
|
|
2456
|
+
|
|
2457
|
+
if not cleanable:
|
|
2458
|
+
console.print("[dim]No sessions to clean.[/]")
|
|
2459
|
+
return
|
|
2460
|
+
|
|
2461
|
+
console.print(f"Found {len(cleanable)} completed/failed sessions.")
|
|
2462
|
+
|
|
2463
|
+
if not yes:
|
|
2464
|
+
confirm = typer.confirm(f"Remove sessions older than {keep_days} days?")
|
|
2465
|
+
if not confirm:
|
|
2466
|
+
console.print("[dim]Cancelled.[/]")
|
|
2467
|
+
return
|
|
2468
|
+
|
|
2469
|
+
cleaned = manager.cleanup_completed(keep_days)
|
|
2470
|
+
console.print(f"[green]Cleaned {cleaned} sessions.[/]")
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
# =============================================================================
|
|
2474
|
+
# Main callback and entry point
|
|
2475
|
+
# =============================================================================
|
|
2476
|
+
|
|
2477
|
+
def _get_version() -> str:
|
|
2478
|
+
"""Get version from package metadata."""
|
|
2479
|
+
try:
|
|
2480
|
+
from importlib.metadata import version as get_pkg_version
|
|
2481
|
+
return get_pkg_version("zwarm")
|
|
2482
|
+
except Exception:
|
|
2483
|
+
return "0.0.0"
|
|
2484
|
+
|
|
2485
|
+
|
|
2486
|
+
@app.callback(invoke_without_command=True)
|
|
2487
|
+
def main_callback(
|
|
2488
|
+
ctx: typer.Context,
|
|
2489
|
+
version: Annotated[bool, typer.Option("--version", "-V", help="Show version")] = False,
|
|
2490
|
+
):
|
|
2491
|
+
"""Main callback for version flag."""
|
|
2492
|
+
if version:
|
|
2493
|
+
console.print(f"[bold cyan]zwarm[/] version [green]{_get_version()}[/]")
|
|
2494
|
+
raise typer.Exit()
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
def main():
|
|
2498
|
+
"""Entry point for the CLI."""
|
|
2499
|
+
app()
|
|
2500
|
+
|
|
2501
|
+
|
|
2502
|
+
if __name__ == "__main__":
|
|
2503
|
+
main()
|