From 4c5b54a742f70628f6908cc32f213046b4f86a07 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:03:52 +0000 Subject: [PATCH 01/11] feat(background): add monitor task kind and MonitorPayload Co-Authored-By: Kimi K2.7 Code --- src/kimi_cli/background/models.py | 15 ++++++++++++++- tests/background/test_monitor_models.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tests/background/test_monitor_models.py diff --git a/src/kimi_cli/background/models.py b/src/kimi_cli/background/models.py index a3cb033ba..7f038a6f7 100644 --- a/src/kimi_cli/background/models.py +++ b/src/kimi_cli/background/models.py @@ -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", @@ -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") diff --git a/tests/background/test_monitor_models.py b/tests/background/test_monitor_models.py new file mode 100644 index 000000000..23082171f --- /dev/null +++ b/tests/background/test_monitor_models.py @@ -0,0 +1,24 @@ +from kimi_cli.background.models import TaskKind, MonitorPayload, monitor_payload, TaskSpec + + +def test_monitor_payload_defaults(): + p = MonitorPayload() + assert (p.batch_ms, p.max_lines_per_window, p.volume_window_s, p.notify_offset) == (200, 200, 5.0, 0) + + +def test_monitor_payload_from_spec_roundtrip(): + spec = TaskSpec( + id="monitor-x", + kind="monitor", + session_id="s", + description="d", + tool_call_id="t", + kind_payload={"notify_offset": 42, "batch_ms": 100}, + ) + p = monitor_payload(spec) + assert p.notify_offset == 42 and p.batch_ms == 100 + + +def test_monitor_payload_missing_payload_is_defaults(): + spec = TaskSpec(id="monitor-y", kind="monitor", session_id="s", description="d", tool_call_id="t") + assert monitor_payload(spec).notify_offset == 0 From 01515703c8ecbfce8ff4a571ebec1626e06349b0 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:04:45 +0000 Subject: [PATCH 02/11] feat(background): add create_monitor_task Co-Authored-By: Kimi K2.7 Code --- src/kimi_cli/background/ids.py | 1 + src/kimi_cli/background/manager.py | 60 ++++++++++++++++++++ tests/background/test_create_monitor_task.py | 31 ++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/background/test_create_monitor_task.py diff --git a/src/kimi_cli/background/ids.py b/src/kimi_cli/background/ids.py index 282ac0d08..f2cc0532a 100644 --- a/src/kimi_cli/background/ids.py +++ b/src/kimi_cli/background/ids.py @@ -10,6 +10,7 @@ _TASK_ID_PREFIXES: dict[TaskKind, str] = { "bash": "bash", "agent": "agent", + "monitor": "monitor", } diff --git a/src/kimi_cli/background/manager.py b/src/kimi_cli/background/manager.py index 95eb8e7a3..b2e8eb1ea 100644 --- a/src/kimi_cli/background/manager.py +++ b/src/kimi_cli/background/manager.py @@ -23,6 +23,7 @@ from .ids import generate_task_id from .models import ( + MonitorPayload, TaskOutputChunk, TaskRuntime, TaskSpec, @@ -206,6 +207,65 @@ 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, *, diff --git a/tests/background/test_create_monitor_task.py b/tests/background/test_create_monitor_task.py new file mode 100644 index 000000000..599a6b9e1 --- /dev/null +++ b/tests/background/test_create_monitor_task.py @@ -0,0 +1,31 @@ +import pytest + +from kimi_cli.background.models import MonitorPayload, monitor_payload + + +@pytest.fixture +def make_manager(runtime): + def _factory(): + return runtime.background_tasks + + return _factory + + +@pytest.mark.asyncio +async def test_create_monitor_task_persists_kind_and_payload(make_manager, monkeypatch): + mgr = make_manager() + monkeypatch.setattr(mgr, "_launch_worker", lambda task_dir: 4242) + view = mgr.create_monitor_task( + command="printf 'a\\nb\\n'", + description="mon", + timeout_s=None, + tool_call_id="tc1", + shell_name="bash", + shell_path="/bin/bash", + cwd="/tmp", + payload=MonitorPayload(batch_ms=50), + ) + assert view.spec.kind == "monitor" + assert view.spec.command == "printf 'a\\nb\\n'" + assert view.spec.timeout_s is None + assert monitor_payload(view.spec).batch_ms == 50 From ada40cffa1248a815e2eedf886038ebc0a3750e2 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:06:01 +0000 Subject: [PATCH 03/11] feat(background): publish per-line monitor notifications with volume cap Co-Authored-By: Kimi K2.7 Code --- src/kimi_cli/background/manager.py | 70 ++++++++++++++- .../background/test_monitor_notifications.py | 90 +++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 tests/background/test_monitor_notifications.py diff --git a/src/kimi_cli/background/manager.py b/src/kimi_cli/background/manager.py index b2e8eb1ea..b1ee2ec0e 100644 --- a/src/kimi_cli/background/manager.py +++ b/src/kimi_cli/background/manager.py @@ -30,6 +30,7 @@ TaskStatus, TaskView, is_terminal_status, + monitor_payload, ) from .store import BackgroundTaskStore @@ -51,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: @@ -542,7 +544,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] = [] @@ -665,6 +669,70 @@ 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 "\n" not in text: + continue # only a partial trailing line so far; wait for newline + complete, _, partial = text.rpartition("\n") + lines = complete.split("\n") + consumed_bytes = len(complete.encode("utf-8")) + 1 # include the final "\n" + + # 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): diff --git a/tests/background/test_monitor_notifications.py b/tests/background/test_monitor_notifications.py new file mode 100644 index 000000000..450f4ede7 --- /dev/null +++ b/tests/background/test_monitor_notifications.py @@ -0,0 +1,90 @@ +import pytest + +from kimi_cli.background.models import MonitorPayload, monitor_payload + + +@pytest.mark.asyncio +async def test_volume_cap_auto_kills_and_warns(make_manager, write_output, monkeypatch): + mgr = make_manager() + monkeypatch.setattr(mgr, "_launch_worker", lambda task_dir: 4242) + view = mgr.create_monitor_task( + command="true", + description="flood", + timeout_s=None, + tool_call_id="t", + shell_name="bash", + shell_path="/bin/bash", + cwd="/tmp", + payload=MonitorPayload(max_lines_per_window=3, volume_window_s=999), + ) + tid = view.spec.id + write_output(tid, "".join(f"l{i}\n" for i in range(10))) + ids = mgr.publish_monitor_notifications() + sevs = [mgr._notifications.store.read_event(i).severity for i in ids] + assert "warning" in sevs # auto-stop warning emitted + + +@pytest.fixture +def make_manager(runtime): + def _factory(): + return runtime.background_tasks + + return _factory + + +@pytest.fixture +def write_output(make_manager): + def _write(task_id: str, text: str) -> None: + mgr = make_manager() + path = mgr.resolve_output_path(task_id) + path.write_text(path.read_text(encoding="utf-8") + text, encoding="utf-8") + + return _write + + +@pytest.mark.asyncio +async def test_emits_one_batched_notification_per_pass(make_manager, write_output, monkeypatch): + mgr = make_manager() + monkeypatch.setattr(mgr, "_launch_worker", lambda task_dir: 4242) + view = mgr.create_monitor_task( + command="true", + description="mon", + timeout_s=None, + tool_call_id="t", + shell_name="bash", + shell_path="/bin/bash", + cwd="/tmp", + payload=MonitorPayload(), + ) + tid = view.spec.id + write_output(tid, "line one\nline two\n") # full lines + ids = mgr.publish_monitor_notifications() + assert len(ids) == 1 + ev = mgr._notifications.store.read_event(ids[0]) # file-based store + assert ev.type == "monitor_line" and ev.source_id == tid + assert ev.body == "line one\nline two" and ev.title == "mon" + # offset advanced; no replay on a second pass with no new bytes + assert mgr.publish_monitor_notifications() == [] + + +@pytest.mark.asyncio +async def test_partial_trailing_line_not_emitted_until_complete(make_manager, write_output, monkeypatch): + mgr = make_manager() + monkeypatch.setattr(mgr, "_launch_worker", lambda task_dir: 4242) + view = mgr.create_monitor_task( + command="true", + description="m", + timeout_s=None, + tool_call_id="t", + shell_name="bash", + shell_path="/bin/bash", + cwd="/tmp", + payload=MonitorPayload(), + ) + tid = view.spec.id + write_output(tid, "partial") # no newline yet + assert mgr.publish_monitor_notifications() == [] # nothing emitted + write_output(tid, " done\n") # completes the line + ids = mgr.publish_monitor_notifications() + ev = mgr._notifications.store.read_event(ids[0]) + assert ev.body == "partial done" From 22cb0f8b6b7f6613312905f95cacd15233443022 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:07:07 +0000 Subject: [PATCH 04/11] feat(tools): add Monitor tool Co-Authored-By: Kimi K2.7 Code --- src/kimi_cli/tools/monitor/__init__.py | 100 +++++++++++++++++++++++++ src/kimi_cli/tools/monitor/monitor.md | 12 +++ tests/tools/test_monitor_tool.py | 45 +++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/kimi_cli/tools/monitor/__init__.py create mode 100644 src/kimi_cli/tools/monitor/monitor.md create mode 100644 tests/tools/test_monitor_tool.py diff --git a/src/kimi_cli/tools/monitor/__init__.py b/src/kimi_cli/tools/monitor/__init__.py new file mode 100644 index 000000000..33fd8ba5c --- /dev/null +++ b/src/kimi_cli/tools/monitor/__init__.py @@ -0,0 +1,100 @@ +from pathlib import Path +from typing import override + +from kosong.tooling import CallableTool2, ToolError, ToolReturnValue +from pydantic import BaseModel, Field + +from kimi_cli.background.models import MonitorPayload +from kimi_cli.soul.agent import Runtime +from kimi_cli.soul.approval import Approval +from kimi_cli.soul.toolset import get_current_tool_call_or_none +from kimi_cli.tools.background import _ensure_root +from kimi_cli.tools.display import BackgroundTaskDisplayBlock +from kimi_cli.tools.utils import load_desc +from kimi_cli.utils.environment import Environment + + +class MonitorParams(BaseModel): + command: str = Field( + description=( + "Shell command to monitor. Each stdout line is an event; " + "the command should self-filter (e.g. grep --line-buffered)." + ) + ) + description: str = Field(description="Short description shown in every notification.") + timeout_ms: int = Field( + default=300000, + ge=1000, + le=3600000, + description="Kill the monitor after this deadline. Ignored when persistent=true.", + ) + persistent: bool = Field( + default=False, + description="Run for the lifetime of the session (no timeout). Stop with TaskStop.", + ) + + +class Monitor(CallableTool2[MonitorParams]): + name: str = "Monitor" + description: str = load_desc(Path(__file__).parent / "monitor.md") + params: type[MonitorParams] = MonitorParams + + def __init__(self, approval: Approval, environment: Environment, runtime: Runtime): + super().__init__() + self._approval = approval + self._shell_path = environment.shell_path + self._runtime = runtime + + @override + async def __call__(self, params: MonitorParams) -> ToolReturnValue: + if err := _ensure_root(self._runtime): + return err + if self._runtime.session.state.plan_mode: + return ToolError( + message="Monitor is not available in plan mode.", + brief="Blocked in plan mode", + ) + + tool_call = get_current_tool_call_or_none() + if tool_call is None: + return ToolError( + message="Monitor must be invoked as a tool call.", + brief="No tool call context", + ) + + result = await self._approval.request( + self.name, + "start monitor", + f"Monitor `{params.command}`", + ) + if not result: + return result.rejection_error() + + timeout_s = None if params.persistent else max(1, params.timeout_ms // 1000) + view = self._runtime.background_tasks.create_monitor_task( + command=params.command, + description=params.description, + timeout_s=timeout_s, + tool_call_id=tool_call.id, + shell_name="bash", + shell_path=str(self._shell_path), + cwd=str(self._runtime.session.work_dir), + payload=MonitorPayload(), + ) + display = BackgroundTaskDisplayBlock( + task_id=view.spec.id, + kind=view.spec.kind, + status=view.runtime.status, + description=view.spec.description, + ) + return ToolReturnValue( + is_error=False, + output=( + f"Monitor started.\ntask_id: {view.spec.id}\n" + f"persistent: {str(params.persistent).lower()}\n" + "Each matching stdout line will arrive as a notification. " + "Stop with TaskStop." + ), + message="Monitor started.", + display=[display], + ) diff --git a/src/kimi_cli/tools/monitor/monitor.md b/src/kimi_cli/tools/monitor/monitor.md new file mode 100644 index 000000000..4396b7d8a --- /dev/null +++ b/src/kimi_cli/tools/monitor/monitor.md @@ -0,0 +1,12 @@ +Start a background monitor that streams events from a long-running command. Each stdout line is an event delivered to you as a notification; you keep working while events arrive. + +Pick by how many notifications you need: +- One ("tell me when the build finishes") -> prefer a background Shell task that exits when done, not Monitor. +- One per occurrence, indefinitely ("every ERROR line") -> Monitor with an unbounded command (tail -f, while true) and persistent=true. +- One per occurrence until a known end -> Monitor with a command that emits lines and then exits. + +Your command's stdout is the event stream. Make it self-filter and flush per line: grep needs --line-buffered, awk needs fflush(). Never pipe raw logs. + +Coverage — silence is not success. Your filter must match failure signatures too (Traceback|Error|FAILED|Killed|OOM), not just the happy-path marker; otherwise a crash looks identical to "still running". + +A monitor that emits too many lines is auto-stopped; restart with a tighter filter. Set persistent=true for session-length watches and stop them with TaskStop; otherwise the monitor is killed after timeout_ms. diff --git a/tests/tools/test_monitor_tool.py b/tests/tools/test_monitor_tool.py new file mode 100644 index 000000000..7f191d7c4 --- /dev/null +++ b/tests/tools/test_monitor_tool.py @@ -0,0 +1,45 @@ +import pytest + +from kimi_cli.soul.toolset import current_tool_call +from kimi_cli.tools.monitor import Monitor, MonitorParams +from kimi_cli.wire.types import ToolCall + + +def _tool_call_token(): + return current_tool_call.set( + ToolCall(id="test", function=ToolCall.FunctionBody(name="Monitor", arguments=None)) + ) + + +@pytest.fixture +def monitor_tool(approval, environment, runtime): + token = _tool_call_token() + try: + yield Monitor(approval, environment, runtime) + finally: + current_tool_call.reset(token) + + +@pytest.fixture +def subagent_monitor_tool(approval, environment, runtime): + original_role = runtime.role + runtime.role = "subagent" + token = _tool_call_token() + try: + yield Monitor(approval, environment, runtime) + finally: + current_tool_call.reset(token) + runtime.role = original_role + + +def test_params_bounds_and_defaults(): + p = MonitorParams(command="tail -f x | grep --line-buffered ERR", description="errs") + assert p.timeout_ms == 300000 and p.persistent is False + with pytest.raises(Exception): + MonitorParams(command="x", description="d", timeout_ms=10) # below min 1000 + + +@pytest.mark.asyncio +async def test_non_root_is_rejected(subagent_monitor_tool): + res = await subagent_monitor_tool(MonitorParams(command="true", description="d")) + assert res.is_error From 15bc804c1af1911144a2b217355c484d521d47fd Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:07:22 +0000 Subject: [PATCH 05/11] feat(agent): register Monitor tool in default agent toolset Co-Authored-By: Kimi K2.7 Code --- src/kimi_cli/agents/default/agent.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/kimi_cli/agents/default/agent.yaml b/src/kimi_cli/agents/default/agent.yaml index eade17f12..ae24a5c15 100644 --- a/src/kimi_cli/agents/default/agent.yaml +++ b/src/kimi_cli/agents/default/agent.yaml @@ -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" From f2a927dc5e0954d39620795ec56066857b6c052f Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:08:06 +0000 Subject: [PATCH 06/11] test(monitor): end-to-end Monitor streaming test Co-Authored-By: Kimi K2.7 Code --- tests/e2e/test_monitor_e2e.py | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/e2e/test_monitor_e2e.py diff --git a/tests/e2e/test_monitor_e2e.py b/tests/e2e/test_monitor_e2e.py new file mode 100644 index 000000000..f221ebbd0 --- /dev/null +++ b/tests/e2e/test_monitor_e2e.py @@ -0,0 +1,44 @@ +import pytest + +from kimi_cli.background.models import MonitorPayload, is_terminal_status + + +@pytest.fixture +def make_manager(runtime): + def _factory(): + return runtime.background_tasks + + return _factory + + +@pytest.mark.asyncio +async def test_monitor_streams_lines_as_notifications(make_manager): + mgr = make_manager() + view = mgr.create_monitor_task( + command="printf 'A\nB\nC\n'", + description="letters", + timeout_s=10, + tool_call_id="tc-monitor-e2e", + shell_name="bash", + shell_path="/bin/bash", + cwd=str(mgr._session.work_dir), + payload=MonitorPayload(), + ) + tid = view.spec.id + + # Wait for the short-lived monitor command to finish. + view = await mgr.wait(tid, timeout_s=10) + assert is_terminal_status(view.runtime.status) + assert view.runtime.exit_code == 0 + + # Pump the notification publisher. + published = mgr.reconcile() + assert published + + events = [mgr._notifications.store.read_event(eid) for eid in published] + monitor_events = [ev for ev in events if ev.type == "monitor_line" and ev.severity == "info"] + assert monitor_events + bodies = "\n".join(ev.body for ev in monitor_events) + assert "A" in bodies + assert "B" in bodies + assert "C" in bodies From 1607e98efc26880ccbae2e77c986d7d0f8a5a9f5 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:22:17 +0000 Subject: [PATCH 07/11] test: update snapshots for Monitor tool registration Co-Authored-By: Kimi K2.7 Code --- tests/core/test_agent_spec.py | 15 +++++---------- tests/core/test_default_agent.py | 3 +-- tests/utils/test_pyinstaller_utils.py | 4 ++-- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/core/test_agent_spec.py b/tests/core/test_agent_spec.py index 700bec6be..295275c62 100644 --- a/tests/core/test_agent_spec.py +++ b/tests/core/test_agent_spec.py @@ -32,8 +32,7 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "kimi_cli.tools.background:TaskList", "kimi_cli.tools.background:TaskOutput", - "kimi_cli.tools.background:TaskStop", - "kimi_cli.tools.file:ReadFile", + "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", "kimi_cli.tools.file:Grep", @@ -103,8 +102,7 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "kimi_cli.tools.background:TaskList", "kimi_cli.tools.background:TaskOutput", - "kimi_cli.tools.background:TaskStop", - "kimi_cli.tools.file:ReadFile", + "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", "kimi_cli.tools.file:Grep", @@ -186,8 +184,7 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "kimi_cli.tools.background:TaskList", "kimi_cli.tools.background:TaskOutput", - "kimi_cli.tools.background:TaskStop", - "kimi_cli.tools.file:ReadFile", + "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", "kimi_cli.tools.file:Grep", @@ -253,8 +250,7 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "kimi_cli.tools.background:TaskList", "kimi_cli.tools.background:TaskOutput", - "kimi_cli.tools.background:TaskStop", - "kimi_cli.tools.file:ReadFile", + "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", "kimi_cli.tools.file:Grep", @@ -349,8 +345,7 @@ def test_load_agent_spec_default_extension(): "kimi_cli.tools.shell:Shell", "kimi_cli.tools.background:TaskList", "kimi_cli.tools.background:TaskOutput", - "kimi_cli.tools.background:TaskStop", - "kimi_cli.tools.file:ReadFile", + "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", "kimi_cli.tools.file:Grep", diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index 2b597443b..a47ff91b0 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -265,8 +265,7 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime): "Shell", "TaskList", "TaskOutput", - "TaskStop", - "ReadFile", + "TaskStop", "Monitor", "ReadFile", "ReadMediaFile", "Glob", "Grep", diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 2f754720f..696a76f5a 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -90,6 +90,7 @@ def test_pyinstaller_datas(): ("src/kimi_cli/tools/background/list.md", "kimi_cli/tools/background"), ("src/kimi_cli/tools/background/output.md", "kimi_cli/tools/background"), ("src/kimi_cli/tools/background/stop.md", "kimi_cli/tools/background"), + ("src/kimi_cli/tools/monitor/monitor.md", "kimi_cli/tools/monitor"), ( "src/kimi_cli/tools/file/glob.md", "kimi_cli/tools/file", @@ -166,8 +167,7 @@ def test_pyinstaller_hiddenimports(): "kimi_cli.tools.file.read_media", "kimi_cli.tools.file.replace", "kimi_cli.tools.file.utils", - "kimi_cli.tools.file.write", - "kimi_cli.tools.plan", + "kimi_cli.tools.file.write", "kimi_cli.tools.monitor", "kimi_cli.tools.plan", "kimi_cli.tools.plan.enter", "kimi_cli.tools.plan.heroes", "kimi_cli.tools.shell", From c2b8759628f65ac9ccd06862e09ae7608ca04589 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:22:30 +0000 Subject: [PATCH 08/11] style: format snapshot updates Co-Authored-By: Kimi K2.7 Code --- tests/core/test_agent_spec.py | 20 +++++++++++++++----- tests/core/test_default_agent.py | 4 +++- tests/utils/test_pyinstaller_utils.py | 4 +++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/core/test_agent_spec.py b/tests/core/test_agent_spec.py index 295275c62..bd2c2596d 100644 --- a/tests/core/test_agent_spec.py +++ b/tests/core/test_agent_spec.py @@ -32,7 +32,9 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "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.background:TaskStop", + "kimi_cli.tools.monitor:Monitor", + "kimi_cli.tools.file:ReadFile", "kimi_cli.tools.file:ReadMediaFile", "kimi_cli.tools.file:Glob", "kimi_cli.tools.file:Grep", @@ -102,7 +104,9 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "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.background:TaskStop", + "kimi_cli.tools.monitor:Monitor", + "kimi_cli.tools.file:ReadFile", "kimi_cli.tools.file:ReadMediaFile", "kimi_cli.tools.file:Glob", "kimi_cli.tools.file:Grep", @@ -184,7 +188,9 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "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.background:TaskStop", + "kimi_cli.tools.monitor:Monitor", + "kimi_cli.tools.file:ReadFile", "kimi_cli.tools.file:ReadMediaFile", "kimi_cli.tools.file:Glob", "kimi_cli.tools.file:Grep", @@ -250,7 +256,9 @@ def test_load_default_agent_spec(): "kimi_cli.tools.shell:Shell", "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.background:TaskStop", + "kimi_cli.tools.monitor:Monitor", + "kimi_cli.tools.file:ReadFile", "kimi_cli.tools.file:ReadMediaFile", "kimi_cli.tools.file:Glob", "kimi_cli.tools.file:Grep", @@ -345,7 +353,9 @@ def test_load_agent_spec_default_extension(): "kimi_cli.tools.shell:Shell", "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.background:TaskStop", + "kimi_cli.tools.monitor:Monitor", + "kimi_cli.tools.file:ReadFile", "kimi_cli.tools.file:ReadMediaFile", "kimi_cli.tools.file:Glob", "kimi_cli.tools.file:Grep", diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index a47ff91b0..5694c6a38 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -265,7 +265,9 @@ async def test_default_agent_background_bash_guardrails(runtime: Runtime): "Shell", "TaskList", "TaskOutput", - "TaskStop", "Monitor", "ReadFile", + "TaskStop", + "Monitor", + "ReadFile", "ReadMediaFile", "Glob", "Grep", diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 696a76f5a..f60c17277 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -167,7 +167,9 @@ def test_pyinstaller_hiddenimports(): "kimi_cli.tools.file.read_media", "kimi_cli.tools.file.replace", "kimi_cli.tools.file.utils", - "kimi_cli.tools.file.write", "kimi_cli.tools.monitor", "kimi_cli.tools.plan", + "kimi_cli.tools.file.write", + "kimi_cli.tools.monitor", + "kimi_cli.tools.plan", "kimi_cli.tools.plan.enter", "kimi_cli.tools.plan.heroes", "kimi_cli.tools.shell", From 7d546199dae5bfc44f850e8d0b404e8619f667ba Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:28:08 +0000 Subject: [PATCH 09/11] style: format Monitor implementation and tests Co-Authored-By: Kimi K2.7 Code --- src/kimi_cli/background/manager.py | 9 ++++++--- src/kimi_cli/tools/monitor/__init__.py | 8 +++++--- tests/background/test_monitor_models.py | 13 ++++++++++--- tests/background/test_monitor_notifications.py | 6 ++++-- tests/tools/test_monitor_tool.py | 4 +++- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/kimi_cli/background/manager.py b/src/kimi_cli/background/manager.py index b1ee2ec0e..39f4ac67c 100644 --- a/src/kimi_cli/background/manager.py +++ b/src/kimi_cli/background/manager.py @@ -260,7 +260,8 @@ def create_monitor_task( 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 == "created" + or (runtime.status == "starting" and runtime.worker_pid is None) ): runtime.status = "starting" runtime.worker_pid = worker_pid @@ -679,7 +680,7 @@ def publish_monitor_notifications(self, *, limit: int | None = None) -> list[str text = chunk.text if "\n" not in text: continue # only a partial trailing line so far; wait for newline - complete, _, partial = text.rpartition("\n") + complete, _, _ = text.rpartition("\n") lines = complete.split("\n") consumed_bytes = len(complete.encode("utf-8")) + 1 # include the final "\n" @@ -705,7 +706,9 @@ def publish_monitor_notifications(self, *, limit: int | None = None) -> list[str published.append(event.id) # advance + persist notify_offset - new_payload = payload.model_copy(update={"notify_offset": payload.notify_offset + consumed_bytes}) + 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) diff --git a/src/kimi_cli/tools/monitor/__init__.py b/src/kimi_cli/tools/monitor/__init__.py index 33fd8ba5c..83b676edb 100644 --- a/src/kimi_cli/tools/monitor/__init__.py +++ b/src/kimi_cli/tools/monitor/__init__.py @@ -8,7 +8,6 @@ from kimi_cli.soul.agent import Runtime from kimi_cli.soul.approval import Approval from kimi_cli.soul.toolset import get_current_tool_call_or_none -from kimi_cli.tools.background import _ensure_root from kimi_cli.tools.display import BackgroundTaskDisplayBlock from kimi_cli.tools.utils import load_desc from kimi_cli.utils.environment import Environment @@ -47,8 +46,11 @@ def __init__(self, approval: Approval, environment: Environment, runtime: Runtim @override async def __call__(self, params: MonitorParams) -> ToolReturnValue: - if err := _ensure_root(self._runtime): - return err + if self._runtime.role != "root": + return ToolError( + message="Background tasks can only be managed by the root agent.", + brief="Background task unavailable", + ) if self._runtime.session.state.plan_mode: return ToolError( message="Monitor is not available in plan mode.", diff --git a/tests/background/test_monitor_models.py b/tests/background/test_monitor_models.py index 23082171f..d365e03df 100644 --- a/tests/background/test_monitor_models.py +++ b/tests/background/test_monitor_models.py @@ -1,9 +1,14 @@ -from kimi_cli.background.models import TaskKind, MonitorPayload, monitor_payload, TaskSpec +from kimi_cli.background.models import MonitorPayload, TaskSpec, monitor_payload def test_monitor_payload_defaults(): p = MonitorPayload() - assert (p.batch_ms, p.max_lines_per_window, p.volume_window_s, p.notify_offset) == (200, 200, 5.0, 0) + assert (p.batch_ms, p.max_lines_per_window, p.volume_window_s, p.notify_offset) == ( + 200, + 200, + 5.0, + 0, + ) def test_monitor_payload_from_spec_roundtrip(): @@ -20,5 +25,7 @@ def test_monitor_payload_from_spec_roundtrip(): def test_monitor_payload_missing_payload_is_defaults(): - spec = TaskSpec(id="monitor-y", kind="monitor", session_id="s", description="d", tool_call_id="t") + spec = TaskSpec( + id="monitor-y", kind="monitor", session_id="s", description="d", tool_call_id="t" + ) assert monitor_payload(spec).notify_offset == 0 diff --git a/tests/background/test_monitor_notifications.py b/tests/background/test_monitor_notifications.py index 450f4ede7..b7a6b3934 100644 --- a/tests/background/test_monitor_notifications.py +++ b/tests/background/test_monitor_notifications.py @@ -1,6 +1,6 @@ import pytest -from kimi_cli.background.models import MonitorPayload, monitor_payload +from kimi_cli.background.models import MonitorPayload @pytest.mark.asyncio @@ -68,7 +68,9 @@ async def test_emits_one_batched_notification_per_pass(make_manager, write_outpu @pytest.mark.asyncio -async def test_partial_trailing_line_not_emitted_until_complete(make_manager, write_output, monkeypatch): +async def test_partial_trailing_line_not_emitted_until_complete( + make_manager, write_output, monkeypatch +): mgr = make_manager() monkeypatch.setattr(mgr, "_launch_worker", lambda task_dir: 4242) view = mgr.create_monitor_task( diff --git a/tests/tools/test_monitor_tool.py b/tests/tools/test_monitor_tool.py index 7f191d7c4..bc64fe46f 100644 --- a/tests/tools/test_monitor_tool.py +++ b/tests/tools/test_monitor_tool.py @@ -33,9 +33,11 @@ def subagent_monitor_tool(approval, environment, runtime): def test_params_bounds_and_defaults(): + from pydantic import ValidationError + p = MonitorParams(command="tail -f x | grep --line-buffered ERR", description="errs") assert p.timeout_ms == 300000 and p.persistent is False - with pytest.raises(Exception): + with pytest.raises(ValidationError): MonitorParams(command="x", description="d", timeout_ms=10) # below min 1000 From 289b4da0011f9d8e129aaf9703d32d2a7c3c82d4 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:40:00 +0000 Subject: [PATCH 10/11] docs(monitor): add Monitor tool to changelog and user docs Generated via make gen-changelog and make gen-docs. Co-Authored-By: Kimi K2.7 Code --- CHANGELOG.md | 5 +++++ docs/en/guides/interaction.md | 20 ++++++++++++++++++++ docs/en/reference/slash-commands.md | 2 +- docs/en/release-notes/changelog.md | 5 +++++ docs/zh/guides/interaction.md | 20 ++++++++++++++++++++ docs/zh/reference/slash-commands.md | 2 +- docs/zh/release-notes/changelog.md | 5 +++++ 7 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa0812663..d005acca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 8e78655f1..09bc19878 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -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). ::: diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index e3100d996..b50a86d45 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -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` diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 616365e72..a35f7710d 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -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 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 158623bfe..83f21f531 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -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)。 ::: diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 3bd3cd5bd..d559e5db2 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -299,7 +299,7 @@ Flow Skill 也可以通过 `/skill:` 调用,此时作为普通 Skill 加 任务浏览器每秒自动刷新,实时显示任务状态变化。 ::: tip 提示 -后台任务通过 AI 使用 `Shell` 工具的 `run_in_background=true` 参数启动。当后台任务完成时,系统会自动通知 AI。 +后台任务通过 AI 使用 `Shell` 工具的 `run_in_background=true` 参数或 `Monitor` 工具启动。当后台任务完成或 Monitor 产生新的输出行时,系统会自动通知 AI。详见 [后台任务](../guides/interaction.md#后台任务)。 ::: ### `/yolo` diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index d46650a9f..921497e1a 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,11 @@ ## 未发布 +**亮点**:新增 `Monitor` 工具,可将后台命令输出以通知形式逐行推送,并自带流量上限防止消息刷屏。 + +- Tool:新增 `Monitor` 工具——在后台运行自带过滤的 Shell 命令,并将每条标准输出行作为通知推送;可用 `TaskStop` 停止或等待超时 +- Core:为 Monitor 通知设置流量上限——若 5 秒窗口内输出超过 200 行则自动停止该监控任务,并提示用户使用更严格的过滤条件重启 + ## 1.47.0 (2026-06-05) - Shell:引导用户升级到新版独立 Kimi Code——新增 `/upgrade` 命令一键安装(自动迁移现有配置与会话),并新增欢迎界面提示与每天一次的退出提示 From bcc1db0d65c6cc749ba42e5bfd358bb993b0bba1 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 22 Jun 2026 21:48:19 +0000 Subject: [PATCH 11/11] fix(monitor): flush trailing partial line when a monitor task ends publish_monitor_notifications held back any output after the last newline, waiting for a newline that never comes once the task is terminal. A monitor that ends mid-line (crash, or `printf` with no trailing newline) silently dropped its final partial line. Now flush the remaining buffer as the last line for terminal tasks. Reported by Devin Review on #2471. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/kimi_cli/background/manager.py | 26 ++++++-- .../background/test_monitor_notifications.py | 64 +++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/kimi_cli/background/manager.py b/src/kimi_cli/background/manager.py index 39f4ac67c..ec179d2b3 100644 --- a/src/kimi_cli/background/manager.py +++ b/src/kimi_cli/background/manager.py @@ -678,11 +678,27 @@ def publish_monitor_notifications(self, *, limit: int | None = None) -> list[str payload = monitor_payload(view.spec) chunk = self.read_output(view.spec.id, offset=payload.notify_offset) text = chunk.text - if "\n" not in text: - continue # only a partial trailing line so far; wait for newline - complete, _, _ = text.rpartition("\n") - lines = complete.split("\n") - consumed_bytes = len(complete.encode("utf-8")) + 1 # include the final "\n" + 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() diff --git a/tests/background/test_monitor_notifications.py b/tests/background/test_monitor_notifications.py index b7a6b3934..e232d8e1a 100644 --- a/tests/background/test_monitor_notifications.py +++ b/tests/background/test_monitor_notifications.py @@ -90,3 +90,67 @@ async def test_partial_trailing_line_not_emitted_until_complete( ids = mgr.publish_monitor_notifications() ev = mgr._notifications.store.read_event(ids[0]) assert ev.body == "partial done" + + +def _mark_completed(mgr, task_id: str) -> None: + runtime = mgr._store.read_runtime(task_id) + runtime.status = "completed" + mgr._store.write_runtime(task_id, runtime) + + +@pytest.mark.asyncio +async def test_terminal_task_flushes_trailing_partial_line( + make_manager, write_output, monkeypatch +): + # A monitor that ends mid-line (full line + trailing partial, no final newline, + # e.g. a crash mid-line) must flush the partial once terminal, not drop it. + mgr = make_manager() + monkeypatch.setattr(mgr, "_launch_worker", lambda task_dir: 4242) + view = mgr.create_monitor_task( + command="true", + description="m", + timeout_s=None, + tool_call_id="t", + shell_name="bash", + shell_path="/bin/bash", + cwd="/tmp", + payload=MonitorPayload(), + ) + tid = view.spec.id + write_output(tid, "line1\npartial") # no final newline + ids = mgr.publish_monitor_notifications() # running: emit "line1", hold "partial" + assert mgr._notifications.store.read_event(ids[0]).body == "line1" + + _mark_completed(mgr, tid) # task ends with "partial" still unflushed + ids = mgr.publish_monitor_notifications() + assert len(ids) == 1 + assert mgr._notifications.store.read_event(ids[0]).body == "partial" + assert mgr.publish_monitor_notifications() == [] # fully consumed; no replay + + +@pytest.mark.asyncio +async def test_terminal_task_flushes_single_unterminated_line( + make_manager, write_output, monkeypatch +): + # A monitor whose entire output is one line with no newline (e.g. `printf foo`) + # then exits must still deliver that line. + mgr = make_manager() + monkeypatch.setattr(mgr, "_launch_worker", lambda task_dir: 4242) + view = mgr.create_monitor_task( + command="true", + description="m", + timeout_s=None, + tool_call_id="t", + shell_name="bash", + shell_path="/bin/bash", + cwd="/tmp", + payload=MonitorPayload(), + ) + tid = view.spec.id + write_output(tid, "only-line") # no newline ever + assert mgr.publish_monitor_notifications() == [] # running: held + + _mark_completed(mgr, tid) + ids = mgr.publish_monitor_notifications() + assert len(ids) == 1 + assert mgr._notifications.store.read_event(ids[0]).body == "only-line"