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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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()