zwarm 1.3.11__py3-none-any.whl → 2.0.1__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/tools/delegation.py CHANGED
@@ -1,20 +1,23 @@
1
1
  """
2
2
  Delegation tools for the orchestrator.
3
3
 
4
- These are the core tools that orchestrators use to delegate work to executors:
5
- - delegate: Start a new session with an executor
6
- - converse: Continue a sync conversation
7
- - check_session: Check status of an async session
8
- - end_session: End a session
9
-
10
- Sessions are also registered with CodexSessionManager for unified visibility
11
- in `zwarm interactive`.
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
12
16
  """
13
17
 
14
18
  from __future__ import annotations
15
19
 
16
- import asyncio
17
- from datetime import datetime
20
+ import time
18
21
  from pathlib import Path
19
22
  from typing import TYPE_CHECKING, Any, Literal
20
23
 
@@ -25,103 +28,47 @@ if TYPE_CHECKING:
25
28
 
26
29
 
27
30
  def _get_session_manager(orchestrator: "Orchestrator"):
28
- """Get or create the CodexSessionManager for unified session tracking."""
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
+ """
29
37
  if not hasattr(orchestrator, "_session_manager"):
30
38
  from zwarm.sessions import CodexSessionManager
31
39
  orchestrator._session_manager = CodexSessionManager(orchestrator.working_dir / ".zwarm")
32
40
  return orchestrator._session_manager
33
41
 
34
42
 
35
- def _register_session_for_visibility(
36
- orchestrator: "Orchestrator",
37
- session_id: str,
38
- task: str,
39
- adapter: str,
40
- model: str,
41
- working_dir: Path,
42
- status: str = "running",
43
- pid: int | None = None,
44
- ):
45
- """
46
- Register an orchestrator session with CodexSessionManager for visibility.
47
-
48
- This allows `zwarm interactive` to show orchestrator-delegated sessions
49
- in its unified dashboard.
43
+ def _wait_for_completion(manager, session_id: str, timeout: float = 300.0, poll_interval: float = 1.0) -> bool:
50
44
  """
51
- from zwarm.sessions import CodexSession, SessionStatus, SessionMessage
52
-
53
- manager = _get_session_manager(orchestrator)
54
- now = datetime.now().isoformat()
55
-
56
- # Map status
57
- status_map = {
58
- "running": SessionStatus.RUNNING,
59
- "active": SessionStatus.RUNNING,
60
- "completed": SessionStatus.COMPLETED,
61
- "failed": SessionStatus.FAILED,
62
- }
63
-
64
- session = CodexSession(
65
- id=session_id,
66
- task=task,
67
- status=status_map.get(status, SessionStatus.RUNNING),
68
- working_dir=working_dir,
69
- created_at=now,
70
- updated_at=now,
71
- model=model or "unknown",
72
- pid=pid,
73
- source=f"orchestrator:{orchestrator.instance_id or 'unknown'}",
74
- adapter=adapter,
75
- messages=[SessionMessage(role="user", content=task, timestamp=now)],
76
- )
77
-
78
- # Save to disk
79
- manager._save_session(session)
80
- return session
45
+ Wait for a session to complete.
81
46
 
47
+ Args:
48
+ manager: CodexSessionManager
49
+ session_id: Session to wait for
50
+ timeout: Max seconds to wait
51
+ poll_interval: Seconds between polls
82
52
 
83
- def _update_session_visibility(
84
- orchestrator: "Orchestrator",
85
- session_id: str,
86
- status: str | None = None,
87
- messages: list | None = None,
88
- token_usage: dict | None = None,
89
- error: str | None = None,
90
- ):
91
- """Update a session's visibility record."""
92
- manager = _get_session_manager(orchestrator)
93
- session = manager._load_session(session_id)
94
-
95
- if not session:
96
- return
97
-
98
- from zwarm.sessions import SessionStatus, SessionMessage
99
-
100
- if status:
101
- status_map = {
102
- "running": SessionStatus.RUNNING,
103
- "active": SessionStatus.RUNNING,
104
- "completed": SessionStatus.COMPLETED,
105
- "failed": SessionStatus.FAILED,
106
- }
107
- session.status = status_map.get(status, session.status)
53
+ Returns:
54
+ True if completed, False if timed out
55
+ """
56
+ from zwarm.sessions import SessionStatus
108
57
 
109
- if messages:
110
- for msg in messages:
111
- if hasattr(msg, "role") and hasattr(msg, "content"):
112
- session.messages.append(SessionMessage(
113
- role=msg.role,
114
- content=msg.content,
115
- timestamp=datetime.now().isoformat(),
116
- ))
58
+ start = time.time()
59
+ while time.time() - start < timeout:
60
+ # get_session() auto-updates status based on output completion markers
61
+ session = manager.get_session(session_id)
62
+ if not session:
63
+ return False
117
64
 
118
- if token_usage:
119
- session.token_usage = token_usage
65
+ # Check status (not is_running - PID check is unreliable due to reuse)
66
+ if session.status in (SessionStatus.COMPLETED, SessionStatus.FAILED, SessionStatus.KILLED):
67
+ return True
120
68
 
121
- if error:
122
- session.error = error
69
+ time.sleep(poll_interval)
123
70
 
124
- manager._save_session(session)
71
+ return False
125
72
 
126
73
 
127
74
  def _truncate(text: str, max_len: int = 200) -> str:
@@ -131,9 +78,9 @@ def _truncate(text: str, max_len: int = 200) -> str:
131
78
  return text[:max_len - 3] + "..."
132
79
 
133
80
 
134
- def _format_session_header(session_id: str, adapter: str, mode: str) -> str:
81
+ def _format_session_header(session) -> str:
135
82
  """Format a nice session header."""
136
- return f"[{session_id[:8]}] {adapter} ({mode})"
83
+ return f"[{session.short_id}] codex ({session.status.value})"
137
84
 
138
85
 
139
86
  def _validate_working_dir(
@@ -200,30 +147,27 @@ def delegate(
200
147
  self: "Orchestrator",
201
148
  task: str,
202
149
  mode: Literal["sync", "async"] = "sync",
203
- adapter: str | None = None,
204
150
  model: str | None = None,
205
151
  working_dir: str | None = None,
206
152
  ) -> dict[str, Any]:
207
153
  """
