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.
- examples/cli_entrypoint.py +60 -0
- examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
- examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
- examples/components/messages_component.py +199 -0
- examples/file_freshness_example.py +22 -22
- examples/file_watching_example.py +32 -26
- examples/interruptible_tui.py +921 -3
- examples/repl_tui.py +129 -0
- examples/skills/example_usage.py +57 -0
- examples/start.py +173 -0
- minion_code/__init__.py +1 -1
- minion_code/acp_server/__init__.py +34 -0
- minion_code/acp_server/agent.py +539 -0
- minion_code/acp_server/hooks.py +354 -0
- minion_code/acp_server/main.py +194 -0
- minion_code/acp_server/permissions.py +142 -0
- minion_code/acp_server/test_client.py +104 -0
- minion_code/adapters/__init__.py +22 -0
- minion_code/adapters/output_adapter.py +207 -0
- minion_code/adapters/rich_adapter.py +169 -0
- minion_code/adapters/textual_adapter.py +254 -0
- minion_code/agents/__init__.py +2 -2
- minion_code/agents/code_agent.py +517 -104
- minion_code/agents/hooks.py +378 -0
- minion_code/cli.py +538 -429
- minion_code/cli_simple.py +665 -0
- minion_code/commands/__init__.py +136 -29
- minion_code/commands/clear_command.py +19 -46
- minion_code/commands/help_command.py +33 -49
- minion_code/commands/history_command.py +37 -55
- minion_code/commands/model_command.py +194 -0
- minion_code/commands/quit_command.py +9 -12
- minion_code/commands/resume_command.py +181 -0
- minion_code/commands/skill_command.py +89 -0
- minion_code/commands/status_command.py +48 -73
- minion_code/commands/tools_command.py +54 -52
- minion_code/commands/version_command.py +34 -69
- minion_code/components/ConfirmDialog.py +430 -0
- minion_code/components/Message.py +318 -97
- minion_code/components/MessageResponse.py +30 -29
- minion_code/components/Messages.py +351 -0
- minion_code/components/PromptInput.py +499 -245
- minion_code/components/__init__.py +24 -17
- minion_code/const.py +7 -0
- minion_code/screens/REPL.py +1453 -469
- minion_code/screens/__init__.py +1 -1
- minion_code/services/__init__.py +20 -20
- minion_code/services/event_system.py +19 -14
- minion_code/services/file_freshness_service.py +223 -170
- minion_code/skills/__init__.py +25 -0
- minion_code/skills/skill.py +128 -0
- minion_code/skills/skill_loader.py +198 -0
- minion_code/skills/skill_registry.py +177 -0
- minion_code/subagents/__init__.py +31 -0
- minion_code/subagents/builtin/__init__.py +30 -0
- minion_code/subagents/builtin/claude_code_guide.py +32 -0
- minion_code/subagents/builtin/explore.py +36 -0
- minion_code/subagents/builtin/general_purpose.py +19 -0
- minion_code/subagents/builtin/plan.py +61 -0
- minion_code/subagents/subagent.py +116 -0
- minion_code/subagents/subagent_loader.py +147 -0
- minion_code/subagents/subagent_registry.py +151 -0
- minion_code/tools/__init__.py +8 -2
- minion_code/tools/bash_tool.py +16 -3
- minion_code/tools/file_edit_tool.py +201 -104
- minion_code/tools/file_read_tool.py +183 -26
- minion_code/tools/file_write_tool.py +17 -3
- minion_code/tools/glob_tool.py +23 -2
- minion_code/tools/grep_tool.py +229 -21
- minion_code/tools/ls_tool.py +28 -3
- minion_code/tools/multi_edit_tool.py +89 -84
- minion_code/tools/python_interpreter_tool.py +9 -1
- minion_code/tools/skill_tool.py +210 -0
- minion_code/tools/task_tool.py +287 -0
- minion_code/tools/todo_read_tool.py +28 -24
- minion_code/tools/todo_write_tool.py +82 -65
- minion_code/{types.py → type_defs.py} +15 -2
- minion_code/utils/__init__.py +45 -17
- minion_code/utils/config.py +610 -0
- minion_code/utils/history.py +114 -0
- minion_code/utils/logs.py +53 -0
- minion_code/utils/mcp_loader.py +153 -55
- minion_code/utils/output_truncator.py +233 -0
- minion_code/utils/session_storage.py +369 -0
- minion_code/utils/todo_file_utils.py +26 -22
- minion_code/utils/todo_storage.py +43 -33
- minion_code/web/__init__.py +9 -0
- minion_code/web/adapters/__init__.py +5 -0
- minion_code/web/adapters/web_adapter.py +524 -0
- minion_code/web/api/__init__.py +7 -0
- minion_code/web/api/chat.py +277 -0
- minion_code/web/api/interactions.py +136 -0
- minion_code/web/api/sessions.py +135 -0
- minion_code/web/server.py +149 -0
- minion_code/web/services/__init__.py +5 -0
- minion_code/web/services/session_manager.py +420 -0
- minion_code-0.1.2.dist-info/METADATA +476 -0
- minion_code-0.1.2.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
- minion_code-0.1.2.dist-info/entry_points.txt +6 -0
- tests/test_adapter.py +67 -0
- tests/test_adapter_simple.py +79 -0
- tests/test_file_read_tool.py +144 -0
- tests/test_readonly_tools.py +0 -2
- tests/test_skills.py +441 -0
- examples/advance_tui.py +0 -508
- examples/rich_example.py +0 -4
- examples/simple_file_watching.py +0 -57
- examples/simple_tui.py +0 -267
- examples/simple_usage.py +0 -69
- minion_code-0.1.0.dist-info/METADATA +0 -350
- minion_code-0.1.0.dist-info/RECORD +0 -59
- minion_code-0.1.0.dist-info/entry_points.txt +0 -4
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.2.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"]
|