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/forge.py ADDED
@@ -0,0 +1,172 @@
1
+ """Forge integration through official CLIs (no API tokens in-process).
2
+
3
+ GitHub work goes through ``gh`` and GitLab through ``glab``; the user's CLI
4
+ login provides credentials, so no token is read or stored by KiwiMateCoder.
5
+ Only create/list/view actions are exposed — merge, close, and delete are
6
+ deliberately absent.
7
+
8
+ The argument builders are pure so they can be unit-tested, and they know the
9
+ CLI differences: GitLab calls pull requests "merge requests" and uses
10
+ ``--description``/``--target-branch`` instead of ``--body``/``--base``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ from typing import Any
18
+ from urllib.parse import urlsplit
19
+
20
+ MAX_OUTPUT = 30_000
21
+ DEFAULT_TIMEOUT = 60
22
+ FORGE_CLIS = {"github": "gh", "gitlab": "glab"}
23
+ READ_ACTIONS = ("pr_list", "pr_view", "issue_list", "issue_view")
24
+ WRITE_ACTIONS = ("pr_create", "issue_create")
25
+
26
+
27
+ def _host_from_remote(remote_url: str) -> str:
28
+ raw = str(remote_url).strip()
29
+ if not raw:
30
+ return ""
31
+ if "://" in raw:
32
+ return (urlsplit(raw).hostname or "").lower().rstrip(".")
33
+ if "@" in raw and ":" in raw:
34
+ return raw.split("@", 1)[1].split(":", 1)[0].strip().lower().rstrip(".")
35
+ return raw.split("/", 1)[0].strip().lower().rstrip(".")
36
+
37
+
38
+ def detect_forge(remote_url: str | None) -> str | None:
39
+ """Return ``"github"``/``"gitlab"`` for a known host, else ``None``.
40
+
41
+ Recognizes the public hosts (``github.com``, ``gitlab.com``) over https,
42
+ ssh, and scp-style remotes. Self-hosted instances are not guessed.
43
+ """
44
+ host = _host_from_remote(remote_url or "")
45
+ if host in {"github.com", "www.github.com"}:
46
+ return "github"
47
+ if host in {"gitlab.com", "www.gitlab.com"}:
48
+ return "gitlab"
49
+ return None
50
+
51
+
52
+ def forge_cli(forge: str | None) -> str | None:
53
+ """The CLI binary for a forge, or ``None`` when unknown."""
54
+ return FORGE_CLIS.get(str(forge or "").lower())
55
+
56
+
57
+ def cli_available(command: str) -> bool:
58
+ """Whether ``command`` is on ``PATH``."""
59
+ return shutil.which(command) is not None
60
+
61
+
62
+ def _pr_verb(forge: str) -> str:
63
+ return "mr" if str(forge).lower() == "gitlab" else "pr"
64
+
65
+
66
+ def _body_flag(forge: str) -> str:
67
+ return "--description" if str(forge).lower() == "gitlab" else "--body"
68
+
69
+
70
+ def _base_flag(forge: str) -> str:
71
+ return "--target-branch" if str(forge).lower() == "gitlab" else "--base"
72
+
73
+
74
+ def pr_list_args(forge: str = "github") -> list[str]:
75
+ return [_pr_verb(forge), "list"]
76
+
77
+
78
+ def pr_view_args(number: int, forge: str = "github") -> list[str]:
79
+ return [_pr_verb(forge), "view", str(number)]
80
+
81
+
82
+ def pr_create_args(
83
+ title: str,
84
+ body: str = "",
85
+ base: str | None = None,
86
+ draft: bool = False,
87
+ forge: str = "github",
88
+ ) -> list[str]:
89
+ args = [
90
+ _pr_verb(forge),
91
+ "create",
92
+ "--title",
93
+ str(title),
94
+ _body_flag(forge),
95
+ str(body),
96
+ ]
97
+ if base:
98
+ args.extend([_base_flag(forge), str(base)])
99
+ if draft:
100
+ args.append("--draft")
101
+ return args
102
+
103
+
104
+ def issue_list_args() -> list[str]:
105
+ return ["issue", "list"]
106
+
107
+
108
+ def issue_view_args(number: int) -> list[str]:
109
+ return ["issue", "view", str(number)]
110
+
111
+
112
+ def issue_create_args(title: str, body: str = "", forge: str = "github") -> list[str]:
113
+ return [
114
+ "issue",
115
+ "create",
116
+ "--title",
117
+ str(title),
118
+ _body_flag(forge),
119
+ str(body),
120
+ ]
121
+
122
+
123
+ def build_write_args(forge: str, args: dict[str, Any]) -> list[str]:
124
+ """Build the argv for a ``forge_write`` call, raising ``ValueError``."""
125
+ action = str(args.get("action") or "").strip().lower()
126
+ title = str(args.get("title") or "").strip()
127
+ body = str(args.get("body") or "")
128
+ base = str(args["base"]).strip() if args.get("base") else None
129
+ if action == "pr_create":
130
+ if not title:
131
+ raise ValueError("A non-empty 'title' is required for pr_create.")
132
+ return pr_create_args(title, body, base, bool(args.get("draft")), forge=forge)
133
+ if action == "issue_create":
134
+ if not title:
135
+ raise ValueError("A non-empty 'title' is required for issue_create.")
136
+ return issue_create_args(title, body, forge=forge)
137
+ raise ValueError(f"Unknown forge_write action '{action}'.")
138
+
139
+
140
+ def current_remote_url(session: Any) -> str | None:
141
+ """Return the ``origin`` remote URL for the session workspace, if any."""
142
+ from kiwimatecoder import git as git_module
143
+
144
+ code, output = git_module.run_git(["remote", "get-url", "origin"], session)
145
+ if code != 0:
146
+ return None
147
+ text = output.strip()
148
+ return text.splitlines()[0].strip() if text else None
149
+
150
+
151
+ def run_forge(
152
+ cli: str, args: list[str], session: Any, timeout: int = DEFAULT_TIMEOUT
153
+ ) -> tuple[int, str]:
154
+ """Run a forge CLI command in the workspace; never raises, never a shell."""
155
+ command = [cli, *(str(arg) for arg in args)]
156
+ try:
157
+ proc = subprocess.run(
158
+ command,
159
+ cwd=str(session.workspace_root),
160
+ capture_output=True,
161
+ text=True,
162
+ timeout=timeout,
163
+ check=False,
164
+ )
165
+ except FileNotFoundError:
166
+ return 127, f"{cli} is not installed or not on PATH."
167
+ except subprocess.TimeoutExpired:
168
+ return 124, f"{cli} timed out after {timeout}s."
169
+ output = proc.stdout or ""
170
+ if proc.stderr:
171
+ output = f"{output}\n{proc.stderr}" if output else proc.stderr
172
+ return proc.returncode, output
kiwimatecoder/git.py ADDED
@@ -0,0 +1,164 @@
1
+ """Git helpers: pure argument builders and a subprocess runner.
2
+
3
+ Only safe, non-destructive actions are exposed. ``push``, ``reset``, and
4
+ ``clean`` are deliberately absent: mutating the remote or discarding work is
5
+ not something an agent should do through this surface.
6
+
7
+ Argument builders return argv lists so callers pass values as separate
8
+ arguments. That removes any shell-quoting surface: the runner never uses
9
+ ``shell=True``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import shlex
16
+ import subprocess
17
+ from typing import Any
18
+
19
+ DEFAULT_TIMEOUT = 30
20
+ MAX_OUTPUT = 30_000
21
+ LOG_LIMIT_MIN = 1
22
+ LOG_LIMIT_MAX = 100
23
+ WRITE_ACTIONS = ("stage", "unstage", "commit", "checkout_branch")
24
+
25
+ _BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
26
+
27
+
28
+ def status_args(*, short: bool = True) -> list[str]:
29
+ """``git status`` argv, short format by default."""
30
+ return ["status", "--short"] if short else ["status"]
31
+
32
+
33
+ def diff_args(
34
+ *, staged: bool = False, path: str | None = None, ref: str | None = None
35
+ ) -> list[str]:
36
+ """``git diff`` argv for unstaged/staged changes, optionally against ``ref``."""
37
+ args = ["diff"]
38
+ if staged:
39
+ args.append("--staged")
40
+ if ref:
41
+ args.append(str(ref))
42
+ if path:
43
+ args.extend(["--", str(path)])
44
+ return args
45
+
46
+
47
+ def log_args(
48
+ limit: int = 20, *, path: str | None = None, oneline: bool = True
49
+ ) -> list[str]:
50
+ """``git log`` argv with a clamped ``limit`` (1-100)."""
51
+ try:
52
+ count = int(limit)
53
+ except (TypeError, ValueError):
54
+ count = 20
55
+ count = max(LOG_LIMIT_MIN, min(count, LOG_LIMIT_MAX))
56
+ args = ["log", f"-{count}", "--no-color"]
57
+ if oneline:
58
+ args.append("--oneline")
59
+ if path:
60
+ args.extend(["--", str(path)])
61
+ return args
62
+
63
+
64
+ def show_args(ref: str = "HEAD") -> list[str]:
65
+ """``git show`` argv for one commit."""
66
+ return ["show", "--no-color", str(ref or "HEAD")]
67
+
68
+
69
+ def branch_args() -> list[str]:
70
+ """``git branch`` argv listing local branches."""
71
+ return ["branch", "--list", "--no-color"]
72
+
73
+
74
+ def valid_branch(name: str) -> bool:
75
+ """Whether ``name`` is a safe, plausible branch name.
76
+
77
+ Mirrors the common ``git check-ref-format --branch`` rules that matter
78
+ here without shelling out: no leading dash, no ``..``/``@{``/``//``, and
79
+ no trailing ``/``, ``.``, or ``.lock``.
80
+ """
81
+ cleaned = str(name).strip()
82
+ if not cleaned or cleaned.startswith("-"):
83
+ return False
84
+ if not _BRANCH_RE.match(cleaned):
85
+ return False
86
+ if ".." in cleaned or "@{" in cleaned or "//" in cleaned:
87
+ return False
88
+ return not cleaned.endswith(("/", ".", ".lock"))
89
+
90
+
91
+ def _clean_paths(raw: Any) -> list[str]:
92
+ if raw is None:
93
+ return []
94
+ if not isinstance(raw, list):
95
+ raise ValueError("'paths' must be a list of paths.")
96
+ paths: list[str] = []
97
+ for item in raw:
98
+ text = str(item).strip()
99
+ if not text:
100
+ raise ValueError("'paths' entries must be non-empty.")
101
+ paths.append(text)
102
+ return paths
103
+
104
+
105
+ def build_write_args(args: dict[str, Any]) -> list[str]:
106
+ """Build the argv for a ``git_write`` call, raising ``ValueError`` when invalid."""
107
+ action = str(args.get("action") or "").strip().lower()
108
+ if action in {"stage", "unstage"}:
109
+ paths = _clean_paths(args.get("paths"))
110
+ if not paths:
111
+ raise ValueError(f"'paths' is required for {action}.")
112
+ if action == "stage":
113
+ return ["add", "--", *paths]
114
+ return ["restore", "--staged", "--", *paths]
115
+ if action == "commit":
116
+ message = str(args.get("message") or "").strip()
117
+ if not message:
118
+ raise ValueError("A non-empty 'message' is required for commit.")
119
+ return ["commit", "-m", message]
120
+ if action == "checkout_branch":
121
+ branch = str(args.get("branch") or "").strip()
122
+ if not valid_branch(branch):
123
+ raise ValueError(f"Invalid branch name '{branch}'.")
124
+ return ["checkout", "-b", branch]
125
+ raise ValueError(f"Unknown git_write action '{action}'.")
126
+
127
+
128
+ def run_git(
129
+ args: list[str], session: Any, timeout: int = DEFAULT_TIMEOUT
130
+ ) -> tuple[int, str]:
131
+ """Run ``git`` with ``args`` in the session workspace.
132
+
133
+ Returns ``(returncode, output)`` where output is stdout plus stderr. Never
134
+ raises: a missing binary and a timeout become synthetic exit codes so the
135
+ tool can report them as text.
136
+ """
137
+ command = ["git", *(str(arg) for arg in args)]
138
+ try:
139
+ proc = subprocess.run(
140
+ command,
141
+ cwd=str(session.workspace_root),
142
+ capture_output=True,
143
+ text=True,
144
+ timeout=timeout,
145
+ check=False,
146
+ )
147
+ except FileNotFoundError:
148
+ return 127, "git is not installed or not on PATH."
149
+ except subprocess.TimeoutExpired:
150
+ return 124, f"git timed out after {timeout}s."
151
+ output = proc.stdout or ""
152
+ if proc.stderr:
153
+ output = f"{output}\n{proc.stderr}" if output else proc.stderr
154
+ return proc.returncode, output
155
+
156
+
157
+ def preview(args: dict[str, Any], session: Any) -> str:
158
+ """Return the exact ``git ...`` command line for the approval prompt."""
159
+ del session
160
+ try:
161
+ command = build_write_args(args)
162
+ except ValueError as exc:
163
+ return str(exc)
164
+ return "git " + " ".join(shlex.quote(part) for part in command)
@@ -0,0 +1,255 @@
1
+ """Headless one-shot agent runs shared by the CLI and the SDK.
2
+
3
+ The CLI's ``-p/--print`` mode and :mod:`kiwimatecoder.sdk` both drive the
4
+ regular :class:`~kiwimatecoder.agent.Agent` through this module so session
5
+ construction, event collection, and cost accounting live in one place.
6
+
7
+ Rendering stays with the caller: pass ``on_event`` to receive
8
+ ``(name, payload)`` notifications for every emitted event and ``console`` for
9
+ the agent's own tool/progress output. Without a console the run is silent.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any, Callable
18
+
19
+ from rich.console import Console
20
+
21
+ from kiwimatecoder import config, ui
22
+ from kiwimatecoder.agent import Agent
23
+ from kiwimatecoder.permissions import ConfirmFn, PermissionMode
24
+ from kiwimatecoder.pricing import estimate_cost
25
+ from kiwimatecoder.session import Session
26
+
27
+ EventCallback = Callable[[str, dict[str, Any]], None]
28
+
29
+
30
+ @dataclass
31
+ class RunOutcome:
32
+ """Everything one headless run produces for its caller."""
33
+
34
+ text: str = ""
35
+ usage: dict[str, int] = field(default_factory=dict)
36
+ cost_usd: float | None = None
37
+ provider: str = ""
38
+ model: str = ""
39
+ mode: str = ""
40
+ tools_used: list[str] = field(default_factory=list)
41
+ messages: int = 0
42
+ success: bool = True
43
+ error: str | None = None
44
+
45
+
46
+ def _resolve_workspace(workspace: str | Path | None) -> Path:
47
+ root = Path(workspace).expanduser() if workspace is not None else Path.cwd()
48
+ if not root.is_dir():
49
+ raise ValueError(f"Workspace is not a directory: {root}")
50
+ return root.resolve()
51
+
52
+
53
+ def build_session(
54
+ *,
55
+ workspace: str | Path | None = None,
56
+ provider: str | None = None,
57
+ model: str | None = None,
58
+ mode: PermissionMode | str | None = None,
59
+ ) -> Session:
60
+ """Build a fresh session exactly like the interactive launch path.
61
+
62
+ ``provider``/``model``/``mode`` override the configured values for this
63
+ run only; nothing is persisted. An overriding ``provider`` becomes the
64
+ primary, ahead of the configured roster. Raises ``ValueError`` for an
65
+ unknown workspace or mode, ``config.ModelNotChosenError`` (a
66
+ ``ValueError``) when no model was passed or chosen for the provider, and
67
+ ``KeyError`` for an unknown provider.
68
+ """
69
+ cfg = config.load_config()
70
+ provider_id = provider or config.get_selected_provider_id(cfg)
71
+ provider_cfg = config.get_provider_config(provider_id, cfg)
72
+ resolved_model = config.require_model(provider_cfg, cfg, override=model)
73
+ roster = config.get_active_provider_ids(cfg)
74
+ active_provider_ids = [provider_id, *(pid for pid in roster if pid != provider_id)]
75
+ if mode is None:
76
+ try:
77
+ resolved_mode = PermissionMode.from_str(str(cfg.get("default_mode", "ask")))
78
+ except ValueError:
79
+ resolved_mode = PermissionMode.ASK
80
+ else:
81
+ resolved_mode = PermissionMode.from_str(mode) if isinstance(mode, str) else mode
82
+ return Session(
83
+ provider_id=provider_id,
84
+ model=resolved_model,
85
+ mode=resolved_mode,
86
+ workspace_root=_resolve_workspace(workspace),
87
+ active_provider_ids=active_provider_ids,
88
+ always_allowed=set(config.get_always_allowed_tools(cfg)),
89
+ output_style=config.get_output_style(cfg),
90
+ custom_system_prompt=config.get_system_prompt(cfg),
91
+ command_rules=config.get_command_rules(cfg),
92
+ trusted_workspace=config.get_trusted_workspace(cfg),
93
+ verify_command=config.get_verify_command(cfg),
94
+ compact_at_tokens=config.get_compact_at_tokens(cfg),
95
+ context_window=config.get_context_window(cfg),
96
+ )
97
+
98
+
99
+ def _apply_overrides(
100
+ session: Session,
101
+ *,
102
+ workspace: str | Path | None,
103
+ provider: str | None,
104
+ model: str | None,
105
+ mode: PermissionMode | str | None,
106
+ ) -> None:
107
+ """Apply per-run overrides to an existing session.
108
+
109
+ Raises ``config.ModelNotChosenError`` when switching to a provider with no
110
+ model passed or chosen.
111
+ """
112
+ if mode is not None:
113
+ session.mode = PermissionMode.from_str(mode) if isinstance(mode, str) else mode
114
+ if provider and provider != session.provider_id:
115
+ provider_cfg = config.get_provider_config(provider)
116
+ session.set_provider(provider, config.require_model(provider_cfg, override=model))
117
+ session.active_provider_ids = [
118
+ provider,
119
+ *(pid for pid in session.active_provider_ids if pid != provider),
120
+ ]
121
+ elif model:
122
+ session.model = model
123
+ if workspace is not None:
124
+ session.workspace_root = _resolve_workspace(workspace)
125
+
126
+
127
+ def _deny(summary: str, preview: str | None) -> bool:
128
+ """Default approval behavior for runs with no interactive user."""
129
+ return False
130
+
131
+
132
+ def _final_text(session: Session, deltas: list[str]) -> str:
133
+ """The last assistant message, falling back to the streamed text."""
134
+ for message in reversed(session.messages):
135
+ if message.get("role") == "assistant" and message.get("content"):
136
+ return str(message["content"])
137
+ return "".join(deltas)
138
+
139
+
140
+ def shutdown_runtime() -> None:
141
+ """Stop anything a headless run lazily started (MCP, LSP, browser)."""
142
+ from kiwimatecoder import browser, lsp, mcp
143
+
144
+ manager = mcp.get_manager()
145
+ if manager is not None:
146
+ mcp.set_manager(None)
147
+ manager.shutdown()
148
+ lsp_manager = lsp.get_manager()
149
+ if lsp_manager is not None:
150
+ lsp.set_manager(None)
151
+ lsp_manager.shutdown()
152
+ browser.reset_driver()
153
+
154
+
155
+ async def run_agent_once(
156
+ prompt: str,
157
+ *,
158
+ workspace: str | Path | None = None,
159
+ provider: str | None = None,
160
+ model: str | None = None,
161
+ mode: PermissionMode | str | None = None,
162
+ confirm: ConfirmFn | None = None,
163
+ on_event: EventCallback | None = None,
164
+ max_turns: int | None = 30,
165
+ console: Console | None = None,
166
+ session: Session | None = None,
167
+ render_text: bool = True,
168
+ output_mode: str | None = None,
169
+ ) -> RunOutcome:
170
+ """Drive one agent turn and return its outcome.
171
+
172
+ Approvals are denied when ``confirm`` is omitted. Text deltas are written
173
+ to ``console`` unless ``render_text`` is False; every emitted event is
174
+ forwarded to ``on_event``. Lazily started managers are shut down before
175
+ returning.
176
+ """
177
+ if session is None:
178
+ session = build_session(
179
+ workspace=workspace, provider=provider, model=model, mode=mode
180
+ )
181
+ else:
182
+ _apply_overrides(
183
+ session, workspace=workspace, provider=provider, model=model, mode=mode
184
+ )
185
+
186
+ if console is None:
187
+ console = Console(file=io.StringIO(), no_color=True, highlight=False)
188
+
189
+ text_parts: list[str] = []
190
+ tools_used: list[str] = []
191
+ errors: list[str] = []
192
+ done_reasons: list[str] = []
193
+
194
+ def collect(name: str, payload: dict[str, Any]) -> None:
195
+ if name == "text_delta":
196
+ text_parts.append(str(payload.get("text") or ""))
197
+ elif name == "tool_start":
198
+ tool = str(payload.get("tool") or "")
199
+ if tool and tool not in tools_used:
200
+ tools_used.append(tool)
201
+ elif name == "error":
202
+ errors.append(str(payload.get("error") or "provider error"))
203
+ elif name == "done":
204
+ reason = str(payload.get("reason") or "")
205
+ done_reasons.append(reason)
206
+ if reason == "budget" and payload.get("error"):
207
+ errors.append(str(payload["error"]))
208
+ if on_event is not None:
209
+ on_event(name, payload)
210
+
211
+ ui_config = ui.ui_config()
212
+ agent = Agent(
213
+ session,
214
+ console,
215
+ confirm if confirm is not None else _deny,
216
+ ascii_mode=ui_config["ascii"],
217
+ output_mode=output_mode or ui_config["output_mode"],
218
+ event_handler=collect,
219
+ max_turns=max_turns,
220
+ render_text=render_text,
221
+ spinner=ui_config["spinner"],
222
+ )
223
+
224
+ try:
225
+ await agent.run_turn(prompt)
226
+ finally:
227
+ shutdown_runtime()
228
+
229
+ if max_turns is not None and "max_turns" in done_reasons:
230
+ errors.append(
231
+ f"Reached the maximum of {max_turns} tool-loop turn(s) without a "
232
+ "final answer."
233
+ )
234
+ error = errors[0] if errors else None
235
+ return RunOutcome(
236
+ text=_final_text(session, text_parts),
237
+ usage={
238
+ "prompt_tokens": session.prompt_tokens,
239
+ "completion_tokens": session.completion_tokens,
240
+ },
241
+ cost_usd=estimate_cost(
242
+ session.prompt_tokens,
243
+ session.completion_tokens,
244
+ session.model,
245
+ session.provider_id,
246
+ is_local=session.provider.is_local,
247
+ ),
248
+ provider=session.provider_id,
249
+ model=session.model,
250
+ mode=session.mode.value,
251
+ tools_used=tools_used,
252
+ messages=len(session.messages),
253
+ success=error is None,
254
+ error=error,
255
+ )