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 +735 -310
- zwarm/sessions/__init__.py +24 -0
- zwarm/sessions/manager.py +589 -0
- zwarm/tools/delegation.py +143 -1
- {zwarm-1.3.5.dist-info → zwarm-1.3.9.dist-info}/METADATA +1 -1
- {zwarm-1.3.5.dist-info → zwarm-1.3.9.dist-info}/RECORD +8 -6
- {zwarm-1.3.5.dist-info → zwarm-1.3.9.dist-info}/WHEEL +0 -0
- {zwarm-1.3.5.dist-info → zwarm-1.3.9.dist-info}/entry_points.txt +0 -0
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
|
|
1102
|
-
|
|
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
|
|
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]
|
|
1108
|
-
[cyan]c[/] / [cyan]
|
|
1109
|
-
[cyan]
|
|
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 --
|
|
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
|
|
1121
|
-
> spawn "Fix tests" --dir ~/api
|
|
1122
|
-
>
|
|
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.
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
1200
|
-
help_table.add_row(" --dir PATH", "Working directory
|
|
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("
|
|
1206
|
-
help_table.add_row("
|
|
1207
|
-
help_table.add_row("
|
|
1208
|
-
help_table.add_row("
|
|
1209
|
-
help_table.add_row("
|
|
1210
|
-
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")
|
|
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("
|
|
1213
|
-
help_table.add_row("q / quit", "Exit
|
|
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
|
|
1168
|
+
console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
|
|
1219
1169
|
return
|
|
1220
1170
|
|
|
1221
|
-
|
|
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("
|
|
1224
|
-
table.add_column("
|
|
1225
|
-
table.add_column("
|
|
1226
|
-
table.add_column("
|
|
1227
|
-
table.add_column("
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
|
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] [--
|
|
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] [--
|
|
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
|
-
|
|
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
|
|
1269
|
+
console.print(f"\n[dim]Spawning session...[/]")
|
|
1302
1270
|
console.print(f" [dim]Dir: {work_dir}[/]")
|
|
1303
|
-
console.print(f" [dim]
|
|
1271
|
+
console.print(f" [dim]Model: {session_model}[/]")
|
|
1304
1272
|
|
|
1305
1273
|
try:
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
|
1348
|
-
console.print(
|
|
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
|
-
|
|
1352
|
-
|
|
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]
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
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
|
|
1373
|
-
session
|
|
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
|
-
|
|
1361
|
+
# Get messages
|
|
1362
|
+
messages = manager.get_messages(session.id)
|
|
1379
1363
|
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
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"
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
console.print(f"
|
|
1430
|
-
console.print(f"
|
|
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
|
|
1433
|
-
"""Show raw
|
|
1434
|
-
session
|
|
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
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
console.print(f"
|
|
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
|
|
1462
|
-
if session.
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1533
|
-
|
|
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 ("
|
|
1533
|
+
elif cmd in ("?", "show"):
|
|
1540
1534
|
if not args:
|
|
1541
|
-
console.print(" [red]Usage:[/]
|
|
1535
|
+
console.print(" [red]Usage:[/] ? SESSION_ID")
|
|
1542
1536
|
else:
|
|
1543
|
-
|
|
1537
|
+
do_show(args[0])
|
|
1544
1538
|
|
|
1545
|
-
elif cmd in ("c", "
|
|
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
|
-
|
|
1543
|
+
do_continue(args[0], " ".join(args[1:]))
|
|
1550
1544
|
|
|
1551
|
-
elif cmd
|
|
1545
|
+
elif cmd == "logs":
|
|
1552
1546
|
if not args:
|
|
1553
|
-
console.print(" [red]Usage:[/]
|
|
1547
|
+
console.print(" [red]Usage:[/] logs SESSION_ID [-f]")
|
|
1554
1548
|
else:
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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 == "
|
|
1579
|
-
|
|
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,
|