zwarm 1.3.10__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zwarm/cli/main.py CHANGED
@@ -304,8 +304,13 @@ def exec(
304
304
  console.print(f" Mode: {mode.value}")
305
305
  console.print(f" Task: {task}")
306
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
+
307
312
  try:
308
- executor = get_adapter(adapter.value, model=model)
313
+ executor = get_adapter(adapter.value, model=model, config_path=config_path)
309
314
  except ValueError as e:
310
315
  console.print(f"[red]Error:[/] {e}")
311
316
  sys.exit(1)
@@ -743,6 +748,13 @@ def init(
743
748
  config_toml_path.write_text(toml_content)
744
749
  console.print(f" [green]✓[/] Created .zwarm/config.toml")
745
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
+
746
758
  # Create zwarm.yaml
747
759
  if create_project_config:
748
760
  if zwarm_yaml_path.exists() and not non_interactive:
@@ -813,6 +825,35 @@ def _generate_config_toml(
813
825
  return "\n".join(lines)
814
826
 
815
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
+
816
857
  def _generate_zwarm_yaml(
817
858
  description: str = "",
818
859
  context: str = "",
@@ -1092,6 +1133,7 @@ def clean(
1092
1133
  def interactive(
1093
1134
  default_dir: Annotated[Path, typer.Option("--dir", "-d", help="Default working directory")] = Path("."),
1094
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",
1095
1137
  state_dir: Annotated[Path, typer.Option("--state-dir", help="State directory for persistence")] = Path(".zwarm"),
1096
1138
  ):
1097
1139
  """
@@ -1100,79 +1142,121 @@ def interactive(
1100
1142
  Spawn multiple agents across different directories, manage them interactively,
1101
1143
  and view their outputs. You are the orchestrator.
1102
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
+
1103
1148
  [bold]Commands:[/]
1104
- [cyan]spawn[/] "task" [opts] Start a coding agent session
1105
- [cyan]orchestrate[/] "task" Start an orchestrator agent
1149
+ [cyan]spawn[/] "task" [opts] Start a coding agent session (sync mode)
1150
+ [cyan]async[/] "task" [opts] Start async session (fire-and-forget)
1106
1151
  [cyan]ls[/] / [cyan]list[/] Dashboard of all sessions
1107
- [cyan]?[/] / [cyan]show[/] ID Check session status & response
1108
- [cyan]c[/] / [cyan]continue[/] ID "msg" Continue a completed session
1109
- [cyan]logs[/] ID Show raw JSONL output
1110
- [cyan]kill[/] ID Stop a session
1152
+ [cyan]?[/] ID Quick peek (status + latest message)
1153
+ [cyan]show[/] ID Full session details & history
1154
+ [cyan]c[/] / [cyan]continue[/] ID "msg" Continue a sync conversation
1155
+ [cyan]kill[/] ID Stop a session (keeps in history)
1156
+ [cyan]rm[/] ID Delete session entirely
1111
1157
  [cyan]killall[/] Stop all running sessions
1158
+ [cyan]clean[/] Remove old sessions (>7 days)
1112
1159
  [cyan]q[/] / [cyan]quit[/] Exit
1113
1160
 
1114
1161
  [bold]Spawn Options:[/]
1115
- spawn "task" --dir ~/project --model gpt-5.1-codex-max
1162
+ spawn "task" --dir ~/project --model gpt-5.1-codex-max --async
1116
1163
 
1117
1164
  [bold]Examples:[/]
1118
1165
  $ zwarm interactive
1119
1166
  > spawn "Build auth module" --dir ~/api
1120
1167
  > spawn "Fix tests" --dir ~/api
1121
- > orchestrate "Refactor the entire API layer"
1168
+ > c abc123 "Now add error handling"
1122
1169
  > ls
1123
1170
  > ? abc123
1124
1171
  """
1125
- from zwarm.sessions import CodexSessionManager, SessionStatus
1172
+ from zwarm.adapters import get_adapter, list_adapters
1173
+ from zwarm.core.models import ConversationSession, SessionStatus, SessionMode
1126
1174
  import argparse
1127
- import subprocess as sp
1128
1175
 
1129
- # Initialize session manager
1130
- manager = CodexSessionManager(state_dir)
1176
+ # Initialize adapter (same as orchestrator uses)
1131
1177
  default_model = model or "gpt-5.1-codex-mini"
1178
+ default_adapter = adapter
1179
+
1180
+ # Config path for isolated codex configuration
1181
+ codex_config_path = state_dir / "codex.toml"
1182
+ if not codex_config_path.exists():
1183
+ # Try relative to working dir
1184
+ codex_config_path = default_dir / ".zwarm" / "codex.toml"
1185
+ if not codex_config_path.exists():
1186
+ codex_config_path = None # Fall back to overrides
1187
+
1188
+ # Session tracking - same pattern as orchestrator
1189
+ sessions: dict[str, ConversationSession] = {}
1190
+ adapters_cache: dict[str, Any] = {}
1191
+
1192
+ def get_or_create_adapter(adapter_name: str, session_model: str | None = None) -> Any:
1193
+ """Get or create an adapter instance with isolated config."""
1194
+ cache_key = f"{adapter_name}:{session_model or default_model}"
1195
+ if cache_key not in adapters_cache:
1196
+ adapters_cache[cache_key] = get_adapter(
1197
+ adapter_name,
1198
+ model=session_model or default_model,
1199
+ config_path=codex_config_path,
1200
+ )
1201
+ return adapters_cache[cache_key]
1202
+
1203
+ def find_session(query: str) -> tuple[ConversationSession | None, str | None]:
1204
+ """Find session by full or partial ID."""
1205
+ if query in sessions:
1206
+ return sessions[query], query
1207
+ for sid, session in sessions.items():
1208
+ if sid.startswith(query):
1209
+ return session, sid
1210
+ return None, None
1132
1211
 
1133
1212
  console.print("\n[bold cyan]zwarm interactive[/] - Multi-Agent Command Center\n")
1134
1213
  console.print(f" Working dir: {default_dir.absolute()}")
1135
1214
  console.print(f" Model: [cyan]{default_model}[/]")
1136
- console.print(f" Sessions: {state_dir / 'sessions'}")
1215
+ console.print(f" Adapter: [cyan]{default_adapter}[/]")
1216
+ if codex_config_path:
1217
+ console.print(f" Config: [green]{codex_config_path}[/] (isolated)")
1218
+ else:
1219
+ console.print(f" Config: [yellow]using fallback overrides[/] (run 'zwarm init' for isolation)")
1220
+ console.print(f" Available: {', '.join(list_adapters())}")
1137
1221
  console.print("\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.\n")
1138
1222
 
1139
1223
  def show_help():
1140
1224
  help_table = Table(show_header=False, box=None, padding=(0, 2))
1141
1225
  help_table.add_column("Command", style="cyan", width=35)
1142
1226
  help_table.add_column("Description")
1143
- help_table.add_row('spawn "task" [options]', "Start a coding agent")
1227
+ help_table.add_row('spawn "task" [options]', "Start session (waits for completion)")
1144
1228
  help_table.add_row(" --dir PATH", "Working directory")
1145
1229
  help_table.add_row(" --model NAME", "Model override")
1230
+ help_table.add_row(" --async", "Background mode (don't wait)")
1146
1231
  help_table.add_row("", "")
1147
- help_table.add_row('orchestrate "task" [options]', "Start an orchestrator agent")
1148
- help_table.add_row(" --dir PATH", "Working directory")
1149
- help_table.add_row("", "")
1150
- help_table.add_row("ls / list [--all]", "Dashboard of sessions")
1151
- help_table.add_row("? / show ID", "Show session messages")
1152
- help_table.add_row("c / continue ID \"msg\"", "Continue a completed session")
1153
- help_table.add_row("logs ID [-f]", "Show raw JSONL output")
1232
+ help_table.add_row("ls / list", "Dashboard of all sessions")
1233
+ help_table.add_row("? / show ID", "Show session details & messages")
1234
+ help_table.add_row('c ID "msg"', "Continue conversation (wait for response)")
1235
+ help_table.add_row('ca ID "msg"', "Continue async (fire-and-forget)")
1236
+ help_table.add_row("check ID", "Check session status")
1154
1237
  help_table.add_row("kill ID", "Stop a running session")
1155
1238
  help_table.add_row("killall", "Stop all running sessions")
1156
- help_table.add_row("clean", "Remove completed sessions")
1239
+ help_table.add_row("clean", "Remove old completed sessions")
1157
1240
  help_table.add_row("q / quit", "Exit")
1158
1241
  console.print(help_table)
1159
1242
 
1160
- def show_sessions(show_all: bool = False):
1161
- sessions = manager.list_sessions()
1243
+ def show_sessions():
1244
+ """List all sessions from CodexSessionManager (same as orchestrator)."""
1245
+ from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1246
+ from datetime import datetime
1162
1247
 
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]
1248
+ manager = CodexSessionManager(default_dir / ".zwarm")
1249
+ all_sessions = manager.list_sessions()
1166
1250
 
1167
- if not sessions:
1251
+ if not all_sessions:
1168
1252
  console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
1169
1253
  return
1170
1254
 
1171
1255
  # 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)
1256
+ running = sum(1 for s in all_sessions if s.status == SessStatus.RUNNING)
1257
+ completed = sum(1 for s in all_sessions if s.status == SessStatus.COMPLETED)
1258
+ failed = sum(1 for s in all_sessions if s.status == SessStatus.FAILED)
1259
+ killed = sum(1 for s in all_sessions if s.status == SessStatus.KILLED)
1176
1260
 
1177
1261
  summary_parts = []
1178
1262
  if running:
@@ -1181,48 +1265,91 @@ def interactive(
1181
1265
  summary_parts.append(f"[green]{completed} done[/]")
1182
1266
  if failed:
1183
1267
  summary_parts.append(f"[red]{failed} failed[/]")
1268
+ if killed:
1269
+ summary_parts.append(f"[dim]{killed} killed[/]")
1184
1270
  if summary_parts:
1185
1271
  console.print(" | ".join(summary_parts))
1186
1272
  console.print()
1187
1273
 
1274
+ def time_ago(iso_str: str) -> str:
1275
+ """Convert ISO timestamp to human-readable 'X ago' format."""
1276
+ try:
1277
+ dt = datetime.fromisoformat(iso_str)
1278
+ delta = datetime.now() - dt
1279
+ secs = delta.total_seconds()
1280
+ if secs < 60:
1281
+ return f"{int(secs)}s"
1282
+ elif secs < 3600:
1283
+ return f"{int(secs/60)}m"
1284
+ elif secs < 86400:
1285
+ return f"{secs/3600:.1f}h"
1286
+ else:
1287
+ return f"{secs/86400:.1f}d"
1288
+ except:
1289
+ return "?"
1290
+
1188
1291
  table = Table(box=None, show_header=True, header_style="bold dim")
1189
1292
  table.add_column("ID", style="cyan", width=10)
1190
1293
  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)
1294
+ table.add_column("T", width=2) # Turn
1295
+ table.add_column("Task", max_width=30)
1296
+ table.add_column("Updated", justify="right", width=8)
1297
+ table.add_column("Last Message", max_width=40)
1195
1298
 
1196
1299
  status_icons = {
1197
- SessionStatus.RUNNING: "[yellow][/]",
1198
- SessionStatus.COMPLETED: "[green]✓[/]",
1199
- SessionStatus.FAILED: "[red]✗[/]",
1200
- SessionStatus.KILLED: "[dim][/]",
1201
- SessionStatus.PENDING: "[dim][/]",
1300
+ "running": "[yellow][/]",
1301
+ "completed": "[green]✓[/]",
1302
+ "failed": "[red]✗[/]",
1303
+ "killed": "[dim][/]",
1304
+ "pending": "[dim][/]",
1202
1305
  }
1203
1306
 
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}[/]"
1307
+ for s in all_sessions:
1308
+ icon = status_icons.get(s.status.value, "?")
1309
+ task_preview = s.task[:27] + "..." if len(s.task) > 30 else s.task
1310
+ updated = time_ago(s.updated_at)
1311
+
1312
+ # Get last assistant message preview
1313
+ messages = manager.get_messages(s.id)
1314
+ last_msg = ""
1315
+ for msg in reversed(messages):
1316
+ if msg.role == "assistant":
1317
+ last_msg = msg.content.replace("\n", " ")[:37]
1318
+ if len(msg.content) > 37:
1319
+ last_msg += "..."
1320
+ break
1321
+
1322
+ # Style the last message based on recency
1323
+ if s.status == SessStatus.RUNNING:
1324
+ last_msg_styled = f"[yellow]{last_msg or '(working...)'}[/]"
1325
+ updated_styled = f"[yellow]{updated}[/]"
1326
+ elif s.status == SessStatus.COMPLETED:
1327
+ # Highlight if recently completed (< 60s)
1328
+ try:
1329
+ dt = datetime.fromisoformat(s.updated_at)
1330
+ is_recent = (datetime.now() - dt).total_seconds() < 60
1331
+ except:
1332
+ is_recent = False
1333
+ if is_recent:
1334
+ last_msg_styled = f"[green bold]{last_msg or '(done)'}[/]"
1335
+ updated_styled = f"[green bold]{updated} ★[/]"
1336
+ else:
1337
+ last_msg_styled = f"[green]{last_msg or '(done)'}[/]"
1338
+ updated_styled = f"[dim]{updated}[/]"
1339
+ elif s.status == SessStatus.FAILED:
1340
+ last_msg_styled = f"[red]{s.error[:37] if s.error else '(failed)'}...[/]"
1341
+ updated_styled = f"[red]{updated}[/]"
1216
1342
  else:
1217
- source_styled = f"[dim]{source}[/]"
1343
+ last_msg_styled = f"[dim]{last_msg or '-'}[/]"
1344
+ updated_styled = f"[dim]{updated}[/]"
1218
1345
 
1219
1346
  table.add_row(
1220
1347
  s.short_id,
1221
1348
  icon,
1222
- source_styled,
1349
+ str(s.turn),
1223
1350
  task_preview,
1224
- s.runtime,
1225
- tokens_str,
1351
+ updated_styled,
1352
+ last_msg_styled,
1226
1353
  )
1227
1354
 
1228
1355
  console.print(table)
@@ -1233,6 +1360,7 @@ def interactive(
1233
1360
  parser.add_argument("task", nargs="*")
1234
1361
  parser.add_argument("--dir", "-d", type=Path, default=None)
1235
1362
  parser.add_argument("--model", "-m", default=None)
1363
+ parser.add_argument("--async", dest="async_mode", action="store_true", default=False)
1236
1364
 
1237
1365
  try:
1238
1366
  parsed, _ = parser.parse_known_args(args)
@@ -1240,48 +1368,80 @@ def interactive(
1240
1368
  "task": " ".join(parsed.task) if parsed.task else "",
1241
1369
  "dir": parsed.dir,
1242
1370
  "model": parsed.model,
1371
+ "async_mode": parsed.async_mode,
1243
1372
  }
1244
1373
  except SystemExit:
1245
1374
  return {"error": "Invalid spawn arguments"}
1246
1375
 
1247
1376
  def do_spawn(args: list[str]):
1248
- """Spawn a new coding agent session."""
1377
+ """Spawn a new coding agent session using CodexSessionManager (same as orchestrator)."""
1378
+ from zwarm.sessions import CodexSessionManager
1379
+ import time
1380
+
1249
1381
  parsed = parse_spawn_args(args)
1250
1382
 
1251
1383
  if "error" in parsed:
1252
1384
  console.print(f" [red]{parsed['error']}[/]")
1253
- console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME][/]")
1385
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME] [--async][/]")
1254
1386
  return
1255
1387
 
1256
1388
  if not parsed["task"]:
1257
1389
  console.print(" [red]Task required[/]")
1258
- console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME][/]")
1390
+ console.print(" [dim]Usage: spawn \"task\" [--dir PATH] [--model NAME] [--async][/]")
1259
1391
  return
1260
1392
 
1261
1393
  task = parsed["task"]
1262
1394
  work_dir = (parsed["dir"] or default_dir).absolute()
1263
1395
  session_model = parsed["model"] or default_model
1396
+ is_async = parsed.get("async_mode", False)
1264
1397
 
1265
1398
  if not work_dir.exists():
1266
1399
  console.print(f" [red]Directory not found:[/] {work_dir}")
1267
1400
  return
1268
1401
 
1269
- console.print(f"\n[dim]Spawning session...[/]")
1402
+ mode_str = "async" if is_async else "sync"
1403
+ console.print(f"\n[dim]Spawning {mode_str} session...[/]")
1270
1404
  console.print(f" [dim]Dir: {work_dir}[/]")
1271
1405
  console.print(f" [dim]Model: {session_model}[/]")
1272
1406
 
1273
1407
  try:
1408
+ # Use CodexSessionManager - SAME as orchestrator's delegate()
1409
+ manager = CodexSessionManager(work_dir / ".zwarm")
1274
1410
  session = manager.start_session(
1275
1411
  task=task,
1276
1412
  working_dir=work_dir,
1277
1413
  model=session_model,
1414
+ sandbox="workspace-write",
1278
1415
  source="user",
1279
1416
  adapter="codex",
1280
1417
  )
1281
1418
 
1282
1419
  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[/]")
1420
+ console.print(f" [dim]PID: {session.pid}[/]")
1421
+
1422
+ # For sync mode, wait for completion and show response
1423
+ if not is_async:
1424
+ console.print(f"\n[dim]Waiting for completion...[/]")
1425
+ timeout = 300.0
1426
+ start = time.time()
1427
+ while time.time() - start < timeout:
1428
+ session = manager.get_session(session.id)
1429
+ if not session.is_running:
1430
+ break
1431
+ time.sleep(1.0)
1432
+
1433
+ # Get the response
1434
+ messages = manager.get_messages(session.id)
1435
+ for msg in messages:
1436
+ if msg.role == "assistant":
1437
+ response_preview = msg.content[:300]
1438
+ if len(msg.content) > 300:
1439
+ response_preview += "..."
1440
+ console.print(f"\n[bold]Response:[/]\n{response_preview}")
1441
+ break
1442
+
1443
+ console.print(f"\n[dim]Use 'show {session.short_id}' to see full details[/]")
1444
+ console.print(f"[dim]Use 'c {session.short_id} \"message\"' to continue[/]")
1285
1445
 
1286
1446
  except Exception as e:
1287
1447
  console.print(f" [red]Error:[/] {e}")
@@ -1351,34 +1511,81 @@ def interactive(
1351
1511
  except Exception as e:
1352
1512
  console.print(f" [red]Error starting orchestrator:[/] {e}")
1353
1513
 
1354
- def do_show(session_id: str):
1355
- """Show session details and messages."""
1514
+ def do_peek(session_id: str):
1515
+ """Quick peek at session - just status + latest message (for fast polling)."""
1516
+ from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1517
+
1518
+ manager = CodexSessionManager(default_dir / ".zwarm")
1356
1519
  session = manager.get_session(session_id)
1520
+
1357
1521
  if not session:
1358
- console.print(f" [red]Session not found:[/] {session_id}")
1522
+ console.print(f" [red]Not found:[/] {session_id}")
1359
1523
  return
1360
1524
 
1361
- # Get messages
1525
+ # Status icon
1526
+ icon = {
1527
+ "running": "[yellow]●[/]",
1528
+ "completed": "[green]✓[/]",
1529
+ "failed": "[red]✗[/]",
1530
+ "killed": "[dim]○[/]",
1531
+ }.get(session.status.value, "?")
1532
+
1533
+ # Get latest assistant message
1362
1534
  messages = manager.get_messages(session.id)
1535
+ latest = None
1536
+ for msg in reversed(messages):
1537
+ if msg.role == "assistant":
1538
+ latest = msg.content.replace("\n", " ")
1539
+ break
1540
+
1541
+ if session.status == SessStatus.RUNNING:
1542
+ console.print(f"{icon} [cyan]{session.short_id}[/] [yellow](running...)[/]")
1543
+ elif latest:
1544
+ # Truncate for one-liner
1545
+ preview = latest[:120] + "..." if len(latest) > 120 else latest
1546
+ console.print(f"{icon} [cyan]{session.short_id}[/] {preview}")
1547
+ elif session.status == SessStatus.FAILED:
1548
+ error = (session.error or "unknown")[:80]
1549
+ console.print(f"{icon} [cyan]{session.short_id}[/] [red]{error}[/]")
1550
+ else:
1551
+ console.print(f"{icon} [cyan]{session.short_id}[/] [dim](no response)[/]")
1552
+
1553
+ def do_show(session_id: str):
1554
+ """Show full session details and messages using CodexSessionManager."""
1555
+ from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1556
+
1557
+ manager = CodexSessionManager(default_dir / ".zwarm")
1558
+ session = manager.get_session(session_id)
1559
+
1560
+ if not session:
1561
+ console.print(f" [red]Session not found:[/] {session_id}")
1562
+ console.print(f" [dim]Use 'ls' to see available sessions[/]")
1563
+ return
1363
1564
 
1364
1565
  # Status styling
1365
1566
  status_display = {
1366
- "running": "[yellow] running[/]",
1567
+ "running": "[yellow] running[/]",
1367
1568
  "completed": "[green]✓ completed[/]",
1368
1569
  "failed": "[red]✗ failed[/]",
1369
- "killed": "[dim] killed[/]",
1370
- "pending": "[dim] pending[/]",
1371
- }.get(session.status.value, session.status.value)
1570
+ "killed": "[dim] killed[/]",
1571
+ "pending": "[dim] pending[/]",
1572
+ }.get(session.status.value, str(session.status.value))
1372
1573
 
1373
1574
  console.print()
1374
1575
  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}")
