kivi-cli 2.0.6__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.
- kivi_cli-2.0.6.dist-info/METADATA +17 -0
- kivi_cli-2.0.6.dist-info/RECORD +47 -0
- kivi_cli-2.0.6.dist-info/WHEEL +5 -0
- kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
- kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
- kiviai/__init__.py +0 -0
- kiviai/backend.py +1372 -0
- kiviai/index.html +6172 -0
- kiviai/services/__init__.py +0 -0
- kiviai/services/git_service.py +219 -0
- kiviai/services/monitor.py +97 -0
- kiviai/services/providers/__init__.py +0 -0
- kiviai/services/providers/base.py +131 -0
- kiviai/services/providers/claude.py +125 -0
- kiviai/services/providers/copilot.py +251 -0
- kiviai/services/providers/inhouse.py +130 -0
- kiviai/services/providers/kivi.py +108 -0
- kiviai/services/providers/model_costs.py +137 -0
- kiviai/services/providers/openai_agent.py +113 -0
- kiviai/services/providers/opencode.py +157 -0
- kiviai/services/providers/orchestrator_provider.py +163 -0
- kiviai/services/providers/registry.py +89 -0
- kiviai/services/providers/vllm.py +319 -0
- kiviai/services/scheduler.py +244 -0
- kiviai/services/tools/__init__.py +1 -0
- kiviai/services/tools/copilot_tools/__init__.py +92 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
- kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
- kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
- kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
- kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
- kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
- kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
- kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
- kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
- kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
- kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
- kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
- kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
- kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
- kiviai/services/tools/copilot_tools/web/_store.py +122 -0
- kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
- kiviai/services/tools/copilot_tools/web/search.py +116 -0
- kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
- kiviai/services/tools/tool_manager.py +239 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent orchestration: launch_task, read_agent, list_agents, execute_skill.
|
|
3
|
+
|
|
4
|
+
Implements a hierarchical manager-worker pattern where a main agent delegates
|
|
5
|
+
tasks to isolated sub-agents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Enums & constants
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
class AgentType(str, Enum):
|
|
23
|
+
EXPLORE = "explore"
|
|
24
|
+
TASK = "task"
|
|
25
|
+
GENERAL_PURPOSE = "general-purpose"
|
|
26
|
+
CODE_REVIEW = "code-review"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentStatus(str, Enum):
|
|
30
|
+
RUNNING = "running"
|
|
31
|
+
IDLE = "idle"
|
|
32
|
+
COMPLETED = "completed"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
CANCELLED = "cancelled"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_MODELS: dict[AgentType, str] = {
|
|
38
|
+
AgentType.EXPLORE: "claude-haiku-4.5",
|
|
39
|
+
AgentType.TASK: "claude-haiku-4.5",
|
|
40
|
+
AgentType.GENERAL_PURPOSE: "claude-sonnet-4.6",
|
|
41
|
+
AgentType.CODE_REVIEW: "claude-sonnet-4.6",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Agent data structures
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Turn:
|
|
51
|
+
turn_index: int
|
|
52
|
+
role: str
|
|
53
|
+
content: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Agent:
|
|
58
|
+
agent_id: str
|
|
59
|
+
name: str
|
|
60
|
+
agent_type: AgentType
|
|
61
|
+
description: str
|
|
62
|
+
model: str
|
|
63
|
+
prompt: str
|
|
64
|
+
status: AgentStatus = AgentStatus.RUNNING
|
|
65
|
+
turns: list[Turn] = field(default_factory=list)
|
|
66
|
+
_thread: Optional[threading.Thread] = field(default=None, repr=False)
|
|
67
|
+
_handler: Optional[Callable[..., str]] = field(default=None, repr=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# AgentManager — singleton registry
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
class AgentManager:
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._agents: dict[str, Agent] = {}
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
|
|
79
|
+
def register(self, agent: Agent) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._agents[agent.agent_id] = agent
|
|
82
|
+
|
|
83
|
+
def get(self, agent_id: str) -> Optional[Agent]:
|
|
84
|
+
return self._agents.get(agent_id)
|
|
85
|
+
|
|
86
|
+
def all_agents(self, *, include_completed: bool = True) -> list[Agent]:
|
|
87
|
+
agents = list(self._agents.values())
|
|
88
|
+
if not include_completed:
|
|
89
|
+
agents = [
|
|
90
|
+
a for a in agents
|
|
91
|
+
if a.status in (AgentStatus.RUNNING, AgentStatus.IDLE)
|
|
92
|
+
]
|
|
93
|
+
return agents
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_manager = AgentManager()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Default handler (stub — replace with real LLM call in production)
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _default_handler(agent: Agent) -> None:
|
|
104
|
+
"""Placeholder that simulates agent work. Override in production."""
|
|
105
|
+
agent.turns.append(
|
|
106
|
+
Turn(
|
|
107
|
+
turn_index=0,
|
|
108
|
+
role="assistant",
|
|
109
|
+
content=(
|
|
110
|
+
f"[stub] Agent '{agent.name}' ({agent.agent_type.value}) "
|
|
111
|
+
f"received prompt: {agent.prompt[:200]}..."
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
agent.status = AgentStatus.COMPLETED
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Public API
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def launch_task(
|
|
123
|
+
name: str,
|
|
124
|
+
prompt: str,
|
|
125
|
+
agent_type: str,
|
|
126
|
+
description: str,
|
|
127
|
+
*,
|
|
128
|
+
model: Optional[str] = None,
|
|
129
|
+
mode: str = "background",
|
|
130
|
+
handler: Optional[Callable[..., None]] = None,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Launch a specialised sub-agent.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
name:
|
|
137
|
+
Short human-readable name (e.g. ``"auth-flow"``).
|
|
138
|
+
prompt:
|
|
139
|
+
Complete task description with all context.
|
|
140
|
+
agent_type:
|
|
141
|
+
``"explore"`` | ``"task"`` | ``"general-purpose"`` | ``"code-review"``.
|
|
142
|
+
description:
|
|
143
|
+
3-5 word UI summary.
|
|
144
|
+
model:
|
|
145
|
+
Override the default model for the agent type.
|
|
146
|
+
mode:
|
|
147
|
+
``"background"`` (default) or ``"sync"``.
|
|
148
|
+
handler:
|
|
149
|
+
Custom callable ``(Agent) -> None``. Defaults to a stub.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
dict with ``agent_id`` and ``status``.
|
|
154
|
+
"""
|
|
155
|
+
atype = AgentType(agent_type)
|
|
156
|
+
agent_id = f"{name}-{uuid.uuid4().hex[:4]}"
|
|
157
|
+
|
|
158
|
+
agent = Agent(
|
|
159
|
+
agent_id=agent_id,
|
|
160
|
+
name=name,
|
|
161
|
+
agent_type=atype,
|
|
162
|
+
description=description,
|
|
163
|
+
model=model or DEFAULT_MODELS[atype],
|
|
164
|
+
prompt=prompt,
|
|
165
|
+
)
|
|
166
|
+
_manager.register(agent)
|
|
167
|
+
|
|
168
|
+
work_fn = handler or _default_handler
|
|
169
|
+
|
|
170
|
+
if mode == "sync":
|
|
171
|
+
try:
|
|
172
|
+
work_fn(agent)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
agent.status = AgentStatus.FAILED
|
|
175
|
+
agent.turns.append(
|
|
176
|
+
Turn(turn_index=len(agent.turns), role="error", content=str(exc))
|
|
177
|
+
)
|
|
178
|
+
return {"agent_id": agent_id, "status": agent.status.value}
|
|
179
|
+
|
|
180
|
+
# background
|
|
181
|
+
def _run() -> None:
|
|
182
|
+
try:
|
|
183
|
+
work_fn(agent)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
agent.status = AgentStatus.FAILED
|
|
186
|
+
agent.turns.append(
|
|
187
|
+
Turn(turn_index=len(agent.turns), role="error", content=str(exc))
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
t = threading.Thread(target=_run, daemon=True)
|
|
191
|
+
agent._thread = t
|
|
192
|
+
t.start()
|
|
193
|
+
|
|
194
|
+
return {"agent_id": agent_id, "status": "running"}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_agent(
|
|
198
|
+
agent_id: str,
|
|
199
|
+
*,
|
|
200
|
+
wait: bool = False,
|
|
201
|
+
timeout: float = 30,
|
|
202
|
+
since_turn: Optional[int] = None,
|
|
203
|
+
) -> dict:
|
|
204
|
+
"""Read status and results from a background agent.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
agent_id:
|
|
209
|
+
ID returned by :func:`launch_task`.
|
|
210
|
+
wait:
|
|
211
|
+
Block until the agent completes.
|
|
212
|
+
timeout:
|
|
213
|
+
Max seconds to wait (only with ``wait=True``). Max 180.
|
|
214
|
+
since_turn:
|
|
215
|
+
Only return turns after this index (exclusive).
|
|
216
|
+
"""
|
|
217
|
+
agent = _manager.get(agent_id)
|
|
218
|
+
if agent is None:
|
|
219
|
+
raise KeyError(f"No agent with id={agent_id!r}")
|
|
220
|
+
|
|
221
|
+
timeout = min(timeout, 180)
|
|
222
|
+
|
|
223
|
+
if wait and agent._thread is not None:
|
|
224
|
+
agent._thread.join(timeout=timeout)
|
|
225
|
+
|
|
226
|
+
turns = agent.turns
|
|
227
|
+
if since_turn is not None:
|
|
228
|
+
turns = [t for t in turns if t.turn_index > since_turn]
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"agent_id": agent.agent_id,
|
|
232
|
+
"status": agent.status.value,
|
|
233
|
+
"turns": [
|
|
234
|
+
{"turn_index": t.turn_index, "role": t.role, "content": t.content}
|
|
235
|
+
for t in turns
|
|
236
|
+
],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def list_agents(*, include_completed: bool = True) -> list[dict]:
|
|
241
|
+
"""List all agents.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
include_completed:
|
|
246
|
+
When ``False``, only return ``running`` and ``idle`` agents.
|
|
247
|
+
"""
|
|
248
|
+
return [
|
|
249
|
+
{
|
|
250
|
+
"agent_id": a.agent_id,
|
|
251
|
+
"name": a.name,
|
|
252
|
+
"agent_type": a.agent_type.value,
|
|
253
|
+
"status": a.status.value,
|
|
254
|
+
"description": a.description,
|
|
255
|
+
}
|
|
256
|
+
for a in _manager.all_agents(include_completed=include_completed)
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# Skills
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
# Skill registry — register custom skills with ``register_skill(name, fn)``
|
|
265
|
+
_skill_registry: dict[str, Callable[..., Any]] = {}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def register_skill(name: str, fn: Callable[..., Any]) -> None:
|
|
269
|
+
"""Register a skill handler for use with :func:`execute_skill`."""
|
|
270
|
+
_skill_registry[name] = fn
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def execute_skill(skill: str) -> Any:
|
|
274
|
+
"""Execute a named skill in the main conversation context.
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
skill:
|
|
279
|
+
Skill name (e.g. ``"pdf"``, ``"xlsx"``, ``"customize-cloud-agent"``).
|
|
280
|
+
|
|
281
|
+
Returns
|
|
282
|
+
-------
|
|
283
|
+
Whatever the skill handler returns.
|
|
284
|
+
|
|
285
|
+
Raises
|
|
286
|
+
------
|
|
287
|
+
KeyError
|
|
288
|
+
If the skill is not registered.
|
|
289
|
+
"""
|
|
290
|
+
fn = _skill_registry.get(skill)
|
|
291
|
+
if fn is None:
|
|
292
|
+
raise KeyError(
|
|
293
|
+
f"Skill {skill!r} is not registered. "
|
|
294
|
+
f"Available: {list(_skill_registry.keys())}"
|
|
295
|
+
)
|
|
296
|
+
return fn()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bash Execution Tools — interactive shell session management.
|
|
3
|
+
|
|
4
|
+
Provides five tools that give a full interactive shell:
|
|
5
|
+
bash — Run a command (sync or async), create/reuse sessions
|
|
6
|
+
write_bash — Send input (text + key sequences) to a running session
|
|
7
|
+
read_bash — Read accumulated output from a session
|
|
8
|
+
stop_bash — Terminate a session and clean up resources
|
|
9
|
+
list_bash — List all active sessions
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from copilot_tools.bash_execution.sessions import (
|
|
13
|
+
bash,
|
|
14
|
+
write_bash,
|
|
15
|
+
read_bash,
|
|
16
|
+
stop_bash,
|
|
17
|
+
list_bash,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["bash", "write_bash", "read_bash", "stop_bash", "list_bash"]
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session-oriented PTY shell management.
|
|
3
|
+
|
|
4
|
+
Each session owns a pseudo-terminal (PTY), a PID, and persistent env vars.
|
|
5
|
+
Sessions are identified by a string ``shellId`` and managed through a global
|
|
6
|
+
``SessionManager``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Constants & key-map
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
class Mode(str, Enum):
|
|
27
|
+
SYNC = "sync"
|
|
28
|
+
ASYNC = "async"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
KEY_MAP: dict[str, str] = {
|
|
32
|
+
"{up}": "\x1b[A",
|
|
33
|
+
"{down}": "\x1b[B",
|
|
34
|
+
"{right}": "\x1b[C",
|
|
35
|
+
"{left}": "\x1b[D",
|
|
36
|
+
"{enter}": "\n",
|
|
37
|
+
"{backspace}": "\x7f",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
DEFAULT_INITIAL_WAIT = 30
|
|
41
|
+
MIN_INITIAL_WAIT = 30
|
|
42
|
+
MAX_INITIAL_WAIT = 600
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Session dataclass
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Session:
|
|
51
|
+
shell_id: str
|
|
52
|
+
command: str
|
|
53
|
+
mode: Mode
|
|
54
|
+
pid: Optional[int] = None
|
|
55
|
+
process: Optional[subprocess.Popen] = None
|
|
56
|
+
detach: bool = False
|
|
57
|
+
_output_buffer: str = ""
|
|
58
|
+
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
59
|
+
|
|
60
|
+
# -- output helpers -----------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def append_output(self, text: str) -> None:
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._output_buffer += text
|
|
65
|
+
|
|
66
|
+
def read_output(self) -> str:
|
|
67
|
+
with self._lock:
|
|
68
|
+
buf = self._output_buffer
|
|
69
|
+
self._output_buffer = ""
|
|
70
|
+
return buf
|
|
71
|
+
|
|
72
|
+
def full_output(self) -> str:
|
|
73
|
+
with self._lock:
|
|
74
|
+
return self._output_buffer
|
|
75
|
+
|
|
76
|
+
# -- status helpers -----------------------------------------------------
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_running(self) -> bool:
|
|
80
|
+
if self.process is None:
|
|
81
|
+
return False
|
|
82
|
+
return self.process.poll() is None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def exit_code(self) -> Optional[int]:
|
|
86
|
+
if self.process is None:
|
|
87
|
+
return None
|
|
88
|
+
return self.process.poll()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def has_unread_output(self) -> bool:
|
|
92
|
+
with self._lock:
|
|
93
|
+
return len(self._output_buffer) > 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# SessionManager — singleton pool of sessions
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
class SessionManager:
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._sessions: dict[str, Session] = {}
|
|
103
|
+
self._lock = threading.Lock()
|
|
104
|
+
|
|
105
|
+
def get_or_create(self, shell_id: Optional[str], mode: Mode) -> Session:
|
|
106
|
+
if shell_id and shell_id in self._sessions:
|
|
107
|
+
return self._sessions[shell_id]
|
|
108
|
+
sid = shell_id or f"session-{uuid.uuid4().hex[:8]}"
|
|
109
|
+
session = Session(shell_id=sid, command="", mode=mode)
|
|
110
|
+
with self._lock:
|
|
111
|
+
self._sessions[sid] = session
|
|
112
|
+
return session
|
|
113
|
+
|
|
114
|
+
def get(self, shell_id: str) -> Optional[Session]:
|
|
115
|
+
return self._sessions.get(shell_id)
|
|
116
|
+
|
|
117
|
+
def remove(self, shell_id: str) -> None:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._sessions.pop(shell_id, None)
|
|
120
|
+
|
|
121
|
+
def all_sessions(self) -> list[Session]:
|
|
122
|
+
return list(self._sessions.values())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_manager = SessionManager()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Internal helpers
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _translate_keys(raw_input: str) -> str:
|
|
133
|
+
"""Replace ``{enter}``, ``{up}`` etc. with ANSI escape sequences."""
|
|
134
|
+
result = raw_input
|
|
135
|
+
for token, escape in KEY_MAP.items():
|
|
136
|
+
result = result.replace(token, escape)
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _collect_output(session: Session, timeout: float) -> str:
|
|
141
|
+
"""Block until the process finishes *or* ``timeout`` expires, collecting stdout/stderr."""
|
|
142
|
+
if session.process is None:
|
|
143
|
+
return ""
|
|
144
|
+
try:
|
|
145
|
+
stdout, _ = session.process.communicate(timeout=timeout)
|
|
146
|
+
session.append_output(stdout)
|
|
147
|
+
except subprocess.TimeoutExpired:
|
|
148
|
+
pass
|
|
149
|
+
return session.full_output()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _start_process(command: str, detach: bool) -> subprocess.Popen:
|
|
153
|
+
kwargs: dict = dict(
|
|
154
|
+
shell=True,
|
|
155
|
+
executable="/bin/bash",
|
|
156
|
+
stdout=subprocess.PIPE,
|
|
157
|
+
stderr=subprocess.STDOUT,
|
|
158
|
+
text=True,
|
|
159
|
+
)
|
|
160
|
+
if detach:
|
|
161
|
+
kwargs["start_new_session"] = True
|
|
162
|
+
return subprocess.Popen(command, **kwargs)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Public API
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def bash(
|
|
170
|
+
command: str,
|
|
171
|
+
description: str,
|
|
172
|
+
*,
|
|
173
|
+
mode: str = "sync",
|
|
174
|
+
initial_wait: int = DEFAULT_INITIAL_WAIT,
|
|
175
|
+
shell_id: Optional[str] = None,
|
|
176
|
+
detach: bool = False,
|
|
177
|
+
) -> dict:
|
|
178
|
+
"""Run a Bash command in an interactive session.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
command:
|
|
183
|
+
The shell command to run.
|
|
184
|
+
description:
|
|
185
|
+
Human-readable description (max 100 chars), displayed in the UI.
|
|
186
|
+
mode:
|
|
187
|
+
``"sync"`` (default) — wait for output; ``"async"`` — run in background.
|
|
188
|
+
initial_wait:
|
|
189
|
+
Seconds (30-600) to wait for initial output in sync mode.
|
|
190
|
+
shell_id:
|
|
191
|
+
Reuse an existing session. Auto-generated when ``None``.
|
|
192
|
+
detach:
|
|
193
|
+
Async-only. If ``True`` the process survives agent shutdown.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
dict with keys: ``output``, ``exitCode``, ``shellId``, ``status``.
|
|
198
|
+
"""
|
|
199
|
+
initial_wait = max(MIN_INITIAL_WAIT, min(initial_wait, MAX_INITIAL_WAIT))
|
|
200
|
+
run_mode = Mode(mode)
|
|
201
|
+
|
|
202
|
+
session = _manager.get_or_create(shell_id, run_mode)
|
|
203
|
+
session.command = command
|
|
204
|
+
session.mode = run_mode
|
|
205
|
+
session.detach = detach
|
|
206
|
+
|
|
207
|
+
proc = _start_process(command, detach=(run_mode == Mode.ASYNC and detach))
|
|
208
|
+
session.process = proc
|
|
209
|
+
session.pid = proc.pid
|
|
210
|
+
|
|
211
|
+
if run_mode == Mode.SYNC:
|
|
212
|
+
output = _collect_output(session, timeout=initial_wait)
|
|
213
|
+
|
|
214
|
+
if session.is_running:
|
|
215
|
+
return {
|
|
216
|
+
"output": output,
|
|
217
|
+
"exitCode": None,
|
|
218
|
+
"shellId": session.shell_id,
|
|
219
|
+
"status": "running",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"output": session.full_output(),
|
|
224
|
+
"exitCode": session.exit_code,
|
|
225
|
+
"shellId": session.shell_id,
|
|
226
|
+
"status": "completed",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# async — return immediately
|
|
230
|
+
return {
|
|
231
|
+
"output": "",
|
|
232
|
+
"shellId": session.shell_id,
|
|
233
|
+
"status": "running",
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def write_bash(
|
|
238
|
+
shell_id: str,
|
|
239
|
+
*,
|
|
240
|
+
input_text: Optional[str] = None,
|
|
241
|
+
delay: float = 10,
|
|
242
|
+
) -> dict:
|
|
243
|
+
"""Send input to a running session, then read output after *delay* seconds.
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
shell_id:
|
|
248
|
+
Session to write to.
|
|
249
|
+
input_text:
|
|
250
|
+
Text / key tokens (``{enter}``, ``{up}``, …) to send.
|
|
251
|
+
delay:
|
|
252
|
+
Seconds to wait before reading the output buffer.
|
|
253
|
+
"""
|
|
254
|
+
session = _manager.get(shell_id)
|
|
255
|
+
if session is None:
|
|
256
|
+
raise KeyError(f"No session with shellId={shell_id!r}")
|
|
257
|
+
|
|
258
|
+
if input_text and session.process and session.process.stdin:
|
|
259
|
+
translated = _translate_keys(input_text)
|
|
260
|
+
session.process.stdin.write(translated)
|
|
261
|
+
session.process.stdin.flush()
|
|
262
|
+
|
|
263
|
+
time.sleep(delay)
|
|
264
|
+
return {"output": session.read_output()}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def read_bash(shell_id: str, *, delay: float = 5) -> dict:
|
|
268
|
+
"""Read accumulated output from a running session.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
shell_id:
|
|
273
|
+
Session to read from.
|
|
274
|
+
delay:
|
|
275
|
+
Seconds to wait before reading.
|
|
276
|
+
"""
|
|
277
|
+
session = _manager.get(shell_id)
|
|
278
|
+
if session is None:
|
|
279
|
+
raise KeyError(f"No session with shellId={shell_id!r}")
|
|
280
|
+
|
|
281
|
+
time.sleep(delay)
|
|
282
|
+
|
|
283
|
+
if session.process and session.is_running:
|
|
284
|
+
try:
|
|
285
|
+
stdout, _ = session.process.communicate(timeout=0.5)
|
|
286
|
+
session.append_output(stdout)
|
|
287
|
+
except subprocess.TimeoutExpired:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
return {"output": session.read_output()}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def stop_bash(shell_id: str) -> dict:
|
|
294
|
+
"""Terminate a Bash session and clean up resources.
|
|
295
|
+
|
|
296
|
+
Sends SIGTERM → grace period → SIGKILL if needed.
|
|
297
|
+
"""
|
|
298
|
+
session = _manager.get(shell_id)
|
|
299
|
+
if session is None:
|
|
300
|
+
raise KeyError(f"No session with shellId={shell_id!r}")
|
|
301
|
+
|
|
302
|
+
if session.process and session.is_running:
|
|
303
|
+
pid = session.process.pid
|
|
304
|
+
try:
|
|
305
|
+
pgid = os.getpgid(pid)
|
|
306
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
307
|
+
except (ProcessLookupError, PermissionError):
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
session.process.wait(timeout=5)
|
|
312
|
+
except subprocess.TimeoutExpired:
|
|
313
|
+
try:
|
|
314
|
+
pgid = os.getpgid(pid)
|
|
315
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
316
|
+
except (ProcessLookupError, PermissionError):
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
_manager.remove(shell_id)
|
|
320
|
+
return {"status": "terminated", "shellId": shell_id}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def list_bash() -> list[dict]:
|
|
324
|
+
"""Return info about all active sessions."""
|
|
325
|
+
return [
|
|
326
|
+
{
|
|
327
|
+
"shellId": s.shell_id,
|
|
328
|
+
"command": s.command,
|
|
329
|
+
"mode": s.mode.value,
|
|
330
|
+
"pid": s.pid,
|
|
331
|
+
"status": "running" if s.is_running else "completed",
|
|
332
|
+
"unreadOutput": s.has_unread_output,
|
|
333
|
+
}
|
|
334
|
+
for s in _manager.all_sessions()
|
|
335
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code Search Tools — fast content and filename searching.
|
|
3
|
+
|
|
4
|
+
grep — Search file *contents* via regex (wraps ripgrep)
|
|
5
|
+
glob_search — Search file *names* via glob patterns
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from copilot_tools.code_search.tools import grep, glob_search
|
|
9
|
+
|
|
10
|
+
__all__ = ["grep", "glob_search"]
|