power-loop 0.2.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 (53) hide show
  1. llm_client/__init__.py +0 -0
  2. llm_client/capabilities.py +162 -0
  3. llm_client/interface.py +470 -0
  4. llm_client/llm_factory.py +981 -0
  5. llm_client/llm_tooling.py +645 -0
  6. llm_client/llm_utils.py +205 -0
  7. llm_client/multimodal.py +237 -0
  8. llm_client/qwen_image.py +576 -0
  9. llm_client/web_search.py +149 -0
  10. power_loop/__init__.py +326 -0
  11. power_loop/agent/__init__.py +6 -0
  12. power_loop/agent/sink.py +247 -0
  13. power_loop/agent/stateful_loop.py +363 -0
  14. power_loop/agent/system_prompt.py +396 -0
  15. power_loop/agent/types.py +41 -0
  16. power_loop/contracts/__init__.py +132 -0
  17. power_loop/contracts/errors.py +140 -0
  18. power_loop/contracts/event_payloads.py +278 -0
  19. power_loop/contracts/events.py +86 -0
  20. power_loop/contracts/handlers.py +45 -0
  21. power_loop/contracts/hook_contexts.py +265 -0
  22. power_loop/contracts/hooks.py +64 -0
  23. power_loop/contracts/messages.py +90 -0
  24. power_loop/contracts/protocols.py +48 -0
  25. power_loop/contracts/tools.py +56 -0
  26. power_loop/core/agent_context.py +94 -0
  27. power_loop/core/events.py +124 -0
  28. power_loop/core/hooks.py +122 -0
  29. power_loop/core/phase.py +217 -0
  30. power_loop/core/pipeline.py +880 -0
  31. power_loop/core/runner.py +60 -0
  32. power_loop/core/state.py +208 -0
  33. power_loop/runtime/budget.py +179 -0
  34. power_loop/runtime/cancellation.py +127 -0
  35. power_loop/runtime/compact.py +300 -0
  36. power_loop/runtime/env.py +103 -0
  37. power_loop/runtime/memory.py +107 -0
  38. power_loop/runtime/provider.py +176 -0
  39. power_loop/runtime/retry.py +182 -0
  40. power_loop/runtime/session_store.py +636 -0
  41. power_loop/runtime/skills.py +201 -0
  42. power_loop/runtime/spec.py +233 -0
  43. power_loop/runtime/structured.py +225 -0
  44. power_loop/tools/__init__.py +51 -0
  45. power_loop/tools/default_manifest.py +244 -0
  46. power_loop/tools/default_tools.py +766 -0
  47. power_loop/tools/registry.py +162 -0
  48. power_loop/tools/spawn_agent.py +173 -0
  49. power_loop-0.2.0.dist-info/METADATA +632 -0
  50. power_loop-0.2.0.dist-info/RECORD +53 -0
  51. power_loop-0.2.0.dist-info/WHEEL +5 -0
  52. power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. power_loop-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,766 @@
