bhu-cli 1.46.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 (258) hide show
  1. bhu_cli-1.46.0.dist-info/METADATA +210 -0
  2. bhu_cli-1.46.0.dist-info/RECORD +258 -0
  3. bhu_cli-1.46.0.dist-info/WHEEL +4 -0
  4. bhu_cli-1.46.0.dist-info/entry_points.txt +4 -0
  5. kimi_cli/CHANGELOG.md +1281 -0
  6. kimi_cli/__init__.py +29 -0
  7. kimi_cli/__main__.py +43 -0
  8. kimi_cli/_build_info.py +1 -0
  9. kimi_cli/acp/AGENTS.md +92 -0
  10. kimi_cli/acp/__init__.py +13 -0
  11. kimi_cli/acp/convert.py +128 -0
  12. kimi_cli/acp/kaos.py +291 -0
  13. kimi_cli/acp/mcp.py +46 -0
  14. kimi_cli/acp/server.py +468 -0
  15. kimi_cli/acp/session.py +499 -0
  16. kimi_cli/acp/tools.py +167 -0
  17. kimi_cli/acp/types.py +13 -0
  18. kimi_cli/acp/version.py +45 -0
  19. kimi_cli/agents/default/agent.yaml +36 -0
  20. kimi_cli/agents/default/coder.yaml +25 -0
  21. kimi_cli/agents/default/explore.yaml +46 -0
  22. kimi_cli/agents/default/plan.yaml +30 -0
  23. kimi_cli/agents/default/system.md +160 -0
  24. kimi_cli/agents/okabe/agent.yaml +22 -0
  25. kimi_cli/agentspec.py +160 -0
  26. kimi_cli/app.py +807 -0
  27. kimi_cli/approval_runtime/__init__.py +29 -0
  28. kimi_cli/approval_runtime/models.py +42 -0
  29. kimi_cli/approval_runtime/runtime.py +235 -0
  30. kimi_cli/auth/__init__.py +5 -0
  31. kimi_cli/auth/oauth.py +1092 -0
  32. kimi_cli/auth/platforms.py +374 -0
  33. kimi_cli/background/__init__.py +36 -0
  34. kimi_cli/background/agent_runner.py +231 -0
  35. kimi_cli/background/ids.py +19 -0
  36. kimi_cli/background/manager.py +725 -0
  37. kimi_cli/background/models.py +105 -0
  38. kimi_cli/background/store.py +237 -0
  39. kimi_cli/background/summary.py +66 -0
  40. kimi_cli/background/worker.py +209 -0
  41. kimi_cli/cli/__init__.py +1081 -0
  42. kimi_cli/cli/__main__.py +34 -0
  43. kimi_cli/cli/_lazy_group.py +238 -0
  44. kimi_cli/cli/export.py +322 -0
  45. kimi_cli/cli/info.py +62 -0
  46. kimi_cli/cli/mcp.py +353 -0
  47. kimi_cli/cli/plugin.py +347 -0
  48. kimi_cli/cli/toad.py +73 -0
  49. kimi_cli/cli/vis.py +38 -0
  50. kimi_cli/cli/web.py +80 -0
  51. kimi_cli/config.py +429 -0
  52. kimi_cli/constant.py +116 -0
  53. kimi_cli/exception.py +43 -0
  54. kimi_cli/hooks/__init__.py +24 -0
  55. kimi_cli/hooks/config.py +34 -0
  56. kimi_cli/hooks/defaults/rtk.py +48 -0
  57. kimi_cli/hooks/engine.py +394 -0
  58. kimi_cli/hooks/events.py +190 -0
  59. kimi_cli/hooks/runner.py +103 -0
  60. kimi_cli/llm.py +333 -0
  61. kimi_cli/mcp_oauth.py +72 -0
  62. kimi_cli/metadata.py +79 -0
  63. kimi_cli/notifications/__init__.py +33 -0
  64. kimi_cli/notifications/llm.py +77 -0
  65. kimi_cli/notifications/manager.py +145 -0
  66. kimi_cli/notifications/models.py +50 -0
  67. kimi_cli/notifications/notifier.py +41 -0
  68. kimi_cli/notifications/store.py +118 -0
  69. kimi_cli/notifications/wire.py +21 -0
  70. kimi_cli/plugin/__init__.py +124 -0
  71. kimi_cli/plugin/manager.py +153 -0
  72. kimi_cli/plugin/tool.py +173 -0
  73. kimi_cli/prompts/__init__.py +6 -0
  74. kimi_cli/prompts/compact.md +73 -0
  75. kimi_cli/prompts/init.md +21 -0
  76. kimi_cli/py.typed +0 -0
  77. kimi_cli/session.py +319 -0
  78. kimi_cli/session_fork.py +325 -0
  79. kimi_cli/session_state.py +132 -0
  80. kimi_cli/share.py +14 -0
  81. kimi_cli/skill/__init__.py +727 -0
  82. kimi_cli/skill/flow/__init__.py +99 -0
  83. kimi_cli/skill/flow/d2.py +482 -0
  84. kimi_cli/skill/flow/mermaid.py +266 -0
  85. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  86. kimi_cli/skills/skill-creator/SKILL.md +367 -0
  87. kimi_cli/soul/__init__.py +304 -0
  88. kimi_cli/soul/agent.py +519 -0
  89. kimi_cli/soul/approval.py +267 -0
  90. kimi_cli/soul/btw.py +237 -0
  91. kimi_cli/soul/compaction.py +189 -0
  92. kimi_cli/soul/context.py +339 -0
  93. kimi_cli/soul/denwarenji.py +39 -0
  94. kimi_cli/soul/dynamic_injection.py +84 -0
  95. kimi_cli/soul/dynamic_injections/__init__.py +0 -0
  96. kimi_cli/soul/dynamic_injections/afk_mode.py +74 -0
  97. kimi_cli/soul/dynamic_injections/plan_mode.py +245 -0
  98. kimi_cli/soul/kimisoul.py +1710 -0
  99. kimi_cli/soul/message.py +92 -0
  100. kimi_cli/soul/slash.py +341 -0
  101. kimi_cli/soul/toolset.py +891 -0
  102. kimi_cli/subagents/__init__.py +21 -0
  103. kimi_cli/subagents/builder.py +42 -0
  104. kimi_cli/subagents/core.py +86 -0
  105. kimi_cli/subagents/git_context.py +170 -0
  106. kimi_cli/subagents/models.py +54 -0
  107. kimi_cli/subagents/output.py +71 -0
  108. kimi_cli/subagents/registry.py +28 -0
  109. kimi_cli/subagents/runner.py +428 -0
  110. kimi_cli/subagents/store.py +196 -0
  111. kimi_cli/telemetry/__init__.py +197 -0
  112. kimi_cli/telemetry/crash.py +149 -0
  113. kimi_cli/telemetry/sink.py +143 -0
  114. kimi_cli/telemetry/transport.py +318 -0
  115. kimi_cli/tools/AGENTS.md +5 -0
  116. kimi_cli/tools/__init__.py +105 -0
  117. kimi_cli/tools/agent/__init__.py +277 -0
  118. kimi_cli/tools/agent/description.md +41 -0
  119. kimi_cli/tools/ask_user/__init__.py +154 -0
  120. kimi_cli/tools/ask_user/description.md +19 -0
  121. kimi_cli/tools/background/__init__.py +318 -0
  122. kimi_cli/tools/background/list.md +10 -0
  123. kimi_cli/tools/background/output.md +11 -0
  124. kimi_cli/tools/background/stop.md +8 -0
  125. kimi_cli/tools/display.py +46 -0
  126. kimi_cli/tools/dmail/__init__.py +38 -0
  127. kimi_cli/tools/dmail/dmail.md +17 -0
  128. kimi_cli/tools/file/__init__.py +30 -0
  129. kimi_cli/tools/file/glob.md +21 -0
  130. kimi_cli/tools/file/glob.py +178 -0
  131. kimi_cli/tools/file/grep.md +6 -0
  132. kimi_cli/tools/file/grep_local.py +590 -0
  133. kimi_cli/tools/file/plan_mode.py +45 -0
  134. kimi_cli/tools/file/read.md +16 -0
  135. kimi_cli/tools/file/read.py +300 -0
  136. kimi_cli/tools/file/read_media.md +24 -0
  137. kimi_cli/tools/file/read_media.py +217 -0
  138. kimi_cli/tools/file/replace.md +7 -0
  139. kimi_cli/tools/file/replace.py +195 -0
  140. kimi_cli/tools/file/utils.py +257 -0
  141. kimi_cli/tools/file/write.md +5 -0
  142. kimi_cli/tools/file/write.py +177 -0
  143. kimi_cli/tools/plan/__init__.py +339 -0
  144. kimi_cli/tools/plan/description.md +29 -0
  145. kimi_cli/tools/plan/enter.py +197 -0
  146. kimi_cli/tools/plan/enter_description.md +35 -0
  147. kimi_cli/tools/plan/heroes.py +277 -0
  148. kimi_cli/tools/shell/__init__.py +260 -0
  149. kimi_cli/tools/shell/bash.md +35 -0
  150. kimi_cli/tools/test.py +55 -0
  151. kimi_cli/tools/think/__init__.py +21 -0
  152. kimi_cli/tools/think/think.md +1 -0
  153. kimi_cli/tools/todo/__init__.py +168 -0
  154. kimi_cli/tools/todo/set_todo_list.md +23 -0
  155. kimi_cli/tools/utils.py +199 -0
  156. kimi_cli/tools/web/__init__.py +4 -0
  157. kimi_cli/tools/web/fetch.md +1 -0
  158. kimi_cli/tools/web/fetch.py +189 -0
  159. kimi_cli/tools/web/search.md +1 -0
  160. kimi_cli/tools/web/search.py +163 -0
  161. kimi_cli/ui/__init__.py +0 -0
  162. kimi_cli/ui/acp/__init__.py +99 -0
  163. kimi_cli/ui/print/__init__.py +474 -0
  164. kimi_cli/ui/print/visualize.py +194 -0
  165. kimi_cli/ui/shell/__init__.py +1540 -0
  166. kimi_cli/ui/shell/console.py +109 -0
  167. kimi_cli/ui/shell/debug.py +190 -0
  168. kimi_cli/ui/shell/echo.py +17 -0
  169. kimi_cli/ui/shell/export_import.py +117 -0
  170. kimi_cli/ui/shell/keyboard.py +300 -0
  171. kimi_cli/ui/shell/mcp_status.py +111 -0
  172. kimi_cli/ui/shell/oauth.py +149 -0
  173. kimi_cli/ui/shell/placeholders.py +531 -0
  174. kimi_cli/ui/shell/prompt.py +2259 -0
  175. kimi_cli/ui/shell/replay.py +215 -0
  176. kimi_cli/ui/shell/session_picker.py +227 -0
  177. kimi_cli/ui/shell/setup.py +212 -0
  178. kimi_cli/ui/shell/slash.py +893 -0
  179. kimi_cli/ui/shell/startup.py +32 -0
  180. kimi_cli/ui/shell/task_browser.py +486 -0
  181. kimi_cli/ui/shell/update.py +349 -0
  182. kimi_cli/ui/shell/usage.py +295 -0
  183. kimi_cli/ui/shell/visualize/__init__.py +165 -0
  184. kimi_cli/ui/shell/visualize/_approval_panel.py +505 -0
  185. kimi_cli/ui/shell/visualize/_blocks.py +640 -0
  186. kimi_cli/ui/shell/visualize/_btw_panel.py +224 -0
  187. kimi_cli/ui/shell/visualize/_input_router.py +48 -0
  188. kimi_cli/ui/shell/visualize/_interactive.py +524 -0
  189. kimi_cli/ui/shell/visualize/_live_view.py +902 -0
  190. kimi_cli/ui/shell/visualize/_question_panel.py +586 -0
  191. kimi_cli/ui/theme.py +241 -0
  192. kimi_cli/utils/__init__.py +0 -0
  193. kimi_cli/utils/aiohttp.py +24 -0
  194. kimi_cli/utils/aioqueue.py +72 -0
  195. kimi_cli/utils/broadcast.py +37 -0
  196. kimi_cli/utils/changelog.py +108 -0
  197. kimi_cli/utils/clipboard.py +246 -0
  198. kimi_cli/utils/datetime.py +64 -0
  199. kimi_cli/utils/diff.py +135 -0
  200. kimi_cli/utils/editor.py +91 -0
  201. kimi_cli/utils/environment.py +225 -0
  202. kimi_cli/utils/envvar.py +22 -0
  203. kimi_cli/utils/export.py +696 -0
  204. kimi_cli/utils/file_filter.py +367 -0
  205. kimi_cli/utils/frontmatter.py +70 -0
  206. kimi_cli/utils/io.py +27 -0
  207. kimi_cli/utils/logging.py +124 -0
  208. kimi_cli/utils/media_tags.py +29 -0
  209. kimi_cli/utils/message.py +24 -0
  210. kimi_cli/utils/path.py +245 -0
  211. kimi_cli/utils/proctitle.py +33 -0
  212. kimi_cli/utils/proxy.py +31 -0
  213. kimi_cli/utils/pyinstaller.py +43 -0
  214. kimi_cli/utils/rich/__init__.py +33 -0
  215. kimi_cli/utils/rich/columns.py +99 -0
  216. kimi_cli/utils/rich/diff_render.py +481 -0
  217. kimi_cli/utils/rich/markdown.py +898 -0
  218. kimi_cli/utils/rich/markdown_sample.md +108 -0
  219. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  220. kimi_cli/utils/rich/syntax.py +114 -0
  221. kimi_cli/utils/sensitive.py +54 -0
  222. kimi_cli/utils/server.py +121 -0
  223. kimi_cli/utils/shell_quoting.py +38 -0
  224. kimi_cli/utils/signals.py +43 -0
  225. kimi_cli/utils/slashcmd.py +145 -0
  226. kimi_cli/utils/string.py +41 -0
  227. kimi_cli/utils/subprocess_env.py +73 -0
  228. kimi_cli/utils/term.py +168 -0
  229. kimi_cli/utils/typing.py +20 -0
  230. kimi_cli/utils/windows_paths.py +48 -0
  231. kimi_cli/vis/__init__.py +0 -0
  232. kimi_cli/vis/api/__init__.py +5 -0
  233. kimi_cli/vis/api/sessions.py +687 -0
  234. kimi_cli/vis/api/statistics.py +209 -0
  235. kimi_cli/vis/api/system.py +19 -0
  236. kimi_cli/vis/app.py +175 -0
  237. kimi_cli/web/__init__.py +5 -0
  238. kimi_cli/web/api/__init__.py +15 -0
  239. kimi_cli/web/api/config.py +208 -0
  240. kimi_cli/web/api/open_in.py +197 -0
  241. kimi_cli/web/api/sessions.py +1223 -0
  242. kimi_cli/web/app.py +451 -0
  243. kimi_cli/web/auth.py +191 -0
  244. kimi_cli/web/models.py +98 -0
  245. kimi_cli/web/runner/__init__.py +5 -0
  246. kimi_cli/web/runner/messages.py +57 -0
  247. kimi_cli/web/runner/process.py +754 -0
  248. kimi_cli/web/runner/worker.py +95 -0
  249. kimi_cli/web/store/__init__.py +1 -0
  250. kimi_cli/web/store/sessions.py +432 -0
  251. kimi_cli/wire/__init__.py +148 -0
  252. kimi_cli/wire/file.py +151 -0
  253. kimi_cli/wire/jsonrpc.py +263 -0
  254. kimi_cli/wire/protocol.py +2 -0
  255. kimi_cli/wire/root_hub.py +27 -0
  256. kimi_cli/wire/serde.py +26 -0
  257. kimi_cli/wire/server.py +1060 -0
  258. kimi_cli/wire/types.py +717 -0
