emdash-core 0.1.25__py3-none-any.whl → 0.1.37__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.
Files changed (39) hide show
  1. emdash_core/agent/__init__.py +4 -0
  2. emdash_core/agent/agents.py +84 -23
  3. emdash_core/agent/events.py +42 -20
  4. emdash_core/agent/hooks.py +419 -0
  5. emdash_core/agent/inprocess_subagent.py +166 -18
  6. emdash_core/agent/prompts/__init__.py +4 -3
  7. emdash_core/agent/prompts/main_agent.py +67 -2
  8. emdash_core/agent/prompts/plan_mode.py +236 -107
  9. emdash_core/agent/prompts/subagents.py +103 -23
  10. emdash_core/agent/prompts/workflow.py +159 -26
  11. emdash_core/agent/providers/factory.py +2 -2
  12. emdash_core/agent/providers/openai_provider.py +67 -15
  13. emdash_core/agent/runner/__init__.py +49 -0
  14. emdash_core/agent/runner/agent_runner.py +765 -0
  15. emdash_core/agent/runner/context.py +470 -0
  16. emdash_core/agent/runner/factory.py +108 -0
  17. emdash_core/agent/runner/plan.py +217 -0
  18. emdash_core/agent/runner/sdk_runner.py +324 -0
  19. emdash_core/agent/runner/utils.py +67 -0
  20. emdash_core/agent/skills.py +47 -8
  21. emdash_core/agent/toolkit.py +46 -14
  22. emdash_core/agent/toolkits/__init__.py +117 -18
  23. emdash_core/agent/toolkits/base.py +87 -2
  24. emdash_core/agent/toolkits/explore.py +18 -0
  25. emdash_core/agent/toolkits/plan.py +27 -11
  26. emdash_core/agent/tools/__init__.py +2 -2
  27. emdash_core/agent/tools/coding.py +48 -4
  28. emdash_core/agent/tools/modes.py +151 -143
  29. emdash_core/agent/tools/task.py +52 -6
  30. emdash_core/api/agent.py +706 -1
  31. emdash_core/ingestion/repository.py +17 -198
  32. emdash_core/models/agent.py +4 -0
  33. emdash_core/skills/frontend-design/SKILL.md +56 -0
  34. emdash_core/sse/stream.py +4 -0
  35. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/METADATA +4 -1
  36. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/RECORD +38 -30
  37. emdash_core/agent/runner.py +0 -1123
  38. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/WHEEL +0 -0
  39. {emdash_core-0.1.25.dist-info → emdash_core-0.1.37.dist-info}/entry_points.txt +0 -0
emdash_core/api/agent.py CHANGED
@@ -31,6 +31,61 @@ def _ensure_emdash_importable():
31
31
  pass # emdash_core is already in the package
32
32
 
33
33
 
