arkaos 2.21.0 → 2.22.0

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 (41) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +2 -1
  3. package/arka/skills/costs/SKILL.md +62 -0
  4. package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
  5. package/core/cognition/auto_documentor.py +75 -52
  6. package/core/jobs/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
  8. package/core/obsidian/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/core/obsidian/__pycache__/cataloger.cpython-313.pyc +0 -0
  10. package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
  11. package/core/obsidian/__pycache__/taxonomy.cpython-313.pyc +0 -0
  12. package/core/runtime/__init__.py +22 -1
  13. package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  15. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  16. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  17. package/core/runtime/__pycache__/cursor.cpython-313.pyc +0 -0
  18. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  19. package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
  20. package/core/runtime/__pycache__/llm_cost_telemetry_cli.cpython-313.pyc +0 -0
  21. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  22. package/core/runtime/__pycache__/pricing.cpython-313.pyc +0 -0
  23. package/core/runtime/base.py +30 -1
  24. package/core/runtime/claude_code.py +68 -0
  25. package/core/runtime/codex_cli.py +33 -0
  26. package/core/runtime/cursor.py +19 -0
  27. package/core/runtime/gemini_cli.py +33 -0
  28. package/core/runtime/llm_cost_telemetry.py +306 -0
  29. package/core/runtime/llm_cost_telemetry_cli.py +138 -0
  30. package/core/runtime/llm_provider.py +382 -0
  31. package/core/runtime/pricing.py +85 -0
  32. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  33. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  34. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  35. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  36. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  37. package/core/workflow/__pycache__/kb_first_decider.cpython-313.pyc +0 -0
  38. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  39. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  40. package/package.json +1 -1
  41. package/pyproject.toml +1 -1
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.21.0
1
+ 2.22.0
package/arka/SKILL.md CHANGED
@@ -109,7 +109,8 @@ violation (squad-routing, arka-supremacy, spec-driven, mandatory-qa).
109
109
 
110
110
  | Command | Description |
111
111
  |---------|-------------|
112
- | `/arka status` | System status (version, departments, agents, active projects) |
112
+ | `/arka status` | System status (version, departments, agents, active projects). Includes **LLM costs (24h)** section: top-line cost + cache hit rate + call count from `core.runtime.llm_cost_telemetry.summarise(period="today")`. |
113
+ | `/arka costs [period]` | LLM cost visibility — aggregates telemetry by day/week/month/all, with top expensive sessions. See `arka/skills/costs/SKILL.md`. Shells out to `python -m core.runtime.llm_cost_telemetry_cli <period>`. |
113
114
  | `/arka standup` | Daily standup (projects, priorities, blockers, updates) |
114
115
  | `/arka monitor` | System health monitoring |
115
116
  | `/arka onboard <path>` | Onboard an existing project into ArkaOS |
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: arka-costs
3
+ description: >
4
+ LLM cost visibility — aggregates `~/.arkaos/telemetry/llm-cost.jsonl` by
5
+ day/week/month/all, breaks down by provider/model/session, surfaces top
6
+ expensive sessions and cache hit rate. Visibility-only per ADR-011;
7
+ never imposes hard caps.
8
+ allowed-tools: [Bash, Read]
9
+ ---
10
+
11
+ # /arka costs — LLM cost visibility
12
+
13
+ Aggregates runtime-agnostic LLM call telemetry written by
14
+ `core/runtime/llm_cost_telemetry.record_cost`. Per ADR-011, token
15
+ budgets are **informational, not restrictive** — this command only
16
+ surfaces usage and emits soft advisories. It never blocks a call.
17
+
18
+ ## Usage
19
+
20
+ | Command | What it shows |
21
+ | --- | --- |
22
+ | `/arka costs` | Today (UTC midnight → now) |
23
+ | `/arka costs today` | Same as above |
24
+ | `/arka costs week` | Rolling last 7 days |
25
+ | `/arka costs month` | Rolling last 30 days |
26
+ | `/arka costs all` | Entire history in the JSONL |
27
+ | `/arka costs sessions` | Top 10 most expensive sessions (all time) |
28
+
29
+ ## Output
30
+
31
+ - Total cost (USD, `n/a` when all entries are unpriced models)
32
+ - Total tokens in / out, plus cached tokens
33
+ - Cache hit rate (`cached / tokens_in`)
34
+ - Breakdown by provider
35
+ - Breakdown by model (`<unknown>` bucket for calls with no model)
36
+ - Top 10 sessions sorted by cost
37
+ - Advisories — a soft line per session that crossed the
38
+ `advisory_threshold_usd` (default $5 per session)
39
+
40
+ ## Implementation
41
+
42
+ This skill shells out to the Python CLI:
43
+
44
+ ```bash
45
+ python -m core.runtime.llm_cost_telemetry_cli <period>
46
+ ```
47
+
48
+ Source:
49
+ - `core/runtime/llm_cost_telemetry.py` — `summarise`, `list_expensive_sessions`
50
+ - `core/runtime/llm_cost_telemetry_cli.py` — markdown renderer
51
+
52
+ ## Data source
53
+
54
+ `~/.arkaos/telemetry/llm-cost.jsonl` (override with `ARKA_LLM_COST_PATH`).
55
+ One JSONL line per LLM call, written by every provider adapter.
56
+ Malformed lines are skipped and counted, never raised.
57
+
58
+ ## Non-negotiables
59
+
60
+ 1. Read-only. This skill never edits state.
61
+ 2. No hard budget caps — advisories are strings, not errors.
62
+ 3. No external dependencies; stdlib only.
@@ -5,14 +5,15 @@ synthesises learnings about external sources consulted, decisions made,
5
5
  and deliverables produced, then invokes the Obsidian cataloger + relator