1
+ from __future__ import annotations
2
+
3
+ # NOTE: This module intentionally copies tool implementations from zero-code/core/tools.py
4
+ # with only import-path adjustments for power-loop package layout.
5
+ import difflib
6
+ import os
7
+ import queue
8
+ import re
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from power_loop.core.agent_context import get_ctx
17
+ from power_loop.runtime.env import AGENT_DIR, AGENT_RW_ALLOWLIST, WORKSPACE_DIR, safe_path
18
+ from power_loop.runtime.skills import SKILL_LOADER
19
+
20
+ RESULT_MAX_CHARS = 50000
21
+ _HEAD_LINES = 30
22
+ _TAIL_LINES = 170
23
+ SENTINEL = "___ZERO_CODE_CMD_DONE___"
24
+
25
+ FILE_READ_STATE: dict[str, float] = {}
26
+
27
+
28
+ def _truncate_output(lines: list[str], head: int = _HEAD_LINES, tail: int = _TAIL_LINES) -> str:
29
+ limit = head + tail
30
+ if len(lines) <= limit:
31
+ return "\n".join(lines)
32
+ omitted = len(lines) - limit
33
+ return (
34
+ "\n".join(lines[:head])
35
+ + f"\n\n... ({omitted} lines omitted) ...\n\n"
36
+ + "\n".join(lines[-tail:])
37
+ )
38
+
39
+
40
+ class BashSession:
41
+ """Persistent bash process with merged stdout/stderr via pty."""
42
+
43
+ def __init__(self, cwd: Path):
44
+ self._cwd = cwd
45
+ self._proc: subprocess.Popen | None = None
46
+ self._q: queue.Queue[str] = queue.Queue()
47
+ self._master_fd: int | None = None
48
+ self._start()
49
+
50
+ def _start(self) -> None:
51
+ import pty
52
+
53
+ master_fd, slave_fd = pty.openpty()
54
+ try:
55
+ import termios
56
+
57
+ attrs = termios.tcgetattr(master_fd)
58
+ attrs[3] &= ~termios.ECHO
59
+ termios.tcsetattr(master_fd, termios.TCSANOW, attrs)
60
+ except Exception:
61
+ pass
62
+
63
+ env = os.environ.copy()
64
+ env["TERM"] = "dumb"
65
+
66
+ self._proc = subprocess.Popen(
67
+ ["/bin/bash", "--norc", "--noprofile"],
68
+ stdin=subprocess.PIPE,
69
+ stdout=slave_fd,
70
+ stderr=slave_fd,
71
+ text=True,
72
+ bufsize=0,
73
+ cwd=str(self._cwd),
74
+ env=env,
75
+ )
76
+ os.close(slave_fd)
77
+ self._master_fd = master_fd
78
+ self._q = queue.Queue()
79
+ threading.Thread(target=self._reader, daemon=True).start()
80
+
81
+ def _reader(self) -> None:
82
+ buf = ""
83
+ fd = self._master_fd
84
+ if fd is None:
85
+ return
86
+ try:
87
+ while True:
88
+ try:
89
+ data = os.read(fd, 4096)
90
+ except OSError:
91
+ break
92
+ if not data:
93
+ break
94
+ buf += data.decode("utf-8", errors="replace")
95
+ while True:
96
+ idx_n = buf.find("\n")
97
+ idx_r = buf.find("\r")
98
+ if idx_n == -1 and idx_r == -1:
99
+ break
100
+ candidates = [i for i in (idx_n, idx_r) if i != -1]
101
+ cut = min(candidates)
102
+ line, buf = buf[:cut], buf[cut + 1 :]
103
+ if line:
104
+ self._q.put(line)
105
+ if buf:
106
+ self._q.put(buf)
107
+ except Exception:
108
+ pass
109
+
110
+ def _drain(self, timeout: float, idle_timeout: float = 5.0) -> tuple[list[str], str | None]:
111
+ lines: list[str] = []
112
+ exit_code: str | None = None
113
+ deadline = time.monotonic() + timeout
114
+
115
+ while True:
116
+ remaining = deadline - time.monotonic()
117
+ if remaining <= 0:
118
+ break
119
+ wait = min(remaining, idle_timeout)
120
+ try:
121
+ line = self._q.get(timeout=wait)
122
+ except queue.Empty:
123
+ if self._proc is not None and self._proc.poll() is not None:
124
+ while not self._q.empty():
125
+ try:
126
+ line = self._q.get_nowait()
127
+ cleaned = re.sub(r"\\x1b\\[[0-9;]*[A-Za-z]", "", line).rstrip("\r")
128
+ if SENTINEL in cleaned:
129
+ parts = cleaned.strip().split()
130
+ if len(parts) >= 2 and parts[-1].lstrip("-").isdigit():
131
+ exit_code = parts[-1]
132
+ break
133
+ lines.append(cleaned)
134
+ except queue.Empty:
135
+ break
136
+ break
137
+ break
138
+
139
+ cleaned = re.sub(r"\\x1b\\[[0-9;]*[A-Za-z]", "", line).rstrip("\r")
140
+ if SENTINEL in cleaned:
141
+ parts = cleaned.strip().split()
142
+ if len(parts) >= 2 and parts[-1].lstrip("-").isdigit():
143
+ exit_code = parts[-1]
144
+ break
145
+ lines.append(cleaned)
146
+
147
+ return lines, exit_code
148
+
149
+ def execute(self, command: str, timeout: int = 120) -> str:
150
+ dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
151
+ if any(d in command for d in dangerous):
152
+ return (
153
+ "Error: Dangerous command blocked.\n"
154
+ "For safety, interactive or privileged commands (like sudo / shutdown) "
155
+ "must be run manually in your own terminal, not via the agent bash tool."
156
+ )
157
+
158
+ if self._proc is None or self._proc.poll() is not None:
159
+ self._start()
160
+
161
+ assert self._proc is not None and self._proc.stdin is not None
162
+ full_cmd = f"{command}\\necho {SENTINEL} $?\\n"
163
+ try:
164
+ self._proc.stdin.write(full_cmd)
165
+ self._proc.stdin.flush()
166
+ except (BrokenPipeError, OSError):
167
+ self._start()
168
+ return "Error: Bash session crashed, restarted. Please retry."
169
+
170
+ lines, exit_code = self._drain(timeout, idle_timeout=5.0)
171
+ timed_out = exit_code is None
172
+
173
+ if timed_out and self._proc is not None and self._proc.poll() is not None:
174
+ exit_code = str(self._proc.returncode)
175
+ timed_out = False
176
+
177
+ header = f"exit_code={exit_code or '?'}"
178
+ if timed_out:
179
+ header += f" (timed out after {timeout}s — command may still be running)"
180
+
181
+ body = _truncate_output(lines) if lines else "(no output)"
182
+ return f"{header}\\n{body}"[:RESULT_MAX_CHARS]
183
+
184
+ def restart(self) -> str:
185
+ if self._master_fd is not None:
186
+ try:
187
+ os.close(self._master_fd)
188
+ except OSError:
189
+ pass
190
+ self._master_fd = None
191
+ if self._proc and self._proc.poll() is None:
192
+ self._proc.terminate()
193
+ try:
194
+ self._proc.wait(timeout=5)
195
+ except Exception:
196
+ self._proc.kill()
197
+ self._start()
198
+ return "Bash session restarted."
199
+
200
+
201
+ BASH = BashSession(WORKSPACE_DIR)
202
+
203
+
204
+ def _display_path(path: Path) -> str:
205
+ resolved = path.resolve()
206
+ if resolved.is_relative_to(WORKSPACE_DIR):
207
+ return str(resolved.relative_to(WORKSPACE_DIR))
208
+ if resolved.is_relative_to(AGENT_DIR):
209
+ return f"@agent/{resolved.relative_to(AGENT_DIR)}"
210
+ return str(resolved)
211
+
212
+
213
+ _BASH_WRITE_HINTS = (
214
+ " >",
215
+ ">>",
216
+ " tee ",
217
+ " sed -i",
218
+ " rm ",
219
+ " mv ",
220
+ " cp ",
221
+ " touch ",
222
+ " mkdir ",
223
+ )
224
+ _BASH_READ_HINTS = (
225
+ " cat ",
226
+ " less ",
227
+ " head ",
228
+ " tail ",
229
+ " grep ",
230
+ " rg ",
231
+ " find ",
232
+ )
233
+
234
+
235
+ def _is_agent_path_allowed_for_bash(command: str) -> bool:
236
+ lowered = command.lower()
237
+ if str(AGENT_DIR).lower() not in lowered:
238
+ return True
239
+ return any(str(path).lower() in lowered for path in AGENT_RW_ALLOWLIST)
240
+
241
+
242
+ def _validate_bash_command_scope(command: str) -> str | None:
243
+ lowered = f" {command.lower()} "
244
+ if str(AGENT_DIR).lower() not in lowered:
245
+ return None
246
+ if _is_agent_path_allowed_for_bash(command):
247
+ return None
248
+
249
+ if any(hint in lowered for hint in _BASH_WRITE_HINTS):
250
+ return (
251
+ "Error: Writing under agent home is blocked outside allowlisted paths (.cache/logs). "
252
+ "Use workspace files or allowlisted agent paths only."
253
+ )
254
+ if any(hint in lowered for hint in _BASH_READ_HINTS):
255
+ return (
256
+ "Error: Reading agent-home internals is blocked outside allowlisted paths (.cache/logs). "
257
+ "Use load_skill(name) for skill content instead of direct file reads."
258
+ )
259
+ return None
260
+
261
+
262
+ def run_bash(command: str | None = None, restart: bool = False, timeout: int = 120) -> str:
263
+ if restart:
264
+ return BASH.restart()
265
+ if not command:
266
+ return "Error: command is required (or set restart=true)"
267
+ timeout = max(5, min(int(timeout), 600))
268
+ scope_err = _validate_bash_command_scope(command)
269
+ if scope_err:
270
+ return scope_err
271
+ return BASH.execute(command, timeout=timeout)
272
+
273
+
274
+ def _list_directory(dp: Path) -> str:
275
+ entries = sorted(dp.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
276
+ lines = [f"Directory: {_display_path(dp)}/"]
277
+ for entry in entries[:100]:
278
+ prefix = "d " if entry.is_dir() else "f "
279
+ size = ""
280
+ if entry.is_file():
281
+ size = f" ({entry.stat().st_size} bytes)"
282
+ lines.append(f" {prefix}{entry.name}{size}")
283
+ if len(entries) > 100:
284
+ lines.append(f" ... and {len(entries) - 100} more entries")
285
+ return "\\n".join(lines)
286
+
287
+
288
+ def run_read(path: str, offset: int | None = None, limit: int | None = None) -> str:
289
+ try:
290
+ fp = safe_path(path)
291
+ if fp.is_dir():
292
+ return _list_directory(fp)
293
+ text = fp.read_text()
294
+ all_lines = text.splitlines()
295
+ total = len(all_lines)
296
+ start = max(0, (offset or 1) - 1)
297
+ end = min(total, start + limit) if limit else total
298
+ selected = all_lines[start:end]
299
+ numbered = [f"{start + i + 1:>6}|{line}" for i, line in enumerate(selected)]
300
+ header = f"({total} lines total)"
301
+ if start > 0 or end < total:
302
+ header = f"(showing lines {start+1}-{end} of {total})"
303
+ FILE_READ_STATE[str(fp)] = fp.stat().st_mtime
304
+ return header + "\\n" + "\\n".join(numbered)
305
+ except Exception as e:
306
+ return f"Error: {e}"
307
+
308
+
309
+ def run_write(path: str, content: str) -> str:
310
+ try:
311
+ fp = safe_path(path)
312
+ fp.parent.mkdir(parents=True, exist_ok=True)
313
+ existed = fp.exists()
314
+ old_size = fp.stat().st_size if existed else 0
315
+ fp.write_text(content)
316
+ line_count = content.count("\\n") + (1 if content and not content.endswith("\\n") else 0)
317
+ display = _display_path(fp)
318
+ if existed:
319
+ return f"Wrote {len(content)} bytes ({line_count} lines) to {display} (overwritten, was {old_size} bytes) [workspace: {WORKSPACE_DIR}]"
320
+ return f"Wrote {len(content)} bytes ({line_count} lines) to {display} (new file) [workspace: {WORKSPACE_DIR}]"
321
+ except Exception as e:
322
+ return f"Error: {e}"
323
+
324
+
325
+ def _check_read_state(fp: Path) -> str | None:
326
+ key = str(fp)
327
+ if key not in FILE_READ_STATE:
328
+ return f"Error: File has not been read yet. Use read_file first before editing: {fp.name}"
329
+ if fp.exists():
330
+ current_mtime = fp.stat().st_mtime
331
+ if current_mtime > FILE_READ_STATE[key]:
332
+ return f"Error: File was modified since last read. Re-read it first: {fp.name}"
333
+ return None
334
+
335
+
336
+ def _detect_line_ending(content: str) -> str:
337
+ crlf_idx = content.find("\\r\\n")
338
+ lf_idx = content.find("\\n")
339
+ if lf_idx == -1:
340
+ return "\\n"
341
+ if crlf_idx == -1:
342
+ return "\\n"
343
+ return "\\r\\n" if crlf_idx < lf_idx else "\\n"
344
+
345
+
346
+ def _normalize_to_lf(text: str) -> str:
347
+ return text.replace("\\r\\n", "\\n").replace("\\r", "\\n")
348
+
349
+
350
+ def _restore_line_endings(text: str, ending: str) -> str:
351
+ return text.replace("\\n", "\\r\\n") if ending == "\\r\\n" else text
352
+
353
+
354
+ def _strip_bom(content: str) -> tuple[str, str]:
355
+ if content.startswith("\ufeff"):
356
+ return "\ufeff", content[1:]
357
+ return "", content
358
+
359
+
360
+ def _normalize_unicode(text: str) -> str:
361
+ lines = text.split("\\n")
362
+ stripped = "\\n".join(line.rstrip() for line in lines)
363
+ result = re.sub(r"[\u2018\u2019\u201a\u201b]", "'", stripped)
364
+ result = re.sub(r"[\u201c\u201d\u201e\u201f]", '"', result)
365
+ result = re.sub(r"[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]", "-", result)
366
+ result = re.sub(r"[\u00a0\u2002-\u200a\u202f\u205f\u3000]", " ", result)
367
+ return result
368
+
369
+
370
+ def _fuzzy_find(content: str, old_text: str) -> tuple[int, int, str] | None:
371
+ idx = content.find(old_text)
372
+ if idx != -1:
373
+ return idx, idx + len(old_text), content
374
+
375
+ lf_content = _normalize_to_lf(content)
376
+ lf_old = _normalize_to_lf(old_text)
377
+ idx = lf_content.find(lf_old)
378
+ if idx != -1:
379
+ return idx, idx + len(lf_old), lf_content
380
+
381
+ uni_content = _normalize_unicode(lf_content)
382
+ uni_old = _normalize_unicode(lf_old)
383
+ idx = uni_content.find(uni_old)
384
+ if idx != -1:
385
+ return idx, idx + len(uni_old), uni_content
386
+
387
+ trim_content = "\\n".join(line.strip() for line in lf_content.split("\\n"))
388
+ trim_old = "\\n".join(line.strip() for line in lf_old.split("\\n"))
389
+ idx = trim_content.find(trim_old)
390
+ if idx != -1:
391
+ return idx, idx + len(trim_old), trim_content
392
+
393
+ return None
394
+
395
+
396
+ def _generate_diff(old_content: str, new_content: str, context: int = 3) -> str:
397
+ old_lines = old_content.split("\\n")
398
+ new_lines = new_content.split("\\n")
399
+ diff = difflib.unified_diff(old_lines, new_lines, lineterm="", n=context)
400
+ return "\\n".join(diff)
401
+
402
+
403
+ def run_edit(path: str, old_text: str, new_text: str, replace_all: bool = False) -> str:
404
+ try:
405
+ fp = safe_path(path)
406
+
407
+ read_err = _check_read_state(fp)
408
+ if read_err:
409
+ return read_err
410
+
411
+ raw_content = fp.read_text()
412
+ bom, content = _strip_bom(raw_content)
413
+ original_ending = _detect_line_ending(content)
414
+ normalized = _normalize_to_lf(content)
415
+ norm_old = _normalize_to_lf(old_text)
416
+ norm_new = _normalize_to_lf(new_text)
417
+
418
+ if norm_old == norm_new:
419
+ return "Error: old_text and new_text are identical."
420
+
421
+ if replace_all:
422
+ match = _fuzzy_find(normalized, norm_old)
423
+ if match is None:
424
+ return f"Error: Text not found in {path}. Provide a larger unique snippet."
425
+ _, _, base = match
426
+ if base == normalized:
427
+ count = base.count(norm_old)
428
+ updated = base.replace(norm_old, norm_new)
429
+ else:
430
+ search_key = _normalize_unicode(norm_old)
431
+ count = base.count(search_key)
432
+ updated = base.replace(search_key, norm_new)
433
+ else:
434
+ match = _fuzzy_find(normalized, norm_old)
435
+ if match is None:
436
+ return f"Error: Text not found in {path}. Provide a larger unique snippet."
437
+ start, end, base = match
438
+
439
+ fuzzy_base = _normalize_unicode(_normalize_to_lf(base))
440
+ fuzzy_old = _normalize_unicode(norm_old)
441
+ occurrence_count = fuzzy_base.split(fuzzy_old)
442
+ count = len(occurrence_count) - 1
443
+ if count > 1:
444
+ positions = []
445
+ search_start = 0
446
+ for _ in range(min(count, 5)):
447
+ idx = fuzzy_base.find(fuzzy_old, search_start)
448
+ if idx == -1:
449
+ break
450
+ line_no = fuzzy_base[:idx].count("\\n") + 1
451
+ positions.append(str(line_no))
452
+ search_start = idx + 1
453
+ return (
454
+ f"Error: old_text matches {count} locations in {path} (lines: {', '.join(positions)}). "
455
+ "Provide more surrounding context to make it unique, or use replace_all=true."
456
+ )
457
+
458
+ updated = base[:start] + norm_new + base[end:]
459
+ count = 1
460
+
461
+ diff_output = _generate_diff(base, updated)
462
+ final = bom + _restore_line_endings(updated, original_ending)
463
+ fp.write_text(final)
464
+ FILE_READ_STATE[str(fp)] = fp.stat().st_mtime
465
+
466
+ label = "replace_all" if replace_all else ("fuzzy match" if base != normalized else "exact")
467
+ return f"Edited {path} ({label}, {count} replacement{'s' if count != 1 else ''})\\n{diff_output}"
468
+ except Exception as e:
469
+ return f"Error: {e}"
470
+
471
+
472
+ def _seek_context(lines: list[str], context_lines: list[str], start_from: int = 0) -> int | None:
473
+ def _match_line(file_line: str, ctx_line: str) -> bool:
474
+ if file_line == ctx_line:
475
+ return True
476
+ if _normalize_unicode(file_line) == _normalize_unicode(ctx_line):
477
+ return True
478
+ if file_line.rstrip() == ctx_line.rstrip():
479
+ return True
480
+ if file_line.strip() == ctx_line.strip():
481
+ return True
482
+ return False
483
+
484
+ if not context_lines:
485
+ return start_from
486
+
487
+ for i in range(start_from, len(lines)):
488
+ if _match_line(lines[i], context_lines[0]):
489
+ if len(context_lines) == 1:
490
+ return i
491
+ all_match = True
492
+ for j, ctx in enumerate(context_lines[1:], 1):
493
+ if i + j >= len(lines) or not _match_line(lines[i + j], ctx):
494
+ all_match = False
495
+ break
496
+ if all_match:
497
+ return i
498
+ return None
499
+
500
+
501
+ def _parse_patch(patch_text: str) -> list[dict[str, Any]]:
502
+ hunks: list[dict[str, Any]] = []
503
+ current_context: list[str] = []
504
+ current_changes: list[tuple[str, str]] = []
505
+
506
+ for raw_line in patch_text.split("\\n"):
507
+ if raw_line.startswith("@@"):
508
+ if current_changes:
509
+ hunks.append({"context": current_context, "changes": current_changes})
510
+ current_context = []
511
+ current_changes = []
512
+ ctx_text = raw_line[2:].strip() if len(raw_line) > 2 else ""
513
+ if ctx_text:
514
+ current_context.append(ctx_text)
515
+ elif raw_line.startswith("-"):
516
+ current_changes.append(("-", raw_line[1:]))
517
+ elif raw_line.startswith("+"):
518
+ current_changes.append(("+", raw_line[1:]))
519
+ elif raw_line.startswith(" "):
520
+ current_changes.append((" ", raw_line[1:]))
521
+
522
+ if current_changes:
523
+ hunks.append({"context": current_context, "changes": current_changes})
524
+
525
+ return hunks
526
+
527
+
528
+ def run_apply_patch(path: str, patch: str) -> str:
529
+ try:
530
+ fp = safe_path(path)
531
+
532
+ read_err = _check_read_state(fp)
533
+ if read_err:
534
+ return read_err
535
+
536
+ raw_content = fp.read_text()
537
+ bom, content = _strip_bom(raw_content)
538
+ original_ending = _detect_line_ending(content)
539
+ normalized = _normalize_to_lf(content)
540
+ lines = normalized.split("\\n")
541
+
542
+ hunks = _parse_patch(patch)
543
+ if not hunks:
544
+ return "Error: No valid hunks found in patch. Use @@ for context and +/- for changes."
545
+
546
+ cursor = 0
547
+ for hi, hunk in enumerate(hunks):
548
+ ctx = hunk["context"]
549
+ changes = hunk["changes"]
550
+
551
+ if ctx:
552
+ pos = _seek_context(lines, ctx, cursor)
553
+ if pos is None:
554
+ return (
555
+ f"Error: Could not locate context for hunk {hi + 1} in {path}. "
556
+ f"Context: {ctx!r}"
557
+ )
558
+ cursor = pos + len(ctx)
559
+ else:
560
+ if hi == 0:
561
+ cursor = 0
562
+
563
+ apply_at = cursor
564
+ i = apply_at
565
+ result_insert = []
566
+
567
+ for op, text in changes:
568
+ if op == "-":
569
+ if i >= len(lines):
570
+ return (
571
+ f"Error: Hunk {hi + 1} tries to delete beyond end of file. "
572
+ f"Expected: {text!r}"
573
+ )
574
+ file_line = lines[i]
575
+ if not (
576
+ file_line == text
577
+ or file_line.strip() == text.strip()
578
+ or _normalize_unicode(file_line) == _normalize_unicode(text)
579
+ ):
580
+ return (
581
+ f"Error: Hunk {hi + 1} delete mismatch at line {i + 1}. "
582
+ f"Expected: {text!r}, Found: {file_line!r}"
583
+ )
584
+ i += 1
585
+ elif op == "+":
586
+ result_insert.append(text)
587
+ elif op == " ":
588
+ if i >= len(lines):
589
+ return (
590
+ f"Error: Hunk {hi + 1} context line beyond end of file. "
591
+ f"Expected: {text!r}"
592
+ )
593
+ i += 1
594
+ result_insert.append(lines[i - 1])
595
+
596
+ lines[apply_at:i] = result_insert
597
+ cursor = apply_at + len(result_insert)
598
+
599
+ new_content = "\\n".join(lines)
600
+ diff_output = _generate_diff(normalized, new_content)
601
+ final = bom + _restore_line_endings(new_content, original_ending)
602
+ fp.write_text(final)
603
+ FILE_READ_STATE[str(fp)] = fp.stat().st_mtime
604
+
605
+ return f"Patched {path} ({len(hunks)} hunk{'s' if len(hunks) != 1 else ''})\\n{diff_output}"
606
+ except Exception as e:
607
+ return f"Error: {e}"
608
+
609
+
610
+ def run_glob(pattern: str, path: str = ".") -> str:
611
+ try:
612
+ base = safe_path(path)
613
+ if not base.is_dir():
614
+ return f"Error: {path} is not a directory"
615
+ if not pattern.startswith("**/") and "/" not in pattern:
616
+ pattern = "**/" + pattern
617
+ matches = sorted(base.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
618
+ if not matches:
619
+ return f"No files matching '{pattern}' in {_display_path(base)} [workspace: {WORKSPACE_DIR}]"
620
+ header = f"[searched in: {_display_path(base)}, workspace: {WORKSPACE_DIR}]"
621
+ lines = [header] + [_display_path(m) for m in matches[:50]]
622
+ result = "\\n".join(lines)
623
+ if len(matches) > 50:
624
+ result += f"\\n... and {len(matches) - 50} more"
625
+ return result
626
+ except Exception as e:
627
+ return f"Error: {e}"
628
+
629
+
630
+ def run_grep(pattern: str, path: str = ".", include: str | None = None, max_results: int = 50) -> str:
631
+ try:
632
+ base = safe_path(path)
633
+ cmd = ["rg", "--no-heading", "--line-number", "--max-count", str(max_results), pattern, str(base)]
634
+ if include:
635
+ cmd.extend(["--glob", include])
636
+ try:
637
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
638
+ if r.returncode > 1:
639
+ err = r.stderr.strip() or "rg failed"
640
+ return f"Error: {err}"
641
+ out = r.stdout.strip()
642
+ if not out:
643
+ return f"No matches for '{pattern}'"
644
+ lines = out.splitlines()[:max_results]
645
+ return "\\n".join(lines)
646
+ except FileNotFoundError:
647
+ compiled = re.compile(pattern)
648
+ results = []
649
+ search_dir = base if base.is_dir() else base.parent
650
+ glob_pat = include or "**/*"
651
+ for fp in search_dir.glob(glob_pat):
652
+ if not fp.is_file():
653
+ continue
654
+ try:
655
+ for i, line in enumerate(fp.read_text().splitlines(), 1):
656
+ if compiled.search(line):
657
+ results.append(f"{_display_path(fp)}:{i}:{line.rstrip()}")
658
+ if len(results) >= max_results:
659
+ break
660
+ except (UnicodeDecodeError, PermissionError):
661
+ continue
662
+ if len(results) >= max_results:
663
+ break
664
+ return "\\n".join(results) if results else f"No matches for '{pattern}'"
665
+ except Exception as e:
666
+ return f"Error: {e}"
667
+
668
+
669
+ def run_load_skill(name: str) -> str:
670
+ return SKILL_LOADER.get_content(name)
671
+
672
+ class BackgroundManager:
673
+ """Background command runner with task tracking."""
674
+
675
+ def __init__(self) -> None:
676
+ self.tasks: dict[str, dict[str, Any]] = {}
677
+ self._lock = threading.Lock()
678
+
679
+ def run(self, command: str) -> str:
680
+ dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
681
+ if any(d in command for d in dangerous):
682
+ return (
683
+ "Error: Dangerous command blocked.\n"
684
+ "Background tasks do not support interactive or privileged commands "
685
+ "(like sudo / shutdown). Please run these manually in your own shell."
686
+ )
687
+
688
+ scope_err = _validate_bash_command_scope(command)
689
+ if scope_err:
690
+ return scope_err
691
+
692
+ task_id = str(uuid.uuid4())[:8]
693
+ with self._lock:
694
+ self.tasks[task_id] = {"status": "running", "result": None, "command": command}
695
+
696
+ threading.Thread(target=self._execute, args=(task_id, command), daemon=True).start()
697
+ return f"Background task {task_id} started: {command[:80]}"
698
+
699
+ def _execute(self, task_id: str, command: str) -> None:
700
+ try:
701
+ r = subprocess.run(
702
+ command,
703
+ shell=True,
704
+ cwd=str(WORKSPACE_DIR),
705
+ capture_output=True,
706
+ text=True,
707
+ timeout=300,
708
+ )
709
+ output = (r.stdout + r.stderr).strip()[:50000]
710
+ status = "completed"
711
+ except subprocess.TimeoutExpired:
712
+ output = "Error: Timeout (300s)"
713
+ status = "timeout"
714
+ except Exception as e: # pragma: no cover
715
+ output = f"Error: {e}"
716
+ status = "error"
717
+
718
+ with self._lock:
719
+ task = self.tasks.get(task_id)
720
+ if task is not None:
721
+ task["status"] = status
722
+ task["result"] = output or "(no output)"
723
+
724
+ def check(self, task_id: str | None = None) -> str:
725
+ with self._lock:
726
+ if task_id:
727
+ task = self.tasks.get(task_id)
728
+ if not task:
729
+ return f"Error: Unknown task {task_id}"
730
+ return f"[{task['status']}] {task['command'][:60]}\n{task.get('result') or '(running)'}"
731
+
732
+ lines: list[str] = []
733
+ for tid, task in self.tasks.items():
734
+ lines.append(f"{tid}: [{task['status']}] {task['command'][:60]}")
735
+ return "\n".join(lines) if lines else "No background tasks."
736
+
737
+
738
+ BG = BackgroundManager()
739
+
740
+
741
+ def run_background(command: str) -> str:
742
+ return BG.run(command)
743
+
744
+
745
+ def check_background(task_id: str | None = None) -> str:
746
+ return BG.check(task_id)
747
+
748
+
749
+ def run_todo(items: list[dict[str, Any]]) -> str:
750
+ return get_ctx().todo.update(items)
751
+
752
+
753
+ # Default tool handlers copied from zero-code mapping style.
754
+ DEFAULT_TOOL_HANDLERS: dict[str, Any] = {
755
+ "bash": lambda **kw: run_bash(kw.get("command"), kw.get("restart", False), kw.get("timeout", 120)),
756
+ "read_file": lambda **kw: run_read(kw["path"], kw.get("offset"), kw.get("limit")),
757
+ "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
758
+ "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"], kw.get("replace_all", False)),
759
+ "apply_patch": lambda **kw: run_apply_patch(kw["path"], kw["patch"]),
760
+ "glob": lambda **kw: run_glob(kw["pattern"], kw.get("path", ".")),
761
+ "grep": lambda **kw: run_grep(kw["pattern"], kw.get("path", "."), kw.get("include"), kw.get("max_results", 50)),
762
+ "load_skill": lambda **kw: run_load_skill(kw["name"]),
763
+ "todo": lambda **kw: run_todo(kw["items"]),
764
+ "background_run": lambda **kw: run_background(kw["command"]),
765
+ "check_background": lambda **kw: check_background(kw.get("task_id")),
766
+ }