local-coding-agent 1.1.0__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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Task envelope shared by the controller and bounded repository tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class TaskEnvelope:
|
|
12
|
+
id: str
|
|
13
|
+
goal: str
|
|
14
|
+
files: tuple[str, ...]
|
|
15
|
+
context: str = ""
|
|
16
|
+
constraints: tuple[str, ...] = ()
|
|
17
|
+
checks: tuple[str, ...] = ()
|
|
18
|
+
acceptance: tuple[str, ...] = ()
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if not isinstance(self.id, str) or not self.id.strip():
|
|
22
|
+
raise ValueError("task field 'id' must be a non-empty string")
|
|
23
|
+
if not isinstance(self.goal, str) or not self.goal.strip():
|
|
24
|
+
raise ValueError("task field 'goal' must be a non-empty string")
|
|
25
|
+
if isinstance(self.files, str) or not isinstance(self.files, (tuple, list)) or not self.files:
|
|
26
|
+
raise ValueError("task field 'files' must not be empty")
|
|
27
|
+
for raw_path in self.files:
|
|
28
|
+
if not isinstance(raw_path, str) or not raw_path or "\x00" in raw_path:
|
|
29
|
+
raise ValueError("task files must contain valid relative paths")
|
|
30
|
+
if (
|
|
31
|
+
raw_path.startswith("/")
|
|
32
|
+
or raw_path.startswith("\\")
|
|
33
|
+
or (len(raw_path) >= 2 and raw_path[1] == ":" and raw_path[0].isalpha())
|
|
34
|
+
):
|
|
35
|
+
raise ValueError(f"task file must be relative and inside workspace: {raw_path!r}")
|
|
36
|
+
path = Path(raw_path)
|
|
37
|
+
if path.is_absolute() or path.drive or path.root or ".." in path.parts:
|
|
38
|
+
raise ValueError(f"task file must be relative and inside workspace: {raw_path!r}")
|
|
39
|
+
|
|
40
|
+
# A frozen dataclass does not make an incoming list immutable. Keep the
|
|
41
|
+
# envelope stable after validation so service-level fingerprints and the
|
|
42
|
+
# controller always observe the same allowlist and checks.
|
|
43
|
+
object.__setattr__(self, "files", tuple(self.files))
|
|
44
|
+
object.__setattr__(self, "constraints", tuple(self.constraints))
|
|
45
|
+
object.__setattr__(self, "checks", tuple(self.checks))
|
|
46
|
+
object.__setattr__(self, "acceptance", tuple(self.acceptance))
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "TaskEnvelope":
|
|
50
|
+
def strings(name: str) -> tuple[str, ...]:
|
|
51
|
+
raw = value.get(name, ())
|
|
52
|
+
if isinstance(raw, str) or not isinstance(raw, (list, tuple)):
|
|
53
|
+
raise ValueError(f"task field '{name}' must be a list of strings")
|
|
54
|
+
if not all(isinstance(item, str) and item for item in raw):
|
|
55
|
+
raise ValueError(f"task field '{name}' must contain non-empty strings")
|
|
56
|
+
return tuple(raw)
|
|
57
|
+
|
|
58
|
+
identifier = value.get("id")
|
|
59
|
+
goal = value.get("goal")
|
|
60
|
+
context = value.get("context", "")
|
|
61
|
+
if not isinstance(identifier, str) or not identifier.strip():
|
|
62
|
+
raise ValueError("task field 'id' must be a non-empty string")
|
|
63
|
+
if not isinstance(goal, str) or not goal.strip():
|
|
64
|
+
raise ValueError("task field 'goal' must be a non-empty string")
|
|
65
|
+
if not isinstance(context, str):
|
|
66
|
+
raise ValueError("task field 'context' must be a string")
|
|
67
|
+
files = strings("files")
|
|
68
|
+
if not files:
|
|
69
|
+
raise ValueError("task field 'files' must not be empty")
|
|
70
|
+
return cls(
|
|
71
|
+
id=identifier,
|
|
72
|
+
goal=goal,
|
|
73
|
+
files=files,
|
|
74
|
+
context=context,
|
|
75
|
+
constraints=strings("constraints"),
|
|
76
|
+
checks=strings("checks"),
|
|
77
|
+
acceptance=strings("acceptance"),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def as_dict(self) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"id": self.id,
|
|
83
|
+
"goal": self.goal,
|
|
84
|
+
"files": list(self.files),
|
|
85
|
+
"context": self.context,
|
|
86
|
+
"constraints": list(self.constraints),
|
|
87
|
+
"checks": list(self.checks),
|
|
88
|
+
"acceptance": list(self.acceptance),
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Durable storage abstraction for task delegation state and recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Mapping, Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TaskRecord:
|
|
15
|
+
"""A durable record representing a delegated task and its terminal outcome."""
|
|
16
|
+
|
|
17
|
+
task_id: str
|
|
18
|
+
caller_id: str
|
|
19
|
+
request_id: str
|
|
20
|
+
workspace_ref: str
|
|
21
|
+
model_profile: str
|
|
22
|
+
state: str
|
|
23
|
+
created_at: str
|
|
24
|
+
updated_at: str
|
|
25
|
+
result: dict[str, Any] | None = None
|
|
26
|
+
error: dict[str, Any] | None = None
|
|
27
|
+
fingerprint: str = ""
|
|
28
|
+
|
|
29
|
+
def as_dict(self) -> dict[str, Any]:
|
|
30
|
+
return asdict(self)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, data: Mapping[str, Any]) -> TaskRecord:
|
|
34
|
+
return cls(
|
|
35
|
+
task_id=str(data["task_id"]),
|
|
36
|
+
caller_id=str(data["caller_id"]),
|
|
37
|
+
request_id=str(data["request_id"]),
|
|
38
|
+
workspace_ref=str(data["workspace_ref"]),
|
|
39
|
+
model_profile=str(data["model_profile"]),
|
|
40
|
+
state=str(data["state"]),
|
|
41
|
+
created_at=str(data.get("created_at", "")),
|
|
42
|
+
updated_at=str(data.get("updated_at", "")),
|
|
43
|
+
result=dict(data["result"]) if isinstance(data.get("result"), Mapping) else None,
|
|
44
|
+
error=dict(data["error"]) if isinstance(data.get("error"), Mapping) else None,
|
|
45
|
+
fingerprint=str(data.get("fingerprint", "")),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TaskStore(Protocol):
|
|
50
|
+
"""Protocol for persisting and recovering delegation tasks across restarts."""
|
|
51
|
+
|
|
52
|
+
def save(self, record: TaskRecord) -> None: ...
|
|
53
|
+
|
|
54
|
+
def get(self, task_id: str) -> TaskRecord | None: ...
|
|
55
|
+
|
|
56
|
+
def list(
|
|
57
|
+
self,
|
|
58
|
+
caller_id: str | None = None,
|
|
59
|
+
status: str | None = None,
|
|
60
|
+
limit: int = 100,
|
|
61
|
+
) -> list[TaskRecord]: ...
|
|
62
|
+
|
|
63
|
+
def delete(self, task_id: str) -> bool: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class JsonFileTaskStore:
|
|
67
|
+
"""Thread-safe, filesystem-backed JSON task store with atomic writes."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, directory: str | Path) -> None:
|
|
70
|
+
self._dir = Path(directory)
|
|
71
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
self._lock = threading.RLock()
|
|
73
|
+
self._cache: dict[str, TaskRecord] = {}
|
|
74
|
+
self._load_and_recover()
|
|
75
|
+
|
|
76
|
+
def _load_and_recover(self) -> None:
|
|
77
|
+
"""Scan directory, recover existing task files, and mark interrupted tasks."""
|
|
78
|
+
with self._lock:
|
|
79
|
+
for path in self._dir.glob("*.json"):
|
|
80
|
+
try:
|
|
81
|
+
content = path.read_text(encoding="utf-8")
|
|
82
|
+
data = json.loads(content)
|
|
83
|
+
record = TaskRecord.from_dict(data)
|
|
84
|
+
# If previous process crashed while task was running/queued
|
|
85
|
+
if record.state in {"queued", "working"}:
|
|
86
|
+
record = TaskRecord(
|
|
87
|
+
task_id=record.task_id,
|
|
88
|
+
caller_id=record.caller_id,
|
|
89
|
+
request_id=record.request_id,
|
|
90
|
+
workspace_ref=record.workspace_ref,
|
|
91
|
+
model_profile=record.model_profile,
|
|
92
|
+
state="interrupted",
|
|
93
|
+
created_at=record.created_at,
|
|
94
|
+
updated_at=record.updated_at,
|
|
95
|
+
result=record.result,
|
|
96
|
+
error={"kind": "process_interrupted", "message": "task was interrupted by server restart"},
|
|
97
|
+
fingerprint=record.fingerprint,
|
|
98
|
+
)
|
|
99
|
+
self._write_file(record)
|
|
100
|
+
self._cache[record.task_id] = record
|
|
101
|
+
except (json.JSONDecodeError, KeyError, OSError):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
def _write_file(self, record: TaskRecord) -> None:
|
|
105
|
+
target = self._dir / f"{record.task_id}.json"
|
|
106
|
+
temp = self._dir / f"{record.task_id}.tmp"
|
|
107
|
+
data = json.dumps(record.as_dict(), ensure_ascii=False, indent=2)
|
|
108
|
+
temp.write_text(data, encoding="utf-8")
|
|
109
|
+
temp.replace(target)
|
|
110
|
+
|
|
111
|
+
def save(self, record: TaskRecord) -> None:
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._write_file(record)
|
|
114
|
+
self._cache[record.task_id] = record
|
|
115
|
+
|
|
116
|
+
def get(self, task_id: str) -> TaskRecord | None:
|
|
117
|
+
with self._lock:
|
|
118
|
+
return self._cache.get(task_id)
|
|
119
|
+
|
|
120
|
+
def list(
|
|
121
|
+
self,
|
|
122
|
+
caller_id: str | None = None,
|
|
123
|
+
status: str | None = None,
|
|
124
|
+
limit: int = 100,
|
|
125
|
+
) -> list[TaskRecord]:
|
|
126
|
+
with self._lock:
|
|
127
|
+
records = list(self._cache.values())
|
|
128
|
+
if caller_id is not None:
|
|
129
|
+
records = [r for r in records if r.caller_id == caller_id]
|
|
130
|
+
if status is not None:
|
|
131
|
+
records = [r for r in records if r.state == status]
|
|
132
|
+
return records[:limit]
|
|
133
|
+
|
|
134
|
+
def delete(self, task_id: str) -> bool:
|
|
135
|
+
with self._lock:
|
|
136
|
+
if task_id in self._cache:
|
|
137
|
+
del self._cache[task_id]
|
|
138
|
+
target = self._dir / f"{task_id}.json"
|
|
139
|
+
if target.exists():
|
|
140
|
+
target.unlink()
|
|
141
|
+
return True
|
|
142
|
+
return False
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""MCP Tasks extension (``io.modelcontextprotocol/tasks``) for async delegation.
|
|
2
|
+
|
|
3
|
+
The 2026-07-28 stateless protocol moved long-running work into the Tasks
|
|
4
|
+
extension (SEP-2663). This module is the server side of that extension: it
|
|
5
|
+
declares the extension identifier, serves the ``tasks/get``, ``tasks/update``
|
|
6
|
+
and ``tasks/cancel`` methods, and intercepts ``tools/call`` so a
|
|
7
|
+
``delegate_code`` invocation can return a ``CreateTaskResult``
|
|
8
|
+
(``resultType: "task"``) instead of blocking.
|
|
9
|
+
|
|
10
|
+
The extension does not own policy, validation or result state. It is a thin
|
|
11
|
+
wire adapter over a :class:`~local_coding_agent.worker_pool.BoundedWorkerPool`,
|
|
12
|
+
which is the bounded in-memory reservation for a task handle.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Any, Mapping
|
|
20
|
+
|
|
21
|
+
from .worker_pool import BoundedWorkerPool
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
25
|
+
|
|
26
|
+
from mcp.server.context import ServerRequestContext
|
|
27
|
+
from mcp.server.extension import Extension, MethodBinding
|
|
28
|
+
from mcp.types import (
|
|
29
|
+
CallToolRequestParams,
|
|
30
|
+
CallToolResult,
|
|
31
|
+
CancelTaskRequestParams,
|
|
32
|
+
GetTaskRequestParams,
|
|
33
|
+
INVALID_PARAMS,
|
|
34
|
+
TextContent,
|
|
35
|
+
)
|
|
36
|
+
from mcp.shared.exceptions import MCPError
|
|
37
|
+
|
|
38
|
+
_MCP_AVAILABLE = True
|
|
39
|
+
except ImportError: # pragma: no cover - mcp/pydantic is an optional dependency
|
|
40
|
+
_MCP_AVAILABLE = False
|
|
41
|
+
|
|
42
|
+
TASKS_IDENTIFIER = "io.modelcontextprotocol/tasks"
|
|
43
|
+
|
|
44
|
+
_POOL_STATE_TO_TASK = {
|
|
45
|
+
"queued": "working",
|
|
46
|
+
"working": "working",
|
|
47
|
+
"completed": "completed",
|
|
48
|
+
# A restarted process cannot safely replay without the original request
|
|
49
|
+
# envelope. The worker pool exposes an interrupted terminal result, and
|
|
50
|
+
# Tasks carries it as a completed response with isError=true.
|
|
51
|
+
"interrupted": "completed",
|
|
52
|
+
# Controller/tool failures are CallToolResult(isError=true), not JSON-RPC
|
|
53
|
+
# failures; SEP-2663 keeps those tasks in the completed state.
|
|
54
|
+
"failed": "completed",
|
|
55
|
+
"cancelled": "cancelled",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_DEFAULT_TTL_MS = 30 * 60 * 1000
|
|
59
|
+
_DEFAULT_POLL_INTERVAL_MS = 2000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now_iso() -> str:
|
|
63
|
+
return datetime.now(timezone.utc).isoformat()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _client_declared_tasks(ctx: ServerRequestContext[Any, Any]) -> bool:
|
|
67
|
+
"""True when the client declared the tasks extension in its capabilities."""
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
capabilities = ctx.session.client_capabilities
|
|
71
|
+
except AttributeError: # pragma: no cover - defensive against SDK drift
|
|
72
|
+
return False
|
|
73
|
+
declared = capabilities.extensions if capabilities is not None else None
|
|
74
|
+
return bool(declared and TASKS_IDENTIFIER in declared)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def task_dict(pool: BoundedWorkerPool, caller_id: str, task_id: str) -> dict[str, Any]:
|
|
78
|
+
"""Render a pool snapshot as the modern (SEP-2663) flat Task wire shape.
|
|
79
|
+
|
|
80
|
+
The wire shape is the flat ``Task`` from the ext-tasks schema: ``taskId``,
|
|
81
|
+
``status``, ``statusMessage``, ``createdAt``, ``lastUpdatedAt``, ``ttlMs``
|
|
82
|
+
and optional ``pollIntervalMs``, with a terminal ``result``/``error``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
snapshot = pool.get(caller_id, task_id)
|
|
86
|
+
_raise_if_unknown(snapshot, task_id)
|
|
87
|
+
status = _POOL_STATE_TO_TASK.get(snapshot.get("status"), "working")
|
|
88
|
+
created_at = snapshot.get("created_at") or _now_iso()
|
|
89
|
+
updated_at = snapshot.get("updated_at") or created_at
|
|
90
|
+
task: dict[str, Any] = {
|
|
91
|
+
"taskId": task_id,
|
|
92
|
+
"status": status,
|
|
93
|
+
"createdAt": created_at,
|
|
94
|
+
"lastUpdatedAt": updated_at,
|
|
95
|
+
"ttlMs": _DEFAULT_TTL_MS,
|
|
96
|
+
"pollIntervalMs": _DEFAULT_POLL_INTERVAL_MS,
|
|
97
|
+
}
|
|
98
|
+
if status == "completed":
|
|
99
|
+
task["result"] = _call_tool_result(snapshot.get("result") or {})
|
|
100
|
+
return task
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _raise_if_unknown(snapshot: Mapping[str, Any], task_id: str) -> None:
|
|
104
|
+
error = snapshot.get("error")
|
|
105
|
+
if isinstance(error, Mapping) and error.get("kind") == "unknown_job":
|
|
106
|
+
raise MCPError(
|
|
107
|
+
code=INVALID_PARAMS,
|
|
108
|
+
message=f"unknown taskId: {task_id}",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _call_tool_result(result: Mapping[str, Any]) -> dict[str, Any]:
|
|
113
|
+
"""Convert controller-owned result data to the original CallToolResult shape."""
|
|
114
|
+
|
|
115
|
+
value = dict(result)
|
|
116
|
+
return {
|
|
117
|
+
"content": [
|
|
118
|
+
{
|
|
119
|
+
"type": "text",
|
|
120
|
+
"text": json.dumps(value, ensure_ascii=False, sort_keys=True),
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"isError": value.get("status") == "failed",
|
|
124
|
+
"structuredContent": value,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if _MCP_AVAILABLE:
|
|
129
|
+
|
|
130
|
+
class UpdateTaskParams(BaseModel):
|
|
131
|
+
"""Params for ``tasks/update`` (SEP-2663): responses to input requests."""
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
134
|
+
|
|
135
|
+
task_id: str = Field(alias="taskId")
|
|
136
|
+
input_responses: dict[str, Any] | None = Field(alias="inputResponses", default=None)
|
|
137
|
+
|
|
138
|
+
class TasksExtension(Extension):
|
|
139
|
+
"""Server side of ``io.modelcontextprotocol/tasks`` over a worker pool."""
|
|
140
|
+
|
|
141
|
+
identifier = TASKS_IDENTIFIER
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
pool: BoundedWorkerPool,
|
|
146
|
+
*,
|
|
147
|
+
caller_id: str,
|
|
148
|
+
ttl_ms: int = _DEFAULT_TTL_MS,
|
|
149
|
+
poll_interval_ms: int = _DEFAULT_POLL_INTERVAL_MS,
|
|
150
|
+
) -> None:
|
|
151
|
+
self._pool = pool
|
|
152
|
+
self._caller_id = caller_id
|
|
153
|
+
self._ttl_ms = ttl_ms
|
|
154
|
+
self._poll_interval_ms = poll_interval_ms
|
|
155
|
+
|
|
156
|
+
def methods(self) -> list[MethodBinding]:
|
|
157
|
+
return [
|
|
158
|
+
MethodBinding("tasks/get", GetTaskRequestParams, self._handle_get),
|
|
159
|
+
MethodBinding("tasks/cancel", CancelTaskRequestParams, self._handle_cancel),
|
|
160
|
+
MethodBinding("tasks/update", UpdateTaskParams, self._handle_update),
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
async def intercept_tool_call(
|
|
164
|
+
self,
|
|
165
|
+
params: CallToolRequestParams,
|
|
166
|
+
ctx: ServerRequestContext[Any, Any],
|
|
167
|
+
call_next,
|
|
168
|
+
) -> Any:
|
|
169
|
+
"""Short-circuit ``delegate_code`` into a task when the client opted in.
|
|
170
|
+
|
|
171
|
+
A client that did not declare the extension falls through to the
|
|
172
|
+
synchronous handler, so the server never returns a task handle to a
|
|
173
|
+
client that cannot handle it.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
if params.name != "delegate_code":
|
|
177
|
+
return await call_next(ctx)
|
|
178
|
+
if not _client_declared_tasks(ctx):
|
|
179
|
+
return await call_next(ctx)
|
|
180
|
+
try:
|
|
181
|
+
from .service import DelegationRequest
|
|
182
|
+
from .task import TaskEnvelope
|
|
183
|
+
|
|
184
|
+
arguments = params.arguments or {}
|
|
185
|
+
request = DelegationRequest(
|
|
186
|
+
request_id=arguments.get("request_id"),
|
|
187
|
+
workspace_ref=arguments.get("workspace_ref"),
|
|
188
|
+
model_profile=arguments.get("model_profile"),
|
|
189
|
+
task=TaskEnvelope.from_mapping(arguments.get("task") or {}),
|
|
190
|
+
)
|
|
191
|
+
except (TypeError, ValueError):
|
|
192
|
+
# Invalid arguments must report the same synchronous policy failure
|
|
193
|
+
# as the non-task path; fall through to the real handler.
|
|
194
|
+
return await call_next(ctx)
|
|
195
|
+
snapshot = self._pool.submit(self._caller_id, request)
|
|
196
|
+
if snapshot.get("status") == "failed":
|
|
197
|
+
error = snapshot.get("error") or {}
|
|
198
|
+
return CallToolResult(
|
|
199
|
+
content=[TextContent(type="text", text=str(error))],
|
|
200
|
+
is_error=True,
|
|
201
|
+
)
|
|
202
|
+
return {
|
|
203
|
+
"resultType": "task",
|
|
204
|
+
"taskId": snapshot["job_id"],
|
|
205
|
+
"status": _POOL_STATE_TO_TASK.get(snapshot.get("status"), "working"),
|
|
206
|
+
"createdAt": snapshot.get("created_at") or _now_iso(),
|
|
207
|
+
"lastUpdatedAt": snapshot.get("updated_at") or _now_iso(),
|
|
208
|
+
"ttlMs": self._ttl_ms,
|
|
209
|
+
"pollIntervalMs": self._poll_interval_ms,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async def _handle_get(
|
|
213
|
+
self,
|
|
214
|
+
ctx: ServerRequestContext[Any, Any],
|
|
215
|
+
params: GetTaskRequestParams,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
task = task_dict(self._pool, self._caller_id, params.task_id)
|
|
218
|
+
task["resultType"] = "complete"
|
|
219
|
+
return task
|
|
220
|
+
|
|
221
|
+
async def _handle_cancel(
|
|
222
|
+
self,
|
|
223
|
+
ctx: ServerRequestContext[Any, Any],
|
|
224
|
+
params: CancelTaskRequestParams,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
snapshot = self._pool.cancel(self._caller_id, params.task_id)
|
|
227
|
+
_raise_if_unknown(snapshot, params.task_id)
|
|
228
|
+
return {"resultType": "complete"}
|
|
229
|
+
|
|
230
|
+
async def _handle_update(
|
|
231
|
+
self,
|
|
232
|
+
ctx: ServerRequestContext[Any, Any],
|
|
233
|
+
params: UpdateTaskParams,
|
|
234
|
+
) -> dict[str, Any]:
|
|
235
|
+
snapshot = self._pool.get(self._caller_id, params.task_id)
|
|
236
|
+
_raise_if_unknown(snapshot, params.task_id)
|
|
237
|
+
# MVP has no `input_required` flow: delegate_code never surfaces
|
|
238
|
+
# input requests, so tasks/update only acknowledges. Responses to
|
|
239
|
+
# unknown or already-satisfied keys are ignored per the spec.
|
|
240
|
+
return {"resultType": "complete"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Persistent PTY Terminal Seam & Interactive Process Control (R26).
|
|
2
|
+
|
|
3
|
+
Adapted from DeepSeek Harness @deepseek-ai/dsh-terminal and @deepseek-ai/dsh-tool-terminal.
|
|
4
|
+
Provides persistent background interactive shell / process sessions, non-blocking I/O drainers,
|
|
5
|
+
circular scrollback buffers, cross-platform signal propagation, and robust process tree cleanup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..terminal._process import kill_process_tree
|
|
11
|
+
from ..terminal._session import TerminalSession
|
|
12
|
+
from ..terminal._types import (
|
|
13
|
+
TerminalError,
|
|
14
|
+
TerminalProcessExitedError,
|
|
15
|
+
TerminalSessionExistsError,
|
|
16
|
+
TerminalSessionInfo,
|
|
17
|
+
TerminalSessionNotFoundError,
|
|
18
|
+
TerminalTimeoutError,
|
|
19
|
+
)
|
|
20
|
+
from ..terminal._manager import TerminalManager
|
|
21
|
+
from ..terminal._tools import execute_terminal_tool, get_terminal_tool_schemas
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"TerminalError",
|
|
25
|
+
"TerminalManager",
|
|
26
|
+
"TerminalProcessExitedError",
|
|
27
|
+
"TerminalSession",
|
|
28
|
+
"TerminalSessionExistsError",
|
|
29
|
+
"TerminalSessionInfo",
|
|
30
|
+
"TerminalSessionNotFoundError",
|
|
31
|
+
"TerminalTimeoutError",
|
|
32
|
+
"execute_terminal_tool",
|
|
33
|
+
"get_terminal_tool_schemas",
|
|
34
|
+
"kill_process_tree",
|
|
35
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Registry and lifecycle manager for persistent terminal sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from ..terminal._session import TerminalSession
|
|
10
|
+
from ..terminal._types import (
|
|
11
|
+
TerminalError,
|
|
12
|
+
TerminalSessionExistsError,
|
|
13
|
+
TerminalSessionNotFoundError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ============================================================================
|
|
18
|
+
# TerminalManager
|
|
19
|
+
# ============================================================================
|
|
20
|
+
|
|
21
|
+
class TerminalManager:
|
|
22
|
+
"""Registry and lifecycle manager for multiple persistent terminal sessions."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
workspace_root: str | Path | None = None,
|
|
27
|
+
*,
|
|
28
|
+
default_max_buffer: int = 1_048_576,
|
|
29
|
+
strict_workspace: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.workspace_root = (
|
|
32
|
+
Path(workspace_root).resolve() if workspace_root else Path.cwd()
|
|
33
|
+
)
|
|
34
|
+
self.default_max_buffer = default_max_buffer
|
|
35
|
+
self.strict_workspace = strict_workspace
|
|
36
|
+
self._sessions: dict[str, TerminalSession] = {}
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
def create_session(
|
|
40
|
+
self,
|
|
41
|
+
session_id: str,
|
|
42
|
+
cwd: str | Path | None = None,
|
|
43
|
+
shell: str | Sequence[str] | None = None,
|
|
44
|
+
*,
|
|
45
|
+
max_buffer_bytes: int | None = None,
|
|
46
|
+
env: Mapping[str, str] | None = None,
|
|
47
|
+
) -> TerminalSession:
|
|
48
|
+
"""Create and start a new interactive persistent terminal session."""
|
|
49
|
+
session_id = str(session_id).strip()
|
|
50
|
+
if not session_id:
|
|
51
|
+
raise TerminalError("session_id must be a non-empty string")
|
|
52
|
+
|
|
53
|
+
if cwd is not None:
|
|
54
|
+
raw_cwd = Path(cwd)
|
|
55
|
+
if raw_cwd.is_absolute():
|
|
56
|
+
target_cwd = raw_cwd.resolve()
|
|
57
|
+
else:
|
|
58
|
+
target_cwd = (self.workspace_root / raw_cwd).resolve()
|
|
59
|
+
else:
|
|
60
|
+
target_cwd = self.workspace_root
|
|
61
|
+
|
|
62
|
+
if self.strict_workspace:
|
|
63
|
+
try:
|
|
64
|
+
target_cwd.relative_to(self.workspace_root)
|
|
65
|
+
except ValueError as e:
|
|
66
|
+
raise TerminalError(
|
|
67
|
+
f"Path traversal denied: working directory '{target_cwd}' is outside workspace root '{self.workspace_root}'"
|
|
68
|
+
) from e
|
|
69
|
+
|
|
70
|
+
with self._lock:
|
|
71
|
+
existing = self._sessions.get(session_id)
|
|
72
|
+
if existing is not None:
|
|
73
|
+
if existing.is_alive():
|
|
74
|
+
raise TerminalSessionExistsError(
|
|
75
|
+
f"Terminal session '{session_id}' already exists and is active"
|
|
76
|
+
)
|
|
77
|
+
# Cleanup dead session before replacing
|
|
78
|
+
existing.close()
|
|
79
|
+
del self._sessions[session_id]
|
|
80
|
+
|
|
81
|
+
buf_limit = max_buffer_bytes or self.default_max_buffer
|
|
82
|
+
|
|
83
|
+
session = TerminalSession(
|
|
84
|
+
session_id=session_id,
|
|
85
|
+
cwd=target_cwd,
|
|
86
|
+
shell=shell,
|
|
87
|
+
max_buffer_bytes=buf_limit,
|
|
88
|
+
env=env,
|
|
89
|
+
)
|
|
90
|
+
self._sessions[session_id] = session
|
|
91
|
+
return session
|
|
92
|
+
|
|
93
|
+
def get_session(self, session_id: str) -> TerminalSession:
|
|
94
|
+
"""Retrieve an existing terminal session by ID."""
|
|
95
|
+
with self._lock:
|
|
96
|
+
session = self._sessions.get(session_id)
|
|
97
|
+
if session is None:
|
|
98
|
+
raise TerminalSessionNotFoundError(
|
|
99
|
+
f"Terminal session '{session_id}' not found"
|
|
100
|
+
)
|
|
101
|
+
return session
|
|
102
|
+
|
|
103
|
+
def send_input(
|
|
104
|
+
self,
|
|
105
|
+
session_id: str,
|
|
106
|
+
text: str,
|
|
107
|
+
wait_ms: int = 500,
|
|
108
|
+
submit: bool = True,
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Send input to an identified session and collect response output slice."""
|
|
111
|
+
session = self.get_session(session_id)
|
|
112
|
+
return session.send_input(text, wait_ms=wait_ms, submit=submit)
|
|
113
|
+
|
|
114
|
+
def read_buffer(
|
|
115
|
+
self,
|
|
116
|
+
session_id: str,
|
|
117
|
+
offset: int = 0,
|
|
118
|
+
limit: int = 4096,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Read a slice from the session's retained output buffer."""
|
|
121
|
+
session = self.get_session(session_id)
|
|
122
|
+
return session.read_buffer(offset=offset, limit=limit)
|
|
123
|
+
|
|
124
|
+
def send_signal(self, session_id: str, sig: str) -> bool:
|
|
125
|
+
"""Deliver a signal to the identified session."""
|
|
126
|
+
session = self.get_session(session_id)
|
|
127
|
+
return session.send_signal(sig)
|
|
128
|
+
|
|
129
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
130
|
+
"""List summary info for all tracked terminal sessions."""
|
|
131
|
+
with self._lock:
|
|
132
|
+
return [s.snapshot().to_dict() for s in self._sessions.values()]
|
|
133
|
+
|
|
134
|
+
def close_session(self, session_id: str) -> None:
|
|
135
|
+
"""Shut down and unregister a terminal session."""
|
|
136
|
+
with self._lock:
|
|
137
|
+
session = self._sessions.pop(session_id, None)
|
|
138
|
+
if session is not None:
|
|
139
|
+
session.close()
|
|
140
|
+
|
|
141
|
+
def close_all(self) -> None:
|
|
142
|
+
"""Shut down and clean up all active terminal sessions."""
|
|
143
|
+
with self._lock:
|
|
144
|
+
sessions = list(self._sessions.values())
|
|
145
|
+
self._sessions.clear()
|
|
146
|
+
for s in sessions:
|
|
147
|
+
try:
|
|
148
|
+
s.close()
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
def __enter__(self) -> TerminalManager:
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
156
|
+
self.close_all()
|