Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/kimi_cli/acp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ async def new_session(
cli_instance = await KimiCLI.create(
session,
mcp_configs=[mcp_config],
ui_mode="acp",
)
config = cli_instance.soul.runtime.config
acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities)
Expand Down Expand Up @@ -225,6 +226,7 @@ async def _setup_session(
session,
mcp_configs=[mcp_config],
resumed=True, # _setup_session loads existing sessions
ui_mode="acp",
)
config = cli_instance.soul.runtime.config
acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities)
Expand Down
37 changes: 36 additions & 1 deletion src/kimi_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import contextlib
import dataclasses
import time
import warnings
from collections.abc import AsyncGenerator, Callable
from pathlib import Path
Expand All @@ -13,9 +14,10 @@
from pydantic import SecretStr

from kimi_cli.agentspec import DEFAULT_AGENT_FILE
from kimi_cli.auth.oauth import OAuthManager
from kimi_cli.auth.oauth import KIMI_CODE_OAUTH_KEY, OAuthManager, get_device_id
from kimi_cli.cli import InputFormat, OutputFormat
from kimi_cli.config import Config, LLMModel, LLMProvider, load_config
from kimi_cli.constant import VERSION
from kimi_cli.llm import augment_provider_with_env_vars, create_llm, model_display_name
from kimi_cli.session import Session
from kimi_cli.share import get_share_dir
Expand All @@ -24,6 +26,7 @@
from kimi_cli.soul.context import Context
from kimi_cli.soul.kimisoul import KimiSoul
from kimi_cli.utils.aioqueue import QueueShutDown
from kimi_cli.utils.envvar import get_env_bool
from kimi_cli.utils.logging import logger, redirect_stderr_to_logger
from kimi_cli.utils.path import shorten_home
from kimi_cli.wire import Wire, WireUISide
Expand Down Expand Up @@ -99,6 +102,7 @@ async def create(
yolo: bool = False,
plan_mode: bool = False,
resumed: bool = False,
ui_mode: str = "shell",
# Extensions
agent_file: Path | None = None,
mcp_configs: list[MCPConfig] | list[dict[str, Any]] | None = None,
Expand Down Expand Up @@ -147,6 +151,8 @@ async def create(
MCPRuntimeError(KimiCLIException, RuntimeError): When any MCP server cannot be
connected.
"""
_create_t0 = time.monotonic()

if startup_progress is not None:
startup_progress("Loading configuration...")

Expand Down Expand Up @@ -272,6 +278,35 @@ async def create(
soul.set_hook_engine(hook_engine)
runtime.hook_engine = hook_engine

# --- Initialize telemetry ---
from kimi_cli.telemetry import attach_sink, set_context
from kimi_cli.telemetry import disable as disable_telemetry

telemetry_disabled = not config.telemetry or get_env_bool("KIMI_DISABLE_TELEMETRY")
if telemetry_disabled:
disable_telemetry()
else:
set_context(device_id=get_device_id(), session_id=session.id)
from kimi_cli.telemetry.sink import EventSink
from kimi_cli.telemetry.transport import AsyncTransport

def _get_token() -> str | None:
return oauth.get_cached_access_token(KIMI_CODE_OAUTH_KEY)

transport = AsyncTransport(get_access_token=_get_token)
sink = EventSink(
transport,
version=VERSION,
model=model.model if model else "",
ui_mode=ui_mode,
)
attach_sink(sink)

from kimi_cli.telemetry import track

track("kimi_started", resumed=resumed, yolo=yolo)
track("kimi_startup_perf", duration_ms=int((time.monotonic() - _create_t0) * 1000))

return KimiCLI(soul, runtime, env_overrides)

def __init__(
Expand Down
13 changes: 13 additions & 0 deletions src/kimi_cli/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ def get_device_id() -> str:
device_id = uuid.uuid4().hex
path.write_text(device_id, encoding="utf-8")
_ensure_private_file(path)
from kimi_cli.telemetry import track

track("kimi_first_launch")
return device_id


Expand Down Expand Up @@ -643,6 +646,10 @@ def _cache_access_token(self, ref: OAuthRef, token: OAuthToken) -> None:
return
self._access_tokens[ref.key] = token.access_token

def get_cached_access_token(self, key: str) -> str | None:
"""Get a cached access token by key, or None if not available."""
return self._access_tokens.get(key)

def common_headers(self) -> dict[str, str]:
return _common_headers()

Expand Down Expand Up @@ -779,10 +786,16 @@ async def _refresh_tokens(
return
except Exception as exc:
logger.warning("Failed to refresh OAuth token: {error}", error=exc)
from kimi_cli.telemetry import track

track("kimi_oauth_refresh", success=False)
return
save_tokens(ref, refreshed)
self._cache_access_token(ref, refreshed)
self._apply_access_token(runtime, refreshed.access_token)
from kimi_cli.telemetry import track

track("kimi_oauth_refresh", success=True)

def _apply_access_token(self, runtime: Runtime | None, access_token: str) -> None:
if runtime is None:
Expand Down
23 changes: 23 additions & 0 deletions src/kimi_cli/background/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ def create_bash_task(
timeout_s=timeout_s,
)
self._store.create_task(spec)
from kimi_cli.telemetry import track

track("kimi_background_task_created")

runtime = self._store.read_runtime(task_id)
task_dir = self._store.task_dir(task_id)
Expand Down Expand Up @@ -545,6 +548,11 @@ def _mark_task_completed(self, task_id: str) -> None:
runtime.finished_at = runtime.updated_at
runtime.failure_reason = None
self._store.write_runtime(task_id, runtime)
from kimi_cli.telemetry import track

if runtime.started_at and runtime.finished_at:
duration = runtime.finished_at - runtime.started_at
track("kimi_background_task_completed", success=True, duration_s=duration)

def _mark_task_failed(self, task_id: str, reason: str) -> None:
runtime = self._store.read_runtime(task_id)
Expand All @@ -555,6 +563,11 @@ def _mark_task_failed(self, task_id: str, reason: str) -> None:
runtime.finished_at = runtime.updated_at
runtime.failure_reason = reason
self._store.write_runtime(task_id, runtime)
from kimi_cli.telemetry import track

if runtime.started_at and runtime.finished_at:
duration = runtime.finished_at - runtime.started_at
track("kimi_background_task_completed", success=False, duration_s=duration)

def _mark_task_timed_out(self, task_id: str, reason: str) -> None:
runtime = self._store.read_runtime(task_id)
Expand All @@ -567,6 +580,11 @@ def _mark_task_timed_out(self, task_id: str, reason: str) -> None:
runtime.timed_out = True
runtime.failure_reason = reason
self._store.write_runtime(task_id, runtime)
from kimi_cli.telemetry import track

if runtime.started_at and runtime.finished_at:
duration = runtime.finished_at - runtime.started_at
track("kimi_background_task_completed", success=False, duration_s=duration)

def _mark_task_killed(self, task_id: str, reason: str) -> None:
runtime = self._store.read_runtime(task_id)
Expand All @@ -578,3 +596,8 @@ def _mark_task_killed(self, task_id: str, reason: str) -> None:
runtime.interrupted = True
runtime.failure_reason = reason
self._store.write_runtime(task_id, runtime)
from kimi_cli.telemetry import track

if runtime.started_at and runtime.finished_at:
duration = runtime.finished_at - runtime.started_at
track("kimi_background_task_completed", success=False, duration_s=duration)
1 change: 1 addition & 0 deletions src/kimi_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,7 @@ async def _run(session_id: str | None, prefill_text: str | None = None) -> tuple
max_ralph_iterations=max_ralph_iterations,
startup_progress=startup_progress.update if ui == "shell" else None,
defer_mcp_loading=ui == "shell" and prompt is None,
ui_mode=ui,
)
startup_progress.stop()

Expand Down
4 changes: 4 additions & 0 deletions src/kimi_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ class Config(BaseModel):
"instead of using only the first one found"
),
)
telemetry: bool = Field(
default=True,
description="Enable anonymous telemetry to help improve kimi-cli. Set to false to disable.",
)

@model_validator(mode="after")
def validate_model(self) -> Self:
Expand Down
7 changes: 6 additions & 1 deletion src/kimi_cli/hooks/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,14 @@ async def trigger(
return []

try:
return await self._execute_hooks(
results = await self._execute_hooks(
event, matcher_value, server_matched, wire_matched, input_data
)
from kimi_cli.telemetry import track

has_block = any(r.action == "block" for r in results)
track("kimi_hook_triggered", event_type=event, action="block" if has_block else "allow")
return results
Comment on lines +207 to +211
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Telemetry code inside fail-open try/except can silently discard hook results

The telemetry tracking code (import, any() call, track()) was inserted inside the try block that was originally designed to catch only _execute_hooks() errors and fail open. If any of these new statements raise (e.g., _sink.accept() encounters an unexpected error), the already-computed results are silently discarded and replaced with []. This changes the error-handling semantics: previously only hook-execution failures caused fail-open, now telemetry failures also cause it. For security-critical hooks like PreToolUse that block dangerous operations, a telemetry failure would silently bypass the block.

Original vs new code structure

Original:

try:
    return await self._execute_hooks(...)
except Exception:
    logger.warning("Hook engine error for {}, failing open", event)
    return []

New code places track() between result computation and return, still inside the same try/except:

try:
    results = await self._execute_hooks(...)
    from kimi_cli.telemetry import track
    has_block = any(r.action == "block" for r in results)
    track("kimi_hook_triggered", ...)
    return results
except Exception:
    logger.warning("Hook engine error for {}, failing open", event)
    return []
Suggested change
from kimi_cli.telemetry import track
has_block = any(r.action == "block" for r in results)
track("kimi_hook_triggered", event_type=event, action="block" if has_block else "allow")
return results
from kimi_cli.telemetry import track
has_block = any(r.action == "block" for r in results)
track("kimi_hook_triggered", event_type=event, action="block" if has_block else "allow")
return results
except Exception:
logger.warning("Hook engine error for {}, failing open", event)
return []
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

except Exception:
logger.warning("Hook engine error for {}, failing open", event)
return []
Expand Down
9 changes: 9 additions & 0 deletions src/kimi_cli/soul/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,27 @@ async def request(
try:
response, feedback = await self._runtime.wait_for_response(request_id)
except ApprovalCancelledError:
from kimi_cli.telemetry import track

track("kimi_tool_rejected", tool_name=tool_call.function.name)
return ApprovalResult(approved=False)
from kimi_cli.telemetry import track

match response:
case "approve":
track("kimi_tool_approved", tool_name=tool_call.function.name)
return ApprovalResult(approved=True)
case "approve_for_session":
track("kimi_tool_approved", tool_name=tool_call.function.name)
self._state.auto_approve_actions.add(action)
self._state.notify_change()
for pending in self._runtime.list_pending():
if pending.action == action:
self._runtime.resolve(pending.id, "approve")
return ApprovalResult(approved=True)
case "reject":
track("kimi_tool_rejected", tool_name=tool_call.function.name)
return ApprovalResult(approved=False, feedback=feedback)
case _:
track("kimi_tool_rejected", tool_name=tool_call.function.name)
return ApprovalResult(approved=False)
37 changes: 37 additions & 0 deletions src/kimi_cli/soul/kimisoul.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,9 @@ def _find_slash_command(self, name: str) -> SlashCommand[Any] | None:

def _make_skill_runner(self, skill: Skill) -> Callable[[KimiSoul, str], None | Awaitable[None]]:
async def _run_skill(soul: KimiSoul, args: str, *, _skill: Skill = skill) -> None:
from kimi_cli.telemetry import track

track("kimi_skill_invoked", skill_name=_skill.name)
skill_text = await read_skill_text(_skill)
if skill_text is None:
wire_send(
Expand Down Expand Up @@ -683,17 +686,30 @@ async def _agent_loop(self) -> TurnOutcome:
wire_send(MCPLoadingBegin())
try:
await self.wait_for_background_mcp_loading()
# Track MCP connection result
if loading:
from kimi_cli.telemetry import track as _track_mcp

mcp_snap = self._mcp_status_snapshot()
if mcp_snap:
if mcp_snap.connected > 0:
_track_mcp("kimi_mcp_connected", server_count=mcp_snap.connected)
_failed = mcp_snap.total - mcp_snap.connected
if _failed > 0:
_track_mcp("kimi_mcp_failed")
finally:
if loading:
wire_send(StatusUpdate(mcp_status=self._mcp_status_snapshot()))
wire_send(MCPLoadingEnd())

step_no = 0
self._current_step_no = 0
while True:
step_no += 1
if step_no > self._loop_control.max_steps_per_turn:
raise MaxStepsReached(self._loop_control.max_steps_per_turn)

self._current_step_no = step_no
wire_send(StepBegin(n=step_no))
back_to_the_future: BackToTheFuture | None = None
step_outcome: StepOutcome | None = None
Expand Down Expand Up @@ -735,6 +751,23 @@ async def _agent_loop(self) -> TurnOutcome:
request_id=req_id,
)
wire_send(StepInterrupted())
# Track API/step errors
from kimi_cli.telemetry import track

error_type = "other"
if isinstance(e, APIStatusError):
status = getattr(e, "status_code", getattr(e, "status", 0))
if status == 429:
error_type = "rate_limit"
elif status in (401, 403):
error_type = "auth"
else:
error_type = "api"
elif isinstance(e, APIConnectionError):
error_type = "network"
elif isinstance(e, (APITimeoutError, TimeoutError)):
error_type = "timeout"
track("kimi_api_error", error_type=error_type)
# --- StopFailure hook ---
from kimi_cli.hooks import events as _hook_events

Expand Down Expand Up @@ -1185,6 +1218,10 @@ def ralph_loop(
return FlowRunner(flow, max_moves=max_moves)

async def run(self, soul: KimiSoul, args: str) -> None:
if self._name:
from kimi_cli.telemetry import track

track("kimi_flow_invoked", flow_name=self._name)
if args.strip():
command = f"/{FLOW_COMMAND_PREFIX}{self._name}" if self._name else "/flow"
logger.warning("Agent flow {command} ignores args: {args}", command=command, args=args)
Comment on lines +1221 to 1227
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 kimi_flow_invoked tracked even when flow does not execute due to args validation failure

In FlowRunner.run() at src/kimi_cli/soul/kimisoul.py:1221-1224, the track("kimi_flow_invoked") call is placed before the args validation check at line 1225. When a user passes arguments to a flow command (e.g., /flow:myflow some args), the flow logs a warning and returns immediately at line 1228 without executing. But the telemetry event has already been emitted, inflating the flow invocation count with failed attempts.

(Refers to lines 1221-1228)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand Down
7 changes: 7 additions & 0 deletions src/kimi_cli/soul/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ async def init(soul: KimiSoul, args: str):
f"Latest AGENTS.md file content:\n{agents_md}"
)
await soul.context.append_message(Message(role="user", content=[system_message]))
from kimi_cli.telemetry import track

track("kimi_init_complete")


@registry.command
Expand Down Expand Up @@ -92,11 +95,15 @@ async def clear(soul: KimiSoul, args: str):
@registry.command
async def yolo(soul: KimiSoul, args: str):
"""Toggle YOLO mode (auto-approve all actions)"""
from kimi_cli.telemetry import track

if soul.runtime.approval.is_yolo():
soul.runtime.approval.set_yolo(False)
track("kimi_yolo_toggle", enabled=False)
wire_send(TextPart(text="You only die once! Actions will require approval."))
else:
soul.runtime.approval.set_yolo(True)
track("kimi_yolo_toggle", enabled=True)
wire_send(TextPart(text="You only live once! All actions will be auto-approved."))


Expand Down
3 changes: 3 additions & 0 deletions src/kimi_cli/soul/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ async def _call():
_hook_task.add_done_callback(
lambda t: t.exception() if not t.cancelled() else None
)
from kimi_cli.telemetry import track

track("kimi_tool_error", tool_name=tool_call.function.name)
return ToolResult(
tool_call_id=tool_call.id,
return_value=ToolRuntimeError(str(e)),
Expand Down
3 changes: 3 additions & 0 deletions src/kimi_cli/subagents/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ async def _prepare_instance(self, req: ForegroundRunRequest) -> PreparedInstance
effective_model=req.model or type_def.default_model,
),
)
from kimi_cli.telemetry import track

track("kimi_subagent_created")
return PreparedInstance(
record=record,
actual_type=actual_type,
Expand Down
Loading
Loading