minion-code 0.1.0__py3-none-any.whl → 0.1.2__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 (115) hide show
  1. examples/cli_entrypoint.py +60 -0
  2. examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
  3. examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
  4. examples/components/messages_component.py +199 -0
  5. examples/file_freshness_example.py +22 -22
  6. examples/file_watching_example.py +32 -26
  7. examples/interruptible_tui.py +921 -3
  8. examples/repl_tui.py +129 -0
  9. examples/skills/example_usage.py +57 -0
  10. examples/start.py +173 -0
  11. minion_code/__init__.py +1 -1
  12. minion_code/acp_server/__init__.py +34 -0
  13. minion_code/acp_server/agent.py +539 -0
  14. minion_code/acp_server/hooks.py +354 -0
  15. minion_code/acp_server/main.py +194 -0
  16. minion_code/acp_server/permissions.py +142 -0
  17. minion_code/acp_server/test_client.py +104 -0
  18. minion_code/adapters/__init__.py +22 -0
  19. minion_code/adapters/output_adapter.py +207 -0
  20. minion_code/adapters/rich_adapter.py +169 -0
  21. minion_code/adapters/textual_adapter.py +254 -0
  22. minion_code/agents/__init__.py +2 -2
  23. minion_code/agents/code_agent.py +517 -104
  24. minion_code/agents/hooks.py +378 -0
  25. minion_code/cli.py +538 -429
  26. minion_code/cli_simple.py +665 -0
  27. minion_code/commands/__init__.py +136 -29
  28. minion_code/commands/clear_command.py +19 -46
  29. minion_code/commands/help_command.py +33 -49
  30. minion_code/commands/history_command.py +37 -55
  31. minion_code/commands/model_command.py +194 -0
  32. minion_code/commands/quit_command.py +9 -12
  33. minion_code/commands/resume_command.py +181 -0
  34. minion_code/commands/skill_command.py +89 -0
  35. minion_code/commands/status_command.py +48 -73
  36. minion_code/commands/tools_command.py +54 -52
  37. minion_code/commands/version_command.py +34 -69
  38. minion_code/components/ConfirmDialog.py +430 -0
  39. minion_code/components/Message.py +318 -97
  40. minion_code/components/MessageResponse.py +30 -29
  41. minion_code/components/Messages.py +351 -0
  42. minion_code/components/PromptInput.py +499 -245
  43. minion_code/components/__init__.py +24 -17
  44. minion_code/const.py +7 -0
  45. minion_code/screens/REPL.py +1453 -469
  46. minion_code/screens/__init__.py +1 -1
  47. minion_code/services/__init__.py +20 -20
  48. minion_code/services/event_system.py +19 -14
  49. minion_code/services/file_freshness_service.py +223 -170
  50. minion_code/skills/__init__.py +25 -0
  51. minion_code/skills/skill.py +128 -0
  52. minion_code/skills/skill_loader.py +198 -0
  53. minion_code/skills/skill_registry.py +177 -0
  54. minion_code/subagents/__init__.py +31 -0
  55. minion_code/subagents/builtin/__init__.py +30 -0
  56. minion_code/subagents/builtin/claude_code_guide.py +32 -0
  57. minion_code/subagents/builtin/explore.py +36 -0
  58. minion_code/subagents/builtin/general_purpose.py +19 -0
  59. minion_code/subagents/builtin/plan.py +61 -0
  60. minion_code/subagents/subagent.py +116 -0
  61. minion_code/subagents/subagent_loader.py +147 -0
  62. minion_code/subagents/subagent_registry.py +151 -0
  63. minion_code/tools/__init__.py +8 -2
  64. minion_code/tools/bash_tool.py +16 -3
  65. minion_code/tools/file_edit_tool.py +201 -104
  66. minion_code/tools/file_read_tool.py +183 -26
  67. minion_code/tools/file_write_tool.py +17 -3
  68. minion_code/tools/glob_tool.py +23 -2
  69. minion_code/tools/grep_tool.py +229 -21
  70. minion_code/tools/ls_tool.py +28 -3
  71. minion_code/tools/multi_edit_tool.py +89 -84
  72. minion_code/tools/python_interpreter_tool.py +9 -1
  73. minion_code/tools/skill_tool.py +210 -0
  74. minion_code/tools/task_tool.py +287 -0
  75. minion_code/tools/todo_read_tool.py +28 -24
  76. minion_code/tools/todo_write_tool.py +82 -65
  77. minion_code/{types.py → type_defs.py} +15 -2
  78. minion_code/utils/__init__.py +45 -17
  79. minion_code/utils/config.py +610 -0
  80. minion_code/utils/history.py +114 -0
  81. minion_code/utils/logs.py +53 -0
  82. minion_code/utils/mcp_loader.py +153 -55
  83. minion_code/utils/output_truncator.py +233 -0
  84. minion_code/utils/session_storage.py +369 -0
  85. minion_code/utils/todo_file_utils.py +26 -22
  86. minion_code/utils/todo_storage.py +43 -33
  87. minion_code/web/__init__.py +9 -0
  88. minion_code/web/adapters/__init__.py +5 -0
  89. minion_code/web/adapters/web_adapter.py +524 -0
  90. minion_code/web/api/__init__.py +7 -0
  91. minion_code/web/api/chat.py +277 -0
  92. minion_code/web/api/interactions.py +136 -0
  93. minion_code/web/api/sessions.py +135 -0
  94. minion_code/web/server.py +149 -0
  95. minion_code/web/services/__init__.py +5 -0
  96. minion_code/web/services/session_manager.py +420 -0
  97. minion_code-0.1.2.dist-info/METADATA +476 -0
  98. minion_code-0.1.2.dist-info/RECORD +111 -0
  99. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
  100. minion_code-0.1.2.dist-info/entry_points.txt +6 -0
  101. tests/test_adapter.py +67 -0
  102. tests/test_adapter_simple.py +79 -0
  103. tests/test_file_read_tool.py +144 -0
  104. tests/test_readonly_tools.py +0 -2
  105. tests/test_skills.py +441 -0
  106. examples/advance_tui.py +0 -508
  107. examples/rich_example.py +0 -4
  108. examples/simple_file_watching.py +0 -57
  109. examples/simple_tui.py +0 -267
  110. examples/simple_usage.py +0 -69
  111. minion_code-0.1.0.dist-info/METADATA +0 -350
  112. minion_code-0.1.0.dist-info/RECORD +0 -59
  113. minion_code-0.1.0.dist-info/entry_points.txt +0 -4
  114. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
  115. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,420 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Session Manager for Web API.
