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,276 @@
1
+ """Rolling context window: compaction of long chat histories for bounded models.
2
+
3
+ Local models run with a fixed ``num_ctx``; a long multi-turn session eventually
4
+ produces a prompt larger than that window and the request dies. This module
5
+ implements the borrowed "rolling window" pattern:
6
+
7
+ - Evict WHOLE turns oldest-first. A turn starts at a ``user`` message and
8
+ includes everything up to (not including) the next ``user`` message. The
9
+ newest message is never dropped.
10
+ - Insert a single visible system notice describing the omission, so the model
11
+ knows earlier context was truncated rather than never existed.
12
+ - Support a STICKY boundary: callers persist ``next_sticky_boundary`` per
13
+ session and pass it back on the next call, so repeated compactions keep
14
+ dropping from the same frontier instead of creeping forward turn-by-turn
15
+ (which would shred llama-server's prefix cache).
16
+ - Leave headroom below the budget (see
17
+ ``ROLLING_COMPACTION_HEADROOM_RATIO``) so the next turn does not immediately
18
+ re-trigger compaction.
19
+
20
+ All functions are pure: no I/O and no global state is mutated. Token counts
21
+ are honest character-based estimates (see :func:`estimate_tokens`), never
22
+ self-reported model numbers.
23
+ """
24
+
25
+ import math
26
+ from typing import Any
27
+
28
+ ROLLING_COMPACTION_HEADROOM_RATIO = 0.9
29
+ """Fraction of ``token_budget`` that :func:`fit_history` aims to fit under.
30
+
31
+ Compaction triggers when the history exceeds the full budget, but evicts only
32
+ until the compacted history fits ``int(token_budget * 0.9)``. The reserved
33
+ 10% headroom absorbs the next user/assistant exchange so compaction does not
34
+ re-trigger on every single turn.
35
+ """
36
+
37
+ _CHARS_PER_TOKEN = 4
38
+ _UNBROKEN_ASCII_CHARS_PER_TOKEN = 2
39
+ _UNBROKEN_ASCII_RUN_MIN = 64
40
+ _MESSAGE_OVERHEAD_TOKENS = 4
41
+
42
+ _CJK_RANGES = (
43
+ (0x3000, 0x303F), # CJK symbols and punctuation
44
+ (0x3040, 0x309F), # Hiragana
45
+ (0x30A0, 0x30FF), # Katakana
46
+ (0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
47
+ (0x4E00, 0x9FFF), # CJK Unified Ideographs
48
+ (0xAC00, 0xD7AF), # Hangul syllables
49
+ (0xF900, 0xFAFF), # CJK Compatibility Ideographs
50
+ (0xFF00, 0xFFEF), # Fullwidth forms
51
+ )
52
+
53
+
54
+ def _is_cjk(ch: str) -> bool:
55
+ cp = ord(ch)
56
+ return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
57
+
58
+
59
+ def estimate_tokens(text: str) -> int:
60
+ """Honest char-based token estimate for a single string.
61
+
62
+ - Normal text (short ASCII runs, non-CJK characters): ~4 chars/token.
63
+ - CJK ideographs/kana/hangul/fullwidth punctuation: ~1 token/char.
64
+ - An unbroken ASCII run (no whitespace) of >= 64 chars — hashes, base64,
65
+ minified blobs — is priced at ~2 chars/token.
66
+
67
+ Runs are broken by whitespace, so ordinary prose with spaces stays at
68
+ ~4 chars/token. Returns the ceiling of the accumulated weight; empty
69
+ strings cost 0.
70
+ """
71
+ if not text:
72
+ return 0
73
+ total_weight = 0.0
74
+ n = len(text)
75
+ i = 0
76
+ while i < n:
77
+ ch = text[i]
78
+ if ch.isascii() and not ch.isspace():
79
+ j = i
80
+ while j < n and text[j].isascii() and not text[j].isspace():
81
+ j += 1
82
+ run_len = j - i
83
+ if run_len >= _UNBROKEN_ASCII_RUN_MIN:
84
+ total_weight += run_len / _UNBROKEN_ASCII_CHARS_PER_TOKEN
85
+ else:
86
+ total_weight += run_len / _CHARS_PER_TOKEN
87
+ i = j
88
+ elif _is_cjk(ch):
89
+ total_weight += 1.0
90
+ i += 1
91
+ else: # whitespace and other non-CJK characters
92
+ total_weight += 1.0 / _CHARS_PER_TOKEN
93
+ i += 1
94
+ return math.ceil(total_weight)
95
+
96
+
97
+ def estimate_messages_tokens(messages: list[dict[str, Any]]) -> int:
98
+ """Estimate the prompt tokens for an OpenAI-style message list.
99
+
100
+ Sum of :func:`estimate_tokens` over each message's content plus a small
101
+ per-message overhead constant (~4 tokens) covering role framing. Non-str
102
+ content is coerced with ``str(content)`` for estimation only; callers must
103
+ coerce identically at send time or treat this as a lower bound.
104
+ """
105
+ return sum(
106
+ estimate_tokens(str(message.get("content", ""))) + _MESSAGE_OVERHEAD_TOKENS
107
+ for message in messages
108
+ )
109
+
110
+
111
+ def _compaction_notice(dropped_count: int) -> str:
112
+ return (
113
+ f"[context compacted: {dropped_count} earlier message(s) omitted "
114
+ f"to fit the model context window]"
115
+ )
116
+
117
+
118
+ def _message_cost(message: dict[str, Any]) -> int:
119
+ return estimate_tokens(str(message.get("content", ""))) + _MESSAGE_OVERHEAD_TOKENS
120
+
121
+
122
+ def _plan_eviction(
123
+ messages: list[dict[str, Any]], sticky_boundary: int
124
+ ) -> tuple[set[int], list[tuple[int, int]]]:
125
+ """Plan this run's eviction.
126
+
127
+ Returns ``(dead_indices, units)``:
128
+
129
+ - ``dead_indices``: messages at ``[1, frontier)`` already compacted away by
130
+ earlier runs (sticky replay of the full transcript). They are omitted
131
+ from the output for free and are NOT counted in ``dropped_count``.
132
+ - ``units``: half-open index ranges evictable THIS run, oldest first. A
133
+ unit is a whole turn (starts at a ``user`` message, runs to the next
134
+ ``user`` message) with ``start >= max(1, sticky_boundary)``; a turn
135
+ containing the last message contributes only its prefix
136
+ ``[start, last_index)`` so the final message is never touched. Index 0
137
+ is always protected.
138
+
139
+ If nothing is droppable at/after the sticky boundary, the boundary is
140
+ abandoned: ``dead_indices`` is emptied and units are rebuilt from index 1
141
+ (the protected region becomes ordinary evictable content).
142
+ """
143
+ last_idx = len(messages) - 1
144
+ unit_floor = max(1, sticky_boundary)
145
+ frontier = min(unit_floor, max(last_idx, 1))
146
+ first_user = next(
147
+ (i for i, m in enumerate(messages) if m.get("role") == "user"), None
148
+ )
149
+
150
+ def collect(floor: int) -> list[tuple[int, int]]:
151
+ if first_user is None:
152
+ return []
153
+ starts = [first_user] + [
154
+ i
155
+ for i in range(first_user + 1, len(messages))
156
+ if messages[i].get("role") == "user"
157
+ ]
158
+ units: list[tuple[int, int]] = []
159
+ for pos, start in enumerate(starts):
160
+ end = starts[pos + 1] if pos + 1 < len(starts) else len(messages)
161
+ if end <= last_idx:
162
+ if start >= floor:
163
+ units.append((start, end))
164
+ else: # turn contains the last message: only its prefix is evictable
165
+ p_start = max(start, floor)
166
+ if p_start < last_idx:
167
+ units.append((p_start, last_idx))
168
+ units.sort()
169
+ return units
170
+
171
+ units = collect(unit_floor)
172
+ if units:
173
+ return set(range(1, frontier)), units
174
+ return set(), collect(1)
175
+
176
+
177
+ def fit_history(
178
+ messages: list[dict[str, Any]],
179
+ token_budget: int,
180
+ sticky_boundary: int = 0,
181
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
182
+ """Compact a chat history to fit ``token_budget`` estimated tokens.
183
+
184
+ Semantics:
185
+ - ``token_budget <= 0`` raises ``ValueError``. If even system+last exceed
186
+ the budget, still returns the best-effort result (system?, notice, last)
187
+ with an honest (possibly over-budget) ``estimated_tokens``; never raises
188
+ beyond the budget-validation error.
189
+ - Never drops ``messages[0]`` when its role is ``"system"`` (index 0 is
190
+ protected unconditionally) and never drops the last message.
191
+ - If the whole history already fits the budget, returns ``(copy, info)``
192
+ unchanged with ``compacted=False``.
193
+ - Otherwise drops whole turns (a turn starts at a ``user`` message and runs
194
+ to the next ``user`` message) oldest-first, beginning after index 0 and,
195
+ when ``sticky_boundary > 0``, only from indices ``>= sticky_boundary``;
196
+ if nothing droppable exists at/after the sticky boundary, falls back to
197
+ dropping from index 1. Eviction stops once the projected result fits
198
+ ``int(token_budget * ROLLING_COMPACTION_HEADROOM_RATIO)``; if everything
199
+ evictable was dropped and it still does not fit, the best-effort result
200
+ is returned anyway.
201
+ - Sticky replay: messages at indices ``[1, sticky_frontier)`` of the input
202
+ were already compacted away by earlier runs (the caller replays the full
203
+ transcript together with the persisted boundary). They are omitted from
204
+ the output for free — not counted in ``dropped_count`` and not mentioned
205
+ by this run's notice. The frontier never covers the last message.
206
+ - When messages were dropped, exactly one system-role notice is inserted
207
+ right after the surviving system message (or at index 0 if there is none).
208
+
209
+ Non-str content is coerced with ``str(content)`` for estimation; original
210
+ message objects are preserved untouched (the returned list is a shallow
211
+ copy plus, if compacting, one new notice dict).
212
+
213
+ ``info`` keys:
214
+ - ``dropped_count``: number of messages evicted THIS run (whole turns /
215
+ final-turn prefixes), excluding free omissions below the sticky frontier.
216
+ - ``next_sticky_boundary``: defined EXACTLY as
217
+ ``sticky_boundary + dropped_count``. It is an index into the ORIGINAL
218
+ message list marking where future compactions should start dropping:
219
+ every index below it has been omitted or evicted by some run so far.
220
+ Persist it per session (e.g. alongside the stored transcript) and pass
221
+ it back as ``sticky_boundary`` on the next call; without it the eviction
222
+ frontier creeps forward each turn and destroys llama-server's prefix
223
+ cache.
224
+ - ``estimated_tokens``: honest estimate of the returned list (notice
225
+ included), recomputed on the final result.
226
+ - ``compacted``: True iff any message was dropped or omitted.
227
+ """
228
+ if token_budget <= 0:
229
+ raise ValueError("token_budget must be a positive integer")
230
+ costs = [_message_cost(m) for m in messages]
231
+ total = sum(costs)
232
+ if total <= token_budget:
233
+ return list(messages), {
234
+ "dropped_count": 0,
235
+ "next_sticky_boundary": sticky_boundary,
236
+ "estimated_tokens": total,
237
+ "compacted": False,
238
+ }
239
+
240
+ dead, units = _plan_eviction(messages, sticky_boundary)
241
+ live_total = total - sum(costs[i] for i in dead)
242
+ target = int(token_budget * ROLLING_COMPACTION_HEADROOM_RATIO)
243
+ dropped_indices: set[int] = set()
244
+ dropped_tokens = 0
245
+ dropped_count = 0
246
+ for start, end in units:
247
+ dropped_indices.update(range(start, end))
248
+ dropped_tokens += sum(costs[start:end])
249
+ dropped_count += end - start
250
+ notice_cost = (
251
+ estimate_tokens(_compaction_notice(dropped_count))
252
+ + _MESSAGE_OVERHEAD_TOKENS
253
+ )
254
+ if live_total - dropped_tokens + notice_cost <= target:
255
+ break
256
+
257
+ if not dead and not dropped_indices:
258
+ # Over budget but nothing is evictable (e.g. [system, last] alone).
259
+ return list(messages), {
260
+ "dropped_count": 0,
261
+ "next_sticky_boundary": sticky_boundary,
262
+ "estimated_tokens": total,
263
+ "compacted": False,
264
+ }
265
+
266
+ skip = dead | dropped_indices
267
+ result = [m for i, m in enumerate(messages) if i not in skip]
268
+ notice_msg = {"role": "system", "content": _compaction_notice(dropped_count)}
269
+ insert_at = 1 if messages[0].get("role") == "system" else 0
270
+ result.insert(insert_at, notice_msg)
271
+ return result, {
272
+ "dropped_count": dropped_count,
273
+ "next_sticky_boundary": sticky_boundary + dropped_count,
274
+ "estimated_tokens": estimate_messages_tokens(result),
275
+ "compacted": True,
276
+ }
@@ -0,0 +1,12 @@
1
+ """Command-line entry point for local-coding-agent.
2
+
3
+ Refactored from a single module into a package. The full public API is
4
+ re-exported here so existing imports keep working.
5
+ """
6
+
7
+ from ._main import main
8
+ from ._parser import build_parser
9
+ from ._dispatch import handle_subcommand
10
+ from ._input import load_task_input, load_task_file
11
+
12
+ __all__ = ["main", "build_parser", "handle_subcommand", "load_task_input", "load_task_file"]
@@ -0,0 +1,377 @@
1
+ """Subcommand dispatch and remaining handlers for the local-coding-agent CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from ._handlers import (
11
+ _handle_apply,
12
+ _handle_calibrate,
13
+ _handle_decompose,
14
+ _handle_delegate,
15
+ _handle_memory,
16
+ _handle_profiles,
17
+ )
18
+ from ._handlers2 import (
19
+ _handle_benchmark,
20
+ _handle_chat,
21
+ _handle_desktop,
22
+ _handle_doctor,
23
+ _handle_init_mcp,
24
+ _handle_monitor,
25
+ _handle_scan_models,
26
+ _handle_serve_acp,
27
+ _handle_serve_mcp,
28
+ _handle_sessions,
29
+ _handle_skill,
30
+ _handle_smoke,
31
+ )
32
+
33
+
34
+ def _handle_skeletonize(args: argparse.Namespace) -> int:
35
+ from ..ast_compactor import skeletonize_file
36
+
37
+ try:
38
+ skeleton = skeletonize_file(str(args.file), target_symbols=args.symbols)
39
+ if getattr(args, "json", False):
40
+ print(
41
+ json.dumps(
42
+ {"file": str(args.file), "symbols": args.symbols, "skeleton": skeleton},
43
+ ensure_ascii=False,
44
+ indent=2,
45
+ )
46
+ )
47
+ else:
48
+ try:
49
+ print(skeleton)
50
+ except UnicodeEncodeError:
51
+ if hasattr(sys.stdout, "buffer"):
52
+ sys.stdout.buffer.write(skeleton.encode("utf-8", errors="replace"))
53
+ sys.stdout.buffer.write(b"\n")
54
+ sys.stdout.buffer.flush()
55
+ else:
56
+ print(skeleton.encode("ascii", errors="replace").decode("ascii"))
57
+ return 0
58
+ except Exception as error:
59
+ if getattr(args, "json", False):
60
+ print(
61
+ json.dumps(
62
+ {"status": "failed", "error": str(error)},
63
+ ensure_ascii=False,
64
+ indent=2,
65
+ )
66
+ )
67
+ else:
68
+ print(f"Error skeletonizing file: {error}", file=sys.stderr)
69
+ return 1
70
+
71
+
72
+ def _handle_lint_patch(args: argparse.Namespace) -> int:
73
+ from ..semantic_linter import lint_patch_in_memory
74
+
75
+ patch_str = args.patch
76
+ if args.patch_file:
77
+ patch_str = Path(args.patch_file).read_text(encoding="utf-8-sig", errors="replace")
78
+ if not patch_str:
79
+ if getattr(args, "json", False):
80
+ print(
81
+ json.dumps(
82
+ {"valid": False, "error": "No patch specified"},
83
+ ensure_ascii=False,
84
+ indent=2,
85
+ )
86
+ )
87
+ else:
88
+ print("Error: No patch specified via --patch or --patch-file", file=sys.stderr)
89
+ return 2
90
+ report = lint_patch_in_memory(str(args.workspace), patch_str)
91
+ if getattr(args, "json", False):
92
+ print(
93
+ json.dumps(
94
+ {
95
+ "valid": report.valid,
96
+ "diagnostics": [
97
+ {"file": d.file, "line": d.line, "message": d.message, "rule": d.rule}
98
+ for d in report.diagnostics
99
+ ],
100
+ "prescriptions": list(report.prescriptions),
101
+ },
102
+ ensure_ascii=False,
103
+ indent=2,
104
+ )
105
+ )
106
+ else:
107
+ if report.valid:
108
+ print("[OK] Patch passed fast static linter gates without issues.")
109
+ else:
110
+ print("[FAIL] Patch failed static linter gates:")
111
+ for p in report.prescriptions:
112
+ print(f" - {p}")
113
+ return 0 if report.valid else 1
114
+
115
+
116
+ def _handle_spill_read(args: argparse.Namespace) -> int:
117
+ from ..spill import read_spill
118
+
119
+ loc = args.opt_locator or args.locator
120
+ if not loc:
121
+ if getattr(args, "json", False):
122
+ print(json.dumps({"status": "failed", "error": "Spill locator is required"}, ensure_ascii=False, indent=2))
123
+ else:
124
+ print("Error: spill locator is required", file=sys.stderr)
125
+ return 2
126
+ try:
127
+ content = read_spill(loc, offset_line=args.offset, limit_lines=args.limit)
128
+ if getattr(args, "json", False):
129
+ print(
130
+ json.dumps(
131
+ {"locator": loc, "offset": args.offset, "limit": args.limit, "content": content},
132
+ ensure_ascii=False,
133
+ indent=2,
134
+ )
135
+ )
136
+ else:
137
+ try:
138
+ print(content)
139
+ except UnicodeEncodeError:
140
+ if hasattr(sys.stdout, "buffer"):
141
+ sys.stdout.buffer.write(content.encode("utf-8", errors="replace"))
142
+ sys.stdout.buffer.write(b"\n")
143
+ sys.stdout.buffer.flush()
144
+ else:
145
+ print(content.encode("ascii", errors="replace").decode("ascii"))
146
+ return 0
147
+ except Exception as error:
148
+ if getattr(args, "json", False):
149
+ print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
150
+ else:
151
+ print(f"Error reading spill: {error}", file=sys.stderr)
152
+ return 1
153
+
154
+
155
+ def _handle_grep(args: argparse.Namespace) -> int:
156
+ from ..ripgrep import ripgrep_search
157
+
158
+ try:
159
+ globs = args.paths if args.paths else None
160
+ matches = ripgrep_search(
161
+ args.query,
162
+ root=args.workspace,
163
+ globs=globs,
164
+ is_regex=args.regex,
165
+ case_sensitive=args.case_sensitive,
166
+ max_results=args.max_results,
167
+ )
168
+ if getattr(args, "json", False):
169
+ print(
170
+ json.dumps(
171
+ {
172
+ "query": args.query,
173
+ "count": len(matches),
174
+ "matches": [
175
+ {"file": m.file, "line": m.line_number, "text": m.line_content}
176
+ for m in matches
177
+ ],
178
+ },
179
+ ensure_ascii=False,
180
+ indent=2,
181
+ )
182
+ )
183
+ else:
184
+ for m in matches:
185
+ print(f"{m.file}:{m.line_number}: {m.line_content}")
186
+ return 0
187
+ except Exception as error:
188
+ if getattr(args, "json", False):
189
+ print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
190
+ else:
191
+ print(f"Error during grep: {error}", file=sys.stderr)
192
+ return 1
193
+
194
+
195
+ def _handle_lsp(args: argparse.Namespace) -> int:
196
+ from ..lsp import LspManager, LspPosition
197
+
198
+ try:
199
+ manager = LspManager(workspace_root=args.workspace)
200
+ if args.operation == "definition":
201
+ res = manager.go_to_definition(args.file, line=args.line, character=args.char, workspace_root=args.workspace)
202
+ data = [r.to_dict() for r in res]
203
+ elif args.operation == "references":
204
+ res = manager.find_references(args.file, line=args.line, character=args.char, workspace_root=args.workspace)
205
+ data = [r.to_dict() for r in res]
206
+ elif args.operation == "hover":
207
+ h_res = manager.hover(args.file, line=args.line, character=args.char, workspace_root=args.workspace)
208
+ data = h_res.to_dict() if h_res else None
209
+ elif args.operation == "symbols":
210
+ syms = manager.document_symbols(args.file, workspace_root=args.workspace)
211
+ data = [s.to_dict() for s in syms]
212
+ else:
213
+ data = None
214
+
215
+ if getattr(args, "json", False):
216
+ print(
217
+ json.dumps(
218
+ {"operation": args.operation, "file": str(args.file), "result": data},
219
+ ensure_ascii=False,
220
+ indent=2,
221
+ )
222
+ )
223
+ else:
224
+ print(json.dumps(data, ensure_ascii=False, indent=2))
225
+ return 0
226
+ except Exception as error:
227
+ if getattr(args, "json", False):
228
+ print(json.dumps({"status": "failed", "error": str(error)}, ensure_ascii=False, indent=2))
229
+ else:
230
+ print(f"Error during LSP query: {error}", file=sys.stderr)
231
+ return 1
232
+
233
+
234
+ def _handle_init_agent(args: argparse.Namespace) -> int:
235
+ import os
236
+
237
+ from ..profiles import get_profile
238
+
239
+ base_url = args.desktop_url.rstrip("/")
240
+ model = getattr(args, "model", None)
241
+ if not model:
242
+ try:
243
+ profile = get_profile(os.environ.get("LCA_PROFILE") or "qwen2.5-coder")
244
+ raw = str(getattr(profile, "model", "") or "qwen2.5-coder")
245
+ model = raw if ":" in raw else f"{raw}:latest"
246
+ except Exception:
247
+ model = "qwen2.5-coder:latest"
248
+
249
+ def emit(payload: dict, human: list[str], code: int = 0) -> int:
250
+ if getattr(args, "json", False):
251
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
252
+ else:
253
+ print("\n".join(human))
254
+ return code
255
+
256
+ if args.agent == "codex":
257
+ toml_block = (
258
+ "[model_providers.local_agent]\n"
259
+ f'name = "Local Coding Agent (Desktop Harness)"\n'
260
+ f'base_url = "{base_url}/v1"\n'
261
+ 'env_key = "LOCAL_AGENT_API_KEY"\n'
262
+ 'wire_api = "chat"\n'
263
+ )
264
+ launch_cmd = (
265
+ f'codex -c model_provider=local_agent -m "{model}" '
266
+ "(set LOCAL_AGENT_API_KEY first; the Desktop Harness ignores its value)"
267
+ )
268
+ env_hint = (
269
+ f'PowerShell: $env:LOCAL_AGENT_API_KEY="local" | sh/bash: export LOCAL_AGENT_API_KEY=local'
270
+ )
271
+ config_path = None
272
+ written = False
273
+ error_msg = None
274
+ if args.write:
275
+ codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
276
+ config_path = codex_home / "config.toml"
277
+ try:
278
+ existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
279
+ if "[model_providers.local_agent]" in existing:
280
+ return emit(
281
+ {"status": "failed", "error": f"{config_path} already defines [model_providers.local_agent]; edit it manually."},
282
+ [f"[REFUSED] {config_path} already defines [model_providers.local_agent]. Edit it manually instead of duplicating."],
283
+ 1,
284
+ )
285
+ config_path.parent.mkdir(parents=True, exist_ok=True)
286
+ with open(config_path, "a", encoding="utf-8") as fh:
287
+ if existing and not existing.endswith("\n"):
288
+ fh.write("\n")
289
+ fh.write(toml_block)
290
+ written = True
291
+ except OSError as os_error:
292
+ return emit(
293
+ {"status": "failed", "error": str(os_error)},
294
+ [f"[FAILED] Could not write {config_path}: {os_error}"],
295
+ 1,
296
+ )
297
+ payload = {
298
+ "status": "ok",
299
+ "agent": "codex",
300
+ "model": model,
301
+ "base_url": f"{base_url}/v1",
302
+ "config_path": str(config_path) if config_path else None,
303
+ "written": written,
304
+ "config_snippet": toml_block,
305
+ "launch_command": launch_cmd,
306
+ }
307
+ human = [
308
+ f"--- Codex -> Local Agent ({'written' if written else 'preview'}) ---",
309
+ toml_block,
310
+ f"Launch: {launch_cmd}",
311
+ f"Auth: {env_hint}",
312
+ f"Prereq: start the harness first (`local-agent desktop`), then load a model in Local Inference Servers.",
313
+ ]
314
+ if config_path:
315
+ human.insert(1, f"Config: {config_path}")
316
+ return emit(payload, human)
317
+
318
+ # generic OpenAI-compatible target
319
+ payload = {
320
+ "status": "ok",
321
+ "agent": "generic",
322
+ "model": model,
323
+ "base_url": f"{base_url}/v1",
324
+ "env": {
325
+ "OPENAI_BASE_URL": f"{base_url}/v1",
326
+ "OPENAI_API_KEY": "local",
327
+ },
328
+ }
329
+ human = [
330
+ "--- Generic OpenAI-compatible client -> Local Agent ---",
331
+ f" OPENAI_BASE_URL={base_url}/v1",
332
+ " OPENAI_API_KEY=local # value ignored by the Desktop Harness",
333
+ f" Model id to request: {model}",
334
+ "Prereq: start the harness first (`local-agent desktop`), then load a model in Local Inference Servers.",
335
+ ]
336
+ return emit(payload, human)
337
+
338
+
339
+ _HANDLERS = {
340
+ "delegate": _handle_delegate,
341
+ "run": _handle_delegate,
342
+ "decompose": _handle_decompose,
343
+ "atomize": _handle_decompose,
344
+ "profiles": _handle_profiles,
345
+ "memory": _handle_memory,
346
+ "calibrate": _handle_calibrate,
347
+ "apply": _handle_apply,
348
+ "init-skill": _handle_skill,
349
+ "skill": _handle_skill,
350
+ "doctor": _handle_doctor,
351
+ "init-mcp": _handle_init_mcp,
352
+ "test-run": _handle_smoke,
353
+ "smoke": _handle_smoke,
354
+ "serve-mcp": _handle_serve_mcp,
355
+ "serve-acp": _handle_serve_acp,
356
+ "monitor": _handle_monitor,
357
+ "ui": _handle_monitor,
358
+ "app": _handle_monitor,
359
+ "desktop": _handle_desktop,
360
+ "chat": _handle_chat,
361
+ "scan-models": _handle_scan_models,
362
+ "benchmark": _handle_benchmark,
363
+ "skeletonize": _handle_skeletonize,
364
+ "lint-patch": _handle_lint_patch,
365
+ "spill-read": _handle_spill_read,
366
+ "grep": _handle_grep,
367
+ "lsp": _handle_lsp,
368
+ "sessions": _handle_sessions,
369
+ "init-agent": _handle_init_agent,
370
+ }
371
+
372
+
373
+ def handle_subcommand(args: argparse.Namespace) -> int:
374
+ handler = _HANDLERS.get(args.subcommand)
375
+ if handler is None:
376
+ return -1
377
+ return handler(args)