Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ Only write entries that are worth mentioning to users.

## Unreleased

**Highlights**: New `Monitor` tool streams background command output as notifications, with a volume cap that auto-stops runaway output.

- Tool: Add `Monitor` tool — run a self-filtering shell command in the background and receive each stdout line as a notification; stop the monitor with `TaskStop` or let it time out
- Core: Cap monitor notification volume — auto-stops a monitor if it emits more than 200 lines in a 5-second window and warns the user to restart with a tighter filter

## 1.47.0 (2026-06-05)

- Shell: Guide users to the new standalone Kimi Code — adds a `/upgrade` command that installs it (migrating your config & sessions automatically), a welcome-screen nudge, and a once-per-day tip shown on exit
Expand Down
20 changes: 20 additions & 0 deletions docs/en/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@ How background tasks work:

You can use the `/task` slash command to open the interactive task browser, where you can view the status and output of all background tasks in real time (including tasks that are still running). See [Slash commands reference](../reference/slash-commands.md#task) for details.

### Monitor tool

In addition to `Shell` background tasks, the AI can also use the `Monitor` tool to start a background monitor that streams each line of a command's stdout to you as a notification. This is great for watching build logs, test output, or server logs for key events.

How the `Monitor` tool works:

1. The AI invokes the `Monitor` tool with the command to run and a short description
2. The tool runs the command in the background and pushes a notification for every stdout line it produces
3. You can keep talking to the AI; when a notification arrives, the system automatically triggers a new agent turn to handle the event if the AI is idle

::: tip Usage advice
`Monitor` commands should self-filter and flush per line. For example, use `grep --line-buffered` to filter key log lines, or `tail -f` to watch a file continuously. A monitor is auto-stopped if it emits more than 200 lines within a 5-second window to prevent notification spam; in that case, ask the AI to restart it with a tighter filter.
:::

You can stop a running monitor with the `TaskStop` tool, or let it time out according to `timeout_ms`. A monitor with `persistent=true` runs until the session ends or it is stopped manually.

::: warning Note
The `Monitor` tool is not available in plan mode and can only be used by the root agent.
:::

::: tip
By default, up to 4 background tasks can run simultaneously. This can be adjusted in the `[background]` section of the config file. All background tasks are terminated when the CLI exits by default. See [Configuration files](../configuration/config-files.md#background).
:::
Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ Supported keyboard shortcuts:
The task browser automatically refreshes every second, showing real-time task status changes.

::: tip
Background tasks are started by the AI using the `Shell` tool with `run_in_background=true`. The system automatically notifies the AI when background tasks complete.
Background tasks are started by the AI using the `Shell` tool with `run_in_background=true` or via the `Monitor` tool. The system automatically notifies the AI when background tasks complete or when a monitor emits a new output line. See [Background tasks](../guides/interaction.md#background-tasks) for details.
:::

### `/yolo`
Expand Down
5 changes: 5 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

**Highlights**: New `Monitor` tool streams background command output as notifications, with a volume cap that auto-stops runaway output.

- Tool: Add `Monitor` tool — run a self-filtering shell command in the background and receive each stdout line as a notification; stop the monitor with `TaskStop` or let it time out
- Core: Cap monitor notification volume — auto-stops a monitor if it emits more than 200 lines in a 5-second window and warns the user to restart with a tighter filter

## 1.47.0 (2026-06-05)

- Shell: Guide users to the new standalone Kimi Code — adds a `/upgrade` command that installs it (migrating your config & sessions automatically), a welcome-screen nudge, and a once-per-day tip shown on exit
Expand Down
20 changes: 20 additions & 0 deletions docs/zh/guides/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@ Thinking 模式需要当前模型支持。部分模型(如 `kimi-k2-thinking-t

你可以使用 `/task` 斜杠命令打开交互式任务浏览器,实时查看所有后台任务的状态和输出(包括正在运行中的任务)。详见 [斜杠命令参考](../reference/slash-commands.md#task)。

### Monitor 工具

除了 `Shell` 后台任务,AI 还可以使用 `Monitor` 工具启动一个后台监控任务,将命令的标准输出逐行作为通知推送给你。这非常适合监控构建日志、测试输出或服务器日志中的关键事件。

`Monitor` 工具的工作方式:

1. AI 调用 `Monitor` 工具,传入要执行的命令和简短描述
2. 工具在后台运行该命令,每产生一行标准输出就推送一条通知
3. 你可以继续与 AI 对话;收到通知后,如果 AI 处于空闲状态,系统会自动触发新的 Agent 轮次处理事件

::: tip 使用建议
`Monitor` 命令应该自带过滤并逐行刷新。例如使用 `grep --line-buffered` 过滤关键日志,或使用 `tail -f` 持续监控文件。监控任务会在 5 秒窗口内输出超过 200 行时自动停止,以防止通知刷屏;遇到这种情况应让 AI 使用更严格的过滤条件重启监控。
:::

你可以使用 `TaskStop` 工具停止一个正在运行的监控任务,或等待它达到 `timeout_ms` 设定的超时时间。`persistent=true` 的监控任务会持续到会话结束或手动停止。

::: warning 注意
`Monitor` 工具在 Plan 模式下不可用,只能由 root agent 使用。
:::

::: tip 提示
默认最多同时运行 4 个后台任务,可在配置文件的 `[background]` 节中调整。CLI 退出时默认会终止所有后台任务。详见 [配置文件](../configuration/config-files.md#background)。
:::
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ Flow Skill 也可以通过 `/skill:<name>` 调用,此时作为普通 Skill 加
任务浏览器每秒自动刷新,实时显示任务状态变化。

::: tip 提示
后台任务通过 AI 使用 `Shell` 工具的 `run_in_background=true` 参数启动。当后台任务完成时,系统会自动通知 AI。
后台任务通过 AI 使用 `Shell` 工具的 `run_in_background=true` 参数或 `Monitor` 工具启动。当后台任务完成或 Monitor 产生新的输出行时,系统会自动通知 AI。详见 [后台任务](../guides/interaction.md#后台任务)
:::

### `/yolo`
Expand Down
5 changes: 5 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

## 未发布

**亮点**:新增 `Monitor` 工具,可将后台命令输出以通知形式逐行推送,并自带流量上限防止消息刷屏。

- Tool:新增 `Monitor` 工具——在后台运行自带过滤的 Shell 命令,并将每条标准输出行作为通知推送;可用 `TaskStop` 停止或等待超时
- Core:为 Monitor 通知设置流量上限——若 5 秒窗口内输出超过 200 行则自动停止该监控任务,并提示用户使用更严格的过滤条件重启

## 1.47.0 (2026-06-05)

- Shell:引导用户升级到新版独立 Kimi Code——新增 `/upgrade` 命令一键安装(自动迁移现有配置与会话),并新增欢迎界面提示与每天一次的退出提示
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/agents/default/agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ agent:
- "kimi_cli.tools.background:TaskList"
- "kimi_cli.tools.background:TaskOutput"
- "kimi_cli.tools.background:TaskStop"
- "kimi_cli.tools.monitor:Monitor"
- "kimi_cli.tools.file:ReadFile"
- "kimi_cli.tools.file:ReadMediaFile"
- "kimi_cli.tools.file:Glob"
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/background/ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
_TASK_ID_PREFIXES: dict[TaskKind, str] = {
"bash": "bash",
"agent": "agent",
"monitor": "monitor",
}


Expand Down
149 changes: 148 additions & 1 deletion src/kimi_cli/background/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@

from .ids import generate_task_id
from .models import (
MonitorPayload,
TaskOutputChunk,
TaskRuntime,
TaskSpec,
TaskStatus,
TaskView,
is_terminal_status,
monitor_payload,
)
from .store import BackgroundTaskStore

Expand All @@ -50,6 +52,7 @@ def __init__(
self._runtime: Runtime | None = None
self._live_agent_tasks: dict[str, asyncio.Task[None]] = {}
self._completion_event: asyncio.Event = asyncio.Event()
self._monitor_windows: dict[str, list[float]] = {}

@property
def completion_event(self) -> asyncio.Event:
Expand Down Expand Up @@ -206,6 +209,66 @@ def create_bash_task(
self._store.write_runtime(task_id, runtime)
return self._store.merged_view(task_id)

def create_monitor_task(
self,
*,
command: str,
description: str,
timeout_s: int | None,
tool_call_id: str,
shell_name: str,
shell_path: str,
cwd: str,
payload: MonitorPayload,
) -> TaskView:
self._ensure_root()
self._ensure_local_backend()
if self._active_task_count() >= self._config.max_running_tasks:
raise RuntimeError("Too many background tasks are already running.")

task_id = generate_task_id("monitor")
spec = TaskSpec(
id=task_id,
kind="monitor",
session_id=self._session.id,
description=description,
tool_call_id=tool_call_id,
owner_role="root",
command=command,
shell_name=shell_name,
shell_path=shell_path,
cwd=cwd,
timeout_s=timeout_s,
kind_payload=payload.model_dump(),
)
self._store.create_task(spec)
from kimi_cli.telemetry import track

track("background_task_created")

runtime = self._store.read_runtime(task_id)
task_dir = self._store.task_dir(task_id)
try:
worker_pid = self._launch_worker(task_dir)
except Exception as exc:
runtime.status = "failed"
runtime.failure_reason = f"Failed to launch worker: {exc}"
runtime.finished_at = time.time()
runtime.updated_at = runtime.finished_at
self._store.write_runtime(task_id, runtime)
raise

runtime = self._store.read_runtime(task_id)
if runtime.finished_at is None and (
runtime.status == "created"
or (runtime.status == "starting" and runtime.worker_pid is None)
):
runtime.status = "starting"
runtime.worker_pid = worker_pid
runtime.updated_at = time.time()
self._store.write_runtime(task_id, runtime)
return self._store.merged_view(task_id)

def create_agent_task(
self,
*,
Expand Down Expand Up @@ -482,7 +545,9 @@ def recover(self) -> None:

def reconcile(self, *, limit: int | None = None) -> list[str]:
self.recover()
return self.publish_terminal_notifications(limit=limit)
published = self.publish_terminal_notifications(limit=limit)
published += self.publish_monitor_notifications(limit=limit)
return published

def publish_terminal_notifications(self, *, limit: int | None = None) -> list[str]:
published: list[str] = []
Expand Down Expand Up @@ -605,6 +670,88 @@ def publish_terminal_notifications(self, *, limit: int | None = None) -> list[st
break
return published

def publish_monitor_notifications(self, *, limit: int | None = None) -> list[str]:
published: list[str] = []
for view in self._store.list_views():
if view.spec.kind != "monitor":
continue
payload = monitor_payload(view.spec)
chunk = self.read_output(view.spec.id, offset=payload.notify_offset)
text = chunk.text
if not text:
continue
terminal = is_terminal_status(view.runtime.status)
if "\n" in text:
complete, _, partial = text.rpartition("\n")
if terminal and partial:
# The task has ended: flush the trailing partial line (output
# with no final newline, e.g. a crash mid-line or `printf` with
# no trailing "\n") as the last line instead of dropping it.
lines = text.split("\n")
consumed_bytes = len(text.encode("utf-8"))
else:
lines = complete.split("\n")
consumed_bytes = len(complete.encode("utf-8")) + 1 # include the final "\n"
elif terminal:
# No newline at all, but the task is terminal — the whole remaining
# buffer is the final line; emit it rather than waiting forever.
lines = [text]
consumed_bytes = len(text.encode("utf-8"))
else:
continue # running: only a partial trailing line so far; wait for newline

# volume cap: too many lines since the window started -> auto-stop
now = time.time()
win = self._monitor_windows.setdefault(view.spec.id, [now, 0])
if now - win[0] > payload.volume_window_s:
win[0], win[1] = now, 0
win[1] += len(lines)
over_cap = win[1] > payload.max_lines_per_window

event = NotificationEvent(
id=self._notifications.new_id(),
category="task",
type="monitor_line",
source_kind="monitor",
source_id=view.spec.id,
title=view.spec.description,
body="\n".join(lines),
severity="info",
)
self._notifications.publish(event)
published.append(event.id)

# advance + persist notify_offset
new_payload = payload.model_copy(
update={"notify_offset": payload.notify_offset + consumed_bytes}
)
spec = self._store.read_spec(view.spec.id)
spec.kind_payload = new_payload.model_dump()
self._store.write_spec(spec)

if over_cap and not is_terminal_status(view.runtime.status):
self.kill(view.spec.id, reason="Monitor auto-stopped: too noisy")
warn = NotificationEvent(
id=self._notifications.new_id(),
category="task",
type="monitor_line",
source_kind="monitor",
source_id=view.spec.id,
title=view.spec.description,
severity="warning",
body=(
f"Monitor auto-stopped: too noisy "
f"(>{payload.max_lines_per_window} lines / {payload.volume_window_s:g}s). "
"Restart with a tighter filter."
),
)
self._notifications.publish(warn)
published.append(warn.id)

if limit is not None and len(published) >= limit:
break
return published

def _mark_task_running(self, task_id: str) -> None:
runtime = self._store.read_runtime(task_id)
if is_terminal_status(runtime.status):
Expand Down
15 changes: 14 additions & 1 deletion src/kimi_cli/background/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from pydantic import BaseModel, ConfigDict, Field, field_validator

type TaskKind = Literal["bash", "agent"]
type TaskKind = Literal["bash", "agent", "monitor"]
type TaskStatus = Literal[
"created",
"starting",
Expand Down Expand Up @@ -53,6 +53,19 @@ def _normalize_owner_role(cls, v: str) -> str:
kind_payload: dict[str, Any] | None = None


class MonitorPayload(BaseModel):
model_config = ConfigDict(extra="ignore")

batch_ms: int = 200
max_lines_per_window: int = 200
volume_window_s: float = 5.0
notify_offset: int = 0


def monitor_payload(spec: TaskSpec) -> MonitorPayload:
return MonitorPayload.model_validate(spec.kind_payload or {})


class TaskRuntime(BaseModel):
model_config = ConfigDict(extra="ignore")

Expand Down
Loading