5
+
6
+ Manages web sessions, agent instances, and the relationship between them.
7
+ Supports two history modes:
8
+ - full: Each request creates new Agent, loads full history (stateless, scalable)
9
+ - incremental: Reuse Agent, only send new message (stateful, low latency)
10
+ """
11
+
12
+ import asyncio
13
+ import time
14
+ import uuid
15
+ import logging
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Dict, Optional, Literal, List, Any
19
+
20
+ from ..adapters.web_adapter import WebOutputAdapter, TaskState
21
+ from minion_code.utils.session_storage import (
22
+ Session,
23
+ SessionStorage,
24
+ SessionMessage,
25
+ create_session,
26
+ load_session,
27
+ save_session,
28
+ add_message,
29
+ restore_agent_history,
30
+ )
31
+ from minion_code.agents.hooks import (
32
+ HookConfig,
33
+ HookMatcher,
34
+ create_confirm_writes_hook,
35
+ create_dangerous_command_check_hook,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ HistoryMode = Literal["full", "incremental"]
41
+
42
+
43
+ @dataclass
44
+ class WebSession:
45
+ """Web session containing agent and adapter."""
46
+
47
+ session_id: str
48
+ project_path: str
49
+ adapter: WebOutputAdapter
50
+ created_at: float = field(default_factory=time.time)
51
+ last_activity: float = field(default_factory=time.time)
52
+ history_mode: HistoryMode = "full"
53
+
54
+ # Agent instance (only used in incremental mode)
55
+ _agent: Optional[Any] = field(default=None, repr=False)
56
+
57
+ # Abort event for cancelling current task
58
+ abort_event: asyncio.Event = field(default_factory=asyncio.Event)
59
+
60
+ # Current task ID
61
+ current_task_id: Optional[str] = None
62
+
63
+ # Session storage (persisted)
64
+ _storage_session: Optional[Session] = field(default=None, repr=False)
65
+
66
+ def update_activity(self):
67
+ """Update last activity timestamp."""
68
+ self.last_activity = time.time()
69
+
70
+ def generate_task_id(self) -> str:
71
+ """Generate unique task ID."""
72
+ return f"task_{self.session_id}_{int(time.time() * 1000)}"
73
+
74
+
75
+ class SessionManager:
76
+ """
77
+ Web session manager.
78
+
79
+ Manages the lifecycle of web sessions and their associated resources.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ max_sessions: int = 100,
85
+ session_timeout: int = 3600, # 1 hour
86
+ default_history_mode: HistoryMode = "full",
87
+ ):
88
+ """
89
+ Initialize session manager.
90
+
91
+ Args:
92
+ max_sessions: Maximum number of concurrent sessions
93
+ session_timeout: Session timeout in seconds
94
+ default_history_mode: Default history mode for new sessions
95
+ """
96
+ self.max_sessions = max_sessions
97
+ self.session_timeout = session_timeout
98
+ self.default_history_mode = default_history_mode
99
+
100
+ self.sessions: Dict[str, WebSession] = {}
101
+ self.storage = SessionStorage()
102
+ self._lock = asyncio.Lock()
103
+
104
+ async def create_session(
105
+ self, project_path: str = ".", history_mode: Optional[HistoryMode] = None
106
+ ) -> WebSession:
107
+ """
108
+ Create a new web session.
109
+
110
+ Args:
111
+ project_path: Working directory for the session
112
+ history_mode: History mode ('full' or 'incremental')
113
+
114
+ Returns:
115
+ New WebSession instance
116
+ """
117
+ async with self._lock:
118
+ # Cleanup expired sessions
119
+ await self._cleanup_expired_sessions()
120
+
121
+ if len(self.sessions) >= self.max_sessions:
122
+ raise Exception("Maximum sessions reached")
123
+
124
+ # Generate session ID
125
+ session_id = str(uuid.uuid4())[:8]
126
+
127
+ # Create adapter
128
+ adapter = WebOutputAdapter(session_id=session_id)
129
+
130
+ # Create web session
131
+ session = WebSession(
132
+ session_id=session_id,
133
+ project_path=str(Path(project_path).resolve()),
134
+ adapter=adapter,
135
+ history_mode=history_mode or self.default_history_mode,
136
+ )
137
+
138
+ # Create persistent storage session
139
+ storage_session = self.storage.create_session(session.project_path)
140
+ # Override session_id to match web session
141
+ storage_session.metadata.session_id = session_id
142
+ session._storage_session = storage_session
143
+
144
+ self.sessions[session_id] = session
145
+ logger.info(
146
+ f"Created session {session_id} with history_mode={session.history_mode}"
147
+ )
148
+
149
+ return session
150
+
151
+ async def get_session(self, session_id: str) -> Optional[WebSession]:
152
+ """
153
+ Get a session by ID.
154
+
155
+ Args:
156
+ session_id: Session identifier
157
+
158
+ Returns:
159
+ WebSession if found, None otherwise
160
+ """
161
+ session = self.sessions.get(session_id)
162
+ if session:
163
+ session.update_activity()
164
+ return session
165
+
166
+ async def get_or_create_session(
167
+ self,
168
+ session_id: Optional[str] = None,
169
+ project_path: str = ".",
170
+ history_mode: Optional[HistoryMode] = None,
171
+ ) -> WebSession:
172
+ """
173
+ Get existing session or create new one.
174
+
175
+ Args:
176
+ session_id: Optional session ID to look up
177
+ project_path: Working directory for new session
178
+ history_mode: History mode for new session
179
+
180
+ Returns:
181
+ WebSession instance
182
+ """
183
+ if session_id:
184
+ session = await self.get_session(session_id)
185
+ if session:
186
+ return session
187
+
188
+ return await self.create_session(project_path, history_mode)
189
+
190
+ async def delete_session(self, session_id: str) -> bool:
191
+ """
192
+ Delete a session.
193
+
194
+ Args:
195
+ session_id: Session identifier
196
+
197
+ Returns:
198
+ True if session was deleted, False if not found
199
+ """
200
+ async with self._lock:
201
+ session = self.sessions.pop(session_id, None)
202
+ if session:
203
+ # Abort any running task
204
+ session.abort_event.set()
205
+ logger.info(f"Deleted session {session_id}")
206
+ return True
207
+ return False
208
+
209
+ def find_session_by_interaction(self, interaction_id: str) -> Optional[WebSession]:
210
+ """
211
+ Find session containing a pending interaction.
212
+
213
+ Args:
214
+ interaction_id: Interaction identifier
215
+
216
+ Returns:
217
+ WebSession if found, None otherwise
218
+ """
219
+ for session in self.sessions.values():
220
+ if session.adapter.get_pending_interaction(interaction_id):
221
+ return session
222
+ return None
223
+
224
+ def _create_hooks_for_session(self, session: WebSession) -> HookConfig:
225
+ """
226
+ Create hook configuration for a session.
227
+
228
+ Configures:
229
+ - Dangerous command blocking for bash
230
+ - User confirmation for write operations via adapter.confirm()
231
+
232
+ Args:
233
+ session: WebSession with adapter for confirmations
234
+
235
+ Returns:
236
+ HookConfig for the session
237
+ """
238
+ return HookConfig(
239
+ pre_tool_use=[
240
+ # Block dangerous bash commands
241
+ HookMatcher("bash", create_dangerous_command_check_hook()),
242
+ # Confirm non-readonly tools via WebOutputAdapter
243
+ HookMatcher("*", create_confirm_writes_hook(session.adapter)),
244
+ ]
245
+ )
246
+
247
+ def _create_compaction_callback(self, session: WebSession):
248
+ """
249
+ Create a callback function for syncing compacted history to storage.
250
+
251
+ When BaseAgent compacts history, this callback updates session storage
252
+ with the compacted agent_history.
253
+
254
+ Args:
255
+ session: WebSession to sync compacted history for
256
+
257
+ Returns:
258
+ Callback function for on_compaction hook
259
+ """
260
+
261
+ def sync_compaction(compacted_messages: List[Dict[str, Any]]):
262
+ if session._storage_session:
263
+ session._storage_session.agent_history = compacted_messages
264
+ session._storage_session.compaction_count += 1
265
+ save_session(session._storage_session)
266
+ logger.info(
267
+ f"Session {session.session_id}: Synced compacted history "
268
+ f"({len(compacted_messages)} messages, "
269
+ f"compaction #{session._storage_session.compaction_count})"
270
+ )
271
+
272
+ return sync_compaction
273
+
274
+ async def get_or_create_agent(self, session: WebSession):
275
+ """
276
+ Get or create agent for session based on history mode.
277
+
278
+ For 'full' mode: Creates new agent, restores history from storage
279
+ For 'incremental' mode: Reuses existing agent or creates new one
280
+
281
+ Args:
282
+ session: WebSession to get agent for
283
+
284
+ Returns:
285
+ MinionCodeAgent instance
286
+ """
287
+ from minion_code.agents import MinionCodeAgent
288
+
289
+ # Create hooks for permission control
290
+ hooks = self._create_hooks_for_session(session)
291
+
292
+ if session.history_mode == "full":
293
+ # Full mode: Always create new agent and restore history
294
+ agent = await MinionCodeAgent.create(
295
+ name=f"WebAgent-{session.session_id}",
296
+ llm="sonnet",
297
+ workdir=session.project_path,
298
+ hooks=hooks,
299
+ # History decay: save large outputs to file after N steps
300
+ decay_enabled=True,
301
+ decay_ttl_steps=3,
302
+ decay_min_size=100_000, # 100KB
303
+ )
304
+
305
+ # Inject on_compaction callback to sync compacted history
306
+ agent.on_compaction = self._create_compaction_callback(session)
307
+
308
+ # Restore history from storage (prefers agent_history if available)
309
+ if session._storage_session:
310
+ restore_agent_history(agent, session._storage_session, verbose=False)
311
+
312
+ return agent
313
+
314
+ else: # incremental mode
315
+ # Incremental mode: Reuse agent if available
316
+ if session._agent is None:
317
+ session._agent = await MinionCodeAgent.create(
318
+ name=f"WebAgent-{session.session_id}",
319
+ llm="sonnet",
320
+ workdir=session.project_path,
321
+ hooks=hooks,
322
+ # History decay: save large outputs to file after N steps
323
+ decay_enabled=True,
324
+ decay_ttl_steps=3,
325
+ decay_min_size=100_000, # 100KB
326
+ )
327
+
328
+ # Inject on_compaction callback to sync compacted history
329
+ session._agent.on_compaction = self._create_compaction_callback(session)
330
+
331
+ # Restore history on first creation (prefers agent_history if available)
332
+ if session._storage_session:
333
+ restore_agent_history(
334
+ session._agent, session._storage_session, verbose=False
335
+ )
336
+
337
+ return session._agent
338
+
339
+ def save_message(self, session: WebSession, role: str, content: str):
340
+ """
341
+ Save a message to session storage.
342
+
343
+ Args:
344
+ session: WebSession to save message for
345
+ role: 'user' or 'assistant'
346
+ content: Message content
347
+ """
348
+ if session._storage_session:
349
+ add_message(session._storage_session, role, content, auto_save=True)
350
+
351
+ def get_messages(self, session: WebSession) -> List[Dict[str, str]]:
352
+ """
353
+ Get all messages from session storage.
354
+
355
+ Args:
356
+ session: WebSession to get messages for
357
+
358
+ Returns:
359
+ List of message dicts with 'role' and 'content'
360
+ """
361
+ if session._storage_session:
362
+ return [
363
+ {"role": msg.role, "content": msg.content}
364
+ for msg in session._storage_session.messages
365
+ ]
366
+ return []
367
+
368
+ async def abort_task(self, session_id: str) -> bool:
369
+ """
370
+ Abort the current task in a session.
371
+
372
+ Args:
373
+ session_id: Session identifier
374
+
375
+ Returns:
376
+ True if abort signal was sent, False if session not found
377
+ """
378
+ session = self.sessions.get(session_id)
379
+ if session:
380
+ session.abort_event.set()
381
+ # Reset for next task
382
+ session.abort_event = asyncio.Event()
383
+ logger.info(f"Aborted task in session {session_id}")
384
+ return True
385
+ return False
386
+
387
+ async def _cleanup_expired_sessions(self):
388
+ """Remove expired sessions."""
389
+ current_time = time.time()
390
+ expired = [
391
+ sid
392
+ for sid, session in self.sessions.items()
393
+ if current_time - session.last_activity > self.session_timeout
394
+ ]
395
+ for sid in expired:
396
+ del self.sessions[sid]
397
+ logger.info(f"Cleaned up expired session {sid}")
398
+
399
+ def list_sessions(self) -> List[Dict[str, Any]]:
400
+ """
401
+ List all active sessions.
402
+
403
+ Returns:
404
+ List of session info dicts
405
+ """
406
+ return [
407
+ {
408
+ "session_id": s.session_id,
409
+ "project_path": s.project_path,
410
+ "history_mode": s.history_mode,
411
+ "created_at": s.created_at,
412
+ "last_activity": s.last_activity,
413
+ "has_pending_interactions": s.adapter.has_pending_interactions(),
414
+ }
415
+ for s in self.sessions.values()
416
+ ]
417
+
418
+
419
+ # Global instance
420
+ session_manager = SessionManager()