zwarm 2.3.5__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,784 @@
1
+ """
2
+ Delegation tools for the orchestrator.
3
+
4
+ These are the core tools that orchestrators use to delegate work to executors.
5
+ They use the SAME CodexSessionManager that `zwarm interactive` uses - no special
6
+ MCP integration, no separate code path.
7
+
8
+ The orchestrator LLM has access to the exact same tools a human would use.
9
+
10
+ Tools:
11
+ - delegate: Start a new codex session
12
+ - converse: Continue a conversation (inject follow-up message)
13
+ - check_session: Check status of a session
14
+ - end_session: End/kill a session
15
+ - list_sessions: List all sessions
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any, Literal
23
+
24
+ from wbal.helper import weaveTool
25
+
26
+ if TYPE_CHECKING:
27
+ from zwarm.orchestrator import Orchestrator
28
+
29
+
30
+ def _get_session_manager(orchestrator: "Orchestrator"):
31
+ """
32
+ Get the CodexSessionManager - the SINGLE source of truth for sessions.
33
+
34
+ Both `zwarm interactive` and `zwarm orchestrate` use the same session manager.
35
+ The orchestrator is just another user that happens to be an LLM.
36
+
37
+ The session manager is created eagerly in Orchestrator.model_post_init()
38
+ and shared with the environment for observe() visibility.
39
+ """
40
+ # Should already exist from model_post_init, but create if not
41
+ if not hasattr(orchestrator, "_session_manager") or orchestrator._session_manager is None:
42
+ from zwarm.sessions import CodexSessionManager
43
+ orchestrator._session_manager = CodexSessionManager(orchestrator.working_dir / ".zwarm")
44
+ return orchestrator._session_manager
45
+
46
+
47
+ def _wait_for_completion(manager, session_id: str, timeout: float = 300.0, poll_interval: float = 1.0) -> bool:
48
+ """
49
+ Wait for a session to complete.
50
+
51
+ Args:
52
+ manager: CodexSessionManager
53
+ session_id: Session to wait for
54
+ timeout: Max seconds to wait
55
+ poll_interval: Seconds between polls
56
+
57
+ Returns:
58
+ True if completed, False if timed out
59
+ """
60
+ from zwarm.sessions import SessionStatus
61
+
62
+ start = time.time()
63
+ while time.time() - start < timeout:
64
+ # get_session() auto-updates status based on output completion markers
65
+ session = manager.get_session(session_id)
66
+ if not session:
67
+ return False
68
+
69
+ # Check status (not is_running - PID check is unreliable due to reuse)
70
+ if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
71
+ return True
72
+
73
+ time.sleep(poll_interval)
74
+
75
+ return False
76
+
77
+
78
+ def _truncate(text: str, max_len: int = 200) -> str:
79
+ """Truncate text with ellipsis."""
80
+ if len(text) <= max_len:
81
+ return text
82
+ return text[:max_len - 3] + "..."
83
+
84
+
85
+ def _format_session_header(session) -> str:
86
+ """Format a nice session header."""
87
+ return f"[{session.short_id}] codex ({session.status.value})"
88
+
89
+
90
+ def _get_total_tokens(session) -> int:
91
+ """Get total tokens, computing from input+output if not present."""
92
+ usage = session.token_usage
93
+ if "total_tokens" in usage:
94
+ return usage["total_tokens"]
95
+ return usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
96
+
97
+
98
+ def _validate_working_dir(
99
+ requested_dir: Path | str | None,
100
+ default_dir: Path,
101
+ allowed_dirs: list[str] | None,
102
+ ) -> tuple[Path, str | None]:
103
+ """
104
+ Validate requested working directory against allowed_dirs config.
105
+
106
+ Args:
107
+ requested_dir: Directory requested by the agent (or None for default)
108
+ default_dir: The orchestrator's working directory
109
+ allowed_dirs: Config setting - None means only default allowed,
110
+ ["*"] means any, or list of allowed paths
111
+
112
+ Returns:
113
+ (validated_path, error_message) - error is None if valid
114
+ """
115
+ if requested_dir is None:
116
+ return default_dir, None
117
+
118
+ requested = Path(requested_dir).resolve()
119
+
120
+ # Check if directory exists
121
+ if not requested.exists():
122
+ return default_dir, f"Directory does not exist: {requested}"
123
+
124
+ if not requested.is_dir():
125
+ return default_dir, f"Not a directory: {requested}"
126
+
127
+ # If allowed_dirs is None, only default is allowed
128
+ if allowed_dirs is None:
129
+ if requested == default_dir.resolve():
130
+ return requested, None
131
+ return default_dir, (
132
+ f"Directory not allowed: {requested}. "
133
+ f"Agent can only delegate to working directory ({default_dir}). "
134
+ "Set orchestrator.allowed_dirs in config to allow other directories."
135
+ )
136
+
137
+ # If ["*"], any directory is allowed
138
+ if allowed_dirs == ["*"]:
139
+ return requested, None
140
+
141
+ # Check against allowed list
142
+ for allowed in allowed_dirs:
143
+ allowed_path = Path(allowed).resolve()
144
+ # Allow if requested is the allowed path or a subdirectory of it
145
+ try:
146
+ requested.relative_to(allowed_path)
147
+ return requested, None
148
+ except ValueError:
149
+ continue
150
+
151
+ return default_dir, (
152
+ f"Directory not allowed: {requested}. "
153
+ f"Allowed directories: {allowed_dirs}"
154
+ )
155
+
156
+
157
+ @weaveTool
158
+ def delegate(
159
+ self: "Orchestrator",
160
+ task: str,
161
+ mode: Literal["sync", "async"] = "sync",
162
+ model: str | None = None,
163
+ working_dir: str | None = None,
164
+ ) -> dict[str, Any]:
165
+ """
166
+ Delegate work to a Codex agent.
167
+
168
+ This spawns a codex session - the exact same way `zwarm interactive` does.
169
+ Two modes available:
170
+
171
+ **sync** (default): Wait for codex to complete, then return the response.
172
+ Best for: most tasks - you get the full response immediately.
173
+
174
+ **async**: Fire-and-forget execution.
175
+ Check progress later with check_session().
176
+ Best for: long-running tasks, parallel work.
177
+
178
+ Args:
179
+ task: Clear description of what to do. Be specific about requirements.
180
+ mode: "sync" to wait for completion, "async" for fire-and-forget.
181
+ model: Model override (default: gpt-5.1-codex-mini).
182
+ working_dir: Directory for codex to work in (default: orchestrator's dir).
183
+
184
+ Returns:
185
+ {session_id, status, response (if sync)}
186
+
187
+ Example:
188
+ delegate(task="Add a logout button to the navbar", mode="sync")
189
+ # Then use converse() to refine: "Also add a confirmation dialog"
190
+ """
191
+ # Validate working directory
192
+ effective_dir, dir_error = _validate_working_dir(
193
+ working_dir,
194
+ self.working_dir,
195
+ self.config.orchestrator.allowed_dirs,
196
+ )
197
+
198
+ if dir_error:
199
+ return {
200
+ "success": False,
201
+ "error": dir_error,
202
+ "hint": "Use the default working directory or ask user to update allowed_dirs config",
203
+ }
204
+
205
+ # Get the session manager (same one zwarm interactive uses)
206
+ manager = _get_session_manager(self)
207
+
208
+ # Determine model
209
+ effective_model = model or self.config.executor.model or "gpt-5.1-codex-mini"
210
+
211
+ # Determine sandbox mode
212
+ sandbox = self.config.executor.sandbox or "workspace-write"
213
+
214
+ # Start the session using CodexSessionManager
215
+ # This is the SAME method that `zwarm interactive` uses
216
+ session = manager.start_session(
217
+ task=task,
218
+ working_dir=effective_dir,
219
+ model=effective_model,
220
+ sandbox=sandbox,
221
+ source=f"orchestrator:{self.instance_id or 'default'}",
222
+ adapter="codex",
223
+ )
224
+
225
+ # For sync mode, wait for completion
226
+ if mode == "sync":
227
+ completed = _wait_for_completion(
228
+ manager,
229
+ session.id,
230
+ timeout=self.config.executor.timeout or 300.0,
231
+ )
232
+
233
+ # Refresh session to get updated status and messages
234
+ session = manager.get_session(session.id)
235
+
236
+ if not completed:
237
+ return {
238
+ "success": False,
239
+ "session_id": session.id,
240
+ "status": "timeout",
241
+ "error": "Session timed out waiting for codex to complete",
242
+ "hint": "Use check_session() to monitor progress, or use async mode for long tasks",
243
+ }
244
+
245
+ # Get the response from messages
246
+ response_text = ""
247
+ messages = manager.get_messages(session.id)
248
+ for msg in messages:
249
+ if msg.role == "assistant":
250
+ response_text = msg.content
251
+ break # Take first assistant message
252
+
253
+ # Build log path for debugging
254
+ log_path = str(manager._output_path(session.id, session.turn))
255
+
256
+ # Check if session failed
257
+ from zwarm.sessions import SessionStatus
258
+ if session.status == SessionStatus.FAILED:
259
+ return {
260
+ "success": False,
261
+ "session": _format_session_header(session),
262
+ "session_id": session.id,
263
+ "status": "failed",
264
+ "task": _truncate(task, 100),
265
+ "error": session.error or "Unknown error",
266
+ "response": response_text or "(no response captured)",
267
+ "tokens": _get_total_tokens(session),
268
+ "log_file": log_path,
269
+ "hint": "Check log_file for raw codex output. Use bash('cat <log_file>') to inspect.",
270
+ }
271
+
272
+ return {
273
+ "success": True,
274
+ "session": _format_session_header(session),
275
+ "session_id": session.id,
276
+ "status": session.status.value,
277
+ "task": _truncate(task, 100),
278
+ "response": response_text or "(no response captured)",
279
+ "tokens": _get_total_tokens(session),
280
+ "log_file": log_path,
281
+ "hint": "Use converse(session_id, message) to send follow-up messages",
282
+ }
283
+ else:
284
+ # Async mode - return immediately
285
+ return {
286
+ "success": True,
287
+ "session": _format_session_header(session),
288
+ "session_id": session.id,
289
+ "status": "running",
290
+ "task": _truncate(task, 100),
291
+ "hint": "Use check_session(session_id) to monitor progress",
292
+ }
293
+
294
+
295
+ @weaveTool
296
+ def converse(
297
+ self: "Orchestrator",
298
+ session_id: str,
299
+ message: str,
300
+ wait: bool = True,
301
+ ) -> dict[str, Any]:
302
+ """
303
+ Continue a conversation with a codex session.
304
+
305
+ This injects a follow-up message into the session, providing the
306
+ conversation history as context. Like chatting with a developer.
307
+
308
+ Two modes:
309
+ - **wait=True** (default): Wait for codex to respond before returning.
310
+ - **wait=False**: Fire-and-forget. Message sent, codex runs in background.
311
+ Use check_session() later to see the response.
312
+
313
+ Args:
314
+ session_id: The session to continue (from delegate() result).
315
+ message: Your next message to codex.
316
+ wait: If True, wait for response. If False, return immediately.
317
+
318
+ Returns:
319
+ {session_id, response (if wait=True), turn}
320
+
321
+ Example (sync):
322
+ result = delegate(task="Add user authentication")
323
+ converse(session_id=result["session_id"], message="Use JWT")
324
+ # Returns with response
325
+
326
+ Example (async - managing multiple sessions):
327
+ converse(session_id="abc123", message="Add tests", wait=False)
328
+ converse(session_id="def456", message="Fix bug", wait=False)
329
+ # Both running in parallel, check later with check_session()
330
+ """
331
+ manager = _get_session_manager(self)
332
+
333
+ # Get current session
334
+ session = manager.get_session(session_id)
335
+ if not session:
336
+ return {
337
+ "success": False,
338
+ "error": f"Unknown session: {session_id}",
339
+ "hint": "Use list_sessions() to see available sessions",
340
+ }
341
+
342
+ # Check if session is in a conversable state
343
+ from zwarm.sessions import SessionStatus
344
+ if session.status == SessionStatus.RUNNING:
345
+ return {
346
+ "success": False,
347
+ "error": "Session is still running",
348
+ "hint": "Wait for the current task to complete, or use check_session() to monitor",
349
+ }
350
+
351
+ if session.status == SessionStatus.KILLED:
352
+ return {
353
+ "success": False,
354
+ "error": "Session was killed",
355
+ "hint": "Start a new session with delegate()",
356
+ }
357
+
358
+ # Inject the follow-up message
359
+ # This uses CodexSessionManager.inject_message() which:
360
+ # 1. Builds context from previous messages
361
+ # 2. Starts a new turn with the context + new message (background process)
362
+ updated_session = manager.inject_message(session_id, message)
363
+
364
+ if not updated_session:
365
+ return {
366
+ "success": False,
367
+ "error": "Failed to inject message",
368
+ "session_id": session_id,
369
+ }
370
+
371
+ if not wait:
372
+ # Async mode - return immediately
373
+ return {
374
+ "success": True,
375
+ "session": _format_session_header(updated_session),
376
+ "session_id": session_id,
377
+ "turn": updated_session.turn,
378
+ "status": "running",
379
+ "you_said": _truncate(message, 100),
380
+ "hint": "Use check_session(session_id) to see the response when ready",
381
+ }
382
+
383
+ # Sync mode - wait for completion
384
+ completed = _wait_for_completion(
385
+ manager,
386
+ session_id,
387
+ timeout=self.config.executor.timeout or 300.0,
388
+ )
389
+
390
+ # Refresh session
391
+ session = manager.get_session(session_id)
392
+
393
+ if not completed:
394
+ return {
395
+ "success": False,
396
+ "session_id": session_id,
397
+ "status": "timeout",
398
+ "error": "Session timed out waiting for response",
399
+ "hint": "Use check_session() to monitor progress",
400
+ }
401
+
402
+ # Get the response (last assistant message)
403
+ response_text = ""
404
+ messages = manager.get_messages(session_id)
405
+ for msg in reversed(messages):
406
+ if msg.role == "assistant":
407
+ response_text = msg.content
408
+ break
409
+
410
+ return {
411
+ "success": True,
412
+ "session": _format_session_header(session),
413
+ "session_id": session_id,
414
+ "turn": session.turn,
415
+ "you_said": _truncate(message, 100),
416
+ "response": response_text or "(no response captured)",
417
+ "tokens": _get_total_tokens(session),
418
+ }
419
+
420
+
421
+ @weaveTool
422
+ def check_session(
423
+ self: "Orchestrator",
424
+ session_id: str,
425
+ ) -> dict[str, Any]:
426
+ """
427
+ Check the status of a session.
428
+
429
+ Use this to:
430
+ - Check if an async session has finished
431
+ - Get current status and message count
432
+ - View the latest response
433
+
434
+ Args:
435
+ session_id: The session to check.
436
+
437
+ Returns:
438
+ {session_id, status, messages, response}
439
+ """
440
+ manager = _get_session_manager(self)
441
+
442
+ session = manager.get_session(session_id)
443
+ if not session:
444
+ return {
445
+ "success": False,
446
+ "error": f"Unknown session: {session_id}",
447
+ "hint": "Use list_sessions() to see available sessions",
448
+ }
449
+
450
+ # Get latest response
451
+ response_text = ""
452
+ messages = manager.get_messages(session_id)
453
+ for msg in reversed(messages):
454
+ if msg.role == "assistant":
455
+ response_text = msg.content
456
+ break
457
+
458
+ # Build log path
459
+ log_path = str(manager._output_path(session.id, session.turn))
460
+
461
+ result = {
462
+ "success": True,
463
+ "session": _format_session_header(session),
464
+ "session_id": session_id,
465
+ "status": session.status.value,
466
+ "is_running": session.is_running,
467
+ "turn": session.turn,
468
+ "message_count": len(messages),
469
+ "task": _truncate(session.task, 80),
470
+ "response": _truncate(response_text, 500) if response_text else "(no response yet)",
471
+ "tokens": _get_total_tokens(session),
472
+ "runtime": session.runtime,
473
+ "log_file": log_path,
474
+ }
475
+
476
+ # Add error info if failed
477
+ from zwarm.sessions import SessionStatus
478
+ if session.status == SessionStatus.FAILED:
479
+ result["success"] = False
480
+ result["error"] = session.error or "Unknown error"
481
+
482
+ return result
483
+
484
+
485
+ @weaveTool
486
+ def peek_session(
487
+ self: "Orchestrator",
488
+ session_id: str,
489
+ ) -> dict[str, Any]:
490
+ """
491
+ Quick peek at a session - minimal info for fast polling.
492
+
493
+ Returns just status and latest message. Use check_session() for full details.
494
+
495
+ Args:
496
+ session_id: The session to peek at.
497
+
498
+ Returns:
499
+ {session_id, status, latest_message}
500
+ """
501
+ manager = _get_session_manager(self)
502
+
503
+ session = manager.get_session(session_id)
504
+ if not session:
505
+ return {"success": False, "error": f"Unknown session: {session_id}"}
506
+
507
+ # Get latest assistant message only
508
+ latest = ""
509
+ messages = manager.get_messages(session_id)
510
+ for msg in reversed(messages):
511
+ if msg.role == "assistant":
512
+ latest = msg.content.replace("\n", " ")
513
+ break
514
+
515
+ return {
516
+ "success": True,
517
+ "session_id": session.short_id,
518
+ "status": session.status.value,
519
+ "is_running": session.status.value == "running",
520
+ "latest_message": _truncate(latest, 150) if latest else None,
521
+ }
522
+
523
+
524
+ @weaveTool
525
+ def get_trajectory(
526
+ self: "Orchestrator",
527
+ session_id: str,
528
+ full: bool = False,
529
+ ) -> dict[str, Any]:
530
+ """
531
+ Get the full trajectory of a session - all steps the agent took.
532
+
533
+ Shows reasoning, commands, tool calls, and responses in order.
534
+ Useful for understanding HOW the agent completed a task, not just
535
+ the final result.
536
+
537
+ Args:
538
+ session_id: The session to get trajectory for.
539
+ full: If True, include full untruncated content (default: False for summary view).
540
+
541
+ Returns:
542
+ {steps: [...], step_count}
543
+ """
544
+ manager = _get_session_manager(self)
545
+
546
+ session = manager.get_session(session_id)
547
+ if not session:
548
+ return {"success": False, "error": f"Unknown session: {session_id}"}
549
+
550
+ trajectory = manager.get_trajectory(session_id, full=full)
551
+
552
+ # Format steps for easy reading
553
+ formatted_steps = []
554
+ for step in trajectory:
555
+ step_type = step.get("type", "unknown")
556
+
557
+ if step_type == "reasoning":
558
+ text = step.get("full_text") if full else step.get("summary", "")
559
+ formatted_steps.append(f"[thinking] {text}")
560
+ elif step_type == "command":
561
+ cmd = step.get("command", "")
562
+ output = step.get("output", "")
563
+ exit_code = step.get("exit_code")
564
+ step_str = f"[command] $ {cmd}"
565
+ if output:
566
+ if full:
567
+ step_str += f"\n → {output}"
568
+ else:
569
+ step_str += f"\n → {output[:100]}{'...' if len(output) > 100 else ''}"
570
+ if exit_code and exit_code != 0:
571
+ step_str += f" (exit: {exit_code})"
572
+ formatted_steps.append(step_str)
573
+ elif step_type == "tool_call":
574
+ if full and step.get("full_args"):
575
+ import json
576
+ args_str = json.dumps(step["full_args"], indent=2)
577
+ formatted_steps.append(f"[tool] {step.get('tool', 'unknown')}\n {args_str}")
578
+ else:
579
+ formatted_steps.append(f"[tool] {step.get('tool', 'unknown')}({step.get('args_preview', '')})")
580
+ elif step_type == "tool_output":
581
+ output = step.get("output", "")
582
+ if not full:
583
+ output = output[:100]
584
+ formatted_steps.append(f"[result] {output}")
585
+ elif step_type == "message":
586
+ text = step.get("full_text") if full else step.get("summary", "")
587
+ formatted_steps.append(f"[response] {text}")
588
+
589
+ return {
590
+ "success": True,
591
+ "session_id": session.short_id,
592
+ "task": _truncate(session.task, 80),
593
+ "step_count": len(trajectory),
594
+ "steps": formatted_steps,
595
+ "mode": "full" if full else "summary",
596
+ }
597
+
598
+
599
+ @weaveTool
600
+ def end_session(
601
+ self: "Orchestrator",
602
+ session_id: str,
603
+ reason: str | None = None,
604
+ delete: bool = False,
605
+ ) -> dict[str, Any]:
606
+ """
607
+ End/kill a session.
608
+
609
+ Call this when:
610
+ - You want to stop a running session
611
+ - Clean up a completed session
612
+ - Cancel a task
613
+
614
+ Args:
615
+ session_id: The session to end.
616
+ reason: Optional reason for ending.
617
+ delete: If True, delete session entirely (removes from list_sessions).
618
+
619
+ Returns:
620
+ {session_id, status}
621
+ """
622
+ manager = _get_session_manager(self)
623
+
624
+ session = manager.get_session(session_id)
625
+ if not session:
626
+ return {
627
+ "success": False,
628
+ "error": f"Unknown session: {session_id}",
629
+ }
630
+
631
+ # If delete requested, remove entirely
632
+ if delete:
633
+ deleted = manager.delete_session(session_id)
634
+ return {
635
+ "success": deleted,
636
+ "session_id": session_id,
637
+ "status": "deleted",
638
+ "reason": reason or "deleted by orchestrator",
639
+ }
640
+
641
+ # Kill if still running
642
+ if session.is_running:
643
+ killed = manager.kill_session(session_id)
644
+ if not killed:
645
+ return {
646
+ "success": False,
647
+ "error": "Failed to kill session",
648
+ "session_id": session_id,
649
+ }
650
+
651
+ # Refresh
652
+ session = manager.get_session(session_id)
653
+
654
+ return {
655
+ "success": True,
656
+ "session": _format_session_header(session),
657
+ "session_id": session_id,
658
+ "status": session.status.value,
659
+ "reason": reason or "ended by orchestrator",
660
+ "turn": session.turn,
661
+ "tokens": _get_total_tokens(session),
662
+ }
663
+
664
+
665
+ @weaveTool
666
+ def list_sessions(
667
+ self: "Orchestrator",
668
+ status: str | None = None,
669
+ ) -> dict[str, Any]:
670
+ """
671
+ List all sessions, optionally filtered by status.
672
+
673
+ Returns rich information about each session including:
674
+ - Status (running/completed/failed)
675
+ - Last update time (for detecting changes)
676
+ - Last message preview (quick peek at response)
677
+ - Whether it's recently updated (needs_attention flag)
678
+
679
+ Use this to monitor multiple parallel sessions and see which
680
+ ones have new responses.
681
+
682
+ Args:
683
+ status: Filter by status ("running", "completed", "failed", "killed").
684
+
685
+ Returns:
686
+ {sessions: [...], count, running, completed, needs_attention}
687
+ """
688
+ from datetime import datetime
689
+
690
+ manager = _get_session_manager(self)
691
+
692
+ # Map string status to enum
693
+ from zwarm.sessions import SessionStatus
694
+ status_filter = None
695
+ if status:
696
+ status_map = {
697
+ "running": SessionStatus.RUNNING,
698
+ "completed": SessionStatus.COMPLETED,
699
+ "failed": SessionStatus.FAILED,
700
+ "killed": SessionStatus.KILLED,
701
+ "pending": SessionStatus.PENDING,
702
+ }
703
+ status_filter = status_map.get(status.lower())
704
+
705
+ sessions = manager.list_sessions(status=status_filter)
706
+
707
+ def time_ago(iso_str: str) -> tuple[str, float]:
708
+ """Convert ISO timestamp to ('Xm ago', seconds)."""
709
+ try:
710
+ dt = datetime.fromisoformat(iso_str)
711
+ delta = datetime.now() - dt
712
+ secs = delta.total_seconds()
713
+ if secs < 60:
714
+ return f"{int(secs)}s ago", secs
715
+ elif secs < 3600:
716
+ return f"{int(secs/60)}m ago", secs
717
+ elif secs < 86400:
718
+ return f"{secs/3600:.1f}h ago", secs
719
+ else:
720
+ return f"{secs/86400:.1f}d ago", secs
721
+ except:
722
+ return "?", 999999
723
+
724
+ session_list = []
725
+ needs_attention_count = 0
726
+
727
+ for s in sessions:
728
+ status_icon = {
729
+ "running": "●",
730
+ "completed": "✓",
731
+ "failed": "✗",
732
+ "killed": "○",
733
+ "pending": "◌",
734
+ }.get(s.status.value, "?")
735
+
736
+ updated_str, updated_secs = time_ago(s.updated_at)
737
+
738
+ # Get last assistant message
739
+ messages = manager.get_messages(s.id)
740
+ last_message = ""
741
+ for msg in reversed(messages):
742
+ if msg.role == "assistant":
743
+ last_message = msg.content.replace("\n", " ")
744
+ break
745
+
746
+ # Flag sessions that need attention:
747
+ # - Recently completed (< 60s)
748
+ # - Failed
749
+ is_recent = updated_secs < 60
750
+ needs_attention = (
751
+ (s.status == SessionStatus.COMPLETED and is_recent) or
752
+ s.status == SessionStatus.FAILED
753
+ )
754
+ if needs_attention:
755
+ needs_attention_count += 1
756
+
757
+ session_list.append({
758
+ "id": s.short_id,
759
+ "full_id": s.id,
760
+ "status": f"{status_icon} {s.status.value}",
761
+ "is_running": s.status == SessionStatus.RUNNING,
762
+ "task": _truncate(s.task, 50),
763
+ "turn": s.turn,
764
+ "updated": updated_str,
765
+ "updated_secs": int(updated_secs),
766
+ "last_message": _truncate(last_message, 100) if last_message else "(no response yet)",
767
+ "needs_attention": needs_attention,
768
+ "tokens": _get_total_tokens(s),
769
+ })
770
+
771
+ # Summary counts
772
+ running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
773
+ completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
774
+
775
+ return {
776
+ "success": True,
777
+ "sessions": session_list,
778
+ "count": len(sessions),
779
+ "running": running_count,
780
+ "completed": completed_count,
781
+ "needs_attention": needs_attention_count,
782
+ "filter": status or "all",
783
+ "hint": "Sessions with needs_attention=True have new responses to review" if needs_attention_count else None,
784
+ }