minion-code 0.1.0__py3-none-any.whl → 0.1.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.
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.1.dist-info/METADATA +475 -0
  98. minion_code-0.1.1.dist-info/RECORD +111 -0
  99. {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/WHEEL +1 -1
  100. minion_code-0.1.1.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.1.dist-info}/licenses/LICENSE +0 -0
  115. {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ ACP-specific hooks for tool execution notifications.
5
+
6
+ These hooks integrate with the ACP protocol to send session_update
7
+ notifications when tools are called.
8
+ """
9
+
10
+ import logging
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, Optional
14
+
15
+ from acp import Client
16
+ from acp.schema import (
17
+ ToolCallStart,
18
+ ToolCallProgress,
19
+ ToolCallUpdate,
20
+ PermissionOption,
21
+ TextContentBlock,
22
+ ContentToolCallContent,
23
+ )
24
+
25
+ from ..agents.hooks import (
26
+ HookConfig,
27
+ PreToolUseResult,
28
+ PostToolUseResult,
29
+ PermissionDecision,
30
+ )
31
+ from .permissions import PermissionStore
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # Map tool names to ToolKind
37
+ TOOL_KIND_MAP = {
38
+ "file_read": "read",
39
+ "file_write": "edit",
40
+ "file_edit": "edit",
41
+ "glob": "search",
42
+ "grep": "search",
43
+ "bash": "execute",
44
+ "python_interpreter": "execute",
45
+ "web_fetch": "fetch",
46
+ "web_search": "search",
47
+ "think": "think",
48
+ }
49
+
50
+ # Tools that are safe and don't need permission
51
+ # These are read-only, internal, or non-destructive operations
52
+ SAFE_TOOLS = {
53
+ # Read-only tools
54
+ # "file_read",
55
+ # "glob",
56
+ # "grep",
57
+ # "ls",
58
+ # "todo_read",
59
+ # Internal/non-destructive tools
60
+ "think",
61
+ "final_answer",
62
+ "user_input",
63
+ # Note: file_write, file_edit, bash, python_interpreter are NOT safe
64
+ }
65
+
66
+
67
+ def get_tool_kind(tool_name: str) -> str:
68
+ """Get the ACP ToolKind for a tool name."""
69
+ return TOOL_KIND_MAP.get(tool_name, "other")
70
+
71
+
72
+ @dataclass
73
+ class ACPToolHooks:
74
+ """
75
+ ACP-specific tool hooks that send session_update notifications.
76
+
77
+ This class creates pre/post tool use hooks that:
78
+ 1. pre_tool_use: Sends ToolCallStart notification (status="in_progress")
79
+ 2. post_tool_use: Sends ToolCallProgress update (status="completed"/"failed")
80
+ """
81
+
82
+ client: Client
83
+ session_id: str
84
+ request_permission: bool = False # Whether to request permission via ACP
85
+ permission_store: Optional[PermissionStore] = None # Persistent permission storage
86
+ _tool_call_ids: Dict[str, str] = field(default_factory=dict)
87
+
88
+ @staticmethod
89
+ def _generate_tool_call_id() -> str:
90
+ """Generate a unique tool call ID."""
91
+ return str(uuid.uuid4())
92
+
93
+ async def pre_tool_use(
94
+ self, tool_name: str, tool_input: Dict[str, Any], tool_use_id: str
95
+ ) -> PreToolUseResult:
96
+ """
97
+ Pre-tool-use hook that sends ToolCallStart notification.
98
+
99
+ Sends a session_update with ToolCallStart to notify the ACP client
100
+ that a tool is about to be executed.
101
+ """
102
+ # Generate and store tool call ID
103
+ tool_call_id = self._generate_tool_call_id()
104
+ self._tool_call_ids[tool_use_id] = tool_call_id
105
+
106
+ # Check if this tool needs permission
107
+ needs_permission = self.request_permission and tool_name not in SAFE_TOOLS
108
+
109
+ if tool_name in SAFE_TOOLS:
110
+ logger.debug(f"Tool {tool_name} is safe, skipping permission request")
111
+
112
+ # Check persistent permissions first
113
+ if needs_permission and self.permission_store:
114
+ stored_permission = self.permission_store.is_allowed(tool_name)
115
+ if stored_permission is True:
116
+ logger.info(
117
+ f"Tool {tool_name} has persistent allow permission, skipping request"
118
+ )
119
+ needs_permission = False
120
+ elif stored_permission is False:
121
+ logger.info(f"Tool {tool_name} has persistent reject permission")
122
+ return PreToolUseResult(
123
+ decision=PermissionDecision.DENY,
124
+ reason="Tool permanently rejected by user",
125
+ )
126
+
127
+ # Request permission via ACP if enabled and tool is not safe
128
+ if needs_permission:
129
+ try:
130
+ # Create permission options
131
+ options = [
132
+ PermissionOption(
133
+ option_id="allow_once",
134
+ name="Allow once",
135
+ kind="allow_once",
136
+ ),
137
+ PermissionOption(
138
+ option_id="allow_always",
139
+ name="Always allow this tool",
140
+ kind="allow_always",
141
+ ),
142
+ PermissionOption(
143
+ option_id="reject_once",
144
+ name="Reject",
145
+ kind="reject_once",
146
+ ),
147
+ ]
148
+
149
+ # Create tool call info for permission request (use ToolCallUpdate, not ToolCallStart)
150
+ tool_call_for_permission = ToolCallUpdate(
151
+ tool_call_id=tool_call_id,
152
+ title=f"Permission: {tool_name}",
153
+ kind=get_tool_kind(tool_name),
154
+ status="pending",
155
+ content=[
156
+ ContentToolCallContent(
157
+ type="content",
158
+ content=TextContentBlock(
159
+ type="text",
160
+ text=f"Tool: {tool_name}\nInput: {tool_input}",
161
+ ),
162
+ )
163
+ ],
164
+ )
165
+
166
+ # Request permission from user
167
+ permission_response = await self.client.request_permission(
168
+ options=options,
169
+ session_id=self.session_id,
170
+ tool_call=tool_call_for_permission,
171
+ )
172
+
173
+ # Check response - extract option_id and outcome
174
+ raw_outcome = permission_response.outcome
175
+ option_id = None
176
+ outcome = raw_outcome
177
+
178
+ # Handle nested structures from different ACP clients
179
+ if hasattr(raw_outcome, "option_id"):
180
+ option_id = raw_outcome.option_id
181
+ if hasattr(raw_outcome, "outcome"):
182
+ outcome = raw_outcome.outcome
183
+ if hasattr(outcome, "option_id"):
184
+ option_id = outcome.option_id
185
+
186
+ # Use option_id if available (more reliable), otherwise fall back to outcome
187
+ selected = option_id or outcome
188
+
189
+ logger.info(
190
+ f"Permission response for {tool_name}: selected={selected}, option_id={option_id}, outcome={outcome}"
191
+ )
192
+
193
+ if selected in ("rejected", "reject_once", "reject_always"):
194
+ logger.info(f"Permission denied for {tool_name}: {selected}")
195
+ # Save persistent rejection if "always"
196
+ if selected == "reject_always" and self.permission_store:
197
+ self.permission_store.set_permission(
198
+ tool_name, always_allow=False
199
+ )
200
+ return PreToolUseResult(
201
+ decision=PermissionDecision.DENY,
202
+ reason="User denied permission",
203
+ )
204
+
205
+ # Save persistent allowance if "always"
206
+ if selected == "allow_always" and self.permission_store:
207
+ self.permission_store.set_permission(tool_name, always_allow=True)
208
+ logger.info(f"Saved persistent allow permission for {tool_name}")
209
+
210
+ logger.info(f"Permission granted for {tool_name}: {selected}")
211
+
212
+ except Exception as e:
213
+ logger.error(f"Failed to request permission: {e}")
214
+ # Continue without permission on error (fail open)
215
+
216
+ # Send tool_call start notification
217
+ try:
218
+ tool_call = ToolCallStart(
219
+ session_update="tool_call",
220
+ tool_call_id=tool_call_id,
221
+ title=f"Running {tool_name}",
222
+ kind=get_tool_kind(tool_name),
223
+ status="in_progress",
224
+ raw_input=tool_input,
225
+ )
226
+ await self.client.session_update(
227
+ session_id=self.session_id,
228
+ update=tool_call,
229
+ )
230
+ except Exception as e:
231
+ logger.error(f"Failed to send tool_call notification: {e}")
232
+
233
+ return PreToolUseResult(decision=PermissionDecision.ACCEPT)
234
+
235
+ async def post_tool_use(
236
+ self,
237
+ tool_name: str,
238
+ tool_input: Dict[str, Any],
239
+ tool_use_id: str,
240
+ result: Any,
241
+ error: Optional[Exception] = None,
242
+ ) -> PostToolUseResult:
243
+ """
244
+ Post-tool-use hook that sends ToolCallProgress notification.
245
+
246
+ Sends a session_update with ToolCallProgress to notify the ACP client
247
+ about the tool execution result.
248
+ """
249
+ # Get the tool call ID
250
+ tool_call_id = self._tool_call_ids.pop(tool_use_id, None)
251
+ if not tool_call_id:
252
+ logger.warning(f"No tool_call_id found for {tool_use_id}")
253
+ return PostToolUseResult()
254
+
255
+ # Determine status and format output
256
+ if error:
257
+ status = "failed"
258
+ output = str(error)
259
+ else:
260
+ status = "completed"
261
+ # Format result for display
262
+ if isinstance(result, str):
263
+ output = result
264
+ elif result is None:
265
+ output = "(no output)"
266
+ else:
267
+ try:
268
+ import json
269
+
270
+ output = json.dumps(result, indent=2, default=str)
271
+ except Exception:
272
+ output = str(result)
273
+
274
+ # Send tool_call progress notification
275
+ try:
276
+ update = ToolCallProgress(
277
+ session_update="tool_call_update",
278
+ tool_call_id=tool_call_id,
279
+ status=status,
280
+ raw_output=output,
281
+ )
282
+ await self.client.session_update(
283
+ session_id=self.session_id,
284
+ update=update,
285
+ )
286
+ except Exception as e:
287
+ logger.error(f"Failed to send tool_call_update notification: {e}")
288
+
289
+ return PostToolUseResult()
290
+
291
+
292
+ def create_acp_hooks(
293
+ client: Client,
294
+ session_id: str,
295
+ request_permission: bool = False,
296
+ include_dangerous_check: bool = True,
297
+ permission_store: Optional[PermissionStore] = None,
298
+ ) -> HookConfig:
299
+ """
300
+ Create HookConfig with ACP-specific hooks.
301
+
302
+ Args:
303
+ client: ACP Client instance
304
+ session_id: Current session ID
305
+ request_permission: Whether to request permission via ACP for tool calls
306
+ include_dangerous_check: Whether to include dangerous command blocking
307
+ permission_store: Optional persistent permission storage
308
+
309
+ Returns:
310
+ HookConfig configured for ACP integration
311
+ """
312
+ acp_hooks = ACPToolHooks(
313
+ client=client,
314
+ session_id=session_id,
315
+ request_permission=request_permission,
316
+ permission_store=permission_store,
317
+ )
318
+
319
+ # Create hook functions
320
+ async def acp_pre_tool_use(
321
+ tool_name: str, tool_input: Dict[str, Any], tool_use_id: str
322
+ ) -> PreToolUseResult:
323
+ return await acp_hooks.pre_tool_use(tool_name, tool_input, tool_use_id)
324
+
325
+ async def acp_post_tool_use(
326
+ tool_name: str,
327
+ tool_input: Dict[str, Any],
328
+ tool_use_id: str,
329
+ result: Any,
330
+ error: Optional[Exception] = None,
331
+ ) -> PostToolUseResult:
332
+ return await acp_hooks.post_tool_use(
333
+ tool_name, tool_input, tool_use_id, result, error
334
+ )
335
+
336
+ config = HookConfig()
337
+
338
+ # Add dangerous command check if requested
339
+ if include_dangerous_check:
340
+ from ..agents.hooks import create_dangerous_command_check_hook
341
+
342
+ config.add_pre_tool_use("bash", create_dangerous_command_check_hook())
343
+
344
+ # Add ACP hooks for all tools
345
+ config.add_pre_tool_use("*", acp_pre_tool_use)
346
+ config.add_post_tool_use("*", acp_post_tool_use)
347
+
348
+ return config
349
+
350
+
351
+ __all__ = [
352
+ "ACPToolHooks",
353
+ "create_acp_hooks",
354
+ ]
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ ACP server entry point for minion-code.
5
+
6
+ This module provides the main entry point for running minion-code
7
+ as an ACP agent over stdio.
8
+
9
+ Usage:
10
+ mcode acp
11
+ mcode acp --dangerously-skip-permissions
12
+ python -m minion_code.acp_server.main
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ # Save original stdout for ACP communication
24
+ _original_stdout = sys.stdout
25
+
26
+ # Configure loguru to use stderr BEFORE any imports that use it
27
+ # This is critical for ACP - stdout is reserved for JSON-RPC communication
28
+ from loguru import logger as loguru_logger
29
+
30
+ loguru_logger.remove() # Remove default handler
31
+ loguru_logger.add(
32
+ sys.stderr, format="{time} | {level} | {name}:{function}:{line} - {message}"
33
+ )
34
+
35
+ # Also redirect standard stdout to stderr for any stray prints
36
+ sys.stdout = sys.stderr
37
+
38
+ # Now import everything else
39
+ from acp import run_agent
40
+
41
+ from .agent import MinionACPAgent
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Config directory
46
+ MINION_CONFIG_DIR = Path.home() / ".minion"
47
+ MINION_CODE_CONFIG = MINION_CONFIG_DIR / "minion-code.json"
48
+
49
+
50
+ def ensure_config_dir() -> Path:
51
+ """Ensure ~/.minion directory exists."""
52
+ MINION_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
53
+ return MINION_CONFIG_DIR
54
+
55
+
56
+ def load_config() -> dict:
57
+ """Load minion-code config from ~/.minion/minion-code.json"""
58
+ if MINION_CODE_CONFIG.exists():
59
+ try:
60
+ with open(MINION_CODE_CONFIG, "r") as f:
61
+ return json.load(f)
62
+ except Exception as e:
63
+ logger.warning(f"Failed to load config: {e}")
64
+ return {}
65
+
66
+
67
+ def save_config(config: dict) -> None:
68
+ """Save minion-code config to ~/.minion/minion-code.json"""
69
+ ensure_config_dir()
70
+ try:
71
+ with open(MINION_CODE_CONFIG, "w") as f:
72
+ json.dump(config, f, indent=2)
73
+ except Exception as e:
74
+ logger.warning(f"Failed to save config: {e}")
75
+
76
+
77
+ def get_session_log_dir(cwd: str) -> Path:
78
+ """Get session log directory for a project."""
79
+ # Hash the cwd to create a unique folder name
80
+ import hashlib
81
+
82
+ cwd_hash = hashlib.md5(cwd.encode()).hexdigest()[:8]
83
+ project_name = Path(cwd).name
84
+ session_dir = MINION_CONFIG_DIR / "sessions" / f"{project_name}-{cwd_hash}"
85
+ session_dir.mkdir(parents=True, exist_ok=True)
86
+ return session_dir
87
+
88
+
89
+ def setup_logging(level: str = "INFO") -> None:
90
+ """Setup logging to stderr and file (stdout is used for ACP protocol)."""
91
+ # Log to stderr
92
+ logging.basicConfig(
93
+ level=getattr(logging, level.upper()),
94
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
95
+ stream=sys.stderr,
96
+ )
97
+ # Also log to file for debugging
98
+ debug_log = os.path.expanduser("~/minion-code-acp-debug.log")
99
+ file_handler = logging.FileHandler(debug_log, mode="a")
100
+ file_handler.setFormatter(
101
+ logging.Formatter(
102
+ "%(asctime)s - PID=%(process)d - %(name)s - %(levelname)s - %(message)s"
103
+ )
104
+ )
105
+ logging.getLogger().addHandler(file_handler)
106
+
107
+
108
+ def main(
109
+ log_level: str = "INFO",
110
+ dangerously_skip_permissions: bool = False,
111
+ cwd: Optional[str] = None,
112
+ model: Optional[str] = None,
113
+ ) -> None:
114
+ """
115
+ Main entry point for running minion-code as an ACP agent.
116
+
117
+ This function:
118
+ 1. Redirects stdout to stderr (stdout is reserved for ACP)
119
+ 2. Sets up logging
120
+ 3. Creates the MinionACPAgent
121
+ 4. Runs the ACP server over stdio
122
+
123
+ Args:
124
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
125
+ dangerously_skip_permissions: If True, skip permission prompts for tool calls
126
+ cwd: Working directory for the agent (defaults to current directory)
127
+ model: LLM model to use (defaults to config file setting)
128
+ """
129
+ setup_logging(log_level)
130
+ pid = os.getpid()
131
+ logger.info(f"Starting minion-code ACP agent [PID={pid}]")
132
+
133
+ # Resolve working directory
134
+ if cwd:
135
+ cwd = os.path.abspath(cwd)
136
+ logger.info(f"Using working directory: {cwd}")
137
+ else:
138
+ cwd = os.getcwd()
139
+
140
+ # Load config
141
+ config = load_config()
142
+
143
+ # Handle model: CLI arg > config file > default
144
+ if model:
145
+ logger.info(f"Using model from CLI: {model}")
146
+ elif config.get("model"):
147
+ model = config.get("model")
148
+ logger.info(f"Using model from config: {model}")
149
+ else:
150
+ logger.info("Using default model (from MinionCodeAgent)")
151
+
152
+ # Check if permissions should be skipped
153
+ skip_permissions = dangerously_skip_permissions or config.get(
154
+ "skip_permissions", False
155
+ )
156
+ if skip_permissions:
157
+ logger.warning("Permission prompts DISABLED (--dangerously-skip-permissions)")
158
+
159
+ # Create the agent with config
160
+ agent = MinionACPAgent(
161
+ skip_permissions=skip_permissions,
162
+ config=config,
163
+ cwd=cwd,
164
+ model=model,
165
+ )
166
+
167
+ # Restore stdout for ACP communication
168
+ sys.stdout = _original_stdout
169
+
170
+ # Run the ACP agent (run_agent is an async function)
171
+ try:
172
+ asyncio.run(run_agent(agent))
173
+ except KeyboardInterrupt:
174
+ logger.info("Shutting down ACP agent")
175
+ except Exception as e:
176
+ logger.error(f"ACP agent error: {e}")
177
+ raise
178
+
179
+
180
+ if __name__ == "__main__":
181
+ import argparse
182
+
183
+ parser = argparse.ArgumentParser(description="Minion Code ACP Agent")
184
+ parser.add_argument("--log-level", default="INFO", help="Log level")
185
+ parser.add_argument(
186
+ "--dangerously-skip-permissions",
187
+ action="store_true",
188
+ help="Skip permission prompts for tool calls",
189
+ )
190
+ args = parser.parse_args()
191
+ main(
192
+ log_level=args.log_level,
193
+ dangerously_skip_permissions=args.dangerously_skip_permissions,
194
+ )
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Permission store for persistent tool permissions.
5
+
6
+ Stores user's "always allow" and "always reject" preferences
7
+ per project in ~/.minion/sessions/<project>-<hash>/permissions.json
8
+ """
9
+
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Optional, Set, Dict
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class PermissionStore:
20
+ """
21
+ Manages persistent tool permission preferences per project.
22
+
23
+ Stores permissions in ~/.minion/sessions/<project-name>-<hash>/permissions.json
24
+ """
25
+
26
+ def __init__(self, cwd: str):
27
+ """
28
+ Initialize permission store for a specific project.
29
+
30
+ Args:
31
+ cwd: The working directory (project root) for this session
32
+ """
33
+ cwd_hash = hashlib.md5(cwd.encode()).hexdigest()[:8]
34
+ project_name = Path(cwd).name
35
+ self.config_dir = (
36
+ Path.home() / ".minion" / "sessions" / f"{project_name}-{cwd_hash}"
37
+ )
38
+ self.permissions_file = self.config_dir / "permissions.json"
39
+
40
+ # Permission sets
41
+ self._allow_always: Set[str] = set()
42
+ self._reject_always: Set[str] = set()
43
+
44
+ # Load existing permissions
45
+ self._load()
46
+
47
+ def is_allowed(self, tool_name: str) -> Optional[bool]:
48
+ """
49
+ Check if tool has persistent permission.
50
+
51
+ Args:
52
+ tool_name: Name of the tool to check
53
+
54
+ Returns:
55
+ True if always allowed, False if always rejected, None if not set
56
+ """
57
+ if tool_name in self._allow_always:
58
+ return True
59
+ if tool_name in self._reject_always:
60
+ return False
61
+ return None
62
+
63
+ def set_permission(self, tool_name: str, always_allow: bool) -> None:
64
+ """
65
+ Set persistent permission for a tool.
66
+
67
+ Args:
68
+ tool_name: Name of the tool
69
+ always_allow: True to always allow, False to always reject
70
+ """
71
+ if always_allow:
72
+ self._allow_always.add(tool_name)
73
+ self._reject_always.discard(tool_name)
74
+ logger.info(f"Set permission: always allow '{tool_name}'")
75
+ else:
76
+ self._reject_always.add(tool_name)
77
+ self._allow_always.discard(tool_name)
78
+ logger.info(f"Set permission: always reject '{tool_name}'")
79
+
80
+ self._save()
81
+
82
+ def clear_permission(self, tool_name: str) -> None:
83
+ """
84
+ Clear permission for a tool (reset to ask every time).
85
+
86
+ Args:
87
+ tool_name: Name of the tool
88
+ """
89
+ self._allow_always.discard(tool_name)
90
+ self._reject_always.discard(tool_name)
91
+ self._save()
92
+ logger.info(f"Cleared permission for '{tool_name}'")
93
+
94
+ def clear_all(self) -> None:
95
+ """Clear all permissions."""
96
+ self._allow_always.clear()
97
+ self._reject_always.clear()
98
+ self._save()
99
+ logger.info("Cleared all permissions")
100
+
101
+ def get_all(self) -> Dict[str, list]:
102
+ """Get all permissions as a dict."""
103
+ return {
104
+ "allow_always": sorted(self._allow_always),
105
+ "reject_always": sorted(self._reject_always),
106
+ }
107
+
108
+ def _load(self) -> None:
109
+ """Load permissions from file."""
110
+ if not self.permissions_file.exists():
111
+ return
112
+
113
+ try:
114
+ with open(self.permissions_file, "r") as f:
115
+ data = json.load(f)
116
+
117
+ self._allow_always = set(data.get("allow_always", []))
118
+ self._reject_always = set(data.get("reject_always", []))
119
+ logger.debug(f"Loaded permissions from {self.permissions_file}")
120
+ except Exception as e:
121
+ logger.warning(f"Failed to load permissions: {e}")
122
+
123
+ def _save(self) -> None:
124
+ """Save permissions to file."""
125
+ try:
126
+ # Ensure directory exists
127
+ self.config_dir.mkdir(parents=True, exist_ok=True)
128
+
129
+ data = {
130
+ "allow_always": sorted(self._allow_always),
131
+ "reject_always": sorted(self._reject_always),
132
+ }
133
+
134
+ with open(self.permissions_file, "w") as f:
135
+ json.dump(data, f, indent=2)
136
+
137
+ logger.debug(f"Saved permissions to {self.permissions_file}")
138
+ except Exception as e:
139
+ logger.error(f"Failed to save permissions: {e}")
140
+
141
+
142
+ __all__ = ["PermissionStore"]