zwarm 2.3.5__py3-none-any.whl → 3.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zwarm/cli/interactive.py +1065 -0
- zwarm/cli/main.py +525 -934
- zwarm/cli/pilot.py +1240 -0
- zwarm/core/__init__.py +20 -0
- zwarm/core/checkpoints.py +216 -0
- zwarm/core/config.py +26 -9
- zwarm/core/costs.py +71 -0
- zwarm/core/registry.py +329 -0
- zwarm/core/test_config.py +2 -3
- zwarm/orchestrator.py +17 -43
- zwarm/prompts/__init__.py +3 -0
- zwarm/prompts/orchestrator.py +36 -29
- zwarm/prompts/pilot.py +147 -0
- zwarm/sessions/__init__.py +48 -9
- zwarm/sessions/base.py +501 -0
- zwarm/sessions/claude.py +481 -0
- zwarm/sessions/manager.py +233 -486
- zwarm/tools/delegation.py +150 -187
- zwarm-3.6.0.dist-info/METADATA +445 -0
- zwarm-3.6.0.dist-info/RECORD +39 -0
- zwarm/adapters/__init__.py +0 -21
- zwarm/adapters/base.py +0 -109
- zwarm/adapters/claude_code.py +0 -357
- zwarm/adapters/codex_mcp.py +0 -1262
- zwarm/adapters/registry.py +0 -69
- zwarm/adapters/test_codex_mcp.py +0 -274
- zwarm/adapters/test_registry.py +0 -68
- zwarm-2.3.5.dist-info/METADATA +0 -309
- zwarm-2.3.5.dist-info/RECORD +0 -38
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/WHEEL +0 -0
- {zwarm-2.3.5.dist-info → zwarm-3.6.0.dist-info}/entry_points.txt +0 -0
zwarm/cli/interactive.py
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive REPL for zwarm session management.
|
|
3
|
+
|
|
4
|
+
A clean, autocomplete-enabled interface for managing codex sessions.
|
|
5
|
+
This is the user's direct REPL over the session primitives.
|
|
6
|
+
|
|
7
|
+
Topology: interactive → CodexSessionManager (substrate)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shlex
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from prompt_toolkit import PromptSession
|
|
19
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
20
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
21
|
+
from prompt_toolkit.styles import Style
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# =============================================================================
|
|
29
|
+
# Session ID Completer
|
|
30
|
+
# =============================================================================
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SessionCompleter(Completer):
|
|
34
|
+
"""
|
|
35
|
+
Autocomplete for session IDs.
|
|
36
|
+
|
|
37
|
+
Provides completions for commands that take session IDs.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, get_sessions_fn):
|
|
41
|
+
"""
|
|
42
|
+
Args:
|
|
43
|
+
get_sessions_fn: Callable that returns list of sessions
|
|
44
|
+
"""
|
|
45
|
+
self.get_sessions_fn = get_sessions_fn
|
|
46
|
+
|
|
47
|
+
# Commands that take session ID as first argument
|
|
48
|
+
self.session_commands = {
|
|
49
|
+
"?", "peek", "show", "traj", "trajectory", "watch",
|
|
50
|
+
"c", "continue",
|
|
51
|
+
}
|
|
52
|
+
# Commands that take session ID OR "all"
|
|
53
|
+
self.session_or_all_commands = {"kill", "rm", "delete"}
|
|
54
|
+
|
|
55
|
+
def get_completions(self, document, complete_event):
|
|
56
|
+
text = document.text_before_cursor
|
|
57
|
+
words = text.split()
|
|
58
|
+
|
|
59
|
+
# If we're completing the first word, suggest commands
|
|
60
|
+
if len(words) == 0 or (len(words) == 1 and not text.endswith(" ")):
|
|
61
|
+
word = words[0] if words else ""
|
|
62
|
+
commands = [
|
|
63
|
+
"spawn", "ls", "peek", "show", "traj", "watch",
|
|
64
|
+
"c", "kill", "rm", "help", "quit",
|
|
65
|
+
]
|
|
66
|
+
for cmd in commands:
|
|
67
|
+
if cmd.startswith(word.lower()):
|
|
68
|
+
yield Completion(cmd, start_position=-len(word))
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# If we have a command and need session ID
|
|
72
|
+
cmd = words[0].lower()
|
|
73
|
+
needs_session = cmd in self.session_commands or cmd in self.session_or_all_commands
|
|
74
|
+
|
|
75
|
+
if needs_session:
|
|
76
|
+
# Get sessions
|
|
77
|
+
try:
|
|
78
|
+
sessions = self.get_sessions_fn()
|
|
79
|
+
except Exception:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
# What has user typed for session ID?
|
|
83
|
+
if len(words) == 1 and text.endswith(" "):
|
|
84
|
+
# Just typed command + space, show all IDs
|
|
85
|
+
partial = ""
|
|
86
|
+
elif len(words) == 2 and not text.endswith(" "):
|
|
87
|
+
# Typing session ID
|
|
88
|
+
partial = words[1]
|
|
89
|
+
else:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# For kill/rm, also offer "all" as option
|
|
93
|
+
if cmd in self.session_or_all_commands:
|
|
94
|
+
if "all".startswith(partial.lower()):
|
|
95
|
+
yield Completion(
|
|
96
|
+
"all",
|
|
97
|
+
start_position=-len(partial),
|
|
98
|
+
display="all",
|
|
99
|
+
display_meta="all sessions",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Yield matching session IDs
|
|
103
|
+
for s in sessions:
|
|
104
|
+
short_id = s.short_id
|
|
105
|
+
if short_id.lower().startswith(partial.lower()):
|
|
106
|
+
# Show task as meta info
|
|
107
|
+
task_preview = s.task[:30] + "..." if len(s.task) > 30 else s.task
|
|
108
|
+
yield Completion(
|
|
109
|
+
short_id,
|
|
110
|
+
start_position=-len(partial),
|
|
111
|
+
display=short_id,
|
|
112
|
+
display_meta=f"{s.status.value}: {task_preview}",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# =============================================================================
|
|
117
|
+
# Display Helpers
|
|
118
|
+
# =============================================================================
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def time_ago(iso_str: str) -> str:
|
|
122
|
+
"""Convert ISO timestamp to human-readable 'Xs/Xm/Xh ago' format."""
|
|
123
|
+
try:
|
|
124
|
+
dt = datetime.fromisoformat(iso_str)
|
|
125
|
+
delta = datetime.now() - dt
|
|
126
|
+
secs = delta.total_seconds()
|
|
127
|
+
if secs < 60:
|
|
128
|
+
return f"{int(secs)}s"
|
|
129
|
+
elif secs < 3600:
|
|
130
|
+
return f"{int(secs/60)}m"
|
|
131
|
+
elif secs < 86400:
|
|
132
|
+
return f"{secs/3600:.1f}h"
|
|
133
|
+
else:
|
|
134
|
+
return f"{secs/86400:.1f}d"
|
|
135
|
+
except Exception:
|
|
136
|
+
return "?"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
STATUS_ICONS = {
|
|
140
|
+
"running": "[yellow]●[/]",
|
|
141
|
+
"completed": "[green]✓[/]",
|
|
142
|
+
"failed": "[red]✗[/]",
|
|
143
|
+
"killed": "[dim]○[/]",
|
|
144
|
+
"pending": "[dim]◌[/]",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# =============================================================================
|
|
149
|
+
# Commands
|
|
150
|
+
# =============================================================================
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_help():
|
|
154
|
+
"""Show help."""
|
|
155
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
156
|
+
table.add_column("Command", style="cyan", width=35)
|
|
157
|
+
table.add_column("Description")
|
|
158
|
+
|
|
159
|
+
table.add_row("[bold]Session Lifecycle[/]", "")
|
|
160
|
+
table.add_row('spawn "task" [--model M] [--adapter A]', "Start new session")
|
|
161
|
+
table.add_row('c ID "message"', "Continue conversation")
|
|
162
|
+
table.add_row("kill ID | all", "Stop session(s)")
|
|
163
|
+
table.add_row("rm ID | all", "Delete session(s)")
|
|
164
|
+
table.add_row("", "")
|
|
165
|
+
table.add_row("[bold]Viewing[/]", "")
|
|
166
|
+
table.add_row("ls", "Dashboard of all sessions")
|
|
167
|
+
table.add_row("? ID / peek ID", "Quick peek (status + latest)")
|
|
168
|
+
table.add_row("show ID", "Full session details")
|
|
169
|
+
table.add_row("traj ID [--full]", "Show trajectory (steps taken)")
|
|
170
|
+
table.add_row("watch ID", "Live follow session output")
|
|
171
|
+
table.add_row("", "")
|
|
172
|
+
table.add_row("[bold]Configuration[/]", "")
|
|
173
|
+
table.add_row("models", "List available models and adapters")
|
|
174
|
+
table.add_row("", "")
|
|
175
|
+
table.add_row("[bold]Shell[/]", "")
|
|
176
|
+
table.add_row("!<command>", "Run shell command (e.g., !ls, !git status)")
|
|
177
|
+
table.add_row("", "")
|
|
178
|
+
table.add_row("[bold]Meta[/]", "")
|
|
179
|
+
table.add_row("help", "Show this help")
|
|
180
|
+
table.add_row("quit", "Exit")
|
|
181
|
+
|
|
182
|
+
console.print(table)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_models():
|
|
186
|
+
"""Show available models."""
|
|
187
|
+
from zwarm.core.registry import list_models, list_adapters
|
|
188
|
+
|
|
189
|
+
table = Table(title="Available Models", box=None)
|
|
190
|
+
table.add_column("Adapter", style="cyan")
|
|
191
|
+
table.add_column("Model", style="green")
|
|
192
|
+
table.add_column("Aliases", style="dim")
|
|
193
|
+
table.add_column("Price ($/1M)", justify="right")
|
|
194
|
+
table.add_column("Description")
|
|
195
|
+
|
|
196
|
+
for adapter in list_adapters():
|
|
197
|
+
first = True
|
|
198
|
+
for model in list_models(adapter):
|
|
199
|
+
default_mark = " *" if model.is_default else ""
|
|
200
|
+
price = f"{model.input_per_million:.2f}/{model.output_per_million:.2f}"
|
|
201
|
+
aliases = ", ".join(model.aliases)
|
|
202
|
+
table.add_row(
|
|
203
|
+
adapter if first else "",
|
|
204
|
+
f"{model.canonical}{default_mark}",
|
|
205
|
+
aliases,
|
|
206
|
+
price,
|
|
207
|
+
model.description,
|
|
208
|
+
)
|
|
209
|
+
first = False
|
|
210
|
+
|
|
211
|
+
console.print(table)
|
|
212
|
+
console.print("\n[dim]* = default for adapter. Price = input/output per 1M tokens.[/]")
|
|
213
|
+
console.print("[dim]Use --model <name> or --adapter <adapter> with spawn.[/]")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_ls(manager):
|
|
217
|
+
"""List all sessions."""
|
|
218
|
+
from zwarm.sessions import SessionStatus
|
|
219
|
+
from zwarm.core.costs import estimate_session_cost, format_cost
|
|
220
|
+
|
|
221
|
+
sessions = manager.list_sessions()
|
|
222
|
+
|
|
223
|
+
if not sessions:
|
|
224
|
+
console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
# Summary counts
|
|
228
|
+
running = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
|
|
229
|
+
completed = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
|
|
230
|
+
failed = sum(1 for s in sessions if s.status == SessionStatus.FAILED)
|
|
231
|
+
killed = sum(1 for s in sessions if s.status == SessionStatus.KILLED)
|
|
232
|
+
|
|
233
|
+
# Total cost and tokens
|
|
234
|
+
total_cost = 0.0
|
|
235
|
+
total_tokens = 0
|
|
236
|
+
for s in sessions:
|
|
237
|
+
cost_info = estimate_session_cost(s.model, s.token_usage)
|
|
238
|
+
if cost_info["cost"] is not None:
|
|
239
|
+
total_cost += cost_info["cost"]
|
|
240
|
+
total_tokens += s.token_usage.get("total_tokens", 0)
|
|
241
|
+
|
|
242
|
+
parts = []
|
|
243
|
+
if running:
|
|
244
|
+
parts.append(f"[yellow]{running} running[/]")
|
|
245
|
+
if completed:
|
|
246
|
+
parts.append(f"[green]{completed} done[/]")
|
|
247
|
+
if failed:
|
|
248
|
+
parts.append(f"[red]{failed} failed[/]")
|
|
249
|
+
if killed:
|
|
250
|
+
parts.append(f"[dim]{killed} killed[/]")
|
|
251
|
+
parts.append(f"[cyan]{total_tokens:,} tokens[/]")
|
|
252
|
+
parts.append(f"[green]{format_cost(total_cost)}[/]")
|
|
253
|
+
if parts:
|
|
254
|
+
console.print(" | ".join(parts))
|
|
255
|
+
console.print()
|
|
256
|
+
|
|
257
|
+
# Table
|
|
258
|
+
table = Table(box=None, show_header=True, header_style="bold dim")
|
|
259
|
+
table.add_column("ID", style="cyan", width=10)
|
|
260
|
+
table.add_column("", width=2)
|
|
261
|
+
table.add_column("Model", width=12)
|
|
262
|
+
table.add_column("T", width=2)
|
|
263
|
+
table.add_column("Task", max_width=26)
|
|
264
|
+
table.add_column("Updated", justify="right", width=8)
|
|
265
|
+
table.add_column("Last Message", max_width=36)
|
|
266
|
+
|
|
267
|
+
for s in sessions:
|
|
268
|
+
icon = STATUS_ICONS.get(s.status.value, "?")
|
|
269
|
+
task_preview = s.task[:23] + "..." if len(s.task) > 26 else s.task
|
|
270
|
+
updated = time_ago(s.updated_at)
|
|
271
|
+
|
|
272
|
+
# Short model name (e.g., "gpt-5.1-codex-mini" -> "codex-mini")
|
|
273
|
+
model_short = s.model or "?"
|
|
274
|
+
if "codex" in model_short.lower():
|
|
275
|
+
# Extract codex variant: gpt-5.1-codex-mini -> codex-mini
|
|
276
|
+
parts = model_short.split("-")
|
|
277
|
+
codex_idx = next((i for i, p in enumerate(parts) if "codex" in p.lower()), -1)
|
|
278
|
+
if codex_idx >= 0:
|
|
279
|
+
model_short = "-".join(parts[codex_idx:])
|
|
280
|
+
elif len(model_short) > 12:
|
|
281
|
+
model_short = model_short[:10] + ".."
|
|
282
|
+
|
|
283
|
+
# Get last assistant message
|
|
284
|
+
messages = manager.get_messages(s.id)
|
|
285
|
+
last_msg = ""
|
|
286
|
+
for msg in reversed(messages):
|
|
287
|
+
if msg.role == "assistant":
|
|
288
|
+
last_msg = msg.content.replace("\n", " ")[:33]
|
|
289
|
+
if len(msg.content) > 33:
|
|
290
|
+
last_msg += "..."
|
|
291
|
+
break
|
|
292
|
+
|
|
293
|
+
# Style based on status
|
|
294
|
+
if s.status == SessionStatus.RUNNING:
|
|
295
|
+
last_msg_styled = f"[yellow]{last_msg or '(working...)'}[/]"
|
|
296
|
+
updated_styled = f"[yellow]{updated}[/]"
|
|
297
|
+
elif s.status == SessionStatus.COMPLETED:
|
|
298
|
+
try:
|
|
299
|
+
dt = datetime.fromisoformat(s.updated_at)
|
|
300
|
+
is_recent = (datetime.now() - dt).total_seconds() < 60
|
|
301
|
+
except Exception:
|
|
302
|
+
is_recent = False
|
|
303
|
+
if is_recent:
|
|
304
|
+
last_msg_styled = f"[green bold]{last_msg or '(done)'}[/]"
|
|
305
|
+
updated_styled = f"[green bold]{updated} ★[/]"
|
|
306
|
+
else:
|
|
307
|
+
last_msg_styled = f"[green]{last_msg or '(done)'}[/]"
|
|
308
|
+
updated_styled = f"[dim]{updated}[/]"
|
|
309
|
+
elif s.status == SessionStatus.FAILED:
|
|
310
|
+
err = s.error[:33] if s.error else "(failed)"
|
|
311
|
+
last_msg_styled = f"[red]{err}...[/]"
|
|
312
|
+
updated_styled = f"[red]{updated}[/]"
|
|
313
|
+
else:
|
|
314
|
+
last_msg_styled = f"[dim]{last_msg or '-'}[/]"
|
|
315
|
+
updated_styled = f"[dim]{updated}[/]"
|
|
316
|
+
|
|
317
|
+
table.add_row(s.short_id, icon, f"[dim]{model_short}[/]", str(s.turn), task_preview, updated_styled, last_msg_styled)
|
|
318
|
+
|
|
319
|
+
console.print(table)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def cmd_ls_multi(sessions: list, managers: dict | None = None):
|
|
323
|
+
"""
|
|
324
|
+
List sessions from multiple managers.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
sessions: List of Session objects
|
|
328
|
+
managers: Optional dict of adapter -> manager for getting messages
|
|
329
|
+
"""
|
|
330
|
+
from zwarm.sessions import SessionStatus
|
|
331
|
+
from zwarm.core.costs import estimate_session_cost, format_cost
|
|
332
|
+
|
|
333
|
+
if not sessions:
|
|
334
|
+
console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
# Summary counts
|
|
338
|
+
running = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
|
|
339
|
+
completed = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
|
|
340
|
+
failed = sum(1 for s in sessions if s.status == SessionStatus.FAILED)
|
|
341
|
+
killed = sum(1 for s in sessions if s.status == SessionStatus.KILLED)
|
|
342
|
+
|
|
343
|
+
# Total cost and tokens
|
|
344
|
+
total_cost = 0.0
|
|
345
|
+
total_tokens = 0
|
|
346
|
+
for s in sessions:
|
|
347
|
+
cost_info = estimate_session_cost(s.model, s.token_usage)
|
|
348
|
+
if cost_info["cost"] is not None:
|
|
349
|
+
total_cost += cost_info["cost"]
|
|
350
|
+
total_tokens += s.token_usage.get("total_tokens", 0)
|
|
351
|
+
|
|
352
|
+
parts = []
|
|
353
|
+
if running:
|
|
354
|
+
parts.append(f"[yellow]{running} running[/]")
|
|
355
|
+
if completed:
|
|
356
|
+
parts.append(f"[green]{completed} done[/]")
|
|
357
|
+
if failed:
|
|
358
|
+
parts.append(f"[red]{failed} failed[/]")
|
|
359
|
+
if killed:
|
|
360
|
+
parts.append(f"[dim]{killed} killed[/]")
|
|
361
|
+
parts.append(f"[cyan]{total_tokens:,} tokens[/]")
|
|
362
|
+
parts.append(f"[green]{format_cost(total_cost)}[/]")
|
|
363
|
+
if parts:
|
|
364
|
+
console.print(" | ".join(parts))
|
|
365
|
+
console.print()
|
|
366
|
+
|
|
367
|
+
# Table
|
|
368
|
+
table = Table(box=None, show_header=True, header_style="bold dim")
|
|
369
|
+
table.add_column("ID", style="cyan", width=10)
|
|
370
|
+
table.add_column("", width=2)
|
|
371
|
+
table.add_column("Adapter", width=7)
|
|
372
|
+
table.add_column("Model", width=12)
|
|
373
|
+
table.add_column("T", width=2)
|
|
374
|
+
table.add_column("Task", max_width=24)
|
|
375
|
+
table.add_column("Updated", justify="right", width=8)
|
|
376
|
+
|
|
377
|
+
for s in sessions:
|
|
378
|
+
icon = STATUS_ICONS.get(s.status.value, "?")
|
|
379
|
+
task_preview = s.task[:21] + "..." if len(s.task) > 24 else s.task
|
|
380
|
+
updated = time_ago(s.updated_at)
|
|
381
|
+
|
|
382
|
+
# Short model name
|
|
383
|
+
model_short = s.model or "?"
|
|
384
|
+
if "codex" in model_short.lower():
|
|
385
|
+
parts = model_short.split("-")
|
|
386
|
+
codex_idx = next((i for i, p in enumerate(parts) if "codex" in p.lower()), -1)
|
|
387
|
+
if codex_idx >= 0:
|
|
388
|
+
model_short = "-".join(parts[codex_idx:])
|
|
389
|
+
elif len(model_short) > 12:
|
|
390
|
+
model_short = model_short[:10] + ".."
|
|
391
|
+
|
|
392
|
+
# Adapter short name
|
|
393
|
+
adapter_short = getattr(s, "adapter", "?")[:7]
|
|
394
|
+
|
|
395
|
+
# Style based on status
|
|
396
|
+
if s.status == SessionStatus.RUNNING:
|
|
397
|
+
updated_styled = f"[yellow]{updated}[/]"
|
|
398
|
+
elif s.status == SessionStatus.COMPLETED:
|
|
399
|
+
try:
|
|
400
|
+
dt = datetime.fromisoformat(s.updated_at)
|
|
401
|
+
is_recent = (datetime.now() - dt).total_seconds() < 60
|
|
402
|
+
except Exception:
|
|
403
|
+
is_recent = False
|
|
404
|
+
if is_recent:
|
|
405
|
+
updated_styled = f"[green bold]{updated} ★[/]"
|
|
406
|
+
else:
|
|
407
|
+
updated_styled = f"[dim]{updated}[/]"
|
|
408
|
+
elif s.status == SessionStatus.FAILED:
|
|
409
|
+
updated_styled = f"[red]{updated}[/]"
|
|
410
|
+
else:
|
|
411
|
+
updated_styled = f"[dim]{updated}[/]"
|
|
412
|
+
|
|
413
|
+
table.add_row(s.short_id, icon, f"[dim]{adapter_short}[/]", f"[dim]{model_short}[/]", str(s.turn), task_preview, updated_styled)
|
|
414
|
+
|
|
415
|
+
console.print(table)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def cmd_peek(manager, session_id: str):
|
|
419
|
+
"""Quick peek at session status."""
|
|
420
|
+
session = manager.get_session(session_id)
|
|
421
|
+
if not session:
|
|
422
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
icon = STATUS_ICONS.get(session.status.value, "?")
|
|
426
|
+
console.print(f"\n{icon} [cyan]{session.short_id}[/] ({session.status.value})")
|
|
427
|
+
console.print(f" [dim]Task:[/] {session.task[:60]}...")
|
|
428
|
+
console.print(f" [dim]Model:[/] {session.model} | [dim]Turn:[/] {session.turn} | [dim]Updated:[/] {time_ago(session.updated_at)}")
|
|
429
|
+
|
|
430
|
+
# Latest message
|
|
431
|
+
messages = manager.get_messages(session.id)
|
|
432
|
+
for msg in reversed(messages):
|
|
433
|
+
if msg.role == "assistant":
|
|
434
|
+
preview = msg.content.replace("\n", " ")[:100]
|
|
435
|
+
if len(msg.content) > 100:
|
|
436
|
+
preview += "..."
|
|
437
|
+
console.print(f"\n [bold]Latest:[/] {preview}")
|
|
438
|
+
break
|
|
439
|
+
console.print()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def cmd_show(manager, session_id: str):
|
|
443
|
+
"""Full session details with messages."""
|
|
444
|
+
from zwarm.core.costs import estimate_session_cost
|
|
445
|
+
|
|
446
|
+
session = manager.get_session(session_id)
|
|
447
|
+
if not session:
|
|
448
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
# Header
|
|
452
|
+
icon = STATUS_ICONS.get(session.status.value, "?")
|
|
453
|
+
console.print(f"\n{icon} [bold cyan]{session.short_id}[/] - {session.status.value}")
|
|
454
|
+
console.print(f" [dim]Task:[/] {session.task}")
|
|
455
|
+
console.print(f" [dim]Model:[/] {session.model} | [dim]Turn:[/] {session.turn} | [dim]Runtime:[/] {session.runtime}")
|
|
456
|
+
|
|
457
|
+
# Token usage with cost estimate
|
|
458
|
+
usage = session.token_usage
|
|
459
|
+
input_tok = usage.get("input_tokens", 0)
|
|
460
|
+
output_tok = usage.get("output_tokens", 0)
|
|
461
|
+
total_tok = usage.get("total_tokens", input_tok + output_tok)
|
|
462
|
+
|
|
463
|
+
cost_info = estimate_session_cost(session.model, usage)
|
|
464
|
+
cost_str = f"[green]{cost_info['cost_formatted']}[/]" if cost_info["pricing_known"] else "[dim]?[/]"
|
|
465
|
+
|
|
466
|
+
console.print(f" [dim]Tokens:[/] {total_tok:,} ({input_tok:,} in / {output_tok:,} out) | [dim]Cost:[/] {cost_str}")
|
|
467
|
+
|
|
468
|
+
if session.error:
|
|
469
|
+
console.print(f" [red]Error:[/] {session.error}")
|
|
470
|
+
|
|
471
|
+
# Messages
|
|
472
|
+
messages = manager.get_messages(session.id)
|
|
473
|
+
if messages:
|
|
474
|
+
console.print(f"\n[bold]Messages ({len(messages)}):[/]")
|
|
475
|
+
for msg in messages:
|
|
476
|
+
role = msg.role
|
|
477
|
+
content = msg.content[:200]
|
|
478
|
+
if len(msg.content) > 200:
|
|
479
|
+
content += "..."
|
|
480
|
+
|
|
481
|
+
if role == "user":
|
|
482
|
+
console.print(f" [blue]USER:[/] {content}")
|
|
483
|
+
elif role == "assistant":
|
|
484
|
+
console.print(f" [green]ASSISTANT:[/] {content}")
|
|
485
|
+
else:
|
|
486
|
+
console.print(f" [dim]{role.upper()}:[/] {content[:100]}")
|
|
487
|
+
|
|
488
|
+
console.print()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def cmd_traj(manager, session_id: str, full: bool = False):
|
|
492
|
+
"""Show session trajectory."""
|
|
493
|
+
session = manager.get_session(session_id)
|
|
494
|
+
if not session:
|
|
495
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
trajectory = manager.get_trajectory(session_id, full=full)
|
|
499
|
+
|
|
500
|
+
console.print(f"\n[bold]Trajectory for {session.short_id}[/] ({len(trajectory)} steps)")
|
|
501
|
+
console.print(f" [dim]Task:[/] {session.task[:60]}...")
|
|
502
|
+
console.print()
|
|
503
|
+
|
|
504
|
+
for i, step in enumerate(trajectory):
|
|
505
|
+
step_type = step.get("type", "unknown")
|
|
506
|
+
|
|
507
|
+
if step_type == "reasoning":
|
|
508
|
+
text = step.get("full_text") if full else step.get("summary", "")
|
|
509
|
+
console.print(f" [dim]{i+1}.[/] [magenta]💭 thinking[/]")
|
|
510
|
+
if text:
|
|
511
|
+
console.print(f" {text[:150]}{'...' if len(text) > 150 else ''}")
|
|
512
|
+
|
|
513
|
+
elif step_type == "command":
|
|
514
|
+
cmd = step.get("command", "")
|
|
515
|
+
output = step.get("output", "")
|
|
516
|
+
exit_code = step.get("exit_code", 0)
|
|
517
|
+
console.print(f" [dim]{i+1}.[/] [yellow]$ {cmd}[/]")
|
|
518
|
+
if output and (full or len(output) < 100):
|
|
519
|
+
console.print(f" {output[:200]}")
|
|
520
|
+
if exit_code and exit_code != 0:
|
|
521
|
+
console.print(f" [red](exit: {exit_code})[/]")
|
|
522
|
+
|
|
523
|
+
elif step_type == "tool_call":
|
|
524
|
+
tool = step.get("tool", "unknown")
|
|
525
|
+
args_preview = step.get("args_preview", "")
|
|
526
|
+
console.print(f" [dim]{i+1}.[/] [cyan]🔧 {tool}[/]({args_preview})")
|
|
527
|
+
|
|
528
|
+
elif step_type == "tool_output":
|
|
529
|
+
output = step.get("output", "")
|
|
530
|
+
preview = output[:100] if not full else output[:300]
|
|
531
|
+
console.print(f" [dim]→ {preview}[/]")
|
|
532
|
+
|
|
533
|
+
elif step_type == "message":
|
|
534
|
+
text = step.get("full_text") if full else step.get("summary", "")
|
|
535
|
+
console.print(f" [dim]{i+1}.[/] [green]💬 response[/]")
|
|
536
|
+
if text:
|
|
537
|
+
console.print(f" {text[:150]}{'...' if len(text) > 150 else ''}")
|
|
538
|
+
|
|
539
|
+
console.print()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def cmd_watch(manager, session_id: str):
|
|
543
|
+
"""
|
|
544
|
+
Watch session output live.
|
|
545
|
+
|
|
546
|
+
Polls trajectory and displays new steps as they appear.
|
|
547
|
+
"""
|
|
548
|
+
from zwarm.sessions import SessionStatus
|
|
549
|
+
|
|
550
|
+
session = manager.get_session(session_id)
|
|
551
|
+
if not session:
|
|
552
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
console.print(f"\n[bold]Watching {session.short_id}[/]...")
|
|
556
|
+
console.print(f" [dim]Task:[/] {session.task[:60]}...")
|
|
557
|
+
console.print(f" [dim]Model:[/] {session.model}")
|
|
558
|
+
console.print(f" [dim]Press Ctrl+C to stop watching[/]\n")
|
|
559
|
+
|
|
560
|
+
seen_steps = 0
|
|
561
|
+
last_status = None
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
while True:
|
|
565
|
+
# Refresh session
|
|
566
|
+
session = manager.get_session(session_id)
|
|
567
|
+
if not session:
|
|
568
|
+
console.print("[red]Session disappeared![/]")
|
|
569
|
+
break
|
|
570
|
+
|
|
571
|
+
# Status change
|
|
572
|
+
if session.status.value != last_status:
|
|
573
|
+
icon = STATUS_ICONS.get(session.status.value, "?")
|
|
574
|
+
console.print(f"\n{icon} Status: [bold]{session.status.value}[/]")
|
|
575
|
+
last_status = session.status.value
|
|
576
|
+
|
|
577
|
+
# Get trajectory
|
|
578
|
+
trajectory = manager.get_trajectory(session_id, full=False)
|
|
579
|
+
|
|
580
|
+
# Show new steps
|
|
581
|
+
for i, step in enumerate(trajectory[seen_steps:], start=seen_steps + 1):
|
|
582
|
+
step_type = step.get("type", "unknown")
|
|
583
|
+
|
|
584
|
+
if step_type == "reasoning":
|
|
585
|
+
text = step.get("summary", "")[:80]
|
|
586
|
+
console.print(f" [magenta]💭[/] {text}...")
|
|
587
|
+
|
|
588
|
+
elif step_type == "command":
|
|
589
|
+
cmd = step.get("command", "")
|
|
590
|
+
console.print(f" [yellow]$[/] {cmd}")
|
|
591
|
+
|
|
592
|
+
elif step_type == "tool_call":
|
|
593
|
+
tool = step.get("tool", "unknown")
|
|
594
|
+
console.print(f" [cyan]🔧[/] {tool}(...)")
|
|
595
|
+
|
|
596
|
+
elif step_type == "message":
|
|
597
|
+
text = step.get("summary", "")[:80]
|
|
598
|
+
console.print(f" [green]💬[/] {text}...")
|
|
599
|
+
|
|
600
|
+
seen_steps = len(trajectory)
|
|
601
|
+
|
|
602
|
+
# Check if done
|
|
603
|
+
if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
|
|
604
|
+
console.print(f"\n[dim]Session {session.status.value}. Final message:[/]")
|
|
605
|
+
messages = manager.get_messages(session.id)
|
|
606
|
+
for msg in reversed(messages):
|
|
607
|
+
if msg.role == "assistant":
|
|
608
|
+
console.print(f" {msg.content[:200]}...")
|
|
609
|
+
break
|
|
610
|
+
break
|
|
611
|
+
|
|
612
|
+
time.sleep(1.0)
|
|
613
|
+
|
|
614
|
+
except KeyboardInterrupt:
|
|
615
|
+
console.print("\n[dim]Stopped watching.[/]")
|
|
616
|
+
|
|
617
|
+
console.print()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def cmd_spawn(managers: dict, task: str, working_dir: Path, model: str, adapter: str | None = None):
|
|
621
|
+
"""
|
|
622
|
+
Spawn a new session.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
managers: Dict of adapter name -> session manager
|
|
626
|
+
task: Task description
|
|
627
|
+
working_dir: Working directory
|
|
628
|
+
model: Model name or alias
|
|
629
|
+
adapter: Adapter override (auto-detected from model if None)
|
|
630
|
+
"""
|
|
631
|
+
from zwarm.core.registry import get_adapter_for_model, get_default_model, resolve_model
|
|
632
|
+
|
|
633
|
+
# Auto-detect adapter from model if not specified
|
|
634
|
+
if adapter is None:
|
|
635
|
+
detected = get_adapter_for_model(model)
|
|
636
|
+
if detected:
|
|
637
|
+
adapter = detected
|
|
638
|
+
else:
|
|
639
|
+
# Default to codex if model not recognized
|
|
640
|
+
adapter = "codex"
|
|
641
|
+
|
|
642
|
+
# Resolve model alias to canonical name if needed
|
|
643
|
+
model_info = resolve_model(model)
|
|
644
|
+
effective_model = model_info.canonical if model_info else model
|
|
645
|
+
|
|
646
|
+
# Get the right manager
|
|
647
|
+
if adapter not in managers:
|
|
648
|
+
console.print(f" [red]Unknown adapter:[/] {adapter}")
|
|
649
|
+
console.print(f" [dim]Available: {', '.join(managers.keys())}[/]")
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
manager = managers[adapter]
|
|
653
|
+
|
|
654
|
+
console.print(f"\n[dim]Spawning session...[/]")
|
|
655
|
+
console.print(f" [dim]Adapter:[/] {adapter}")
|
|
656
|
+
console.print(f" [dim]Model:[/] {effective_model}")
|
|
657
|
+
console.print(f" [dim]Dir:[/] {working_dir}")
|
|
658
|
+
|
|
659
|
+
try:
|
|
660
|
+
session = manager.start_session(
|
|
661
|
+
task=task,
|
|
662
|
+
working_dir=working_dir,
|
|
663
|
+
model=effective_model,
|
|
664
|
+
sandbox="workspace-write",
|
|
665
|
+
source="user",
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
console.print(f"\n[green]✓[/] Session: [cyan]{session.short_id}[/]")
|
|
669
|
+
console.print(f" [dim]Use 'watch {session.short_id}' to follow progress[/]")
|
|
670
|
+
console.print(f" [dim]Use 'show {session.short_id}' when complete[/]")
|
|
671
|
+
|
|
672
|
+
except Exception as e:
|
|
673
|
+
console.print(f" [red]Error:[/] {e}")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def cmd_continue(manager, session_id: str, message: str):
|
|
677
|
+
"""Continue a conversation."""
|
|
678
|
+
from zwarm.sessions import SessionStatus
|
|
679
|
+
|
|
680
|
+
session = manager.get_session(session_id)
|
|
681
|
+
if not session:
|
|
682
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
683
|
+
return
|
|
684
|
+
|
|
685
|
+
if session.status == SessionStatus.RUNNING:
|
|
686
|
+
console.print(f" [yellow]Session still running - wait for it to complete[/]")
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
if session.status == SessionStatus.KILLED:
|
|
690
|
+
console.print(f" [red]Session was killed - start a new one[/]")
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
console.print(f"\n[dim]Injecting message into {session.short_id}...[/]")
|
|
694
|
+
|
|
695
|
+
updated = manager.inject_message(session_id, message)
|
|
696
|
+
if updated:
|
|
697
|
+
console.print(f"[green]✓[/] Message sent (turn {updated.turn})")
|
|
698
|
+
console.print(f" [dim]Use 'watch {session.short_id}' to follow response[/]")
|
|
699
|
+
else:
|
|
700
|
+
console.print(f" [red]Failed to inject message[/]")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def cmd_kill(manager, target: str):
|
|
704
|
+
"""
|
|
705
|
+
Kill session(s).
|
|
706
|
+
|
|
707
|
+
Args:
|
|
708
|
+
target: Session ID or "all" to kill all running
|
|
709
|
+
"""
|
|
710
|
+
from zwarm.sessions import SessionStatus
|
|
711
|
+
|
|
712
|
+
if target.lower() == "all":
|
|
713
|
+
# Kill all running
|
|
714
|
+
sessions = manager.list_sessions(status=SessionStatus.RUNNING)
|
|
715
|
+
if not sessions:
|
|
716
|
+
console.print(" [dim]No running sessions[/]")
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
killed = 0
|
|
720
|
+
for s in sessions:
|
|
721
|
+
if manager.kill_session(s.id):
|
|
722
|
+
killed += 1
|
|
723
|
+
console.print(f" [green]✓[/] Killed {s.short_id}")
|
|
724
|
+
|
|
725
|
+
console.print(f"\n[green]Killed {killed} session(s)[/]")
|
|
726
|
+
else:
|
|
727
|
+
# Kill single session
|
|
728
|
+
session = manager.get_session(target)
|
|
729
|
+
if not session:
|
|
730
|
+
console.print(f" [red]Session not found:[/] {target}")
|
|
731
|
+
return
|
|
732
|
+
|
|
733
|
+
if manager.kill_session(session.id):
|
|
734
|
+
console.print(f"[green]✓[/] Killed {session.short_id}")
|
|
735
|
+
else:
|
|
736
|
+
console.print(f" [yellow]Session not running or already stopped[/]")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def cmd_rm(manager, target: str):
|
|
740
|
+
"""
|
|
741
|
+
Delete session(s).
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
target: Session ID or "all" to delete all non-running
|
|
745
|
+
"""
|
|
746
|
+
from zwarm.sessions import SessionStatus
|
|
747
|
+
|
|
748
|
+
if target.lower() == "all":
|
|
749
|
+
# Delete all non-running (completed, failed, killed)
|
|
750
|
+
sessions = manager.list_sessions()
|
|
751
|
+
to_delete = [s for s in sessions if s.status != SessionStatus.RUNNING]
|
|
752
|
+
|
|
753
|
+
if not to_delete:
|
|
754
|
+
console.print(" [dim]Nothing to delete[/]")
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
deleted = 0
|
|
758
|
+
for s in to_delete:
|
|
759
|
+
if manager.delete_session(s.id):
|
|
760
|
+
deleted += 1
|
|
761
|
+
|
|
762
|
+
console.print(f"[green]✓[/] Deleted {deleted} session(s)")
|
|
763
|
+
else:
|
|
764
|
+
# Delete single session
|
|
765
|
+
session = manager.get_session(target)
|
|
766
|
+
if not session:
|
|
767
|
+
console.print(f" [red]Session not found:[/] {target}")
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
if manager.delete_session(session.id):
|
|
771
|
+
console.print(f"[green]✓[/] Deleted {session.short_id}")
|
|
772
|
+
else:
|
|
773
|
+
console.print(f" [red]Failed to delete[/]")
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# =============================================================================
|
|
779
|
+
# Main REPL
|
|
780
|
+
# =============================================================================
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def run_interactive(
|
|
784
|
+
working_dir: Path,
|
|
785
|
+
model: str = "gpt-5.1-codex-mini",
|
|
786
|
+
):
|
|
787
|
+
"""
|
|
788
|
+
Run the interactive REPL.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
working_dir: Default working directory for sessions
|
|
792
|
+
model: Default model for sessions
|
|
793
|
+
"""
|
|
794
|
+
from zwarm.sessions import get_session_manager
|
|
795
|
+
from zwarm.core.registry import get_adapter_for_model, list_adapters
|
|
796
|
+
|
|
797
|
+
# Initialize managers for all adapters
|
|
798
|
+
state_dir = working_dir / ".zwarm"
|
|
799
|
+
managers = {}
|
|
800
|
+
for adapter in list_adapters():
|
|
801
|
+
try:
|
|
802
|
+
managers[adapter] = get_session_manager(adapter, str(state_dir))
|
|
803
|
+
except Exception:
|
|
804
|
+
pass # Adapter not available
|
|
805
|
+
|
|
806
|
+
if not managers:
|
|
807
|
+
console.print("[red]No adapters available. Run 'zwarm init' first.[/]")
|
|
808
|
+
return
|
|
809
|
+
|
|
810
|
+
# Primary manager for listing (aggregates across all adapters)
|
|
811
|
+
primary_adapter = get_adapter_for_model(model) or "codex"
|
|
812
|
+
if primary_adapter not in managers:
|
|
813
|
+
primary_adapter = list(managers.keys())[0]
|
|
814
|
+
|
|
815
|
+
# Setup prompt with autocomplete
|
|
816
|
+
def get_sessions():
|
|
817
|
+
# Aggregate sessions from all managers
|
|
818
|
+
all_sessions = []
|
|
819
|
+
for mgr in managers.values():
|
|
820
|
+
all_sessions.extend(mgr.list_sessions())
|
|
821
|
+
return all_sessions
|
|
822
|
+
|
|
823
|
+
completer = SessionCompleter(get_sessions)
|
|
824
|
+
style = Style.from_dict({
|
|
825
|
+
"prompt": "cyan bold",
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
session = PromptSession(
|
|
829
|
+
completer=completer,
|
|
830
|
+
history=InMemoryHistory(),
|
|
831
|
+
style=style,
|
|
832
|
+
complete_while_typing=True,
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# Welcome
|
|
836
|
+
console.print("\n[bold cyan]zwarm interactive[/] - Session Manager\n")
|
|
837
|
+
console.print(f" [dim]Dir:[/] {working_dir.absolute()}")
|
|
838
|
+
console.print(f" [dim]Model:[/] {model}")
|
|
839
|
+
console.print(f" [dim]Adapters:[/] {', '.join(managers.keys())}")
|
|
840
|
+
console.print(f"\n Type [cyan]help[/] for commands, [cyan]models[/] to see available models.")
|
|
841
|
+
console.print(f" [dim]Tab to autocomplete session IDs[/]\n")
|
|
842
|
+
|
|
843
|
+
# REPL
|
|
844
|
+
while True:
|
|
845
|
+
try:
|
|
846
|
+
raw = session.prompt("> ").strip()
|
|
847
|
+
if not raw:
|
|
848
|
+
continue
|
|
849
|
+
|
|
850
|
+
# Bang command: !cmd runs shell command
|
|
851
|
+
if raw.startswith("!"):
|
|
852
|
+
import subprocess
|
|
853
|
+
shell_cmd = raw[1:].strip()
|
|
854
|
+
if shell_cmd:
|
|
855
|
+
try:
|
|
856
|
+
result = subprocess.run(
|
|
857
|
+
shell_cmd,
|
|
858
|
+
shell=True,
|
|
859
|
+
cwd=working_dir,
|
|
860
|
+
capture_output=True,
|
|
861
|
+
text=True,
|
|
862
|
+
)
|
|
863
|
+
if result.stdout:
|
|
864
|
+
console.print(result.stdout.rstrip())
|
|
865
|
+
if result.stderr:
|
|
866
|
+
console.print(f"[red]{result.stderr.rstrip()}[/]")
|
|
867
|
+
if result.returncode != 0:
|
|
868
|
+
console.print(f"[dim](exit code: {result.returncode})[/]")
|
|
869
|
+
except Exception as e:
|
|
870
|
+
console.print(f"[red]Error:[/] {e}")
|
|
871
|
+
continue
|
|
872
|
+
|
|
873
|
+
try:
|
|
874
|
+
parts = shlex.split(raw)
|
|
875
|
+
except ValueError:
|
|
876
|
+
parts = raw.split()
|
|
877
|
+
|
|
878
|
+
cmd = parts[0].lower()
|
|
879
|
+
args = parts[1:]
|
|
880
|
+
|
|
881
|
+
# Helper to find session and return the correct manager for its adapter
|
|
882
|
+
def find_session(sid: str):
|
|
883
|
+
# First, find the session (any manager can load it)
|
|
884
|
+
session = None
|
|
885
|
+
for mgr in managers.values():
|
|
886
|
+
session = mgr.get_session(sid)
|
|
887
|
+
if session:
|
|
888
|
+
break
|
|
889
|
+
|
|
890
|
+
if not session:
|
|
891
|
+
return None, None
|
|
892
|
+
|
|
893
|
+
# Return the manager that matches the session's adapter
|
|
894
|
+
adapter = getattr(session, "adapter", "codex")
|
|
895
|
+
if adapter in managers:
|
|
896
|
+
return managers[adapter], session
|
|
897
|
+
else:
|
|
898
|
+
# Fallback to whichever manager found it
|
|
899
|
+
return mgr, session
|
|
900
|
+
|
|
901
|
+
# Dispatch
|
|
902
|
+
if cmd in ("q", "quit", "exit"):
|
|
903
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
904
|
+
break
|
|
905
|
+
|
|
906
|
+
elif cmd in ("h", "help"):
|
|
907
|
+
cmd_help()
|
|
908
|
+
|
|
909
|
+
elif cmd == "models":
|
|
910
|
+
cmd_models()
|
|
911
|
+
|
|
912
|
+
elif cmd in ("ls", "list"):
|
|
913
|
+
# Aggregate sessions from all managers
|
|
914
|
+
from zwarm.sessions import SessionStatus
|
|
915
|
+
from zwarm.core.costs import estimate_session_cost, format_cost
|
|
916
|
+
|
|
917
|
+
all_sessions = []
|
|
918
|
+
for mgr in managers.values():
|
|
919
|
+
all_sessions.extend(mgr.list_sessions())
|
|
920
|
+
|
|
921
|
+
if not all_sessions:
|
|
922
|
+
console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
|
|
923
|
+
else:
|
|
924
|
+
# Use first manager's cmd_ls logic but with aggregated sessions
|
|
925
|
+
cmd_ls_multi(all_sessions, managers)
|
|
926
|
+
|
|
927
|
+
elif cmd in ("?", "peek"):
|
|
928
|
+
if not args:
|
|
929
|
+
console.print(" [red]Usage:[/] peek ID")
|
|
930
|
+
else:
|
|
931
|
+
mgr, _ = find_session(args[0])
|
|
932
|
+
if mgr:
|
|
933
|
+
cmd_peek(mgr, args[0])
|
|
934
|
+
else:
|
|
935
|
+
console.print(f" [red]Session not found:[/] {args[0]}")
|
|
936
|
+
|
|
937
|
+
elif cmd == "show":
|
|
938
|
+
if not args:
|
|
939
|
+
console.print(" [red]Usage:[/] show ID")
|
|
940
|
+
else:
|
|
941
|
+
mgr, _ = find_session(args[0])
|
|
942
|
+
if mgr:
|
|
943
|
+
cmd_show(mgr, args[0])
|
|
944
|
+
else:
|
|
945
|
+
console.print(f" [red]Session not found:[/] {args[0]}")
|
|
946
|
+
|
|
947
|
+
elif cmd in ("traj", "trajectory"):
|
|
948
|
+
if not args:
|
|
949
|
+
console.print(" [red]Usage:[/] traj ID [--full]")
|
|
950
|
+
else:
|
|
951
|
+
full = "--full" in args
|
|
952
|
+
sid = [a for a in args if not a.startswith("-")][0]
|
|
953
|
+
mgr, _ = find_session(sid)
|
|
954
|
+
if mgr:
|
|
955
|
+
cmd_traj(mgr, sid, full=full)
|
|
956
|
+
else:
|
|
957
|
+
console.print(f" [red]Session not found:[/] {sid}")
|
|
958
|
+
|
|
959
|
+
elif cmd == "watch":
|
|
960
|
+
if not args:
|
|
961
|
+
console.print(" [red]Usage:[/] watch ID")
|
|
962
|
+
else:
|
|
963
|
+
mgr, _ = find_session(args[0])
|
|
964
|
+
if mgr:
|
|
965
|
+
cmd_watch(mgr, args[0])
|
|
966
|
+
else:
|
|
967
|
+
console.print(f" [red]Session not found:[/] {args[0]}")
|
|
968
|
+
|
|
969
|
+
elif cmd == "spawn":
|
|
970
|
+
if not args:
|
|
971
|
+
console.print(" [red]Usage:[/] spawn \"task\" [--model M] [--adapter A]")
|
|
972
|
+
else:
|
|
973
|
+
# Parse spawn args
|
|
974
|
+
task_parts = []
|
|
975
|
+
spawn_dir = working_dir
|
|
976
|
+
spawn_model = model
|
|
977
|
+
spawn_adapter = None
|
|
978
|
+
i = 0
|
|
979
|
+
while i < len(args):
|
|
980
|
+
if args[i] in ("--dir", "-d") and i + 1 < len(args):
|
|
981
|
+
spawn_dir = Path(args[i + 1])
|
|
982
|
+
i += 2
|
|
983
|
+
elif args[i] in ("--model", "-m") and i + 1 < len(args):
|
|
984
|
+
spawn_model = args[i + 1]
|
|
985
|
+
i += 2
|
|
986
|
+
elif args[i] in ("--adapter", "-a") and i + 1 < len(args):
|
|
987
|
+
spawn_adapter = args[i + 1]
|
|
988
|
+
i += 2
|
|
989
|
+
else:
|
|
990
|
+
task_parts.append(args[i])
|
|
991
|
+
i += 1
|
|
992
|
+
|
|
993
|
+
task = " ".join(task_parts)
|
|
994
|
+
if task:
|
|
995
|
+
cmd_spawn(managers, task, spawn_dir, spawn_model, spawn_adapter)
|
|
996
|
+
else:
|
|
997
|
+
console.print(" [red]Task required[/]")
|
|
998
|
+
|
|
999
|
+
elif cmd in ("c", "continue"):
|
|
1000
|
+
if len(args) < 2:
|
|
1001
|
+
console.print(" [red]Usage:[/] c ID \"message\"")
|
|
1002
|
+
else:
|
|
1003
|
+
mgr, _ = find_session(args[0])
|
|
1004
|
+
if mgr:
|
|
1005
|
+
cmd_continue(mgr, args[0], " ".join(args[1:]))
|
|
1006
|
+
else:
|
|
1007
|
+
console.print(f" [red]Session not found:[/] {args[0]}")
|
|
1008
|
+
|
|
1009
|
+
elif cmd == "kill":
|
|
1010
|
+
if not args:
|
|
1011
|
+
console.print(" [red]Usage:[/] kill ID | all")
|
|
1012
|
+
elif args[0].lower() == "all":
|
|
1013
|
+
# Kill all running across all managers
|
|
1014
|
+
killed = 0
|
|
1015
|
+
for mgr in managers.values():
|
|
1016
|
+
from zwarm.sessions import SessionStatus
|
|
1017
|
+
for s in mgr.list_sessions(status=SessionStatus.RUNNING):
|
|
1018
|
+
if mgr.kill_session(s.id):
|
|
1019
|
+
killed += 1
|
|
1020
|
+
console.print(f" [green]✓[/] Killed {s.short_id}")
|
|
1021
|
+
if killed:
|
|
1022
|
+
console.print(f"\n[green]Killed {killed} session(s)[/]")
|
|
1023
|
+
else:
|
|
1024
|
+
console.print(" [dim]No running sessions[/]")
|
|
1025
|
+
else:
|
|
1026
|
+
mgr, _ = find_session(args[0])
|
|
1027
|
+
if mgr:
|
|
1028
|
+
cmd_kill(mgr, args[0])
|
|
1029
|
+
else:
|
|
1030
|
+
console.print(f" [red]Session not found:[/] {args[0]}")
|
|
1031
|
+
|
|
1032
|
+
elif cmd in ("rm", "delete"):
|
|
1033
|
+
if not args:
|
|
1034
|
+
console.print(" [red]Usage:[/] rm ID | all")
|
|
1035
|
+
elif args[0].lower() == "all":
|
|
1036
|
+
# Delete all non-running across all managers
|
|
1037
|
+
deleted = 0
|
|
1038
|
+
for mgr in managers.values():
|
|
1039
|
+
from zwarm.sessions import SessionStatus
|
|
1040
|
+
for s in mgr.list_sessions():
|
|
1041
|
+
if s.status != SessionStatus.RUNNING:
|
|
1042
|
+
if mgr.delete_session(s.id):
|
|
1043
|
+
deleted += 1
|
|
1044
|
+
if deleted:
|
|
1045
|
+
console.print(f"[green]✓[/] Deleted {deleted} session(s)")
|
|
1046
|
+
else:
|
|
1047
|
+
console.print(" [dim]Nothing to delete[/]")
|
|
1048
|
+
else:
|
|
1049
|
+
mgr, _ = find_session(args[0])
|
|
1050
|
+
if mgr:
|
|
1051
|
+
cmd_rm(mgr, args[0])
|
|
1052
|
+
else:
|
|
1053
|
+
console.print(f" [red]Session not found:[/] {args[0]}")
|
|
1054
|
+
|
|
1055
|
+
else:
|
|
1056
|
+
console.print(f" [yellow]Unknown command:[/] {cmd}")
|
|
1057
|
+
console.print(" [dim]Type 'help' for commands[/]")
|
|
1058
|
+
|
|
1059
|
+
except KeyboardInterrupt:
|
|
1060
|
+
console.print("\n[dim](Ctrl+C again or 'quit' to exit)[/]")
|
|
1061
|
+
except EOFError:
|
|
1062
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
1063
|
+
break
|
|
1064
|
+
except Exception as e:
|
|
1065
|
+
console.print(f" [red]Error:[/] {e}")
|