From c9e647316163731bd2736a7e1704c61fc2a0f38e Mon Sep 17 00:00:00 2001 From: Emil Date: Sat, 18 Jul 2026 23:59:53 +0300 Subject: [PATCH] fix: handle ACP filesystem requests and resilient activity events --- Dashboard.html | 8 ++-- Program.cs | 121 ++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/Dashboard.html b/Dashboard.html index b712be2..fe67d89 100644 --- a/Dashboard.html +++ b/Dashboard.html @@ -4,12 +4,12 @@ :root{color-scheme:dark;--bg:#0b1020;--panel:#121a31;--line:#253252;--text:#edf2ff;--muted:#98a7ca;--accent:#76a7ff;--ok:#50d7a1;--warn:#ffc46b;--bad:#ff7777}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top,#19274b,#0b1020 58%);color:var(--text);font:14px Inter,ui-sans-serif,system-ui}header{padding:28px max(6vw,24px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}h1{margin:0;font-size:25px;letter-spacing:-.6px}.sub{color:var(--muted);margin-top:5px}.live{color:var(--ok);font-weight:700}.dot{width:9px;height:9px;display:inline-block;border-radius:50%;background:var(--ok);margin-right:7px;box-shadow:0 0 14px var(--ok)}main{max-width:1280px;margin:0 auto;padding:26px;display:grid;grid-template-columns:1.1fr .9fr;gap:20px}.panel{background:color-mix(in srgb,var(--panel) 92%,transparent);border:1px solid var(--line);border-radius:16px;padding:18px;box-shadow:0 16px 40px #0003}h2{margin:0 0 15px;font-size:15px}.agents{display:grid;gap:12px}.agent{padding:15px;border:1px solid var(--line);border-radius:12px;background:#0d152a}.agent-top{display:flex;justify-content:space-between;align-items:center}.name{font-weight:750;font-size:16px}.badge{padding:4px 9px;border-radius:999px;background:#1a2b4c;color:var(--accent);font-size:12px}.state-ready{color:var(--ok)}.state-working{color:var(--warn)}.state-error{color:var(--bad)}.meta{color:var(--muted);font-size:12px;margin-top:8px}.now{display:flex;align-items:center;gap:8px;margin-top:13px;padding:9px 10px;border-radius:8px;background:#101c33;color:#d8e4ff}.now-mark{width:7px;height:7px;border-radius:50%;background:var(--warn);box-shadow:0 0 10px var(--warn);flex:none}.now.ready .now-mark{background:var(--ok);box-shadow:0 0 10px var(--ok)}.now.error .now-mark{background:var(--bad);box-shadow:0 0 10px var(--bad)}.now-label{font-size:10px;font-weight:800;letter-spacing:.08em;color:var(--muted)}.now-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scopes{margin-top:10px;display:flex;gap:6px;flex-wrap:wrap}.scope{font:11px ui-monospace,monospace;background:#17223d;border-radius:5px;padding:3px 6px;color:#c0cdf1}.events{height:600px;overflow:auto;display:flex;flex-direction:column-reverse;gap:8px}.event{border-left:3px solid var(--accent);background:#0d152a;border-radius:0 8px 8px 0;padding:10px 11px}.event-head{font-size:12px;color:var(--muted);margin-bottom:4px}.event-body{line-height:1.35;word-break:break-word}.empty{color:var(--muted);padding:18px 3px}@media(max-width:850px){main{grid-template-columns:1fr}.events{height:360px}}

KimiFleet

MCP-controlled Kimi K3 agent fleet
LIVE

Agents

Loading agents…

Activity

Waiting for events…
diff --git a/Program.cs b/Program.cs index 8ac368f..3dd4398 100644 --- a/Program.cs +++ b/Program.cs @@ -248,11 +248,16 @@ sealed class FleetHost : IDisposable using var document = JsonDocument.Parse(message); var update = document.RootElement.GetProperty("update"); var type = update.GetProperty("sessionUpdate").GetString(); - if (type == "tool_call_update") return $"{update.GetProperty("title").GetString()} — {update.GetProperty("status").GetString()}"; + if (type == "tool_call_update") + { + var title = update.TryGetProperty("title", out var titleNode) ? titleNode.GetString() : null; + var status = update.TryGetProperty("status", out var statusNode) ? statusNode.GetString() : null; + return $"{title ?? "Tool call"} — {status ?? "updated"}"; + } if (type == "agent_message_chunk") return "Kimi sent a response"; return type ?? "session update"; } - catch (JsonException) { return "session update"; } + catch (Exception) { return "session update"; } } public void Dispose() @@ -369,7 +374,8 @@ sealed class KimiAgent : IDisposable if (!_pending.TryAdd(id, completion)) throw new InvalidOperationException("Could not register ACP request."); var envelope = JsonSerializer.Serialize(new { jsonrpc = "2.0", id, method, @params = parameters }); await SendRawAsync(envelope); - using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(10)); + var requestTimeout = method == "session/prompt" ? TimeSpan.FromHours(2) : TimeSpan.FromMinutes(2); + using var timeout = new CancellationTokenSource(requestTimeout); await using var registration = timeout.Token.Register(() => completion.TrySetException(new TimeoutException($"ACP request '{method}' timed out."))); return await completion.Task; } @@ -400,6 +406,20 @@ sealed class KimiAgent : IDisposable { using var document = JsonDocument.Parse(line); var root = document.RootElement; + if (root.TryGetProperty("method", out var method)) + { + var methodName = method.GetString() ?? "notification"; + if (root.TryGetProperty("id", out var requestId)) + { + var parameters = root.TryGetProperty("params", out var requestParams) ? requestParams.Clone() : default; + _ = HandleClientRequestAsync(methodName, requestId.GetRawText(), parameters); + return; + } + if (methodName == "session/update" && root.TryGetProperty("params", out var updateParams)) + UpdateActivityFromSessionUpdate(updateParams); + _publish(Name, methodName, root.TryGetProperty("params", out var p) ? p.GetRawText() : string.Empty); + return; + } if (root.TryGetProperty("id", out var idNode) && idNode.TryGetInt64(out var id) && _pending.TryRemove(id, out var completion)) { if (root.TryGetProperty("error", out var error)) completion.TrySetException(new InvalidOperationException(error.GetRawText())); @@ -407,24 +427,78 @@ sealed class KimiAgent : IDisposable else completion.TrySetException(new InvalidOperationException("ACP response has neither result nor error.")); return; } - if (root.TryGetProperty("method", out var method)) - { - var methodName = method.GetString() ?? "notification"; - if (methodName == "session/request_permission" && root.TryGetProperty("id", out var permissionId) && root.TryGetProperty("params", out var permission)) - { - var optionId = SelectApproval(permission); - SetActivity("Approving requested action"); - _ = RespondToPermissionAsync(permissionId.GetRawText(), optionId); - _publish(Name, "permission", $"Auto-approved '{optionId}' (yolo mode)."); - return; - } - if (methodName == "session/update" && root.TryGetProperty("params", out var updateParams)) - UpdateActivityFromSessionUpdate(updateParams); - _publish(Name, methodName, root.TryGetProperty("params", out var p) ? p.GetRawText() : string.Empty); - } - else _publish(Name, "protocol", line); + _publish(Name, "protocol", line); } - catch (JsonException) { _publish(Name, "stdout", line); } + catch (Exception ex) + { + _publish(Name, "protocol-error", ex.Message); + _publish(Name, "stdout", line); + } + } + + private async Task HandleClientRequestAsync(string method, string requestId, JsonElement parameters) + { + try + { + object result; + switch (method) + { + case "session/request_permission": + var optionId = SelectApproval(parameters); + SetActivity("Approving requested action"); + _publish(Name, "permission", $"Auto-approved '{optionId}' (yolo mode)."); + result = new { outcome = new { outcome = "selected", optionId } }; + break; + case "fs/read_text_file": + var readPath = ResolveWorkspacePath(parameters); + SetActivity($"Reading {Path.GetFileName(readPath)}"); + _publish(Name, "file", $"Reading {Path.GetRelativePath(Workspace, readPath)}"); + result = new { content = await File.ReadAllTextAsync(readPath) }; + break; + case "fs/write_text_file": + var writePath = ResolveWorkspacePath(parameters); + EnsureWritableScope(writePath); + if (!parameters.TryGetProperty("content", out var contentNode) || contentNode.ValueKind != JsonValueKind.String) + throw new InvalidOperationException("ACP write request has no text content."); + Directory.CreateDirectory(Path.GetDirectoryName(writePath)!); + await File.WriteAllTextAsync(writePath, contentNode.GetString()!); + SetActivity($"Editing {Path.GetFileName(writePath)}"); + _publish(Name, "file", $"Updated {Path.GetRelativePath(Workspace, writePath)}"); + result = new { }; + break; + default: + throw new InvalidOperationException($"Unsupported ACP client request '{method}'."); + } + await SendResultAsync(requestId, result); + } + catch (Exception ex) + { + _publish(Name, "client-error", $"{method}: {ex.Message}"); + await SendErrorAsync(requestId, ex.Message); + } + } + + private string ResolveWorkspacePath(JsonElement parameters) + { + if (!parameters.TryGetProperty("path", out var pathNode) || pathNode.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(pathNode.GetString())) + throw new InvalidOperationException("ACP filesystem request has no path."); + var requested = pathNode.GetString()!; + var fullPath = Path.GetFullPath(Path.IsPathRooted(requested) ? requested : Path.Combine(Workspace, requested)); + if (!IsWithin(fullPath, Workspace)) throw new UnauthorizedAccessException("ACP filesystem request escaped the agent workspace."); + return fullPath; + } + + private void EnsureWritableScope(string path) + { + if (Scopes.Count == 0) return; + if (Scopes.Any(scope => IsWithin(path, Path.GetFullPath(Path.Combine(Workspace, scope))))) return; + throw new UnauthorizedAccessException("ACP write request is outside the agent's exclusive scopes."); + } + + private static bool IsWithin(string path, string directory) + { + var relative = Path.GetRelativePath(directory, path); + return relative != ".." && !relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal); } private void UpdateActivityFromSessionUpdate(JsonElement parameters) @@ -460,8 +534,11 @@ sealed class KimiAgent : IDisposable ?? throw new InvalidOperationException("ACP permission request has no approval option."); } - private Task RespondToPermissionAsync(string requestId, string optionId) => - SendRawAsync($"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"result\":{{\"outcome\":{{\"outcome\":\"selected\",\"optionId\":{JsonSerializer.Serialize(optionId)}}}}}}}"); + private Task SendResultAsync(string requestId, object result) => + SendRawAsync($"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"result\":{JsonSerializer.Serialize(result)}}}"); + + private Task SendErrorAsync(string requestId, string message) => + SendRawAsync($"{{\"jsonrpc\":\"2.0\",\"id\":{requestId},\"error\":{{\"code\":-32000,\"message\":{JsonSerializer.Serialize(message)}}}}}"); private async Task SendRawAsync(string json) {