6
6
  (Task #4 modules) to file structured, wikilinked notes into the vault.
7
7
 
8
- Model routing is dynamic: a complexity heuristic over the learning
9
- content chooses haiku / sonnet / opus. Nothing is hardcoded per task
10
- type. The actual LLM call is abstracted behind `_call_llm` and falls
11
- back to a deterministic template when no SDK is wired this keeps the
12
- module testable and unblocks the SDK integration as a follow-up.
8
+ The synthesis step is runtime- and model-agnostic: it delegates to the
9
+ active `LLMProvider` (see `core.runtime.llm_provider`). This module
10
+ NEVER picks a model the provider / runtime / env does. When no
11
+ provider is available or the call fails, it falls through to a
12
+ deterministic template synthesiser that preserves every extracted fact.
13
13
 
14
14
  ADR/Plan references:
15
15
  - ~/.arkaos/plans/2026-04-20-intelligence-v2.md (Task #7 — Épico B)
16
+ - ~/.arkaos/plans/2026-04-20-llm-agnostic.md (Task #12/#13 — LLMProvider)
16
17
  - core/obsidian/cataloger.py, core/obsidian/relator.py (Task #4)
17
18
  """
18
19
 
@@ -21,9 +22,8 @@ from __future__ import annotations
21
22
  import json
22
23
  import re
23
24
  from dataclasses import dataclass, field
24
- from datetime import date
25
25
  from pathlib import Path
26
- from typing import Iterable, Optional
26
+ from typing import Iterable
27
27
 
28
28
  from core.obsidian import cataloger as _cataloger
29
29
  from core.obsidian import relator as _relator
@@ -32,14 +32,6 @@ from core.obsidian.writer import ObsidianWriter
32
32
 
33
33
  SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
34
34
 
35
- _ARCHITECTURAL_KEYWORDS = (
36
- "architecture", "adr", "decision", "trade-off", "tradeoff",
37
- "design pattern", "refactor", "migration", "schema", "bounded context",
38
- )
39
- _ANALYSIS_KEYWORDS = (
40
- "analysis", "investigation", "compare", "benchmark", "evaluate",
41
- "profile", "review", "audit",
42
- )
43
35
  _URL_RE = re.compile(r"https?://[^\s\)\]\"']+")
44
36
  _FILE_PATH_RE = re.compile(r"(?:^|[\s`'])(/[A-Za-z0-9_./\-]+\.[A-Za-z0-9]+)")
45
37
  _ROUTING_MARKER_RE = re.compile(
@@ -58,8 +50,17 @@ _EXTERNAL_RESEARCH_TOOLS = frozenset({
58
50
  "mcp__firecrawl__firecrawl_extract",
59
51
  })
60
52
 
61
- _HAIKU_MAX_CHARS = 600
62
- _OPUS_MIN_CHARS = 4000
53
+ _AUTO_DOC_SUFFIX = "Auto-documented by ArkaOS"
54
+ _LLM_MAX_TOKENS = 1500
55
+
56
+ _SYSTEM_PROMPT = (
57
+ "You are ArkaOS's auto-documentor. Produce a concise knowledge note "
58
+ "(150-300 words) summarising the session. Structure: short intro, "
59
+ "then markdown sections for Key Facts, Decisions, and Sources. "
60
+ "Preserve every URL and file path verbatim. Use Obsidian wikilinks "
61
+ "([[Topic]]) for reusable concepts. No preamble, no sign-off, no "
62
+ "meta commentary about the model or prompt. Output only markdown."
63
+ )
63
64
 
64
65
 
65
66
  @dataclass
@@ -264,49 +265,73 @@ def _dedupe_keep_order(items: Iterable[str]) -> list[str]:
264
265
  return out
265
266
 
266
267
 
267
- # ─── Model routing ─────────────────────────────────────────────────────
268
-
269
-
270
- def choose_model(learning: Learning) -> str:
271
- """Return 'haiku' | 'sonnet' | 'opus' based on content complexity."""
272
- low = learning.content.lower()
273
- length = len(learning.content)
274
- if any(kw in low for kw in _ARCHITECTURAL_KEYWORDS):
275
- return "opus"
276
- if length >= _OPUS_MIN_CHARS and len(learning.decisions) >= 3:
277
- return "opus"
278
- if length <= _HAIKU_MAX_CHARS and len(learning.decisions) <= 1:
279
- return "haiku"
280
- if any(kw in low for kw in _ANALYSIS_KEYWORDS):
281
- return "sonnet"
282
- if length >= _HAIKU_MAX_CHARS:
283
- return "sonnet"
284
- return "haiku"
285
-
286
-
287
268
  # ─── Synthesis ─────────────────────────────────────────────────────────
288
269
 
289
270
 
290
- def synthesize(learning: Learning, model_hint: str) -> str:
271
+ def synthesize(learning: Learning) -> str:
291
272
  """Produce a markdown body for the learning.
292
273
 
293
- Calls `_call_llm` when a real LLM integration is wired; otherwise
294
- falls back to a deterministic template that preserves all extracted
295
- information. The template path is what ships in Task #7.
274
+ Delegates to the active `LLMProvider` via `_call_llm`. If the
275
+ provider is unavailable, returns empty text, or raises, falls
276
+ through to a deterministic template that preserves every extracted
277
+ fact. No model name ever crosses this boundary.
296
278
  """
297
- llm_out = _call_llm(learning, model_hint)
279
+ llm_out = _call_llm(learning)
298
280
  if llm_out:
299
281
  return llm_out
300
- return _template_synthesize(learning, model_hint)
282
+ return _template_synthesize(learning)
301
283
 
302
284
 
303
- def _call_llm(learning: Learning, model_hint: str) -> str:
304
- return ""
285
+ def _call_llm(learning: Learning) -> str:
286
+ from core.runtime import get_llm_provider
287
+ from core.runtime.llm_provider import LLMUnavailable
305
288
 
289
+ try:
290
+ provider = get_llm_provider()
291
+ if not provider.is_available():
292
+ return ""
293
+ prompt = _build_synthesis_prompt(learning)
294
+ response = provider.complete(
295
+ prompt, max_tokens=_LLM_MAX_TOKENS, system=_SYSTEM_PROMPT
296
+ )
297
+ return response.text.strip()
298
+ except LLMUnavailable:
299
+ return ""
300
+ except Exception: # noqa: BLE001 — LLM path must never crash the doc job
301
+ return ""
306
302
 
307
- def _template_synthesize(learning: Learning, model_hint: str) -> str:
303
+
304
+ def _build_synthesis_prompt(learning: Learning) -> str:
305
+ lines = [f"Topic: {learning.topic}", ""]
306
+ if learning.content.strip():
307
+ lines.append("Session blob:")
308
+ lines.append(learning.content.strip())
309
+ lines.append("")
310
+ if learning.sources:
311
+ lines.append("Sources consulted:")
312
+ for src in learning.sources[:20]:
313
+ lines.append(f"- {src}")
314
+ lines.append("")
315
+ if learning.decisions:
316
+ lines.append("Decisions recorded:")
317
+ for dec in learning.decisions[:10]:
318
+ lines.append(f"- {dec}")
319
+ lines.append("")
320
+ if learning.metadata:
321
+ meta_pairs = sorted(learning.metadata.items())
322
+ lines.append("Metadata:")
323
+ for key, value in meta_pairs:
324
+ lines.append(f"- {key}: {value}")
325
+ lines.append("")
326
+ lines.append(
327
+ "Write the note now. Obey the system prompt. Output only markdown."
328
+ )
329
+ return "\n".join(lines)
330
+
331
+
332
+ def _template_synthesize(learning: Learning) -> str:
308
333
  parts = [f"# {learning.topic}", ""]
309
- parts.append(f"> Auto-documented via ArkaOS ({model_hint}).")
334
+ parts.append(f"> {_AUTO_DOC_SUFFIX}.")
310
335
  parts.append("")
311
336
  if learning.content.strip():
312
337
  parts.append(learning.content.strip())
@@ -318,7 +343,7 @@ def _template_synthesize(learning: Learning, model_hint: str) -> str:
318
343
  parts.append(f"- {src}")
319
344
  parts.append("")
320
345
  if learning.decisions:
321
- parts.append("## Key decisions")
346
+ parts.append("## Decisions")
322
347
  parts.append("")
323
348
  for dec in learning.decisions[:10]:
324
349
  parts.append(f"- {dec}")
@@ -358,14 +383,12 @@ def _document_one(
358
383
  writer: ObsidianWriter,
359
384
  vault_path: Path,
360
385
  session_id: str,
361
- ) -> Optional[Path]:
362
- model_hint = choose_model(learning)
363
- body = synthesize(learning, model_hint)
386
+ ) -> Path | None:
387
+ body = synthesize(learning)
364
388
  meta = dict(learning.metadata)
365
389
  meta.setdefault("title", learning.topic[:80])
366
390
  meta.setdefault("session", session_id)
367
391
  meta.setdefault("auto_documented", True)
368
- meta.setdefault("model_hint", model_hint)
369
392
  try:
370
393
  plan = _cataloger.plan(body, meta)
371
394
  except ValueError:
@@ -2,5 +2,26 @@
2
2
 
3
3
  from core.runtime.base import RuntimeAdapter, RuntimeConfig
4
4
  from core.runtime.registry import get_adapter, detect_runtime
5
+ from core.runtime.llm_provider import (
6
+ AnthropicDirectProvider,
7
+ LLMProvider,
8
+ LLMResponse,
9
+ LLMUnavailable,
10
+ StubProvider,
11
+ SubagentProvider,
12
+ get_llm_provider,
13
+ )
5
14
 
6
- __all__ = ["RuntimeAdapter", "RuntimeConfig", "get_adapter", "detect_runtime"]
15
+ __all__ = [
16
+ "AnthropicDirectProvider",
17
+ "LLMProvider",
18
+ "LLMResponse",
19
+ "LLMUnavailable",
20
+ "RuntimeAdapter",
21
+ "RuntimeConfig",
22
+ "StubProvider",
23
+ "SubagentProvider",
24
+ "detect_runtime",
25
+ "get_adapter",
26
+ "get_llm_provider",
27
+ ]
@@ -11,7 +11,10 @@ knowing which runtime is active. Each adapter handles the translation.
11
11
  from abc import ABC, abstractmethod
12
12
  from dataclasses import dataclass, field
13
13
  from pathlib import Path
14
- from typing import Any
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from core.runtime.llm_provider import LLMResponse
15
18
 
16
19
 
17
20
  @dataclass
@@ -141,3 +144,29 @@ class RuntimeAdapter(ABC):
141
144
  "mcp": config.supports_mcp,
142
145
  }
143
146
  return feature_map.get(feature, False)
147
+
148
+ def headless_supported(self) -> bool:
149
+ """Whether this adapter can perform a headless CLI completion.
150
+
151
+ Default: False. Override in adapters that implement
152
+ `headless_complete`. `SubagentProvider` consults this before
153
+ attempting a call so the factory can fall back cleanly.
154
+ """
155
+ return False
156
+
157
+ def headless_complete(
158
+ self,
159
+ prompt: str,
160
+ *,
161
+ max_tokens: int = 2000,
162
+ system: str = "",
163
+ ) -> "LLMResponse":
164
+ """Run a one-shot headless completion via this runtime's CLI.
165
+
166
+ Default implementation raises NotImplementedError — adapters
167
+ that do not have a headless CLI mode (e.g. Cursor) inherit this
168
+ behaviour. Never hardcodes a model.
169
+ """
170
+ raise NotImplementedError(
171
+ f"{self.get_config().name} does not support headless LLM completion"
172
+ )
@@ -4,11 +4,18 @@ Claude Code is the primary and most capable runtime for ArkaOS.
4
4
  It supports hooks, subagents (Agent tool), MCP servers, and worktrees.
5
5
  """
6
6
 
7
+ import json
8
+ import shutil
9
+ import subprocess
7
10
  from pathlib import Path
8
11
  from os.path import expanduser
12
+ from typing import TYPE_CHECKING
9
13
 
10
14
  from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
11
15
 
16
+ if TYPE_CHECKING:
17
+ from core.runtime.llm_provider import LLMResponse
18
+
12
19
 
13
20
  class ClaudeCodeAdapter(RuntimeAdapter):
14
21
  """Adapter for Anthropic's Claude Code CLI."""
@@ -102,3 +109,64 @@ class ClaudeCodeAdapter(RuntimeAdapter):
102
109
  def supports_feature(self, feature: str) -> bool:
103
110
  """Claude Code supports all features."""
104
111
  return True
112
+
113
+ def headless_supported(self) -> bool:
114
+ return shutil.which("claude") is not None
115
+
116
+ def headless_complete(
117
+ self,
118
+ prompt: str,
119
+ *,
120
+ max_tokens: int = 2000,
121
+ system: str = "",
122
+ ) -> "LLMResponse":
123
+ from core.runtime.llm_provider import LLMResponse, LLMUnavailable
124
+
125
+ binary = shutil.which("claude")
126
+ if binary is None:
127
+ raise NotImplementedError(
128
+ "claude CLI not found on PATH — install Claude Code to enable "
129
+ "headless completion via this adapter."
130
+ )
131
+ cmd = [binary, "-p", prompt, "--output-format", "json"]
132
+ if system:
133
+ cmd.extend(["--append-system-prompt", system])
134
+ try:
135
+ proc = subprocess.run(
136
+ cmd,
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=60,
140
+ check=False,
141
+ )
142
+ except subprocess.TimeoutExpired as exc:
143
+ raise LLMUnavailable("claude CLI timed out after 60s") from exc
144
+ except OSError as exc:
145
+ raise LLMUnavailable(f"claude CLI subprocess failed: {exc}") from exc
146
+
147
+ if proc.returncode != 0:
148
+ raise LLMUnavailable(
149
+ f"claude CLI exited {proc.returncode}: {proc.stderr.strip()[:200]}"
150
+ )
151
+ return _parse_claude_json(proc.stdout)
152
+
153
+
154
+ def _parse_claude_json(stdout: str) -> "LLMResponse":
155
+ from core.runtime.llm_provider import LLMResponse
156
+
157
+ payload = json.loads(stdout) if stdout.strip() else {}
158
+ text = str(payload.get("result") or payload.get("response") or "")
159
+ usage = payload.get("usage") or {}
160
+ tokens_in = int(usage.get("input_tokens") or 0)
161
+ tokens_out = int(usage.get("output_tokens") or 0)
162
+ cache_read = int(usage.get("cache_read_input_tokens") or 0)
163
+ cache_write = int(usage.get("cache_creation_input_tokens") or 0)
164
+ total_input = tokens_in + cache_read + cache_write
165
+ model = str(payload.get("model") or "")
166
+ return LLMResponse(
167
+ text=text,
168
+ tokens_in=total_input,
169
+ tokens_out=tokens_out,
170
+ cached_tokens=cache_read,
171
+ model=model,
172
+ )
@@ -4,11 +4,16 @@ OpenAI's Codex CLI. Supports sandboxed execution and file operations.
4
4
  More limited than Claude Code: no native hooks, no MCP servers.
5
5
  """
6
6
 
7
+ import shutil
7
8
  from pathlib import Path
8
9
  from os.path import expanduser
10
+ from typing import TYPE_CHECKING
9
11
 
10
12
  from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
11
13
 
14
+ if TYPE_CHECKING:
15
+ from core.runtime.llm_provider import LLMResponse
16
+
12
17
 
13
18
  class CodexCliAdapter(RuntimeAdapter):
14
19
  """Adapter for OpenAI's Codex CLI."""
@@ -69,3 +74,31 @@ class CodexCliAdapter(RuntimeAdapter):
69
74
 
70
75
  def search_content(self, pattern: str, path: str = ".") -> list[str]:
71
76
  raise NotImplementedError("Use Codex CLI's native content search")
77
+
78
+ def headless_supported(self) -> bool:
79
+ # Codex CLI headless invocation syntax is not stable as of
80
+ # 2026-04-20. Until verified, we surface unsupported and let
81
+ # SubagentProvider fall back to AnthropicDirect or stub.
82
+ return False
83
+
84
+ def headless_complete(
85
+ self,
86
+ prompt: str,
87
+ *,
88
+ max_tokens: int = 2000,
89
+ system: str = "",
90
+ ) -> "LLMResponse":
91
+ binary = shutil.which("codex")
92
+ if binary is None:
93
+ raise NotImplementedError(
94
+ "codex CLI not found on PATH — install Codex CLI to "
95
+ "enable headless completion."
96
+ )
97
+ # TODO(llm-agnostic): Verify Codex CLI headless invocation
98
+ # syntax (`codex exec "<prompt>"` was the working hypothesis
99
+ # but has not been confirmed for the current release). Until
100
+ # then, refuse rather than guess. Tracked in Task #12 report.
101
+ raise NotImplementedError(
102
+ "Codex CLI headless completion not yet wired — verify CLI "
103
+ "syntax before enabling. See core/runtime/codex_cli.py TODO."
104
+ )
@@ -6,9 +6,13 @@ Supports agent mode with tool execution.
6
6
 
7
7
  from pathlib import Path
8
8
  from os.path import expanduser
9
+ from typing import TYPE_CHECKING
9
10
 
10
11
  from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
11
12
 
13
+ if TYPE_CHECKING:
14
+ from core.runtime.llm_provider import LLMResponse
15
+
12
16
 
13
17
  class CursorAdapter(RuntimeAdapter):
14
18
  """Adapter for Cursor AI IDE."""
@@ -69,3 +73,18 @@ class CursorAdapter(RuntimeAdapter):
69
73
 
70
74
  def search_content(self, pattern: str, path: str = ".") -> list[str]:
71
75
  raise NotImplementedError("Use Cursor's native content search")
76
+
77
+ def headless_supported(self) -> bool:
78
+ return False
79
+
80
+ def headless_complete(
81
+ self,
82
+ prompt: str,
83
+ *,
84
+ max_tokens: int = 2000,
85
+ system: str = "",
86
+ ) -> "LLMResponse":
87
+ raise NotImplementedError(
88
+ "Cursor has no headless CLI mode as of 2026-04. "
89
+ "Fall back to AnthropicDirectProvider or StubProvider."
90
+ )
@@ -3,11 +3,16 @@
3
3
  Google's Gemini CLI. Uses GEMINI.md for instructions and activate_skill for skills.
4
4
  """
5
5
 
6
+ import shutil
6
7
  from pathlib import Path
7
8
  from os.path import expanduser
9
+ from typing import TYPE_CHECKING
8
10
 
9
11
  from core.runtime.base import RuntimeAdapter, RuntimeConfig, AgentContext, AgentResult
10
12
 
13
+ if TYPE_CHECKING:
14
+ from core.runtime.llm_provider import LLMResponse
15
+
11
16
 
12
17
  class GeminiCliAdapter(RuntimeAdapter):
13
18
  """Adapter for Google's Gemini CLI."""
@@ -66,3 +71,31 @@ class GeminiCliAdapter(RuntimeAdapter):
66
71
 
67
72
  def search_content(self, pattern: str, path: str = ".") -> list[str]:
68
73
  raise NotImplementedError("Use Gemini CLI's native content search")
74
+
75
+ def headless_supported(self) -> bool:
76
+ # Gemini CLI headless invocation syntax is not verified for the
77
+ # current release. Returning False lets SubagentProvider fall
78
+ # back gracefully rather than shell out blindly.
79
+ return False
80
+
81
+ def headless_complete(
82
+ self,
83
+ prompt: str,
84
+ *,
85
+ max_tokens: int = 2000,
86
+ system: str = "",
87
+ ) -> "LLMResponse":
88
+ binary = shutil.which("gemini")
89
+ if binary is None:
90
+ raise NotImplementedError(
91
+ "gemini CLI not found on PATH — install Gemini CLI to "
92
+ "enable headless completion."
93
+ )
94
+ # TODO(llm-agnostic): Verify Gemini CLI's headless invocation
95
+ # (`gemini -p "<prompt>"` was the working hypothesis). Until
96
+ # confirmed for the shipped CLI version, refuse rather than
97
+ # guess. Tracked in Task #12 report.
98
+ raise NotImplementedError(
99
+ "Gemini CLI headless completion not yet wired — verify CLI "
100
+ "syntax before enabling. See core/runtime/gemini_cli.py TODO."
101
+ )