kimi_cli/app.py ADDED
@@ -0,0 +1,807 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import dataclasses
6
+ import sys
7
+ import time
8
+ import warnings
9
+ from collections.abc import AsyncGenerator, Callable
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import kaos
14
+ from kaos.path import KaosPath
15
+ from pydantic import SecretStr
16
+
17
+ from kimi_cli.agentspec import DEFAULT_AGENT_FILE
18
+ from kimi_cli.auth.oauth import KIMI_CODE_OAUTH_KEY, OAuthManager, get_device_id
19
+ from kimi_cli.background.models import is_terminal_status
20
+ from kimi_cli.cli import InputFormat, OutputFormat
21
+ from kimi_cli.config import Config, LLMModel, LLMProvider, load_config
22
+ from kimi_cli.constant import VERSION
23
+ from kimi_cli.llm import augment_provider_with_env_vars, create_llm, model_display_name
24
+ from kimi_cli.session import Session
25
+ from kimi_cli.share import get_share_dir
26
+ from kimi_cli.soul import RunCancelled, run_soul
27
+ from kimi_cli.soul.agent import Runtime, load_agent
28
+ from kimi_cli.soul.context import Context
29
+ from kimi_cli.soul.kimisoul import KimiSoul
30
+ from kimi_cli.utils.aioqueue import QueueShutDown
31
+ from kimi_cli.utils.envvar import get_env_bool
32
+ from kimi_cli.utils.logging import logger, open_original_stderr, redirect_stderr_to_logger
33
+ from kimi_cli.utils.path import shorten_home
34
+ from kimi_cli.wire import Wire, WireUISide
35
+ from kimi_cli.wire.types import ApprovalRequest, ApprovalResponse, ContentPart, WireMessage
36
+
37
+ if TYPE_CHECKING:
38
+ from fastmcp.mcp_config import MCPConfig
39
+
40
+
41
+ def _patch_session_id(record: dict[str, Any]) -> None:
42
+ """Inject the current session ID (from ContextVar) into log records."""
43
+ try:
44
+ from kimi_cli.soul.toolset import get_session_id
45
+
46
+ sid = get_session_id()
47
+ record["extra"]["sid"] = sid if sid else ""
48
+ except Exception:
49
+ record["extra"].setdefault("sid", "")
50
+
51
+
52
+ def enable_logging(debug: bool = False, *, redirect_stderr: bool = True) -> None:
53
+ # NOTE: stderr redirection is implemented by swapping the process-level fd=2 (dup2).
54
+ # That can hide Click/Typer error output during CLI startup, so some entrypoints delay
55
+ # installing it until after critical initialization succeeds.
56
+ logger.remove() # Remove default stderr handler
57
+ logger.enable("kimi_cli")
58
+ if debug:
59
+ logger.enable("kosong")
60
+ logger.add(
61
+ get_share_dir() / "logs" / "kimi.log",
62
+ # FIXME: configure level for different modules
63
+ level="TRACE" if debug else "INFO",
64
+ format=(
65
+ "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | "
66
+ "{name}:{function}:{line} | {extra[sid]} - {message}"
67
+ ),
68
+ rotation="06:00",
69
+ retention="10 days",
70
+ )
71
+ logger.configure(extra={"sid": ""}, patcher=_patch_session_id)
72
+ if redirect_stderr:
73
+ redirect_stderr_to_logger()
74
+
75
+
76
+ def _write_original_stderr(text: str) -> None:
77
+ """Write a user-facing notice to the terminal even if ``fd=2`` has been
78
+ redirected into the logger by ``redirect_stderr_to_logger``.
79
+
80
+ Falls back to ``sys.stderr`` when no redirector is installed (tests,
81
+ early-startup code paths), matching the semantics of ``_emit_fatal_error``
82
+ in ``cli/__init__.py``.
83
+ """
84
+ with open_original_stderr() as stream:
85
+ if stream is not None:
86
+ stream.write(text.encode("utf-8", errors="replace"))
87
+ stream.flush()
88
+ return
89
+ sys.stderr.write(text)
90
+
91
+
92
+ async def _refresh_managed_models_silent(config: Config) -> None:
93
+ from kimi_cli.auth.platforms import refresh_managed_models
94
+
95
+ try:
96
+ await refresh_managed_models(config)
97
+ except Exception as exc:
98
+ logger.warning("Background managed-model refresh failed: {error}", error=exc)
99
+
100
+
101
+ def _cleanup_stale_foreground_subagents(runtime: Runtime) -> None:
102
+ subagent_store = getattr(runtime, "subagent_store", None)
103
+ if subagent_store is None:
104
+ return
105
+
106
+ stale_agent_ids = [
107
+ record.agent_id
108
+ for record in subagent_store.list_instances()
109
+ if record.status == "running_foreground"
110
+ ]
111
+ for agent_id in stale_agent_ids:
112
+ logger.warning(
113
+ "Marking stale foreground subagent instance as failed during startup: {agent_id}",
114
+ agent_id=agent_id,
115
+ )
116
+ subagent_store.update_instance(agent_id, status="failed")
117
+
118
+
119
+ class KimiCLI:
120
+ @staticmethod
121
+ async def create(
122
+ session: Session,
123
+ *,
124
+ # Basic configuration
125
+ config: Config | Path | None = None,
126
+ model_name: str | None = None,
127
+ thinking: bool | None = None,
128
+ # Run mode
129
+ yolo: bool = False,
130
+ afk: bool = False,
131
+ runtime_afk: bool = False,
132
+ plan_mode: bool = False,
133
+ resumed: bool = False,
134
+ ui_mode: str = "shell",
135
+ # Extensions
136
+ agent_file: Path | None = None,
137
+ mcp_configs: list[MCPConfig] | list[dict[str, Any]] | None = None,
138
+ skills_dirs: list[KaosPath] | None = None,
139
+ # Loop control
140
+ max_steps_per_turn: int | None = None,
141
+ max_retries_per_step: int | None = None,
142
+ max_ralph_iterations: int | None = None,
143
+ startup_progress: Callable[[str], None] | None = None,
144
+ defer_mcp_loading: bool = False,
145
+ ) -> KimiCLI:
146
+ """
147
+ Create a KimiCLI instance.
148
+
149
+ Args:
150
+ session (Session): A session created by `Session.create` or `Session.continue_`.
151
+ config (Config | Path | None, optional): Configuration to use, or path to config file.
152
+ Defaults to None.
153
+ model_name (str | None, optional): Name of the model to use. Defaults to None.
154
+ thinking (bool | None, optional): Whether to enable thinking mode. Defaults to None.
155
+ yolo (bool, optional): Approve all actions without confirmation. Defaults to False.
156
+ afk (bool, optional): Invocation-level away-from-keyboard mode (no user is present
157
+ to answer questions or approve actions). Implies auto-approve. Defaults to False.
158
+ runtime_afk (bool, optional): Internal invocation-only afk overlay, used by print mode
159
+ so it stays non-interactive without changing persisted session afk. Defaults to
160
+ False.
161
+ agent_file (Path | None, optional): Path to the agent file. Defaults to None.
162
+ mcp_configs (list[MCPConfig | dict[str, Any]] | None, optional): MCP configs to load
163
+ MCP tools from. Defaults to None.
164
+ skills_dirs (list[KaosPath] | None, optional): Custom skills directories that
165
+ override default user/project discovery. Defaults to None.
166
+ max_steps_per_turn (int | None, optional): Maximum number of steps in one turn.
167
+ Defaults to None.
168
+ max_retries_per_step (int | None, optional): Maximum number of retries in one step.
169
+ Defaults to None.
170
+ max_ralph_iterations (int | None, optional): Extra iterations after the first turn in
171
+ Ralph mode. Defaults to None.
172
+ startup_progress (Callable[[str], None] | None, optional): Progress callback used by
173
+ interactive startup UI. Defaults to None.
174
+ defer_mcp_loading (bool, optional): Defer MCP startup until the interactive shell is
175
+ ready. Defaults to False.
176
+
177
+ Raises:
178
+ FileNotFoundError: When the agent file is not found.
179
+ ConfigError(KimiCLIException, ValueError): When the configuration is invalid.
180
+ AgentSpecError(KimiCLIException, ValueError): When the agent specification is invalid.
181
+ SystemPromptTemplateError(KimiCLIException, ValueError): When the system prompt
182
+ template is invalid.
183
+ InvalidToolError(KimiCLIException, ValueError): When any tool cannot be loaded.
184
+ MCPConfigError(KimiCLIException, ValueError): When any MCP configuration is invalid.
185
+ MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be
186
+ connected.
187
+ """
188
+ _create_t0 = time.monotonic()
189
+ _phase_timings_ms: dict[str, int] = {}
190
+
191
+ if startup_progress is not None:
192
+ startup_progress("Loading configuration...")
193
+
194
+ _phase_t = time.monotonic()
195
+ config = config if isinstance(config, Config) else load_config(config)
196
+ _phase_timings_ms["config_ms"] = int((time.monotonic() - _phase_t) * 1000)
197
+ if max_steps_per_turn is not None:
198
+ config.loop_control.max_steps_per_turn = max_steps_per_turn
199
+ if max_retries_per_step is not None:
200
+ config.loop_control.max_retries_per_step = max_retries_per_step
201
+ if max_ralph_iterations is not None:
202
+ config.loop_control.max_ralph_iterations = max_ralph_iterations
203
+ logger.info("Loaded config: {config}", config=config)
204
+
205
+ _phase_t = time.monotonic()
206
+ oauth = OAuthManager(config)
207
+
208
+ bg_refresh_task = asyncio.create_task(_refresh_managed_models_silent(config))
209
+
210
+ model: LLMModel | None = None
211
+ provider: LLMProvider | None = None
212
+
213
+ # try to use config file
214
+ if not model_name and config.default_model:
215
+ # no --model specified && default model is set in config
216
+ model = config.models[config.default_model]
217
+ provider = config.providers[model.provider]
218
+ if model_name and model_name in config.models:
219
+ # --model specified && model is set in config
220
+ model = config.models[model_name]
221
+ provider = config.providers[model.provider]
222
+
223
+ if not model:
224
+ model = LLMModel(provider="", model="", max_context_size=100_000)
225
+ provider = LLMProvider(type="kimi", base_url="", api_key=SecretStr(""))
226
+
227
+ # try overwrite with environment variables
228
+ assert provider is not None
229
+ assert model is not None
230
+ env_overrides = augment_provider_with_env_vars(provider, model)
231
+
232
+ # determine thinking mode
233
+ thinking = config.default_thinking if thinking is None else thinking
234
+
235
+ # determine yolo mode
236
+ yolo = yolo if yolo else config.default_yolo
237
+
238
+ # determine plan mode (only for new sessions, not restored)
239
+ if not resumed:
240
+ plan_mode = plan_mode if plan_mode else config.default_plan_mode
241
+
242
+ llm = create_llm(
243
+ provider,
244
+ model,
245
+ thinking=thinking,
246
+ session_id=session.id,
247
+ oauth=oauth,
248
+ )
249
+ if llm is not None:
250
+ logger.info("Using LLM provider: {provider}", provider=provider)
251
+ logger.info("Using LLM model: {model}", model=model)
252
+ logger.info("Thinking mode: {thinking}", thinking=thinking)
253
+
254
+ if startup_progress is not None:
255
+ startup_progress("Scanning workspace...")
256
+
257
+ runtime = await Runtime.create(
258
+ config,
259
+ oauth,
260
+ llm,
261
+ session,
262
+ yolo,
263
+ afk=afk,
264
+ runtime_afk=runtime_afk,
265
+ skills_dirs=skills_dirs,
266
+ )
267
+ runtime.ui_mode = ui_mode
268
+ runtime.resumed = resumed
269
+ runtime.notifications.recover()
270
+ runtime.background_tasks.reconcile()
271
+ _cleanup_stale_foreground_subagents(runtime)
272
+ _phase_timings_ms["init_ms"] = int((time.monotonic() - _phase_t) * 1000)
273
+
274
+ # Refresh plugin configs with fresh credentials (e.g. OAuth tokens)
275
+ try:
276
+ from kimi_cli.plugin.manager import (
277
+ collect_host_values,
278
+ get_plugins_dir,
279
+ refresh_plugin_configs,
280
+ )
281
+
282
+ host_values = collect_host_values(config, oauth)
283
+ if host_values.get("api_key"):
284
+ refresh_plugin_configs(get_plugins_dir(), host_values)
285
+ except Exception:
286
+ logger.debug("Failed to refresh plugin configs, skipping")
287
+
288
+ if agent_file is None:
289
+ agent_file = DEFAULT_AGENT_FILE
290
+ if startup_progress is not None:
291
+ startup_progress("Loading agent...")
292
+
293
+ _phase_t = time.monotonic()
294
+ agent = await load_agent(
295
+ agent_file,
296
+ runtime,
297
+ mcp_configs=mcp_configs or [],
298
+ start_mcp_loading=not defer_mcp_loading,
299
+ )
300
+ _phase_timings_ms["mcp_ms"] = int((time.monotonic() - _phase_t) * 1000)
301
+
302
+ if startup_progress is not None:
303
+ startup_progress("Restoring conversation...")
304
+ context = Context(session.context_file)
305
+ await context.restore()
306
+
307
+ if context.system_prompt is not None:
308
+ agent = dataclasses.replace(agent, system_prompt=context.system_prompt)
309
+ else:
310
+ await context.write_system_prompt(agent.system_prompt)
311
+
312
+ soul = KimiSoul(agent, context=context)
313
+
314
+ # Activate plan mode if requested (for new sessions or --plan flag)
315
+ if plan_mode and not soul.plan_mode:
316
+ await soul.set_plan_mode_from_manual(True)
317
+ elif plan_mode and soul.plan_mode:
318
+ # Already in plan mode from restored session, trigger activation reminder
319
+ soul.schedule_plan_activation_reminder()
320
+
321
+ # Create and inject hook engine
322
+ from kimi_cli.hooks.engine import HookEngine
323
+
324
+ hook_engine = HookEngine(config.hooks, cwd=str(session.work_dir))
325
+ soul.set_hook_engine(hook_engine)
326
+ runtime.hook_engine = hook_engine
327
+
328
+ # --- Initialize telemetry ---
329
+ from kimi_cli.telemetry import attach_sink, set_context
330
+ from kimi_cli.telemetry import disable as disable_telemetry
331
+
332
+ telemetry_disabled = not config.telemetry or get_env_bool("KIMI_DISABLE_TELEMETRY")
333
+ if telemetry_disabled:
334
+ disable_telemetry()
335
+ else:
336
+ device_id = get_device_id()
337
+ set_context(device_id=device_id, session_id=session.id)
338
+ from kimi_cli.telemetry.sink import EventSink
339
+ from kimi_cli.telemetry.transport import AsyncTransport
340
+
341
+ def _get_token() -> str | None:
342
+ return oauth.get_cached_access_token(KIMI_CODE_OAUTH_KEY)
343
+
344
+ transport = AsyncTransport(device_id=device_id, get_access_token=_get_token)
345
+ sink = EventSink(
346
+ transport,
347
+ version=VERSION,
348
+ model=model.model if model else "",
349
+ ui_mode=ui_mode,
350
+ )
351
+ attach_sink(sink)
352
+
353
+ from kimi_cli.telemetry import track, track_session_started_once
354
+ from kimi_cli.telemetry.crash import install_asyncio_handler, set_phase
355
+
356
+ # App init finished — enter runtime phase and hook asyncio crashes.
357
+ install_asyncio_handler()
358
+ set_phase("runtime")
359
+
360
+ if ui_mode != "wire":
361
+ track_session_started_once(ui_mode=ui_mode, resumed=resumed)
362
+ track(
363
+ "started",
364
+ resumed=resumed,
365
+ yolo=runtime.approval.is_yolo(),
366
+ afk=runtime.approval.is_afk(),
367
+ )
368
+ track(
369
+ "startup_perf",
370
+ duration_ms=int((time.monotonic() - _create_t0) * 1000),
371
+ config_ms=_phase_timings_ms.get("config_ms", 0),
372
+ init_ms=_phase_timings_ms.get("init_ms", 0),
373
+ mcp_ms=_phase_timings_ms.get("mcp_ms", 0),
374
+ )
375
+
376
+ return KimiCLI(soul, runtime, env_overrides, bg_refresh_task)
377
+
378
+ def __init__(
379
+ self,
380
+ _soul: KimiSoul,
381
+ _runtime: Runtime,
382
+ _env_overrides: dict[str, str],
383
+ _bg_refresh_task: asyncio.Task[None] | None = None,
384
+ ) -> None:
385
+ self._soul = _soul
386
+ self._runtime = _runtime
387
+ self._env_overrides = _env_overrides
388
+ self._bg_refresh_task = _bg_refresh_task
389
+
390
+ @property
391
+ def soul(self) -> KimiSoul:
392
+ """Get the KimiSoul instance."""
393
+ return self._soul
394
+
395
+ @property
396
+ def session(self) -> Session:
397
+ """Get the Session instance."""
398
+ return self._runtime.session
399
+
400
+ async def shutdown_background_tasks(self) -> None:
401
+ """Kill active background tasks on exit, unless keep_alive_on_exit is configured.
402
+
403
+ Prints a stderr notice naming each task so the user knows what is being
404
+ terminated, waits out the configured kill grace period so SIGTERM can
405
+ take effect, then reconciles and reports any workers that ignored the
406
+ signal.
407
+
408
+ This runs on the CLI's hard-shutdown path, so every failure mode must
409
+ be contained: disk IO errors from ``list_tasks`` / ``reconcile`` or
410
+ store corruption must not propagate and replace the real exit code
411
+ with a traceback.
412
+ """
413
+ # Cancel the startup managed-model refresh task if it is still running
414
+ # so it does not outlive the CLI process.
415
+ if self._bg_refresh_task is not None and not self._bg_refresh_task.done():
416
+ self._bg_refresh_task.cancel()
417
+
418
+ bg_config = self._runtime.config.background
419
+ if bg_config.keep_alive_on_exit:
420
+ return
421
+
422
+ try:
423
+ manager = self._runtime.background_tasks
424
+ active_views = [
425
+ v
426
+ for v in manager.list_tasks(status=None, limit=None)
427
+ if not is_terminal_status(v.runtime.status)
428
+ ]
429
+ if not active_views:
430
+ return
431
+
432
+ # Split by whether the task has already been kill-requested (e.g.
433
+ # by the ``--print`` timeout path which ran immediately before
434
+ # this shutdown). For those:
435
+ # - don't re-announce on stderr (user saw the timeout notice)
436
+ # - don't re-kill with a generic reason, which would overwrite
437
+ # the more specific ``kill_reason`` on disk
438
+ # We still reconcile + grace-wait for them so they reach terminal
439
+ # status before the process exits.
440
+ fresh_targets = [v for v in active_views if v.control.kill_requested_at is None]
441
+
442
+ if fresh_targets:
443
+ # Build and emit the kill notice via ``open_original_stderr``
444
+ # — ``sys.stderr.write`` alone would silently land in
445
+ # ``kimi.log`` because ``redirect_stderr_to_logger`` has
446
+ # replaced fd=2 with a pipe into the logger by this point.
447
+ lines = [f"\u26a0 Killing {len(fresh_targets)} background tasks:\n"]
448
+ for view in fresh_targets:
449
+ description = view.spec.description or ""
450
+ if len(description) > 60:
451
+ description = description[:57] + "..."
452
+ lines.append(f" {view.spec.id} {description}\n")
453
+ _write_original_stderr("".join(lines))
454
+
455
+ killed: list[str] = []
456
+ for view in fresh_targets:
457
+ try:
458
+ manager.kill(view.spec.id, reason="CLI session ended")
459
+ killed.append(view.spec.id)
460
+ except Exception:
461
+ logger.exception(
462
+ "Failed to kill task {task_id} during shutdown",
463
+ task_id=view.spec.id,
464
+ )
465
+ if killed:
466
+ logger.info(
467
+ "Stopped {n} background task(s) on exit: {ids}",
468
+ n=len(killed),
469
+ ids=killed,
470
+ )
471
+
472
+ await asyncio.sleep(bg_config.kill_grace_period_ms / 1000)
473
+ manager.reconcile()
474
+ survivors = [
475
+ v
476
+ for v in manager.list_tasks(status=None, limit=None)
477
+ if not is_terminal_status(v.runtime.status)
478
+ ]
479
+ if survivors:
480
+ # Distinguish "worker is mid-shutdown" (kill request on record,
481
+ # SIGTERM delivered, worker just hasn't written terminal state
482
+ # yet) from a genuine leak (never got kill-requested, i.e.
483
+ # ``manager.kill`` raised). Without this split, users saw
484
+ # ``killed N`` from the --print timeout path immediately
485
+ # followed by ``(N tasks still alive)`` here — a direct
486
+ # semantic contradiction.
487
+ terminating = [s for s in survivors if s.control.kill_requested_at is not None]
488
+ leaking = [s for s in survivors if s.control.kill_requested_at is None]
489
+ # Report leaks first — ``stop request failed`` is strictly
490
+ # more severe than ``still terminating`` (the latter will
491
+ # resolve on its own once the worker writes terminal state).
492
+ if leaking:
493
+ _write_original_stderr(
494
+ f" ({len(leaking)} tasks still running; stop request failed)\n"
495
+ )
496
+ if terminating:
497
+ _write_original_stderr(f" ({len(terminating)} tasks still terminating)\n")
498
+ except Exception:
499
+ logger.warning("Error during background task shutdown; continuing exit", exc_info=True)
500
+
501
+ async def await_bg_tasks_shutdown(self, timeout: float = 2.0) -> None:
502
+ """Await completion of the model-refresh background task after cancellation."""
503
+ task = self._bg_refresh_task
504
+ if task is None or task.done():
505
+ return
506
+ # Best-effort cleanup — errors inside the task are already logged.
507
+ with contextlib.suppress(TimeoutError, asyncio.CancelledError, Exception):
508
+ await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
509
+
510
+ @contextlib.asynccontextmanager
511
+ async def _env(self) -> AsyncGenerator[None]:
512
+ original_cwd = KaosPath.cwd()
513
+ await kaos.chdir(self._runtime.session.work_dir)
514
+ try:
515
+ # to ignore possible warnings from dateparser
516
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
517
+ async with self._runtime.oauth.refreshing(self._runtime):
518
+ yield
519
+ finally:
520
+ await kaos.chdir(original_cwd)
521
+
522
+ async def run(
523
+ self,
524
+ user_input: str | list[ContentPart],
525
+ cancel_event: asyncio.Event,
526
+ merge_wire_messages: bool = False,
527
+ ) -> AsyncGenerator[WireMessage]:
528
+ """
529
+ Run the Kimi Code CLI instance without any UI and yield Wire messages directly.
530
+
531
+ Args:
532
+ user_input (str | list[ContentPart]): The user input to the agent.
533
+ cancel_event (asyncio.Event): An event to cancel the run.
534
+ merge_wire_messages (bool): Whether to merge Wire messages as much as possible.
535
+
536
+ Yields:
537
+ WireMessage: The Wire messages from the `KimiSoul`.
538
+
539
+ Raises:
540
+ LLMNotSet: When the LLM is not set.
541
+ LLMNotSupported: When the LLM does not have required capabilities.
542
+ ChatProviderError: When the LLM provider returns an error.
543
+ MaxStepsReached: When the maximum number of steps is reached.
544
+ RunCancelled: When the run is cancelled by the cancel event.
545
+ """
546
+ async with self._env():
547
+ wire_future = asyncio.Future[WireUISide]()
548
+ stop_ui_loop = asyncio.Event()
549
+ approval_bridge_tasks: dict[str, asyncio.Task[None]] = {}
550
+ forwarded_approval_requests: dict[str, ApprovalRequest] = {}
551
+
552
+ async def _bridge_approval_request(request: ApprovalRequest) -> None:
553
+ try:
554
+ response = await request.wait()
555
+ assert self._runtime.approval_runtime is not None
556
+ self._runtime.approval_runtime.resolve(
557
+ request.id, response, feedback=request.feedback
558
+ )
559
+ finally:
560
+ approval_bridge_tasks.pop(request.id, None)
561
+ forwarded_approval_requests.pop(request.id, None)
562
+
563
+ def _forward_approval_request(wire: Wire, request: ApprovalRequest) -> None:
564
+ if request.id in forwarded_approval_requests:
565
+ return
566
+ forwarded_approval_requests[request.id] = request
567
+ if request.id not in approval_bridge_tasks:
568
+ approval_bridge_tasks[request.id] = asyncio.create_task(
569
+ _bridge_approval_request(request)
570
+ )
571
+ wire.soul_side.send(request)
572
+
573
+ async def _ui_loop_fn(wire: Wire) -> None:
574
+ wire_future.set_result(wire.ui_side(merge=merge_wire_messages))
575
+ assert self._runtime.root_wire_hub is not None
576
+ assert self._runtime.approval_runtime is not None
577
+ root_hub_queue = self._runtime.root_wire_hub.subscribe()
578
+ stop_task = asyncio.create_task(stop_ui_loop.wait())
579
+ queue_task = asyncio.create_task(root_hub_queue.get())
580
+ try:
581
+ for pending in self._runtime.approval_runtime.list_pending():
582
+ _forward_approval_request(
583
+ wire,
584
+ ApprovalRequest(
585
+ id=pending.id,
586
+ tool_call_id=pending.tool_call_id,
587
+ sender=pending.sender,
588
+ action=pending.action,
589
+ description=pending.description,
590
+ display=pending.display,
591
+ source_kind=pending.source.kind,
592
+ source_id=pending.source.id,
593
+ agent_id=pending.source.agent_id,
594
+ subagent_type=pending.source.subagent_type,
595
+ ),
596
+ )
597
+ while True:
598
+ done, _ = await asyncio.wait(
599
+ [stop_task, queue_task],
600
+ return_when=asyncio.FIRST_COMPLETED,
601
+ )
602
+ if stop_task in done:
603
+ break
604
+ try:
605
+ msg = queue_task.result()
606
+ except QueueShutDown:
607
+ break
608
+ match msg:
609
+ case ApprovalRequest() as request:
610
+ _forward_approval_request(wire, request)
611
+ queue_task = asyncio.create_task(root_hub_queue.get())
612
+ continue
613
+ case ApprovalResponse() as response:
614
+ if (
615
+ request := forwarded_approval_requests.get(response.request_id)
616
+ ) and not request.resolved:
617
+ request.resolve(response.response, response.feedback)
618
+ case _:
619
+ pass
620
+ wire.soul_side.send(msg)
621
+ queue_task = asyncio.create_task(root_hub_queue.get())
622
+ finally:
623
+ stop_task.cancel()
624
+ queue_task.cancel()
625
+ with contextlib.suppress(asyncio.CancelledError):
626
+ await stop_task
627
+ with contextlib.suppress(asyncio.CancelledError):
628
+ await queue_task
629
+ for task in list(approval_bridge_tasks.values()):
630
+ task.cancel()
631
+ for task in list(approval_bridge_tasks.values()):
632
+ with contextlib.suppress(asyncio.CancelledError):
633
+ await task
634
+ approval_bridge_tasks.clear()
635
+ forwarded_approval_requests.clear()
636
+ assert self._runtime.root_wire_hub is not None
637
+ self._runtime.root_wire_hub.unsubscribe(root_hub_queue)
638
+
639
+ run_cancel_event = asyncio.Event()
640
+
641
+ async def _mirror_external_cancel() -> None:
642
+ await cancel_event.wait()
643
+ run_cancel_event.set()
644
+
645
+ external_cancel_task = asyncio.create_task(
646
+ _mirror_external_cancel(),
647
+ name="cancel-event-mirror",
648
+ )
649
+ soul_task = asyncio.create_task(
650
+ run_soul(
651
+ self.soul,
652
+ user_input,
653
+ _ui_loop_fn,
654
+ run_cancel_event,
655
+ runtime=self._runtime,
656
+ )
657
+ )
658
+
659
+ wire_shut_down = False
660
+ try:
661
+ wire_ui = await wire_future
662
+ while True:
663
+ msg = await wire_ui.receive()
664
+ yield msg
665
+ except QueueShutDown:
666
+ wire_shut_down = True
667
+ pass
668
+ finally:
669
+ # stop consuming Wire messages
670
+ stop_ui_loop.set()
671
+ cleanup_cancelled_run = False
672
+ if not wire_shut_down and not soul_task.done() and not cancel_event.is_set():
673
+ cleanup_cancelled_run = True
674
+ run_cancel_event.set()
675
+ # wait for the soul task to finish, or raise
676
+ try:
677
+ await soul_task
678
+ except RunCancelled:
679
+ if not cleanup_cancelled_run:
680
+ raise
681
+ finally:
682
+ external_cancel_task.cancel()
683
+ with contextlib.suppress(asyncio.CancelledError):
684
+ await external_cancel_task
685
+
686
+ async def run_shell(
687
+ self, command: str | None = None, *, prefill_text: str | None = None
688
+ ) -> bool:
689
+ """Run the Kimi Code CLI instance with shell UI."""
690
+ from kimi_cli.ui.shell import Shell, WelcomeInfoItem
691
+
692
+ if command is None:
693
+ from kimi_cli.ui.shell.update import check_update_gate
694
+
695
+ check_update_gate()
696
+
697
+ welcome_info = [
698
+ WelcomeInfoItem(
699
+ name="Directory", value=str(shorten_home(self._runtime.session.work_dir))
700
+ ),
701
+ WelcomeInfoItem(name="Session", value=self._runtime.session.id),
702
+ ]
703
+ if base_url := self._env_overrides.get("KIMI_BASE_URL"):
704
+ welcome_info.append(
705
+ WelcomeInfoItem(
706
+ name="API URL",
707
+ value=f"{base_url} (from KIMI_BASE_URL)",
708
+ level=WelcomeInfoItem.Level.WARN,
709
+ )
710
+ )
711
+ if self._env_overrides.get("KIMI_API_KEY"):
712
+ welcome_info.append(
713
+ WelcomeInfoItem(
714
+ name="API Key",
715
+ value="****** (from KIMI_API_KEY)",
716
+ level=WelcomeInfoItem.Level.WARN,
717
+ )
718
+ )
719
+ if not self._runtime.llm:
720
+ welcome_info.append(
721
+ WelcomeInfoItem(
722
+ name="Model",
723
+ value="not set, send /login to login",
724
+ level=WelcomeInfoItem.Level.WARN,
725
+ )
726
+ )
727
+ elif "KIMI_MODEL_NAME" in self._env_overrides:
728
+ welcome_info.append(
729
+ WelcomeInfoItem(
730
+ name="Model",
731
+ value=f"{self._soul.model_name} (from KIMI_MODEL_NAME)",
732
+ level=WelcomeInfoItem.Level.WARN,
733
+ )
734
+ )
735
+ else:
736
+ welcome_info.append(
737
+ WelcomeInfoItem(
738
+ name="Model",
739
+ value=model_display_name(
740
+ self._soul.model_name,
741
+ self._runtime.llm.model_config if self._runtime.llm else None,
742
+ ),
743
+ level=WelcomeInfoItem.Level.INFO,
744
+ )
745
+ )
746
+ model_name = self._soul.model_name
747
+ if model_name not in (
748
+ "kimi-for-coding",
749
+ "kimi-code",
750
+ ) and not model_name.startswith("kimi-k2"):
751
+ welcome_info.append(
752
+ WelcomeInfoItem(
753
+ name="Tip",
754
+ value="send /login to use Kimi for Coding",
755
+ level=WelcomeInfoItem.Level.WARN,
756
+ )
757
+ )
758
+ welcome_info.append(
759
+ WelcomeInfoItem(
760
+ name="\nTip",
761
+ value=(
762
+ "Spot a bug or have feedback? Type /feedback right in this session"
763
+ " — every report makes Kimi better."
764
+ ),
765
+ level=WelcomeInfoItem.Level.INFO,
766
+ )
767
+ )
768
+ async with self._env():
769
+ shell = Shell(self._soul, welcome_info=welcome_info, prefill_text=prefill_text)
770
+ return await shell.run(command)
771
+
772
+ async def run_print(
773
+ self,
774
+ input_format: InputFormat,
775
+ output_format: OutputFormat,
776
+ command: str | None = None,
777
+ *,
778
+ final_only: bool = False,
779
+ ) -> int:
780
+ """Run the Kimi Code CLI instance with print UI."""
781
+ from kimi_cli.ui.print import Print
782
+
783
+ async with self._env():
784
+ print_ = Print(
785
+ self._soul,
786
+ input_format,
787
+ output_format,
788
+ self._runtime.session.context_file,
789
+ final_only=final_only,
790
+ )
791
+ return await print_.run(command)
792
+
793
+ async def run_acp(self) -> None:
794
+ """Run the Kimi Code CLI instance as ACP server."""
795
+ from kimi_cli.ui.acp import ACP
796
+
797
+ async with self._env():
798
+ acp = ACP(self._soul)
799
+ await acp.run()
800
+
801
+ async def run_wire_stdio(self) -> None:
802
+ """Run the Kimi Code CLI instance as Wire server over stdio."""
803
+ from kimi_cli.wire.server import WireServer
804
+
805
+ async with self._env():
806
+ server = WireServer(self._soul)
807
+ await server.serve()