208
- Delegate work to an executor agent.
154
+ Delegate work to a Codex agent.
209
155
 
210
- Use this to assign coding tasks to an executor. Two modes available:
156
+ This spawns a codex session - the exact same way `zwarm interactive` does.
157
+ Two modes available:
211
158
 
212
- **sync** (default): Start a conversation with the executor.
213
- You can iteratively refine requirements using converse().
214
- Best for: ambiguous tasks, complex requirements, tasks needing guidance.
159
+ **sync** (default): Wait for codex to complete, then return the response.
160
+ Best for: most tasks - you get the full response immediately.
215
161
 
216
162
  **async**: Fire-and-forget execution.
217
163
  Check progress later with check_session().
218
- Best for: clear self-contained tasks, parallel work.
164
+ Best for: long-running tasks, parallel work.
219
165
 
220
166
  Args:
221
167
  task: Clear description of what to do. Be specific about requirements.
222
- mode: "sync" for conversational, "async" for fire-and-forget.
223
- adapter: Which executor adapter to use (default: config setting).
224
- model: Model override for the executor.
225
- working_dir: Directory for the executor to work in (default: orchestrator's dir).
226
- NOTE: May be restricted by orchestrator.allowed_dirs config.
168
+ mode: "sync" to wait for completion, "async" for fire-and-forget.
169
+ model: Model override (default: gpt-5.1-codex-mini).
170
+ working_dir: Directory for codex to work in (default: orchestrator's dir).
227
171
 
228
172
  Returns:
229
173
  {session_id, status, response (if sync)}
@@ -232,7 +176,7 @@ def delegate(
232
176
  delegate(task="Add a logout button to the navbar", mode="sync")
233
177
  # Then use converse() to refine: "Also add a confirmation dialog"
234
178
  """
235
- # Validate working directory against allowed_dirs config
179
+ # Validate working directory
236
180
  effective_dir, dir_error = _validate_working_dir(
237
181
  working_dir,
238
182
  self.working_dir,
@@ -246,92 +190,69 @@ def delegate(
246
190
  "hint": "Use the default working directory or ask user to update allowed_dirs config",
247
191
  }
248
192
 
249
- # Get adapter (use default from config if not specified)
250
- adapter_name = adapter or self.config.executor.adapter
251
- executor = self._get_adapter(adapter_name)
252
-
253
- # Run async start_session
254
- session = asyncio.run(
255
- executor.start_session(
256
- task=task,
257
- working_dir=effective_dir,
258
- mode=mode,
259
- model=model or self.config.executor.model,
260
- sandbox=self.config.executor.sandbox,
261
- )
262
- )
193
+ # Get the session manager (same one zwarm interactive uses)
194
+ manager = _get_session_manager(self)
263
195
 
264
- # Track session
265
- self._sessions[session.id] = session
266
- self.state.add_session(session)
196
+ # Determine model
197
+ effective_model = model or self.config.executor.model or "gpt-5.1-codex-mini"
267
198
 
268
- # Register for unified visibility in zwarm interactive
269
- _register_session_for_visibility(
270
- orchestrator=self,
271
- session_id=session.id,
199
+ # Determine sandbox mode
200
+ sandbox = self.config.executor.sandbox or "workspace-write"
201
+
202
+ # Start the session using CodexSessionManager
203
+ # This is the SAME method that `zwarm interactive` uses
204
+ session = manager.start_session(
272
205
  task=task,
273
- adapter=adapter_name,
274
- model=model or self.config.executor.model,
275
206
  working_dir=effective_dir,
276
- status="running" if mode == "async" else "active",
277
- pid=getattr(session, "process", None) and session.process.pid,
207
+ model=effective_model,
208
+ sandbox=sandbox,
209
+ source=f"orchestrator:{self.instance_id or 'default'}",
210
+ adapter="codex",
278
211
  )
279
212
 
280
- # Log events
281
- from zwarm.core.models import event_session_started, event_message_sent, Message
282
- self.state.log_event(event_session_started(session))
283
- self.state.log_event(event_message_sent(session, Message(role="user", content=task)))
284
-
285
- # Get response for sync mode
286
- response_text = ""
287
- if mode == "sync" and session.messages:
288
- response_text = session.messages[-1].content
289
- # Log the assistant response too
290
- self.state.log_event(event_message_sent(
291
- session,
292
- Message(role="assistant", content=response_text)
293
- ))
294
-
295
- # Log delegation result for debugging
296
- from zwarm.core.models import Event
297
- self.state.log_event(Event(
298
- kind="delegation_result",
299
- payload={
300
- "session_id": session.id,
301
- "mode": mode,
302
- "adapter": adapter_name,
303
- "response_length": len(response_text),
304
- "response_preview": response_text[:500] if response_text else "(empty)",
305
- "message_count": len(session.messages),
306
- },
307
- ))
213
+ # For sync mode, wait for completion
214
+ if mode == "sync":
215
+ completed = _wait_for_completion(
216
+ manager,
217
+ session.id,
218
+ timeout=self.config.executor.timeout or 300.0,
219
+ )
308
220
 
309
- # Build nice result
310
- header = _format_session_header(session.id, adapter_name, mode)
221
+ # Refresh session to get updated status and messages
222
+ session = manager.get_session(session.id)
223
+
224
+ if not completed:
225
+ return {
226
+ "success": False,
227
+ "session_id": session.id,
228
+ "status": "timeout",
229
+ "error": "Session timed out waiting for codex to complete",
230
+ "hint": "Use check_session() to monitor progress, or use async mode for long tasks",
231
+ }
232
+
233
+ # Get the response from messages
234
+ response_text = ""
235
+ messages = manager.get_messages(session.id)
236
+ for msg in messages:
237
+ if msg.role == "assistant":
238
+ response_text = msg.content
239
+ break # Take first assistant message
311
240
 
312
- if mode == "sync":
313
- result = {
241
+ return {
314
242
  "success": True,
315
- "session": header,
243
+ "session": _format_session_header(session),
316
244
  "session_id": session.id,
317
- "status": "active",
245
+ "status": session.status.value,
318
246
  "task": _truncate(task, 100),
319
- "response": response_text,
247
+ "response": response_text or "(no response captured)",
320
248
  "tokens": session.token_usage.get("total_tokens", 0),
321
- "hint": "Use converse(session_id, message) to continue this conversation",
249
+ "hint": "Use converse(session_id, message) to send follow-up messages",
322
250
  }
323
- # Warn if no conversation ID - converse() won't work
324
- if not session.conversation_id:
325
- result["warning"] = "no_conversation_id"
326
- result["hint"] = (
327
- "WARNING: MCP didn't return a conversation ID. "
328
- "You cannot use converse() - send all instructions upfront or use async mode."
329
- )
330
- return result
331
251
  else:
252
+ # Async mode - return immediately
332
253
  return {
333
254
  "success": True,
334
- "session": header,
255
+ "session": _format_session_header(session),
335
256
  "session_id": session.id,
336
257
  "status": "running",
337
258
  "task": _truncate(task, 100),
@@ -344,28 +265,41 @@ def converse(
344
265
  self: "Orchestrator",
345
266
  session_id: str,
346
267
  message: str,
268
+ wait: bool = True,
347
269
  ) -> dict[str, Any]:
348
270
  """
349
- Continue a sync conversation with an executor.
271
+ Continue a conversation with a codex session.
272
+
273
+ This injects a follow-up message into the session, providing the
274
+ conversation history as context. Like chatting with a developer.
350
275
 
351
- Use this to iteratively refine requirements, ask for changes,
352
- or guide the executor step-by-step. Like chatting with a developer.
276
+ Two modes:
277
+ - **wait=True** (default): Wait for codex to respond before returning.
278
+ - **wait=False**: Fire-and-forget. Message sent, codex runs in background.
279
+ Use check_session() later to see the response.
353
280
 
354
281
  Args:
355
282
  session_id: The session to continue (from delegate() result).
356
- message: Your next message to the executor.
283
+ message: Your next message to codex.
284
+ wait: If True, wait for response. If False, return immediately.
357
285
 
358
286
  Returns:
359
- {session_id, response, turn}
287
+ {session_id, response (if wait=True), turn}
360
288
 
361
- Example:
289
+ Example (sync):
362
290
  result = delegate(task="Add user authentication")
363
- # Executor responds with initial plan
364
- converse(session_id=result["session_id"], message="Use JWT, not sessions")
365
- # Executor adjusts approach
366
- converse(session_id=result["session_id"], message="Now add tests")
291
+ converse(session_id=result["session_id"], message="Use JWT")
292
+ # Returns with response
293
+
294
+ Example (async - managing multiple sessions):
295
+ converse(session_id="abc123", message="Add tests", wait=False)
296
+ converse(session_id="def456", message="Fix bug", wait=False)
297
+ # Both running in parallel, check later with check_session()
367
298
  """
368
- session = self._sessions.get(session_id)
299
+ manager = _get_session_manager(self)
300
+
301
+ # Get current session
302
+ session = manager.get_session(session_id)
369
303
  if not session:
370
304
  return {
371
305
  "success": False,
@@ -373,91 +307,84 @@ def converse(
373
307
  "hint": "Use list_sessions() to see available sessions",
374
308
  }
375
309
 
376
- if session.mode.value != "sync":
310
+ # Check if session is in a conversable state
311
+ from zwarm.sessions import SessionStatus
312
+ if session.status == SessionStatus.RUNNING:
377
313
  return {
378
314
  "success": False,
379
- "error": "Cannot converse with async session",
380
- "hint": "Use check_session() for async sessions instead",
315
+ "error": "Session is still running",
316
+ "hint": "Wait for the current task to complete, or use check_session() to monitor",
381
317
  }
382
318
 
383
- if session.status.value != "active":
319
+ if session.status == SessionStatus.KILLED:
384
320
  return {
385
321
  "success": False,
386
- "error": f"Session is {session.status.value}, not active",
322
+ "error": "Session was killed",
387
323
  "hint": "Start a new session with delegate()",
388
324
  }
389
325
 
390
- # Check for stale/missing conversation_id (common after resume)
391
- if not session.conversation_id:
326
+ # Inject the follow-up message
327
+ # This uses CodexSessionManager.inject_message() which:
328
+ # 1. Builds context from previous messages
329
+ # 2. Starts a new turn with the context + new message (background process)
330
+ updated_session = manager.inject_message(session_id, message)
331
+
332
+ if not updated_session:
392
333
  return {
393
334
  "success": False,
394
- "error": "Session has no conversation ID (likely stale after resume)",
395
- "hint": (
396
- "This session's conversation was lost (MCP server restarted). "
397
- "Use end_session() to close it, then delegate() a new task."
398
- ),
335
+ "error": "Failed to inject message",
399
336
  "session_id": session_id,
400
337
  }
401
338
 
402
- # Get adapter and send message
403
- executor = self._get_adapter(session.adapter)
404
- try:
405
- response = asyncio.run(
406
- executor.send_message(session, message)
407
- )
408
- except Exception as e:
339
+ if not wait:
340
+ # Async mode - return immediately
409
341
  return {
410
- "success": False,
411
- "error": str(e),
342
+ "success": True,
343
+ "session": _format_session_header(updated_session),
412
344
  "session_id": session_id,
345
+ "turn": updated_session.turn,
346
+ "status": "running",
347
+ "you_said": _truncate(message, 100),
348
+ "hint": "Use check_session(session_id) to see the response when ready",
413
349
  }
414
350
 
415
- # Update state
416
- self.state.update_session(session)
417
-
418
- # Update visibility record
419
- from zwarm.core.models import Message
420
- _update_session_visibility(
421
- orchestrator=self,
422
- session_id=session_id,
423
- messages=[Message(role="user", content=message), Message(role="assistant", content=response)],
424
- token_usage=session.token_usage,
351
+ # Sync mode - wait for completion
352
+ completed = _wait_for_completion(
353
+ manager,
354
+ session_id,
355
+ timeout=self.config.executor.timeout or 300.0,
425
356
  )
426
357
 
427
- # Log both messages
428
- from zwarm.core.models import event_message_sent
429
- self.state.log_event(event_message_sent(session, Message(role="user", content=message)))
430
- self.state.log_event(event_message_sent(session, Message(role="assistant", content=response)))
358
+ # Refresh session
359
+ session = manager.get_session(session_id)
431
360
 
432
- # Calculate turn number
433
- turn = len([m for m in session.messages if m.role == "user"])
434
- header = _format_session_header(session.id, session.adapter, session.mode.value)
361
+ if not completed:
362
+ return {
363
+ "success": False,
364
+ "session_id": session_id,
365
+ "status": "timeout",
366
+ "error": "Session timed out waiting for response",
367
+ "hint": "Use check_session() to monitor progress",
368
+ }
435
369
 
436
- # Check for conversation loss (indicated by error in response)
437
- conversation_lost = (
438
- "[ERROR] Conversation lost" in response
439
- or session.conversation_id is None
440
- )
370
+ # Get the response (last assistant message)
371
+ response_text = ""
372
+ messages = manager.get_messages(session_id)
373
+ for msg in reversed(messages):
374
+ if msg.role == "assistant":
375
+ response_text = msg.content
376
+ break
441
377
 
442
- result = {
378
+ return {
443
379
  "success": True,
444
- "session": header,
380
+ "session": _format_session_header(session),
445
381
  "session_id": session_id,
446
- "turn": turn,
382
+ "turn": session.turn,
447
383
  "you_said": _truncate(message, 100),
448
- "response": response,
384
+ "response": response_text or "(no response captured)",
449
385
  "tokens": session.token_usage.get("total_tokens", 0),
450
386
  }
451
387
 
452
- if conversation_lost:
453
- result["warning"] = "conversation_lost"
454
- result["hint"] = (
455
- "The MCP server lost this conversation. You should end_session() "
456
- "and delegate() a new task with the full context."
457
- )
458
-
459
- return result
460
-
461
388
 
462
389
  @weaveTool
463
390
  def check_session(
@@ -467,16 +394,20 @@ def check_session(
467
394
  """
468
395
  Check the status of a session.
469
396
 
470
- For async sessions: Check if the executor has finished.
471
- For sync sessions: Get current status and message count.
397
+ Use this to:
398
+ - Check if an async session has finished
399
+ - Get current status and message count
400
+ - View the latest response
472
401
 
473
402
  Args:
474
403
  session_id: The session to check.
475
404
 
476
405
  Returns:
477
- {session_id, status, ...}
406
+ {session_id, status, messages, response}
478
407
  """
479
- session = self._sessions.get(session_id)
408
+ manager = _get_session_manager(self)
409
+
410
+ session = manager.get_session(session_id)
480
411
  if not session:
481
412
  return {
482
413
  "success": False,
@@ -484,33 +415,65 @@ def check_session(
484
415
  "hint": "Use list_sessions() to see available sessions",
485
416
  }
486
417
 
487
- executor = self._get_adapter(session.adapter)
488
- status = asyncio.run(
489
- executor.check_status(session)
490
- )
418
+ # Get latest response
419
+ response_text = ""
420
+ messages = manager.get_messages(session_id)
421
+ for msg in reversed(messages):
422
+ if msg.role == "assistant":
423
+ response_text = msg.content
424
+ break
425
+
426
+ return {
427
+ "success": True,
428
+ "session": _format_session_header(session),
429
+ "session_id": session_id,
430
+ "status": session.status.value,
431
+ "is_running": session.is_running,
432
+ "turn": session.turn,
433
+ "message_count": len(messages),
434
+ "task": _truncate(session.task, 80),
435
+ "response": _truncate(response_text, 500) if response_text else "(no response yet)",
436
+ "tokens": session.token_usage.get("total_tokens", 0),
437
+ "runtime": session.runtime,
438
+ }
491
439
 
492
- # Update state if status changed
493
- self.state.update_session(session)
494
440
 
495
- # Sync visibility record
496
- _update_session_visibility(
497
- orchestrator=self,
498
- session_id=session_id,
499
- status=session.status.value,
500
- token_usage=session.token_usage,
501
- )
441
+ @weaveTool
442
+ def peek_session(
443
+ self: "Orchestrator",
444
+ session_id: str,
445
+ ) -> dict[str, Any]:
446
+ """
447
+ Quick peek at a session - minimal info for fast polling.
448
+
449
+ Returns just status and latest message. Use check_session() for full details.
450
+
451
+ Args:
452
+ session_id: The session to peek at.
453
+
454
+ Returns:
455
+ {session_id, status, latest_message}
456
+ """
457
+ manager = _get_session_manager(self)
458
+
459
+ session = manager.get_session(session_id)
460
+ if not session:
461
+ return {"success": False, "error": f"Unknown session: {session_id}"}
502
462
 
503
- header = _format_session_header(session.id, session.adapter, session.mode.value)
463
+ # Get latest assistant message only
464
+ latest = ""
465
+ messages = manager.get_messages(session_id)
466
+ for msg in reversed(messages):
467
+ if msg.role == "assistant":
468
+ latest = msg.content.replace("\n", " ")
469
+ break
504
470
 
505
471
  return {
506
472
  "success": True,
507
- "session": header,
508
- "session_id": session_id,
509
- "mode": session.mode.value,
473
+ "session_id": session.short_id,
510
474
  "status": session.status.value,
511
- "messages": len(session.messages),
512
- "task": _truncate(session.task_description, 80),
513
- **status,
475
+ "is_running": session.status.value == "running",
476
+ "latest_message": _truncate(latest, 150) if latest else None,
514
477
  }
515
478
 
516
479
 
@@ -518,72 +481,65 @@ def check_session(
518
481
  def end_session(
519
482
  self: "Orchestrator",
520
483
  session_id: str,
521
- verdict: Literal["completed", "failed", "cancelled"] = "completed",
522
- summary: str | None = None,
484
+ reason: str | None = None,
485
+ delete: bool = False,
523
486
  ) -> dict[str, Any]:
524
487
  """
525
- End a session with a verdict.
488
+ End/kill a session.
526
489
 
527
490
  Call this when:
528
- - Task is done (verdict="completed")
529
- - Task failed and you're giving up (verdict="failed")
530
- - You want to stop early (verdict="cancelled")
491
+ - You want to stop a running session
492
+ - Clean up a completed session
493
+ - Cancel a task
531
494
 
532
495
  Args:
533
496
  session_id: The session to end.
534
- verdict: How the session ended.
535
- summary: Optional summary of what was accomplished.
497
+ reason: Optional reason for ending.
498
+ delete: If True, delete session entirely (removes from list_sessions).
536
499
 
537
500
  Returns:
538
- {session_id, status, summary}
501
+ {session_id, status}
539
502
  """
540
- session = self._sessions.get(session_id)
503
+ manager = _get_session_manager(self)
504
+
505
+ session = manager.get_session(session_id)
541
506
  if not session:
542
507
  return {
543
508
  "success": False,
544
509
  "error": f"Unknown session: {session_id}",
545
510
  }
546
511
 
547
- # Stop the session if still running
548
- if session.status.value == "active":
549
- executor = self._get_adapter(session.adapter)
550
- if verdict == "completed":
551
- session.complete(summary)
552
- else:
553
- asyncio.run(executor.stop(session))
554
- if verdict == "failed":
555
- session.fail(summary)
556
- else:
557
- session.fail(f"Cancelled: {summary}" if summary else "Cancelled")
558
-
559
- # Update state
560
- self.state.update_session(session)
561
-
562
- # Update visibility record
563
- _update_session_visibility(
564
- orchestrator=self,
565
- session_id=session_id,
566
- status=verdict,
567
- token_usage=session.token_usage,
568
- error=summary if verdict == "failed" else None,
569
- )
512
+ # If delete requested, remove entirely
513
+ if delete:
514
+ deleted = manager.delete_session(session_id)
515
+ return {
516
+ "success": deleted,
517
+ "session_id": session_id,
518
+ "status": "deleted",
519
+ "reason": reason or "deleted by orchestrator",
520
+ }
570
521
 
571
- # Log event
572
- from zwarm.core.models import event_session_completed
573
- self.state.log_event(event_session_completed(session))
522
+ # Kill if still running
523
+ if session.is_running:
524
+ killed = manager.kill_session(session_id)
525
+ if not killed:
526
+ return {
527
+ "success": False,
528
+ "error": "Failed to kill session",
529
+ "session_id": session_id,
530
+ }
574
531
 
575
- header = _format_session_header(session.id, session.adapter, session.mode.value)
576
- verdict_icon = {"completed": "✓", "failed": "✗", "cancelled": "○"}.get(verdict, "?")
532
+ # Refresh
533
+ session = manager.get_session(session_id)
577
534
 
578
535
  return {
579
536
  "success": True,
580
- "session": header,
537
+ "session": _format_session_header(session),
581
538
  "session_id": session_id,
582
- "verdict": f"{verdict_icon} {verdict}",
583
- "summary": session.exit_message or "(no summary)",
584
- "total_turns": len([m for m in session.messages if m.role == "user"]),
585
- "total_tokens": session.token_usage.get("total_tokens", 0),
586
- "token_usage": session.token_usage,
539
+ "status": session.status.value,
540
+ "reason": reason or "ended by orchestrator",
541
+ "turn": session.turn,
542
+ "tokens": session.token_usage.get("total_tokens", 0),
587
543
  }
588
544
 
589
545
 
@@ -595,36 +551,115 @@ def list_sessions(
595
551
  """
596
552
  List all sessions, optionally filtered by status.
597
553
 
554
+ Returns rich information about each session including:
555
+ - Status (running/completed/failed)
556
+ - Last update time (for detecting changes)
557
+ - Last message preview (quick peek at response)
558
+ - Whether it's recently updated (needs_attention flag)
559
+
560
+ Use this to monitor multiple parallel sessions and see which
561
+ ones have new responses.
562
+
598
563
  Args:
599
- status: Filter by status ("active", "completed", "failed").
564
+ status: Filter by status ("running", "completed", "failed", "killed").
600
565
 
601
566
  Returns:
602
- {sessions: [...], count}
567
+ {sessions: [...], count, running, completed, needs_attention}
603
568
  """
604
- sessions = self.state.list_sessions(status=status)
569
+ from datetime import datetime
570
+
571
+ manager = _get_session_manager(self)
572
+
573
+ # Map string status to enum
574
+ from zwarm.sessions import SessionStatus
575
+ status_filter = None
576
+ if status:
577
+ status_map = {
578
+ "running": SessionStatus.RUNNING,
579
+ "completed": SessionStatus.COMPLETED,
580
+ "failed": SessionStatus.FAILED,
581
+ "killed": SessionStatus.KILLED,
582
+ "pending": SessionStatus.PENDING,
583
+ }
584
+ status_filter = status_map.get(status.lower())
585
+
586
+ sessions = manager.list_sessions(status=status_filter)
587
+
588
+ def time_ago(iso_str: str) -> tuple[str, float]:
589
+ """Convert ISO timestamp to ('Xm ago', seconds)."""
590
+ try:
591
+ dt = datetime.fromisoformat(iso_str)
592
+ delta = datetime.now() - dt
593
+ secs = delta.total_seconds()
594
+ if secs < 60:
595
+ return f"{int(secs)}s ago", secs
596
+ elif secs < 3600:
597
+ return f"{int(secs/60)}m ago", secs
598
+ elif secs < 86400:
599
+ return f"{secs/3600:.1f}h ago", secs
600
+ else:
601
+ return f"{secs/86400:.1f}d ago", secs
602
+ except:
603
+ return "?", 999999
605
604
 
606
605
  session_list = []
606
+ needs_attention_count = 0
607
+
607
608
  for s in sessions:
608
609
  status_icon = {
609
- "active": "●",
610
+ "running": "●",
610
611
  "completed": "✓",
611
612
  "failed": "✗",
613
+ "killed": "○",
614
+ "pending": "◌",
612
615
  }.get(s.status.value, "?")
613
616
 
617
+ updated_str, updated_secs = time_ago(s.updated_at)
618
+
619
+ # Get last assistant message
620
+ messages = manager.get_messages(s.id)
621
+ last_message = ""
622
+ for msg in reversed(messages):
623
+ if msg.role == "assistant":
624
+ last_message = msg.content.replace("\n", " ")
625
+ break
626
+
627
+ # Flag sessions that need attention:
628
+ # - Recently completed (< 60s)
629
+ # - Failed
630
+ is_recent = updated_secs < 60
631
+ needs_attention = (
632
+ (s.status == SessionStatus.COMPLETED and is_recent) or
633
+ s.status == SessionStatus.FAILED
634
+ )
635
+ if needs_attention:
636
+ needs_attention_count += 1
637
+
614
638
  session_list.append({
615
- "id": s.id[:8] + "...",
639
+ "id": s.short_id,
616
640
  "full_id": s.id,
617
641
  "status": f"{status_icon} {s.status.value}",
618
- "adapter": s.adapter,
619
- "mode": s.mode.value,
620
- "task": _truncate(s.task_description, 60),
621
- "turns": len([m for m in s.messages if m.role == "user"]),
642
+ "is_running": s.status == SessionStatus.RUNNING,
643
+ "task": _truncate(s.task, 50),
644
+ "turn": s.turn,
645
+ "updated": updated_str,
646
+ "updated_secs": int(updated_secs),
647
+ "last_message": _truncate(last_message, 100) if last_message else "(no response yet)",
648
+ "needs_attention": needs_attention,
622
649
  "tokens": s.token_usage.get("total_tokens", 0),
623
650
  })
624
651
 
652
+ # Summary counts
653
+ running_count = sum(1 for s in sessions if s.status == SessionStatus.RUNNING)
654
+ completed_count = sum(1 for s in sessions if s.status == SessionStatus.COMPLETED)
655
+
625
656
  return {
626
657
  "success": True,
627
658
  "sessions": session_list,
628
659
  "count": len(sessions),
660
+ "running": running_count,
661
+ "completed": completed_count,
662
+ "needs_attention": needs_attention_count,
629
663
  "filter": status or "all",
664
+ "hint": "Sessions with needs_attention=True have new responses to review" if needs_attention_count else None,
630
665
  }