zwarm 1.3.5__py3-none-any.whl → 1.3.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zwarm/cli/main.py CHANGED
@@ -1090,7 +1090,6 @@ def clean(
1090
1090
 
1091
1091
  @app.command()
1092
1092
  def interactive(
1093
- default_adapter: Annotated[AdapterType, typer.Option("--adapter", "-a", help="Default executor adapter")] = AdapterType.codex_mcp,
1094
1093
  default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
1095
1094
  model: Annotated[Optional[str], typer.Option("--model", help="Default model override")] = None,
1096
1095
  state_dir: Annotated[Path, typer.Option("--state-dir", help="State directory for persistence")] = Path(".zwarm"),
@@ -1098,156 +1097,132 @@ def interactive(
1098
1097
  """
1099
1098
  Universal multi-agent CLI for commanding coding agents.
1100
1099
 
1101
- Spawn multiple agents (Codex, Claude Code) across different directories,
1102
- manage them interactively, and view their outputs. You are the orchestrator.
1100
+ Spawn multiple agents across different directories, manage them interactively,
1101
+ and view their outputs. You are the orchestrator.
1103
1102
 
1104
1103
  [bold]Commands:[/]
1105
- [cyan]spawn[/] "task" [opts] Start a session (see spawn --help)
1104
+ [cyan]spawn[/] "task" [opts] Start a coding agent session
1105
+ [cyan]orchestrate[/] "task" Start an orchestrator agent
1106
1106
  [cyan]ls[/] / [cyan]list[/] Dashboard of all sessions
1107
- [cyan]?[/] / [cyan]check[/] ID Check session status & response
1108
- [cyan]c[/] / [cyan]converse[/] ID "msg" Continue sync conversation
1109
- [cyan]view[/] ID Open session output in $EDITOR
1107
+ [cyan]?[/] / [cyan]show[/] ID Check session status & response
1108
+ [cyan]c[/] / [cyan]continue[/] ID "msg" Continue a completed session
1109
+ [cyan]logs[/] ID Show raw JSONL output
1110
1110
  [cyan]kill[/] ID Stop a session
1111
1111
  [cyan]killall[/] Stop all running sessions
1112
- [cyan]refresh[/] Poll all async sessions
1113
1112
  [cyan]q[/] / [cyan]quit[/] Exit
1114
1113
 
1115
1114
  [bold]Spawn Options:[/]
1116
- spawn "task" --dir ~/project --adapter claude_code --async
1115
+ spawn "task" --dir ~/project --model gpt-5.1-codex-max
1117
1116
 
1118
1117
  [bold]Examples:[/]
1119
1118
  $ zwarm interactive
1120
- > spawn "Build auth module" --dir ~/api --async
1121
- > spawn "Fix tests" --dir ~/api --async
1122
- > spawn "Add docs" --dir ~/docs
1119
+ > spawn "Build auth module" --dir ~/api
1120
+ > spawn "Fix tests" --dir ~/api
1121
+ > orchestrate "Refactor the entire API layer"
1123
1122
  > ls
1124
1123
  > ? abc123
1125
- > view abc123
1126
1124
  """
1127
- from zwarm.adapters import get_adapter, list_adapters
1128
- from zwarm.core.models import ConversationSession
1125
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1129
1126
  import argparse
1130
- import tempfile
1131
1127
  import subprocess as sp
1132
1128
 
1133
- console.print("\n[bold cyan]zwarm interactive[/] - Universal Multi-Agent CLI\n")
1134
- console.print(f" Default adapter: [cyan]{default_adapter.value}[/]")
1135
- console.print(f" Default dir: {default_dir.absolute()}")
1136
- console.print(f" Available adapters: {', '.join(list_adapters())}")
1137
- console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
1129
+ # Initialize session manager
1130
+ manager = CodexSessionManager(state_dir)
1131
+ default_model = model or "gpt-5.1-codex-mini"
1138
1132
 
1139
- # Adapter cache (lazy-loaded per adapter type)
1140
- adapters: dict[str, Any] = {}
1141
-
1142
- def get_or_create_adapter(adapter_name: str, adapter_model: str | None = None) -> Any:
1143
- """Get cached adapter or create new one."""
1144
- key = f"{adapter_name}:{adapter_model or 'default'}"
1145
- if key not in adapters:
1146
- adapters[key] = get_adapter(adapter_name, model=adapter_model)
1147
- return adapters[key]
1148
-
1149
- # Session tracking with metadata
1150
- sessions: dict[str, ConversationSession] = {}
1151
- session_dirs: dict[str, Path] = {} # session_id -> working_dir
1152
- session_adapters: dict[str, str] = {} # session_id -> adapter_name
1153
-
1154
- # Persistence directory
1155
- interactive_state_dir = state_dir / "interactive"
1156
- interactive_state_dir.mkdir(parents=True, exist_ok=True)
1157
-
1158
- def short_id(session_id: str) -> str:
1159
- """Get short display ID."""
1160
- return session_id[:8]
1161
-
1162
- def find_session(query: str) -> tuple[ConversationSession | None, str | None]:
1163
- """Find session by full or partial ID. Returns (session, full_id)."""
1164
- if query in sessions:
1165
- return sessions[query], query
1166
- for sid, session in sessions.items():
1167
- if sid.startswith(query):
1168
- return session, sid
1169
- return None, None
1170
-
1171
- def save_session_output(session: ConversationSession) -> Path:
1172
- """Save session output to file for viewing."""
1173
- session_dir = interactive_state_dir / short_id(session.id)
1174
- session_dir.mkdir(exist_ok=True)
1175
-
1176
- output_file = session_dir / "output.txt"
1177
- with open(output_file, "w") as f:
1178
- f.write(f"Session: {session.id}\n")
1179
- f.write(f"Task: {session.task_description}\n")
1180
- f.write(f"Adapter: {session.adapter}\n")
1181
- f.write(f"Model: {session.model}\n")
1182
- f.write(f"Status: {session.status.value}\n")
1183
- f.write(f"Mode: {session.mode.value}\n")
1184
- f.write(f"Working Dir: {session.working_dir}\n")
1185
- f.write(f"Tokens: {session.token_usage}\n")
1186
- f.write("\n" + "="*60 + "\n\n")
1187
-
1188
- for msg in session.messages:
1189
- f.write(f"[{msg.role.upper()}]\n")
1190
- f.write(msg.content)
1191
- f.write("\n\n" + "-"*40 + "\n\n")
1192
-
1193
- return output_file
1133
+ console.print("\n[bold cyan]zwarm interactive[/] - Multi-Agent Command Center\n")
1134
+ console.print(f" Working dir: {default_dir.absolute()}")
1135
+ console.print(f" Model: [cyan]{default_model}[/]")
1136
+ console.print(f" Sessions: {state_dir / 'sessions'}")
1137
+ console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
1194
1138
 
1195
1139
  def show_help():
1196
1140
  help_table = Table(show_header=False, box=None, padding=(0, 2))
1197
1141
  help_table.add_column("Command", style="cyan", width=35)
1198
1142
  help_table.add_column("Description")
1199
- help_table.add_row('spawn "task" [options]', "Start new session")
1200
- help_table.add_row(" --dir PATH", "Working directory for this session")
1201
- help_table.add_row(" --adapter NAME", f"Adapter ({', '.join(list_adapters())})")
1202
- help_table.add_row(" --async / -a", "Fire-and-forget mode")
1143
+ help_table.add_row('spawn "task" [options]', "Start a coding agent")
1144
+ help_table.add_row(" --dir PATH", "Working directory")
1203
1145
  help_table.add_row(" --model NAME", "Model override")
1204
1146
  help_table.add_row("", "")
1205
- help_table.add_row("ls / list", "Dashboard of all sessions")
1206
- help_table.add_row("? / check ID", "Check session status & messages")
1207
- help_table.add_row("c / converse ID \"msg\"", "Continue sync conversation")
1208
- help_table.add_row("view ID", "Open session output in $EDITOR")
1209
- help_table.add_row("raw ID", "Show raw session JSON")
1210
- help_table.add_row("kill ID", "Stop a session")
1147
+ help_table.add_row('orchestrate "task" [options]', "Start an orchestrator agent")
1148
+ help_table.add_row(" --dir PATH", "Working directory")
1149
+ help_table.add_row("", "")
1150
+ help_table.add_row("ls / list [--all]", "Dashboard of sessions")
1151
+ help_table.add_row("? / show ID", "Show session messages")
1152
+ help_table.add_row("c / continue ID \"msg\"", "Continue a completed session")
1153
+ help_table.add_row("logs ID [-f]", "Show raw JSONL output")
1154
+ help_table.add_row("kill ID", "Stop a running session")
1211
1155
  help_table.add_row("killall", "Stop all running sessions")
1212
- help_table.add_row("refresh", "Poll all async sessions for updates")
1213
- help_table.add_row("q / quit", "Exit interactive mode")
1156
+ help_table.add_row("clean", "Remove completed sessions")
1157
+ help_table.add_row("q / quit", "Exit")
1214
1158
  console.print(help_table)
1215
1159
 
1216
- def show_sessions():
1160
+ def show_sessions(show_all: bool = False):
1161
+ sessions = manager.list_sessions()
1162
+
1163
+ if not show_all:
1164
+ # Show running + recent completed
1165
+ sessions = [s for s in sessions if s.status == SessionStatus.RUNNING or s.status == SessionStatus.COMPLETED][:20]
1166
+
1217
1167
  if not sessions:
1218
- console.print(" [dim]No sessions yet. Use 'spawn \"task\"' to start one.[/]")
1168
+ console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
1219
1169
  return
1220
1170
 
1221
- table = Table(title="Sessions Dashboard", show_header=True)
1171
+ # Status summary
1172
+ all_sessions = manager.list_sessions()
1173
+ running = sum(1 for s in all_sessions if s.status == SessionStatus.RUNNING)
1174
+ completed = sum(1 for s in all_sessions if s.status == SessionStatus.COMPLETED)
1175
+ failed = sum(1 for s in all_sessions if s.status == SessionStatus.FAILED)
1176
+
1177
+ summary_parts = []
1178
+ if running:
1179
+ summary_parts.append(f"[yellow]{running} running[/]")
1180
+ if completed:
1181
+ summary_parts.append(f"[green]{completed} done[/]")
1182
+ if failed:
1183
+ summary_parts.append(f"[red]{failed} failed[/]")
1184
+ if summary_parts:
1185
+ console.print(" | ".join(summary_parts))
1186
+ console.print()
1187
+
1188
+ table = Table(box=None, show_header=True, header_style="bold dim")
1222
1189
  table.add_column("ID", style="cyan", width=10)
1223
- table.add_column("Status", width=12)
1224
- table.add_column("Adapter", width=12)
1225
- table.add_column("Dir", width=20)
1226
- table.add_column("Tokens", width=8, justify="right")
1227
- table.add_column("Task", width=35)
1228
-
1229
- for sid, s in sessions.items():
1230
- status_icons = {
1231
- "active": "[green]● active[/]",
1232
- "completed": "[dim]✓ done[/]",
1233
- "failed": "[red]✗ failed[/]",
1234
- }
1235
- status = status_icons.get(s.status.value, f"? {s.status.value}")
1190
+ table.add_column("", width=2) # Status icon
1191
+ table.add_column("Source", width=10)
1192
+ table.add_column("Task", max_width=40)
1193
+ table.add_column("Runtime", justify="right", style="dim", width=8)
1194
+ table.add_column("Tokens", justify="right", style="dim", width=8)
1195
+
1196
+ status_icons = {
1197
+ SessionStatus.RUNNING: "[yellow]⟳[/]",
1198
+ SessionStatus.COMPLETED: "[green][/]",
1199
+ SessionStatus.FAILED: "[red][/]",
1200
+ SessionStatus.KILLED: "[dim][/]",
1201
+ SessionStatus.PENDING: "[dim]○[/]",
1202
+ }
1236
1203
 
1237
- # Add mode indicator
1238
- if s.mode.value == "async" and s.status.value == "active":
1239
- status = "[yellow] running[/]"
1240
-
1241
- work_dir = session_dirs.get(sid, s.working_dir)
1242
- dir_display = str(work_dir.name) if work_dir else "."
1204
+ for s in sessions:
1205
+ icon = status_icons.get(s.status, "?")
1206
+ task_preview = s.task[:37] + "..." if len(s.task) > 40 else s.task
1207
+ tokens = s.token_usage.get("total_tokens", 0)
1208
+ tokens_str = f"{tokens:,}" if tokens else "-"
1209
+
1210
+ # Source display with styling
1211
+ source = s.source_display
1212
+ if source == "you":
1213
+ source_styled = "[blue]you[/]"
1214
+ elif source.startswith("orch:"):
1215
+ source_styled = f"[magenta]{source}[/]"
1216
+ else:
1217
+ source_styled = f"[dim]{source}[/]"
1243
1218
 
1244
1219
  table.add_row(
1245
- short_id(sid),
1246
- status,
1247
- s.adapter,
1248
- dir_display,
1249
- str(s.token_usage.get("total_tokens", 0)),
1250
- s.task_description[:32] + "..." if len(s.task_description) > 32 else s.task_description,
1220
+ s.short_id,
1221
+ icon,
1222
+ source_styled,
1223
+ task_preview,
1224
+ s.runtime,
1225
+ tokens_str,
1251
1226
  )
1252
1227
 
1253
1228
  console.print(table)
@@ -1257,240 +1232,264 @@ def interactive(
1257
1232
  parser = argparse.ArgumentParser(add_help=False)
1258
1233
  parser.add_argument("task", nargs="*")
1259
1234
  parser.add_argument("--dir", "-d", type=Path, default=None)
1260
- parser.add_argument("--adapter", default=None)
1261
1235
  parser.add_argument("--model", "-m", default=None)
1262
- parser.add_argument("--async", "-a", dest="is_async", action="store_true")
1263
1236
 
1264
1237
  try:
1265
1238
  parsed, _ = parser.parse_known_args(args)
1266
1239
  return {
1267
1240
  "task": " ".join(parsed.task) if parsed.task else "",
1268
1241
  "dir": parsed.dir,
1269
- "adapter": parsed.adapter,
1270
1242
  "model": parsed.model,
1271
- "is_async": parsed.is_async,
1272
1243
  }
1273
1244
  except SystemExit:
1274
1245
  return {"error": "Invalid spawn arguments"}
1275
1246
 
1276
1247
  def do_spawn(args: list[str]):
1277
- """Spawn a new session with optional per-session config."""
1248
+ """Spawn a new coding agent session."""
1278
1249
  parsed = parse_spawn_args(args)
1279
1250
 
1280
1251
  if "error" in parsed:
1281
1252
  console.print(f" [red]{parsed['error']}[/]")
1282
- console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
1253
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME][/]")
1283
1254
  return
1284
1255
 
1285
1256
  if not parsed["task"]:
1286
1257
  console.print(" [red]Task required[/]")
1287
- console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--adapter NAME] [--async][/]")
1258
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME][/]")
1288
1259
  return
1289
1260
 
1290
1261
  task = parsed["task"]
1291
1262
  work_dir = (parsed["dir"] or default_dir).absolute()
1292
- adapter_name = parsed["adapter"] or default_adapter.value
1293
- adapter_model = parsed["model"] or model
1294
- mode = "async" if parsed["is_async"] else "sync"
1263
+ session_model = parsed["model"] or default_model
1295
1264
 
1296
- # Validate directory exists
1297
1265
  if not work_dir.exists():
1298
1266
  console.print(f" [red]Directory not found:[/] {work_dir}")
1299
1267
  return
1300
1268
 
1301
- console.print(f"\n[dim]Spawning {mode} session...[/]")
1269
+ console.print(f"\n[dim]Spawning session...[/]")
1302
1270
  console.print(f" [dim]Dir: {work_dir}[/]")
1303
- console.print(f" [dim]Adapter: {adapter_name}[/]")
1271
+ console.print(f" [dim]Model: {session_model}[/]")
1304
1272
 
1305
1273
  try:
1306
- executor = get_or_create_adapter(adapter_name, adapter_model)
1307
- session = asyncio.run(
1308
- executor.start_session(
1309
- task=task,
1310
- working_dir=work_dir,
1311
- mode=mode,
1312
- model=adapter_model,
1313
- )
1274
+ session = manager.start_session(
1275
+ task=task,
1276
+ working_dir=work_dir,
1277
+ model=session_model,
1278
+ source="user",
1279
+ adapter="codex",
1314
1280
  )
1315
1281
 
1316
- sessions[session.id] = session
1317
- session_dirs[session.id] = work_dir
1318
- session_adapters[session.id] = adapter_name
1319
-
1320
- console.print(f" [green]✓[/] Session: [cyan]{short_id(session.id)}[/]")
1321
- console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
1322
-
1323
- if mode == "sync" and session.messages:
1324
- for msg in session.messages:
1325
- if msg.role == "assistant":
1326
- console.print(f"\n[bold]Response:[/]")
1327
- content = msg.content[:2000] + ("..." if len(msg.content) > 2000 else "")
1328
- console.print(Panel(content, border_style="green"))
1329
- else:
1330
- console.print(f" [dim]Async session started. Use '? {short_id(session.id)}' to check status.[/]")
1331
-
1332
- # Save output for viewing
1333
- save_session_output(session)
1282
+ console.print(f"\n[green]✓[/] Session: [cyan]{session.short_id}[/]")
1283
+ console.print(f" PID: {session.pid}")
1284
+ console.print(f"\n[dim]Use '? {session.short_id}' to check status[/]")
1334
1285
 
1335
1286
  except Exception as e:
1336
1287
  console.print(f" [red]Error:[/] {e}")
1337
1288
  import traceback
1338
1289
  console.print(f" [dim]{traceback.format_exc()}[/]")
1339
1290
 
1340
- def do_converse(session_id: str, message: str):
1341
- session, sid = find_session(session_id)
1342
- if not session:
1343
- console.print(f" [red]Session not found:[/] {session_id}")
1344
- console.print(f" [dim]Use 'ls' to see available sessions[/]")
1291
+ def do_orchestrate(args: list[str]):
1292
+ """Spawn an orchestrator agent that delegates to sub-sessions."""
1293
+ parsed = parse_spawn_args(args)
1294
+
1295
+ if "error" in parsed:
1296
+ console.print(f" [red]{parsed['error']}[/]")
1297
+ console.print(" [dim]Usage: orchestrate \"task\" [--dir PATH][/]")
1345
1298
  return
1346
1299
 
1347
- if session.mode.value != "sync":
1348
- console.print(f" [yellow]Warning:[/] Session is async, can't converse")
1300
+ if not parsed["task"]:
1301
+ console.print(" [red]Task required[/]")
1302
+ console.print(" [dim]Usage: orchestrate \"task\" [--dir PATH][/]")
1349
1303
  return
1350
1304
 
1351
- if session.status.value != "active":
1352
- console.print(f" [yellow]Warning:[/] Session is {session.status.value}")
1305
+ task = parsed["task"]
1306
+ work_dir = (parsed["dir"] or default_dir).absolute()
1307
+
1308
+ if not work_dir.exists():
1309
+ console.print(f" [red]Directory not found:[/] {work_dir}")
1353
1310
  return
1354
1311
 
1355
- console.print(f"\n[dim]Sending message to {short_id(session.id)}...[/]")
1312
+ console.print(f"\n[dim]Starting orchestrator...[/]")
1313
+ console.print(f" [dim]Dir: {work_dir}[/]")
1314
+ console.print(f" [dim]Task: {task[:60]}{'...' if len(task) > 60 else ''}[/]")
1315
+
1316
+ import subprocess
1317
+ from uuid import uuid4
1318
+
1319
+ # Generate instance ID for tracking
1320
+ instance_id = str(uuid4())[:8]
1321
+
1322
+ # Build command to run orchestrator in background
1323
+ cmd = [
1324
+ sys.executable, "-m", "zwarm.cli.main", "orchestrate",
1325
+ "--task", task,
1326
+ "--working-dir", str(work_dir),
1327
+ "--instance", instance_id,
1328
+ ]
1329
+
1330
+ # Create log file for orchestrator output
1331
+ log_dir = state_dir / "orchestrators"
1332
+ log_dir.mkdir(parents=True, exist_ok=True)
1333
+ log_file = log_dir / f"{instance_id}.log"
1356
1334
 
1357
1335
  try:
1358
- adapter_name = session_adapters.get(sid, default_adapter.value)
1359
- executor = get_or_create_adapter(adapter_name)
1360
- response = asyncio.run(executor.send_message(session, message))
1361
- console.print(f" [green]✓[/] Response received")
1362
- console.print(f" Tokens: {session.token_usage.get('total_tokens', 0)}")
1363
- console.print(f"\n[bold]Response:[/]")
1364
- console.print(Panel(response[:2000] + ("..." if len(response) > 2000 else ""), border_style="green"))
1336
+ with open(log_file, "w") as f:
1337
+ proc = subprocess.Popen(
1338
+ cmd,
1339
+ cwd=work_dir,
1340
+ stdout=f,
1341
+ stderr=subprocess.STDOUT,
1342
+ start_new_session=True,
1343
+ )
1365
1344
 
1366
- # Update saved output
1367
- save_session_output(session)
1345
+ console.print(f"\n[green]✓[/] Orchestrator started: [magenta]{instance_id}[/]")
1346
+ console.print(f" PID: {proc.pid}")
1347
+ console.print(f" Log: {log_file}")
1348
+ console.print(f"\n[dim]Delegated sessions will appear with source 'orch:{instance_id[:4]}'[/]")
1349
+ console.print(f"[dim]Use 'ls' to monitor progress[/]")
1368
1350
 
1369
1351
  except Exception as e:
1370
- console.print(f" [red]Error:[/] {e}")
1352
+ console.print(f" [red]Error starting orchestrator:[/] {e}")
1371
1353
 
1372
- def do_check(session_id: str):
1373
- session, sid = find_session(session_id)
1354
+ def do_show(session_id: str):
1355
+ """Show session details and messages."""
1356
+ session = manager.get_session(session_id)
1374
1357
  if not session:
1375
1358
  console.print(f" [red]Session not found:[/] {session_id}")
1376
1359
  return
1377
1360
 
1378
- work_dir = session_dirs.get(sid, session.working_dir)
1361
+ # Get messages
1362
+ messages = manager.get_messages(session.id)
1379
1363
 
1380
- console.print(f"\n[bold]Session {short_id(session.id)}[/]")
1381
- console.print(f" Status: {session.status.value}")
1382
- console.print(f" Mode: {session.mode.value}")
1383
- console.print(f" Adapter: {session.adapter}")
1384
- console.print(f" Model: {session.model}")
1385
- console.print(f" Directory: {work_dir}")
1386
- console.print(f" Messages: {len(session.messages)}")
1387
- console.print(f" Tokens: {session.token_usage}")
1388
- console.print(f" Task: {session.task_description}")
1364
+ # Status styling
1365
+ status_display = {
1366
+ "running": "[yellow]⟳ running[/]",
1367
+ "completed": "[green]✓ completed[/]",
1368
+ "failed": "[red]✗ failed[/]",
1369
+ "killed": "[dim]⊘ killed[/]",
1370
+ "pending": "[dim]○ pending[/]",
1371
+ }.get(session.status.value, session.status.value)
1389
1372
 
1390
- if session.mode.value == "async" and session.status.value == "active":
1391
- try:
1392
- adapter_name = session_adapters.get(sid, default_adapter.value)
1393
- executor = get_or_create_adapter(adapter_name)
1394
- status = asyncio.run(executor.check_status(session))
1395
- console.print(f"\n[bold]Async status:[/]")
1396
- for k, v in status.items():
1397
- if k != "events": # Skip verbose events
1398
- val_str = str(v)[:200] + "..." if len(str(v)) > 200 else str(v)
1399
- console.print(f" {k}: {val_str}")
1400
-
1401
- # Update saved output after status check
1402
- save_session_output(session)
1403
-
1404
- except Exception as e:
1405
- console.print(f" [red]Status check error:[/] {e}")
1406
-
1407
- # Show messages
1408
- if session.messages:
1409
- console.print(f"\n[bold]Messages:[/]")
1410
- for i, msg in enumerate(session.messages):
1411
- role_color = {"user": "blue", "assistant": "green", "system": "yellow"}.get(msg.role, "white")
1412
- content_preview = msg.content[:500] + "..." if len(msg.content) > 500 else msg.content
1413
- console.print(f" [{role_color}]{msg.role}[/]: {content_preview}")
1414
-
1415
- def do_view(session_id: str):
1416
- """Open session output in $EDITOR."""
1417
- session, sid = find_session(session_id)
1373
+ console.print()
1374
+ console.print(f"[bold cyan]Session {session.short_id}[/] {status_display}")
1375
+ console.print(f"[dim]Source:[/] {session.source_display} [dim]│[/] [dim]Model:[/] {session.model}")
1376
+ console.print(f"[dim]Task:[/] {session.task}")
1377
+ console.print(f"[dim]Dir:[/] {session.working_dir} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
1378
+ console.print()
1379
+
1380
+ if not messages:
1381
+ if session.status == SessionStatus.RUNNING:
1382
+ console.print("[yellow]Session is still running...[/]")
1383
+ else:
1384
+ console.print("[dim]No messages captured.[/]")
1385
+ return
1386
+
1387
+ # Display messages
1388
+ for msg in messages:
1389
+ if msg.role == "user":
1390
+ console.print(Panel(msg.content, title="[bold blue]User[/]", border_style="blue"))
1391
+ elif msg.role == "assistant":
1392
+ content = msg.content
1393
+ if len(content) > 2000:
1394
+ content = content[:2000] + "\n\n[dim]... (truncated)[/]"
1395
+ console.print(Panel(content, title="[bold green]Assistant[/]", border_style="green"))
1396
+ elif msg.role == "tool":
1397
+ if msg.content.startswith("[Calling:"):
1398
+ console.print(f" [dim]⚙[/] {msg.content}")
1399
+ else:
1400
+ console.print(f" [dim]└─ {msg.content[:100]}[/]")
1401
+
1402
+ console.print()
1403
+ if session.token_usage:
1404
+ tokens = session.token_usage
1405
+ console.print(f"[dim]Tokens: {tokens.get('input_tokens', 0):,} in / {tokens.get('output_tokens', 0):,} out[/]")
1406
+
1407
+ if session.error:
1408
+ console.print(f"[red]Error:[/] {session.error}")
1409
+
1410
+ def do_continue(session_id: str, message: str):
1411
+ """Continue a completed session with a follow-up message."""
1412
+ session = manager.get_session(session_id)
1418
1413
  if not session:
1419
1414
  console.print(f" [red]Session not found:[/] {session_id}")
1420
1415
  return
1421
1416
 
1422
- output_file = save_session_output(session)
1423
- editor = os.environ.get("EDITOR", "less")
1417
+ if session.status == SessionStatus.RUNNING:
1418
+ console.print("[yellow]Session is still running.[/]")
1419
+ console.print("[dim]Wait for it to complete, then continue.[/]")
1420
+ return
1424
1421
 
1425
- console.print(f" [dim]Opening {output_file} in {editor}...[/]")
1426
- try:
1427
- sp.run([editor, str(output_file)])
1428
- except Exception as e:
1429
- console.print(f" [red]Error opening editor:[/] {e}")
1430
- console.print(f" [dim]File saved at: {output_file}[/]")
1422
+ console.print(f"\n[dim]Injecting message into {session.short_id}...[/]")
1423
+
1424
+ updated = manager.inject_message(session.id, message)
1425
+ if updated:
1426
+ console.print(f"[green][/] Turn {updated.turn} started")
1427
+ console.print(f" PID: {updated.pid}")
1428
+ console.print(f"\n[dim]Use '? {session.short_id}' to check status[/]")
1429
+ else:
1430
+ console.print("[red]Failed to inject message.[/]")
1431
1431
 
1432
- def do_raw(session_id: str):
1433
- """Show raw session data for debugging."""
1434
- session, _ = find_session(session_id)
1432
+ def do_logs(session_id: str, follow: bool = False):
1433
+ """Show raw JSONL logs for a session."""
1434
+ session = manager.get_session(session_id)
1435
1435
  if not session:
1436
1436
  console.print(f" [red]Session not found:[/] {session_id}")
1437
1437
  return
1438
1438
 
1439
- import json
1440
- console.print(f"\n[bold]Raw session data for {short_id(session.id)}:[/]")
1441
- console.print(json.dumps(session.to_dict(), indent=2, default=str))
1439
+ if follow and session.status == SessionStatus.RUNNING:
1440
+ import time
1441
+ output_path = manager._output_path(session.id, session.turn)
1442
+ console.print(f"[dim]Following {output_path}... (Ctrl+C to stop)[/]\n")
1443
+
1444
+ try:
1445
+ with open(output_path, "r") as f:
1446
+ for line in f:
1447
+ console.print(line.rstrip())
1448
+ while session.is_running:
1449
+ line = f.readline()
1450
+ if line:
1451
+ console.print(line.rstrip())
1452
+ else:
1453
+ time.sleep(0.5)
1454
+ session = manager.get_session(session_id)
1455
+ except KeyboardInterrupt:
1456
+ console.print("\n[dim]Stopped.[/]")
1457
+ else:
1458
+ output = manager.get_output(session.id)
1459
+ if output:
1460
+ console.print(output)
1461
+ else:
1462
+ console.print("[dim]No output yet.[/]")
1442
1463
 
1443
1464
  def do_kill(session_id: str):
1444
- session, sid = find_session(session_id)
1465
+ """Kill a running session."""
1466
+ session = manager.get_session(session_id)
1445
1467
  if not session:
1446
1468
  console.print(f" [red]Session not found:[/] {session_id}")
1447
1469
  return
1448
1470
 
1449
- try:
1450
- adapter_name = session_adapters.get(sid, default_adapter.value)
1451
- executor = get_or_create_adapter(adapter_name)
1452
- asyncio.run(executor.stop(session))
1453
- console.print(f" [green]✓[/] Session {short_id(session.id)} killed")
1454
- save_session_output(session)
1455
- except Exception as e:
1456
- console.print(f" [red]Error:[/] {e}")
1471
+ if not session.is_running:
1472
+ console.print(f"[yellow]Session {session.short_id} is not running.[/]")
1473
+ return
1474
+
1475
+ if manager.kill_session(session.id):
1476
+ console.print(f"[green]✓[/] Killed session {session.short_id}")
1477
+ else:
1478
+ console.print(f"[red]Failed to kill session[/]")
1457
1479
 
1458
1480
  def do_killall():
1459
1481
  """Kill all running sessions."""
1482
+ sessions = manager.list_sessions(status=SessionStatus.RUNNING)
1460
1483
  killed = 0
1461
- for sid, session in sessions.items():
1462
- if session.status.value == "active":
1463
- try:
1464
- adapter_name = session_adapters.get(sid, default_adapter.value)
1465
- executor = get_or_create_adapter(adapter_name)
1466
- asyncio.run(executor.stop(session))
1467
- killed += 1
1468
- except Exception:
1469
- pass
1470
- console.print(f" [green]✓[/] Killed {killed} sessions")
1471
-
1472
- def do_refresh():
1473
- """Poll all async sessions for status updates."""
1474
- updated = 0
1475
- for sid, session in sessions.items():
1476
- if session.mode.value == "async" and session.status.value == "active":
1477
- try:
1478
- adapter_name = session_adapters.get(sid, default_adapter.value)
1479
- executor = get_or_create_adapter(adapter_name)
1480
- status = asyncio.run(executor.check_status(session))
1481
- save_session_output(session)
1482
- updated += 1
1483
-
1484
- if status.get("status") in ("completed", "failed"):
1485
- icon = "[green]✓[/]" if status["status"] == "completed" else "[red]✗[/]"
1486
- console.print(f" {icon} {short_id(sid)}: {status['status']}")
1487
- except Exception as e:
1488
- console.print(f" [red]✗[/] {short_id(sid)}: {e}")
1489
-
1490
- if updated == 0:
1491
- console.print(" [dim]No async sessions to refresh[/]")
1492
- else:
1493
- console.print(f" [dim]Refreshed {updated} sessions[/]")
1484
+ for session in sessions:
1485
+ if manager.kill_session(session.id):
1486
+ killed += 1
1487
+ console.print(f"[green]✓[/] Killed {killed} sessions")
1488
+
1489
+ def do_clean():
1490
+ """Remove completed sessions."""
1491
+ cleaned = manager.cleanup_completed(keep_days=0)
1492
+ console.print(f"[green]✓[/] Cleaned {cleaned} sessions")
1494
1493
 
1495
1494
  # REPL loop
1496
1495
  import shlex
@@ -1505,15 +1504,13 @@ def interactive(
1505
1504
  try:
1506
1505
  parts = shlex.split(raw_input)
1507
1506
  except ValueError:
1508
- # Handle unclosed quotes
1509
1507
  parts = raw_input.split()
1510
1508
 
1511
1509
  cmd = parts[0].lower()
1512
1510
  args = parts[1:]
1513
1511
 
1514
1512
  if cmd in ("q", "quit", "exit"):
1515
- # Offer to kill running sessions
1516
- running = [s for s in sessions.values() if s.status.value == "active"]
1513
+ running = manager.list_sessions(status=SessionStatus.RUNNING)
1517
1514
  if running:
1518
1515
  console.print(f" [yellow]Warning:[/] {len(running)} sessions still running")
1519
1516
  console.print(" [dim]Use 'killall' first, or they'll continue in background[/]")
@@ -1524,47 +1521,34 @@ def interactive(
1524
1521
  show_help()
1525
1522
 
1526
1523
  elif cmd in ("ls", "list"):
1527
- show_sessions()
1524
+ show_all = "--all" in args or "-a" in args
1525
+ show_sessions(show_all)
1528
1526
 
1529
1527
  elif cmd == "spawn":
1530
1528
  do_spawn(args)
1531
1529
 
1532
- # Keep old shortcuts for convenience
1533
- elif cmd in ("d", "delegate"):
1534
- if not args:
1535
- console.print(" [red]Usage:[/] d \"task\" or spawn \"task\" [options]")
1536
- else:
1537
- do_spawn(args) # Sync by default
1530
+ elif cmd == "orchestrate":
1531
+ do_orchestrate(args)
1538
1532
 
1539
- elif cmd in ("a", "async"):
1533
+ elif cmd in ("?", "show"):
1540
1534
  if not args:
1541
- console.print(" [red]Usage:[/] a \"task\" or spawn \"task\" --async")
1535
+ console.print(" [red]Usage:[/] ? SESSION_ID")
1542
1536
  else:
1543
- do_spawn(args + ["--async"])
1537
+ do_show(args[0])
1544
1538
 
1545
- elif cmd in ("c", "converse"):
1539
+ elif cmd in ("c", "continue"):
1546
1540
  if len(args) < 2:
1547
1541
  console.print(" [red]Usage:[/] c SESSION_ID \"message\"")
1548
1542
  else:
1549
- do_converse(args[0], " ".join(args[1:]))
1543
+ do_continue(args[0], " ".join(args[1:]))
1550
1544
 
1551
- elif cmd in ("?", "check"):
1545
+ elif cmd == "logs":
1552
1546
  if not args:
1553
- console.print(" [red]Usage:[/] ? SESSION_ID")
1547
+ console.print(" [red]Usage:[/] logs SESSION_ID [-f]")
1554
1548
  else:
1555
- do_check(args[0])
1556
-
1557
- elif cmd == "view":
1558
- if not args:
1559
- console.print(" [red]Usage:[/] view SESSION_ID")
1560
- else:
1561
- do_view(args[0])
1562
-
1563
- elif cmd == "raw":
1564
- if not args:
1565
- console.print(" [red]Usage:[/] raw SESSION_ID")
1566
- else:
1567
- do_raw(args[0])
1549
+ follow = "-f" in args or "--follow" in args
1550
+ session_id = [a for a in args if not a.startswith("-")][0]
1551
+ do_logs(session_id, follow)
1568
1552
 
1569
1553
  elif cmd == "kill":
1570
1554
  if not args:
@@ -1575,15 +1559,8 @@ def interactive(
1575
1559
  elif cmd == "killall":
1576
1560
  do_killall()
1577
1561
 
1578
- elif cmd == "refresh":
1579
- do_refresh()
1580
-
1581
- elif cmd in ("e", "end"):
1582
- # Alias for kill
1583
- if not args:
1584
- console.print(" [red]Usage:[/] kill SESSION_ID")
1585
- else:
1586
- do_kill(args[0])
1562
+ elif cmd == "clean":
1563
+ do_clean()
1587
1564
 
1588
1565
  else:
1589
1566
  console.print(f" [yellow]Unknown command:[/] {cmd}")
@@ -1598,6 +1575,454 @@ def interactive(
1598
1575
  console.print(f" [red]Error:[/] {e}")
1599
1576
 
1600
1577
 
1578
+ # =============================================================================
1579
+ # Session Manager Commands (background Codex processes)
1580
+ # =============================================================================
1581
+
1582
+ session_app = typer.Typer(
1583
+ name="session",
1584
+ help="""
1585
+ [bold cyan]Codex Session Manager[/]
1586
+
1587
+ Manage background Codex sessions. Run multiple codex tasks in parallel,
1588
+ monitor their progress, and inject follow-up messages.
1589
+
1590
+ [bold]COMMANDS[/]
1591
+ [cyan]start[/] Start a new session in the background
1592
+ [cyan]ls[/] List all sessions
1593
+ [cyan]show[/] Show messages for a session
1594
+ [cyan]logs[/] Show raw JSONL output
1595
+ [cyan]inject[/] Inject a follow-up message
1596
+ [cyan]kill[/] Kill a running session
1597
+ [cyan]clean[/] Remove old completed sessions
1598
+
1599
+ [bold]EXAMPLES[/]
1600
+ [dim]# Start a background session[/]
1601
+ $ zwarm session start "Add tests for auth module"
1602
+
1603
+ [dim]# List all sessions[/]
1604
+ $ zwarm session ls
1605
+
1606
+ [dim]# View session messages[/]
1607
+ $ zwarm session show abc123
1608
+
1609
+ [dim]# Continue a completed session[/]
1610
+ $ zwarm session inject abc123 "Also add edge case tests"
1611
+ """,
1612
+ )
1613
+ app.add_typer(session_app, name="session")
1614
+
1615
+
1616
+ @session_app.command("start")
1617
+ def session_start(
1618
+ task: Annotated[str, typer.Argument(help="Task description")],
1619
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1620
+ model: Annotated[str, typer.Option("--model", "-m", help="Model to use")] = "gpt-5.1-codex-mini",
1621
+ ):
1622
+ """
1623
+ Start a new Codex session in the background.
1624
+
1625
+ The session runs independently and you can check on it later.
1626
+
1627
+ [bold]Examples:[/]
1628
+ [dim]# Simple task[/]
1629
+ $ zwarm session start "Fix the bug in auth.py"
1630
+
1631
+ [dim]# With specific model[/]
1632
+ $ zwarm session start "Refactor the API" --model gpt-5.1-codex-max
1633
+ """
1634
+ from zwarm.sessions import CodexSessionManager
1635
+
1636
+ manager = CodexSessionManager(working_dir / ".zwarm")
1637
+ session = manager.start_session(
1638
+ task=task,
1639
+ working_dir=working_dir,
1640
+ model=model,
1641
+ )
1642
+
1643
+ console.print()
1644
+ console.print(f"[green]✓ Session started[/] [bold cyan]{session.short_id}[/]")
1645
+ console.print()
1646
+ console.print(f" [dim]Task:[/] {task[:70]}{'...' if len(task) > 70 else ''}")
1647
+ console.print(f" [dim]Model:[/] {model}")
1648
+ console.print(f" [dim]PID:[/] {session.pid}")
1649
+ console.print()
1650
+ console.print("[dim]Commands:[/]")
1651
+ console.print(f" [cyan]zwarm session ls[/] [dim]List all sessions[/]")
1652
+ console.print(f" [cyan]zwarm session show {session.short_id}[/] [dim]View messages[/]")
1653
+ console.print(f" [cyan]zwarm session logs {session.short_id} -f[/] [dim]Follow live output[/]")
1654
+
1655
+
1656
+ @session_app.command("ls")
1657
+ def session_list(
1658
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1659
+ all_sessions: Annotated[bool, typer.Option("--all", "-a", help="Show all sessions including completed")] = False,
1660
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
1661
+ ):
1662
+ """
1663
+ List all sessions.
1664
+
1665
+ Shows running sessions by default. Use --all to include completed.
1666
+
1667
+ [bold]Examples:[/]
1668
+ $ zwarm session ls
1669
+ $ zwarm session ls --all
1670
+ """
1671
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1672
+
1673
+ manager = CodexSessionManager(working_dir / ".zwarm")
1674
+ sessions = manager.list_sessions()
1675
+
1676
+ if not all_sessions:
1677
+ sessions = [s for s in sessions if s.status == SessionStatus.RUNNING]
1678
+
1679
+ if json_output:
1680
+ import json
1681
+ console.print(json.dumps([s.to_dict() for s in sessions], indent=2))
1682
+ return
1683
+
1684
+ if not sessions:
1685
+ if all_sessions:
1686
+ console.print("[dim]No sessions found.[/]")
1687
+ else:
1688
+ console.print("[dim]No running sessions.[/]")
1689
+ console.print("[dim]Use --all to see completed sessions, or start one with:[/]")
1690
+ console.print(" zwarm session start \"your task here\"")
1691
+ return
1692
+
1693
+ # Show status summary
1694
+ running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
1695
+ completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
1696
+ failed_count = sum(1 for s in sessions if s.status == SessionStatus.FAILED)
1697
+
1698
+ summary_parts = []
1699
+ if running_count:
1700
+ summary_parts.append(f"[yellow]⟳ {running_count} running[/]")
1701
+ if completed_count:
1702
+ summary_parts.append(f"[green]✓ {completed_count} completed[/]")
1703
+ if failed_count:
1704
+ summary_parts.append(f"[red]✗ {failed_count} failed[/]")
1705
+
1706
+ if summary_parts:
1707
+ console.print(" │ ".join(summary_parts))
1708
+ console.print()
1709
+
1710
+ # Build table
1711
+ table = Table(box=None, show_header=True, header_style="bold dim")
1712
+ table.add_column("ID", style="cyan")
1713
+ table.add_column("", width=2) # Status icon
1714
+ table.add_column("Task", max_width=50)
1715
+ table.add_column("Runtime", justify="right", style="dim")
1716
+ table.add_column("Tokens", justify="right", style="dim")
1717
+
1718
+ status_icons = {
1719
+ SessionStatus.RUNNING: "[yellow]⟳[/]",
1720
+ SessionStatus.COMPLETED: "[green]✓[/]",
1721
+ SessionStatus.FAILED: "[red]✗[/]",
1722
+ SessionStatus.KILLED: "[dim]⊘[/]",
1723
+ SessionStatus.PENDING: "[dim]○[/]",
1724
+ }
1725
+
1726
+ for session in sessions:
1727
+ status_icon = status_icons.get(session.status, "?")
1728
+ task_preview = session.task[:47] + "..." if len(session.task) > 50 else session.task
1729
+ tokens = session.token_usage.get("total_tokens", 0)
1730
+ tokens_str = f"{tokens:,}" if tokens else "-"
1731
+
1732
+ table.add_row(
1733
+ session.short_id,
1734
+ status_icon,
1735
+ task_preview,
1736
+ session.runtime,
1737
+ tokens_str,
1738
+ )
1739
+
1740
+ console.print()
1741
+ console.print(table)
1742
+ console.print()
1743
+
1744
+
1745
+ @session_app.command("show")
1746
+ def session_show(
1747
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1748
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1749
+ raw: Annotated[bool, typer.Option("--raw", "-r", help="Show raw messages without formatting")] = False,
1750
+ ):
1751
+ """
1752
+ Show messages for a session.
1753
+
1754
+ Displays the conversation history with nice formatting.
1755
+
1756
+ [bold]Examples:[/]
1757
+ $ zwarm session show abc123
1758
+ $ zwarm session show abc123 --raw
1759
+ """
1760
+ from zwarm.sessions import CodexSessionManager
1761
+
1762
+ manager = CodexSessionManager(working_dir / ".zwarm")
1763
+ session = manager.get_session(session_id)
1764
+
1765
+ if not session:
1766
+ console.print(f"[red]Session not found:[/] {session_id}")
1767
+ raise typer.Exit(1)
1768
+
1769
+ # Get messages
1770
+ messages = manager.get_messages(session.id)
1771
+
1772
+ # Status styling
1773
+ status_display = {
1774
+ "running": "[yellow]⟳ running[/]",
1775
+ "completed": "[green]✓ completed[/]",
1776
+ "failed": "[red]✗ failed[/]",
1777
+ "killed": "[dim]⊘ killed[/]",
1778
+ "pending": "[dim]○ pending[/]",
1779
+ }.get(session.status.value, session.status.value)
1780
+
1781
+ console.print()
1782
+ console.print(f"[bold cyan]Session {session.short_id}[/] {status_display}")
1783
+ console.print(f"[dim]Task:[/] {session.task}")
1784
+ console.print(f"[dim]Model:[/] {session.model} [dim]│[/] [dim]Turn:[/] {session.turn} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
1785
+ console.print()
1786
+
1787
+ if not messages:
1788
+ if session.status.value == "running":
1789
+ console.print("[yellow]Session is still running...[/]")
1790
+ console.print("[dim]Check back later for output.[/]")
1791
+ else:
1792
+ console.print("[dim]No messages captured.[/]")
1793
+ return
1794
+
1795
+ # Display messages
1796
+ for msg in messages:
1797
+ if msg.role == "user":
1798
+ if raw:
1799
+ console.print(f"[bold blue]USER:[/] {msg.content}")
1800
+ else:
1801
+ console.print(Panel(msg.content, title="[bold blue]User[/]", border_style="blue"))
1802
+
1803
+ elif msg.role == "assistant":
1804
+ if raw:
1805
+ console.print(f"[bold green]ASSISTANT:[/] {msg.content}")
1806
+ else:
1807
+ # Truncate very long messages
1808
+ content = msg.content
1809
+ if len(content) > 2000:
1810
+ content = content[:2000] + "\n\n[dim]... (truncated, use --raw for full output)[/]"
1811
+ console.print(Panel(content, title="[bold green]Assistant[/]", border_style="green"))
1812
+
1813
+ elif msg.role == "tool":
1814
+ if raw:
1815
+ console.print(f"[dim]TOOL: {msg.content}[/]")
1816
+ else:
1817
+ # Extract function name if present
1818
+ content = msg.content
1819
+ if content.startswith("[Calling:"):
1820
+ console.print(f" [dim]⚙[/] {content}")
1821
+ elif content.startswith("[Output]"):
1822
+ console.print(f" [dim]└─ {content[9:]}[/]") # Skip "[Output]:"
1823
+ else:
1824
+ console.print(f" [dim]{content}[/]")
1825
+
1826
+ console.print()
1827
+
1828
+ # Show token usage
1829
+ if session.token_usage:
1830
+ tokens = session.token_usage
1831
+ console.print(f"[dim]Tokens: {tokens.get('input_tokens', 0):,} in / {tokens.get('output_tokens', 0):,} out[/]")
1832
+
1833
+ # Show error if any
1834
+ if session.error:
1835
+ console.print(f"[red]Error:[/] {session.error}")
1836
+
1837
+ # Helpful tip
1838
+ console.print()
1839
+ if session.status.value == "running":
1840
+ console.print(f"[dim]Tip: Use 'zwarm session logs {session.short_id} --follow' to watch live output[/]")
1841
+ elif session.status.value == "completed":
1842
+ console.print(f"[dim]Tip: Use 'zwarm session inject {session.short_id} \"your message\"' to continue the conversation[/]")
1843
+
1844
+
1845
+ @session_app.command("logs")
1846
+ def session_logs(
1847
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1848
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1849
+ turn: Annotated[Optional[int], typer.Option("--turn", "-t", help="Specific turn number")] = None,
1850
+ follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow output (like tail -f)")] = False,
1851
+ ):
1852
+ """
1853
+ Show raw JSONL logs for a session.
1854
+
1855
+ [bold]Examples:[/]
1856
+ $ zwarm session logs abc123
1857
+ $ zwarm session logs abc123 --follow
1858
+ """
1859
+ from zwarm.sessions import CodexSessionManager
1860
+
1861
+ manager = CodexSessionManager(working_dir / ".zwarm")
1862
+ session = manager.get_session(session_id)
1863
+
1864
+ if not session:
1865
+ console.print(f"[red]Session not found:[/] {session_id}")
1866
+ raise typer.Exit(1)
1867
+
1868
+ if follow and session.status.value == "running":
1869
+ # Follow mode - tail the file
1870
+ import time
1871
+ output_path = manager._output_path(session.id, turn or session.turn)
1872
+
1873
+ console.print(f"[dim]Following {output_path}... (Ctrl+C to stop)[/]")
1874
+ console.print()
1875
+
1876
+ try:
1877
+ with open(output_path, "r") as f:
1878
+ # Print existing content
1879
+ for line in f:
1880
+ console.print(line.rstrip())
1881
+
1882
+ # Follow new content
1883
+ while session.is_running:
1884
+ line = f.readline()
1885
+ if line:
1886
+ console.print(line.rstrip())
1887
+ else:
1888
+ time.sleep(0.5)
1889
+ # Refresh session status
1890
+ session = manager.get_session(session_id)
1891
+
1892
+ except KeyboardInterrupt:
1893
+ console.print("\n[dim]Stopped following.[/]")
1894
+
1895
+ else:
1896
+ # Just print the output
1897
+ output = manager.get_output(session.id, turn)
1898
+ if output:
1899
+ console.print(output)
1900
+ else:
1901
+ console.print("[dim]No output yet.[/]")
1902
+
1903
+
1904
+ @session_app.command("inject")
1905
+ def session_inject(
1906
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1907
+ message: Annotated[str, typer.Argument(help="Follow-up message to inject")],
1908
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1909
+ ):
1910
+ """
1911
+ Inject a follow-up message into a completed session.
1912
+
1913
+ This continues the conversation with context from the previous turn.
1914
+ Can only be used on completed (not running) sessions.
1915
+
1916
+ [bold]Examples:[/]
1917
+ $ zwarm session inject abc123 "Also add edge case tests"
1918
+ $ zwarm session inject abc123 "Good, now refactor the code"
1919
+ """
1920
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1921
+
1922
+ manager = CodexSessionManager(working_dir / ".zwarm")
1923
+ session = manager.get_session(session_id)
1924
+
1925
+ if not session:
1926
+ console.print(f"[red]Session not found:[/] {session_id}")
1927
+ raise typer.Exit(1)
1928
+
1929
+ if session.status == SessionStatus.RUNNING:
1930
+ console.print("[yellow]Session is still running.[/]")
1931
+ console.print("[dim]Wait for it to complete, then inject a follow-up.[/]")
1932
+ raise typer.Exit(1)
1933
+
1934
+ # Inject the message
1935
+ updated_session = manager.inject_message(session.id, message)
1936
+
1937
+ if not updated_session:
1938
+ console.print("[red]Failed to inject message.[/]")
1939
+ raise typer.Exit(1)
1940
+
1941
+ console.print()
1942
+ console.print(f"[green]✓ Message injected[/] Turn {updated_session.turn} started")
1943
+ console.print()
1944
+ console.print(f" [dim]Message:[/] {message[:70]}{'...' if len(message) > 70 else ''}")
1945
+ console.print(f" [dim]PID:[/] {updated_session.pid}")
1946
+ console.print()
1947
+ console.print("[dim]Commands:[/]")
1948
+ console.print(f" [cyan]zwarm session show {session.short_id}[/] [dim]View messages[/]")
1949
+ console.print(f" [cyan]zwarm session logs {session.short_id} -f[/] [dim]Follow live output[/]")
1950
+
1951
+
1952
+ @session_app.command("kill")
1953
+ def session_kill(
1954
+ session_id: Annotated[str, typer.Argument(help="Session ID (or prefix)")],
1955
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1956
+ ):
1957
+ """
1958
+ Kill a running session.
1959
+
1960
+ [bold]Examples:[/]
1961
+ $ zwarm session kill abc123
1962
+ """
1963
+ from zwarm.sessions import CodexSessionManager
1964
+
1965
+ manager = CodexSessionManager(working_dir / ".zwarm")
1966
+ session = manager.get_session(session_id)
1967
+
1968
+ if not session:
1969
+ console.print(f"[red]Session not found:[/] {session_id}")
1970
+ raise typer.Exit(1)
1971
+
1972
+ if not session.is_running:
1973
+ console.print(f"[yellow]Session {session.short_id} is not running.[/]")
1974
+ console.print(f" [dim]Status:[/] {session.status.value}")
1975
+ return
1976
+
1977
+ killed = manager.kill_session(session.id)
1978
+
1979
+ if killed:
1980
+ console.print(f"[green]Killed session {session.short_id}[/]")
1981
+ else:
1982
+ console.print(f"[red]Failed to kill session {session.short_id}[/]")
1983
+
1984
+
1985
+ @session_app.command("clean")
1986
+ def session_clean(
1987
+ working_dir: Annotated[Path, typer.Option("--dir", "-d", help="Working directory")] = Path("."),
1988
+ keep_days: Annotated[int, typer.Option("--keep-days", "-k", help="Keep sessions newer than N days")] = 7,
1989
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
1990
+ ):
1991
+ """
1992
+ Remove old completed sessions.
1993
+
1994
+ [bold]Examples:[/]
1995
+ $ zwarm session clean
1996
+ $ zwarm session clean --keep-days 1
1997
+ """
1998
+ from zwarm.sessions import CodexSessionManager, SessionStatus
1999
+
2000
+ manager = CodexSessionManager(working_dir / ".zwarm")
2001
+ sessions = manager.list_sessions()
2002
+
2003
+ # Count cleanable sessions
2004
+ cleanable = [s for s in sessions if s.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED)]
2005
+
2006
+ if not cleanable:
2007
+ console.print("[dim]No sessions to clean.[/]")
2008
+ return
2009
+
2010
+ console.print(f"Found {len(cleanable)} completed/failed sessions.")
2011
+
2012
+ if not yes:
2013
+ confirm = typer.confirm(f"Remove sessions older than {keep_days} days?")
2014
+ if not confirm:
2015
+ console.print("[dim]Cancelled.[/]")
2016
+ return
2017
+
2018
+ cleaned = manager.cleanup_completed(keep_days)
2019
+ console.print(f"[green]Cleaned {cleaned} sessions.[/]")
2020
+
2021
+
2022
+ # =============================================================================
2023
+ # Main callback and entry point
2024
+ # =============================================================================
2025
+
1601
2026
  @app.callback(invoke_without_command=True)
1602
2027
  def main_callback(
1603
2028
  ctx: typer.Context,