zwarm 3.0__py3-none-any.whl → 3.2.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 +749 -0
- zwarm/cli/main.py +207 -854
- zwarm/cli/pilot.py +293 -151
- zwarm/core/__init__.py +20 -0
- zwarm/core/checkpoints.py +216 -0
- zwarm/core/costs.py +199 -0
- zwarm/tools/delegation.py +18 -161
- {zwarm-3.0.dist-info → zwarm-3.2.0.dist-info}/METADATA +2 -1
- {zwarm-3.0.dist-info → zwarm-3.2.0.dist-info}/RECORD +11 -8
- {zwarm-3.0.dist-info → zwarm-3.2.0.dist-info}/WHEEL +0 -0
- {zwarm-3.0.dist-info → zwarm-3.2.0.dist-info}/entry_points.txt +0 -0
zwarm/cli/interactive.py
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
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=30)
|
|
157
|
+
table.add_column("Description")
|
|
158
|
+
|
|
159
|
+
table.add_row("[bold]Session Lifecycle[/]", "")
|
|
160
|
+
table.add_row('spawn "task" [--dir PATH]', "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]Meta[/]", "")
|
|
173
|
+
table.add_row("help", "Show this help")
|
|
174
|
+
table.add_row("quit", "Exit")
|
|
175
|
+
|
|
176
|
+
console.print(table)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_ls(manager):
|
|
180
|
+
"""List all sessions."""
|
|
181
|
+
from zwarm.sessions import SessionStatus
|
|
182
|
+
from zwarm.core.costs import estimate_session_cost, format_cost
|
|
183
|
+
|
|
184
|
+
sessions = manager.list_sessions()
|
|
185
|
+
|
|
186
|
+
if not sessions:
|
|
187
|
+
console.print(" [dim]No sessions. Use 'spawn \"task\"' to start one.[/]")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Summary counts
|
|
191
|
+
running = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
|
|
192
|
+
completed = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
|
|
193
|
+
failed = sum(1 for s in sessions if s.status == SessionStatus.FAILED)
|
|
194
|
+
killed = sum(1 for s in sessions if s.status == SessionStatus.KILLED)
|
|
195
|
+
|
|
196
|
+
# Total cost and tokens
|
|
197
|
+
total_cost = 0.0
|
|
198
|
+
total_tokens = 0
|
|
199
|
+
for s in sessions:
|
|
200
|
+
cost_info = estimate_session_cost(s.model, s.token_usage)
|
|
201
|
+
if cost_info["cost"] is not None:
|
|
202
|
+
total_cost += cost_info["cost"]
|
|
203
|
+
total_tokens += s.token_usage.get("total_tokens", 0)
|
|
204
|
+
|
|
205
|
+
parts = []
|
|
206
|
+
if running:
|
|
207
|
+
parts.append(f"[yellow]{running} running[/]")
|
|
208
|
+
if completed:
|
|
209
|
+
parts.append(f"[green]{completed} done[/]")
|
|
210
|
+
if failed:
|
|
211
|
+
parts.append(f"[red]{failed} failed[/]")
|
|
212
|
+
if killed:
|
|
213
|
+
parts.append(f"[dim]{killed} killed[/]")
|
|
214
|
+
parts.append(f"[cyan]{total_tokens:,} tokens[/]")
|
|
215
|
+
parts.append(f"[green]{format_cost(total_cost)}[/]")
|
|
216
|
+
if parts:
|
|
217
|
+
console.print(" | ".join(parts))
|
|
218
|
+
console.print()
|
|
219
|
+
|
|
220
|
+
# Table
|
|
221
|
+
table = Table(box=None, show_header=True, header_style="bold dim")
|
|
222
|
+
table.add_column("ID", style="cyan", width=10)
|
|
223
|
+
table.add_column("", width=2)
|
|
224
|
+
table.add_column("T", width=2)
|
|
225
|
+
table.add_column("Task", max_width=30)
|
|
226
|
+
table.add_column("Updated", justify="right", width=8)
|
|
227
|
+
table.add_column("Last Message", max_width=40)
|
|
228
|
+
|
|
229
|
+
for s in sessions:
|
|
230
|
+
icon = STATUS_ICONS.get(s.status.value, "?")
|
|
231
|
+
task_preview = s.task[:27] + "..." if len(s.task) > 30 else s.task
|
|
232
|
+
updated = time_ago(s.updated_at)
|
|
233
|
+
|
|
234
|
+
# Get last assistant message
|
|
235
|
+
messages = manager.get_messages(s.id)
|
|
236
|
+
last_msg = ""
|
|
237
|
+
for msg in reversed(messages):
|
|
238
|
+
if msg.role == "assistant":
|
|
239
|
+
last_msg = msg.content.replace("\n", " ")[:37]
|
|
240
|
+
if len(msg.content) > 37:
|
|
241
|
+
last_msg += "..."
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
# Style based on status
|
|
245
|
+
if s.status == SessionStatus.RUNNING:
|
|
246
|
+
last_msg_styled = f"[yellow]{last_msg or '(working...)'}[/]"
|
|
247
|
+
updated_styled = f"[yellow]{updated}[/]"
|
|
248
|
+
elif s.status == SessionStatus.COMPLETED:
|
|
249
|
+
try:
|
|
250
|
+
dt = datetime.fromisoformat(s.updated_at)
|
|
251
|
+
is_recent = (datetime.now() - dt).total_seconds() < 60
|
|
252
|
+
except Exception:
|
|
253
|
+
is_recent = False
|
|
254
|
+
if is_recent:
|
|
255
|
+
last_msg_styled = f"[green bold]{last_msg or '(done)'}[/]"
|
|
256
|
+
updated_styled = f"[green bold]{updated} ★[/]"
|
|
257
|
+
else:
|
|
258
|
+
last_msg_styled = f"[green]{last_msg or '(done)'}[/]"
|
|
259
|
+
updated_styled = f"[dim]{updated}[/]"
|
|
260
|
+
elif s.status == SessionStatus.FAILED:
|
|
261
|
+
err = s.error[:37] if s.error else "(failed)"
|
|
262
|
+
last_msg_styled = f"[red]{err}...[/]"
|
|
263
|
+
updated_styled = f"[red]{updated}[/]"
|
|
264
|
+
else:
|
|
265
|
+
last_msg_styled = f"[dim]{last_msg or '-'}[/]"
|
|
266
|
+
updated_styled = f"[dim]{updated}[/]"
|
|
267
|
+
|
|
268
|
+
table.add_row(s.short_id, icon, str(s.turn), task_preview, updated_styled, last_msg_styled)
|
|
269
|
+
|
|
270
|
+
console.print(table)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def cmd_peek(manager, session_id: str):
|
|
274
|
+
"""Quick peek at session status."""
|
|
275
|
+
session = manager.get_session(session_id)
|
|
276
|
+
if not session:
|
|
277
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
icon = STATUS_ICONS.get(session.status.value, "?")
|
|
281
|
+
console.print(f"\n{icon} [cyan]{session.short_id}[/] ({session.status.value})")
|
|
282
|
+
console.print(f" [dim]Task:[/] {session.task[:60]}...")
|
|
283
|
+
console.print(f" [dim]Turn:[/] {session.turn} | [dim]Updated:[/] {time_ago(session.updated_at)}")
|
|
284
|
+
|
|
285
|
+
# Latest message
|
|
286
|
+
messages = manager.get_messages(session.id)
|
|
287
|
+
for msg in reversed(messages):
|
|
288
|
+
if msg.role == "assistant":
|
|
289
|
+
preview = msg.content.replace("\n", " ")[:100]
|
|
290
|
+
if len(msg.content) > 100:
|
|
291
|
+
preview += "..."
|
|
292
|
+
console.print(f"\n [bold]Latest:[/] {preview}")
|
|
293
|
+
break
|
|
294
|
+
console.print()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_show(manager, session_id: str):
|
|
298
|
+
"""Full session details with messages."""
|
|
299
|
+
from zwarm.core.costs import estimate_session_cost
|
|
300
|
+
|
|
301
|
+
session = manager.get_session(session_id)
|
|
302
|
+
if not session:
|
|
303
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
# Header
|
|
307
|
+
icon = STATUS_ICONS.get(session.status.value, "?")
|
|
308
|
+
console.print(f"\n{icon} [bold cyan]{session.short_id}[/] - {session.status.value}")
|
|
309
|
+
console.print(f" [dim]Task:[/] {session.task}")
|
|
310
|
+
console.print(f" [dim]Turn:[/] {session.turn} | [dim]Runtime:[/] {session.runtime:.1f}s")
|
|
311
|
+
|
|
312
|
+
# Token usage with cost estimate
|
|
313
|
+
usage = session.token_usage
|
|
314
|
+
input_tok = usage.get("input_tokens", 0)
|
|
315
|
+
output_tok = usage.get("output_tokens", 0)
|
|
316
|
+
total_tok = usage.get("total_tokens", input_tok + output_tok)
|
|
317
|
+
|
|
318
|
+
cost_info = estimate_session_cost(session.model, usage)
|
|
319
|
+
cost_str = f"[green]{cost_info['cost_formatted']}[/]" if cost_info["pricing_known"] else "[dim]?[/]"
|
|
320
|
+
|
|
321
|
+
console.print(f" [dim]Tokens:[/] {total_tok:,} ({input_tok:,} in / {output_tok:,} out) | [dim]Cost:[/] {cost_str}")
|
|
322
|
+
|
|
323
|
+
if session.error:
|
|
324
|
+
console.print(f" [red]Error:[/] {session.error}")
|
|
325
|
+
|
|
326
|
+
# Messages
|
|
327
|
+
messages = manager.get_messages(session.id)
|
|
328
|
+
if messages:
|
|
329
|
+
console.print(f"\n[bold]Messages ({len(messages)}):[/]")
|
|
330
|
+
for msg in messages:
|
|
331
|
+
role = msg.role
|
|
332
|
+
content = msg.content[:200]
|
|
333
|
+
if len(msg.content) > 200:
|
|
334
|
+
content += "..."
|
|
335
|
+
|
|
336
|
+
if role == "user":
|
|
337
|
+
console.print(f" [blue]USER:[/] {content}")
|
|
338
|
+
elif role == "assistant":
|
|
339
|
+
console.print(f" [green]ASSISTANT:[/] {content}")
|
|
340
|
+
else:
|
|
341
|
+
console.print(f" [dim]{role.upper()}:[/] {content[:100]}")
|
|
342
|
+
|
|
343
|
+
console.print()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cmd_traj(manager, session_id: str, full: bool = False):
|
|
347
|
+
"""Show session trajectory."""
|
|
348
|
+
session = manager.get_session(session_id)
|
|
349
|
+
if not session:
|
|
350
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
trajectory = manager.get_trajectory(session_id, full=full)
|
|
354
|
+
|
|
355
|
+
console.print(f"\n[bold]Trajectory for {session.short_id}[/] ({len(trajectory)} steps)")
|
|
356
|
+
console.print(f" [dim]Task:[/] {session.task[:60]}...")
|
|
357
|
+
console.print()
|
|
358
|
+
|
|
359
|
+
for i, step in enumerate(trajectory):
|
|
360
|
+
step_type = step.get("type", "unknown")
|
|
361
|
+
|
|
362
|
+
if step_type == "reasoning":
|
|
363
|
+
text = step.get("full_text") if full else step.get("summary", "")
|
|
364
|
+
console.print(f" [dim]{i+1}.[/] [magenta]💭 thinking[/]")
|
|
365
|
+
if text:
|
|
366
|
+
console.print(f" {text[:150]}{'...' if len(text) > 150 else ''}")
|
|
367
|
+
|
|
368
|
+
elif step_type == "command":
|
|
369
|
+
cmd = step.get("command", "")
|
|
370
|
+
output = step.get("output", "")
|
|
371
|
+
exit_code = step.get("exit_code", 0)
|
|
372
|
+
console.print(f" [dim]{i+1}.[/] [yellow]$ {cmd}[/]")
|
|
373
|
+
if output and (full or len(output) < 100):
|
|
374
|
+
console.print(f" {output[:200]}")
|
|
375
|
+
if exit_code and exit_code != 0:
|
|
376
|
+
console.print(f" [red](exit: {exit_code})[/]")
|
|
377
|
+
|
|
378
|
+
elif step_type == "tool_call":
|
|
379
|
+
tool = step.get("tool", "unknown")
|
|
380
|
+
args_preview = step.get("args_preview", "")
|
|
381
|
+
console.print(f" [dim]{i+1}.[/] [cyan]🔧 {tool}[/]({args_preview})")
|
|
382
|
+
|
|
383
|
+
elif step_type == "tool_output":
|
|
384
|
+
output = step.get("output", "")
|
|
385
|
+
preview = output[:100] if not full else output[:300]
|
|
386
|
+
console.print(f" [dim]→ {preview}[/]")
|
|
387
|
+
|
|
388
|
+
elif step_type == "message":
|
|
389
|
+
text = step.get("full_text") if full else step.get("summary", "")
|
|
390
|
+
console.print(f" [dim]{i+1}.[/] [green]💬 response[/]")
|
|
391
|
+
if text:
|
|
392
|
+
console.print(f" {text[:150]}{'...' if len(text) > 150 else ''}")
|
|
393
|
+
|
|
394
|
+
console.print()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def cmd_watch(manager, session_id: str):
|
|
398
|
+
"""
|
|
399
|
+
Watch session output live.
|
|
400
|
+
|
|
401
|
+
Polls trajectory and displays new steps as they appear.
|
|
402
|
+
"""
|
|
403
|
+
from zwarm.sessions import SessionStatus
|
|
404
|
+
|
|
405
|
+
session = manager.get_session(session_id)
|
|
406
|
+
if not session:
|
|
407
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
console.print(f"\n[bold]Watching {session.short_id}[/]...")
|
|
411
|
+
console.print(f" [dim]Task:[/] {session.task[:60]}...")
|
|
412
|
+
console.print(f" [dim]Press Ctrl+C to stop watching[/]\n")
|
|
413
|
+
|
|
414
|
+
seen_steps = 0
|
|
415
|
+
last_status = None
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
while True:
|
|
419
|
+
# Refresh session
|
|
420
|
+
session = manager.get_session(session_id)
|
|
421
|
+
if not session:
|
|
422
|
+
console.print("[red]Session disappeared![/]")
|
|
423
|
+
break
|
|
424
|
+
|
|
425
|
+
# Status change
|
|
426
|
+
if session.status.value != last_status:
|
|
427
|
+
icon = STATUS_ICONS.get(session.status.value, "?")
|
|
428
|
+
console.print(f"\n{icon} Status: [bold]{session.status.value}[/]")
|
|
429
|
+
last_status = session.status.value
|
|
430
|
+
|
|
431
|
+
# Get trajectory
|
|
432
|
+
trajectory = manager.get_trajectory(session_id, full=False)
|
|
433
|
+
|
|
434
|
+
# Show new steps
|
|
435
|
+
for i, step in enumerate(trajectory[seen_steps:], start=seen_steps + 1):
|
|
436
|
+
step_type = step.get("type", "unknown")
|
|
437
|
+
|
|
438
|
+
if step_type == "reasoning":
|
|
439
|
+
text = step.get("summary", "")[:80]
|
|
440
|
+
console.print(f" [magenta]💭[/] {text}...")
|
|
441
|
+
|
|
442
|
+
elif step_type == "command":
|
|
443
|
+
cmd = step.get("command", "")
|
|
444
|
+
console.print(f" [yellow]$[/] {cmd}")
|
|
445
|
+
|
|
446
|
+
elif step_type == "tool_call":
|
|
447
|
+
tool = step.get("tool", "unknown")
|
|
448
|
+
console.print(f" [cyan]🔧[/] {tool}(...)")
|
|
449
|
+
|
|
450
|
+
elif step_type == "message":
|
|
451
|
+
text = step.get("summary", "")[:80]
|
|
452
|
+
console.print(f" [green]💬[/] {text}...")
|
|
453
|
+
|
|
454
|
+
seen_steps = len(trajectory)
|
|
455
|
+
|
|
456
|
+
# Check if done
|
|
457
|
+
if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
|
|
458
|
+
console.print(f"\n[dim]Session {session.status.value}. Final message:[/]")
|
|
459
|
+
messages = manager.get_messages(session.id)
|
|
460
|
+
for msg in reversed(messages):
|
|
461
|
+
if msg.role == "assistant":
|
|
462
|
+
console.print(f" {msg.content[:200]}...")
|
|
463
|
+
break
|
|
464
|
+
break
|
|
465
|
+
|
|
466
|
+
time.sleep(1.0)
|
|
467
|
+
|
|
468
|
+
except KeyboardInterrupt:
|
|
469
|
+
console.print("\n[dim]Stopped watching.[/]")
|
|
470
|
+
|
|
471
|
+
console.print()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def cmd_spawn(manager, task: str, working_dir: Path, model: str):
|
|
475
|
+
"""Spawn a new session."""
|
|
476
|
+
console.print(f"\n[dim]Spawning session...[/]")
|
|
477
|
+
console.print(f" [dim]Dir:[/] {working_dir}")
|
|
478
|
+
console.print(f" [dim]Model:[/] {model}")
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
session = manager.start_session(
|
|
482
|
+
task=task,
|
|
483
|
+
working_dir=working_dir,
|
|
484
|
+
model=model,
|
|
485
|
+
sandbox="workspace-write",
|
|
486
|
+
source="user",
|
|
487
|
+
adapter="codex",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
console.print(f"\n[green]✓[/] Session: [cyan]{session.short_id}[/]")
|
|
491
|
+
console.print(f" [dim]Use 'watch {session.short_id}' to follow progress[/]")
|
|
492
|
+
console.print(f" [dim]Use 'show {session.short_id}' when complete[/]")
|
|
493
|
+
|
|
494
|
+
except Exception as e:
|
|
495
|
+
console.print(f" [red]Error:[/] {e}")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def cmd_continue(manager, session_id: str, message: str):
|
|
499
|
+
"""Continue a conversation."""
|
|
500
|
+
from zwarm.sessions import SessionStatus
|
|
501
|
+
|
|
502
|
+
session = manager.get_session(session_id)
|
|
503
|
+
if not session:
|
|
504
|
+
console.print(f" [red]Session not found:[/] {session_id}")
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
if session.status == SessionStatus.RUNNING:
|
|
508
|
+
console.print(f" [yellow]Session still running - wait for it to complete[/]")
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
if session.status == SessionStatus.KILLED:
|
|
512
|
+
console.print(f" [red]Session was killed - start a new one[/]")
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
console.print(f"\n[dim]Injecting message into {session.short_id}...[/]")
|
|
516
|
+
|
|
517
|
+
updated = manager.inject_message(session_id, message)
|
|
518
|
+
if updated:
|
|
519
|
+
console.print(f"[green]✓[/] Message sent (turn {updated.turn})")
|
|
520
|
+
console.print(f" [dim]Use 'watch {session.short_id}' to follow response[/]")
|
|
521
|
+
else:
|
|
522
|
+
console.print(f" [red]Failed to inject message[/]")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def cmd_kill(manager, target: str):
|
|
526
|
+
"""
|
|
527
|
+
Kill session(s).
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
target: Session ID or "all" to kill all running
|
|
531
|
+
"""
|
|
532
|
+
from zwarm.sessions import SessionStatus
|
|
533
|
+
|
|
534
|
+
if target.lower() == "all":
|
|
535
|
+
# Kill all running
|
|
536
|
+
sessions = manager.list_sessions(status=SessionStatus.RUNNING)
|
|
537
|
+
if not sessions:
|
|
538
|
+
console.print(" [dim]No running sessions[/]")
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
killed = 0
|
|
542
|
+
for s in sessions:
|
|
543
|
+
if manager.kill_session(s.id):
|
|
544
|
+
killed += 1
|
|
545
|
+
console.print(f" [green]✓[/] Killed {s.short_id}")
|
|
546
|
+
|
|
547
|
+
console.print(f"\n[green]Killed {killed} session(s)[/]")
|
|
548
|
+
else:
|
|
549
|
+
# Kill single session
|
|
550
|
+
session = manager.get_session(target)
|
|
551
|
+
if not session:
|
|
552
|
+
console.print(f" [red]Session not found:[/] {target}")
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
if manager.kill_session(session.id):
|
|
556
|
+
console.print(f"[green]✓[/] Killed {session.short_id}")
|
|
557
|
+
else:
|
|
558
|
+
console.print(f" [yellow]Session not running or already stopped[/]")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def cmd_rm(manager, target: str):
|
|
562
|
+
"""
|
|
563
|
+
Delete session(s).
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
target: Session ID or "all" to delete all non-running
|
|
567
|
+
"""
|
|
568
|
+
from zwarm.sessions import SessionStatus
|
|
569
|
+
|
|
570
|
+
if target.lower() == "all":
|
|
571
|
+
# Delete all non-running (completed, failed, killed)
|
|
572
|
+
sessions = manager.list_sessions()
|
|
573
|
+
to_delete = [s for s in sessions if s.status != SessionStatus.RUNNING]
|
|
574
|
+
|
|
575
|
+
if not to_delete:
|
|
576
|
+
console.print(" [dim]Nothing to delete[/]")
|
|
577
|
+
return
|
|
578
|
+
|
|
579
|
+
deleted = 0
|
|
580
|
+
for s in to_delete:
|
|
581
|
+
if manager.delete_session(s.id):
|
|
582
|
+
deleted += 1
|
|
583
|
+
|
|
584
|
+
console.print(f"[green]✓[/] Deleted {deleted} session(s)")
|
|
585
|
+
else:
|
|
586
|
+
# Delete single session
|
|
587
|
+
session = manager.get_session(target)
|
|
588
|
+
if not session:
|
|
589
|
+
console.print(f" [red]Session not found:[/] {target}")
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
if manager.delete_session(session.id):
|
|
593
|
+
console.print(f"[green]✓[/] Deleted {session.short_id}")
|
|
594
|
+
else:
|
|
595
|
+
console.print(f" [red]Failed to delete[/]")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# =============================================================================
|
|
601
|
+
# Main REPL
|
|
602
|
+
# =============================================================================
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def run_interactive(
|
|
606
|
+
working_dir: Path,
|
|
607
|
+
model: str = "gpt-5.1-codex-mini",
|
|
608
|
+
):
|
|
609
|
+
"""
|
|
610
|
+
Run the interactive REPL.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
working_dir: Default working directory for sessions
|
|
614
|
+
model: Default model for sessions
|
|
615
|
+
"""
|
|
616
|
+
from zwarm.sessions import CodexSessionManager
|
|
617
|
+
|
|
618
|
+
manager = CodexSessionManager(working_dir / ".zwarm")
|
|
619
|
+
|
|
620
|
+
# Setup prompt with autocomplete
|
|
621
|
+
def get_sessions():
|
|
622
|
+
return manager.list_sessions()
|
|
623
|
+
|
|
624
|
+
completer = SessionCompleter(get_sessions)
|
|
625
|
+
style = Style.from_dict({
|
|
626
|
+
"prompt": "cyan bold",
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
session = PromptSession(
|
|
630
|
+
completer=completer,
|
|
631
|
+
history=InMemoryHistory(),
|
|
632
|
+
style=style,
|
|
633
|
+
complete_while_typing=True,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Welcome
|
|
637
|
+
console.print("\n[bold cyan]zwarm interactive[/] - Session Manager\n")
|
|
638
|
+
console.print(f" [dim]Dir:[/] {working_dir.absolute()}")
|
|
639
|
+
console.print(f" [dim]Model:[/] {model}")
|
|
640
|
+
console.print(f"\n Type [cyan]help[/] for commands, [cyan]quit[/] to exit.")
|
|
641
|
+
console.print(f" [dim]Tab to autocomplete session IDs[/]\n")
|
|
642
|
+
|
|
643
|
+
# REPL
|
|
644
|
+
while True:
|
|
645
|
+
try:
|
|
646
|
+
raw = session.prompt("> ").strip()
|
|
647
|
+
if not raw:
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
parts = shlex.split(raw)
|
|
652
|
+
except ValueError:
|
|
653
|
+
parts = raw.split()
|
|
654
|
+
|
|
655
|
+
cmd = parts[0].lower()
|
|
656
|
+
args = parts[1:]
|
|
657
|
+
|
|
658
|
+
# Dispatch
|
|
659
|
+
if cmd in ("q", "quit", "exit"):
|
|
660
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
661
|
+
break
|
|
662
|
+
|
|
663
|
+
elif cmd in ("h", "help"):
|
|
664
|
+
cmd_help()
|
|
665
|
+
|
|
666
|
+
elif cmd in ("ls", "list"):
|
|
667
|
+
cmd_ls(manager)
|
|
668
|
+
|
|
669
|
+
elif cmd in ("?", "peek"):
|
|
670
|
+
if not args:
|
|
671
|
+
console.print(" [red]Usage:[/] peek ID")
|
|
672
|
+
else:
|
|
673
|
+
cmd_peek(manager, args[0])
|
|
674
|
+
|
|
675
|
+
elif cmd == "show":
|
|
676
|
+
if not args:
|
|
677
|
+
console.print(" [red]Usage:[/] show ID")
|
|
678
|
+
else:
|
|
679
|
+
cmd_show(manager, args[0])
|
|
680
|
+
|
|
681
|
+
elif cmd in ("traj", "trajectory"):
|
|
682
|
+
if not args:
|
|
683
|
+
console.print(" [red]Usage:[/] traj ID [--full]")
|
|
684
|
+
else:
|
|
685
|
+
full = "--full" in args
|
|
686
|
+
sid = [a for a in args if not a.startswith("-")][0]
|
|
687
|
+
cmd_traj(manager, sid, full=full)
|
|
688
|
+
|
|
689
|
+
elif cmd == "watch":
|
|
690
|
+
if not args:
|
|
691
|
+
console.print(" [red]Usage:[/] watch ID")
|
|
692
|
+
else:
|
|
693
|
+
cmd_watch(manager, args[0])
|
|
694
|
+
|
|
695
|
+
elif cmd == "spawn":
|
|
696
|
+
if not args:
|
|
697
|
+
console.print(" [red]Usage:[/] spawn \"task\" [--dir PATH]")
|
|
698
|
+
else:
|
|
699
|
+
# Parse spawn args
|
|
700
|
+
task_parts = []
|
|
701
|
+
spawn_dir = working_dir
|
|
702
|
+
spawn_model = model
|
|
703
|
+
i = 0
|
|
704
|
+
while i < len(args):
|
|
705
|
+
if args[i] in ("--dir", "-d") and i + 1 < len(args):
|
|
706
|
+
spawn_dir = Path(args[i + 1])
|
|
707
|
+
i += 2
|
|
708
|
+
elif args[i] in ("--model", "-m") and i + 1 < len(args):
|
|
709
|
+
spawn_model = args[i + 1]
|
|
710
|
+
i += 2
|
|
711
|
+
else:
|
|
712
|
+
task_parts.append(args[i])
|
|
713
|
+
i += 1
|
|
714
|
+
|
|
715
|
+
task = " ".join(task_parts)
|
|
716
|
+
if task:
|
|
717
|
+
cmd_spawn(manager, task, spawn_dir, spawn_model)
|
|
718
|
+
else:
|
|
719
|
+
console.print(" [red]Task required[/]")
|
|
720
|
+
|
|
721
|
+
elif cmd in ("c", "continue"):
|
|
722
|
+
if len(args) < 2:
|
|
723
|
+
console.print(" [red]Usage:[/] c ID \"message\"")
|
|
724
|
+
else:
|
|
725
|
+
cmd_continue(manager, args[0], " ".join(args[1:]))
|
|
726
|
+
|
|
727
|
+
elif cmd == "kill":
|
|
728
|
+
if not args:
|
|
729
|
+
console.print(" [red]Usage:[/] kill ID | all")
|
|
730
|
+
else:
|
|
731
|
+
cmd_kill(manager, args[0])
|
|
732
|
+
|
|
733
|
+
elif cmd in ("rm", "delete"):
|
|
734
|
+
if not args:
|
|
735
|
+
console.print(" [red]Usage:[/] rm ID | all")
|
|
736
|
+
else:
|
|
737
|
+
cmd_rm(manager, args[0])
|
|
738
|
+
|
|
739
|
+
else:
|
|
740
|
+
console.print(f" [yellow]Unknown command:[/] {cmd}")
|
|
741
|
+
console.print(" [dim]Type 'help' for commands[/]")
|
|
742
|
+
|
|
743
|
+
except KeyboardInterrupt:
|
|
744
|
+
console.print("\n[dim](Ctrl+C again or 'quit' to exit)[/]")
|
|
745
|
+
except EOFError:
|
|
746
|
+
console.print("\n[dim]Goodbye![/]\n")
|
|
747
|
+
break
|
|
748
|
+
except Exception as e:
|
|
749
|
+
console.print(f" [red]Error:[/] {e}")
|