1576
+ console.print(f"[dim]Adapter:[/] {session.adapter} [dim]│[/] [dim]Model:[/] {session.model}")
1376
1577
  console.print(f"[dim]Task:[/] {session.task}")
1377
- console.print(f"[dim]Dir:[/] {session.working_dir} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
1578
+ console.print(f"[dim]Dir:[/] {session.working_dir} [dim]│[/] [dim]Turn:[/] {session.turn}")
1579
+ console.print(f"[dim]Source:[/] {session.source_display} [dim]│[/] [dim]Runtime:[/] {session.runtime}")
1580
+ if session.pid:
1581
+ console.print(f"[dim]PID:[/] {session.pid}")
1378
1582
  console.print()
1379
1583
 
1584
+ # Get messages from manager
1585
+ messages = manager.get_messages(session.id)
1586
+
1380
1587
  if not messages:
1381
- if session.status == SessionStatus.RUNNING:
1588
+ if session.is_running:
1382
1589
  console.print("[yellow]Session is still running...[/]")
1383
1590
  else:
1384
1591
  console.print("[dim]No messages captured.[/]")
@@ -1394,10 +1601,7 @@ def interactive(
1394
1601
  content = content[:2000] + "\n\n[dim]... (truncated)[/]"
1395
1602
  console.print(Panel(content, title="[bold green]Assistant[/]", border_style="green"))
1396
1603
  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]}[/]")
