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,458 @@
1
+ """Wire-protocol adapters for Codex and Claude Code hooks (R30)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ._bridge import HookBridge
12
+ from ._decision import HookDecision
13
+
14
+
15
+ class CodexHookAdapter:
16
+ """Wire-protocol adapter and command runner for Codex hooks."""
17
+
18
+ DIALECT = "codex"
19
+
20
+ def __init__(self, bridge: HookBridge | None = None, *, model: str = "") -> None:
21
+ self.bridge = bridge or HookBridge()
22
+ self.model = model
23
+
24
+ def format_session_start(
25
+ self, session_id: str, cwd: str, model: str | None = None, source: str = "user"
26
+ ) -> dict[str, Any]:
27
+ """Format SessionStart event into Codex wire protocol."""
28
+ return {
29
+ "event": "SessionStart",
30
+ "session_id": session_id,
31
+ "cwd": cwd,
32
+ "model": model or self.model,
33
+ "source": source,
34
+ }
35
+
36
+ def format_user_prompt_submit(
37
+ self, turn_id: str | int, prompt: str, cwd: str = "", model: str | None = None
38
+ ) -> dict[str, Any]:
39
+ """Format UserPromptSubmit event into Codex wire protocol."""
40
+ return {
41
+ "event": "UserPromptSubmit",
42
+ "turn_id": str(turn_id),
43
+ "prompt": prompt,
44
+ "cwd": cwd,
45
+ "model": model or self.model,
46
+ }
47
+
48
+ def format_pre_tool_use(
49
+ self,
50
+ tool_name: str,
51
+ tool_input: dict[str, Any],
52
+ turn_id: str | int = "",
53
+ cwd: str = "",
54
+ model: str | None = None,
55
+ ) -> dict[str, Any]:
56
+ """Format PreToolUse event into Codex wire protocol."""
57
+ return {
58
+ "event": "PreToolUse",
59
+ "tool_name": tool_name,
60
+ "tool_input": tool_input,
61
+ "turn_id": str(turn_id),
62
+ "cwd": cwd,
63
+ "model": model or self.model,
64
+ }
65
+
66
+ def format_post_tool_use(
67
+ self,
68
+ tool_name: str,
69
+ tool_input: dict[str, Any],
70
+ tool_output: dict[str, Any],
71
+ turn_id: str | int = "",
72
+ cwd: str = "",
73
+ model: str | None = None,
74
+ ) -> dict[str, Any]:
75
+ """Format PostToolUse event into Codex wire protocol."""
76
+ return {
77
+ "event": "PostToolUse",
78
+ "tool_name": tool_name,
79
+ "tool_input": tool_input,
80
+ "tool_output": tool_output,
81
+ "turn_id": str(turn_id),
82
+ "cwd": cwd,
83
+ "model": model or self.model,
84
+ }
85
+
86
+ def format_stop(
87
+ self, session_id: str, reason: str = "", cwd: str = "", model: str | None = None
88
+ ) -> dict[str, Any]:
89
+ """Format Stop event into Codex wire protocol."""
90
+ return {
91
+ "event": "Stop",
92
+ "session_id": session_id,
93
+ "reason": reason,
94
+ "cwd": cwd,
95
+ "model": model or self.model,
96
+ }
97
+
98
+ def parse_hook_output(self, exit_code: int, stdout: str, stderr: str) -> HookDecision:
99
+ """Parse raw process output into a normalized HookDecision."""
100
+ trimmed_stdout = stdout.strip()
101
+ trimmed_stderr = stderr.strip()
102
+
103
+ # In Codex spec, non-zero exit codes indicate failure / blocking
104
+ if exit_code != 0:
105
+ reason = trimmed_stderr or trimmed_stdout or f"blocked by hook (exit code {exit_code})"
106
+ return HookDecision(
107
+ decision="deny",
108
+ allowed=False,
109
+ reason=reason,
110
+ raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
111
+ )
112
+
113
+ if not trimmed_stdout:
114
+ return HookDecision(raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}])
115
+
116
+ try:
117
+ parsed = json.loads(trimmed_stdout)
118
+ if isinstance(parsed, dict):
119
+ decision = HookDecision()
120
+ self.bridge._fold_outcome(decision, parsed)
121
+ decision.raw_outputs.append({"exit_code": exit_code, "stdout": stdout, "stderr": stderr})
122
+ return decision
123
+ except json.JSONDecodeError:
124
+ pass
125
+
126
+ # Plain text stdout on exit code 0 is treated as additional context
127
+ if exit_code == 0 and trimmed_stdout:
128
+ return HookDecision(
129
+ additional_context=[trimmed_stdout],
130
+ raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
131
+ )
132
+
133
+ if exit_code != 0:
134
+ err_msg = stderr.strip() or f"hook exited with code {exit_code}"
135
+ return HookDecision(
136
+ decision="deny",
137
+ allowed=False,
138
+ reason=err_msg,
139
+ raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
140
+ )
141
+
142
+ return HookDecision(raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}])
143
+
144
+ def run_command_hook(
145
+ self,
146
+ command: str,
147
+ payload: dict[str, Any],
148
+ *,
149
+ timeout: float = 30.0,
150
+ cwd: str | None = None,
151
+ ) -> HookDecision:
152
+ """Run an external command hook sending JSON to stdin and return HookDecision."""
153
+ try:
154
+ # Codex sends payload without trailing newline
155
+ input_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8")
156
+ proc = subprocess.run(
157
+ command,
158
+ input=input_bytes,
159
+ capture_output=True,
160
+ timeout=timeout,
161
+ cwd=cwd,
162
+ shell=True,
163
+ )
164
+ stdout = proc.stdout.decode("utf-8", errors="replace")
165
+ stderr = proc.stderr.decode("utf-8", errors="replace")
166
+ return self.parse_hook_output(proc.returncode, stdout, stderr)
167
+ except subprocess.TimeoutExpired:
168
+ return HookDecision(
169
+ decision="deny",
170
+ allowed=False,
171
+ reason=f"hook command timed out after {timeout}s",
172
+ raw_outputs=[{"error": "timeout", "command": command}],
173
+ )
174
+ except Exception as exc:
175
+ return HookDecision(
176
+ decision="deny",
177
+ allowed=False,
178
+ reason=f"hook command execution error: {exc}",
179
+ raw_outputs=[{"error": str(exc), "command": command}],
180
+ )
181
+
182
+ def load_config(
183
+ self, config_data: dict[str, Any] | str | Path, cwd: str | None = None
184
+ ) -> HookBridge:
185
+ """Load a Codex hooks.json config and register command handlers onto the bridge."""
186
+ if isinstance(config_data, (str, Path)):
187
+ with open(config_data, "r", encoding="utf-8") as f:
188
+ data = json.load(f)
189
+ else:
190
+ data = dict(config_data)
191
+
192
+ for event_name, groups in data.items():
193
+ if not isinstance(groups, list):
194
+ continue
195
+ for grp in groups:
196
+ if not isinstance(grp, dict):
197
+ continue
198
+ matcher = grp.get("matcher", "")
199
+ hooks = grp.get("hooks", [])
200
+ for h in hooks:
201
+ cmd = h.get("command")
202
+ timeout = float(h.get("timeoutSec", 30))
203
+ if not cmd:
204
+ continue
205
+
206
+ def make_handler(command: str, timeout_sec: float):
207
+ def handler(match_target: str, payload: dict[str, Any]) -> HookDecision:
208
+ return self.run_command_hook(command, payload, timeout=timeout_sec, cwd=cwd)
209
+ return handler
210
+
211
+ self.bridge.register(
212
+ event_name,
213
+ make_handler(cmd, timeout),
214
+ matcher=matcher,
215
+ )
216
+ return self.bridge
217
+
218
+
219
+ class ClaudeCodeHookAdapter:
220
+ """Wire-protocol adapter and command runner for Claude Code hooks."""
221
+
222
+ DIALECT = "claude-code"
223
+
224
+ def __init__(
225
+ self,
226
+ bridge: HookBridge | None = None,
227
+ *,
228
+ plugin_root: str = "",
229
+ project_dir: str = "",
230
+ ) -> None:
231
+ self.bridge = bridge or HookBridge()
232
+ self.plugin_root = plugin_root
233
+ self.project_dir = project_dir
234
+
235
+ def format_session_start(
236
+ self, session_id: str, project_dir: str = "", source: str = "startup"
237
+ ) -> dict[str, Any]:
238
+ """Format SessionStart event into Claude Code wire protocol."""
239
+ return {
240
+ "hookEventName": "SessionStart",
241
+ "sessionId": session_id,
242
+ "projectDir": project_dir or self.project_dir,
243
+ "source": source,
244
+ }
245
+
246
+ def format_user_prompt_submit(
247
+ self, turn: int, prompt: str, session_id: str = ""
248
+ ) -> dict[str, Any]:
249
+ """Format UserPromptSubmit event into Claude Code wire protocol."""
250
+ return {
251
+ "hookEventName": "UserPromptSubmit",
252
+ "turn": turn,
253
+ "prompt": prompt,
254
+ "sessionId": session_id,
255
+ }
256
+
257
+ def format_pre_tool_use(
258
+ self,
259
+ tool_name: str,
260
+ tool_input: dict[str, Any],
261
+ turn: int = 1,
262
+ session_id: str = "",
263
+ ) -> dict[str, Any]:
264
+ """Format PreToolUse event into Claude Code wire protocol."""
265
+ return {
266
+ "hookEventName": "PreToolUse",
267
+ "tool": tool_name,
268
+ "toolInput": tool_input,
269
+ "turn": turn,
270
+ "sessionId": session_id,
271
+ }
272
+
273
+ def format_post_tool_use(
274
+ self,
275
+ tool_name: str,
276
+ tool_input: dict[str, Any],
277
+ tool_output: dict[str, Any],
278
+ turn: int = 1,
279
+ session_id: str = "",
280
+ ) -> dict[str, Any]:
281
+ """Format PostToolUse event into Claude Code wire protocol."""
282
+ return {
283
+ "hookEventName": "PostToolUse",
284
+ "tool": tool_name,
285
+ "toolInput": tool_input,
286
+ "toolOutput": tool_output,
287
+ "turn": turn,
288
+ "sessionId": session_id,
289
+ }
290
+
291
+ def format_stop(self, session_id: str, stop_reason: str = "") -> dict[str, Any]:
292
+ """Format Stop event into Claude Code wire protocol."""
293
+ return {
294
+ "hookEventName": "Stop",
295
+ "sessionId": session_id,
296
+ "stopReason": stop_reason,
297
+ }
298
+
299
+ def format_subagent_start(
300
+ self, subagent_id: str, role: str, session_id: str = ""
301
+ ) -> dict[str, Any]:
302
+ """Format SubagentStart event into Claude Code wire protocol."""
303
+ return {
304
+ "hookEventName": "SubagentStart",
305
+ "subagentId": subagent_id,
306
+ "role": role,
307
+ "sessionId": session_id,
308
+ }
309
+
310
+ def format_subagent_stop(
311
+ self, subagent_id: str, session_id: str = "", status: str = "completed"
312
+ ) -> dict[str, Any]:
313
+ """Format SubagentStop event into Claude Code wire protocol."""
314
+ return {
315
+ "hookEventName": "SubagentStop",
316
+ "subagentId": subagent_id,
317
+ "sessionId": session_id,
318
+ "status": status,
319
+ }
320
+
321
+ def parse_hook_output(self, exit_code: int, stdout: str, stderr: str) -> HookDecision:
322
+ """Parse Claude Code hook command output into a normalized HookDecision."""
323
+ trimmed_stdout = stdout.strip()
324
+ trimmed_stderr = stderr.strip()
325
+
326
+ if exit_code != 0 and not trimmed_stdout:
327
+ return HookDecision(
328
+ decision="deny",
329
+ allowed=False,
330
+ reason=trimmed_stderr or f"hook failed with exit code {exit_code}",
331
+ raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
332
+ )
333
+
334
+ if not trimmed_stdout:
335
+ return HookDecision(raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}])
336
+
337
+ try:
338
+ parsed = json.loads(trimmed_stdout)
339
+ if isinstance(parsed, dict):
340
+ decision = HookDecision()
341
+ self.bridge._fold_outcome(decision, parsed)
342
+ decision.raw_outputs.append({"exit_code": exit_code, "stdout": stdout, "stderr": stderr})
343
+ return decision
344
+ except json.JSONDecodeError:
345
+ pass
346
+
347
+ return HookDecision(
348
+ additional_context=[trimmed_stdout] if exit_code == 0 and trimmed_stdout else [],
349
+ raw_outputs=[{"exit_code": exit_code, "stdout": stdout, "stderr": stderr}],
350
+ )
351
+
352
+ def _substitute_vars(self, command: str, plugin_root: str, project_dir: str) -> str:
353
+ res = command.replace("${CLAUDE_PLUGIN_ROOT}", plugin_root)
354
+ res = res.replace("$CLAUDE_PLUGIN_ROOT", plugin_root)
355
+ res = res.replace("${CLAUDE_PROJECT_DIR}", project_dir)
356
+ res = res.replace("$CLAUDE_PROJECT_DIR", project_dir)
357
+ return res
358
+
359
+ def run_command_hook(
360
+ self,
361
+ command: str,
362
+ payload: dict[str, Any],
363
+ *,
364
+ timeout: float = 30.0,
365
+ cwd: str | None = None,
366
+ ) -> HookDecision:
367
+ """Run a Claude Code command hook with JSON input and variable substitutions."""
368
+ plugin_root = self.plugin_root or (cwd or os.getcwd())
369
+ project_dir = self.project_dir or (cwd or os.getcwd())
370
+ substituted_cmd = self._substitute_vars(command, plugin_root, project_dir)
371
+
372
+ env = os.environ.copy()
373
+ env["CLAUDE_PROJECT_DIR"] = project_dir
374
+ if self.plugin_root:
375
+ env["CLAUDE_PLUGIN_ROOT"] = self.plugin_root
376
+
377
+ try:
378
+ input_bytes = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
379
+ proc = subprocess.run(
380
+ substituted_cmd,
381
+ input=input_bytes,
382
+ capture_output=True,
383
+ timeout=timeout,
384
+ cwd=cwd or project_dir,
385
+ shell=True,
386
+ env=env,
387
+ )
388
+ stdout = proc.stdout.decode("utf-8", errors="replace")
389
+ stderr = proc.stderr.decode("utf-8", errors="replace")
390
+ return self.parse_hook_output(proc.returncode, stdout, stderr)
391
+ except subprocess.TimeoutExpired:
392
+ return HookDecision(
393
+ decision="deny",
394
+ allowed=False,
395
+ reason=f"hook command timed out after {timeout}s",
396
+ raw_outputs=[{"error": "timeout", "command": command}],
397
+ )
398
+ except Exception as exc:
399
+ return HookDecision(
400
+ decision="deny",
401
+ allowed=False,
402
+ reason=f"hook execution error: {exc}",
403
+ raw_outputs=[{"error": str(exc), "command": command}],
404
+ )
405
+
406
+ def load_config(
407
+ self,
408
+ config_data: dict[str, Any] | str | Path,
409
+ cwd: str | None = None,
410
+ plugin_root: str | None = None,
411
+ project_dir: str | None = None,
412
+ ) -> HookBridge:
413
+ """Load Claude Code hooks.json / settings.json config and register onto bridge."""
414
+ if plugin_root:
415
+ self.plugin_root = plugin_root
416
+ if project_dir:
417
+ self.project_dir = project_dir
418
+
419
+ if isinstance(config_data, (str, Path)):
420
+ with open(config_data, "r", encoding="utf-8") as f:
421
+ data = json.load(f)
422
+ else:
423
+ data = dict(config_data)
424
+
425
+ # Handle top-level 'hooks' wrapper if present
426
+ hooks_dict = data.get("hooks", data)
427
+ if not isinstance(hooks_dict, dict):
428
+ return self.bridge
429
+
430
+ for event_name, groups in hooks_dict.items():
431
+ if not isinstance(groups, list):
432
+ continue
433
+ for grp in groups:
434
+ if not isinstance(grp, dict):
435
+ continue
436
+ matcher = grp.get("matcher", "")
437
+ hooks = grp.get("hooks", [])
438
+ for h in hooks:
439
+ # Claude code supports {type: "command", command: ...}
440
+ if isinstance(h, dict) and h.get("type", "command") == "command":
441
+ cmd = h.get("command")
442
+ timeout = float(h.get("timeoutSec", h.get("timeout", 30)))
443
+ if not cmd:
444
+ continue
445
+
446
+ def make_handler(command: str, timeout_sec: float):
447
+ def handler(match_target: str, payload: dict[str, Any]) -> HookDecision:
448
+ return self.run_command_hook(
449
+ command, payload, timeout=timeout_sec, cwd=cwd
450
+ )
451
+ return handler
452
+
453
+ self.bridge.register(
454
+ event_name,
455
+ make_handler(cmd, timeout),
456
+ matcher=matcher,
457
+ )
458
+ return self.bridge