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.
@@ -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}")