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,216 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from threading import Event
6
+
7
+ from typing import Any
8
+
9
+ from ..repository_tools import BoundedRepositoryTools, ToolCancelled, ToolPolicyError
10
+ from ..semantic_linter import lint_patch_in_memory
11
+ from ..task import TaskEnvelope
12
+ from ..validators import _normalize_task_path, apply_patch, check_patch_applies, parse_unified_diff
13
+
14
+
15
+ def run_post_apply_checks(
16
+ task: TaskEnvelope,
17
+ tools: BoundedRepositoryTools,
18
+ *,
19
+ active_cancel: Event | None,
20
+ audit: list[dict[str, Any]],
21
+ ) -> tuple[list[dict[str, Any]], bool]:
22
+ """Run each allowlisted check against the already-applied workspace.
23
+
24
+ Module-level so the mediated apply path in ``DelegationService`` reuses the
25
+ exact same evidence contract without re-entering the model loop. Raises
26
+ ``ToolCancelled`` when cancellation is requested between checks.
27
+ """
28
+ checks: list[dict[str, Any]] = []
29
+ if not task.checks:
30
+ raise ToolPolicyError("at least one targeted check is required before apply")
31
+ for command in task.checks:
32
+ if active_cancel is not None and active_cancel.is_set():
33
+ raise ToolCancelled("task was cancelled")
34
+ check = tools.execute("run_tests", {"command": command})
35
+ observed = {
36
+ "command": command,
37
+ "passed": check["passed"],
38
+ "evidence": check["evidence"],
39
+ "stdout": check.get("stdout", ""),
40
+ "stderr": check.get("stderr", ""),
41
+ "exit_code": check.get("exit_code"),
42
+ }
43
+ checks.append(observed)
44
+ audit.append(
45
+ {
46
+ "event": "post_apply_check",
47
+ "command": command,
48
+ "passed": check["passed"],
49
+ "exit_code": check.get("exit_code"),
50
+ "stdout": check.get("stdout", ""),
51
+ "stderr": check.get("stderr", ""),
52
+ }
53
+ )
54
+ return checks, all(check["passed"] for check in checks)
55
+
56
+
57
+ def add_risk(result: dict[str, Any], kind: str, message: str) -> None:
58
+ risks = result.get("risks")
59
+ if not isinstance(risks, list):
60
+ risks = []
61
+ result["risks"] = risks
62
+ risks.append({"kind": kind, "message": message})
63
+
64
+
65
+ def apply_failure(kind: str, message: str, audit: list[dict[str, Any]]) -> dict[str, Any]:
66
+ return {
67
+ "status": "failed",
68
+ "error": {"kind": kind, "message": message},
69
+ "audit": [*audit, {"event": "apply_rejected", "kind": kind}],
70
+ }
71
+
72
+
73
+ def _file_digest(path: Path) -> str | None:
74
+ try:
75
+ return hashlib.sha256(path.read_bytes()).hexdigest()
76
+ except FileNotFoundError:
77
+ return None
78
+ except OSError:
79
+ return "unreadable"
80
+
81
+
82
+ def _baseline(workspace: Path, changed: tuple[str, ...]) -> dict[str, str | None]:
83
+ return {name: _file_digest(workspace / name) for name in changed}
84
+
85
+
86
+ def _restored(workspace: Path, baseline: dict[str, str | None]) -> bool:
87
+ return all(_file_digest(workspace / name) == digest for name, digest in baseline.items())
88
+
89
+
90
+ def mediated_apply(
91
+ workspace: Path,
92
+ patch: str,
93
+ task: TaskEnvelope,
94
+ *,
95
+ cancel_event: Event | None = None,
96
+ audit: list[dict[str, Any]] | None = None,
97
+ ) -> dict[str, Any]:
98
+ """Apply a reviewed patch under the full verification gate.
99
+
100
+ Single controller-owned implementation shared by the service, CLI and
101
+ desktop apply paths: refuse a patch with no targeted check, enforce the
102
+ declared file scope, pre-check and lint the patch, apply it, run the
103
+ allowlisted checks, and roll back to the recorded baseline on any failure.
104
+ A rollback is only reported as successful when the baseline digests are
105
+ actually restored; otherwise the workspace is reported as modified/unknown.
106
+ """
107
+
108
+ audit = list(audit) if audit is not None else []
109
+ if not isinstance(patch, str) or not patch.strip():
110
+ return apply_failure("no_patch", "proposal carries no resolved patch", audit)
111
+ if not task.checks:
112
+ return apply_failure(
113
+ "apply_requires_checks",
114
+ "applying a non-empty patch requires at least one targeted check",
115
+ audit,
116
+ )
117
+
118
+ changed, issues = parse_unified_diff(patch)
119
+ if issues:
120
+ return apply_failure("patch_parse_failed", "; ".join(issues), audit)
121
+ allowed = {_normalize_task_path(name) for name in task.files}
122
+ outside = [name for name in changed if _normalize_task_path(name) not in allowed]
123
+ if outside:
124
+ return apply_failure(
125
+ "patch_outside_scope",
126
+ f"patch touches files outside the declared scope: {', '.join(outside)}",
127
+ audit,
128
+ )
129
+
130
+ try:
131
+ applies, detail = check_patch_applies(workspace, patch)
132
+ except ValueError as error:
133
+ return apply_failure("apply_policy", str(error), audit)
134
+ if not applies:
135
+ return apply_failure(
136
+ "stale_workspace",
137
+ f"proposal no longer applies cleanly to the workspace: {detail}",
138
+ audit,
139
+ )
140
+
141
+ # Semantic linter pre-gate (R18): never mutate the workspace with a
142
+ # patch that introduces syntax errors.
143
+ lint_report = lint_patch_in_memory(str(workspace), patch)
144
+ if not lint_report.valid:
145
+ return apply_failure(
146
+ "semantic_lint_failed",
147
+ "; ".join(lint_report.prescriptions) or "patch failed static analysis",
148
+ audit,
149
+ )
150
+
151
+ baseline = _baseline(workspace, changed)
152
+ try:
153
+ tools = BoundedRepositoryTools(workspace, task, cancel_event=cancel_event)
154
+ except (ToolPolicyError, ValueError) as error:
155
+ return apply_failure("apply_policy", str(error), audit)
156
+
157
+ applied, apply_detail = apply_patch(workspace, patch)
158
+ if not applied:
159
+ return apply_failure("apply_failed", f"patch could not be applied: {apply_detail}", audit)
160
+ audit.append({"event": "patch_applied"})
161
+
162
+ result: dict[str, Any] = {"status": "accepted", "checks": [], "risks": [], "audit": audit}
163
+ try:
164
+ post_checks, post_checks_passed = run_post_apply_checks(
165
+ task, tools, active_cancel=cancel_event, audit=audit
166
+ )
167
+ except ToolCancelled:
168
+ result["status"] = "failed"
169
+ result["error"] = {"kind": "cancelled", "message": "post-apply checks were cancelled"}
170
+ audit.append({"event": "post_apply_cancelled"})
171
+ except ToolPolicyError as error:
172
+ result["status"] = "rejected"
173
+ result["error"] = {"kind": "post_apply_check_failed", "message": f"post-apply check could not complete: {error}"}
174
+ audit.append({"event": "post_apply_check_error", "detail": str(error)})
175
+ except Exception: # noqa: BLE001 - apply boundary must roll back, never leave a half-applied patch.
176
+ result["status"] = "failed"
177
+ result["error"] = {"kind": "apply_error", "message": "post-apply checks raised an unexpected error"}
178
+ audit.append({"event": "post_apply_check_error", "detail": "unexpected_error"})
179
+ else:
180
+ result["checks"] = post_checks
181
+ if post_checks_passed:
182
+ result["applied"] = True
183
+ result["workspace_state"] = "original"
184
+ audit.append({"event": "post_apply_checks_passed"})
185
+ return result
186
+ result["status"] = "rejected"
187
+ result["error"] = {"kind": "post_apply_check_failed", "message": "a targeted check failed after applying the patch"}
188
+ audit.append({"event": "post_apply_check_failed"})
189
+
190
+ # Reached only on a cancelled / policy-error / unexpected-error / failed
191
+ # check: roll the patch back. A failed rollback is surfaced explicitly so
192
+ # a consumer never mistakes a still-modified workspace for a clean one.
193
+ try:
194
+ rollback_ok, rollback_detail = apply_patch(workspace, patch, reverse=True)
195
+ except Exception as error: # noqa: BLE001 - expose uncertain workspace state
196
+ rollback_ok = False
197
+ rollback_detail = str(error)
198
+ if rollback_ok and not _restored(workspace, baseline):
199
+ # ponytail: git apply --reverse can exit 0 without reverting every file
200
+ # (e.g. a partially applied hunk). Compare baseline digests instead of
201
+ # trusting the exit code; verify by content if this ever false-positives.
202
+ rollback_ok = False
203
+ rollback_detail = "rollback did not restore the recorded baseline"
204
+
205
+ if rollback_ok:
206
+ result["rolled_back"] = True
207
+ result["workspace_state"] = "original"
208
+ audit.append({"event": "patch_rolled_back"})
209
+ return result
210
+
211
+ result["rolled_back"] = False
212
+ result["workspace_modified"] = True
213
+ result["workspace_state"] = "unknown"
214
+ add_risk(result, "rollback_failed", f"patch rollback failed: {rollback_detail}")
215
+ audit.append({"event": "rollback_failed", "detail": rollback_detail})
216
+ return result
@@ -0,0 +1,180 @@
1
+ """Harness-agnostic delegating agent: decompose, delegate, decompose further.
2
+
3
+ The delegating agent owns the outer "expensive agent" loop: it breaks a wide
4
+ task into bounded children using decomposition templates, delegates each child
5
+ through the transport-neutral ``delegate`` seam, and on a decomposable failure
6
+ re-splits that child at finer granularity. It does not know about MCP, stdio or
7
+ the CLI, and never talks to the model directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from dataclasses import dataclass
14
+ from typing import Any, Callable, Mapping, Sequence
15
+
16
+ from .atomizer import TaskBudget, decompose
17
+ from .service import DelegationRequest
18
+ from .task import TaskEnvelope
19
+
20
+
21
+ Delegate = Callable[[str, DelegationRequest], Mapping[str, Any]]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class DecompositionTemplate:
26
+ """A named strategy that turns one task envelope into bounded children."""
27
+
28
+ name: str
29
+ split: Callable[[TaskEnvelope, TaskBudget], tuple[TaskEnvelope, ...]]
30
+
31
+
32
+ def _split_by_files(task: TaskEnvelope, budget: TaskBudget) -> tuple[TaskEnvelope, ...]:
33
+ # ponytail: reuse atomizer.decompose; a context/checks-over-budget task is
34
+ # not file-splittable, so fall back to the unchanged envelope and let the
35
+ # delegate reject it with a machine-readable reason.
36
+ try:
37
+ return decompose(task, budget).children
38
+ except ValueError:
39
+ return (task,)
40
+
41
+
42
+ def _split_per_file(task: TaskEnvelope, budget: TaskBudget) -> tuple[TaskEnvelope, ...]:
43
+ del budget
44
+ return tuple(
45
+ TaskEnvelope(
46
+ id=f"{task.id}#{index + 1}",
47
+ goal=task.goal,
48
+ files=(file,),
49
+ context=task.context,
50
+ constraints=task.constraints,
51
+ checks=task.checks,
52
+ acceptance=task.acceptance,
53
+ )
54
+ for index, file in enumerate(task.files)
55
+ )
56
+
57
+
58
+ BY_FILES = DecompositionTemplate("by_files", _split_by_files)
59
+ PER_FILE = DecompositionTemplate("per_file", _split_per_file)
60
+ DEFAULT_TEMPLATES = (BY_FILES, PER_FILE)
61
+
62
+ _DECOMPOSABLE_KINDS = frozenset(
63
+ {
64
+ "preflight_rejected",
65
+ "context_limit",
66
+ "max_turns",
67
+ "retry_budget_exhausted",
68
+ "too_many_files",
69
+ "too_many_checks",
70
+ }
71
+ )
72
+
73
+
74
+ def is_decomposable_failure(result: Mapping[str, Any]) -> bool:
75
+ """True when a child failure can be retried at finer granularity."""
76
+
77
+ error = result.get("error")
78
+ if not isinstance(error, Mapping):
79
+ return False
80
+ kind = error.get("kind")
81
+ if kind in _DECOMPOSABLE_KINDS:
82
+ return True
83
+ if kind == "controller_policy":
84
+ message = str(error.get("message", ""))
85
+ return "max_files" in message or "context" in message
86
+ return False
87
+
88
+
89
+ class DelegatingAgent:
90
+ """Decompose a task into bounded children and delegate each one.
91
+
92
+ ``delegate`` is the harness-agnostic seam (e.g. ``DelegationService.delegate``)
93
+ with signature ``(caller_id, request) -> result``. ``workspace_ref`` and
94
+ ``model_profile`` are deployment configuration, not part of the task.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ delegate: Delegate,
100
+ *,
101
+ workspace_ref: str,
102
+ model_profile: str,
103
+ budget: TaskBudget | None = None,
104
+ templates: Sequence[DecompositionTemplate] = DEFAULT_TEMPLATES,
105
+ max_depth: int = 3,
106
+ max_parallel_children: int = 1,
107
+ ) -> None:
108
+ if max_depth <= 0:
109
+ raise ValueError("max_depth must be positive")
110
+ if not templates:
111
+ raise ValueError("templates must not be empty")
112
+ if max_parallel_children <= 0:
113
+ raise ValueError("max_parallel_children must be positive")
114
+ self.delegate = delegate
115
+ self.workspace_ref = workspace_ref
116
+ self.model_profile = model_profile
117
+ self.budget = budget or TaskBudget()
118
+ self.templates = tuple(templates)
119
+ self.max_depth = max_depth
120
+ self.max_parallel_children = max_parallel_children
121
+
122
+ def run(self, caller_id: str, task: TaskEnvelope) -> dict[str, Any]:
123
+ if not isinstance(caller_id, str) or not caller_id.strip():
124
+ raise ValueError("caller_id must be a non-empty string")
125
+ splits = [0]
126
+ leaves = self._dispatch(caller_id, task, 0, splits)
127
+ accepted = sum(1 for leaf in leaves if leaf["result"].get("status") == "accepted")
128
+ if accepted == len(leaves):
129
+ status = "accepted"
130
+ elif accepted:
131
+ status = "partial"
132
+ else:
133
+ status = "failed"
134
+ return {
135
+ "status": status,
136
+ "task_id": task.id,
137
+ "splits": splits[0],
138
+ "children": leaves,
139
+ }
140
+
141
+ def _dispatch(
142
+ self,
143
+ caller_id: str,
144
+ task: TaskEnvelope,
145
+ depth: int,
146
+ splits: list[int],
147
+ ) -> list[dict[str, Any]]:
148
+ template = self.templates[min(depth, len(self.templates) - 1)]
149
+ children = template.split(task, self.budget)
150
+ leaves: list[dict[str, Any]] = []
151
+
152
+ def run_child(child: TaskEnvelope, index: int) -> tuple[dict[str, Any], bool]:
153
+ request = DelegationRequest(
154
+ request_id=f"{task.id}@{depth}.{index}",
155
+ workspace_ref=self.workspace_ref,
156
+ model_profile=self.model_profile,
157
+ task=child,
158
+ )
159
+ result = self.delegate(caller_id, request)
160
+ return dict(result), is_decomposable_failure(result) and self._can_split_further(child, depth)
161
+
162
+ if self.max_parallel_children > 1 and len(children) > 1:
163
+ with ThreadPoolExecutor(max_workers=self.max_parallel_children) as pool:
164
+ outcomes = list(pool.map(lambda item: run_child(item[1], item[0]), enumerate(children)))
165
+ else:
166
+ outcomes = [run_child(child, index) for index, child in enumerate(children)]
167
+
168
+ for child, (result, decomposable) in zip(children, outcomes):
169
+ if decomposable:
170
+ splits[0] += 1
171
+ leaves.extend(self._dispatch(caller_id, child, depth + 1, splits))
172
+ else:
173
+ leaves.append({"task_id": child.id, "depth": depth, "result": result})
174
+ return leaves
175
+
176
+ def _can_split_further(self, task: TaskEnvelope, depth: int) -> bool:
177
+ if depth + 1 >= self.max_depth:
178
+ return False
179
+ next_template = self.templates[min(depth + 1, len(self.templates) - 1)]
180
+ return len(next_template.split(task, self.budget)) > 1
@@ -0,0 +1,8 @@
1
+ """Desktop AI Coding Harness module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import launch_desktop_app
6
+ from .server import DesktopServer
7
+
8
+ __all__ = ["launch_desktop_app", "DesktopServer"]
@@ -0,0 +1,100 @@
1
+ """Desktop application launcher using pywebview with resilient browser fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import time
8
+ import webbrowser
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .server import DesktopServer
13
+
14
+
15
+ def launch_desktop_app(
16
+ host: str = "127.0.0.1",
17
+ port: int = 8765,
18
+ workspace: str | Path = ".",
19
+ default_profile: str = "qwen2.5-coder",
20
+ browser: bool = False,
21
+ headless: bool = False,
22
+ ) -> int:
23
+ """Launch the Desktop AI Coding Harness."""
24
+ server = DesktopServer(
25
+ host=host,
26
+ port=port,
27
+ workspace=workspace,
28
+ default_profile=default_profile,
29
+ )
30
+ server.start()
31
+ app_url = f"{server.url}/app"
32
+
33
+ if headless:
34
+ print(
35
+ json.dumps(
36
+ {
37
+ "status": "ready",
38
+ "url": app_url,
39
+ "workspace": str(Path(workspace).resolve()),
40
+ },
41
+ ensure_ascii=False,
42
+ ),
43
+ flush=True,
44
+ )
45
+ try:
46
+ while True:
47
+ time.sleep(1)
48
+ except KeyboardInterrupt:
49
+ pass
50
+ finally:
51
+ server.stop()
52
+ return 0
53
+
54
+ print("=" * 72)
55
+ print(" Local AI Coding Harness — Standalone Desktop Cockpit")
56
+ print(f" Interface URL: {app_url}")
57
+ print(f" Workspace: {Path(workspace).resolve()}")
58
+ print("=" * 72)
59
+
60
+ has_webview = False
61
+ if not browser:
62
+ try:
63
+ import webview # type: ignore[import-not-found]
64
+ has_webview = True
65
+ except ImportError:
66
+ has_webview = False
67
+
68
+ if has_webview and not browser:
69
+ try:
70
+ import webview # type: ignore[import-not-found]
71
+
72
+ print("Launching native desktop window (WebView2 / Cocoa / WebKit)...")
73
+ webview.create_window(
74
+ "Local AI Coding Harness",
75
+ app_url,
76
+ width=1360,
77
+ height=840,
78
+ min_size=(960, 640),
79
+ )
80
+ webview.start()
81
+ server.stop()
82
+ print("Desktop window closed.")
83
+ return 0
84
+ except Exception as error:
85
+ print(f"Native desktop window initialization error: {error}. Falling back to default browser...")
86
+
87
+ # Fallback to system browser
88
+ print("Opening application in default web browser (Press Ctrl+C to stop)...")
89
+ try:
90
+ webbrowser.open(app_url)
91
+ except Exception:
92
+ pass
93
+
94
+ try:
95
+ while True:
96
+ time.sleep(1)
97
+ except KeyboardInterrupt:
98
+ server.stop()
99
+ print("\nDesktop Harness stopped.")
100
+ return 0