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
@@ -0,0 +1,1223 @@
1
+ """Sessions API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import shutil
10
+ import time
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+ from urllib.parse import quote
15
+ from uuid import UUID, uuid4
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
18
+ from fastapi.responses import FileResponse, Response
19
+ from kaos.path import KaosPath
20
+ from pydantic import BaseModel, Field
21
+ from starlette.websockets import WebSocket, WebSocketDisconnect
22
+
23
+ from kimi_cli import logger
24
+ from kimi_cli.metadata import load_metadata, save_metadata
25
+ from kimi_cli.session import Session as KimiCLISession
26
+ from kimi_cli.utils.subprocess_env import get_clean_env
27
+ from kimi_cli.web.auth import is_origin_allowed, is_private_ip, verify_token
28
+ from kimi_cli.web.models import (
29
+ GenerateTitleRequest,
30
+ GenerateTitleResponse,
31
+ GitDiffStats,
32
+ GitFileDiff,
33
+ Session,
34
+ SessionStatus,
35
+ UpdateSessionRequest,
36
+ )
37
+ from kimi_cli.web.runner.messages import new_session_status_message, send_history_complete
38
+ from kimi_cli.web.runner.process import KimiCLIRunner
39
+ from kimi_cli.web.store.sessions import (
40
+ JointSession,
41
+ invalidate_sessions_cache,
42
+ load_session_by_id,
43
+ load_sessions_page,
44
+ run_auto_archive,
45
+ )
46
+ from kimi_cli.wire.jsonrpc import (
47
+ ErrorCodes,
48
+ JSONRPCErrorObject,
49
+ JSONRPCErrorResponse,
50
+ JSONRPCInMessageAdapter,
51
+ JSONRPCPromptMessage,
52
+ )
53
+ from kimi_cli.wire.serde import deserialize_wire_message
54
+ from kimi_cli.wire.types import is_request
55
+
56
+ router = APIRouter(prefix="/api/sessions", tags=["sessions"])
57
+ work_dirs_router = APIRouter(prefix="/api/work-dirs", tags=["work-dirs"])
58
+
59
+ # Constants
60
+ MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
61
+ DEFAULT_MAX_PUBLIC_PATH_DEPTH = 6
62
+ SENSITIVE_PATH_PARTS = {
63
+ "id_rsa",
64
+ "id_ed25519",
65
+ "known_hosts",
66
+ "credentials",
67
+ ".aws",
68
+ ".ssh",
69
+ ".gnupg",
70
+ ".kube",
71
+ ".npmrc",
72
+ ".pypirc",
73
+ ".netrc",
74
+ }
75
+ SENSITIVE_PATH_EXTENSIONS = {
76
+ ".pem",
77
+ ".key",
78
+ ".p12",
79
+ ".pfx",
80
+ ".kdbx",
81
+ ".der",
82
+ }
83
+ # Home directory patterns to detect if resolved path escapes to sensitive locations
84
+ SENSITIVE_HOME_PATHS = {
85
+ ".ssh",
86
+ ".gnupg",
87
+ ".aws",
88
+ ".kube",
89
+ }
90
+
91
+
92
+ def sanitize_filename(filename: str) -> str:
93
+ """Remove potentially dangerous characters from filename."""
94
+ # Keep only alphanumeric, dots, underscores, hyphens, and spaces
95
+ safe = "".join(c for c in filename if c.isalnum() or c in "._- ")
96
+ return safe.strip() or "unnamed"
97
+
98
+
99
+ def get_runner(req: Request) -> KimiCLIRunner:
100
+ """Get the KimiCLIRunner from the FastAPI app state."""
101
+ return req.app.state.runner
102
+
103
+
104
+ def get_runner_ws(ws: WebSocket) -> KimiCLIRunner:
105
+ """Get the KimiCLIRunner from the FastAPI app state (for WebSocket routes)."""
106
+ return ws.app.state.runner
107
+
108
+
109
+ def get_editable_session(
110
+ session_id: UUID,
111
+ runner: KimiCLIRunner,
112
+ ) -> JointSession:
113
+ """Get a session and verify it's not busy."""
114
+ session = load_session_by_id(session_id)
115
+ if session is None:
116
+ raise HTTPException(
117
+ status_code=status.HTTP_404_NOT_FOUND,
118
+ detail="Session not found",
119
+ )
120
+ # Check if session is busy
121
+ session_process = runner.get_session(session_id)
122
+ if session_process and session_process.is_busy:
123
+ raise HTTPException(
124
+ status_code=status.HTTP_400_BAD_REQUEST,
125
+ detail="Session is busy. Please wait for it to complete before modifying.",
126
+ )
127
+ return session
128
+
129
+
130
+ def _relative_parts(path: Path) -> list[str]:
131
+ return [part for part in path.parts if part not in {"", "."}]
132
+
133
+
134
+ def _is_sensitive_relative_path(rel_path: Path) -> bool:
135
+ parts = _relative_parts(rel_path)
136
+ for part in parts:
137
+ if part.startswith("."):
138
+ return True
139
+ if part.lower() in SENSITIVE_PATH_PARTS:
140
+ return True
141
+ return rel_path.suffix.lower() in SENSITIVE_PATH_EXTENSIONS
142
+
143
+
144
+ def _contains_symlink(path: Path, base: Path) -> bool:
145
+ """Check if any component of the path (relative to base) is a symlink."""
146
+ try:
147
+ current = base
148
+ rel_parts = path.relative_to(base).parts
149
+ for part in rel_parts:
150
+ current = current / part
151
+ if current.is_symlink():
152
+ return True
153
+ except (ValueError, OSError):
154
+ return True
155
+ return False
156
+
157
+
158
+ def _is_path_in_sensitive_location(path: Path) -> bool:
159
+ """Check if resolved path points to a sensitive location (e.g., ~/.ssh, ~/.aws)."""
160
+ try:
161
+ home = Path.home()
162
+ if path.is_relative_to(home):
163
+ rel_to_home = path.relative_to(home)
164
+ first_part = rel_to_home.parts[0] if rel_to_home.parts else ""
165
+ if first_part in SENSITIVE_HOME_PATHS:
166
+ return True
167
+ except (ValueError, RuntimeError):
168
+ pass
169
+ return False
170
+
171
+
172
+ def _ensure_public_file_access_allowed(
173
+ rel_path: Path,
174
+ restrict_sensitive_apis: bool,
175
+ max_path_depth: int = DEFAULT_MAX_PUBLIC_PATH_DEPTH,
176
+ ) -> None:
177
+ if not restrict_sensitive_apis:
178
+ return
179
+ rel_parts = _relative_parts(rel_path)
180
+ if len(rel_parts) > max_path_depth:
181
+ raise HTTPException(
182
+ status_code=status.HTTP_403_FORBIDDEN,
183
+ detail=f"Path too deep for public access "
184
+ f"(max depth: {max_path_depth}, current: {len(rel_parts)}).",
185
+ )
186
+ if _is_sensitive_relative_path(rel_path):
187
+ raise HTTPException(
188
+ status_code=status.HTTP_403_FORBIDDEN,
189
+ detail="Access to sensitive files is disabled.",
190
+ )
191
+
192
+
193
+ def _read_wire_lines(wire_file: Path) -> list[str]:
194
+ """Read and parse wire.jsonl into JSONRPC event strings (runs in thread)."""
195
+ result: list[str] = []
196
+ with open(wire_file, encoding="utf-8") as f:
197
+ for line in f:
198
+ line = line.strip()
199
+ if not line:
200
+ continue
201
+ try:
202
+ record = json.loads(line)
203
+ if not isinstance(record, dict):
204
+ continue
205
+ record = cast(dict[str, Any], record)
206
+ record_type = record.get("type")
207
+ if isinstance(record_type, str) and record_type == "metadata":
208
+ continue
209
+ message_raw = record.get("message")
210
+ if not isinstance(message_raw, dict):
211
+ continue
212
+ message_raw = cast(dict[str, Any], message_raw)
213
+ message = deserialize_wire_message(message_raw)
214
+ _is_req = is_request(message)
215
+ event_msg: dict[str, Any] = {
216
+ "jsonrpc": "2.0",
217
+ "method": "request" if _is_req else "event",
218
+ "params": message_raw,
219
+ }
220
+ if _is_req:
221
+ # JSON-RPC requests require a top-level ``id`` so the
222
+ # client can correlate its response. Use the request's
223
+ # own ``id`` field (e.g. ApprovalRequest.id,
224
+ # QuestionRequest.id). Note: ``message_raw`` wraps data
225
+ # as ``{"type": ..., "payload": {...}}`` so the id lives
226
+ # on the deserialized object, not at the raw dict top level.
227
+ event_msg["id"] = message.id
228
+ result.append(json.dumps(event_msg, ensure_ascii=False))
229
+ except (json.JSONDecodeError, KeyError, ValueError, TypeError):
230
+ continue
231
+ return result
232
+
233
+
234
+ async def replay_history(ws: WebSocket, session_dir: Path) -> None:
235
+ """Replay historical wire messages from wire.jsonl to a WebSocket."""
236
+ wire_file = session_dir / "wire.jsonl"
237
+ if not await asyncio.to_thread(wire_file.exists):
238
+ return
239
+
240
+ try:
241
+ lines = await asyncio.to_thread(_read_wire_lines, wire_file)
242
+ for event_text in lines:
243
+ await ws.send_text(event_text)
244
+ except Exception:
245
+ pass
246
+
247
+
248
+ @router.get("/", summary="List all sessions")
249
+ async def list_sessions(
250
+ runner: KimiCLIRunner = Depends(get_runner),
251
+ limit: int = 100,
252
+ offset: int = 0,
253
+ q: str | None = None,
254
+ archived: bool | None = None,
255
+ ) -> list[Session]:
256
+ """List sessions with optional pagination and search.
257
+
258
+ Args:
259
+ limit: Maximum number of sessions to return (default 100, max 500).
260
+ offset: Number of sessions to skip (default 0).
261
+ q: Optional search query to filter by title or work_dir.
262
+ archived: Filter by archived status.
263
+ - None (default): Only return non-archived sessions.
264
+ - True: Only return archived sessions.
265
+ """
266
+ if limit <= 0:
267
+ limit = 100
268
+ if limit > 500:
269
+ limit = 500
270
+ if offset < 0:
271
+ offset = 0
272
+
273
+ # Run auto-archive in background (throttled internally, runs at most once per 5 minutes)
274
+ await asyncio.to_thread(run_auto_archive)
275
+
276
+ sessions = load_sessions_page(limit=limit, offset=offset, query=q, archived=archived)
277
+ for session in sessions:
278
+ session_process = runner.get_session(session.session_id)
279
+ session.is_running = session_process is not None and session_process.is_running
280
+ session.status = session_process.status if session_process else None
281
+ return cast(list[Session], sessions)
282
+
283
+
284
+ @router.get("/{session_id}", summary="Get session")
285
+ async def get_session(
286
+ session_id: UUID,
287
+ runner: KimiCLIRunner = Depends(get_runner),
288
+ ) -> Session | None:
289
+ """Get a session by ID."""
290
+ session = load_session_by_id(session_id)
291
+ if session is not None:
292
+ session_process = runner.get_session(session_id)
293
+ session.is_running = session_process is not None and session_process.is_running
294
+ session.status = session_process.status if session_process else None
295
+ return session
296
+
297
+
298
+ @router.post("/", summary="Create a new session")
299
+ async def create_session(request: CreateSessionRequest | None = None) -> Session:
300
+ """Create a new session."""
301
+ # Use provided work_dir or default to user's home directory
302
+ if request and request.work_dir:
303
+ work_dir_path = Path(request.work_dir).expanduser().resolve()
304
+ # Validate the directory exists
305
+ if not work_dir_path.exists():
306
+ if request.create_dir:
307
+ # Auto-create the directory
308
+ try:
309
+ work_dir_path.mkdir(parents=True, exist_ok=True)
310
+ except PermissionError as e:
311
+ raise HTTPException(
312
+ status_code=status.HTTP_403_FORBIDDEN,
313
+ detail=f"Permission denied: cannot create directory {request.work_dir}",
314
+ ) from e
315
+ except OSError as e:
316
+ raise HTTPException(
317
+ status_code=status.HTTP_400_BAD_REQUEST,
318
+ detail=f"Failed to create directory: {e}",
319
+ ) from e
320
+ else:
321
+ # Return 404 to indicate directory does not exist
322
+ raise HTTPException(
323
+ status_code=status.HTTP_404_NOT_FOUND,
324
+ detail=f"Directory does not exist: {request.work_dir}",
325
+ )
326
+ if not work_dir_path.is_dir():
327
+ raise HTTPException(
328
+ status_code=status.HTTP_400_BAD_REQUEST,
329
+ detail=f"Path is not a directory: {request.work_dir}",
330
+ )
331
+ work_dir = KaosPath.unsafe_from_local_path(work_dir_path)
332
+ else:
333
+ work_dir = KaosPath.unsafe_from_local_path(Path.home())
334
+ kimi_cli_session = await KimiCLISession.create(work_dir=work_dir)
335
+ context_file = kimi_cli_session.dir / "context.jsonl"
336
+ invalidate_sessions_cache()
337
+ invalidate_work_dirs_cache()
338
+ return Session(
339
+ session_id=UUID(kimi_cli_session.id),
340
+ title=kimi_cli_session.title,
341
+ last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),
342
+ is_running=False,
343
+ status=SessionStatus(
344
+ session_id=UUID(kimi_cli_session.id),
345
+ state="stopped",
346
+ seq=0,
347
+ worker_id=None,
348
+ reason=None,
349
+ detail=None,
350
+ updated_at=datetime.now(UTC),
351
+ ),
352
+ work_dir=str(work_dir),
353
+ session_dir=str(kimi_cli_session.dir),
354
+ )
355
+
356
+
357
+ class CreateSessionRequest(BaseModel):
358
+ """Create session request."""
359
+
360
+ work_dir: str | None = None
361
+ create_dir: bool = False # Whether to auto-create directory if it doesn't exist
362
+
363
+
364
+ class ForkSessionRequest(BaseModel):
365
+ """Fork session request."""
366
+
367
+ turn_index: int = Field(..., ge=0) # 0-based, fork includes this turn and all previous turns
368
+
369
+
370
+ class UploadSessionFileResponse(BaseModel):
371
+ """Upload file response."""
372
+
373
+ path: str
374
+ filename: str
375
+ size: int
376
+
377
+
378
+ @router.post("/{session_id}/files", summary="Upload file to session")
379
+ async def upload_session_file(
380
+ session_id: UUID,
381
+ file: UploadFile,
382
+ runner: KimiCLIRunner = Depends(get_runner),
383
+ ) -> UploadSessionFileResponse:
384
+ """Upload a file to a session."""
385
+ session = get_editable_session(session_id, runner)
386
+ session_dir = session.kimi_cli_session.dir
387
+ upload_dir = session_dir / "uploads"
388
+ upload_dir.mkdir(parents=True, exist_ok=True)
389
+
390
+ # Read and validate file size
391
+ content = await file.read()
392
+ if len(content) > MAX_UPLOAD_SIZE:
393
+ raise HTTPException(
394
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
395
+ detail=f"File too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)",
396
+ )
397
+
398
+ # Generate safe filename
399
+ file_name = str(uuid4())
400
+ if file.filename:
401
+ safe_name = sanitize_filename(file.filename)
402
+ name, ext = os.path.splitext(safe_name)
403
+ file_name = f"{name}_{file_name[:6]}{ext}"
404
+
405
+ upload_path = upload_dir / file_name
406
+ upload_path.write_bytes(content)
407
+
408
+ return UploadSessionFileResponse(
409
+ path=str(upload_path),
410
+ filename=file_name,
411
+ size=len(content),
412
+ )
413
+
414
+
415
+ @router.get(
416
+ "/{session_id}/uploads/{path:path}",
417
+ summary="Get uploaded file from session uploads",
418
+ )
419
+ async def get_session_upload_file(
420
+ session_id: UUID,
421
+ path: str,
422
+ ) -> Response:
423
+ """Get a file from a session's uploads directory."""
424
+ session = load_session_by_id(session_id)
425
+ if session is None:
426
+ raise HTTPException(
427
+ status_code=status.HTTP_404_NOT_FOUND,
428
+ detail="Session not found",
429
+ )
430
+
431
+ uploads_dir = (session.kimi_cli_session.dir / "uploads").resolve()
432
+ if not uploads_dir.exists():
433
+ raise HTTPException(
434
+ status_code=status.HTTP_404_NOT_FOUND,
435
+ detail="Uploads directory not found",
436
+ )
437
+
438
+ file_path = (uploads_dir / path).resolve()
439
+ if not file_path.is_relative_to(uploads_dir):
440
+ raise HTTPException(
441
+ status_code=status.HTTP_400_BAD_REQUEST,
442
+ detail="Invalid path: path traversal not allowed",
443
+ )
444
+
445
+ if not file_path.exists() or not file_path.is_file():
446
+ raise HTTPException(
447
+ status_code=status.HTTP_404_NOT_FOUND,
448
+ detail="File not found",
449
+ )
450
+
451
+ media_type, _ = mimetypes.guess_type(file_path.name)
452
+ encoded_filename = quote(file_path.name, safe="")
453
+ return FileResponse(
454
+ file_path,
455
+ media_type=media_type or "application/octet-stream",
456
+ headers={
457
+ "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
458
+ },
459
+ )
460
+
461
+
462
+ @router.get(
463
+ "/{session_id}/files/{path:path}",
464
+ summary="Get file or list directory from session work_dir",
465
+ )
466
+ async def get_session_file(
467
+ session_id: UUID,
468
+ path: str,
469
+ request: Request,
470
+ ) -> Response:
471
+ """Get a file or list directory from session work directory."""
472
+ session = load_session_by_id(session_id)
473
+ if session is None:
474
+ raise HTTPException(
475
+ status_code=status.HTTP_404_NOT_FOUND,
476
+ detail="Session not found",
477
+ )
478
+
479
+ # Security check: prevent path traversal attacks using resolve()
480
+ work_dir = Path(str(session.kimi_cli_session.work_dir)).resolve()
481
+ requested_path = work_dir / path
482
+ file_path = requested_path.resolve()
483
+
484
+ # Check path traversal
485
+ if not file_path.is_relative_to(work_dir):
486
+ raise HTTPException(
487
+ status_code=status.HTTP_400_BAD_REQUEST,
488
+ detail="Invalid path: path traversal not allowed",
489
+ )
490
+
491
+ rel_path = file_path.relative_to(work_dir)
492
+ restrict_sensitive_apis = getattr(request.app.state, "restrict_sensitive_apis", False)
493
+ max_path_depth = (
494
+ getattr(request.app.state, "max_public_path_depth", None) or DEFAULT_MAX_PUBLIC_PATH_DEPTH
495
+ )
496
+
497
+ # Additional security checks when restricting sensitive APIs
498
+ if restrict_sensitive_apis:
499
+ # Check for symlinks in the path
500
+ if _contains_symlink(requested_path, work_dir):
501
+ raise HTTPException(
502
+ status_code=status.HTTP_403_FORBIDDEN,
503
+ detail="Symbolic links are not allowed in public mode.",
504
+ )
505
+
506
+ # Check if resolved path points to sensitive location
507
+ if _is_path_in_sensitive_location(file_path):
508
+ raise HTTPException(
509
+ status_code=status.HTTP_403_FORBIDDEN,
510
+ detail="Access to sensitive system directories is not allowed.",
511
+ )
512
+
513
+ _ensure_public_file_access_allowed(rel_path, restrict_sensitive_apis, max_path_depth)
514
+
515
+ if not file_path.exists():
516
+ raise HTTPException(
517
+ status_code=status.HTTP_404_NOT_FOUND,
518
+ detail="File not found",
519
+ )
520
+
521
+ if file_path.is_dir():
522
+ result: list[dict[str, str | int]] = []
523
+ for subpath in file_path.iterdir():
524
+ if restrict_sensitive_apis:
525
+ rel_subpath = rel_path / subpath.name
526
+ if _is_sensitive_relative_path(rel_subpath):
527
+ continue
528
+ if subpath.is_dir():
529
+ result.append({"name": subpath.name, "type": "directory"})
530
+ else:
531
+ try:
532
+ size = subpath.stat().st_size
533
+ except OSError:
534
+ size = 0
535
+ result.append({"name": subpath.name, "type": "file", "size": size})
536
+ result.sort(key=lambda x: (cast(str, x["type"]), cast(str, x["name"])))
537
+ return Response(content=json.dumps(result), media_type="application/json")
538
+
539
+ content = file_path.read_bytes()
540
+ media_type, _ = mimetypes.guess_type(file_path.name)
541
+ encoded_filename = quote(file_path.name, safe="")
542
+ return Response(
543
+ content=content,
544
+ media_type=media_type or "application/octet-stream",
545
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
546
+ )
547
+
548
+
549
+ def _update_last_session_id(session: JointSession) -> None:
550
+ """Update last_session_id for the session's work directory."""
551
+ kimi_session = session.kimi_cli_session
552
+ work_dir = kimi_session.work_dir
553
+
554
+ metadata = load_metadata()
555
+ work_dir_meta = metadata.get_work_dir_meta(work_dir)
556
+
557
+ if work_dir_meta is None:
558
+ work_dir_meta = metadata.new_work_dir_meta(work_dir)
559
+
560
+ work_dir_meta.last_session_id = kimi_session.id
561
+ save_metadata(metadata)
562
+
563
+
564
+ @router.delete("/{session_id}", summary="Delete a session")
565
+ async def delete_session(session_id: UUID, runner: KimiCLIRunner = Depends(get_runner)) -> None:
566
+ """Delete a session."""
567
+ session = get_editable_session(session_id, runner)
568
+ session_process = runner.get_session(session_id)
569
+ if session_process is not None:
570
+ await session_process.stop()
571
+ wd_meta = session.kimi_cli_session.work_dir_meta
572
+ if wd_meta.last_session_id == str(session_id):
573
+ metadata = load_metadata()
574
+ for wd in metadata.work_dirs:
575
+ if wd.path == wd_meta.path:
576
+ wd.last_session_id = None
577
+ break
578
+ save_metadata(metadata)
579
+ session_dir = session.kimi_cli_session.dir
580
+ if session_dir.exists():
581
+ shutil.rmtree(session_dir)
582
+ invalidate_sessions_cache()
583
+
584
+
585
+ @router.patch("/{session_id}", summary="Update session")
586
+ async def update_session(
587
+ session_id: UUID,
588
+ request: UpdateSessionRequest,
589
+ runner: KimiCLIRunner = Depends(get_runner),
590
+ ) -> Session:
591
+ """Update a session (e.g., rename title or archive/unarchive)."""
592
+ from kimi_cli.session_state import load_session_state, save_session_state
593
+
594
+ session = get_editable_session(session_id, runner)
595
+ session_dir = session.kimi_cli_session.dir
596
+ state = load_session_state(session_dir)
597
+
598
+ # Update title if provided
599
+ if request.title is not None:
600
+ state.custom_title = request.title
601
+ state.title_generated = True
602
+
603
+ # Update archived status if provided
604
+ if request.archived is not None:
605
+ state.archived = request.archived
606
+ if request.archived:
607
+ state.archived_at = time.time()
608
+ state.auto_archive_exempt = False
609
+ else:
610
+ state.archived_at = None
611
+ state.auto_archive_exempt = True
612
+
613
+ save_session_state(state, session_dir)
614
+
615
+ # Invalidate cache to force reload
616
+ invalidate_sessions_cache()
617
+
618
+ # Return updated session
619
+ updated_session = load_session_by_id(session_id)
620
+ if updated_session is None:
621
+ raise HTTPException(
622
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
623
+ detail="Failed to reload session after update",
624
+ )
625
+ return updated_session
626
+
627
+
628
+ def extract_first_turn_from_wire(session_dir: Path) -> tuple[str, str] | None:
629
+ """Extract the first turn's user message and assistant response from wire.jsonl.
630
+
631
+ Returns:
632
+ tuple[str, str] | None: (user_message, assistant_response) or None if not found
633
+ """
634
+ wire_file = session_dir / "wire.jsonl"
635
+ if not wire_file.exists():
636
+ return None
637
+
638
+ user_message: str | None = None
639
+ assistant_response_parts: list[str] = []
640
+ in_first_turn = False
641
+
642
+ try:
643
+ with open(wire_file, encoding="utf-8") as f:
644
+ for line in f:
645
+ line = line.strip()
646
+ if not line:
647
+ continue
648
+ try:
649
+ record = json.loads(line)
650
+ message = record.get("message", {})
651
+ msg_type = message.get("type")
652
+
653
+ if msg_type == "TurnBegin":
654
+ if in_first_turn:
655
+ # Second turn started, stop
656
+ break
657
+ in_first_turn = True
658
+ user_input = message.get("payload", {}).get("user_input")
659
+ if user_input:
660
+ from kosong.message import Message
661
+
662
+ msg = Message(role="user", content=user_input)
663
+ user_message = msg.extract_text(" ")
664
+
665
+ elif msg_type == "ContentPart" and in_first_turn:
666
+ payload = message.get("payload", {})
667
+ if payload.get("type") == "text" and payload.get("text"):
668
+ assistant_response_parts.append(payload["text"])
669
+
670
+ elif msg_type == "TurnEnd" and in_first_turn:
671
+ break
672
+
673
+ except json.JSONDecodeError:
674
+ continue
675
+ except OSError:
676
+ return None
677
+
678
+ if user_message and assistant_response_parts:
679
+ return (user_message, "".join(assistant_response_parts))
680
+ return None
681
+
682
+
683
+ @router.post("/{session_id}/fork", summary="Fork a session at a specific turn")
684
+ async def fork_session_endpoint(
685
+ session_id: UUID,
686
+ request: ForkSessionRequest,
687
+ runner: KimiCLIRunner = Depends(get_runner),
688
+ ) -> Session:
689
+ """Fork a session, creating a new session with history up to the specified turn.
690
+
691
+ The new session shares the same work_dir as the original session.
692
+ """
693
+ from kimi_cli.session_fork import fork_session as do_fork
694
+
695
+ source_session = get_editable_session(session_id, runner)
696
+ source_dir = source_session.kimi_cli_session.dir
697
+ work_dir = source_session.kimi_cli_session.work_dir
698
+
699
+ source_title = source_session.title
700
+
701
+ try:
702
+ new_session_id = await do_fork(
703
+ source_session_dir=source_dir,
704
+ work_dir=work_dir,
705
+ turn_index=request.turn_index,
706
+ title_prefix="Fork",
707
+ source_title=source_title,
708
+ )
709
+ except ValueError as e:
710
+ raise HTTPException(
711
+ status_code=status.HTTP_400_BAD_REQUEST,
712
+ detail=str(e),
713
+ ) from e
714
+
715
+ invalidate_sessions_cache()
716
+ invalidate_work_dirs_cache()
717
+
718
+ from kimi_cli.metadata import load_metadata
719
+ from kimi_cli.session_state import load_session_state
720
+
721
+ metadata = load_metadata()
722
+ work_dir_meta = metadata.get_work_dir_meta(work_dir)
723
+ assert work_dir_meta is not None
724
+ new_session_dir = work_dir_meta.sessions_dir / new_session_id
725
+ new_state = load_session_state(new_session_dir)
726
+ fork_title = new_state.custom_title or f"Fork: {source_title}"
727
+
728
+ context_file = new_session_dir / "context.jsonl"
729
+ return Session(
730
+ session_id=UUID(new_session_id),
731
+ title=fork_title,
732
+ last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),
733
+ is_running=False,
734
+ status=SessionStatus(
735
+ session_id=UUID(new_session_id),
736
+ state="stopped",
737
+ seq=0,
738
+ worker_id=None,
739
+ reason=None,
740
+ detail=None,
741
+ updated_at=datetime.now(UTC),
742
+ ),
743
+ work_dir=str(work_dir),
744
+ session_dir=str(new_session_dir),
745
+ )
746
+
747
+
748
+ @router.post("/{session_id}/generate-title", summary="Generate session title using AI")
749
+ async def generate_session_title(
750
+ session_id: UUID,
751
+ request: GenerateTitleRequest | None = None,
752
+ runner: KimiCLIRunner = Depends(get_runner),
753
+ ) -> GenerateTitleResponse:
754
+ """Generate a concise session title using AI based on the first conversation turn.
755
+
756
+ If request body is empty or parameters are missing, the backend will
757
+ automatically read the first turn from wire.jsonl.
758
+ """
759
+ session = get_editable_session(session_id, runner)
760
+ session_dir = session.kimi_cli_session.dir
761
+
762
+ from kimi_cli.session_state import load_session_state, save_session_state
763
+
764
+ state = load_session_state(session_dir)
765
+
766
+ # Check if title was already generated (avoid duplicate calls)
767
+ if state.title_generated:
768
+ return GenerateTitleResponse(title=state.custom_title or "Untitled")
769
+
770
+ # Get message content: prefer request parameters, otherwise read from wire.jsonl
771
+ user_message = request.user_message if request else None
772
+ assistant_response = request.assistant_response if request else None
773
+
774
+ if not user_message or not assistant_response:
775
+ first_turn = extract_first_turn_from_wire(session_dir)
776
+ if first_turn:
777
+ user_message, assistant_response = first_turn
778
+
779
+ # If still no user message, return default title
780
+ if not user_message:
781
+ return GenerateTitleResponse(title="Untitled")
782
+
783
+ from kimi_cli.utils.string import shorten
784
+
785
+ user_text = user_message.strip()
786
+ user_text = " ".join(user_text.split())
787
+ fallback_title = shorten(user_text, width=50) or "Untitled"
788
+
789
+ # If AI generation failed too many times, use fallback and mark as generated
790
+ if state.title_generate_attempts >= 3:
791
+ fresh = load_session_state(session_dir)
792
+ # Respect a title finalized by another request/user action while we
793
+ # were preparing a fallback.
794
+ if fresh.title_generated:
795
+ invalidate_sessions_cache()
796
+ return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
797
+ fresh.custom_title = fallback_title
798
+ fresh.title_generated = True
799
+ save_session_state(fresh, session_dir)
800
+ invalidate_sessions_cache()
801
+ return GenerateTitleResponse(title=fallback_title)
802
+
803
+ # Try to generate title using AI
804
+ title = fallback_title
805
+ ai_generated = False
806
+ try:
807
+ from kosong import generate
808
+ from kosong.message import Message
809
+
810
+ from kimi_cli.auth.oauth import OAuthManager
811
+ from kimi_cli.config import load_config
812
+ from kimi_cli.llm import create_llm
813
+
814
+ config = load_config()
815
+ model_name = config.default_model
816
+
817
+ if model_name and model_name in config.models:
818
+ model_config = config.models[model_name]
819
+ provider_config = config.providers.get(model_config.provider)
820
+
821
+ if provider_config:
822
+ oauth = OAuthManager(config)
823
+ await oauth.ensure_fresh()
824
+ llm = create_llm(provider_config, model_config, oauth=oauth)
825
+
826
+ if llm:
827
+ system_prompt = (
828
+ "Generate a concise session title (max 50 characters) "
829
+ "based on the conversation. "
830
+ "Only respond with the title text, nothing else. "
831
+ "No quotes, no explanation."
832
+ )
833
+
834
+ prompt = f"""User: {user_message[:300]}
835
+ Assistant: {(assistant_response or "")[:300]}
836
+
837
+ Title:"""
838
+
839
+ result = await generate(
840
+ chat_provider=llm.chat_provider,
841
+ system_prompt=system_prompt,
842
+ tools=[],
843
+ history=[Message(role="user", content=prompt)],
844
+ )
845
+
846
+ generated_title = result.message.extract_text().strip()
847
+ # Remove quotes if present
848
+ generated_title = generated_title.strip("\"'")
849
+
850
+ if generated_title and len(generated_title) <= 50:
851
+ title = generated_title
852
+ ai_generated = True
853
+ elif generated_title:
854
+ title = shorten(generated_title, width=50)
855
+ ai_generated = True
856
+
857
+ except Exception as e:
858
+ logger.warning(f"Failed to generate title using AI: {e}")
859
+ # Keep fallback_title, ai_generated stays False
860
+
861
+ # Read-modify-write: reload fresh state to avoid overwriting
862
+ # worker changes made during the LLM call
863
+ fresh = load_session_state(session_dir)
864
+ # Another request or manual rename may have finalized the title while the
865
+ # LLM call was in flight. Preserve that newer title instead of clobbering it.
866
+ if fresh.title_generated:
867
+ invalidate_sessions_cache()
868
+ return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
869
+ fresh.custom_title = title
870
+ if ai_generated:
871
+ fresh.title_generated = True
872
+ else:
873
+ fresh.title_generate_attempts = fresh.title_generate_attempts + 1
874
+ save_session_state(fresh, session_dir)
875
+
876
+ # Invalidate cache
877
+ invalidate_sessions_cache()
878
+
879
+ return GenerateTitleResponse(title=title)
880
+
881
+
882
+ @router.websocket("/{session_id}/stream")
883
+ async def session_stream(
884
+ session_id: UUID,
885
+ websocket: WebSocket,
886
+ runner: KimiCLIRunner = Depends(get_runner_ws),
887
+ ) -> None:
888
+ """WebSocket stream for a session.
889
+
890
+ Flow:
891
+ 1. Accept the WebSocket connection
892
+ 2. If history exists, attach WebSocket in replay mode
893
+ 3. Replay history messages from wire.jsonl
894
+ 4. Start worker if needed
895
+ 5. Flush buffered live messages and send status snapshot
896
+ 6. Forward incoming messages to the subprocess
897
+ 7. Clean up on disconnect
898
+ """
899
+ expected_token = getattr(websocket.app.state, "session_token", None)
900
+ enforce_origin = getattr(websocket.app.state, "enforce_origin", False)
901
+ allowed_origins = getattr(websocket.app.state, "allowed_origins", [])
902
+ lan_only = getattr(websocket.app.state, "lan_only", False)
903
+
904
+ # LAN-only check
905
+ if lan_only:
906
+ client_ip = websocket.client.host if websocket.client else None
907
+ if client_ip and not is_private_ip(client_ip):
908
+ await websocket.close(code=4403, reason="Access denied: LAN only")
909
+ return
910
+
911
+ if enforce_origin:
912
+ origin = websocket.headers.get("origin")
913
+ if origin and not is_origin_allowed(origin, allowed_origins):
914
+ await websocket.close(code=4403, reason="Origin not allowed")
915
+ return
916
+
917
+ if expected_token:
918
+ token = websocket.query_params.get("token")
919
+ if not verify_token(token, expected_token):
920
+ await websocket.close(code=4401, reason="Auth required")
921
+ return
922
+
923
+ await websocket.accept()
924
+
925
+ # Check if session exists
926
+ session = await asyncio.to_thread(load_session_by_id, session_id)
927
+ if session is None:
928
+ await websocket.close(code=4004, reason="Session not found")
929
+ return
930
+
931
+ # Check if session has history
932
+ session_dir = session.kimi_cli_session.dir
933
+ wire_file = session_dir / "wire.jsonl"
934
+ has_history = await asyncio.to_thread(wire_file.exists)
935
+
936
+ session_process = await runner.get_or_create_session(session_id)
937
+ attached = False
938
+ try:
939
+ if has_history:
940
+ # Attach WebSocket in replay mode before history replay
941
+ await session_process.add_websocket_and_begin_replay(websocket)
942
+ attached = True
943
+
944
+ # Replay history
945
+ try:
946
+ await replay_history(websocket, session_dir)
947
+ except Exception as e:
948
+ logger.warning(f"Failed to replay history: {e}")
949
+
950
+ # Check if WebSocket is still connected before continuing
951
+ if not await send_history_complete(websocket):
952
+ logger.debug("WebSocket disconnected during history replay")
953
+ return
954
+
955
+ # Start session environment – if anything fails here, send an error
956
+ # status so the client doesn't hang on "Connecting to environment...".
957
+ try:
958
+ # Ensure work_dir exists
959
+ work_dir = Path(str(session.kimi_cli_session.work_dir))
960
+ await asyncio.to_thread(lambda: work_dir.mkdir(parents=True, exist_ok=True))
961
+
962
+ if not attached:
963
+ # No history: attach and start worker
964
+ session_process = await runner.get_or_create_session(session_id)
965
+ await session_process.add_websocket_and_begin_replay(websocket)
966
+ attached = True
967
+
968
+ assert session_process is not None
969
+ # End replay and start worker
970
+ await session_process.end_replay(websocket)
971
+ await session_process.start()
972
+ await session_process.send_status_snapshot(websocket)
973
+ except Exception as e:
974
+ logger.warning(f"Failed to start session environment: {e}")
975
+ try:
976
+ error_status = SessionStatus(
977
+ session_id=session_id,
978
+ state="error",
979
+ seq=0,
980
+ worker_id=None,
981
+ reason="initialization_failed",
982
+ detail=str(e),
983
+ updated_at=datetime.now(UTC),
984
+ )
985
+ await websocket.send_text(
986
+ new_session_status_message(error_status).model_dump_json()
987
+ )
988
+ except Exception:
989
+ pass
990
+ return
991
+
992
+ # Track whether we've updated last_session_id for this connection.
993
+ # We defer the update until the first prompt message is actually forwarded,
994
+ # so that merely opening/viewing a session does not change last_session_id.
995
+ last_session_id_updated = False
996
+
997
+ # Forward incoming messages to the subprocess
998
+ while True:
999
+ try:
1000
+ message = await websocket.receive_text()
1001
+ # Reject new prompts when session is busy
1002
+ if session_process.is_busy:
1003
+ try:
1004
+ in_message = JSONRPCInMessageAdapter.validate_json(message)
1005
+ except ValueError:
1006
+ in_message = None
1007
+ if isinstance(in_message, JSONRPCPromptMessage):
1008
+ # If the session is in error state, the in-flight IDs
1009
+ # are stale from a failed prompt. Clear them so the
1010
+ # user can recover by sending a new message.
1011
+ if session_process.status.state == "error":
1012
+ logger.info(
1013
+ "Clearing stale in-flight prompts for "
1014
+ f"session {session_id} (was in error state)"
1015
+ )
1016
+ session_process.clear_in_flight()
1017
+ else:
1018
+ await websocket.send_text(
1019
+ JSONRPCErrorResponse(
1020
+ id=in_message.id,
1021
+ error=JSONRPCErrorObject(
1022
+ code=ErrorCodes.INVALID_STATE,
1023
+ message=(
1024
+ "Session is busy; wait for completion before sending "
1025
+ "a new prompt."
1026
+ ),
1027
+ ),
1028
+ ).model_dump_json()
1029
+ )
1030
+ continue
1031
+
1032
+ # Update last_session_id on first successful prompt
1033
+ if not last_session_id_updated:
1034
+ try:
1035
+ in_message = JSONRPCInMessageAdapter.validate_json(message)
1036
+ except ValueError:
1037
+ in_message = None
1038
+ if isinstance(in_message, JSONRPCPromptMessage):
1039
+ await asyncio.to_thread(_update_last_session_id, session)
1040
+ last_session_id_updated = True
1041
+
1042
+ logger.debug(f"sending message to session {session_id}")
1043
+ await session_process.send_message(message)
1044
+ except WebSocketDisconnect:
1045
+ logger.debug("WebSocket disconnected")
1046
+ break
1047
+ except Exception as e:
1048
+ logger.warning(f"WebSocket error: {e.__class__.__name__} {e}")
1049
+ break
1050
+ finally:
1051
+ if attached and session_process:
1052
+ await session_process.remove_websocket(websocket)
1053
+
1054
+
1055
+ # Work dirs cache
1056
+ _work_dirs_cache: list[str] | None = None
1057
+ _work_dirs_cache_time: float = 0.0
1058
+ _WORK_DIRS_CACHE_TTL = 30.0 # seconds
1059
+
1060
+
1061
+ def invalidate_work_dirs_cache() -> None:
1062
+ """Clear the work dirs cache."""
1063
+ global _work_dirs_cache, _work_dirs_cache_time
1064
+ _work_dirs_cache = None
1065
+ _work_dirs_cache_time = 0.0
1066
+
1067
+
1068
+ def _get_work_dirs_sync() -> list[str]:
1069
+ """Synchronous helper for get_work_dirs (runs in thread pool)."""
1070
+ import time
1071
+
1072
+ global _work_dirs_cache, _work_dirs_cache_time
1073
+
1074
+ # Check cache
1075
+ now = time.time()
1076
+ if _work_dirs_cache is not None and (now - _work_dirs_cache_time) < _WORK_DIRS_CACHE_TTL:
1077
+ return _work_dirs_cache
1078
+
1079
+ # Build fresh list
1080
+ metadata = load_metadata()
1081
+ work_dirs: list[str] = []
1082
+ for wd in metadata.work_dirs:
1083
+ # Filter out temporary directories
1084
+ if "/tmp" in wd.path or "/var/folders" in wd.path or "/.cache/" in wd.path:
1085
+ continue
1086
+ # Verify directory exists
1087
+ if Path(wd.path).exists():
1088
+ work_dirs.append(wd.path)
1089
+
1090
+ # Update cache
1091
+ result = work_dirs[:20]
1092
+ _work_dirs_cache = result
1093
+ _work_dirs_cache_time = now
1094
+ return result
1095
+
1096
+
1097
+ @work_dirs_router.get("/", summary="List available work directories")
1098
+ async def get_work_dirs() -> list[str]:
1099
+ """Get a list of available work directories from metadata."""
1100
+ return await asyncio.to_thread(_get_work_dirs_sync)
1101
+
1102
+
1103
+ @work_dirs_router.get("/startup", summary="Get the startup directory")
1104
+ async def get_startup_dir(request: Request) -> str:
1105
+ """Get the directory where kimi web was started."""
1106
+ return request.app.state.startup_dir
1107
+
1108
+
1109
+ @router.get("/{session_id}/git-diff", summary="Get git diff stats")
1110
+ async def get_session_git_diff(session_id: UUID) -> GitDiffStats:
1111
+ """get git diff stats for the session's work directory"""
1112
+ session = load_session_by_id(session_id)
1113
+ if session is None:
1114
+ raise HTTPException(status_code=404, detail="Session not found")
1115
+
1116
+ work_dir = Path(str(session.kimi_cli_session.work_dir))
1117
+
1118
+ # Check if it is a git repository
1119
+ if not (work_dir / ".git").exists():
1120
+ return GitDiffStats(is_git_repo=False)
1121
+
1122
+ try:
1123
+ files: list[GitFileDiff] = []
1124
+ total_add, total_del = 0, 0
1125
+
1126
+ # Check if HEAD exists (repo has at least one commit)
1127
+ check_proc = await asyncio.create_subprocess_exec(
1128
+ "git",
1129
+ "rev-parse",
1130
+ "--verify",
1131
+ "HEAD",
1132
+ cwd=str(work_dir),
1133
+ stdout=asyncio.subprocess.DEVNULL,
1134
+ stderr=asyncio.subprocess.DEVNULL,
1135
+ env=get_clean_env(),
1136
+ )
1137
+ await check_proc.wait()
1138
+ has_head = check_proc.returncode == 0
1139
+
1140
+ if has_head:
1141
+ # Execute git diff --numstat HEAD (including staged and unstaged)
1142
+ proc = await asyncio.create_subprocess_exec(
1143
+ "git",
1144
+ "diff",
1145
+ "--numstat",
1146
+ "HEAD",
1147
+ cwd=str(work_dir),
1148
+ stdout=asyncio.subprocess.PIPE,
1149
+ stderr=asyncio.subprocess.PIPE,
1150
+ env=get_clean_env(),
1151
+ )
1152
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
1153
+
1154
+ # Parse output
1155
+ for line in stdout.decode().strip().split("\n"):
1156
+ if not line:
1157
+ continue
1158
+ parts = line.split("\t")
1159
+ if len(parts) >= 3:
1160
+ add = int(parts[0]) if parts[0] != "-" else 0
1161
+ dele = int(parts[1]) if parts[1] != "-" else 0
1162
+ total_add += add
1163
+ total_del += dele
1164
+ # Determine file status
1165
+ file_status: str = "modified"
1166
+ if dele == 0 and add > 0:
1167
+ file_status = "added"
1168
+ elif add == 0 and dele > 0:
1169
+ file_status = "deleted"
1170
+ files.append(
1171
+ GitFileDiff(
1172
+ path=parts[2],
1173
+ additions=add,
1174
+ deletions=dele,
1175
+ status=file_status, # type: ignore[arg-type]
1176
+ )
1177
+ )
1178
+
1179
+ # Also get untracked files (new files not yet added to git)
1180
+ untracked_proc = await asyncio.create_subprocess_exec(
1181
+ "git",
1182
+ "ls-files",
1183
+ "--others",
1184
+ "--exclude-standard",
1185
+ cwd=str(work_dir),
1186
+ stdout=asyncio.subprocess.PIPE,
1187
+ stderr=asyncio.subprocess.DEVNULL,
1188
+ env=get_clean_env(),
1189
+ )
1190
+ untracked_stdout, _ = await asyncio.wait_for(untracked_proc.communicate(), timeout=5.0)
1191
+
1192
+ # Add untracked files to the result
1193
+ for line in untracked_stdout.decode().strip().split("\n"):
1194
+ if line:
1195
+ files.append(
1196
+ GitFileDiff(
1197
+ path=line,
1198
+ additions=0, # Cannot count lines for untracked files
1199
+ deletions=0,
1200
+ status="added",
1201
+ )
1202
+ )
1203
+
1204
+ if not has_head:
1205
+ return GitDiffStats(
1206
+ is_git_repo=True,
1207
+ has_changes=len(files) > 0,
1208
+ total_additions=0,
1209
+ total_deletions=0,
1210
+ files=files,
1211
+ )
1212
+
1213
+ return GitDiffStats(
1214
+ is_git_repo=True,
1215
+ has_changes=len(files) > 0,
1216
+ total_additions=total_add,
1217
+ total_deletions=total_del,
1218
+ files=files,
1219
+ )
1220
+ except TimeoutError:
1221
+ return GitDiffStats(is_git_repo=True, error="Git command timed out")
1222
+ except Exception as e:
1223
+ return GitDiffStats(is_git_repo=True, error=str(e))