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