34
+ def _run_sdk_agent(
35
+ message: str,
36
+ model: str,
37
+ sse_handler: SSEHandler,
38
+ session_id: str,
39
+ emitter,
40
+ plan_mode: bool = False,
41
+ ):
42
+ """Run the agent using Anthropic Agent SDK.
43
+
44
+ This function is called when use_sdk=True and model is a Claude model.
45
+ It uses the SDKAgentRunner which provides native support for
46
+ Skills, MCP, and extended thinking.
47
+ """
48
+ import asyncio
49
+ from ..agent.runner import SDKAgentRunner
50
+
51
+ # Get working directory from config
52
+ config = get_config()
53
+ cwd = str(config.repo_root) if config.repo_root else str(Path.cwd())
54
+
55
+ # Create SDK runner
56
+ runner = SDKAgentRunner(
57
+ model=model,
58
+ cwd=cwd,
59
+ emitter=emitter,
60
+ plan_mode=plan_mode,
61
+ )
62
+
63
+ # Store session state
64
+ _sessions[session_id] = {
65
+ "runner": runner,
66
+ "message_count": 1,
67
+ "model": model,
68
+ "plan_mode": plan_mode,
69
+ "is_sdk": True,
70
+ }
71
+
72
+ # Run async agent in sync context
73
+ async def run_async():
74
+ response_text = ""
75
+ async for event in runner.run(message):
76
+ if event.get("type") == "text":
77
+ response_text += event.get("content", "")
78
+ return response_text
79
+
80
+ # Run the async code
81
+ try:
82
+ response = asyncio.run(run_async())
83
+ return response
84
+ except Exception as e:
85
+ sse_handler.emit(EventType.ERROR, {"error": str(e)})
86
+ raise
87
+
88
+
34
89
  def _run_agent_sync(
35
90
  message: str,
36
91
  model: str,
@@ -38,19 +93,42 @@ def _run_agent_sync(
38
93
  sse_handler: SSEHandler,
39
94
  session_id: str,
40
95
  images: list = None,
96
+ plan_mode: bool = False,
97
+ use_sdk: bool = None,
98
+ history: list = None,
41
99
  ):
42
100
  """Run the agent synchronously (in thread pool).
43
101
 
44
102
  This function runs in a background thread and emits events
45
103
  to the SSE handler for streaming to the client.
104
+
105
+ For Claude models with use_sdk=True, uses the Anthropic Agent SDK.
106
+ For other models, uses the standard AgentRunner with OpenAI-compatible API.
107
+
108
+ Args:
109
+ history: Optional list of previous messages to pre-populate conversation
46
110
  """
47
111
  try:
48
112
  _ensure_emdash_importable()
49
113
 
50
114
  # Import agent components from emdash_core
51
- from ..agent.runner import AgentRunner
115
+ from ..agent.runner import AgentRunner, is_claude_model
116
+ from ..agent.toolkit import AgentToolkit
52
117
  from ..agent.events import AgentEventEmitter
53
118
 
119
+ # Determine if we should use SDK
120
+ # Auto-detect based on model if not explicitly set
121
+ if use_sdk is None:
122
+ import os
123
+ # Check env var for SDK preference
124
+ sdk_enabled = os.environ.get("EMDASH_USE_SDK", "auto").lower()
125
+ if sdk_enabled == "true":
126
+ use_sdk = True
127
+ elif sdk_enabled == "false":
128
+ use_sdk = False
129
+ else: # "auto"
130
+ use_sdk = is_claude_model(model)
131
+
54
132
  # Create an emitter that forwards to SSE handler
55
133
  class SSEBridgeHandler:
56
134
  """Bridges AgentEventEmitter to SSEHandler."""
@@ -66,20 +144,92 @@ def _run_agent_sync(
66
144
  emitter = AgentEventEmitter(agent_name="Emdash Code")
67
145
  emitter.add_handler(SSEBridgeHandler(sse_handler))
68
146
 
147
+ # Add hook handler for user-defined hooks
148
+ from ..agent.hooks import HookHandler, get_hook_manager
149
+ hook_manager = get_hook_manager()
150
+ hook_manager.set_session_id(session_id)
151
+ emitter.add_handler(HookHandler(hook_manager))
152
+
153
+ # Use SDK for Claude models if enabled
154
+ if use_sdk and is_claude_model(model):
155
+ return _run_sdk_agent(
156
+ message=message,
157
+ model=model,
158
+ sse_handler=sse_handler,
159
+ session_id=session_id,
160
+ emitter=emitter,
161
+ plan_mode=plan_mode,
162
+ )
163
+
164
+ # Standard path: use AgentRunner with OpenAI-compatible API
165
+ # Get repo_root from config (set by server on startup)
166
+ from pathlib import Path
167
+ from ..config import get_config
168
+ from ..utils.logger import log
169
+ config = get_config()
170
+ repo_root = Path(config.repo_root) if config.repo_root else Path.cwd()
171
+ log.info(f"Agent API: config.repo_root={config.repo_root}, resolved repo_root={repo_root}")
172
+
173
+ # Create toolkit with plan_mode if requested
174
+ # When in plan mode, generate a plan file path so write_to_file is available
175
+ plan_file_path = None
176
+ if plan_mode:
177
+ plan_file_path = str(repo_root / ".emdash" / "plan.md")
178
+ # Ensure .emdash directory exists
179
+ (repo_root / ".emdash").mkdir(exist_ok=True)
180
+
181
+ toolkit = AgentToolkit(repo_root=repo_root, plan_mode=plan_mode, plan_file_path=plan_file_path)
182
+
69
183
  runner = AgentRunner(
184
+ toolkit=toolkit,
70
185
  model=model,
71
186
  verbose=True,
72
187
  max_iterations=max_iterations,
73
188
  emitter=emitter,
74
189
  )
75
190
 
191
+ # Inject pre-loaded conversation history if provided
192
+ if history:
193
+ runner._messages = list(history)
194
+ log.info(f"Injected {len(history)} messages from saved session")
195
+
76
196
  # Store session state BEFORE running (so it exists even if interrupted)
77
197
  _sessions[session_id] = {
78
198
  "runner": runner,
79
199
  "message_count": 1,
80
200
  "model": model,
201
+ "plan_mode": plan_mode,
81
202
  }
82
203
 
204
+ # Set up autosave callback if enabled via env var
205
+ import os
206
+ import json
207
+ if os.environ.get("EMDASH_SESSION_AUTOSAVE", "").lower() == "true":
208
+ sessions_dir = repo_root / ".emdash" / "sessions"
209
+ sessions_dir.mkdir(parents=True, exist_ok=True)
210
+ autosave_path = sessions_dir / "_autosave.json"
211
+
212
+ def autosave_callback(messages):
213
+ """Save messages to autosave file after each iteration."""
214
+ try:
215
+ # Limit to last 10 messages
216
+ trimmed = messages[-10:] if len(messages) > 10 else messages
217
+ autosave_data = {
218
+ "name": "_autosave",
219
+ "messages": trimmed,
220
+ "model": model,
221
+ "mode": "plan" if plan_mode else "code",
222
+ "session_id": session_id,
223
+ }
224
+ with open(autosave_path, "w") as f:
225
+ json.dump(autosave_data, f, indent=2, default=str)
226
+ log.debug(f"Autosaved {len(trimmed)} messages to {autosave_path}")
227
+ except Exception as e:
228
+ log.debug(f"Autosave failed: {e}")
229
+
230
+ runner._on_iteration_callback = autosave_callback
231
+ log.info("Session autosave enabled")
232
+
83
233
  # Convert image data if provided
84
234
  agent_images = None
85
235
  if images:
@@ -114,6 +264,7 @@ async def _run_agent_async(
114
264
  # Get model from request or config
115
265
  model = request.model or config.default_model
116
266
  max_iterations = request.options.max_iterations
267
+ plan_mode = request.options.mode == AgentMode.PLAN
117
268
 
118
269
  # Emit session start
119
270
  sse_handler.emit(EventType.SESSION_START, {
@@ -121,6 +272,7 @@ async def _run_agent_async(
121
272
  "model": model,
122
273
  "session_id": session_id,
123
274
  "query": request.message,
275
+ "mode": request.options.mode.value,
124
276
  })
125
277
 
126
278
  loop = asyncio.get_event_loop()
@@ -136,6 +288,9 @@ async def _run_agent_async(
136
288
  sse_handler,
137
289
  session_id,
138
290
  request.images,
291
+ plan_mode,
292
+ None, # use_sdk (auto-detect)
293
+ request.history, # Pre-loaded conversation history
139
294
  )
140
295
 
141
296
  # Emit session end
@@ -306,3 +461,553 @@ async def delete_session(session_id: str):
306
461
  del _sessions[session_id]
307
462
  return {"deleted": True}
308
463
  raise HTTPException(status_code=404, detail="Session not found")
464
+
465
+
466
+ @router.get("/chat/{session_id}/export")
467
+ async def export_session(session_id: str, limit: int = 10):
468
+ """Export session messages for persistence.
469
+
470
+ Args:
471
+ session_id: The session ID
472
+ limit: Maximum number of messages to return (default 10)
473
+
474
+ Returns:
475
+ JSON with messages array and metadata
476
+ """
477
+ if session_id not in _sessions:
478
+ raise HTTPException(status_code=404, detail="Session not found")
479
+
480
+ session = _sessions[session_id]
481
+ runner = session.get("runner")
482
+
483
+ if not runner:
484
+ return {
485
+ "session_id": session_id,
486
+ "messages": [],
487
+ "message_count": 0,
488
+ "model": session.get("model"),
489
+ "mode": "plan" if session.get("plan_mode") else "code",
490
+ }
491
+
492
+ # Get messages from runner
493
+ messages = getattr(runner, "_messages", [])
494
+
495
+ # Trim to limit (most recent)
496
+ if len(messages) > limit:
497
+ messages = messages[-limit:]
498
+
499
+ return {
500
+ "session_id": session_id,
501
+ "messages": messages,
502
+ "message_count": len(messages),
503
+ "model": session.get("model"),
504
+ "mode": "plan" if session.get("plan_mode") else "code",
505
+ }
506
+
507
+
508
+ @router.get("/chat/{session_id}/plan")
509
+ async def get_pending_plan(session_id: str):
510
+ """Get the pending plan for a session, if any.
511
+
512
+ Returns 404 if session not found, 204 if no pending plan.
513
+ """
514
+ if session_id not in _sessions:
515
+ raise HTTPException(status_code=404, detail="Session not found")
516
+
517
+ session = _sessions[session_id]
518
+ runner = session.get("runner")
519
+
520
+ if not runner:
521
+ raise HTTPException(status_code=400, detail="Session has no active runner")
522
+
523
+ pending_plan = runner.get_pending_plan()
524
+ if not pending_plan:
525
+ return {"has_plan": False, "plan": None}
526
+
527
+ return {
528
+ "has_plan": True,
529
+ "session_id": session_id,
530
+ "plan": pending_plan,
531
+ }
532
+
533
+
534
+ @router.post("/chat/{session_id}/plan/approve")
535
+ async def approve_plan(session_id: str):
536
+ """Approve the pending plan and transition to code mode.
537
+
538
+ Returns SSE stream for the implementation phase.
539
+ """
540
+ if session_id not in _sessions:
541
+ raise HTTPException(status_code=404, detail="Session not found")
542
+
543
+ session = _sessions[session_id]
544
+ runner = session.get("runner")
545
+
546
+ if not runner:
547
+ raise HTTPException(status_code=400, detail="Session has no active runner")
548
+
549
+ if not runner.has_pending_plan():
550
+ raise HTTPException(status_code=400, detail="No pending plan to approve")
551
+
552
+ # Create SSE handler for streaming the implementation
553
+ sse_handler = SSEHandler(agent_name="Emdash Code")
554
+
555
+ async def _run_approval():
556
+ loop = asyncio.get_event_loop()
557
+
558
+ try:
559
+ # Wire up SSE handler
560
+ from ..agent.events import AgentEventEmitter
561
+
562
+ class SSEBridgeHandler:
563
+ def __init__(self, sse_handler: SSEHandler):
564
+ self._sse = sse_handler
565
+
566
+ def handle(self, event):
567
+ self._sse.handle(event)
568
+
569
+ emitter = AgentEventEmitter(agent_name="Emdash Code")
570
+ emitter.add_handler(SSEBridgeHandler(sse_handler))
571
+ runner.emitter = emitter
572
+
573
+ sse_handler.emit(EventType.SESSION_START, {
574
+ "agent_name": "Emdash Code",
575
+ "model": session.get("model", "unknown"),
576
+ "session_id": session_id,
577
+ "query": "Plan approved - implementing...",
578
+ "plan_approved": True,
579
+ })
580
+
581
+ # Reset cycle state for new mode
582
+ from ..agent.tools.modes import ModeState
583
+ ModeState.get_instance().reset_cycle()
584
+
585
+ # Approve and run implementation
586
+ await loop.run_in_executor(
587
+ _executor,
588
+ runner.approve_plan,
589
+ )
590
+
591
+ session["plan_mode"] = False # Now in code mode
592
+
593
+ sse_handler.emit(EventType.SESSION_END, {
594
+ "success": True,
595
+ "session_id": session_id,
596
+ })
597
+
598
+ except Exception as e:
599
+ sse_handler.emit(EventType.ERROR, {"message": str(e)})
600
+ sse_handler.emit(EventType.SESSION_END, {
601
+ "success": False,
602
+ "error": str(e),
603
+ "session_id": session_id,
604
+ })
605
+
606
+ finally:
607
+ sse_handler.close()
608
+
609
+ asyncio.create_task(_run_approval())
610
+
611
+ return StreamingResponse(
612
+ sse_handler,
613
+ media_type="text/event-stream",
614
+ headers={
615
+ "Cache-Control": "no-cache",
616
+ "Connection": "keep-alive",
617
+ "X-Session-ID": session_id,
618
+ },
619
+ )
620
+
621
+
622
+ @router.post("/chat/{session_id}/plan/reject")
623
+ async def reject_plan(session_id: str, feedback: str = ""):
624
+ """Reject the pending plan with feedback.
625
+
626
+ Returns SSE stream for the revised planning phase.
627
+ """
628
+ if session_id not in _sessions:
629
+ raise HTTPException(status_code=404, detail="Session not found")
630
+
631
+ session = _sessions[session_id]
632
+ runner = session.get("runner")
633
+
634
+ if not runner:
635
+ raise HTTPException(status_code=400, detail="Session has no active runner")
636
+
637
+ if not runner.has_pending_plan():
638
+ raise HTTPException(status_code=400, detail="No pending plan to reject")
639
+
640
+ sse_handler = SSEHandler(agent_name="Emdash Code")
641
+
642
+ async def _run_rejection():
643
+ loop = asyncio.get_event_loop()
644
+
645
+ try:
646
+ from ..agent.events import AgentEventEmitter
647
+
648
+ class SSEBridgeHandler:
649
+ def __init__(self, sse_handler: SSEHandler):
650
+ self._sse = sse_handler
651
+
652
+ def handle(self, event):
653
+ self._sse.handle(event)
654
+
655
+ emitter = AgentEventEmitter(agent_name="Emdash Code")
656
+ emitter.add_handler(SSEBridgeHandler(sse_handler))
657
+ runner.emitter = emitter
658
+
659
+ sse_handler.emit(EventType.SESSION_START, {
660
+ "agent_name": "Emdash Code",
661
+ "model": session.get("model", "unknown"),
662
+ "session_id": session_id,
663
+ "query": f"Plan rejected - revising... {feedback}",
664
+ "plan_rejected": True,
665
+ })
666
+
667
+ # Reset cycle state for revision
668
+ from ..agent.tools.modes import ModeState
669
+ ModeState.get_instance().reset_cycle()
670
+
671
+ # Reject and continue planning
672
+ await loop.run_in_executor(
673
+ _executor,
674
+ lambda: runner.reject_plan(feedback),
675
+ )
676
+
677
+ sse_handler.emit(EventType.SESSION_END, {
678
+ "success": True,
679
+ "session_id": session_id,
680
+ })
681
+
682
+ except Exception as e:
683
+ sse_handler.emit(EventType.ERROR, {"message": str(e)})
684
+ sse_handler.emit(EventType.SESSION_END, {
685
+ "success": False,
686
+ "error": str(e),
687
+ "session_id": session_id,
688
+ })
689
+
690
+ finally:
691
+ sse_handler.close()
692
+
693
+ asyncio.create_task(_run_rejection())
694
+
695
+ return StreamingResponse(
696
+ sse_handler,
697
+ media_type="text/event-stream",
698
+ headers={
699
+ "Cache-Control": "no-cache",
700
+ "Connection": "keep-alive",
701
+ "X-Session-ID": session_id,
702
+ },
703
+ )
704
+
705
+
706
+ @router.post("/chat/{session_id}/planmode/approve")
707
+ async def approve_plan_mode(session_id: str):
708
+ """Approve entering plan mode.
709
+
710
+ Called when user approves the agent's request to enter plan mode.
711
+ Returns SSE stream for the planning phase.
712
+ """
713
+ if session_id not in _sessions:
714
+ raise HTTPException(status_code=404, detail="Session not found")
715
+
716
+ session = _sessions[session_id]
717
+ runner = session.get("runner")
718
+
719
+ if not runner:
720
+ raise HTTPException(status_code=400, detail="Session has no active runner")
721
+
722
+ # Check if plan mode was actually requested
723
+ from ..agent.tools.modes import ModeState
724
+ state = ModeState.get_instance()
725
+ if not state.plan_mode_requested:
726
+ raise HTTPException(status_code=400, detail="No pending plan mode request")
727
+
728
+ sse_handler = SSEHandler(agent_name="Emdash Code")
729
+
730
+ async def _run_approval():
731
+ loop = asyncio.get_event_loop()
732
+
733
+ try:
734
+ from ..agent.events import AgentEventEmitter
735
+
736
+ class SSEBridgeHandler:
737
+ def __init__(self, sse_handler: SSEHandler):
738
+ self._sse = sse_handler
739
+
740
+ def handle(self, event):
741
+ self._sse.handle(event)
742
+
743
+ emitter = AgentEventEmitter(agent_name="Emdash Code")
744
+ emitter.add_handler(SSEBridgeHandler(sse_handler))
745
+ runner.emitter = emitter
746
+
747
+ sse_handler.emit(EventType.SESSION_START, {
748
+ "agent_name": "Emdash Code",
749
+ "model": session.get("model", "unknown"),
750
+ "session_id": session_id,
751
+ "query": "Plan mode approved - entering plan mode...",
752
+ "plan_mode_approved": True,
753
+ })
754
+
755
+ # Approve and enter plan mode
756
+ await loop.run_in_executor(
757
+ _executor,
758
+ runner.approve_plan_mode,
759
+ )
760
+
761
+ sse_handler.emit(EventType.SESSION_END, {
762
+ "success": True,
763
+ "session_id": session_id,
764
+ })
765
+
766
+ except Exception as e:
767
+ sse_handler.emit(EventType.ERROR, {"message": str(e)})
768
+ sse_handler.emit(EventType.SESSION_END, {
769
+ "success": False,
770
+ "error": str(e),
771
+ "session_id": session_id,
772
+ })
773
+
774
+ finally:
775
+ sse_handler.close()
776
+
777
+ asyncio.create_task(_run_approval())
778
+
779
+ return StreamingResponse(
780
+ sse_handler,
781
+ media_type="text/event-stream",
782
+ headers={
783
+ "Cache-Control": "no-cache",
784
+ "Connection": "keep-alive",
785
+ "X-Session-ID": session_id,
786
+ },
787
+ )
788
+
789
+
790
+ @router.post("/chat/{session_id}/clarification/answer")
791
+ async def answer_clarification(session_id: str, answer: str):
792
+ """Answer a pending clarification question.
793
+
794
+ Called when the user responds to a clarification question asked by the agent
795
+ via ask_followup_question tool. This resumes the agent with the user's answer.
796
+
797
+ Args:
798
+ session_id: The session ID
799
+ answer: The user's answer to the clarification question
800
+
801
+ Returns:
802
+ SSE stream for the agent's continued execution
803
+ """
804
+ if session_id not in _sessions:
805
+ raise HTTPException(status_code=404, detail="Session not found")
806
+
807
+ session = _sessions[session_id]
808
+ runner = session.get("runner")
809
+
810
+ if not runner:
811
+ raise HTTPException(status_code=400, detail="Session has no active runner")
812
+
813
+ sse_handler = SSEHandler(agent_name="Emdash Code")
814
+
815
+ async def _run_answer():
816
+ loop = asyncio.get_event_loop()
817
+
818
+ try:
819
+ from ..agent.events import AgentEventEmitter
820
+
821
+ class SSEBridgeHandler:
822
+ def __init__(self, sse_handler: SSEHandler):
823
+ self._sse = sse_handler
824
+
825
+ def handle(self, event):
826
+ self._sse.handle(event)
827
+
828
+ emitter = AgentEventEmitter(agent_name="Emdash Code")
829
+ emitter.add_handler(SSEBridgeHandler(sse_handler))
830
+ runner.emitter = emitter
831
+
832
+ sse_handler.emit(EventType.SESSION_START, {
833
+ "agent_name": "Emdash Code",
834
+ "model": session.get("model", "unknown"),
835
+ "session_id": session_id,
836
+ "query": f"Clarification answered: {answer[:100]}...",
837
+ "clarification_answered": True,
838
+ })
839
+
840
+ # Answer the clarification and resume the agent
841
+ await loop.run_in_executor(
842
+ _executor,
843
+ lambda: runner.answer_clarification(answer),
844
+ )
845
+
846
+ sse_handler.emit(EventType.SESSION_END, {
847
+ "success": True,
848
+ "session_id": session_id,
849
+ })
850
+
851
+ except Exception as e:
852
+ sse_handler.emit(EventType.ERROR, {"message": str(e)})
853
+ sse_handler.emit(EventType.SESSION_END, {
854
+ "success": False,
855
+ "error": str(e),
856
+ "session_id": session_id,
857
+ })
858
+
859
+ finally:
860
+ sse_handler.close()
861
+
862
+ asyncio.create_task(_run_answer())
863
+
864
+ return StreamingResponse(
865
+ sse_handler,
866
+ media_type="text/event-stream",
867
+ headers={
868
+ "Cache-Control": "no-cache",
869
+ "Connection": "keep-alive",
870
+ "X-Session-ID": session_id,
871
+ },
872
+ )
873
+
874
+
875
+ @router.post("/chat/{session_id}/planmode/reject")
876
+ async def reject_plan_mode(session_id: str, feedback: str = ""):
877
+ """Reject entering plan mode.
878
+
879
+ Called when user rejects the agent's request to enter plan mode.
880
+ Returns SSE stream for continued code mode execution.
881
+ """
882
+ if session_id not in _sessions:
883
+ raise HTTPException(status_code=404, detail="Session not found")
884
+
885
+ session = _sessions[session_id]
886
+ runner = session.get("runner")
887
+
888
+ if not runner:
889
+ raise HTTPException(status_code=400, detail="Session has no active runner")
890
+
891
+ # Check if plan mode was actually requested
892
+ from ..agent.tools.modes import ModeState
893
+ state = ModeState.get_instance()
894
+ if not state.plan_mode_requested:
895
+ raise HTTPException(status_code=400, detail="No pending plan mode request")
896
+
897
+ sse_handler = SSEHandler(agent_name="Emdash Code")
898
+
899
+ async def _run_rejection():
900
+ loop = asyncio.get_event_loop()
901
+
902
+ try:
903
+ from ..agent.events import AgentEventEmitter
904
+
905
+ class SSEBridgeHandler:
906
+ def __init__(self, sse_handler: SSEHandler):
907
+ self._sse = sse_handler
908
+
909
+ def handle(self, event):
910
+ self._sse.handle(event)
911
+
912
+ emitter = AgentEventEmitter(agent_name="Emdash Code")
913
+ emitter.add_handler(SSEBridgeHandler(sse_handler))
914
+ runner.emitter = emitter
915
+
916
+ sse_handler.emit(EventType.SESSION_START, {
917
+ "agent_name": "Emdash Code",
918
+ "model": session.get("model", "unknown"),
919
+ "session_id": session_id,
920
+ "query": f"Plan mode rejected - continuing in code mode... {feedback}",
921
+ "plan_mode_rejected": True,
922
+ })
923
+
924
+ # Reject and stay in code mode
925
+ await loop.run_in_executor(
926
+ _executor,
927
+ lambda: runner.reject_plan_mode(feedback),
928
+ )
929
+
930
+ sse_handler.emit(EventType.SESSION_END, {
931
+ "success": True,
932
+ "session_id": session_id,
933
+ })
934
+
935
+ except Exception as e:
936
+ sse_handler.emit(EventType.ERROR, {"message": str(e)})
937
+ sse_handler.emit(EventType.SESSION_END, {
938
+ "success": False,
939
+ "error": str(e),
940
+ "session_id": session_id,
941
+ })
942
+
943
+ finally:
944
+ sse_handler.close()
945
+
946
+ asyncio.create_task(_run_rejection())
947
+
948
+ return StreamingResponse(
949
+ sse_handler,
950
+ media_type="text/event-stream",
951
+ headers={
952
+ "Cache-Control": "no-cache",
953
+ "Connection": "keep-alive",
954
+ "X-Session-ID": session_id,
955
+ },
956
+ )
957
+
958
+
959
+ @router.get("/chat/{session_id}/todos")
960
+ async def get_todos(session_id: str):
961
+ """Get the current todo list for a session.
962
+
963
+ Returns the agent's task list including status of each item.
964
+ """
965
+ if session_id not in _sessions:
966
+ raise HTTPException(status_code=404, detail="Session not found")
967
+
968
+ # Get todos from TaskState singleton
969
+ from ..agent.tools.tasks import TaskState
970
+ state = TaskState.get_instance()
971
+
972
+ todos = state.get_all_tasks()
973
+
974
+ # Count by status
975
+ pending = sum(1 for t in todos if t["status"] == "pending")
976
+ in_progress = sum(1 for t in todos if t["status"] == "in_progress")
977
+ completed = sum(1 for t in todos if t["status"] == "completed")
978
+
979
+ return {
980
+ "session_id": session_id,
981
+ "todos": todos,
982
+ "summary": {
983
+ "total": len(todos),
984
+ "pending": pending,
985
+ "in_progress": in_progress,
986
+ "completed": completed,
987
+ },
988
+ }
989
+
990
+
991
+ @router.post("/chat/{session_id}/todos")
992
+ async def add_todo(session_id: str, title: str, description: str = ""):
993
+ """Add a new todo item to the agent's task list.
994
+
995
+ This allows users to inject tasks for the agent to work on.
996
+ """
997
+ if session_id not in _sessions:
998
+ raise HTTPException(status_code=404, detail="Session not found")
999
+
1000
+ if not title or not title.strip():
1001
+ raise HTTPException(status_code=400, detail="Title is required")
1002
+
1003
+ # Add todo via TaskState singleton
1004
+ from ..agent.tools.tasks import TaskState
1005
+ state = TaskState.get_instance()
1006
+
1007
+ task = state.add_task(title=title.strip(), description=description.strip())
1008
+
1009
+ return {
1010
+ "session_id": session_id,
1011
+ "task": task.to_dict(),
1012
+ "total_tasks": len(state.tasks),
1013
+ }