kiwimatecoder 0.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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/jobs.py ADDED
@@ -0,0 +1,594 @@
1
+ """Detached background and scheduled agent jobs.
2
+
3
+ A job is one headless agent run (``python -m kiwimatecoder -p ... --output-format
4
+ json``) tracked by a JSON record under ``~/.kiwimatecoder/jobs/<id>.json``. Jobs
5
+ are detached (their own session) and write combined output to
6
+ ``~/.kiwimatecoder/jobs/<id>.log``, so they keep running after the process that
7
+ started them exits.
8
+
9
+ Scheduling is deliberately pull-based: :func:`schedule_job` records an interval
10
+ and :func:`run_due_jobs` starts whatever is due. Nothing runs on its own --
11
+ invoke ``run_due_jobs()`` from the CLI (``kiwimatecoder jobs tick``), a cron
12
+ entry, or a loop. There is no daemon and no platform cron integration.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import json
19
+ import os
20
+ import re
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ import uuid
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any, Sequence
29
+
30
+ from kiwimatecoder.config import (
31
+ ensure_config_dir,
32
+ get_provider_config,
33
+ get_selected_provider_id,
34
+ load_config,
35
+ require_model,
36
+ )
37
+ from kiwimatecoder.permissions import PermissionMode
38
+
39
+ JOB_STATUSES = ("running", "succeeded", "failed", "cancelled")
40
+ TERMINAL_STATUSES = frozenset({"succeeded", "failed", "cancelled"})
41
+ DEFAULT_GRACE_SECONDS = 5.0
42
+ MAX_OUTPUT_BYTES = 100_000
43
+ _JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
44
+
45
+ # Live Popen handles for jobs started by this process; other processes reason
46
+ # about liveness from the recorded pid alone.
47
+ _PROCESSES: dict[str, subprocess.Popen[Any]] = {}
48
+
49
+
50
+ class JobError(RuntimeError):
51
+ """Raised for background-job lifecycle failures."""
52
+
53
+
54
+ @dataclass
55
+ class JobRecord:
56
+ """Persisted state for one background agent job."""
57
+
58
+ id: str
59
+ prompt: str
60
+ workspace: str
61
+ provider: str | None = None
62
+ model: str | None = None
63
+ mode: str = "auto-accept"
64
+ status: str = "running"
65
+ pid: int | None = None
66
+ created_at: str = ""
67
+ finished_at: str | None = None
68
+ exit_code: int | None = None
69
+ result: str | None = None
70
+ error: str | None = None
71
+ output_path: str = ""
72
+ interval: float | None = None
73
+ next_run_at: str | None = None
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ return {
77
+ "id": self.id,
78
+ "prompt": self.prompt,
79
+ "workspace": self.workspace,
80
+ "provider": self.provider,
81
+ "model": self.model,
82
+ "mode": self.mode,
83
+ "status": self.status,
84
+ "pid": self.pid,
85
+ "created_at": self.created_at,
86
+ "finished_at": self.finished_at,
87
+ "exit_code": self.exit_code,
88
+ "result": self.result,
89
+ "error": self.error,
90
+ "output_path": self.output_path,
91
+ "interval": self.interval,
92
+ "next_run_at": self.next_run_at,
93
+ }
94
+
95
+ @classmethod
96
+ def from_dict(cls, data: dict[str, Any]) -> "JobRecord":
97
+ """Rebuild a record, tolerating missing or mistyped fields."""
98
+ job_id = str(data.get("id") or "").strip()
99
+ if not job_id:
100
+ raise ValueError("Job record has no id.")
101
+ status = str(data.get("status") or "running").strip().lower()
102
+ if status not in JOB_STATUSES:
103
+ status = "failed" if data.get("finished_at") else "running"
104
+ provider = data.get("provider")
105
+ model = data.get("model")
106
+ interval_value = data.get("interval")
107
+ try:
108
+ interval = float(interval_value) if interval_value is not None else None
109
+ except (TypeError, ValueError):
110
+ interval = None
111
+ return cls(
112
+ id=job_id,
113
+ prompt=str(data.get("prompt") or ""),
114
+ workspace=str(data.get("workspace") or "."),
115
+ provider=str(provider) if provider else None,
116
+ model=str(model) if model else None,
117
+ mode=str(data.get("mode") or "auto-accept"),
118
+ status=status,
119
+ pid=_optional_int(data.get("pid")),
120
+ created_at=str(data.get("created_at") or ""),
121
+ finished_at=str(data["finished_at"]) if data.get("finished_at") else None,
122
+ exit_code=_optional_int(data.get("exit_code")),
123
+ result=str(data["result"]) if data.get("result") is not None else None,
124
+ error=str(data["error"]) if data.get("error") is not None else None,
125
+ output_path=str(data.get("output_path") or ""),
126
+ interval=interval,
127
+ next_run_at=str(data["next_run_at"]) if data.get("next_run_at") else None,
128
+ )
129
+
130
+
131
+ def _optional_int(value: object) -> int | None:
132
+ if value is None:
133
+ return None
134
+ try:
135
+ return int(value) # type: ignore[call-overload]
136
+ except (TypeError, ValueError):
137
+ return None
138
+
139
+
140
+ def _now_iso() -> str:
141
+ return datetime.datetime.now().isoformat(timespec="seconds")
142
+
143
+
144
+ def _parse_iso(value: str) -> float:
145
+ try:
146
+ return datetime.datetime.fromisoformat(value).timestamp()
147
+ except (TypeError, ValueError):
148
+ return float("inf")
149
+
150
+
151
+ def _jobs_dir() -> Path:
152
+ path = ensure_config_dir() / "jobs"
153
+ path.mkdir(parents=True, exist_ok=True)
154
+ try:
155
+ os.chmod(path, 0o700)
156
+ except OSError:
157
+ pass
158
+ return path
159
+
160
+
161
+ def _safe_id(job_id: str) -> str | None:
162
+ cleaned = str(job_id).strip()
163
+ return cleaned if _JOB_ID_RE.match(cleaned) else None
164
+
165
+
166
+ def _record_path(job_id: str) -> Path:
167
+ return _jobs_dir() / f"{job_id}.json"
168
+
169
+
170
+ def _output_path(job_id: str) -> Path:
171
+ return _jobs_dir() / f"{job_id}.log"
172
+
173
+
174
+ def _atomic_write(path: Path, text: str) -> None:
175
+ tmp = path.with_name(f"{path.name}.{uuid.uuid4().hex[:8]}.tmp")
176
+ tmp.write_text(text, encoding="utf-8")
177
+ os.replace(tmp, path)
178
+
179
+
180
+ def save_job(record: JobRecord) -> Path:
181
+ """Persist a job record with an atomic replace."""
182
+ path = _record_path(record.id)
183
+ _atomic_write(path, json.dumps(record.to_dict(), indent=2) + "\n")
184
+ return path
185
+
186
+
187
+ def _load_record(path: Path) -> JobRecord | None:
188
+ try:
189
+ data = json.loads(path.read_text(encoding="utf-8"))
190
+ except (OSError, json.JSONDecodeError):
191
+ return None
192
+ if not isinstance(data, dict):
193
+ return None
194
+ try:
195
+ return JobRecord.from_dict(data)
196
+ except (TypeError, ValueError):
197
+ return None
198
+
199
+
200
+ def get_job(job_id: str) -> JobRecord | None:
201
+ """Load one job record; corrupt or missing files return None."""
202
+ safe = _safe_id(job_id)
203
+ if safe is None:
204
+ return None
205
+ path = _record_path(safe)
206
+ if not path.is_file():
207
+ return None
208
+ return _load_record(path)
209
+
210
+
211
+ def list_jobs(*, refresh: bool = False) -> list[JobRecord]:
212
+ """List job records newest first, skipping unreadable files."""
213
+ records: list[JobRecord] = []
214
+ try:
215
+ paths = list(_jobs_dir().glob("*.json"))
216
+ except OSError:
217
+ return []
218
+ for path in paths:
219
+ record = _load_record(path)
220
+ if record is not None:
221
+ records.append(record)
222
+ records.sort(key=lambda record: record.created_at, reverse=True)
223
+ if refresh:
224
+ refreshed: list[JobRecord] = []
225
+ for record in records:
226
+ if record.status == "running":
227
+ record = refresh_job(record.id) or record
228
+ refreshed.append(record)
229
+ refreshed.sort(key=lambda record: record.created_at, reverse=True)
230
+ return refreshed
231
+ return records
232
+
233
+
234
+ def _pid_alive(pid: int | None) -> bool:
235
+ if not pid or pid <= 0:
236
+ return False
237
+ try:
238
+ os.kill(pid, 0)
239
+ except ProcessLookupError:
240
+ return False
241
+ except PermissionError:
242
+ return True
243
+ except OSError:
244
+ return False
245
+ return True
246
+
247
+
248
+ def _parse_result(output_path: str) -> dict[str, Any] | None:
249
+ """Return the final JSON result line from a job's output, if present."""
250
+ if not output_path:
251
+ return None
252
+ try:
253
+ text = Path(output_path).read_text(encoding="utf-8", errors="replace")
254
+ except OSError:
255
+ return None
256
+ for line in reversed(text.splitlines()):
257
+ stripped = line.strip()
258
+ if not stripped.startswith("{"):
259
+ continue
260
+ try:
261
+ data = json.loads(stripped)
262
+ except json.JSONDecodeError:
263
+ continue
264
+ if isinstance(data, dict) and "success" in data:
265
+ return data
266
+ return None
267
+
268
+
269
+ def read_job_output(job_id: str, max_bytes: int = MAX_OUTPUT_BYTES) -> str | None:
270
+ """Return bounded combined output for a job, or None when unknown."""
271
+ record = get_job(job_id)
272
+ if record is None:
273
+ return None
274
+ if not record.output_path:
275
+ return ""
276
+ try:
277
+ with Path(record.output_path).open("rb") as handle:
278
+ data = handle.read(max_bytes + 1)
279
+ except OSError:
280
+ return ""
281
+ truncated = len(data) > max_bytes
282
+ if truncated:
283
+ data = data[:max_bytes]
284
+ text = data.decode("utf-8", errors="replace")
285
+ if truncated:
286
+ text += "\n... [output truncated]"
287
+ return text
288
+
289
+
290
+ def start_job(
291
+ prompt: str,
292
+ *,
293
+ workspace: str | Path,
294
+ provider: str | None = None,
295
+ model: str | None = None,
296
+ mode: str | PermissionMode = "auto-accept",
297
+ extra_args: Sequence[str] = (),
298
+ ) -> JobRecord:
299
+ """Spawn a detached headless agent run and return its record.
300
+
301
+ ``mode`` defaults to ``auto-accept`` so the job can act unattended; pass
302
+ ``mode="ask"`` or ``mode="plan"`` to override (an unattended ``ask`` job
303
+ simply has its approvals denied). Raises ``ValueError`` for an empty prompt,
304
+ a missing workspace, an unknown mode, or when no model was passed or chosen
305
+ for the provider; ``KeyError`` for an unknown provider; and ``JobError``
306
+ when the process cannot be started.
307
+ """
308
+ cleaned_prompt = str(prompt).strip()
309
+ if not cleaned_prompt:
310
+ raise ValueError("Job prompt is required.")
311
+ workspace_path = Path(workspace).expanduser()
312
+ if not workspace_path.is_dir():
313
+ raise ValueError(f"Workspace is not a directory: {workspace_path}")
314
+ permission = PermissionMode.from_str(mode) if isinstance(mode, str) else mode
315
+ resolved_mode = permission.value
316
+ if provider is not None:
317
+ get_provider_config(provider) # raises UnknownProviderError (a KeyError)
318
+ # Fail now rather than in the detached run: there are no default models.
319
+ # The run starts in the workspace, so its project config decides.
320
+ cfg = load_config(workspace_path)
321
+ require_model(
322
+ get_provider_config(provider or get_selected_provider_id(cfg), cfg),
323
+ cfg,
324
+ override=model,
325
+ )
326
+
327
+ job_id = uuid.uuid4().hex[:12]
328
+ output_path = _output_path(job_id)
329
+ argv = [
330
+ sys.executable,
331
+ "-m",
332
+ "kiwimatecoder",
333
+ "-p",
334
+ cleaned_prompt,
335
+ "--output-format",
336
+ "json",
337
+ "--workspace",
338
+ str(workspace_path),
339
+ "--mode",
340
+ resolved_mode,
341
+ ]
342
+ if provider:
343
+ argv += ["--provider", provider]
344
+ if model:
345
+ argv += ["--model", model]
346
+ argv += [str(arg) for arg in extra_args]
347
+
348
+ try:
349
+ output_file = output_path.open("wb")
350
+ except OSError as exc:
351
+ raise JobError(f"Failed to create the job output file: {exc}") from exc
352
+ try:
353
+ proc = subprocess.Popen(
354
+ argv,
355
+ stdin=subprocess.DEVNULL,
356
+ stdout=output_file,
357
+ stderr=subprocess.STDOUT,
358
+ cwd=str(workspace_path),
359
+ start_new_session=True,
360
+ )
361
+ except OSError as exc:
362
+ raise JobError(f"Failed to start the job process: {exc}") from exc
363
+ finally:
364
+ output_file.close()
365
+
366
+ record = JobRecord(
367
+ id=job_id,
368
+ prompt=cleaned_prompt,
369
+ workspace=str(workspace_path),
370
+ provider=provider,
371
+ model=model,
372
+ mode=resolved_mode,
373
+ status="running",
374
+ pid=proc.pid,
375
+ created_at=_now_iso(),
376
+ output_path=str(output_path),
377
+ )
378
+ _PROCESSES[job_id] = proc
379
+ try:
380
+ save_job(record)
381
+ except OSError as exc:
382
+ _PROCESSES.pop(job_id, None)
383
+ raise JobError(f"Failed to save the job record: {exc}") from exc
384
+ return record
385
+
386
+
387
+ def refresh_job(job_id: str) -> JobRecord | None:
388
+ """Update a job's status from its process and parsed JSON result."""
389
+ record = get_job(job_id)
390
+ if record is None:
391
+ return None
392
+ if record.status in TERMINAL_STATUSES:
393
+ return record
394
+
395
+ proc = _PROCESSES.get(job_id)
396
+ code: int | None = None
397
+ if proc is not None:
398
+ code = proc.poll()
399
+ if code is None:
400
+ return record
401
+ elif _pid_alive(record.pid):
402
+ return record
403
+ else:
404
+ code = record.exit_code
405
+
406
+ _PROCESSES.pop(job_id, None)
407
+ record.exit_code = code
408
+ payload = _parse_result(record.output_path)
409
+ if payload is not None:
410
+ record.result = str(payload.get("result") or "")
411
+ success = payload.get("success")
412
+ if success is False or (code not in (0, None)):
413
+ record.status = "failed"
414
+ record.error = record.error or "The job reported failure."
415
+ else:
416
+ record.status = "succeeded"
417
+ elif code == 0:
418
+ record.status = "succeeded"
419
+ record.result = ""
420
+ else:
421
+ record.status = "failed"
422
+ record.error = (
423
+ f"Job exited with code {code} before producing a result."
424
+ if code is not None
425
+ else "Job process exited without producing a result."
426
+ )
427
+ record.finished_at = _now_iso()
428
+ save_job(record)
429
+ return record
430
+
431
+
432
+ def _terminate(proc: subprocess.Popen[Any], *, grace: float = DEFAULT_GRACE_SECONDS) -> int | None:
433
+ """SIGTERM a process, escalate to SIGKILL, and return its exit code."""
434
+ if proc.poll() is not None:
435
+ return proc.returncode
436
+ proc.terminate()
437
+ try:
438
+ return proc.wait(timeout=grace)
439
+ except subprocess.TimeoutExpired:
440
+ pass
441
+ proc.kill()
442
+ try:
443
+ return proc.wait(timeout=2.0)
444
+ except subprocess.TimeoutExpired:
445
+ return None
446
+
447
+
448
+ def _terminate_pid(pid: int | None, *, grace: float = DEFAULT_GRACE_SECONDS) -> None:
449
+ if not _pid_alive(pid):
450
+ return
451
+ assert pid is not None
452
+ try:
453
+ os.kill(pid, signal.SIGTERM)
454
+ except OSError:
455
+ return
456
+ deadline = time.monotonic() + grace
457
+ while time.monotonic() < deadline:
458
+ if not _pid_alive(pid):
459
+ return
460
+ time.sleep(0.05)
461
+ try:
462
+ os.kill(pid, signal.SIGKILL)
463
+ except OSError:
464
+ pass
465
+
466
+
467
+ def cancel_job(job_id: str) -> JobRecord | None:
468
+ """Terminate a running job and mark it cancelled; returns the record."""
469
+ record = get_job(job_id)
470
+ if record is None:
471
+ return None
472
+ if record.status in TERMINAL_STATUSES:
473
+ return record
474
+ proc = _PROCESSES.pop(job_id, None)
475
+ if proc is not None:
476
+ record.exit_code = _terminate(proc)
477
+ else:
478
+ _terminate_pid(record.pid)
479
+ record.status = "cancelled"
480
+ record.finished_at = _now_iso()
481
+ save_job(record)
482
+ return record
483
+
484
+
485
+ def clear_jobs(*, finished_only: bool = True) -> int:
486
+ """Delete job records (and output logs); returns how many were removed.
487
+
488
+ With ``finished_only=False`` running jobs are cancelled first so no
489
+ untracked process is left behind.
490
+ """
491
+ removed = 0
492
+ for record in list_jobs():
493
+ if record.status not in TERMINAL_STATUSES:
494
+ if finished_only:
495
+ continue
496
+ cancel_job(record.id)
497
+ _PROCESSES.pop(record.id, None)
498
+ try:
499
+ _record_path(record.id).unlink(missing_ok=True)
500
+ except OSError:
501
+ continue
502
+ if record.output_path:
503
+ try:
504
+ Path(record.output_path).unlink(missing_ok=True)
505
+ except OSError:
506
+ pass
507
+ removed += 1
508
+ return removed
509
+
510
+
511
+ def schedule_job(
512
+ prompt: str,
513
+ every_seconds: float | int,
514
+ *,
515
+ workspace: str | Path,
516
+ provider: str | None = None,
517
+ model: str | None = None,
518
+ mode: str | PermissionMode = "auto-accept",
519
+ extra_args: Sequence[str] = (),
520
+ ) -> JobRecord:
521
+ """Start a job now and record how often it should run again.
522
+
523
+ The first run starts immediately; :func:`run_due_jobs` starts subsequent
524
+ runs once ``next_run_at`` has passed. Raises ``ValueError`` for a
525
+ non-positive interval.
526
+ """
527
+ try:
528
+ interval = float(every_seconds)
529
+ except (TypeError, ValueError) as exc:
530
+ raise ValueError("Schedule interval must be a number of seconds.") from exc
531
+ if interval <= 0:
532
+ raise ValueError("Schedule interval must be positive.")
533
+ record = start_job(
534
+ prompt,
535
+ workspace=workspace,
536
+ provider=provider,
537
+ model=model,
538
+ mode=mode,
539
+ extra_args=extra_args,
540
+ )
541
+ record.interval = interval
542
+ record.next_run_at = datetime.datetime.fromtimestamp(
543
+ time.time() + interval
544
+ ).isoformat(timespec="seconds")
545
+ save_job(record)
546
+ return record
547
+
548
+
549
+ def run_due_jobs(
550
+ *,
551
+ now: float | None = None,
552
+ skipped: list[tuple[JobRecord, str]] | None = None,
553
+ ) -> list[JobRecord]:
554
+ """Start a new run for every scheduled job whose interval has elapsed.
555
+
556
+ A job that is still running is skipped (runs never overlap); a finished job
557
+ hands the schedule to the fresh run. Returns the newly started records.
558
+ Due jobs that could not start (e.g. no model chosen) are appended to
559
+ ``skipped`` with the reason, when given.
560
+ """
561
+ current = time.time() if now is None else now
562
+ started: list[JobRecord] = []
563
+ for scheduled in list_jobs():
564
+ if scheduled.interval is None or scheduled.next_run_at is None:
565
+ continue
566
+ if _parse_iso(scheduled.next_run_at) > current:
567
+ continue
568
+ refreshed = refresh_job(scheduled.id) or scheduled
569
+ if refreshed.status == "running" or refreshed.interval is None:
570
+ continue
571
+ try:
572
+ fresh = start_job(
573
+ refreshed.prompt,
574
+ workspace=refreshed.workspace,
575
+ provider=refreshed.provider,
576
+ model=refreshed.model,
577
+ mode=refreshed.mode,
578
+ )
579
+ except (ValueError, KeyError, JobError) as exc:
580
+ # A broken schedule (missing workspace, retired provider, no model)
581
+ # is left alone so the caller can inspect and fix or cancel it.
582
+ if skipped is not None:
583
+ skipped.append((refreshed, str(exc)))
584
+ continue
585
+ fresh.interval = refreshed.interval
586
+ fresh.next_run_at = datetime.datetime.fromtimestamp(
587
+ current + refreshed.interval
588
+ ).isoformat(timespec="seconds")
589
+ save_job(fresh)
590
+ refreshed.interval = None
591
+ refreshed.next_run_at = None
592
+ save_job(refreshed)
593
+ started.append(fresh)
594
+ return started
@@ -0,0 +1,56 @@
1
+ """Language Server Protocol support: diagnostics and navigation.
2
+
3
+ Opt-in via the ``lsp`` config section. The manager starts a server subprocess
4
+ per language on first use, routes files by extension, and exposes bounded
5
+ diagnostics/definition/references calls; failures are reported, never fatal.
6
+ """
7
+
8
+ from kiwimatecoder.lsp.client import (
9
+ LspClient,
10
+ LspError,
11
+ LspTimeout,
12
+ language_id_for,
13
+ normalize_diagnostics,
14
+ normalize_locations,
15
+ path_to_uri,
16
+ uri_to_path,
17
+ )
18
+ from kiwimatecoder.lsp.manager import (
19
+ LSP_DISABLED_MESSAGE,
20
+ SERVER_PRESETS,
21
+ LspManager,
22
+ LspServerSpec,
23
+ available_servers,
24
+ effective_servers,
25
+ ensure_manager,
26
+ get_manager,
27
+ set_manager,
28
+ )
29
+ from kiwimatecoder.lsp.protocol import (
30
+ LspProtocolError,
31
+ decode_messages,
32
+ encode_message,
33
+ )
34
+
35
+ __all__ = [
36
+ "LSP_DISABLED_MESSAGE",
37
+ "SERVER_PRESETS",
38
+ "LspClient",
39
+ "LspError",
40
+ "LspManager",
41
+ "LspProtocolError",
42
+ "LspServerSpec",
43
+ "LspTimeout",
44
+ "available_servers",
45
+ "decode_messages",
46
+ "effective_servers",
47
+ "encode_message",
48
+ "ensure_manager",
49
+ "get_manager",
50
+ "language_id_for",
51
+ "normalize_diagnostics",
52
+ "normalize_locations",
53
+ "path_to_uri",
54
+ "set_manager",
55
+ "uri_to_path",
56
+ ]