1604
+ console.print(f" [dim]Tool: {msg.content[:100]}[/]")
1401
1605
 
1402
1606
  console.print()
1403
1607
  if session.token_usage:
@@ -1407,89 +1611,195 @@ def interactive(
1407
1611
  if session.error:
1408
1612
  console.print(f"[red]Error:[/] {session.error}")
1409
1613
 
1410
- def do_continue(session_id: str, message: str):
1411
- """Continue a completed session with a follow-up message."""
1614
+ def do_continue(session_id: str, message: str, wait: bool = True):
1615
+ """
1616
+ Continue a conversation using CodexSessionManager.inject_message().
1617
+
1618
+ Works for both sync and async sessions:
1619
+ - If session was sync (or wait=True): wait for response
1620
+ - If session was async (or wait=False): fire-and-forget, check later with '?'
1621
+ """
1622
+ from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1623
+ import time
1624
+
1625
+ manager = CodexSessionManager(default_dir / ".zwarm")
1412
1626
  session = manager.get_session(session_id)
1627
+
1413
1628
  if not session:
1414
1629
  console.print(f" [red]Session not found:[/] {session_id}")
1630
+ console.print(f" [dim]Use 'ls' to see available sessions[/]")
1415
1631
  return
1416
1632
 
1417
- if session.status == SessionStatus.RUNNING:
1633
+ if session.status == SessStatus.RUNNING:
1418
1634
  console.print("[yellow]Session is still running.[/]")
1419
- console.print("[dim]Wait for it to complete, then continue.[/]")
1635
+ console.print("[dim]Wait for it to complete, or use '?' to check progress.[/]")
1420
1636
  return
1421
1637
 
1422
- console.print(f"\n[dim]Injecting message into {session.short_id}...[/]")
1638
+ if session.status == SessStatus.KILLED:
1639
+ console.print("[yellow]Session was killed.[/]")
1640
+ console.print("[dim]Start a new session with 'spawn'.[/]")
1641
+ return
1423
1642
 
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.[/]")
1643
+ # Determine if we should wait based on session source
1644
+ # Sessions from 'user' with --async flag have source='user'
1645
+ # We'll use wait parameter to control this
1646
+ should_wait = wait
1647
+
1648
+ console.print(f"\n[dim]Sending message to {session.short_id}...[/]")
1649
+
1650
+ try:
1651
+ # Inject message - spawns new background process
1652
+ updated_session = manager.inject_message(session.id, message)
1653
+
1654
+ if not updated_session:
1655
+ console.print(f" [red]Failed to inject message[/]")
1656
+ return
1657
+
1658
+ console.print(f"[green]✓[/] Message sent (turn {updated_session.turn})")
1659
+
1660
+ if should_wait:
1661
+ # Sync mode: wait for completion
1662
+ console.print(f"[dim]Waiting for response...[/]")
1663
+ timeout = 300.0
1664
+ start = time.time()
1665
+ while time.time() - start < timeout:
1666
+ session = manager.get_session(session.id)
1667
+ if not session.is_running:
1668
+ break
1669
+ time.sleep(1.0)
1670
+
1671
+ # Get the response (last assistant message)
1672
+ messages = manager.get_messages(session.id)
1673
+ response = ""
1674
+ for msg in reversed(messages):
1675
+ if msg.role == "assistant":
1676
+ response = msg.content
1677
+ break
1678
+
1679
+ console.print(f"\n[bold]Response:[/]")
1680
+ if len(response) > 500:
1681
+ console.print(response[:500] + "...")
1682
+ console.print(f"\n[dim]Use 'show {session.short_id}' to see full response[/]")
1683
+ else:
1684
+ console.print(response or "(no response captured)")
1685
+ else:
1686
+ # Async mode: return immediately
1687
+ console.print(f"[dim]Running in background (PID: {updated_session.pid})[/]")
1688
+ console.print(f"[dim]Use '? {session.short_id}' to check progress[/]")
1431
1689
 
1432
- def do_logs(session_id: str, follow: bool = False):
1433
- """Show raw JSONL logs for a session."""
1690
+ except Exception as e:
1691
+ console.print(f" [red]Error:[/] {e}")
1692
+ import traceback
1693
+ console.print(f" [dim]{traceback.format_exc()}[/]")
1694
+
1695
+ def do_check(session_id: str):
1696
+ """Check status of a session using CodexSessionManager (same as orchestrator)."""
1697
+ from zwarm.sessions import CodexSessionManager
1698
+
1699
+ manager = CodexSessionManager(default_dir / ".zwarm")
1434
1700
  session = manager.get_session(session_id)
1701
+
1435
1702
  if not session:
1436
1703
  console.print(f" [red]Session not found:[/] {session_id}")
1704
+ console.print(f" [dim]Use 'ls' to see available sessions[/]")
1437
1705
  return
1438
1706
 
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.[/]")
1707
+ status_icon = {
1708
+ "running": "●",
1709
+ "completed": "✓",
1710
+ "failed": "✗",
1711
+ "killed": "○",
1712
+ "pending": "◌",
1713
+ }.get(session.status.value, "?")
1714
+
1715
+ console.print(f"\n[bold]Session {session.short_id}[/]: {status_icon} {session.status.value}")
1716
+ console.print(f" [dim]Task: {session.task[:60]}{'...' if len(session.task) > 60 else ''}[/]")
1717
+ console.print(f" [dim]Turn: {session.turn} | Runtime: {session.runtime}[/]")
1718
+
1719
+ if session.status.value == "completed":
1720
+ messages = manager.get_messages(session.id)
1721
+ for msg in reversed(messages):
1722
+ if msg.role == "assistant":
1723
+ console.print(f"\n[bold]Response:[/]")
1724
+ if len(msg.content) > 500:
1725
+ console.print(msg.content[:500] + "...")
1726
+ else:
1727
+ console.print(msg.content)
1728
+ break
1729
+ elif session.status.value == "failed":
1730
+ console.print(f"[red]Error:[/] {session.error or 'Unknown error'}")
1731
+ elif session.status.value == "running":
1732
+ console.print("[dim]Session is still running...[/]")
1733
+ console.print(f" [dim]PID: {session.pid}[/]")
1463
1734
 
1464
1735
  def do_kill(session_id: str):
1465
- """Kill a running session."""
1736
+ """Kill a running session using CodexSessionManager (same as orchestrator)."""
1737
+ from zwarm.sessions import CodexSessionManager
1738
+
1739
+ manager = CodexSessionManager(default_dir / ".zwarm")
1466
1740
  session = manager.get_session(session_id)
1741
+
1467
1742
  if not session:
1468
1743
  console.print(f" [red]Session not found:[/] {session_id}")
1469
1744
  return
1470
1745
 
1471
1746
  if not session.is_running:
1472
- console.print(f"[yellow]Session {session.short_id} is not running.[/]")
1747
+ console.print(f"[yellow]Session {session.short_id} is not running ({session.status.value}).[/]")
1473
1748
  return
1474
1749
 
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[/]")
1750
+ try:
1751
+ killed = manager.kill_session(session.id)
1752
+ if killed:
1753
+ console.print(f"[green]✓[/] Stopped session {session.short_id}")
1754
+ else:
1755
+ console.print(f"[red]Failed to stop session[/]")
1756
+ except Exception as e:
1757
+ console.print(f"[red]Failed to stop session:[/] {e}")
1479
1758
 
1480
1759
  def do_killall():
1481
- """Kill all running sessions."""
1482
- sessions = manager.list_sessions(status=SessionStatus.RUNNING)
1760
+ """Kill all running sessions using CodexSessionManager."""
1761
+ from zwarm.sessions import CodexSessionManager, SessionStatus as SessStatus
1762
+
1763
+ manager = CodexSessionManager(default_dir / ".zwarm")
1764
+ running_sessions = manager.list_sessions(status=SessStatus.RUNNING)
1765
+
1766
+ if not running_sessions:
1767
+ console.print("[dim]No running sessions to kill.[/]")
1768
+ return
1769
+
1483
1770
  killed = 0
1484
- for session in sessions:
1485
- if manager.kill_session(session.id):
1486
- killed += 1
1771
+ for session in running_sessions:
1772
+ try:
1773
+ if manager.kill_session(session.id):
1774
+ killed += 1
1775
+ except Exception as e:
1776
+ console.print(f"[red]Failed to stop {session.short_id}:[/] {e}")
1777
+
1487
1778
  console.print(f"[green]✓[/] Killed {killed} sessions")
1488
1779
 
1489
1780
  def do_clean():
1490
- """Remove completed sessions."""
1491
- cleaned = manager.cleanup_completed(keep_days=0)
1492
- console.print(f"[green]✓[/] Cleaned {cleaned} sessions")
1781
+ """Remove old completed/failed sessions from disk."""
1782
+ from zwarm.sessions import CodexSessionManager
1783
+
1784
+ manager = CodexSessionManager(default_dir / ".zwarm")
1785
+ cleaned = manager.cleanup_completed(keep_days=7)
1786
+ console.print(f"[green]✓[/] Cleaned {cleaned} old sessions")
1787
+
1788
+ def do_delete(session_id: str):
1789
+ """Delete a session entirely (removes from disk and ls)."""
1790
+ from zwarm.sessions import CodexSessionManager
1791
+
1792
+ manager = CodexSessionManager(default_dir / ".zwarm")
1793
+ session = manager.get_session(session_id)
1794
+
1795
+ if not session:
1796
+ console.print(f" [red]Session not found:[/] {session_id}")
1797
+ return
1798
+
1799
+ if manager.delete_session(session.id):
1800
+ console.print(f"[green]✓[/] Deleted session {session.short_id}")
1801
+ else:
1802
+ console.print(f"[red]Failed to delete session[/]")
1493
1803
 
1494
1804
  # REPL loop
1495
1805
  import shlex
@@ -1510,10 +1820,18 @@ def interactive(
1510
1820
  args = parts[1:]
1511
1821
 
1512
1822
  if cmd in ("q", "quit", "exit"):
1513
- running = manager.list_sessions(status=SessionStatus.RUNNING)
1514
- if running:
1515
- console.print(f" [yellow]Warning:[/] {len(running)} sessions still running")
1823
+ active = [s for s in sessions.values() if s.status == SessionStatus.ACTIVE]
1824
+ if active:
1825
+ console.print(f" [yellow]Warning:[/] {len(active)} sessions still active")
1516
1826
  console.print(" [dim]Use 'killall' first, or they'll continue in background[/]")
1827
+
1828
+ # Cleanup adapters
1829
+ for adapter_instance in adapters_cache.values():
1830
+ try:
1831
+ asyncio.run(adapter_instance.cleanup())
1832
+ except Exception:
1833
+ pass
1834
+
1517
1835
  console.print("\n[dim]Goodbye![/]\n")
1518
1836
  break
1519
1837
 
@@ -1521,34 +1839,50 @@ def interactive(
1521
1839
  show_help()
1522
1840
 
1523
1841
  elif cmd in ("ls", "list"):
1524
- show_all = "--all" in args or "-a" in args
1525
- show_sessions(show_all)
1842
+ show_sessions()
1526
1843
 
1527
1844
  elif cmd == "spawn":
1528
1845
  do_spawn(args)
1529
1846
 
1847
+ elif cmd == "async":
1848
+ # Async spawn shorthand
1849
+ do_spawn(args + ["--async"])
1850
+
1530
1851
  elif cmd == "orchestrate":
1531
1852
  do_orchestrate(args)
1532
1853
 
1533
- elif cmd in ("?", "show"):
1854
+ elif cmd in ("?", "peek"):
1534
1855
  if not args:
1535
1856
  console.print(" [red]Usage:[/] ? SESSION_ID")
1857
+ else:
1858
+ do_peek(args[0])
1859
+
1860
+ elif cmd in ("show", "check"):
1861
+ if not args:
1862
+ console.print(" [red]Usage:[/] show SESSION_ID")
1536
1863
  else:
1537
1864
  do_show(args[0])
1538
1865
 
1539
1866
  elif cmd in ("c", "continue"):
1867
+ # Sync continue - waits for response
1540
1868
  if len(args) < 2:
1541
1869
  console.print(" [red]Usage:[/] c SESSION_ID \"message\"")
1542
1870
  else:
1543
- do_continue(args[0], " ".join(args[1:]))
1871
+ do_continue(args[0], " ".join(args[1:]), wait=True)
1872
+
1873
+ elif cmd in ("ca", "async"):
1874
+ # Async continue - fire and forget
1875
+ if len(args) < 2:
1876
+ console.print(" [red]Usage:[/] ca SESSION_ID \"message\"")
1877
+ console.print(" [dim]Sends message and returns immediately (check with '?')[/]")
1878
+ else:
1879
+ do_continue(args[0], " ".join(args[1:]), wait=False)
1544
1880
 
1545
- elif cmd == "logs":
1881
+ elif cmd == "check":
1546
1882
  if not args:
1547
- console.print(" [red]Usage:[/] logs SESSION_ID [-f]")
1883
+ console.print(" [red]Usage:[/] check SESSION_ID")
1548
1884
  else:
1549
- follow = "-f" in args or "--follow" in args
1550
- session_id = [a for a in args if not a.startswith("-")][0]
1551
- do_logs(session_id, follow)
1885
+ do_check(args[0])
1552
1886
 
1553
1887
  elif cmd == "kill":
1554
1888
  if not args:
@@ -1562,6 +1896,12 @@ def interactive(
1562
1896
  elif cmd == "clean":
1563
1897
  do_clean()
1564
1898
 
1899
+ elif cmd in ("rm", "delete", "remove"):
1900
+ if not args:
1901
+ console.print(" [red]Usage:[/] rm SESSION_ID")
1902
+ else:
1903
+ do_delete(args[0])
1904
+
1565
1905
  else:
1566
1906
  console.print(f" [yellow]Unknown command:[/] {cmd}")
1567
1907
  console.print(" [dim]Type 'help' for available commands[/]")