feat: add multi-agent chat cockpit and context controls

This commit is contained in:
Emil
2026-07-19 00:38:24 +03:00
parent c9e6473161
commit 428a2b942c
5 changed files with 574 additions and 61 deletions
+23
View File
@@ -0,0 +1,23 @@
sealed class AgentChatEntry
{
public required string Id { get; init; }
public required DateTimeOffset Time { get; init; }
public required string Type { get; init; }
public string Text { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Status { get; set; }
public string? ToolKind { get; set; }
public string? ToolCallId { get; set; }
public object Snapshot() => new
{
id = Id,
time = Time,
type = Type,
text = Text,
title = Title,
status = Status,
toolKind = ToolKind,
toolCallId = ToolCallId
};
}
+111 -14
View File
@@ -1,15 +1,112 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>KimiFleet</title>
<style>
: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}}
</style></head><body><header><div><h1>KimiFleet</h1><div class="sub">MCP-controlled Kimi K3 agent fleet</div></div><div class="live"><span class="dot"></span>LIVE</div></header><main><section class="panel"><h2>Agents</h2><div id="agents" class="agents"><div class="empty">Loading agents…</div></div></section><section class="panel"><h2>Activity</h2><div id="events" class="events"><div class="empty">Waiting for events…</div></div></section></main>
<script>
const agents=document.querySelector('#agents'),events=document.querySelector('#events'),activityByAgent=new Map(),seenEvents=new Map();
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function drawAgents(items){agents.innerHTML=items.length?items.map(a=>{const activity=activityByAgent.get(a.name)||a.activity||(a.state==='ready'?'Ready for a task':'Synchronizing live activity…');return `<article class="agent"><div class="agent-top"><div><span class="name">${esc(a.name)}</span> <span class="badge">${esc(a.role)}</span></div><b class="state-${esc(a.state)}">${esc(a.state)}</b></div><div class="meta">${esc(a.workspace)}</div><div class="now ${esc(a.state)}" title="${esc(activity)}"><span class="now-mark"></span><span class="now-label">NOW</span><span class="now-text">${esc(activity)}</span></div><div class="scopes">${(a.scopes||[]).map(s=>`<span class="scope">${esc(s)}</span>`).join('')||'<span class="meta">No exclusive scopes</span>'}</div></article>`}).join(''):'<div class="empty">No agents yet. Create one through the MCP tool or REST API.</div>'}
async function refresh(){try{drawAgents(await (await fetch('/agents')).json())}catch{agents.innerHTML='<div class="empty">Dashboard cannot reach KimiFleet.</div>'}}
function add(e){const live=activity(e);if(live)activityByAgent.set(e.agent,live);const text=pretty(e);if(text===null)return;const stamp=new Date(e.time).getTime(),key=`${e.agent}|${text}`,previous=seenEvents.get(key)||0;seenEvents.set(key,stamp);if(stamp-previous<3000)return;events.querySelector('.empty')?.remove();const time=new Date(e.time).toLocaleTimeString();const row=document.createElement('article');row.className='event';row.innerHTML=`<div class="event-head">${esc(time)} · ${esc(e.agent)} · ${esc(e.kind)}</div><div class="event-body">${esc(text)}</div>`;events.prepend(row);while(events.children.length>140)events.removeChild(events.lastChild)}
function pretty(e){if(e.kind!=='session/update')return e.message;try{const u=JSON.parse(e.message).update;if(u.sessionUpdate==='tool_call_update')return u.title||null;if(['agent_thought_chunk','agent_message_chunk','available_commands_update','tool_call'].includes(u.sessionUpdate))return null;return u.sessionUpdate||'session update'}catch{return e.message}}
function activity(e){if(e.kind==='prompt')return 'Planning assigned task';if(e.kind==='completed'||e.kind==='ready')return 'Ready for a task';if(e.kind==='error'||e.kind==='client-error')return `Task failed — ${e.message}`;if(e.kind==='exit'||e.kind==='stopped')return 'Agent stopped';if(e.kind==='file')return e.message;if(e.kind==='permission')return 'Approving requested action';if(e.kind!=='session/update')return null;try{const u=JSON.parse(e.message).update;if(u.sessionUpdate==='tool_call'){if(u.kind==='read')return 'Reading project files';if(u.kind==='bash')return 'Running a project command';return u.title||'Executing a tool'}if(u.sessionUpdate==='tool_call_update')return u.title||null;if(u.sessionUpdate==='agent_thought_chunk')return 'Analyzing task';if(u.sessionUpdate==='agent_message_chunk')return 'Preparing a report';return null}catch{return null}}
new EventSource('/events').onmessage=x=>{add(JSON.parse(x.data));refresh()};refresh();setInterval(refresh,5000);
</script></body></html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KimiFleet</title>
<style>
:root{color-scheme:dark;--bg:#090d16;--surface:#101622;--surface2:#151d2c;--surface3:#1b2537;--line:#263247;--line2:#34435d;--text:#edf2fb;--muted:#8b9ab2;--subtle:#66748b;--blue:#6ea8fe;--cyan:#65d6d0;--green:#52d39a;--yellow:#f4bf64;--red:#ff737d;--violet:#a78bfa;--shadow:0 18px 55px #0005}
*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:radial-gradient(900px 500px at 50% -200px,#213353 0,#0d1320 50%,var(--bg) 100%);color:var(--text);font:14px Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow-x:hidden}button,input,textarea{font:inherit}button{color:inherit}.hidden{display:none!important}
.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:18px;min-height:72px;padding:14px 26px;background:#0b101bd9;border-bottom:1px solid var(--line);backdrop-filter:blur(18px)}.brand{display:flex;align-items:center;gap:11px;min-width:210px}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid #41618f;border-radius:10px;background:linear-gradient(145deg,#223c63,#111a2a);color:#bcd6ff;font-weight:900;box-shadow:inset 0 1px #ffffff14,0 7px 22px #0005}.brand h1{margin:0;font-size:17px;letter-spacing:-.3px}.brand small{display:block;margin-top:2px;color:var(--muted);font-size:11px}.fleet-stats{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px}.stat{display:flex;align-items:center;gap:6px;padding:6px 9px;border:1px solid var(--line);border-radius:999px;background:#101725}.stat-dot{width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green)}.toolbar{display:flex;align-items:center;gap:8px;margin-left:auto}.search-wrap{position:relative}.search-wrap svg{position:absolute;left:11px;top:10px;color:var(--subtle)}.search{width:min(28vw,320px);height:36px;padding:0 12px 0 34px;border:1px solid var(--line);border-radius:9px;outline:none;background:#0e1522;color:var(--text)}.search:focus{border-color:#4c6f9f;box-shadow:0 0 0 3px #4e7fbd1d}.icon-btn,.primary-btn,.quiet-btn{height:36px;border:1px solid var(--line);border-radius:9px;background:#131b29;cursor:pointer;transition:.16s ease}.icon-btn{display:grid;place-items:center;width:36px;padding:0}.icon-btn:hover,.quiet-btn:hover{border-color:var(--line2);background:#1a2434}.primary-btn{display:flex;align-items:center;gap:7px;padding:0 13px;border-color:#456fa8;background:#28598f;color:white;font-weight:700}.primary-btn:hover{background:#326ba7}.quiet-btn{padding:0 11px;color:#c6d2e5}
.fleet{padding:20px 26px 32px}.agent-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(520px,100%),1fr));gap:16px;align-items:start}.agent-grid.dense{grid-template-columns:repeat(auto-fit,minmax(min(400px,100%),1fr))}.agent-grid.focused{display:block;max-width:1180px;margin:0 auto}.empty-fleet{display:grid;place-items:center;min-height:55vh;border:1px dashed var(--line2);border-radius:18px;color:var(--muted);text-align:center}.empty-fleet strong{display:block;margin-bottom:6px;color:var(--text);font-size:17px}
.agent-card{display:flex;flex-direction:column;min-height:650px;max-height:calc(100vh - 112px);overflow:hidden;border:1px solid var(--line);border-radius:15px;background:linear-gradient(180deg,#121a28,#0e1521);box-shadow:var(--shadow)}.dense .agent-card{min-height:520px}.focused .agent-card{height:calc(100vh - 112px);max-height:none}.agent-head{padding:13px 14px 11px;border-bottom:1px solid var(--line);background:#141c2a}.head-row{display:flex;align-items:center;gap:9px}.agent-avatar{display:grid;place-items:center;width:31px;height:31px;border:1px solid #36527a;border-radius:9px;background:#172943;color:#9ec8ff;font-weight:900}.agent-identity{min-width:0}.agent-name{font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-meta{margin-top:2px;color:var(--muted);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-actions{display:flex;gap:5px;margin-left:auto}.mini-btn{display:grid;place-items:center;width:29px;height:29px;padding:0;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer}.mini-btn:hover{border-color:var(--line);background:var(--surface3);color:var(--text)}.state-pill{display:flex;align-items:center;gap:6px;margin-left:5px;padding:4px 8px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}.state-pill i{width:6px;height:6px;border-radius:50%;background:var(--muted)}.state-pill.working{color:var(--yellow)}.state-pill.working i{background:var(--yellow);box-shadow:0 0 8px var(--yellow)}.state-pill.ready{color:var(--green)}.state-pill.ready i{background:var(--green)}.state-pill.error{color:var(--red)}.state-pill.error i{background:var(--red)}
.activity-line{display:flex;align-items:center;gap:8px;margin-top:10px;color:#cbd8ea;font-size:12px}.activity-line .pulse{width:6px;height:6px;border-radius:50%;background:var(--yellow);box-shadow:0 0 9px var(--yellow)}.activity-line.ready .pulse{background:var(--green);box-shadow:0 0 9px var(--green)}.activity-line.error .pulse,.activity-line.stopped .pulse{background:var(--red);box-shadow:0 0 9px var(--red)}.activity-text{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue{margin-left:auto;color:var(--violet);font-size:10px}.context-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin-top:10px}.context-track{height:3px;overflow:hidden;border-radius:4px;background:#253044}.context-fill{height:100%;border-radius:4px;background:var(--cyan);transition:width .3s}.context-fill.warn{background:var(--yellow)}.context-fill.danger{background:var(--red)}.context-label{color:var(--muted);font:10px ui-monospace,SFMono-Regular,Consolas,monospace}
.chat{flex:1;min-height:0;overflow:auto;padding:15px 15px 20px;scrollbar-color:#35445d transparent;scrollbar-width:thin}.chat-empty{display:grid;place-items:center;height:100%;color:var(--subtle);text-align:center}.chat-empty .orb{display:grid;place-items:center;width:42px;height:42px;margin:0 auto 10px;border:1px solid #315078;border-radius:13px;background:#16243a;color:#8ab9f4}.message{margin:0 0 14px}.message-label{margin:0 0 5px;color:var(--muted);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.06em}.message-body{max-width:92%;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#151e2d;color:#dce5f3;line-height:1.48;white-space:pre-wrap;overflow-wrap:anywhere}.message.user{display:flex;flex-direction:column;align-items:flex-end}.message.user .message-label{text-align:right}.message.user .message-body{border-color:#315989;background:#173254;color:#eef6ff}.message.assistant .message-body{border-color:#29384e;background:#131b28}.message.error .message-body{border-color:#6a3540;background:#321b24;color:#ffc6cb}.system-message{margin:11px auto;padding:5px 9px;width:max-content;max-width:90%;border:1px solid var(--line);border-radius:999px;background:#121a27;color:var(--muted);font-size:10px;text-align:center}.thought{margin:0 0 12px;border-left:2px solid #556987;padding-left:10px;color:var(--muted)}.thought summary{cursor:pointer;font-size:11px;font-weight:700;user-select:none}.thought-text{margin-top:7px;max-height:180px;overflow:auto;color:#8fa0b8;font-size:11px;line-height:1.45;white-space:pre-wrap}.tool-call{margin:0 0 9px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:#101824}.tool-main{display:flex;align-items:center;gap:8px}.tool-icon{display:grid;place-items:center;width:22px;height:22px;border-radius:6px;background:#1d2a3e;color:#9bbbe8}.tool-title{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#c8d5e7;font:11px ui-monospace,SFMono-Regular,Consolas,monospace}.tool-status{color:var(--muted);font-size:10px}.tool-status.in_progress{color:var(--yellow)}.tool-status.completed{color:var(--green)}.tool-status.failed{color:var(--red)}.tool-call details{margin-top:7px}.tool-call summary{color:var(--subtle);cursor:pointer;font-size:10px}.tool-detail{max-height:180px;margin:7px 0 0;padding:8px;overflow:auto;border-radius:6px;background:#0a101a;color:#8fa4c2;font:10px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
.composer{padding:11px;border-top:1px solid var(--line);background:#111925}.composer-box{display:flex;align-items:flex-end;gap:8px;padding:8px 8px 8px 11px;border:1px solid var(--line2);border-radius:11px;background:#0c121d;transition:.15s}.composer-box:focus-within{border-color:#4d709e;box-shadow:0 0 0 3px #416caa18}.composer textarea{min-height:24px;max-height:110px;flex:1;resize:none;border:0;outline:0;background:transparent;color:var(--text);line-height:1.45}.composer textarea::placeholder{color:#65748a}.send-btn{display:grid;place-items:center;width:30px;height:30px;flex:none;border:0;border-radius:8px;background:#2c66a4;color:white;cursor:pointer}.send-btn:hover{background:#3979bd}.send-btn:disabled{cursor:not-allowed;opacity:.4}.composer-foot{display:flex;align-items:center;gap:6px;margin-top:7px;color:var(--subtle);font-size:9px}.scope-chip{padding:2px 5px;border-radius:4px;background:#192438;color:#8fa4c1;font:9px ui-monospace,SFMono-Regular,Consolas,monospace}.composer-foot .spacer{flex:1}
dialog{width:min(520px,calc(100vw - 30px));padding:0;border:1px solid var(--line2);border-radius:15px;background:#111826;color:var(--text);box-shadow:0 35px 100px #000b}dialog::backdrop{background:#050812b8;backdrop-filter:blur(5px)}.dialog-head{display:flex;align-items:center;padding:17px 18px;border-bottom:1px solid var(--line)}.dialog-head h2{margin:0;font-size:16px}.dialog-body{display:grid;gap:13px;padding:18px}.field label{display:block;margin-bottom:6px;color:var(--muted);font-size:11px;font-weight:700}.field input,.field textarea{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;outline:none;background:#0b111c;color:var(--text)}.field textarea{min-height:76px;resize:vertical}.field input:focus,.field textarea:focus{border-color:#5278a8}.field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.dialog-foot{display:flex;justify-content:flex-end;gap:8px;padding:13px 18px;border-top:1px solid var(--line)}.toast-stack{position:fixed;right:18px;bottom:18px;z-index:50;display:grid;gap:8px}.toast{max-width:380px;padding:10px 12px;border:1px solid var(--line2);border-radius:9px;background:#172131;box-shadow:0 12px 35px #0008;color:#dce5f2;font-size:12px}.toast.error{border-color:#7b3b48;color:#ffc6cc}
@media(max-width:800px){.topbar{flex-wrap:wrap;padding:12px 14px}.brand{min-width:auto}.fleet-stats{order:3;width:100%}.toolbar{gap:6px}.search{width:180px}.fleet{padding:13px}.agent-card{max-height:none;height:calc(100vh - 150px);min-height:560px}.field-row{grid-template-columns:1fr}}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><div class="brand-mark">K</div><div><h1>KimiFleet</h1><small>K3 context cockpit</small></div></div>
<div id="fleetStats" class="fleet-stats"></div>
<div class="toolbar">
<label class="search-wrap"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg><input id="search" class="search" placeholder="Search agents"></label>
<button id="densityBtn" class="icon-btn" title="Toggle compact grid">▦</button>
<button id="newAgentBtn" class="primary-btn"><span>+</span> New agent</button>
</div>
</header>
<main class="fleet"><section id="agentGrid" class="agent-grid"></section></main>
<dialog id="agentDialog">
<form id="agentForm" method="dialog">
<div class="dialog-head"><h2>Start Kimi K3 instance</h2><button type="button" class="mini-btn" data-close-dialog style="margin-left:auto">✕</button></div>
<div class="dialog-body">
<div class="field-row"><div class="field"><label for="agentName">Name</label><input id="agentName" name="name" required pattern="[A-Za-z0-9_-]+" placeholder="renderer-2"></div><div class="field"><label for="agentRole">Role</label><input id="agentRole" name="role" placeholder="reviewer"></div></div>
<div class="field"><label for="agentWorkspace">Workspace</label><input id="agentWorkspace" name="workspace" required placeholder="/path/to/project"></div>
<div class="field"><label for="agentScopes">Exclusive scopes · one per line</label><textarea id="agentScopes" name="scopes" placeholder="src/Engine.AI&#10;tests/Engine.Tests"></textarea></div>
</div>
<div class="dialog-foot"><button type="button" class="quiet-btn" data-close-dialog>Cancel</button><button type="submit" class="primary-btn">Start instance</button></div>
</form>
</dialog>
<div id="toasts" class="toast-stack"></div>
<script>
const grid=document.querySelector('#agentGrid'),stats=document.querySelector('#fleetStats'),search=document.querySelector('#search'),dialog=document.querySelector('#agentDialog'),form=document.querySelector('#agentForm'),toasts=document.querySelector('#toasts');
const state={agents:[],chats:new Map(),drafts:new Map(),focused:null,dense:localStorage.getItem('kimiFleetDensity')==='dense',refreshing:false,pendingAgents:new Set(),refreshTimer:null};
const esc=value=>String(value??'').replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
const fmtTokens=value=>value>=1000?`${(value/1000).toFixed(value>=100000?0:1)}k`:String(value||0);
const icon=name=>({read:'◇',execute:'›_',edit:'✎',search:'⌕',delete:'−',fetch:'↓',think:'◌',move:'↝'}[name]||'·');
function toast(message,error=false){const node=document.createElement('div');node.className=`toast${error?' error':''}`;node.textContent=message;toasts.append(node);setTimeout(()=>node.remove(),4200)}
async function api(path,options={}){const response=await fetch(path,{headers:{'Content-Type':'application/json',...(options.headers||{})},...options});let body=null;try{body=await response.json()}catch{}if(!response.ok)throw new Error(body?.error||`${response.status} ${response.statusText}`);return body}
function rememberEditor(){const active=document.activeElement;if(!active?.dataset?.composer)return null;return {name:active.dataset.composer,start:active.selectionStart,end:active.selectionEnd}}
function restoreEditor(saved){if(!saved)return;const editor=document.querySelector(`[data-composer="${CSS.escape(saved.name)}"]`);if(editor){editor.focus();editor.setSelectionRange(saved.start,saved.end)}}
function nearBottom(element){return element.scrollHeight-element.scrollTop-element.clientHeight<90}
function render(){
const editor=rememberEditor(),scrolls=new Map([...document.querySelectorAll('.chat')].map(node=>[node.dataset.chat,{top:node.scrollTop,bottom:nearBottom(node)}])),openEntries=new Set([...document.querySelectorAll('details[open][data-entry]')].map(node=>node.dataset.entry));
const query=search.value.trim().toLowerCase();let agents=state.agents.filter(agent=>!query||`${agent.name} ${agent.role} ${agent.workspace} ${(agent.scopes||[]).join(' ')}`.toLowerCase().includes(query));
agents.sort((a,b)=>Number(b.state==='working')-Number(a.state==='working')||a.name.localeCompare(b.name));if(state.focused)agents=agents.filter(agent=>agent.name===state.focused);
grid.className=`agent-grid${state.dense?' dense':''}${state.focused?' focused':''}`;
stats.innerHTML=`<span class="stat"><span class="stat-dot"></span>${state.agents.length} instance${state.agents.length===1?'':'s'}</span><span class="stat">${state.agents.filter(a=>a.state==='working').length} working</span>`;
grid.innerHTML=agents.length?agents.map(card).join(''):`<div class="empty-fleet"><div><strong>${state.agents.length?'No matching agents':'No Kimi instances yet'}</strong>${state.agents.length?'Try another search.':'Start a scoped K3 agent to begin.'}</div></div>`;
bindAutoResize();restoreEditor(editor);document.querySelectorAll('details[data-entry]').forEach(node=>node.open=openEntries.has(node.dataset.entry));
document.querySelectorAll('.chat').forEach(node=>{const saved=scrolls.get(node.dataset.chat);if(saved?.bottom||!saved)node.scrollTop=node.scrollHeight;else node.scrollTop=saved.top});
}
function card(agent){
const chat=state.chats.get(agent.name)||[],percent=Math.min(100,Number(agent.contextPercent)||0),contextClass=percent>=90?'danger':percent>=70?'warn':'',draft=state.drafts.get(agent.name)||'';
return `<article class="agent-card" data-agent="${esc(agent.name)}">
<header class="agent-head"><div class="head-row"><div class="agent-avatar">K</div><div class="agent-identity"><div class="agent-name">${esc(agent.name)}</div><div class="agent-meta">${esc(agent.role)} · ${esc(agent.workspace)}</div></div><span class="state-pill ${esc(agent.state)}"><i></i>${esc(agent.state)}</span><div class="agent-actions">${agent.state==='working'?`<button class="mini-btn" data-action="cancel" data-agent="${esc(agent.name)}" title="Cancel current task">■</button>`:''}<button class="mini-btn" data-action="compact" data-agent="${esc(agent.name)}" title="Compact context">↯</button><button class="mini-btn" data-action="reset" data-agent="${esc(agent.name)}" title="Fresh context">↺</button><button class="mini-btn" data-action="focus" data-agent="${esc(agent.name)}" title="${state.focused===agent.name?'Exit focus':'Focus'}">${state.focused===agent.name?'⊙':'⛶'}</button><button class="mini-btn" data-action="stop" data-agent="${esc(agent.name)}" title="Stop agent">×</button></div></div>
<div class="activity-line ${esc(agent.state)}"><span class="pulse"></span><span class="activity-text" title="${esc(agent.activity)}">${esc(agent.activity||'Waiting')}</span>${agent.queueDepth?`<span class="queue">${agent.queueDepth} queued</span>`:''}</div>
<div class="context-row" title="Estimated context usage"><div class="context-track"><div class="context-fill ${contextClass}" style="width:${percent}%"></div></div><span class="context-label">~${fmtTokens(agent.contextTokens)} / ${fmtTokens(agent.maxContextTokens)} · ${percent.toFixed(1)}%</span></div></header>
<section class="chat" data-chat="${esc(agent.name)}">${chat.length?chat.map(message).join(''):`<div class="chat-empty"><div><div class="orb">K</div>No messages in this context yet</div></div>`}</section>
<footer class="composer"><div class="composer-box"><textarea rows="1" data-composer="${esc(agent.name)}" placeholder="Message ${esc(agent.name)}…">${esc(draft)}</textarea><button class="send-btn" data-action="send" data-agent="${esc(agent.name)}" ${draft.trim()?'':'disabled'} title="Send task">↑</button></div><div class="composer-foot"><span>Enter to send · Shift+Enter newline</span><span class="spacer"></span>${(agent.scopes||[]).slice(0,2).map(scope=>`<span class="scope-chip">${esc(scope)}</span>`).join('')}</div></footer>
</article>`;
}
function message(item){
if(item.type==='system')return `<div class="system-message">${esc(item.text)}</div>`;
if(item.type==='thought')return `<details class="thought" data-entry="${esc(item.id)}"><summary>Thinking</summary><div class="thought-text">${esc(item.text)}</div></details>`;
if(item.type==='tool')return `<div class="tool-call"><div class="tool-main"><span class="tool-icon">${icon(item.toolKind)}</span><span class="tool-title" title="${esc(item.title)}">${esc(item.title||item.toolKind||'Tool call')}</span><span class="tool-status ${esc(item.status)}">${esc((item.status||'pending').replace('_',' '))}</span></div>${item.text?`<details data-entry="${esc(item.id)}"><summary>Details</summary><pre class="tool-detail">${esc(item.text)}</pre></details>`:''}</div>`;
const role=item.type==='user'?'You':item.type==='error'?'Error':'Kimi';return `<div class="message ${esc(item.type)}"><div class="message-label">${role}</div><div class="message-body">${esc(item.text)}</div></div>`;
}
async function refresh(requestedAgents=null){
if(state.refreshing){(requestedAgents||[]).forEach(name=>state.pendingAgents.add(name));return}
state.refreshing=true;
try{
const agents=await api('/agents'),activeNames=new Set(agents.map(agent=>agent.name)),knownNames=new Set(state.agents.map(agent=>agent.name));
const targets=requestedAgents===null?agents.map(agent=>agent.name):[...new Set([...(requestedAgents||[]),...agents.filter(agent=>!knownNames.has(agent.name)).map(agent=>agent.name)])];
const chats=new Map(state.chats);for(const name of [...chats.keys()])if(!activeNames.has(name))chats.delete(name);
await Promise.all(targets.filter(name=>activeNames.has(name)).map(async name=>chats.set(name,await api(`/agents/${encodeURIComponent(name)}/chat`))));
state.agents=agents;state.chats=chats;render();
}catch(error){toast(error.message,true)}finally{state.refreshing=false;if(state.pendingAgents.size)scheduleRefresh()}
}
function scheduleRefresh(agent){if(agent&&agent!=='fleet')state.pendingAgents.add(agent);if(state.refreshTimer)return;state.refreshTimer=setTimeout(()=>{const agents=[...state.pendingAgents];state.pendingAgents.clear();state.refreshTimer=null;refresh(agents)},450)}
function bindAutoResize(){document.querySelectorAll('.composer textarea').forEach(area=>{area.style.height='auto';area.style.height=`${Math.min(110,area.scrollHeight)}px`})}
grid.addEventListener('input',event=>{if(!event.target.matches('[data-composer]'))return;state.drafts.set(event.target.dataset.composer,event.target.value);event.target.style.height='auto';event.target.style.height=`${Math.min(110,event.target.scrollHeight)}px`;const button=event.target.closest('.composer').querySelector('[data-action="send"]');button.disabled=!event.target.value.trim()});
grid.addEventListener('keydown',event=>{if(event.target.matches('[data-composer]')&&event.key==='Enter'&&!event.shiftKey){event.preventDefault();send(event.target.dataset.composer)}});
grid.addEventListener('click',async event=>{const button=event.target.closest('[data-action]');if(!button)return;const {action,agent}=button.dataset;try{if(action==='send')await send(agent);else if(action==='focus'){state.focused=state.focused===agent?null:agent;render()}else if(action==='cancel'){await api(`/agents/${encodeURIComponent(agent)}/cancel`,{method:'POST',body:'{}'});toast(`${agent}: cancellation requested`)}else if(action==='compact'){await api(`/agents/${encodeURIComponent(agent)}/context/compact`,{method:'POST',body:JSON.stringify({instructions:'Preserve decisions, constraints, current task, changed files, and pending verification.'})});toast(`${agent}: context compaction queued`)}else if(action==='reset'){if(confirm(`Create a fresh context for ${agent}? Files are not changed.`)){await api(`/agents/${encodeURIComponent(agent)}/context/reset`,{method:'POST',body:'{}'});toast(`${agent}: fresh context created`);scheduleRefresh()}}else if(action==='stop'){if(confirm(`Stop ${agent} and release its scopes?`)){await api(`/agents/${encodeURIComponent(agent)}/stop`,{method:'POST',body:'{}'});if(state.focused===agent)state.focused=null;toast(`${agent} stopped`);scheduleRefresh()}}}catch(error){toast(error.message,true)}});
async function send(agent){const prompt=(state.drafts.get(agent)||'').trim();if(!prompt)return;state.drafts.set(agent,'');render();try{await api(`/agents/${encodeURIComponent(agent)}/prompt`,{method:'POST',body:JSON.stringify({prompt})});scheduleRefresh()}catch(error){state.drafts.set(agent,prompt);render();toast(error.message,true)}}
document.querySelector('#newAgentBtn').addEventListener('click',()=>{document.querySelector('#agentWorkspace').value=localStorage.getItem('kimiFleetWorkspace')||state.agents[0]?.workspace||'';dialog.showModal()});document.querySelectorAll('[data-close-dialog]').forEach(button=>button.addEventListener('click',()=>dialog.close()));
form.addEventListener('submit',async event=>{event.preventDefault();const data=new FormData(form),payload={name:data.get('name').trim(),workspace:data.get('workspace').trim(),role:data.get('role').trim()||'worker',scopes:data.get('scopes').split(/[,\n]/).map(value=>value.trim()).filter(Boolean)};try{await api('/agents',{method:'POST',body:JSON.stringify(payload)});localStorage.setItem('kimiFleetWorkspace',payload.workspace);dialog.close();form.reset();toast(`${payload.name} started`);scheduleRefresh()}catch(error){toast(error.message,true)}});
document.querySelector('#densityBtn').addEventListener('click',()=>{state.dense=!state.dense;localStorage.setItem('kimiFleetDensity',state.dense?'dense':'comfortable');render()});search.addEventListener('input',render);
const source=new EventSource('/events');source.onmessage=event=>{try{scheduleRefresh(JSON.parse(event.data).agent)}catch{scheduleRefresh()}};source.onerror=()=>setTimeout(()=>scheduleRefresh(),1000);refresh();setInterval(()=>refresh([]),4000);
</script>
</body>
</html>
+50 -14
View File
@@ -9,6 +9,7 @@ sealed class McpStdioServer(FleetHost fleet)
string? line;
while ((line = await Console.In.ReadLineAsync()) is not null)
{
var responseId = "null";
try
{
using var document = JsonDocument.Parse(line);
@@ -16,6 +17,7 @@ sealed class McpStdioServer(FleetHost fleet)
if (!request.TryGetProperty("method", out var methodNode)) continue;
var method = methodNode.GetString();
if (!request.TryGetProperty("id", out var id)) continue; // notifications have no response
responseId = id.GetRawText();
object result = method switch
{
"initialize" => new
@@ -25,6 +27,7 @@ sealed class McpStdioServer(FleetHost fleet)
serverInfo = new { name = "KimiFleet", version = "0.1.0" },
instructions = "Use KimiFleet tools to create scoped Kimi K3 agents, assign work, and require peer review."
},
"ping" => new { },
"tools/list" => new { tools = Tools },
"tools/call" => await CallToolAsync(request.GetProperty("params")),
_ => throw new InvalidOperationException($"Unsupported MCP method '{method}'.")
@@ -34,7 +37,7 @@ sealed class McpStdioServer(FleetHost fleet)
catch (Exception ex)
{
Console.Error.WriteLine($"[mcp] {ex.Message}");
await Console.Out.WriteLineAsync(JsonSerializer.Serialize(new { jsonrpc = "2.0", error = new { code = -32603, message = ex.Message } }, Json));
await Console.Out.WriteLineAsync($"{{\"jsonrpc\":\"2.0\",\"id\":{responseId},\"error\":{JsonSerializer.Serialize(new { code = -32603, message = ex.Message }, Json)}}}");
await Console.Out.FlushAsync();
}
}
@@ -42,20 +45,31 @@ sealed class McpStdioServer(FleetHost fleet)
private async Task<object> CallToolAsync(JsonElement call)
{
var name = call.GetProperty("name").GetString() ?? throw new InvalidOperationException("Tool call has no name.");
var arguments = call.TryGetProperty("arguments", out var value) ? value : default;
object payload = name switch
try
{
"fleet_list_agents" => fleet.ListAgents(),
"fleet_start_agent" => await fleet.StartAgentAsync(new FleetHost.AgentSpec(
Required(arguments, "name"), Required(arguments, "workspace"),
Optional(arguments, "role"), StringArray(arguments, "scopes"))),
"fleet_assign_task" => Assign(Required(arguments, "agent"), Required(arguments, "prompt")),
"fleet_request_review" => Review(Required(arguments, "reviewer"), Required(arguments, "author"), Optional(arguments, "context")),
"fleet_stop_agent" => Stop(Required(arguments, "agent")),
_ => throw new InvalidOperationException($"Unknown KimiFleet tool '{name}'.")
};
return new { content = new[] { new { type = "text", text = JsonSerializer.Serialize(payload, Json) } } };
var name = call.GetProperty("name").GetString() ?? throw new InvalidOperationException("Tool call has no name.");
var arguments = call.TryGetProperty("arguments", out var value) ? value : default;
object payload = name switch
{
"fleet_list_agents" => fleet.ListAgents(),
"fleet_start_agent" => await fleet.StartAgentAsync(new FleetHost.AgentSpec(
Required(arguments, "name"), Required(arguments, "workspace"),
Optional(arguments, "role"), StringArray(arguments, "scopes"))),
"fleet_get_chat" => fleet.GetChat(Required(arguments, "agent")),
"fleet_assign_task" => Assign(Required(arguments, "agent"), Required(arguments, "prompt")),
"fleet_cancel_task" => await Cancel(Required(arguments, "agent")),
"fleet_compact_context" => Compact(Required(arguments, "agent"), Optional(arguments, "instructions")),
"fleet_reset_context" => await Reset(Required(arguments, "agent")),
"fleet_request_review" => Review(Required(arguments, "reviewer"), Required(arguments, "author"), Optional(arguments, "context")),
"fleet_stop_agent" => Stop(Required(arguments, "agent")),
_ => throw new InvalidOperationException($"Unknown KimiFleet tool '{name}'.")
};
return new { content = new[] { new { type = "text", text = JsonSerializer.Serialize(payload, Json) } } };
}
catch (Exception ex)
{
return new { content = new[] { new { type = "text", text = ex.Message } }, isError = true };
}
}
private object Assign(string agent, string prompt)
@@ -70,6 +84,24 @@ sealed class McpStdioServer(FleetHost fleet)
return new { accepted = true, reviewer, author };
}
private object Compact(string agent, string? instructions)
{
fleet.Compact(agent, instructions);
return new { accepted = true, agent, operation = "compact" };
}
private async Task<object> Cancel(string agent)
{
await fleet.Cancel(agent);
return new { accepted = true, agent, operation = "cancel" };
}
private async Task<object> Reset(string agent)
{
await fleet.ResetContext(agent);
return new { accepted = true, agent, operation = "reset" };
}
private object Stop(string agent)
{
fleet.Stop(agent);
@@ -98,7 +130,11 @@ sealed class McpStdioServer(FleetHost fleet)
[
Tool("fleet_list_agents", "List active Kimi K3 agents, their roles, scope locks and state.", new { type = "object", properties = new { } }),
Tool("fleet_start_agent", "Start a yolo Kimi K3 ACP agent with exclusive source scopes.", new { type = "object", required = new[] { "name", "workspace" }, properties = new { name = new { type = "string" }, workspace = new { type = "string" }, role = new { type = "string" }, scopes = new { type = "array", items = new { type = "string" } } } }),
Tool("fleet_get_chat", "Get the complete visible chat and tool-call transcript for an agent.", new { type = "object", required = new[] { "agent" }, properties = new { agent = new { type = "string" } } }),
Tool("fleet_assign_task", "Assign an autonomous task to an active Kimi agent.", new { type = "object", required = new[] { "agent", "prompt" }, properties = new { agent = new { type = "string" }, prompt = new { type = "string" } } }),
Tool("fleet_cancel_task", "Cancel the task currently running in a Kimi agent.", new { type = "object", required = new[] { "agent" }, properties = new { agent = new { type = "string" } } }),
Tool("fleet_compact_context", "Compact an agent context while preserving important decisions and active work.", new { type = "object", required = new[] { "agent" }, properties = new { agent = new { type = "string" }, instructions = new { type = "string" } } }),
Tool("fleet_reset_context", "Create a fresh ACP context for an idle agent without changing files or scope ownership.", new { type = "object", required = new[] { "agent" }, properties = new { agent = new { type = "string" } } }),
Tool("fleet_request_review", "Ask a second agent for read-only peer review of an author's current diff.", new { type = "object", required = new[] { "reviewer", "author" }, properties = new { reviewer = new { type = "string" }, author = new { type = "string" }, context = new { type = "string" } } }),
Tool("fleet_stop_agent", "Stop an agent and release its exclusive scopes.", new { type = "object", required = new[] { "agent" }, properties = new { agent = new { type = "string" } } })
];
+375 -29
View File
@@ -22,22 +22,24 @@ if (options.EnableMcp)
else
await webTask;
sealed record FleetOptions(int Port, string KimiExecutable, string Model, bool EnableMcp)
sealed record FleetOptions(int Port, string KimiExecutable, string Model, int MaxContextTokens, bool EnableMcp)
{
public static FleetOptions Parse(string[] args)
{
var port = 7373;
var kimi = "kimi";
var model = "kimi-code/k3";
var maxContextTokens = 256_000;
var enableMcp = false;
for (var i = 0; i < args.Length; i++)
{
if (args[i] == "--port" && i + 1 < args.Length && int.TryParse(args[++i], out var value)) port = value;
else if (args[i] == "--kimi" && i + 1 < args.Length) kimi = args[++i];
else if (args[i] == "--model" && i + 1 < args.Length) model = args[++i];
else if (args[i] == "--context-size" && i + 1 < args.Length && int.TryParse(args[++i], out var contextSize) && contextSize > 0) maxContextTokens = contextSize;
else if (args[i] == "--mcp") enableMcp = true;
}
return new FleetOptions(port, kimi, model, enableMcp);
return new FleetOptions(port, kimi, model, maxContextTokens, enableMcp);
}
}
@@ -47,6 +49,7 @@ sealed class FleetHost : IDisposable
private readonly HttpListener _listener = new();
private readonly ConcurrentDictionary<string, KimiAgent> _agents = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _scopeOwners = new(StringComparer.OrdinalIgnoreCase);
private readonly object _agentsLock = new();
private readonly ConcurrentDictionary<Guid, Channel<string>> _eventSubscribers = new();
private readonly List<string> _recentEvents = [];
private readonly object _eventsLock = new();
@@ -82,6 +85,8 @@ sealed class FleetHost : IDisposable
await RespondJsonAsync(context, new { ok = true, model = _options.Model });
else if (context.Request.HttpMethod == "GET" && path == "agents")
await RespondJsonAsync(context, _agents.Values.Select(a => a.Snapshot()));
else if (context.Request.HttpMethod == "GET" && parts.Length == 3 && parts[0] == "agents" && parts[2] == "chat")
await RespondJsonAsync(context, GetChat(parts[1]));
else if (context.Request.HttpMethod == "GET" && path == "events")
await StreamEventsAsync(context);
else if (context.Request.HttpMethod == "POST" && path == "agents")
@@ -92,6 +97,12 @@ sealed class FleetHost : IDisposable
await ReviewAsync(context, parts[1]);
else if (context.Request.HttpMethod == "POST" && parts.Length == 3 && parts[0] == "agents" && parts[2] == "stop")
await StopAgentAsync(context, parts[1]);
else if (context.Request.HttpMethod == "POST" && parts.Length == 3 && parts[0] == "agents" && parts[2] == "cancel")
await CancelAgentAsync(context, parts[1]);
else if (context.Request.HttpMethod == "POST" && parts.Length == 4 && parts[0] == "agents" && parts[2] == "context" && parts[3] == "compact")
await CompactContextAsync(context, parts[1]);
else if (context.Request.HttpMethod == "POST" && parts.Length == 4 && parts[0] == "agents" && parts[2] == "context" && parts[3] == "reset")
await ResetContextAsync(context, parts[1]);
else
await RespondJsonAsync(context, new { error = "Unknown endpoint." }, 404);
}
@@ -130,24 +141,56 @@ sealed class FleetHost : IDisposable
await RespondJsonAsync(context, new { stopped = name });
}
private async Task CancelAgentAsync(HttpListenerContext context, string name)
{
await Cancel(name);
await RespondJsonAsync(context, new { accepted = true, agent = name, operation = "cancel" });
}
private async Task CompactContextAsync(HttpListenerContext context, string name)
{
var request = await ReadJsonAsync<CompactContextRequest>(context);
Compact(name, request.Instructions);
await RespondJsonAsync(context, new { accepted = true, agent = name, operation = "compact" });
}
private async Task ResetContextAsync(HttpListenerContext context, string name)
{
await ResetContext(name);
await RespondJsonAsync(context, new { accepted = true, agent = name, operation = "reset" });
}
public async Task<object> StartAgentAsync(AgentSpec spec)
{
var name = ValidateName(spec.Name);
var workspace = Path.GetFullPath(spec.Workspace);
if (!Directory.Exists(workspace)) throw new InvalidOperationException($"Workspace does not exist: {workspace}");
if (_agents.ContainsKey(name)) throw new InvalidOperationException($"Agent '{name}' already exists.");
var scopes = spec.Scopes?.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() ?? [];
foreach (var scope in scopes)
if (_scopeOwners.TryGetValue(scope, out var owner)) throw new InvalidOperationException($"Scope '{scope}' is owned by '{owner}'.");
var agent = new KimiAgent(name, workspace, spec.Role ?? "worker", scopes, _options, Publish);
if (!_agents.TryAdd(name, agent)) throw new InvalidOperationException($"Could not add '{name}'.");
foreach (var scope in scopes) _scopeOwners[scope] = name;
var scopes = NormalizeScopes(workspace, spec.Scopes);
KimiAgent agent;
lock (_agentsLock)
{
if (_agents.ContainsKey(name)) throw new InvalidOperationException($"Agent '{name}' already exists.");
foreach (var scope in scopes)
{
var scopePath = Path.GetFullPath(Path.Combine(workspace, scope));
var conflict = _scopeOwners.FirstOrDefault(pair => PathsOverlap(scopePath, pair.Key));
if (!string.IsNullOrEmpty(conflict.Key))
throw new InvalidOperationException($"Scope '{scope}' overlaps a scope owned by '{conflict.Value}'.");
}
agent = new KimiAgent(name, workspace, spec.Role ?? "worker", scopes, _options, Publish);
if (!_agents.TryAdd(name, agent)) throw new InvalidOperationException($"Could not add '{name}'.");
foreach (var scope in scopes) _scopeOwners[Path.GetFullPath(Path.Combine(workspace, scope))] = name;
}
try { await agent.StartAsync(); return agent.Snapshot(); }
catch { RemoveAgent(name); throw; }
}
public IReadOnlyList<object> ListAgents() => _agents.Values.Select(a => a.Snapshot()).Cast<object>().ToArray();
public IReadOnlyList<object> GetChat(string name) => GetAgent(name).ChatSnapshot();
public void Assign(string name, string prompt) => _ = GetAgent(name).PromptAsync(prompt);
public void Compact(string name, string? instructions) => _ = GetAgent(name).CompactContextAsync(instructions);
public Task Cancel(string name) => GetAgent(name).CancelAsync();
public Task ResetContext(string name) => GetAgent(name).ResetContextAsync();
public void Review(string reviewerName, string authorName, string? context)
{
var reviewer = GetAgent(reviewerName);
@@ -161,12 +204,40 @@ sealed class FleetHost : IDisposable
private void RemoveAgent(string name)
{
if (!_agents.TryRemove(name, out var agent)) return;
foreach (var scope in agent.Scopes) _scopeOwners.TryRemove(scope, out _);
KimiAgent? agent;
lock (_agentsLock)
{
if (!_agents.TryRemove(name, out agent)) return;
foreach (var scope in agent.Scopes)
_scopeOwners.TryRemove(Path.GetFullPath(Path.Combine(agent.Workspace, scope)), out _);
}
agent.Dispose();
Publish(name, "stopped", "Agent stopped.");
}
private static string[] NormalizeScopes(string workspace, string[]? requestedScopes)
{
if (requestedScopes is null) return [];
var scopes = new List<string>();
foreach (var requested in requestedScopes)
{
if (string.IsNullOrWhiteSpace(requested)) continue;
var fullPath = Path.GetFullPath(Path.Combine(workspace, requested.Trim()));
if (!IsWithin(fullPath, workspace)) throw new InvalidOperationException($"Scope escaped the workspace: {requested}");
var relative = Path.GetRelativePath(workspace, fullPath);
if (!scopes.Contains(relative, StringComparer.OrdinalIgnoreCase)) scopes.Add(relative);
}
return scopes.ToArray();
}
private static bool PathsOverlap(string left, string right) => IsWithin(left, right) || IsWithin(right, left);
private static bool IsWithin(string path, string directory)
{
var relative = Path.GetRelativePath(directory, path);
return relative != ".." && !relative.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal);
}
private static string ValidateName(string? name)
{
if (string.IsNullOrWhiteSpace(name) || name.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_'))
@@ -207,7 +278,12 @@ sealed class FleetHost : IDisposable
context.Response.SendChunked = true;
context.Response.Headers.Add("Cache-Control", "no-cache");
var id = Guid.NewGuid();
var channel = Channel.CreateUnbounded<string>();
var channel = Channel.CreateBounded<string>(new BoundedChannelOptions(256)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
_eventSubscribers[id] = channel;
try
{
@@ -275,6 +351,7 @@ sealed class FleetHost : IDisposable
private sealed record StartAgentRequest(string Name, string Workspace, string? Role, string[]? Scopes);
private sealed record PromptRequest(string Prompt);
private sealed record ReviewRequest(string Author, string? Context);
private sealed record CompactContextRequest(string? Instructions);
}
sealed class KimiAgent : IDisposable
@@ -284,8 +361,20 @@ sealed class KimiAgent : IDisposable
private readonly Process _process;
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonElement>> _pending = new();
private readonly SemaphoreSlim _stdinLock = new(1, 1);
private readonly SemaphoreSlim _promptGate = new(1, 1);
private readonly List<AgentChatEntry> _chat = [];
private readonly Dictionary<string, AgentChatEntry> _toolEntries = [];
private readonly object _chatLock = new();
private readonly object _promptLifecycleLock = new();
private long _requestId;
private long _chatId;
private long _estimatedTokens;
private int _queuedPrompts;
private int _cancellationRequested;
private string? _sessionId;
private string? _assistantEntryId;
private string? _thoughtEntryId;
private bool _resettingContext;
private bool _disposed;
public string Name { get; }
@@ -328,7 +417,7 @@ sealed class KimiAgent : IDisposable
{
SetActivity("Initializing ACP session");
if (!_process.Start()) throw new InvalidOperationException($"Could not start Kimi process for '{Name}'.");
_process.Exited += (_, _) => { State = "stopped"; _publish(Name, "exit", $"Kimi exited with {_process.ExitCode}."); };
_process.Exited += (_, _) => HandleProcessExit();
_ = ReadOutputAsync();
_ = ReadErrorAsync();
var initialized = await RequestAsync("initialize", new
@@ -342,29 +431,108 @@ sealed class KimiAgent : IDisposable
_sessionId = session.GetProperty("sessionId").GetString() ?? throw new InvalidOperationException("ACP did not return sessionId.");
State = "ready";
SetActivity("Ready for a task");
AddChat("system", $"Kimi K3 session ready · {Role}");
_publish(Name, "ready", $"Role={Role}; scopes={string.Join(", ", Scopes)}");
}
public async Task PromptAsync(string prompt)
public Task PromptAsync(string prompt) => QueuePromptAsync(prompt);
private Task<bool> QueuePromptAsync(string prompt)
{
if (string.IsNullOrWhiteSpace(prompt)) throw new InvalidOperationException("Prompt must not be empty.");
if (_sessionId is null) throw new InvalidOperationException($"Agent '{Name}' has no ACP session.");
State = "working";
SetActivity("Planning assigned task");
_publish(Name, "prompt", prompt.Length > 160 ? prompt[..160] + "…" : prompt);
int queuedCount;
lock (_promptLifecycleLock)
{
if (_resettingContext) throw new InvalidOperationException("Cannot queue a prompt while context is being reset.");
AddChat("user", prompt);
AddEstimatedTokens(prompt);
queuedCount = Interlocked.Increment(ref _queuedPrompts);
}
return RunPromptAsync(prompt, queuedCount);
}
private async Task<bool> RunPromptAsync(string prompt, int queuedCount)
{
if (_promptGate.CurrentCount == 0) _publish(Name, "queued", $"Task queued at position {queuedCount}.");
await _promptGate.WaitAsync();
Interlocked.Decrement(ref _queuedPrompts);
try
{
State = "working";
SetActivity("Planning assigned task");
_publish(Name, "prompt", prompt.Length > 160 ? prompt[..160] + "…" : prompt);
await RequestAsync("session/prompt", new { sessionId = _sessionId, prompt = new[] { new { type = "text", text = prompt } } });
State = "ready";
SetActivity("Ready for a task");
_publish(Name, "completed", "Prompt completed.");
var cancelled = Interlocked.Exchange(ref _cancellationRequested, 0) == 1;
_publish(Name, cancelled ? "cancelled" : "completed", cancelled ? "Task cancelled." : "Prompt completed.");
return true;
}
catch (Exception ex)
{
Interlocked.Exchange(ref _cancellationRequested, 0);
State = "error";
SetActivity("Needs attention");
AddChat("error", ex.Message);
_publish(Name, "error", ex.Message);
return false;
}
finally { _promptGate.Release(); }
}
public async Task CompactContextAsync(string? instructions)
{
var command = string.IsNullOrWhiteSpace(instructions) ? "/compact" : $"/compact {instructions.Trim()}";
if (await QueuePromptAsync(command))
{
Interlocked.Exchange(ref _estimatedTokens, Math.Min(Interlocked.Read(ref _estimatedTokens), _options.MaxContextTokens / 4));
AddChat("system", "Context compacted");
_publish(Name, "context", "Context compacted.");
}
}
public async Task ResetContextAsync()
{
if (!await _promptGate.WaitAsync(0)) throw new InvalidOperationException("Cannot reset context while the agent is working.");
try
{
lock (_promptLifecycleLock)
{
if (Volatile.Read(ref _queuedPrompts) > 0) throw new InvalidOperationException("Cannot reset context while prompts are queued.");
_resettingContext = true;
}
try
{
var session = await RequestAsync("session/new", new { cwd = Workspace, mcpServers = Array.Empty<object>() });
_sessionId = session.GetProperty("sessionId").GetString() ?? throw new InvalidOperationException("ACP did not return sessionId.");
lock (_chatLock)
{
_chat.Clear();
_toolEntries.Clear();
_assistantEntryId = null;
_thoughtEntryId = null;
}
Interlocked.Exchange(ref _estimatedTokens, 0);
State = "ready";
SetActivity("Ready for a task");
AddChat("system", "Fresh Kimi K3 context created");
_publish(Name, "context", "Context reset.");
}
finally { lock (_promptLifecycleLock) _resettingContext = false; }
}
finally { _promptGate.Release(); }
}
public async Task CancelAsync()
{
if (_sessionId is null) throw new InvalidOperationException($"Agent '{Name}' has no ACP session.");
if (State != "working") throw new InvalidOperationException($"Agent '{Name}' is not working.");
await SendRawAsync(JsonSerializer.Serialize(new { jsonrpc = "2.0", method = "session/cancel", @params = new { sessionId = _sessionId } }));
Interlocked.Exchange(ref _cancellationRequested, 1);
SetActivity("Cancelling current task");
AddChat("system", "Cancellation requested");
_publish(Name, "cancel", "Cancellation requested.");
}
private async Task<JsonElement> RequestAsync(string method, object parameters)
@@ -373,11 +541,15 @@ sealed class KimiAgent : IDisposable
var completion = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
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);
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;
try
{
await SendRawAsync(envelope);
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;
}
finally { _pending.TryRemove(id, out _); }
}
private async Task ReadOutputAsync()
@@ -416,7 +588,10 @@ sealed class KimiAgent : IDisposable
return;
}
if (methodName == "session/update" && root.TryGetProperty("params", out var updateParams))
{
UpdateActivityFromSessionUpdate(updateParams);
UpdateChatFromSessionUpdate(updateParams);
}
_publish(Name, methodName, root.TryGetProperty("params", out var p) ? p.GetRawText() : string.Empty);
return;
}
@@ -446,6 +621,7 @@ sealed class KimiAgent : IDisposable
case "session/request_permission":
var optionId = SelectApproval(parameters);
SetActivity("Approving requested action");
AddChat("system", "Permission approved automatically");
_publish(Name, "permission", $"Auto-approved '{optionId}' (yolo mode).");
result = new { outcome = new { outcome = "selected", optionId } };
break;
@@ -453,7 +629,9 @@ sealed class KimiAgent : IDisposable
var readPath = ResolveWorkspacePath(parameters);
SetActivity($"Reading {Path.GetFileName(readPath)}");
_publish(Name, "file", $"Reading {Path.GetRelativePath(Workspace, readPath)}");
result = new { content = await File.ReadAllTextAsync(readPath) };
var fileContent = await File.ReadAllTextAsync(readPath);
AddEstimatedTokens(fileContent);
result = new { content = fileContent };
break;
case "fs/write_text_file":
var writePath = ResolveWorkspacePath(parameters);
@@ -462,6 +640,7 @@ sealed class KimiAgent : IDisposable
throw new InvalidOperationException("ACP write request has no text content.");
Directory.CreateDirectory(Path.GetDirectoryName(writePath)!);
await File.WriteAllTextAsync(writePath, contentNode.GetString()!);
AddEstimatedTokens(contentNode.GetString()!);
SetActivity($"Editing {Path.GetFileName(writePath)}");
_publish(Name, "file", $"Updated {Path.GetRelativePath(Workspace, writePath)}");
result = new { };
@@ -506,13 +685,135 @@ sealed class KimiAgent : IDisposable
if (!parameters.TryGetProperty("update", out var update) || !update.TryGetProperty("sessionUpdate", out var typeNode)) return;
var type = typeNode.GetString();
if ((type == "tool_call" || type == "tool_call_update") && update.TryGetProperty("title", out var title) && !string.IsNullOrWhiteSpace(title.GetString()))
SetActivity(title.GetString()!);
else if (type == "agent_thought_chunk" && !Activity.StartsWith("Reading", StringComparison.OrdinalIgnoreCase) && !Activity.StartsWith("Running", StringComparison.OrdinalIgnoreCase))
{
var status = update.TryGetProperty("status", out var statusNode) ? statusNode.GetString() : null;
SetActivity(status is "completed" or "failed" ? "Reviewing tool result" : title.GetString()!);
}
else if (type == "agent_thought_chunk")
SetActivity("Analyzing task");
else if (type == "agent_message_chunk")
SetActivity("Preparing a report");
}
private void UpdateChatFromSessionUpdate(JsonElement parameters)
{
if (!parameters.TryGetProperty("update", out var update) || !update.TryGetProperty("sessionUpdate", out var typeNode)) return;
var type = typeNode.GetString();
if (type == "agent_message_chunk")
{
var text = ExtractText(update.TryGetProperty("content", out var content) ? content : default);
AppendChatChunk("assistant", text, ref _assistantEntryId);
AddEstimatedTokens(text);
}
else if (type == "agent_thought_chunk")
{
var text = ExtractText(update.TryGetProperty("content", out var content) ? content : default);
AppendChatChunk("thought", text, ref _thoughtEntryId);
AddEstimatedTokens(text);
}
else if (type is "tool_call" or "tool_call_update")
{
UpsertToolEntry(update);
_assistantEntryId = null;
_thoughtEntryId = null;
}
}
private void UpsertToolEntry(JsonElement update)
{
if (!update.TryGetProperty("toolCallId", out var idNode) || string.IsNullOrWhiteSpace(idNode.GetString())) return;
var toolCallId = idNode.GetString()!;
lock (_chatLock)
{
if (!_toolEntries.TryGetValue(toolCallId, out var entry))
{
entry = NewChatEntry("tool", string.Empty);
entry.ToolCallId = toolCallId;
_toolEntries[toolCallId] = entry;
AddChatEntryLocked(entry);
}
if (update.TryGetProperty("title", out var title) && !string.IsNullOrWhiteSpace(title.GetString())) entry.Title = title.GetString();
if (update.TryGetProperty("status", out var status) && !string.IsNullOrWhiteSpace(status.GetString())) entry.Status = status.GetString();
if (update.TryGetProperty("kind", out var kind) && !string.IsNullOrWhiteSpace(kind.GetString())) entry.ToolKind = kind.GetString();
if (update.TryGetProperty("content", out var content))
{
var text = ExtractText(content);
if (!string.IsNullOrWhiteSpace(text)) entry.Text = LimitText(text, 12_000);
}
}
}
private void AppendChatChunk(string type, string text, ref string? activeEntryId)
{
if (string.IsNullOrEmpty(text)) return;
lock (_chatLock)
{
AgentChatEntry? entry = null;
if (activeEntryId is not null)
{
for (var i = _chat.Count - 1; i >= 0; i--)
if (_chat[i].Id == activeEntryId) { entry = _chat[i]; break; }
}
if (entry is null)
{
entry = NewChatEntry(type, string.Empty);
AddChatEntryLocked(entry);
activeEntryId = entry.Id;
}
entry.Text = LimitText(entry.Text + text, 48_000);
}
}
private void AddChat(string type, string text)
{
lock (_chatLock)
{
AddChatEntryLocked(NewChatEntry(type, LimitText(text, 48_000)));
if (type == "user")
{
_assistantEntryId = null;
_thoughtEntryId = null;
}
}
}
private AgentChatEntry NewChatEntry(string type, string text) => new()
{
Id = $"{Name}-{Interlocked.Increment(ref _chatId)}",
Time = DateTimeOffset.UtcNow,
Type = type,
Text = text
};
private void AddChatEntryLocked(AgentChatEntry entry)
{
_chat.Add(entry);
while (_chat.Count > 500)
{
var removed = _chat[0];
_chat.RemoveAt(0);
if (removed.ToolCallId is not null) _toolEntries.Remove(removed.ToolCallId);
}
}
private static string ExtractText(JsonElement value)
{
if (value.ValueKind == JsonValueKind.Undefined || value.ValueKind == JsonValueKind.Null) return string.Empty;
if (value.ValueKind == JsonValueKind.String) return value.GetString() ?? string.Empty;
if (value.ValueKind == JsonValueKind.Array) return string.Concat(value.EnumerateArray().Select(ExtractText));
if (value.ValueKind != JsonValueKind.Object) return string.Empty;
if (value.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String) return text.GetString() ?? string.Empty;
if (value.TryGetProperty("content", out var content)) return ExtractText(content);
return string.Empty;
}
private void AddEstimatedTokens(string text)
{
if (!string.IsNullOrEmpty(text)) Interlocked.Add(ref _estimatedTokens, Math.Max(1, text.Length / 4));
}
private static string LimitText(string text, int maxLength) => text.Length <= maxLength ? text : $"…{text[^maxLength..]}";
private void SetActivity(string value)
{
Activity = value;
@@ -551,14 +852,59 @@ sealed class KimiAgent : IDisposable
finally { _stdinLock.Release(); }
}
public object Snapshot() => new { name = Name, workspace = Workspace, role = Role, scopes = Scopes, state = State, activity = Activity, activityUpdatedAt = ActivityUpdatedAt, sessionId = _sessionId };
public IReadOnlyList<object> ChatSnapshot()
{
lock (_chatLock) return _chat.Select(entry => entry.Snapshot()).ToArray();
}
public object Snapshot()
{
var estimatedTokens = Interlocked.Read(ref _estimatedTokens);
return new
{
name = Name,
workspace = Workspace,
role = Role,
scopes = Scopes,
state = State,
activity = Activity,
activityUpdatedAt = ActivityUpdatedAt,
queueDepth = Volatile.Read(ref _queuedPrompts),
contextTokens = estimatedTokens,
maxContextTokens = _options.MaxContextTokens,
contextPercent = Math.Round(Math.Min(100d, estimatedTokens * 100d / _options.MaxContextTokens), 1),
sessionId = _sessionId
};
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { if (!_process.HasExited) _process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
_stdinLock.Dispose();
FailPending(new ObjectDisposedException(Name, "Agent stopped."));
try
{
if (!_process.HasExited) _process.Kill(entireProcessTree: true);
_process.WaitForExit(2_000);
}
catch (InvalidOperationException) { }
_process.Dispose();
}
private void HandleProcessExit()
{
State = "stopped";
SetActivity("Kimi process stopped");
var exitCode = 0;
try { exitCode = _process.ExitCode; } catch (Exception) when (_disposed) { }
var error = new InvalidOperationException($"Kimi process exited with code {exitCode}.");
FailPending(error);
_publish(Name, "exit", error.Message);
}
private void FailPending(Exception error)
{
foreach (var request in _pending.ToArray())
if (_pending.TryRemove(request.Key, out var completion)) completion.TrySetException(error);
}
}
+15 -4
View File
@@ -14,7 +14,14 @@ Open a second terminal and watch live activity:
curl -N http://127.0.0.1:7373/events
```
Open `http://127.0.0.1:7373` for the dashboard: it shows agents, their scoped ownership, state and a human-readable live activity feed.
Open `http://127.0.0.1:7373` for the cockpit. Every agent has its own Kimi-style chat, tool-call transcript, task composer, live activity, queue depth, scope ownership and estimated K3 context usage. The grid supports search, compact density and a single-agent focus mode.
Context controls are available on every card:
- **Compact** asks Kimi to preserve decisions, constraints, changed files and pending verification while reducing context pressure.
- **Fresh context** creates a new ACP session without changing files, the agent process or its scope locks.
- **Cancel** interrupts the current turn without stopping the instance or discarding its context.
- New prompts are queued per agent, so one instance cannot receive overlapping `session/prompt` calls.
## MCP mode
@@ -24,7 +31,7 @@ Start both the dashboard and an MCP stdio server:
dotnet run --project KimiFleet.csproj -- --mcp --port 7373
```
The server implements `fleet_list_agents`, `fleet_start_agent`, `fleet_assign_task`, `fleet_request_review`, and `fleet_stop_agent`. In MCP mode stdout is protocol-only; operational logs are written to stderr and remain visible in the dashboard.
The server implements `fleet_list_agents`, `fleet_start_agent`, `fleet_get_chat`, `fleet_assign_task`, `fleet_cancel_task`, `fleet_compact_context`, `fleet_reset_context`, `fleet_request_review`, and `fleet_stop_agent`. In MCP mode stdout is protocol-only; operational logs are written to stderr and remain visible in the dashboard.
## API
@@ -52,6 +59,10 @@ curl -X POST http://127.0.0.1:7373/agents/reviewer/review \
-d '{"author":"vulkan","context":"Review the Vulkan change after its tests finish."}'
```
Useful endpoints: `GET /health`, `GET /agents`, `GET /events`, `POST /agents/{name}/stop`.
Useful endpoints:
All ACP children launch as `kimi --yolo --model kimi-code/k3 acp`. Scope locks prevent two fleet agents from being assigned the same area, but they do not replace a final `git status` check before edits.
- `GET /health`, `GET /agents`, `GET /agents/{name}/chat`, `GET /events`
- `POST /agents`, `POST /agents/{name}/prompt`, `POST /agents/{name}/cancel`, `POST /agents/{name}/review`, `POST /agents/{name}/stop`
- `POST /agents/{name}/context/compact`, `POST /agents/{name}/context/reset`
All ACP children launch as `kimi --yolo --model kimi-code/k3 acp`. Scope locks reject exact and nested overlaps in the same workspace. They coordinate ownership; yolo agents can still run shell commands, so the locks do not replace peer review or a final `git status` check before edits.