-
-
-

Activity

-
- -
-
- - Live -
+ +
+
+ +
+
+
Commands
+
+
+ + + + + +
+
+
/broadcast
+
Send message to all agents
+
+
+
+
+ + + + +
+
+
/status
+
Set your status message
-
- +
+
+
+ + + +
+
+
/clear
+
Clear message view
+
+
-
-
-
-
-
- - -
-
- Ctrl + Enter to send -
-
-
-
- -
- -
-
-
+
+
Channels
+
+
+ + + + + + +
+
+
#general
+
All agent communications
+
+
+
+
Jump to Agent
+ +
+ +
- + +
+
+
+
+ + + + Thread + +
+ +
+
+ +
+
+ + +
+
+
+ + +
+
+
+
+ + + + + + + Spawn New Agent +
+ +
+
+
+ + +
+
+ + + The AI CLI tool to wrap +
+
+ + + This will be injected into the agent's terminal +
+
+
+ +
+
+ + + diff --git a/src/dashboard/public/js/app.js b/src/dashboard/public/js/app.js new file mode 100644 index 000000000..cfe9d7e31 --- /dev/null +++ b/src/dashboard/public/js/app.js @@ -0,0 +1,140 @@ +var i={agents:[],messages:[],currentChannel:"general",currentThread:null,isConnected:!1,ws:null,reconnectAttempts:0},E=[];function V(t){return E.push(t),()=>{let e=E.indexOf(t);e>-1&&E.splice(e,1)}}function w(){E.forEach(t=>t())}function F(t){i.agents=t,w()}function U(t){i.messages=t,w()}function z(t){i.currentChannel=t,w()}function x(t){i.isConnected=t,t&&(i.reconnectAttempts=0),w()}function W(){i.reconnectAttempts++}function _(t){i.ws=t}function J(){let{messages:t,currentChannel:e}=i;return e==="general"?t:t.filter(n=>n.from===e||n.to===e)}function A(t){i.currentThread=t}function Q(t){return i.messages.filter(e=>e.thread===t)}function Y(t){return i.messages.filter(e=>e.thread===t).length}var G=null;function I(){let t=window.location.protocol==="https:"?"wss:":"ws:",e=new WebSocket(`${t}//${window.location.host}/ws`);e.onopen=()=>{x(!0)},e.onclose=()=>{x(!1);let n=Math.min(1e3*Math.pow(2,i.reconnectAttempts),3e4);W(),setTimeout(I,n)},e.onerror=n=>{console.error("WebSocket error:",n)},e.onmessage=n=>{try{let s=JSON.parse(n.data);he(s)}catch(s){console.error("Failed to parse message:",s)}},_(e)}function he(t){console.log("[WS] Received data:",{agentCount:t.agents?.length,messageCount:t.messages?.length}),t.agents&&(console.log("[WS] Setting agents:",t.agents.map(e=>e.name)),F(t.agents)),t.messages&&U(t.messages),G&&G(t)}async function T(t,e,n){try{let s={to:t,message:e};n&&(s.thread=n);let o=await fetch("/api/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)}),r=await o.json();return o.ok&&r.success?{success:!0}:{success:!1,error:r.error||"Failed to send message"}}catch{return{success:!1,error:"Network error - could not send message"}}}function L(t){if(!t)return!1;let e=Date.parse(t);return Number.isNaN(e)?!1:Date.now()-e<3e4}function l(t){if(!t)return"";let e=document.createElement("div");return e.textContent=t,e.innerHTML}function C(t){return new Date(t).toLocaleTimeString([],{hour:"numeric",minute:"2-digit"})}function X(t){let e=new Date(t),n=new Date,s=new Date(n);return s.setDate(s.getDate()-1),e.toDateString()===n.toDateString()?"Today":e.toDateString()===s.toDateString()?"Yesterday":e.toLocaleDateString([],{weekday:"long",month:"long",day:"numeric"})}function h(t){let e=["#e01e5a","#2bac76","#e8a427","#1264a3","#7c3aed","#0d9488","#dc2626","#9333ea","#ea580c","#0891b2"],n=0;for(let s=0;s$1"),e=e.replace(/`([^`]+)`/g,"$1"),e=e.replace(/\n/g,"
"),e}var ee=[],a,c=-1;function te(){return a={connectionDot:document.getElementById("connection-dot"),channelsList:document.getElementById("channels-list"),agentsList:document.getElementById("agents-list"),messagesList:document.getElementById("messages-list"),currentChannelName:document.getElementById("current-channel-name"),channelTopic:document.getElementById("channel-topic"),onlineCount:document.getElementById("online-count"),messageInput:document.getElementById("message-input"),sendBtn:document.getElementById("send-btn"),boldBtn:document.getElementById("bold-btn"),emojiBtn:document.getElementById("emoji-btn"),searchTrigger:document.getElementById("search-trigger"),commandPaletteOverlay:document.getElementById("command-palette-overlay"),paletteSearch:document.getElementById("palette-search"),paletteResults:document.getElementById("palette-results"),paletteChannelsSection:document.getElementById("palette-channels-section"),paletteAgentsSection:document.getElementById("palette-agents-section"),paletteMessagesSection:document.getElementById("palette-messages-section"),typingIndicator:document.getElementById("typing-indicator"),threadPanelOverlay:document.getElementById("thread-panel-overlay"),threadPanelId:document.getElementById("thread-panel-id"),threadPanelClose:document.getElementById("thread-panel-close"),threadMessages:document.getElementById("thread-messages"),threadMessageInput:document.getElementById("thread-message-input"),threadSendBtn:document.getElementById("thread-send-btn"),mentionAutocomplete:document.getElementById("mention-autocomplete"),mentionAutocompleteList:document.getElementById("mention-autocomplete-list"),spawnBtn:document.getElementById("spawn-btn"),spawnModalOverlay:document.getElementById("spawn-modal-overlay"),spawnModalClose:document.getElementById("spawn-modal-close"),spawnNameInput:document.getElementById("spawn-name-input"),spawnCliInput:document.getElementById("spawn-cli-input"),spawnTaskInput:document.getElementById("spawn-task-input"),spawnSubmitBtn:document.getElementById("spawn-submit-btn"),spawnStatus:document.getElementById("spawn-status")},a}function H(){return a}function ne(){i.isConnected?a.connectionDot.classList.remove("offline"):a.connectionDot.classList.add("offline")}function $(){console.log("[UI] renderAgents called, agents:",i.agents.length,i.agents.map(n=>n.name));let t=new Set(ee.map(n=>n.name)),e=i.agents.map(n=>{let o=L(n.lastSeen||n.lastActive)?"online":"",r=i.currentChannel===n.name,d=n.needsAttention?"needs-attention":"",p=t.has(n.name),S=p?` + + + + `:"",ge=p?` + + `:"";return` +
  • +
    + ${v(n.name)} + +
    + ${l(n.name)} + ${S} + ${n.needsAttention?'Needs Input':""} + ${ge} +
  • + `}).join("");a.agentsList.innerHTML=e||'
  • No agents connected
  • ',a.agentsList.querySelectorAll(".channel-item[data-agent]").forEach(n=>{n.addEventListener("click",s=>{if(s.target.closest(".release-btn"))return;let o=n.dataset.agent;o&&g(o)})}),a.agentsList.querySelectorAll(".release-btn[data-release]").forEach(n=>{n.addEventListener("click",async s=>{s.stopPropagation();let o=n.dataset.release;o&&confirm(`Release agent "${o}"? This will terminate the agent.`)&&await Ee(o)})}),ve()}function D(){let t=J();if(t.length===0){a.messagesList.innerHTML=` +
    + + + +
    No messages yet
    +
    + ${i.currentChannel==="general"?"Messages between agents will appear here":`Messages with ${i.currentChannel} will appear here`} +
    +
    + `;return}let e="",n=null;t.forEach(s=>{let o=new Date(s.timestamp).toDateString();o!==n&&(e+=` +
    + ${X(s.timestamp)} +
    + `,n=o);let r=s.to==="*",d=h(s.from),p=Y(s.id),S=r?"@everyone":s.project?`${l(s.project)}@${l(s.to)}`:`@${l(s.to)}`;e+=` +
    +
    + ${v(s.from)} +
    +
    +
    + @${l(s.from)} + + \u2192 ${S} + + ${C(s.timestamp)} +
    +
    ${k(s.content)}
    + ${s.thread?` +
    + + + + Thread: ${l(s.thread)} +
    + `:""} + ${p>0?` +
    + + + + ${p} ${p===1?"reply":"replies"} +
    + `:""} +
    +
    + + +
    +
    + `}),a.messagesList.innerHTML=e,ye()}function g(t){z(t),a.channelsList.querySelectorAll(".channel-item").forEach(n=>{n.classList.toggle("active",n.dataset.channel===t)}),a.agentsList.querySelectorAll(".channel-item").forEach(n=>{n.classList.toggle("active",n.dataset.agent===t)});let e=document.querySelector(".channel-header-name .prefix");if(t==="general")a.currentChannelName.innerHTML="general",a.channelTopic.textContent="All agent communications",e&&(e.textContent="#");else{a.currentChannelName.innerHTML=l(t);let n=i.agents.find(s=>s.name===t);a.channelTopic.textContent=n?.status||"Direct messages",e&&(e.textContent="@")}a.messageInput.placeholder=t==="general"?"@AgentName message... (or @* to broadcast)":`Message ${t}... (@ not required)`,D()}function se(){let t=i.agents.filter(e=>L(e.lastSeen||e.lastActive)).length;a.onlineCount.textContent=`${t} online`}function ve(){let t=i.agents.map(s=>{let o=L(s.lastSeen||s.lastActive);return` +
    +
    +
    + ${v(s.name)} + +
    +
    +
    +
    ${l(s.name)}
    +
    ${o?"Online":"Offline"}
    +
    +
    + `}).join(""),e=a.paletteAgentsSection;e.querySelectorAll(".palette-item").forEach(s=>s.remove()),e.insertAdjacentHTML("beforeend",t),e.querySelectorAll(".palette-item[data-jump-agent]").forEach(s=>{s.addEventListener("click",()=>{let o=s.dataset.jumpAgent;o&&(g(o),m())})})}function ae(){a.paletteChannelsSection.querySelectorAll(".palette-item[data-jump-channel]").forEach(t=>{t.addEventListener("click",()=>{let e=t.dataset.jumpChannel;e&&(g(e),m())})})}function P(){a.commandPaletteOverlay.classList.add("visible"),a.paletteSearch.value="",a.paletteSearch.focus(),c=-1,j("")}function oe(){return Array.from(a.paletteResults.querySelectorAll(".palette-item")).filter(e=>e.style.display!=="none")}function Z(){let t=oe();if(t.forEach(e=>e.classList.remove("selected")),c>=0&&c0?c-1:e.length-1,Z();break;case"Enter":t.preventDefault(),c>=0&&cr.classList.remove("highlighted"),2e3)),m();return}}function m(){a.commandPaletteOverlay.classList.remove("visible")}function j(t){let e=t.toLowerCase();if(c=-1,document.querySelectorAll(".palette-item[data-command]").forEach(n=>{let o=n.querySelector(".palette-item-title")?.textContent?.toLowerCase()||"";n.style.display=o.includes(e)?"flex":"none"}),document.querySelectorAll(".palette-item[data-jump-channel]").forEach(n=>{let o=n.querySelector(".palette-item-title")?.textContent?.toLowerCase()||"";n.style.display=o.includes(e)?"flex":"none"}),document.querySelectorAll(".palette-item[data-jump-agent]").forEach(n=>{let s=n.dataset.jumpAgent?.toLowerCase()||"";n.style.display=s.includes(e)?"flex":"none"}),e.length>=2){let n=i.messages.filter(s=>s.content.toLowerCase().includes(e)).slice(0,5);if(n.length>0){a.paletteMessagesSection.style.display="block";let s=n.map(r=>` +
    +
    + + + +
    +
    +
    ${l(r.from)}
    +
    ${l(r.content.substring(0,60))}${r.content.length>60?"...":""}
    +
    +
    + `).join("");a.paletteMessagesSection.querySelectorAll(".palette-item").forEach(r=>r.remove()),a.paletteMessagesSection.insertAdjacentHTML("beforeend",s)}else a.paletteMessagesSection.style.display="none"}else a.paletteMessagesSection.style.display="none"}function B(t){A(t),a.threadPanelId.textContent=t,a.threadPanelOverlay.classList.add("visible"),a.threadMessageInput.value="",N(t),a.threadMessageInput.focus()}function O(){A(null),a.threadPanelOverlay.classList.remove("visible")}function N(t){let e=Q(t);if(e.length===0){a.threadMessages.innerHTML=` +
    +

    No messages in this thread yet.

    +

    Start the conversation below!

    +
    + `;return}let n=e.map(s=>` +
    +
    +
    + ${v(s.from)} +
    + ${l(s.from)} + ${C(s.timestamp)} +
    +
    ${k(s.content)}
    +
    + `).join("");a.threadMessages.innerHTML=n,a.threadMessages.scrollTop=a.threadMessages.scrollHeight}function ye(){a.messagesList.querySelectorAll(".thread-indicator").forEach(t=>{t.style.cursor="pointer",t.addEventListener("click",e=>{e.stopPropagation();let n=t.dataset.thread;n&&B(n)})}),a.messagesList.querySelectorAll(".reply-count-badge").forEach(t=>{t.addEventListener("click",e=>{e.stopPropagation();let n=t.dataset.thread;n&&B(n)})}),a.messagesList.querySelectorAll('.message-action-btn[data-action="reply"]').forEach(t=>{t.addEventListener("click",e=>{e.stopPropagation();let n=t.closest(".message")?.getAttribute("data-id");n&&B(n)})})}var u=0,M=[];function ie(t){let e=t.toLowerCase();M=i.agents.filter(s=>s.name.toLowerCase().includes(e)),u=0;let n="";("*".includes(e)||"everyone".includes(e)||"all".includes(e)||"broadcast".includes(e))&&(n+=` +
    +
    *
    + @everyone + Broadcast to all +
    + `),M.forEach((s,o)=>{n+=` +
    +
    + ${v(s.name)} +
    + @${l(s.name)} + ${l(s.role||"Agent")} +
    + `}),n===""&&(n='
    No matching agents
    '),a.mentionAutocompleteList.innerHTML=n,a.mentionAutocomplete.classList.add("visible"),a.mentionAutocompleteList.querySelectorAll(".mention-autocomplete-item[data-mention]").forEach(s=>{s.addEventListener("click",()=>{let o=s.dataset.mention;o&&R(o)})})}function f(){a.mentionAutocomplete.classList.remove("visible"),M=[],u=0}function le(){return a.mentionAutocomplete.classList.contains("visible")}function q(t){let e=a.mentionAutocompleteList.querySelectorAll(".mention-autocomplete-item[data-mention]");e.length!==0&&(e[u]?.classList.remove("selected"),t==="down"?u=(u+1)%e.length:u=(u-1+e.length)%e.length,e[u]?.classList.add("selected"),e[u]?.scrollIntoView({block:"nearest"}))}function R(t){let e=a.mentionAutocompleteList.querySelectorAll(".mention-autocomplete-item[data-mention]"),n=t;if(!n&&e.length>0&&(n=e[u]?.dataset.mention),!n){f();return}let s=a.messageInput,o=s.value,r=o.match(/^@\S*/);if(r){let d=`@${n} `;s.value=d+o.substring(r[0].length),s.selectionStart=s.selectionEnd=d.length}f(),s.focus()}function ce(){let t=a.messageInput,e=t.value,n=t.selectionStart,s=e.match(/^@(\S*)/);return s&&n<=s[0].length?s[1]:null}function de(){a.spawnModalOverlay.classList.add("visible"),a.spawnNameInput.value="",a.spawnCliInput.value="claude",a.spawnTaskInput.value="",a.spawnStatus.textContent="",a.spawnStatus.className="spawn-status",a.spawnNameInput.focus()}function y(){a.spawnModalOverlay.classList.remove("visible")}async function K(){let t=a.spawnNameInput.value.trim(),e=a.spawnCliInput.value.trim()||"claude",n=a.spawnTaskInput.value.trim();if(!t)return a.spawnStatus.textContent="Agent name is required",a.spawnStatus.className="spawn-status error",{success:!1,error:"Agent name is required"};a.spawnSubmitBtn.disabled=!0,a.spawnStatus.textContent="Spawning agent...",a.spawnStatus.className="spawn-status loading";try{let s=await fetch("/api/spawn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t,cli:e,task:n})}),o=await s.json();if(s.ok&&o.success)return a.spawnStatus.textContent=`Agent "${t}" spawned successfully!`,a.spawnStatus.className="spawn-status success",await b(),setTimeout(()=>{y()},1e3),{success:!0};throw new Error(o.error||"Failed to spawn agent")}catch(s){return a.spawnStatus.textContent=s.message||"Failed to spawn agent",a.spawnStatus.className="spawn-status error",{success:!1,error:s.message}}finally{a.spawnSubmitBtn.disabled=!1}}async function b(){try{let e=await(await fetch("/api/spawned")).json();e.success&&Array.isArray(e.agents)&&(ee=e.agents,$())}catch(t){console.error("[UI] Failed to fetch spawned agents:",t)}}async function Ee(t){try{let n=await(await fetch(`/api/spawned/${encodeURIComponent(t)}`,{method:"DELETE"})).json();n.success?await b():console.error("[UI] Failed to release agent:",n.error)}catch(e){console.error("[UI] Failed to release agent:",e)}}function ue(){let t=te();V(()=>{ne(),$(),D(),se()}),we(t),I(),b()}function we(t){t.channelsList.querySelectorAll(".channel-item").forEach(e=>{e.addEventListener("click",()=>{let n=e.dataset.channel;n&&g(n)})}),t.sendBtn.addEventListener("click",me),t.messageInput.addEventListener("keydown",e=>{if(le()){if(e.key==="Tab"||e.key==="Enter"){e.preventDefault(),R();return}if(e.key==="ArrowUp"){e.preventDefault(),q("up");return}if(e.key==="ArrowDown"){e.preventDefault(),q("down");return}if(e.key==="Escape"){e.preventDefault(),f();return}}e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),me())}),t.messageInput.addEventListener("input",()=>{t.messageInput.style.height="auto",t.messageInput.style.height=Math.min(t.messageInput.scrollHeight,200)+"px";let e=ce();e!==null?ie(e):f()}),t.messageInput.addEventListener("blur",()=>{setTimeout(()=>{f()},150)}),t.boldBtn.addEventListener("click",()=>{let e=t.messageInput,n=e.selectionStart,s=e.selectionEnd,o=e.value;if(n===s){let r=o.substring(0,n),d=o.substring(s);e.value=r+"**bold**"+d,e.selectionStart=n+2,e.selectionEnd=n+6}else{let r=o.substring(0,n),d=o.substring(n,s),p=o.substring(s);e.value=r+"**"+d+"**"+p,e.selectionStart=n,e.selectionEnd=s+4}e.focus()}),t.emojiBtn.addEventListener("click",()=>{let e=["\u{1F44D}","\u{1F44E}","\u2705","\u274C","\u{1F389}","\u{1F525}","\u{1F4A1}","\u26A0\uFE0F","\u{1F4DD}","\u{1F680}"],n=e[Math.floor(Math.random()*e.length)],s=t.messageInput,o=s.selectionStart,r=s.value;s.value=r.substring(0,o)+n+r.substring(o),s.selectionStart=s.selectionEnd=o+n.length,s.focus()}),t.searchTrigger.addEventListener("click",P),document.addEventListener("keydown",e=>{(e.ctrlKey||e.metaKey)&&e.key==="k"&&(e.preventDefault(),t.commandPaletteOverlay.classList.contains("visible")?m():P()),e.key==="Escape"&&m()}),t.commandPaletteOverlay.addEventListener("click",e=>{e.target===t.commandPaletteOverlay&&m()}),t.paletteSearch.addEventListener("input",e=>{let n=e.target;j(n.value)}),t.paletteSearch.addEventListener("keydown",re),document.querySelectorAll(".palette-item[data-command]").forEach(e=>{e.addEventListener("click",()=>{let n=e.dataset.command;n==="broadcast"?(t.messageInput.value="@* ",t.messageInput.focus()):n==="clear"&&(t.messagesList.innerHTML=""),m()})}),ae(),t.threadPanelClose.addEventListener("click",O),t.threadSendBtn.addEventListener("click",pe),t.threadMessageInput.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),pe())}),document.addEventListener("keydown",e=>{e.key==="Escape"&&t.threadPanelOverlay.classList.contains("visible")&&O()}),t.spawnBtn.addEventListener("click",de),t.spawnModalClose.addEventListener("click",y),document.getElementById("spawn-cancel-btn")?.addEventListener("click",y),t.spawnModalOverlay.addEventListener("click",e=>{e.target===t.spawnModalOverlay&&y()}),document.addEventListener("keydown",e=>{e.key==="Escape"&&t.spawnModalOverlay.classList.contains("visible")&&y()}),t.spawnSubmitBtn.addEventListener("click",K),t.spawnNameInput.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),K())})}function Le(t){let n=t.trim().match(/^@(\*|[^\s]+)\s+(.+)$/s);return n?{to:n[1],message:n[2].trim()}:null}async function me(){let t=H(),e=t.messageInput.value.trim();if(!e)return;let n,s,o=i.currentChannel!=="general",r=Le(e);if(r)n=r.to,s=r.message;else if(o)n=i.currentChannel,s=e;else{alert('Message must start with @recipient (e.g., "@Lead hello" or "@* broadcast")');return}t.sendBtn.disabled=!0;let d=await T(n,s);d.success?(t.messageInput.value="",t.messageInput.style.height="auto"):alert(d.error),t.sendBtn.disabled=!1}async function pe(){let t=H(),e=t.threadMessageInput.value.trim(),n=i.currentThread;if(!e||!n)return;t.threadSendBtn.disabled=!0;let s=await T("*",e,n);s.success?(t.threadMessageInput.value="",N(n)):alert(s.error),t.threadSendBtn.disabled=!1}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",ue):ue());export{ue as initApp}; +//# sourceMappingURL=app.js.map diff --git a/src/dashboard/public/js/app.js.map b/src/dashboard/public/js/app.js.map new file mode 100644 index 000000000..ecdebdb10 --- /dev/null +++ b/src/dashboard/public/js/app.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../frontend/state.ts", "../../frontend/websocket.ts", "../../frontend/utils.ts", "../../frontend/components.ts", "../../frontend/app.ts"], + "sourcesContent": ["/**\n * Dashboard State Management\n */\n\nimport type { Agent, Message, AppState, ChannelType } from './types.js';\n\n/**\n * Global application state\n */\nexport const state: AppState = {\n agents: [],\n messages: [],\n currentChannel: 'general',\n currentThread: null,\n isConnected: false,\n ws: null,\n reconnectAttempts: 0,\n};\n\n/**\n * State update callbacks\n */\ntype StateListener = () => void;\nconst listeners: StateListener[] = [];\n\n/**\n * Subscribe to state changes\n */\nexport function subscribe(listener: StateListener): () => void {\n listeners.push(listener);\n return () => {\n const index = listeners.indexOf(listener);\n if (index > -1) {\n listeners.splice(index, 1);\n }\n };\n}\n\n/**\n * Notify all listeners of state change\n */\nfunction notifyListeners(): void {\n listeners.forEach((listener) => listener());\n}\n\n/**\n * Update agents in state\n */\nexport function setAgents(agents: Agent[]): void {\n state.agents = agents;\n notifyListeners();\n}\n\n/**\n * Update messages in state\n */\nexport function setMessages(messages: Message[]): void {\n state.messages = messages;\n notifyListeners();\n}\n\n/**\n * Set current channel/conversation\n */\nexport function setCurrentChannel(channel: ChannelType): void {\n state.currentChannel = channel;\n notifyListeners();\n}\n\n/**\n * Update connection status\n */\nexport function setConnectionStatus(connected: boolean): void {\n state.isConnected = connected;\n if (connected) {\n state.reconnectAttempts = 0;\n }\n notifyListeners();\n}\n\n/**\n * Increment reconnect attempts\n */\nexport function incrementReconnectAttempts(): void {\n state.reconnectAttempts++;\n}\n\n/**\n * Set WebSocket instance\n */\nexport function setWebSocket(ws: WebSocket | null): void {\n state.ws = ws;\n}\n\n/**\n * Filter messages based on current channel\n */\nexport function getFilteredMessages(): Message[] {\n const { messages, currentChannel } = state;\n\n if (currentChannel === 'general') {\n return messages;\n }\n\n // Filter for specific agent - show messages to/from that agent\n return messages.filter(\n (m) => m.from === currentChannel || m.to === currentChannel\n );\n}\n\n/**\n * Set current thread for thread panel\n */\nexport function setCurrentThread(thread: string | null): void {\n state.currentThread = thread;\n}\n\n/**\n * Get messages for a specific thread\n */\nexport function getThreadMessages(threadId: string): Message[] {\n return state.messages.filter((m) => m.thread === threadId);\n}\n\n/**\n * Get reply count for a thread\n */\nexport function getThreadReplyCount(threadId: string): number {\n return state.messages.filter((m) => m.thread === threadId).length;\n}\n", "/**\n * WebSocket Connection Handler\n */\n\nimport type { DashboardData } from './types.js';\nimport {\n state,\n setAgents,\n setMessages,\n setConnectionStatus,\n setWebSocket,\n incrementReconnectAttempts,\n} from './state.js';\n\ntype DataHandler = (data: DashboardData) => void;\n\nlet dataHandler: DataHandler | null = null;\n\n/**\n * Set the handler for incoming data\n */\nexport function onData(handler: DataHandler): void {\n dataHandler = handler;\n}\n\n/**\n * Connect to the WebSocket server\n */\nexport function connect(): void {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);\n\n ws.onopen = (): void => {\n setConnectionStatus(true);\n };\n\n ws.onclose = (): void => {\n setConnectionStatus(false);\n // Reconnect with exponential backoff\n const delay = Math.min(1000 * Math.pow(2, state.reconnectAttempts), 30000);\n incrementReconnectAttempts();\n setTimeout(connect, delay);\n };\n\n ws.onerror = (error): void => {\n console.error('WebSocket error:', error);\n };\n\n ws.onmessage = (event: MessageEvent): void => {\n try {\n const data: DashboardData = JSON.parse(event.data as string);\n handleData(data);\n } catch (e) {\n console.error('Failed to parse message:', e);\n }\n };\n\n setWebSocket(ws);\n}\n\n/**\n * Handle incoming dashboard data\n */\nfunction handleData(data: DashboardData): void {\n console.log('[WS] Received data:', { agentCount: data.agents?.length, messageCount: data.messages?.length });\n\n if (data.agents) {\n console.log('[WS] Setting agents:', data.agents.map(a => a.name));\n setAgents(data.agents);\n }\n\n if (data.messages) {\n setMessages(data.messages);\n }\n\n if (dataHandler) {\n dataHandler(data);\n }\n}\n\n/**\n * Send a message via the REST API\n */\nexport async function sendMessage(\n to: string,\n message: string,\n thread?: string\n): Promise<{ success: boolean; error?: string }> {\n try {\n const body: { to: string; message: string; thread?: string } = { to, message };\n if (thread) {\n body.thread = thread;\n }\n\n const response = await fetch('/api/send', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n\n const result = await response.json();\n\n if (response.ok && result.success) {\n return { success: true };\n } else {\n return { success: false, error: result.error || 'Failed to send message' };\n }\n } catch (err) {\n return { success: false, error: 'Network error - could not send message' };\n }\n}\n", "/**\n * Dashboard Utility Functions\n */\n\n/** Threshold for considering an agent offline (30 seconds) */\nexport const STALE_THRESHOLD_MS = 30000;\n\n/**\n * Check if an agent is online based on last seen timestamp\n */\nexport function isAgentOnline(lastSeen: string | undefined): boolean {\n if (!lastSeen) return false;\n const ts = Date.parse(lastSeen);\n if (Number.isNaN(ts)) return false;\n return Date.now() - ts < STALE_THRESHOLD_MS;\n}\n\n/**\n * Escape HTML to prevent XSS\n */\nexport function escapeHtml(text: string | undefined): string {\n if (!text) return '';\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n}\n\n/**\n * Format timestamp to locale time string\n */\nexport function formatTime(timestamp: string): string {\n const date = new Date(timestamp);\n return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });\n}\n\n/**\n * Format timestamp to human-readable date\n */\nexport function formatDate(timestamp: string): string {\n const date = new Date(timestamp);\n const today = new Date();\n const yesterday = new Date(today);\n yesterday.setDate(yesterday.getDate() - 1);\n\n if (date.toDateString() === today.toDateString()) {\n return 'Today';\n } else if (date.toDateString() === yesterday.toDateString()) {\n return 'Yesterday';\n } else {\n return date.toLocaleDateString([], {\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n });\n }\n}\n\n/**\n * Generate a consistent color for an agent based on their name\n */\nexport function getAvatarColor(name: string): string {\n const colors = [\n '#e01e5a',\n '#2bac76',\n '#e8a427',\n '#1264a3',\n '#7c3aed',\n '#0d9488',\n '#dc2626',\n '#9333ea',\n '#ea580c',\n '#0891b2',\n ];\n let hash = 0;\n for (let i = 0; i < name.length; i++) {\n hash = name.charCodeAt(i) + ((hash << 5) - hash);\n }\n return colors[Math.abs(hash) % colors.length];\n}\n\n/**\n * Get initials from a name (first 2 characters, uppercase)\n */\nexport function getInitials(name: string): string {\n return name.substring(0, 2).toUpperCase();\n}\n\n/**\n * Format message body with basic markdown-like formatting\n */\nexport function formatMessageBody(content: string | undefined): string {\n if (!content) return '';\n\n let escaped = escapeHtml(content);\n\n // Simple code block detection\n escaped = escaped.replace(/```([\\s\\S]*?)```/g, '
    $1
    ');\n escaped = escaped.replace(/`([^`]+)`/g, '$1');\n\n // Convert newlines to
    for proper multi-line display\n escaped = escaped.replace(/\\n/g, '
    ');\n\n return escaped;\n}\n", "/**\n * Dashboard UI Components\n */\n\nimport type { Agent, Message, DOMElements, ChannelType, SpawnedAgent } from './types.js';\nimport { state, getFilteredMessages, setCurrentChannel, setCurrentThread, getThreadMessages, getThreadReplyCount } from './state.js';\nimport {\n escapeHtml,\n formatTime,\n formatDate,\n getAvatarColor,\n getInitials,\n formatMessageBody,\n isAgentOnline,\n} from './utils.js';\n\n// Track spawned agents\nlet spawnedAgents: SpawnedAgent[] = [];\n\nlet elements: DOMElements;\nlet paletteSelectedIndex = -1;\n\n/**\n * Initialize DOM element references\n */\nexport function initElements(): DOMElements {\n elements = {\n connectionDot: document.getElementById('connection-dot')!,\n channelsList: document.getElementById('channels-list')!,\n agentsList: document.getElementById('agents-list')!,\n messagesList: document.getElementById('messages-list')!,\n currentChannelName: document.getElementById('current-channel-name')!,\n channelTopic: document.getElementById('channel-topic')!,\n onlineCount: document.getElementById('online-count')!,\n messageInput: document.getElementById('message-input') as HTMLTextAreaElement,\n sendBtn: document.getElementById('send-btn') as HTMLButtonElement,\n boldBtn: document.getElementById('bold-btn') as HTMLButtonElement,\n emojiBtn: document.getElementById('emoji-btn') as HTMLButtonElement,\n searchTrigger: document.getElementById('search-trigger')!,\n commandPaletteOverlay: document.getElementById('command-palette-overlay')!,\n paletteSearch: document.getElementById('palette-search') as HTMLInputElement,\n paletteResults: document.getElementById('palette-results')!,\n paletteChannelsSection: document.getElementById('palette-channels-section')!,\n paletteAgentsSection: document.getElementById('palette-agents-section')!,\n paletteMessagesSection: document.getElementById('palette-messages-section')!,\n typingIndicator: document.getElementById('typing-indicator')!,\n threadPanelOverlay: document.getElementById('thread-panel-overlay')!,\n threadPanelId: document.getElementById('thread-panel-id')!,\n threadPanelClose: document.getElementById('thread-panel-close') as HTMLButtonElement,\n threadMessages: document.getElementById('thread-messages')!,\n threadMessageInput: document.getElementById('thread-message-input') as HTMLTextAreaElement,\n threadSendBtn: document.getElementById('thread-send-btn') as HTMLButtonElement,\n mentionAutocomplete: document.getElementById('mention-autocomplete')!,\n mentionAutocompleteList: document.getElementById('mention-autocomplete-list')!,\n // Spawn modal elements\n spawnBtn: document.getElementById('spawn-btn') as HTMLButtonElement,\n spawnModalOverlay: document.getElementById('spawn-modal-overlay')!,\n spawnModalClose: document.getElementById('spawn-modal-close') as HTMLButtonElement,\n spawnNameInput: document.getElementById('spawn-name-input') as HTMLInputElement,\n spawnCliInput: document.getElementById('spawn-cli-input') as HTMLInputElement,\n spawnTaskInput: document.getElementById('spawn-task-input') as HTMLTextAreaElement,\n spawnSubmitBtn: document.getElementById('spawn-submit-btn') as HTMLButtonElement,\n spawnStatus: document.getElementById('spawn-status')!,\n };\n return elements;\n}\n\n/**\n * Get DOM elements\n */\nexport function getElements(): DOMElements {\n return elements;\n}\n\n/**\n * Update connection status indicator\n */\nexport function updateConnectionStatus(): void {\n if (state.isConnected) {\n elements.connectionDot.classList.remove('offline');\n } else {\n elements.connectionDot.classList.add('offline');\n }\n}\n\n/**\n * Render agents list in sidebar\n */\nexport function renderAgents(): void {\n console.log('[UI] renderAgents called, agents:', state.agents.length, state.agents.map(a => a.name));\n\n // Create a set of spawned agent names for quick lookup\n const spawnedNames = new Set(spawnedAgents.map(a => a.name));\n\n const html = state.agents\n .map((agent) => {\n const online = isAgentOnline(agent.lastSeen || agent.lastActive);\n const presenceClass = online ? 'online' : '';\n const isActive = state.currentChannel === agent.name;\n const needsAttentionClass = agent.needsAttention ? 'needs-attention' : '';\n const isSpawned = spawnedNames.has(agent.name);\n\n // Spawned icon SVG (play/launch icon)\n const spawnedIcon = isSpawned ? `\n \n \n \n ` : '';\n\n // Release button for spawned agents\n const releaseBtn = isSpawned ? `\n \n ` : '';\n\n return `\n
  • \n
    \n ${getInitials(agent.name)}\n \n
    \n ${escapeHtml(agent.name)}\n ${spawnedIcon}\n ${agent.needsAttention ? 'Needs Input' : ''}\n ${releaseBtn}\n
  • \n `;\n })\n .join('');\n\n elements.agentsList.innerHTML =\n html ||\n '
  • No agents connected
  • ';\n\n // Add click handlers for agent selection\n elements.agentsList.querySelectorAll('.channel-item[data-agent]').forEach((item) => {\n item.addEventListener('click', (e) => {\n // Don't select channel if clicking release button\n if ((e.target as HTMLElement).closest('.release-btn')) {\n return;\n }\n const agentName = item.dataset.agent;\n if (agentName) {\n selectChannel(agentName);\n }\n });\n });\n\n // Add release button click handlers\n elements.agentsList.querySelectorAll('.release-btn[data-release]').forEach((btn) => {\n btn.addEventListener('click', async (e) => {\n e.stopPropagation();\n const agentName = btn.dataset.release;\n if (agentName && confirm(`Release agent \"${agentName}\"? This will terminate the agent.`)) {\n await releaseAgent(agentName);\n }\n });\n });\n\n // Update command palette agents\n updatePaletteAgents();\n}\n\n/**\n * Render messages list\n */\nexport function renderMessages(): void {\n const filtered = getFilteredMessages();\n\n if (filtered.length === 0) {\n elements.messagesList.innerHTML = `\n
    \n \n \n \n
    No messages yet
    \n
    \n ${\n state.currentChannel === 'general'\n ? 'Messages between agents will appear here'\n : `Messages with ${state.currentChannel} will appear here`\n }\n
    \n
    \n `;\n return;\n }\n\n let html = '';\n let lastDate: string | null = null;\n\n filtered.forEach((msg) => {\n const msgDate = new Date(msg.timestamp).toDateString();\n\n // Add date divider if needed\n if (msgDate !== lastDate) {\n html += `\n
    \n ${formatDate(msg.timestamp)}\n
    \n `;\n lastDate = msgDate;\n }\n\n const isBroadcast = msg.to === '*';\n const avatarColor = getAvatarColor(msg.from);\n const replyCount = getThreadReplyCount(msg.id);\n\n // Format: @From \u2192 @To: message (like Slack)\n // For cross-project messages, show project badge before agent name\n const recipientDisplay = isBroadcast\n ? '@everyone'\n : msg.project\n ? `${escapeHtml(msg.project)}@${escapeHtml(msg.to)}`\n : `@${escapeHtml(msg.to)}`;\n\n html += `\n
    \n
    \n ${getInitials(msg.from)}\n
    \n
    \n
    \n @${escapeHtml(msg.from)}\n \n \u2192 ${recipientDisplay}\n \n ${formatTime(msg.timestamp)}\n
    \n
    ${formatMessageBody(msg.content)}
    \n ${\n msg.thread\n ? `\n
    \n \n \n \n Thread: ${escapeHtml(msg.thread)}\n
    \n `\n : ''\n }\n ${\n replyCount > 0\n ? `\n
    \n \n \n \n ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}\n
    \n `\n : ''\n }\n
    \n
    \n \n \n
    \n
    \n `;\n });\n\n elements.messagesList.innerHTML = html;\n\n // Note: Auto-scroll removed - interferes with manual scrolling through history\n\n // Attach thread click handlers\n attachThreadHandlers();\n}\n\n/**\n * Select a channel and update UI\n */\nexport function selectChannel(channel: ChannelType): void {\n setCurrentChannel(channel);\n\n // Update sidebar active states\n elements.channelsList.querySelectorAll('.channel-item').forEach((item) => {\n item.classList.toggle('active', item.dataset.channel === channel);\n });\n elements.agentsList.querySelectorAll('.channel-item').forEach((item) => {\n item.classList.toggle('active', item.dataset.agent === channel);\n });\n\n // Update header\n const prefixEl = document.querySelector('.channel-header-name .prefix');\n if (channel === 'general') {\n elements.currentChannelName.innerHTML = 'general';\n elements.channelTopic.textContent = 'All agent communications';\n if (prefixEl) prefixEl.textContent = '#';\n } else {\n elements.currentChannelName.innerHTML = escapeHtml(channel);\n const agent = state.agents.find((a) => a.name === channel);\n elements.channelTopic.textContent = agent?.status || 'Direct messages';\n if (prefixEl) prefixEl.textContent = '@';\n }\n\n // Update composer placeholder - DM mode doesn't require @mention\n elements.messageInput.placeholder =\n channel === 'general'\n ? '@AgentName message... (or @* to broadcast)'\n : `Message ${channel}... (@ not required)`;\n\n // Re-render messages\n renderMessages();\n}\n\n/**\n * Update online count display\n */\nexport function updateOnlineCount(): void {\n const online = state.agents.filter((a) => isAgentOnline(a.lastSeen || a.lastActive)).length;\n elements.onlineCount.textContent = `${online} online`;\n}\n\n/**\n * Update agents in command palette\n */\nexport function updatePaletteAgents(): void {\n const html = state.agents\n .map((agent) => {\n const online = isAgentOnline(agent.lastSeen || agent.lastActive);\n return `\n
    \n
    \n
    \n ${getInitials(agent.name)}\n \n
    \n
    \n
    \n
    ${escapeHtml(agent.name)}
    \n
    ${online ? 'Online' : 'Offline'}
    \n
    \n
    \n `;\n })\n .join('');\n\n const section = elements.paletteAgentsSection;\n const items = section.querySelectorAll('.palette-item');\n items.forEach((item) => item.remove());\n section.insertAdjacentHTML('beforeend', html);\n\n // Add click handlers\n section.querySelectorAll('.palette-item[data-jump-agent]').forEach((item) => {\n item.addEventListener('click', () => {\n const agentName = item.dataset.jumpAgent;\n if (agentName) {\n selectChannel(agentName);\n closeCommandPalette();\n }\n });\n });\n}\n\n/**\n * Initialize channel click handlers in command palette\n */\nexport function initPaletteChannels(): void {\n elements.paletteChannelsSection\n .querySelectorAll('.palette-item[data-jump-channel]')\n .forEach((item) => {\n item.addEventListener('click', () => {\n const channelName = item.dataset.jumpChannel;\n if (channelName) {\n selectChannel(channelName);\n closeCommandPalette();\n }\n });\n });\n}\n\n/**\n * Open command palette\n */\nexport function openCommandPalette(): void {\n elements.commandPaletteOverlay.classList.add('visible');\n elements.paletteSearch.value = '';\n elements.paletteSearch.focus();\n paletteSelectedIndex = -1;\n filterPaletteResults('');\n}\n\n/**\n * Get all visible palette items\n */\nexport function getVisiblePaletteItems(): HTMLElement[] {\n const allItems = Array.from(\n elements.paletteResults.querySelectorAll('.palette-item')\n );\n return allItems.filter((item) => item.style.display !== 'none');\n}\n\n/**\n * Update the selected palette item visually\n */\nexport function updatePaletteSelection(): void {\n const items = getVisiblePaletteItems();\n\n // Remove selection from all items\n items.forEach((item) => item.classList.remove('selected'));\n\n // Add selection to current item\n if (paletteSelectedIndex >= 0 && paletteSelectedIndex < items.length) {\n const selectedItem = items[paletteSelectedIndex];\n selectedItem.classList.add('selected');\n\n // Scroll into view if needed\n selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n }\n}\n\n/**\n * Handle keyboard navigation in command palette\n */\nexport function handlePaletteKeydown(e: KeyboardEvent): void {\n const items = getVisiblePaletteItems();\n\n if (items.length === 0) return;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n paletteSelectedIndex = paletteSelectedIndex < items.length - 1\n ? paletteSelectedIndex + 1\n : 0;\n updatePaletteSelection();\n break;\n\n case 'ArrowUp':\n e.preventDefault();\n paletteSelectedIndex = paletteSelectedIndex > 0\n ? paletteSelectedIndex - 1\n : items.length - 1;\n updatePaletteSelection();\n break;\n\n case 'Enter':\n e.preventDefault();\n if (paletteSelectedIndex >= 0 && paletteSelectedIndex < items.length) {\n executePaletteItem(items[paletteSelectedIndex]);\n }\n break;\n }\n}\n\n/**\n * Execute the action for a palette item\n */\nexport function executePaletteItem(item: HTMLElement): void {\n // Check for command\n const command = item.dataset.command;\n if (command) {\n if (command === 'broadcast') {\n // Pre-fill message input with @* for broadcast\n elements.messageInput.value = '@* ';\n elements.messageInput.focus();\n } else if (command === 'clear') {\n elements.messagesList.innerHTML = '';\n }\n closeCommandPalette();\n return;\n }\n\n // Check for channel jump\n const channel = item.dataset.jumpChannel;\n if (channel) {\n selectChannel(channel);\n closeCommandPalette();\n return;\n }\n\n // Check for agent jump\n const agent = item.dataset.jumpAgent;\n if (agent) {\n selectChannel(agent);\n closeCommandPalette();\n return;\n }\n\n // Check for message jump\n const messageId = item.dataset.jumpMessage;\n if (messageId) {\n // Find and scroll to the message\n const messageEl = elements.messagesList.querySelector(`[data-id=\"${messageId}\"]`);\n if (messageEl) {\n messageEl.scrollIntoView({ behavior: 'smooth', block: 'center' });\n messageEl.classList.add('highlighted');\n setTimeout(() => messageEl.classList.remove('highlighted'), 2000);\n }\n closeCommandPalette();\n return;\n }\n}\n\n/**\n * Close command palette\n */\nexport function closeCommandPalette(): void {\n elements.commandPaletteOverlay.classList.remove('visible');\n}\n\n/**\n * Filter command palette results based on query\n */\nexport function filterPaletteResults(query: string): void {\n const q = query.toLowerCase();\n\n // Reset selection when filtering\n paletteSelectedIndex = -1;\n\n // Filter command items\n document.querySelectorAll('.palette-item[data-command]').forEach((item) => {\n const titleEl = item.querySelector('.palette-item-title');\n const title = titleEl?.textContent?.toLowerCase() || '';\n item.style.display = title.includes(q) ? 'flex' : 'none';\n });\n\n // Filter channel items\n document.querySelectorAll('.palette-item[data-jump-channel]').forEach((item) => {\n const titleEl = item.querySelector('.palette-item-title');\n const title = titleEl?.textContent?.toLowerCase() || '';\n item.style.display = title.includes(q) ? 'flex' : 'none';\n });\n\n // Filter agent items\n document.querySelectorAll('.palette-item[data-jump-agent]').forEach((item) => {\n const name = item.dataset.jumpAgent?.toLowerCase() || '';\n item.style.display = name.includes(q) ? 'flex' : 'none';\n });\n\n // Show message search if query is long enough\n if (q.length >= 2) {\n const matches = state.messages.filter((m) => m.content.toLowerCase().includes(q)).slice(0, 5);\n\n if (matches.length > 0) {\n elements.paletteMessagesSection.style.display = 'block';\n const items = matches\n .map(\n (m) => `\n
    \n
    \n \n \n \n
    \n
    \n
    ${escapeHtml(m.from)}
    \n
    ${escapeHtml(m.content.substring(0, 60))}${m.content.length > 60 ? '...' : ''}
    \n
    \n
    \n `\n )\n .join('');\n\n const existingItems = elements.paletteMessagesSection.querySelectorAll('.palette-item');\n existingItems.forEach((item) => item.remove());\n elements.paletteMessagesSection.insertAdjacentHTML('beforeend', items);\n } else {\n elements.paletteMessagesSection.style.display = 'none';\n }\n } else {\n elements.paletteMessagesSection.style.display = 'none';\n }\n}\n\n/**\n * Open thread panel for a specific thread\n */\nexport function openThreadPanel(threadId: string): void {\n setCurrentThread(threadId);\n elements.threadPanelId.textContent = threadId;\n elements.threadPanelOverlay.classList.add('visible');\n elements.threadMessageInput.value = '';\n renderThreadMessages(threadId);\n elements.threadMessageInput.focus();\n}\n\n/**\n * Close thread panel\n */\nexport function closeThreadPanel(): void {\n setCurrentThread(null);\n elements.threadPanelOverlay.classList.remove('visible');\n}\n\n/**\n * Render messages in thread panel\n */\nexport function renderThreadMessages(threadId: string): void {\n const messages = getThreadMessages(threadId);\n\n if (messages.length === 0) {\n elements.threadMessages.innerHTML = `\n
    \n

    No messages in this thread yet.

    \n

    Start the conversation below!

    \n
    \n `;\n return;\n }\n\n const html = messages\n .map((msg) => `\n
    \n
    \n
    \n ${getInitials(msg.from)}\n
    \n ${escapeHtml(msg.from)}\n ${formatTime(msg.timestamp)}\n
    \n
    ${formatMessageBody(msg.content)}
    \n
    \n `)\n .join('');\n\n elements.threadMessages.innerHTML = html;\n\n // Scroll to bottom\n elements.threadMessages.scrollTop = elements.threadMessages.scrollHeight;\n}\n\n/**\n * Attach thread click handlers to messages (call after renderMessages)\n */\nexport function attachThreadHandlers(): void {\n // Thread indicator clicks\n elements.messagesList.querySelectorAll('.thread-indicator').forEach((el) => {\n el.style.cursor = 'pointer';\n el.addEventListener('click', (e) => {\n e.stopPropagation();\n const threadId = el.dataset.thread;\n if (threadId) {\n openThreadPanel(threadId);\n }\n });\n });\n\n // Reply count badge clicks\n elements.messagesList.querySelectorAll('.reply-count-badge').forEach((el) => {\n el.addEventListener('click', (e) => {\n e.stopPropagation();\n const threadId = el.dataset.thread;\n if (threadId) {\n openThreadPanel(threadId);\n }\n });\n });\n\n // Reply in thread button clicks\n elements.messagesList.querySelectorAll('.message-action-btn[data-action=\"reply\"]').forEach((el) => {\n el.addEventListener('click', (e) => {\n e.stopPropagation();\n const messageId = el.closest('.message')?.getAttribute('data-id');\n if (messageId) {\n // Use message ID as thread ID for new threads\n openThreadPanel(messageId);\n }\n });\n });\n}\n\n/**\n * @-Mention Autocomplete State\n */\nlet mentionSelectedIndex = 0;\nlet mentionFilteredAgents: typeof state.agents = [];\n\n/**\n * Show mention autocomplete dropdown with filtered agents\n */\nexport function showMentionAutocomplete(filter: string): void {\n const filterLower = filter.toLowerCase();\n\n // Filter agents by name, include broadcast option\n mentionFilteredAgents = state.agents.filter(agent =>\n agent.name.toLowerCase().includes(filterLower)\n );\n\n // Reset selection\n mentionSelectedIndex = 0;\n\n // Build HTML for agent list\n let html = '';\n\n // Add broadcast option if filter matches\n if ('*'.includes(filterLower) || 'everyone'.includes(filterLower) || 'all'.includes(filterLower) || 'broadcast'.includes(filterLower)) {\n html += `\n
    \n
    *
    \n @everyone\n Broadcast to all\n
    \n `;\n }\n\n // Add agents\n mentionFilteredAgents.forEach((agent, index) => {\n const isSelected = index === mentionSelectedIndex;\n html += `\n
    \n
    \n ${getInitials(agent.name)}\n
    \n @${escapeHtml(agent.name)}\n ${escapeHtml(agent.role || 'Agent')}\n
    \n `;\n });\n\n if (html === '') {\n html = '
    No matching agents
    ';\n }\n\n elements.mentionAutocompleteList.innerHTML = html;\n elements.mentionAutocomplete.classList.add('visible');\n\n // Add click handlers to items\n elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]').forEach((item) => {\n item.addEventListener('click', () => {\n const mention = item.dataset.mention;\n if (mention) {\n completeMention(mention);\n }\n });\n });\n}\n\n/**\n * Hide mention autocomplete dropdown\n */\nexport function hideMentionAutocomplete(): void {\n elements.mentionAutocomplete.classList.remove('visible');\n mentionFilteredAgents = [];\n mentionSelectedIndex = 0;\n}\n\n/**\n * Check if mention autocomplete is visible\n */\nexport function isMentionAutocompleteVisible(): boolean {\n return elements.mentionAutocomplete.classList.contains('visible');\n}\n\n/**\n * Navigate mention autocomplete selection\n */\nexport function navigateMentionAutocomplete(direction: 'up' | 'down'): void {\n const items = elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]');\n if (items.length === 0) return;\n\n // Remove current selection\n items[mentionSelectedIndex]?.classList.remove('selected');\n\n // Update index\n if (direction === 'down') {\n mentionSelectedIndex = (mentionSelectedIndex + 1) % items.length;\n } else {\n mentionSelectedIndex = (mentionSelectedIndex - 1 + items.length) % items.length;\n }\n\n // Add new selection\n items[mentionSelectedIndex]?.classList.add('selected');\n items[mentionSelectedIndex]?.scrollIntoView({ block: 'nearest' });\n}\n\n/**\n * Complete the current mention selection\n */\nexport function completeMention(mention?: string): void {\n const items = elements.mentionAutocompleteList.querySelectorAll('.mention-autocomplete-item[data-mention]');\n\n // Use provided mention or get from selected item\n let selectedMention = mention;\n if (!selectedMention && items.length > 0) {\n selectedMention = items[mentionSelectedIndex]?.dataset.mention;\n }\n\n if (!selectedMention) {\n hideMentionAutocomplete();\n return;\n }\n\n // Replace the @... text with the completed mention\n const input = elements.messageInput;\n const value = input.value;\n\n // Find the @ position (should be at start or after whitespace)\n const atMatch = value.match(/^@\\S*/);\n if (atMatch) {\n // Replace the @partial with @CompletedName\n const completedText = `@${selectedMention} `;\n input.value = completedText + value.substring(atMatch[0].length);\n input.selectionStart = input.selectionEnd = completedText.length;\n }\n\n hideMentionAutocomplete();\n input.focus();\n}\n\n/**\n * Get the current @mention being typed (if any)\n */\nexport function getCurrentMentionQuery(): string | null {\n const input = elements.messageInput;\n const value = input.value;\n const cursorPos = input.selectionStart;\n\n // Check if cursor is within an @mention at the start\n const atMatch = value.match(/^@(\\S*)/);\n if (atMatch && cursorPos <= atMatch[0].length) {\n return atMatch[1]; // Return the text after @\n }\n\n return null;\n}\n\n// ========================================\n// Spawn Modal Functions\n// ========================================\n\n/**\n * Open the spawn agent modal\n */\nexport function openSpawnModal(): void {\n elements.spawnModalOverlay.classList.add('visible');\n elements.spawnNameInput.value = '';\n elements.spawnCliInput.value = 'claude';\n elements.spawnTaskInput.value = '';\n elements.spawnStatus.textContent = '';\n elements.spawnStatus.className = 'spawn-status';\n elements.spawnNameInput.focus();\n}\n\n/**\n * Close the spawn agent modal\n */\nexport function closeSpawnModal(): void {\n elements.spawnModalOverlay.classList.remove('visible');\n}\n\n/**\n * Spawn a new agent via the API\n */\nexport async function spawnAgent(): Promise<{ success: boolean; error?: string }> {\n const name = elements.spawnNameInput.value.trim();\n const cli = elements.spawnCliInput.value.trim() || 'claude';\n const task = elements.spawnTaskInput.value.trim();\n\n if (!name) {\n elements.spawnStatus.textContent = 'Agent name is required';\n elements.spawnStatus.className = 'spawn-status error';\n return { success: false, error: 'Agent name is required' };\n }\n\n elements.spawnSubmitBtn.disabled = true;\n elements.spawnStatus.textContent = 'Spawning agent...';\n elements.spawnStatus.className = 'spawn-status loading';\n\n try {\n const response = await fetch('/api/spawn', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ name, cli, task }),\n });\n\n const result = await response.json();\n\n if (response.ok && result.success) {\n elements.spawnStatus.textContent = `Agent \"${name}\" spawned successfully!`;\n elements.spawnStatus.className = 'spawn-status success';\n\n // Refresh spawned agents list\n await fetchSpawnedAgents();\n\n // Close modal after brief delay\n setTimeout(() => {\n closeSpawnModal();\n }, 1000);\n\n return { success: true };\n } else {\n throw new Error(result.error || 'Failed to spawn agent');\n }\n } catch (err: any) {\n elements.spawnStatus.textContent = err.message || 'Failed to spawn agent';\n elements.spawnStatus.className = 'spawn-status error';\n return { success: false, error: err.message };\n } finally {\n elements.spawnSubmitBtn.disabled = false;\n }\n}\n\n/**\n * Fetch list of spawned agents from API\n */\nexport async function fetchSpawnedAgents(): Promise {\n try {\n const response = await fetch('/api/spawned');\n const result = await response.json();\n\n if (result.success && Array.isArray(result.agents)) {\n spawnedAgents = result.agents;\n // Re-render agents to show spawned status\n renderAgents();\n }\n } catch (err) {\n console.error('[UI] Failed to fetch spawned agents:', err);\n }\n}\n\n/**\n * Release a spawned agent\n */\nexport async function releaseAgent(name: string): Promise {\n try {\n const response = await fetch(`/api/spawned/${encodeURIComponent(name)}`, {\n method: 'DELETE',\n });\n\n const result = await response.json();\n\n if (result.success) {\n // Refresh the list\n await fetchSpawnedAgents();\n } else {\n console.error('[UI] Failed to release agent:', result.error);\n }\n } catch (err) {\n console.error('[UI] Failed to release agent:', err);\n }\n}\n\n/**\n * Get spawned agents list\n */\nexport function getSpawnedAgents(): SpawnedAgent[] {\n return spawnedAgents;\n}\n", "/**\n * Dashboard Application Entry Point\n */\n\nimport { subscribe, state } from './state.js';\nimport { connect, sendMessage } from './websocket.js';\nimport {\n initElements,\n getElements,\n updateConnectionStatus,\n renderAgents,\n renderMessages,\n selectChannel,\n updateOnlineCount,\n openCommandPalette,\n closeCommandPalette,\n filterPaletteResults,\n handlePaletteKeydown,\n initPaletteChannels,\n closeThreadPanel,\n renderThreadMessages,\n showMentionAutocomplete,\n hideMentionAutocomplete,\n isMentionAutocompleteVisible,\n navigateMentionAutocomplete,\n completeMention,\n getCurrentMentionQuery,\n openSpawnModal,\n closeSpawnModal,\n spawnAgent,\n fetchSpawnedAgents,\n} from './components.js';\nimport { state } from './state.js';\n\n/**\n * Initialize the dashboard application\n */\nexport function initApp(): void {\n const elements = initElements();\n\n // Subscribe to state changes\n subscribe(() => {\n updateConnectionStatus();\n renderAgents();\n renderMessages();\n updateOnlineCount();\n });\n\n // Set up event listeners\n setupEventListeners(elements);\n\n // Connect to WebSocket\n connect();\n\n // Fetch initial spawned agents list\n fetchSpawnedAgents();\n}\n\n/**\n * Set up all event listeners\n */\nfunction setupEventListeners(elements: ReturnType): void {\n // Channel clicks\n elements.channelsList.querySelectorAll('.channel-item').forEach((item) => {\n item.addEventListener('click', () => {\n const channel = item.dataset.channel;\n if (channel) {\n selectChannel(channel);\n }\n });\n });\n\n // Send button\n elements.sendBtn.addEventListener('click', handleSend);\n\n // Keyboard shortcuts for composer\n elements.messageInput.addEventListener('keydown', (e: KeyboardEvent) => {\n // Handle mention autocomplete keys first\n if (isMentionAutocompleteVisible()) {\n if (e.key === 'Tab' || e.key === 'Enter') {\n e.preventDefault();\n completeMention();\n return;\n }\n if (e.key === 'ArrowUp') {\n e.preventDefault();\n navigateMentionAutocomplete('up');\n return;\n }\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n navigateMentionAutocomplete('down');\n return;\n }\n if (e.key === 'Escape') {\n e.preventDefault();\n hideMentionAutocomplete();\n return;\n }\n }\n\n // Enter to send (Slack-style), Shift+Enter for newline\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n // Shift+Enter allows default behavior (inserts newline)\n });\n\n // Auto-resize textarea and handle @-mention autocomplete\n elements.messageInput.addEventListener('input', () => {\n elements.messageInput.style.height = 'auto';\n elements.messageInput.style.height =\n Math.min(elements.messageInput.scrollHeight, 200) + 'px';\n\n // Check for @-mention at start of input\n const query = getCurrentMentionQuery();\n if (query !== null) {\n showMentionAutocomplete(query);\n } else {\n hideMentionAutocomplete();\n }\n });\n\n // Hide mention autocomplete when input loses focus (with delay to allow clicks)\n elements.messageInput.addEventListener('blur', () => {\n setTimeout(() => {\n hideMentionAutocomplete();\n }, 150);\n });\n\n // Bold button - wrap selected text with ** or insert **bold**\n elements.boldBtn.addEventListener('click', () => {\n const input = elements.messageInput;\n const start = input.selectionStart;\n const end = input.selectionEnd;\n const text = input.value;\n\n if (start === end) {\n // No selection - insert **bold** placeholder\n const before = text.substring(0, start);\n const after = text.substring(end);\n input.value = before + '**bold**' + after;\n input.selectionStart = start + 2;\n input.selectionEnd = start + 6;\n } else {\n // Wrap selection with **\n const before = text.substring(0, start);\n const selected = text.substring(start, end);\n const after = text.substring(end);\n input.value = before + '**' + selected + '**' + after;\n input.selectionStart = start;\n input.selectionEnd = end + 4;\n }\n input.focus();\n });\n\n // Emoji button - insert common emojis via simple picker\n elements.emojiBtn.addEventListener('click', () => {\n const emojis = ['\uD83D\uDC4D', '\uD83D\uDC4E', '\u2705', '\u274C', '\uD83C\uDF89', '\uD83D\uDD25', '\uD83D\uDCA1', '\u26A0\uFE0F', '\uD83D\uDCDD', '\uD83D\uDE80'];\n const emoji = emojis[Math.floor(Math.random() * emojis.length)];\n const input = elements.messageInput;\n const start = input.selectionStart;\n const text = input.value;\n input.value = text.substring(0, start) + emoji + text.substring(start);\n input.selectionStart = input.selectionEnd = start + emoji.length;\n input.focus();\n });\n\n // Command palette\n elements.searchTrigger.addEventListener('click', openCommandPalette);\n\n document.addEventListener('keydown', (e: KeyboardEvent) => {\n if ((e.ctrlKey || e.metaKey) && e.key === 'k') {\n e.preventDefault();\n if (elements.commandPaletteOverlay.classList.contains('visible')) {\n closeCommandPalette();\n } else {\n openCommandPalette();\n }\n }\n\n if (e.key === 'Escape') {\n closeCommandPalette();\n }\n });\n\n elements.commandPaletteOverlay.addEventListener('click', (e: MouseEvent) => {\n if (e.target === elements.commandPaletteOverlay) {\n closeCommandPalette();\n }\n });\n\n elements.paletteSearch.addEventListener('input', (e: Event) => {\n const target = e.target as HTMLInputElement;\n filterPaletteResults(target.value);\n });\n\n elements.paletteSearch.addEventListener('keydown', handlePaletteKeydown);\n\n // Command execution\n document.querySelectorAll('.palette-item[data-command]').forEach((item) => {\n item.addEventListener('click', () => {\n const command = item.dataset.command;\n\n if (command === 'broadcast') {\n // Pre-fill message input with @* for broadcast\n elements.messageInput.value = '@* ';\n elements.messageInput.focus();\n } else if (command === 'clear') {\n elements.messagesList.innerHTML = '';\n }\n\n closeCommandPalette();\n });\n });\n\n // Initialize palette channel click handlers\n initPaletteChannels();\n\n // Thread panel close button\n elements.threadPanelClose.addEventListener('click', closeThreadPanel);\n\n // Thread panel send button\n elements.threadSendBtn.addEventListener('click', handleThreadSend);\n\n // Thread message input keyboard shortcuts (Slack-style)\n elements.threadMessageInput.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n handleThreadSend();\n }\n // Shift+Enter allows default behavior (inserts newline)\n });\n\n // Close thread panel on Escape\n document.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Escape' && elements.threadPanelOverlay.classList.contains('visible')) {\n closeThreadPanel();\n }\n });\n\n // Spawn modal event listeners\n elements.spawnBtn.addEventListener('click', openSpawnModal);\n\n elements.spawnModalClose.addEventListener('click', closeSpawnModal);\n\n // Cancel button in spawn modal\n document.getElementById('spawn-cancel-btn')?.addEventListener('click', closeSpawnModal);\n\n // Close spawn modal on overlay click\n elements.spawnModalOverlay.addEventListener('click', (e: MouseEvent) => {\n if (e.target === elements.spawnModalOverlay) {\n closeSpawnModal();\n }\n });\n\n // Close spawn modal on Escape\n document.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Escape' && elements.spawnModalOverlay.classList.contains('visible')) {\n closeSpawnModal();\n }\n });\n\n // Submit spawn form\n elements.spawnSubmitBtn.addEventListener('click', spawnAgent);\n\n // Enter key in spawn name input triggers submit\n elements.spawnNameInput.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Enter' && !e.shiftKey) {\n e.preventDefault();\n spawnAgent();\n }\n });\n}\n\n/**\n * Parse @mention from message text\n * Formats: \"@AgentName message\" or \"@* message\" for broadcast\n * Returns { to, message } or null if no valid mention found\n */\nfunction parseMention(text: string): { to: string; message: string } | null {\n const trimmed = text.trim();\n\n // Match @mention at the start of the message\n // @* for broadcast, @AgentName for direct message\n const match = trimmed.match(/^@(\\*|[^\\s]+)\\s+(.+)$/s);\n\n if (!match) {\n return null;\n }\n\n return {\n to: match[1],\n message: match[2].trim(),\n };\n}\n\n/**\n * Handle send button click\n */\nasync function handleSend(): Promise {\n const elements = getElements();\n const rawMessage = elements.messageInput.value.trim();\n\n if (!rawMessage) {\n return;\n }\n\n let to: string;\n let message: string;\n\n // Check if we're in a DM (not general channel)\n const isInDM = state.currentChannel !== 'general';\n\n // Parse @mention from the message\n const parsed = parseMention(rawMessage);\n\n if (parsed) {\n // Message has explicit @mention - use it\n to = parsed.to;\n message = parsed.message;\n } else if (isInDM) {\n // In DM context - send to current channel without requiring @\n to = state.currentChannel;\n message = rawMessage;\n } else {\n // In general channel without @mention - require it\n alert('Message must start with @recipient (e.g., \"@Lead hello\" or \"@* broadcast\")');\n return;\n }\n\n elements.sendBtn.disabled = true;\n\n const result = await sendMessage(to, message);\n\n if (result.success) {\n elements.messageInput.value = '';\n elements.messageInput.style.height = 'auto';\n } else {\n alert(result.error);\n }\n\n elements.sendBtn.disabled = false;\n}\n\n/**\n * Handle thread panel send button click\n */\nasync function handleThreadSend(): Promise {\n const elements = getElements();\n const message = elements.threadMessageInput.value.trim();\n const threadId = state.currentThread;\n\n if (!message || !threadId) {\n return;\n }\n\n // For thread replies, send to broadcast or use original recipient\n // For now, send as broadcast with thread ID\n elements.threadSendBtn.disabled = true;\n\n const result = await sendMessage('*', message, threadId);\n\n if (result.success) {\n elements.threadMessageInput.value = '';\n // Re-render thread messages to show the new message\n renderThreadMessages(threadId);\n } else {\n alert(result.error);\n }\n\n elements.threadSendBtn.disabled = false;\n}\n\n// Auto-initialize when DOM is ready\nif (typeof document !== 'undefined') {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', initApp);\n } else {\n initApp();\n }\n}\n"], + "mappings": "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", + "names": ["state", "listeners", "subscribe", "listener", "index", "notifyListeners", "setAgents", "agents", "setMessages", "messages", "setCurrentChannel", "channel", "setConnectionStatus", "connected", "incrementReconnectAttempts", "setWebSocket", "ws", "getFilteredMessages", "currentChannel", "m", "setCurrentThread", "thread", "getThreadMessages", "threadId", "getThreadReplyCount", "dataHandler", "connect", "protocol", "ws", "setConnectionStatus", "delay", "state", "incrementReconnectAttempts", "error", "event", "data", "handleData", "e", "setWebSocket", "a", "setAgents", "setMessages", "dataHandler", "sendMessage", "to", "message", "thread", "body", "response", "result", "isAgentOnline", "lastSeen", "ts", "escapeHtml", "text", "div", "formatTime", "timestamp", "formatDate", "date", "today", "yesterday", "getAvatarColor", "name", "colors", "hash", "i", "getInitials", "formatMessageBody", "content", "escaped", "spawnedAgents", "elements", "paletteSelectedIndex", "initElements", "getElements", "updateConnectionStatus", "state", "renderAgents", "a", "spawnedNames", "html", "agent", "presenceClass", "isAgentOnline", "isActive", "needsAttentionClass", "isSpawned", "spawnedIcon", "releaseBtn", "escapeHtml", "getAvatarColor", "getInitials", "item", "e", "agentName", "selectChannel", "btn", "releaseAgent", "updatePaletteAgents", "renderMessages", "filtered", "getFilteredMessages", "lastDate", "msg", "msgDate", "formatDate", "isBroadcast", "avatarColor", "replyCount", "getThreadReplyCount", "recipientDisplay", "formatTime", "formatMessageBody", "attachThreadHandlers", "channel", "setCurrentChannel", "prefixEl", "updateOnlineCount", "online", "section", "closeCommandPalette", "initPaletteChannels", "channelName", "openCommandPalette", "filterPaletteResults", "getVisiblePaletteItems", "updatePaletteSelection", "items", "selectedItem", "handlePaletteKeydown", "executePaletteItem", "command", "messageId", "messageEl", "query", "q", "title", "name", "matches", "m", "openThreadPanel", "threadId", "setCurrentThread", "renderThreadMessages", "closeThreadPanel", "messages", "getThreadMessages", "el", "mentionSelectedIndex", "mentionFilteredAgents", "showMentionAutocomplete", "filter", "filterLower", "index", "mention", "completeMention", "hideMentionAutocomplete", "isMentionAutocompleteVisible", "navigateMentionAutocomplete", "direction", "selectedMention", "input", "value", "atMatch", "completedText", "getCurrentMentionQuery", "cursorPos", "openSpawnModal", "closeSpawnModal", "spawnAgent", "cli", "task", "response", "result", "fetchSpawnedAgents", "err", "initApp", "elements", "initElements", "subscribe", "updateConnectionStatus", "renderAgents", "renderMessages", "updateOnlineCount", "setupEventListeners", "connect", "fetchSpawnedAgents", "item", "channel", "selectChannel", "handleSend", "isMentionAutocompleteVisible", "completeMention", "navigateMentionAutocomplete", "hideMentionAutocomplete", "query", "getCurrentMentionQuery", "showMentionAutocomplete", "input", "start", "end", "text", "before", "after", "selected", "emojis", "emoji", "openCommandPalette", "closeCommandPalette", "target", "filterPaletteResults", "handlePaletteKeydown", "command", "initPaletteChannels", "closeThreadPanel", "handleThreadSend", "openSpawnModal", "closeSpawnModal", "spawnAgent", "parseMention", "match", "getElements", "rawMessage", "to", "message", "isInDM", "state", "parsed", "result", "sendMessage", "threadId", "renderThreadMessages"] +} diff --git a/src/dashboard/public/metrics.html b/src/dashboard/public/metrics.html new file mode 100644 index 000000000..3572bb596 --- /dev/null +++ b/src/dashboard/public/metrics.html @@ -0,0 +1,999 @@ + + + + + + Metrics | Agent Relay + + + + + + +
    +
    +
    + + + + + Dashboard + + +
    +
    + + LIVE +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + + diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 4845f6fc2..f2dfa1304 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -3,10 +3,16 @@ import { WebSocketServer, WebSocket } from 'ws'; import http from 'http'; import path from 'path'; import fs from 'fs'; +import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { SqliteStorageAdapter } from '../storage/sqlite-adapter.js'; import type { StorageAdapter, StoredMessage } from '../storage/adapter.js'; import { RelayClient } from '../wrapper/client.js'; +import { computeNeedsAttention } from './needs-attention.js'; +import { computeSystemMetrics, formatPrometheusMetrics } from './metrics.js'; +import { MultiProjectClient } from '../bridge/multi-project-client.js'; +import { AgentSpawner } from '../bridge/spawner.js'; +import type { ProjectConfig, SpawnRequest, WorkerInfo } from '../bridge/types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,6 +25,7 @@ interface AgentStatus { status?: string; lastActive?: string; lastSeen?: string; + needsAttention?: boolean; } interface Message { @@ -57,7 +64,34 @@ interface AgentSummary { context?: string; } -export async function startDashboard(port: number, dataDir: string, teamDir: string, dbPath?: string): Promise { +export interface DashboardOptions { + port: number; + dataDir: string; + teamDir: string; + dbPath?: string; + /** Enable agent spawning API */ + enableSpawner?: boolean; + /** Project root for spawner (defaults to dataDir) */ + projectRoot?: string; + /** Tmux session name for workers */ + tmuxSession?: string; +} + +export async function startDashboard(port: number, dataDir: string, teamDir: string, dbPath?: string): Promise; +export async function startDashboard(options: DashboardOptions): Promise; +export async function startDashboard( + portOrOptions: number | DashboardOptions, + dataDirArg?: string, + teamDirArg?: string, + dbPathArg?: string +): Promise { + // Handle overloaded signatures + const options: DashboardOptions = typeof portOrOptions === 'number' + ? { port: portOrOptions, dataDir: dataDirArg!, teamDir: teamDirArg!, dbPath: dbPathArg } + : portOrOptions; + + const { port, dataDir, teamDir, dbPath, enableSpawner, projectRoot, tmuxSession } = options; + console.log('Starting dashboard...'); console.log('__dirname:', __dirname); const publicDir = path.join(__dirname, 'public'); @@ -66,6 +100,11 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str ? new SqliteStorageAdapter({ dbPath }) : undefined; + // Initialize spawner if enabled + const spawner: AgentSpawner | undefined = enableSpawner + ? new AgentSpawner(projectRoot || dataDir, tmuxSession) + : undefined; + process.on('uncaughtException', (err) => { console.error('Uncaught Exception:', err); }); @@ -76,7 +115,50 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str const app = express(); const server = http.createServer(app); - const wss = new WebSocketServer({ server, path: '/ws' }); + + // Use noServer mode to manually route upgrade requests + // This prevents the bug where multiple WebSocketServers attached to the same + // HTTP server cause conflicts - each one's upgrade handler fires and the ones + // that don't match the path call abortHandshake(400), writing raw HTTP to the socket + const wss = new WebSocketServer({ + noServer: true, + perMessageDeflate: false, + skipUTF8Validation: true, + maxPayload: 100 * 1024 * 1024 // 100MB + }); + const wssBridge = new WebSocketServer({ + noServer: true, + perMessageDeflate: false, + skipUTF8Validation: true, + maxPayload: 100 * 1024 * 1024 + }); + + // Manually handle upgrade requests and route to correct WebSocketServer + server.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname; + + if (pathname === '/ws') { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + } else if (pathname === '/ws/bridge') { + wssBridge.handleUpgrade(request, socket, head, (ws) => { + wssBridge.emit('connection', ws, request); + }); + } else { + // Unknown path - destroy socket + socket.destroy(); + } + }); + + // Server-level error handlers + wss.on('error', (err) => { + console.error('[dashboard] WebSocket server error:', err); + }); + + wssBridge.on('error', (err) => { + console.error('[dashboard] Bridge WebSocket server error:', err); + }); if (storage) { await storage.init(); } @@ -124,9 +206,79 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str // Start relay client connection (non-blocking) connectRelayClient().catch(() => {}); + // Bridge client for cross-project messaging + let bridgeClient: MultiProjectClient | undefined; + let bridgeClientConnecting = false; + + const connectBridgeClient = async (): Promise => { + if (bridgeClient || bridgeClientConnecting) return; + + // Check if bridge-state.json exists and has projects + const bridgeStatePath = path.join(dataDir, 'bridge-state.json'); + if (!fs.existsSync(bridgeStatePath)) { + return; + } + + try { + const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8')); + if (!bridgeState.connected || !bridgeState.projects?.length) { + return; + } + + bridgeClientConnecting = true; + + // Build project configs from bridge state + const projectConfigs: ProjectConfig[] = bridgeState.projects.map((p: { + id: string; + path: string; + lead?: { name: string }; + }) => { + // Compute socket path for each project + const projectHash = crypto.createHash('sha256').update(p.path).digest('hex').slice(0, 12); + const projectDataDir = path.join(path.dirname(dataDir), projectHash); + const socketPath = path.join(projectDataDir, 'relay.sock'); + + return { + id: p.id, + path: p.path, + socketPath, + leadName: p.lead?.name || 'Lead', + cli: 'dashboard-bridge', + }; + }); + + // Filter to projects with existing sockets + const validConfigs = projectConfigs.filter((p: ProjectConfig) => fs.existsSync(p.socketPath)); + if (validConfigs.length === 0) { + bridgeClientConnecting = false; + return; + } + + bridgeClient = new MultiProjectClient(validConfigs, { + agentName: '__DashboardBridge__', // Unique name to avoid conflict with CLI bridge + reconnect: true, + }); + + bridgeClient.onProjectStateChange = (projectId, connected) => { + console.log(`[dashboard-bridge] Project ${projectId} ${connected ? 'connected' : 'disconnected'}`); + }; + + await bridgeClient.connect(); + console.log('[dashboard] Bridge client connected to', validConfigs.length, 'project(s)'); + bridgeClientConnecting = false; + } catch (err) { + console.error('[dashboard] Failed to connect bridge client:', err); + bridgeClient = undefined; + bridgeClientConnecting = false; + } + }; + + // Start bridge client connection (non-blocking) + connectBridgeClient().catch(() => {}); + // API endpoint to send messages app.post('/api/send', async (req, res) => { - const { to, message } = req.body; + const { to, message, thread } = req.body; if (!to || !message) { return res.status(400).json({ error: 'Missing "to" or "message" field' }); @@ -141,7 +293,7 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str } try { - const sent = relayClient.sendMessage(to, message); + const sent = relayClient.sendMessage(to, message, 'message', undefined, thread); if (sent) { res.json({ success: true }); } else { @@ -153,6 +305,35 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str } }); + // API endpoint to send messages via bridge (cross-project) + app.post('/api/bridge/send', async (req, res) => { + const { projectId, to, message } = req.body; + + if (!projectId || !to || !message) { + return res.status(400).json({ error: 'Missing "projectId", "to", or "message" field' }); + } + + // Try to connect bridge client if not connected + if (!bridgeClient) { + await connectBridgeClient(); + if (!bridgeClient) { + return res.status(503).json({ error: 'Bridge not connected. Is the bridge command running?' }); + } + } + + try { + const sent = bridgeClient.sendToProject(projectId, to, message); + if (sent) { + res.json({ success: true }); + } else { + res.status(500).json({ error: `Failed to send message to ${projectId}:${to}` }); + } + } catch (err) { + console.error('[dashboard] Failed to send bridge message:', err); + res.status(500).json({ error: 'Failed to send bridge message' }); + } + }); + const getTeamData = () => { // Try team.json first (file-based team mode) const teamPath = path.join(teamDir, 'team.json'); @@ -244,7 +425,7 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str const getMessages = async (agents: any[]): Promise => { if (storage) { - const rows = await storage.getMessages({ limit: 500, order: 'desc' }); + const rows = await storage.getMessages({ limit: 100, order: 'desc' }); // Dashboard expects oldest first return mapStoredMessages(rows).reverse(); } @@ -319,6 +500,7 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str status: 'Idle', lastSeen: a.lastSeen, lastActive: a.lastActive, + needsAttention: false, }); }); @@ -339,25 +521,62 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str // Derive status from messages sent BY agents // We scan all messages; if M is from A, we check if it is a STATUS message + // Note: lastActive is updated from messages, but lastSeen comes from the registry + // (heartbeat-based) and should NOT be overwritten by message timestamps allMessages.forEach(m => { const agent = agentsMap.get(m.from); if (agent) { agent.lastActive = m.timestamp; - agent.lastSeen = m.timestamp; + // Don't overwrite lastSeen - it comes from registry (heartbeat/connection tracking) if (m.content.startsWith('STATUS:')) { agent.status = m.content.substring(7).trim(); // remove "STATUS:" } } }); + // Detect agents with unanswered inbound messages (needs attention) + const needsAttentionAgents = computeNeedsAttention(allMessages.map((m) => ({ + from: m.from, + to: m.to, + timestamp: m.timestamp, + thread: m.thread, + }))); + + needsAttentionAgents.forEach((agentName) => { + const agent = agentsMap.get(agentName); + if (agent) { + agent.needsAttention = true; + } + }); + // Fetch sessions and summaries in parallel const [sessions, summaries] = await Promise.all([ getRecentSessions(), getAgentSummaries(), ]); + // Filter agents: + // 1. Exclude "Dashboard" (internal agent, not a real team member) + // 2. Exclude offline agents (no lastSeen or lastSeen > 5 minutes ago) + const now = Date.now(); + const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + const filteredAgents = Array.from(agentsMap.values()).filter(agent => { + // Exclude Dashboard + if (agent.name === 'Dashboard') return false; + + // Exclude agents starting with __ (internal/system agents) + if (agent.name.startsWith('__')) return false; + + // Exclude offline agents (no lastSeen or too old) + if (!agent.lastSeen) return false; + const lastSeenTime = new Date(agent.lastSeen).getTime(); + if (now - lastSeenTime > OFFLINE_THRESHOLD_MS) return false; + + return true; + }); + return { - agents: Array.from(agentsMap.values()), + agents: filteredAgents, messages: allMessages, activity: allMessages, // For now, activity log is just the message log sessions, @@ -365,16 +584,188 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str }; }; + // Track clients that are still initializing (haven't received first data yet) + // This prevents race conditions where broadcastData sends before initial data is sent + const initializingClients = new WeakSet(); + const broadcastData = async () => { - const data = await getAllData(); - const payload = JSON.stringify(data); - wss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(payload); + try { + const data = await getAllData(); + const payload = JSON.stringify(data); + + // Guard against empty/invalid payloads + if (!payload || payload.length === 0) { + console.warn('[dashboard] Skipping broadcast - empty payload'); + return; } - }); + + wss.clients.forEach(client => { + // Skip clients that are still being initialized by the connection handler + if (initializingClients.has(client)) { + return; + } + if (client.readyState === WebSocket.OPEN) { + try { + client.send(payload); + } catch (err) { + console.error('[dashboard] Failed to send to client:', err); + } + } + }); + } catch (err) { + console.error('[dashboard] Failed to broadcast data:', err); + } }; + // Bridge data functions - defined before connection handlers + const getBridgeData = async () => { + const bridgeStatePath = path.join(dataDir, 'bridge-state.json'); + if (fs.existsSync(bridgeStatePath)) { + try { + const bridgeState = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8')); + + // Enrich each project with actual agent data from their team directories + if (bridgeState.projects && Array.isArray(bridgeState.projects)) { + for (const project of bridgeState.projects) { + if (project.path) { + // Get project's data directory + const crypto = await import('crypto'); + const projectHash = crypto.createHash('sha256').update(project.path).digest('hex').slice(0, 12); + const projectDataDir = path.join(path.dirname(dataDir), projectHash); + const projectTeamDir = path.join(projectDataDir, 'team'); + const agentsPath = path.join(projectTeamDir, 'agents.json'); + + // Read actual connected agents + if (fs.existsSync(agentsPath)) { + try { + const agentsData = JSON.parse(fs.readFileSync(agentsPath, 'utf-8')); + if (agentsData.agents && Array.isArray(agentsData.agents)) { + // Filter to only show online agents (seen in last 5 minutes) + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + project.agents = agentsData.agents + .filter((a: { lastSeen?: string }) => { + if (!a.lastSeen) return false; + return new Date(a.lastSeen).getTime() > fiveMinutesAgo; + }) + .map((a: { name: string; cli?: string; lastSeen?: string }) => ({ + name: a.name, + status: 'active', + cli: a.cli, + lastSeen: a.lastSeen, + })); + + // Update lead status based on actual agents + if (project.lead) { + const leadAgent = project.agents.find((a: { name: string }) => + a.name.toLowerCase() === project.lead.name.toLowerCase() + ); + project.lead.connected = !!leadAgent; + } + } + } catch (e) { + console.error(`Failed to read agents for ${project.path}:`, e); + } + } + } + } + } + + return bridgeState; + } catch { + return { projects: [], messages: [], connected: false }; + } + } + return { projects: [], messages: [], connected: false }; + }; + + const broadcastBridgeData = async () => { + try { + const data = await getBridgeData(); + const payload = JSON.stringify(data); + + // Guard against empty/invalid payloads + if (!payload || payload.length === 0) { + console.warn('[dashboard] Skipping bridge broadcast - empty payload'); + return; + } + + wssBridge.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + try { + client.send(payload); + } catch (err) { + console.error('[dashboard] Failed to send to bridge client:', err); + } + } + }); + } catch (err) { + console.error('[dashboard] Failed to broadcast bridge data:', err); + } + }; + + // Handle new WebSocket connections - send initial data immediately + wss.on('connection', async (ws, req) => { + console.log('[dashboard] WebSocket client connected from:', req.socket.remoteAddress); + + // Mark as initializing to prevent broadcastData from sending before we do + initializingClients.add(ws); + + try { + const data = await getAllData(); + const payload = JSON.stringify(data); + + // Guard against empty/invalid payloads + if (!payload || payload.length === 0) { + console.warn('[dashboard] Skipping initial send - empty payload'); + return; + } + + if (ws.readyState === WebSocket.OPEN) { + console.log('[dashboard] Sending initial data, size:', payload.length, 'first 200 chars:', payload.substring(0, 200)); + ws.send(payload); + console.log('[dashboard] Initial data sent successfully'); + } else { + console.warn('[dashboard] WebSocket not open, state:', ws.readyState); + } + } catch (err) { + console.error('[dashboard] Failed to send initial data:', err); + } finally { + // Now allow broadcastData to send to this client + initializingClients.delete(ws); + } + + ws.on('error', (err) => { + console.error('[dashboard] WebSocket client error:', err); + }); + + ws.on('close', (code, reason) => { + console.log('[dashboard] WebSocket client disconnected, code:', code, 'reason:', reason?.toString() || 'none'); + }); + }); + + // Handle bridge WebSocket connections + wssBridge.on('connection', async (ws) => { + console.log('[dashboard] Bridge WebSocket client connected'); + + try { + const data = await getBridgeData(); + const payload = JSON.stringify(data); + if (ws.readyState === WebSocket.OPEN) { + ws.send(payload); + } + } catch (err) { + console.error('[dashboard] Failed to send initial bridge data:', err); + } + + ws.on('error', (err) => { + console.error('[dashboard] Bridge WebSocket client error:', err); + }); + + ws.on('close', (code, reason) => { + console.log('[dashboard] Bridge WebSocket client disconnected, code:', code, 'reason:', reason?.toString() || 'none'); + }); + }); + app.get('/api/data', (req, res) => { getAllData().then((data) => res.json(data)).catch((err) => { console.error('Failed to fetch dashboard data', err); @@ -382,13 +773,242 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str }); }); + // ===== Metrics API ===== + + /** + * GET /api/metrics - JSON format metrics for dashboard + */ + app.get('/api/metrics', async (req, res) => { + try { + // Read agent registry for message counts + const agentsPath = path.join(teamDir, 'agents.json'); + let agentRecords: Array<{ + name: string; + messagesSent: number; + messagesReceived: number; + firstSeen: string; + lastSeen: string; + }> = []; + + if (fs.existsSync(agentsPath)) { + const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8')); + agentRecords = (data.agents || []).map((a: any) => ({ + name: a.name, + messagesSent: a.messagesSent ?? 0, + messagesReceived: a.messagesReceived ?? 0, + firstSeen: a.firstSeen ?? new Date().toISOString(), + lastSeen: a.lastSeen ?? new Date().toISOString(), + })); + } + + // Get messages for throughput calculation + const team = getTeamData(); + const messages = team ? await getMessages(team.agents) : []; + + // Get session data for lifecycle metrics + const sessions = storage?.getSessions + ? await storage.getSessions({ limit: 100 }) + : []; + + const metrics = computeSystemMetrics(agentRecords, messages, sessions); + res.json(metrics); + } catch (err) { + console.error('Failed to compute metrics', err); + res.status(500).json({ error: 'Failed to compute metrics' }); + } + }); + + /** + * GET /api/metrics/prometheus - Prometheus exposition format + */ + app.get('/api/metrics/prometheus', async (req, res) => { + try { + // Read agent registry for message counts + const agentsPath = path.join(teamDir, 'agents.json'); + let agentRecords: Array<{ + name: string; + messagesSent: number; + messagesReceived: number; + firstSeen: string; + lastSeen: string; + }> = []; + + if (fs.existsSync(agentsPath)) { + const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8')); + agentRecords = (data.agents || []).map((a: any) => ({ + name: a.name, + messagesSent: a.messagesSent ?? 0, + messagesReceived: a.messagesReceived ?? 0, + firstSeen: a.firstSeen ?? new Date().toISOString(), + lastSeen: a.lastSeen ?? new Date().toISOString(), + })); + } + + // Get messages for throughput calculation + const team = getTeamData(); + const messages = team ? await getMessages(team.agents) : []; + + // Get session data for lifecycle metrics + const sessions = storage?.getSessions + ? await storage.getSessions({ limit: 100 }) + : []; + + const metrics = computeSystemMetrics(agentRecords, messages, sessions); + const prometheusOutput = formatPrometheusMetrics(metrics); + + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(prometheusOutput); + } catch (err) { + console.error('Failed to compute Prometheus metrics', err); + res.status(500).send('# Error computing metrics\n'); + } + }); + + // Metrics view route - serves metrics.html + app.get('/metrics', (req, res) => { + res.sendFile(path.join(publicDir, 'metrics.html')); + }); + + // Bridge view route - serves bridge.html + app.get('/bridge', (req, res) => { + res.sendFile(path.join(publicDir, 'bridge.html')); + }); + + // Bridge API endpoint - returns multi-project data + // This is a placeholder that returns empty data when not in bridge mode + // The actual bridge data comes from MultiProjectClient when running `agent-relay bridge` + app.get('/api/bridge', async (req, res) => { + try { + // Check if bridge state file exists (written by bridge command) + const bridgeStatePath = path.join(dataDir, 'bridge-state.json'); + if (fs.existsSync(bridgeStatePath)) { + const bridgeData = JSON.parse(fs.readFileSync(bridgeStatePath, 'utf-8')); + res.json(bridgeData); + } else { + // No bridge running - return empty state + res.json({ + projects: [], + messages: [], + connected: false, + }); + } + } catch (err) { + console.error('Failed to fetch bridge data', err); + res.status(500).json({ error: 'Failed to load bridge data' }); + } + }); + + // ===== Agent Spawn API ===== + + /** + * POST /api/spawn - Spawn a new agent + * Body: { name: string, cli?: string, task?: string } + */ + app.post('/api/spawn', async (req, res) => { + if (!spawner) { + return res.status(503).json({ + success: false, + error: 'Spawner not enabled. Start dashboard with enableSpawner: true', + }); + } + + const { name, cli = 'claude', task = '' } = req.body; + + if (!name || typeof name !== 'string') { + return res.status(400).json({ + success: false, + error: 'Missing required field: name', + }); + } + + try { + const request: SpawnRequest = { + name, + cli, + task, + requestedBy: 'api', + }; + const result = await spawner.spawn(request); + + if (result.success) { + // Broadcast update to WebSocket clients + broadcastData().catch(() => {}); + } + + res.json(result); + } catch (err: any) { + console.error('[api] Spawn error:', err); + res.status(500).json({ + success: false, + name, + error: err.message, + }); + } + }); + + /** + * GET /api/spawned - List active spawned agents + */ + app.get('/api/spawned', (req, res) => { + if (!spawner) { + return res.status(503).json({ + success: false, + error: 'Spawner not enabled', + agents: [], + }); + } + + const agents = spawner.getActiveWorkers(); + res.json({ + success: true, + agents, + }); + }); + + /** + * DELETE /api/spawned/:name - Release a spawned agent + */ + app.delete('/api/spawned/:name', async (req, res) => { + if (!spawner) { + return res.status(503).json({ + success: false, + error: 'Spawner not enabled', + }); + } + + const { name } = req.params; + + try { + const released = await spawner.release(name); + + if (released) { + broadcastData().catch(() => {}); + } + + res.json({ + success: released, + name, + error: released ? undefined : `Agent ${name} not found`, + }); + } catch (err: any) { + console.error('[api] Release error:', err); + res.status(500).json({ + success: false, + name, + error: err.message, + }); + } + }); + // Watch for changes if (storage) { setInterval(() => { broadcastData().catch((err) => console.error('Broadcast failed', err)); + broadcastBridgeData().catch((err) => console.error('Bridge broadcast failed', err)); }, 1000); } else { let fsWait: NodeJS.Timeout | null = null; + let bridgeFsWait: NodeJS.Timeout | null = null; try { if (fs.existsSync(dataDir)) { console.log(`Watching ${dataDir} for changes...`); @@ -401,6 +1021,14 @@ export async function startDashboard(port: number, dataDir: string, teamDir: str broadcastData(); }, 100); } + // Watch for bridge state changes + if (filename && filename.endsWith('bridge-state.json')) { + if (bridgeFsWait) return; + bridgeFsWait = setTimeout(() => { + bridgeFsWait = null; + broadcastBridgeData(); + }, 100); + } }); } else { console.warn(`Data directory ${dataDir} does not exist yet.`); diff --git a/src/dashboard/start.ts b/src/dashboard/start.ts index 36d55373e..6cf26a6a1 100644 --- a/src/dashboard/start.ts +++ b/src/dashboard/start.ts @@ -13,4 +13,4 @@ console.log(`Starting dashboard for project: ${paths.projectRoot}`); console.log(`Data dir: ${paths.dataDir}`); console.log(`Database: ${paths.dbPath}`); -startDashboard(port, paths.dataDir, paths.dbPath).catch(console.error); +startDashboard(port, paths.dataDir, paths.teamDir, paths.dbPath).catch(console.error); diff --git a/src/storage/sqlite-adapter.ts b/src/storage/sqlite-adapter.ts index b1e414974..118629667 100644 --- a/src/storage/sqlite-adapter.ts +++ b/src/storage/sqlite-adapter.ts @@ -201,6 +201,30 @@ export class SqliteStorageAdapter implements StorageAdapter { CREATE INDEX IF NOT EXISTS idx_summaries_updated ON agent_summaries (last_updated); `); + // Create presence table for real-time status tracking + this.db.exec(` + CREATE TABLE IF NOT EXISTS presence ( + agent_name TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'offline', + status_text TEXT, + last_activity INTEGER NOT NULL, + typing_in TEXT + ); + CREATE INDEX IF NOT EXISTS idx_presence_status ON presence (status); + CREATE INDEX IF NOT EXISTS idx_presence_activity ON presence (last_activity); + `); + + // Create read_state table for tracking last read message per channel/conversation + this.db.exec(` + CREATE TABLE IF NOT EXISTS read_state ( + agent_name TEXT NOT NULL, + channel TEXT NOT NULL, + last_read_ts INTEGER NOT NULL, + last_read_id TEXT, + PRIMARY KEY (agent_name, channel) + ); + `); + this.insertStmt = this.db.prepare(` INSERT OR REPLACE INTO messages (id, ts, sender, recipient, topic, kind, body, data, thread, delivery_seq, delivery_session_id, session_id, status, is_urgent) @@ -619,4 +643,167 @@ export class SqliteStorageAdapter implements StorageAdapter { files: row.files ? JSON.parse(row.files) : undefined, })); } + + // ============ Presence Management ============ + + async updatePresence(presence: { + agentName: string; + status: 'online' | 'away' | 'busy' | 'offline'; + statusText?: string; + typingIn?: string; + }): Promise { + if (!this.db) { + throw new Error('SqliteStorageAdapter not initialized'); + } + + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO presence + (agent_name, status, status_text, last_activity, typing_in) + VALUES (?, ?, ?, ?, ?) + `); + + stmt.run( + presence.agentName, + presence.status, + presence.statusText ?? null, + Date.now(), + presence.typingIn ?? null + ); + } + + async getPresence(agentName: string): Promise<{ + agentName: string; + status: 'online' | 'away' | 'busy' | 'offline'; + statusText?: string; + lastActivity: number; + typingIn?: string; + } | null> { + if (!this.db) { + throw new Error('SqliteStorageAdapter not initialized'); + } + + const stmt = this.db.prepare(` + SELECT agent_name, status, status_text, last_activity, typing_in + FROM presence + WHERE agent_name = ? + `); + + const row: any = stmt.get(agentName); + if (!row) return null; + + return { + agentName: row.agent_name, + status: row.status, + statusText: row.status_text ?? undefined, + lastActivity: row.last_activity, + typingIn: row.typing_in ?? undefined, + }; + } + + async getAllPresence(): Promise> { + if (!this.db) { + throw new Error('SqliteStorageAdapter not initialized'); + } + + const stmt = this.db.prepare(` + SELECT agent_name, status, status_text, last_activity, typing_in + FROM presence + ORDER BY last_activity DESC + `); + + const rows = stmt.all(); + return rows.map((row: any) => ({ + agentName: row.agent_name, + status: row.status, + statusText: row.status_text ?? undefined, + lastActivity: row.last_activity, + typingIn: row.typing_in ?? undefined, + })); + } + + async setTypingIndicator(agentName: string, channel: string | null): Promise { + if (!this.db) { + throw new Error('SqliteStorageAdapter not initialized'); + } + + const stmt = this.db.prepare(` + UPDATE presence + SET typing_in = ?, last_activity = ? + WHERE agent_name = ? + `); + + stmt.run(channel, Date.now(), agentName); + } + + // ============ Read State Management ============ + + async updateReadState(agentName: string, channel: string, lastReadTs: number, lastReadId?: string): Promise { + if (!this.db) { + throw new Error('SqliteStorageAdapter not initialized'); + } + + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO read_state + (agent_name, channel, last_read_ts, last_read_id) + VALUES (?, ?, ?, ?) + `); + + stmt.run(agentName, channel, lastReadTs, lastReadId ?? null); + } + + async getReadState(agentName: string, channel: string): Promise<{ + lastReadTs: number; + lastReadId?: string; + } | null> { + if (!this.db) { + throw new Error('SqliteStorageAdapter not initialized'); + } + + const stmt = this.db.prepare(` + SELECT last_read_ts, last_read_id + FROM read_state + WHERE agent_name = ? AND channel = ? + `); + + const row: any = stmt.get(agentName, channel); + if (!row) return null; + + return { + lastReadTs: row.last_read_ts, + lastReadId: row.last_read_id ?? undefined, + }; + } + + async getUnreadCounts(agentName: string): Promise> { + if (!this.db) { + throw new Error('SqliteStorageAdapter not initialized'); + } + + // Get all read states for this agent + const readStates = this.db.prepare(` + SELECT channel, last_read_ts FROM read_state WHERE agent_name = ? + `).all(agentName) as Array<{ channel: string; last_read_ts: number }>; + + const counts: Record = {}; + + // Count unread messages for each channel (conversation with agent) + for (const { channel, last_read_ts } of readStates) { + const count = this.db.prepare(` + SELECT COUNT(*) as count FROM messages + WHERE recipient = ? AND ts > ? + `).get(channel, last_read_ts) as { count: number }; + + if (count.count > 0) { + counts[channel] = count.count; + } + } + + return counts; + } } diff --git a/src/utils/agent-config.test.ts b/src/utils/agent-config.test.ts new file mode 100644 index 000000000..ac2d882be --- /dev/null +++ b/src/utils/agent-config.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { findAgentConfig, isClaudeCli, buildClaudeArgs } from './agent-config.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +describe('agent-config', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-config-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('findAgentConfig', () => { + it('returns null when no config exists', () => { + const result = findAgentConfig('Lead', tempDir); + expect(result).toBeNull(); + }); + + it('finds config in .claude/agents/', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'lead.md'), `--- +name: lead +model: haiku +description: Test agent +--- + +# Lead Agent +`); + + const result = findAgentConfig('Lead', tempDir); + expect(result).not.toBeNull(); + expect(result?.name).toBe('lead'); + expect(result?.model).toBe('haiku'); + expect(result?.description).toBe('Test agent'); + }); + + it('finds config case-insensitively', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'MyAgent.md'), `--- +name: MyAgent +model: opus +--- +`); + + const result = findAgentConfig('myagent', tempDir); + expect(result).not.toBeNull(); + expect(result?.name).toBe('MyAgent'); + expect(result?.model).toBe('opus'); + }); + + it('finds config in .openagents/', () => { + const agentsDir = path.join(tempDir, '.openagents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'worker.md'), `--- +name: worker +model: sonnet +--- +`); + + const result = findAgentConfig('Worker', tempDir); + expect(result).not.toBeNull(); + expect(result?.model).toBe('sonnet'); + }); + + it('prefers .claude/agents/ over .openagents/', () => { + // Create both directories + const claudeDir = path.join(tempDir, '.claude', 'agents'); + const openDir = path.join(tempDir, '.openagents'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.mkdirSync(openDir, { recursive: true }); + + fs.writeFileSync(path.join(claudeDir, 'agent.md'), `--- +model: haiku +--- +`); + fs.writeFileSync(path.join(openDir, 'agent.md'), `--- +model: opus +--- +`); + + const result = findAgentConfig('agent', tempDir); + expect(result?.model).toBe('haiku'); // Claude takes precedence + }); + + it('parses allowed-tools from frontmatter', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'test.md'), `--- +name: test +allowed-tools: Read, Grep, Glob +--- +`); + + const result = findAgentConfig('test', tempDir); + expect(result?.allowedTools).toEqual(['Read', 'Grep', 'Glob']); + }); + }); + + describe('isClaudeCli', () => { + it('returns true for "claude"', () => { + expect(isClaudeCli('claude')).toBe(true); + }); + + it('returns true for paths containing claude', () => { + expect(isClaudeCli('/usr/local/bin/claude')).toBe(true); + }); + + it('returns false for other commands', () => { + expect(isClaudeCli('codex')).toBe(false); + expect(isClaudeCli('gemini')).toBe(false); + expect(isClaudeCli('node')).toBe(false); + }); + }); + + describe('buildClaudeArgs', () => { + it('returns existing args when no config found', () => { + const args = buildClaudeArgs('Unknown', ['--debug'], tempDir); + expect(args).toEqual(['--debug']); + }); + + it('adds --model and --agent when config found', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'lead.md'), `--- +name: lead +model: haiku +--- +`); + + const args = buildClaudeArgs('Lead', [], tempDir); + expect(args).toContain('--model'); + expect(args).toContain('haiku'); + expect(args).toContain('--agent'); + expect(args).toContain('lead'); + }); + + it('does not duplicate --model if already present', () => { + const agentsDir = path.join(tempDir, '.claude', 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'lead.md'), `--- +model: haiku +--- +`); + + const args = buildClaudeArgs('Lead', ['--model', 'opus'], tempDir); + const modelCount = args.filter(a => a === '--model').length; + expect(modelCount).toBe(1); + expect(args).toContain('opus'); // Original preserved + }); + }); +}); diff --git a/src/utils/agent-config.ts b/src/utils/agent-config.ts new file mode 100644 index 000000000..bbe419e7f --- /dev/null +++ b/src/utils/agent-config.ts @@ -0,0 +1,157 @@ +/** + * Agent Config Detection + * + * Detects agent configuration from .claude/agents/ or .openagents/ directories. + * Parses frontmatter to extract model, description, and other settings. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export interface AgentConfig { + /** Agent name (from filename or frontmatter) */ + name: string; + /** Path to the config file */ + configPath: string; + /** Model to use (e.g., 'haiku', 'sonnet', 'opus') */ + model?: string; + /** Agent description */ + description?: string; + /** Allowed tools */ + allowedTools?: string[]; + /** Agent type */ + agentType?: string; +} + +/** + * Parse YAML-style frontmatter from markdown content. + * Handles basic key: value pairs. + */ +function parseFrontmatter(content: string): Record { + const result: Record = {}; + + // Check for frontmatter delimiters + if (!content.startsWith('---')) { + return result; + } + + const endIndex = content.indexOf('---', 3); + if (endIndex === -1) { + return result; + } + + const frontmatter = content.slice(3, endIndex).trim(); + const lines = frontmatter.split('\n'); + + for (const line of lines) { + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) continue; + + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + + if (key && value) { + result[key] = value; + } + } + + return result; +} + +/** + * Find and parse agent config for a given agent name. + * Searches in order: + * 1. .claude/agents/.md (case-insensitive) + * 2. .openagents/.md (case-insensitive) + * + * @param agentName The agent name to look up + * @param projectRoot The project root directory (defaults to cwd) + * @returns AgentConfig if found, null otherwise + */ +export function findAgentConfig(agentName: string, projectRoot?: string): AgentConfig | null { + const root = projectRoot ?? process.cwd(); + const lowerName = agentName.toLowerCase(); + + // Directories to search + const searchDirs = [ + path.join(root, '.claude', 'agents'), + path.join(root, '.openagents'), + ]; + + for (const dir of searchDirs) { + if (!fs.existsSync(dir)) { + continue; + } + + try { + const files = fs.readdirSync(dir); + + // Find matching file (case-insensitive) + for (const file of files) { + if (!file.endsWith('.md')) continue; + + const baseName = file.slice(0, -3); // Remove .md + if (baseName.toLowerCase() === lowerName) { + const configPath = path.join(dir, file); + const content = fs.readFileSync(configPath, 'utf-8'); + const frontmatter = parseFrontmatter(content); + + return { + name: frontmatter.name || baseName, + configPath, + model: frontmatter.model, + description: frontmatter.description, + allowedTools: frontmatter['allowed-tools']?.split(',').map(t => t.trim()), + agentType: frontmatter.agentType, + }; + } + } + } catch { + // Directory read failed, continue to next + } + } + + return null; +} + +/** + * Check if a command is a Claude CLI command. + */ +export function isClaudeCli(command: string): boolean { + const cmd = command.toLowerCase(); + return cmd === 'claude' || cmd.startsWith('claude ') || cmd.includes('/claude'); +} + +/** + * Build Claude CLI arguments with auto-detected agent config. + * + * @param agentName Agent name + * @param existingArgs Existing command arguments + * @param projectRoot Project root directory + * @returns Modified args array with --model and --agent if applicable + */ +export function buildClaudeArgs( + agentName: string, + existingArgs: string[] = [], + projectRoot?: string +): string[] { + const config = findAgentConfig(agentName, projectRoot); + + if (!config) { + return existingArgs; + } + + const newArgs = [...existingArgs]; + + // Add --model if specified in config and not already in args + if (config.model && !existingArgs.includes('--model')) { + newArgs.push('--model', config.model); + } + + // Add --agent to load the agent's system prompt (if not already specified) + if (!existingArgs.includes('--agent')) { + newArgs.push('--agent', config.name); + } + + return newArgs; +} diff --git a/src/utils/project-namespace.test.ts b/src/utils/project-namespace.test.ts index 34803481f..254c638cf 100644 --- a/src/utils/project-namespace.test.ts +++ b/src/utils/project-namespace.test.ts @@ -145,9 +145,10 @@ describe('project-namespace', () => { it('should include current project if initialized', () => { // Get current project paths const currentPaths = getProjectPaths(); + const markerPath = path.join(currentPaths.dataDir, '.project'); - // Only test if data dir exists (project has been initialized) - if (fs.existsSync(currentPaths.dataDir)) { + // Only test if project marker exists (project has been properly initialized) + if (fs.existsSync(markerPath)) { const projects = listProjects(); const found = projects.find(p => p.projectId === currentPaths.projectId); expect(found).toBeTruthy(); diff --git a/src/wrapper/parser.test.ts b/src/wrapper/parser.test.ts index 7b03e31e0..70a8f19fa 100644 --- a/src/wrapper/parser.test.ts +++ b/src/wrapper/parser.test.ts @@ -508,6 +508,109 @@ Out3 }); }); + describe('Cross-project messaging syntax', () => { + it('parses project:agent syntax for cross-project messaging', () => { + const result = parser.parse('->relay:myproject:agent2 Hello from another project\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]).toMatchObject({ + to: 'agent2', + project: 'myproject', + kind: 'message', + body: 'Hello from another project', + }); + }); + + it('parses local agent without project', () => { + const result = parser.parse('->relay:agent2 Local message\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].to).toBe('agent2'); + expect(result.commands[0].project).toBeUndefined(); + }); + + it('handles project names with dashes and underscores', () => { + const result = parser.parse('->relay:my-project_v2:some-agent Hello\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].to).toBe('some-agent'); + expect(result.commands[0].project).toBe('my-project_v2'); + }); + + it('only splits on first colon to allow colons in agent names', () => { + const result = parser.parse('->relay:proj:agent:with:colons Message\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].to).toBe('agent:with:colons'); + expect(result.commands[0].project).toBe('proj'); + }); + + it('handles cross-project with ->thinking: variant', () => { + const result = parser.parse('->thinking:otherproj:agent2 Thinking about something\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]).toMatchObject({ + to: 'agent2', + project: 'otherproj', + kind: 'thinking', + body: 'Thinking about something', + }); + }); + + it('handles cross-project broadcast', () => { + const result = parser.parse('->relay:prod-project:* Broadcast to all in prod\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0].to).toBe('*'); + expect(result.commands[0].project).toBe('prod-project'); + }); + + it('handles cross-project with thread syntax', () => { + const result = parser.parse('->relay:proj:agent [thread:abc123] Threaded cross-project message\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]).toMatchObject({ + to: 'agent', + project: 'proj', + thread: 'abc123', + body: 'Threaded cross-project message', + }); + }); + + it('parses cross-project in block format with explicit project field', () => { + const result = parser.parse('[[RELAY]]{"to":"agent2","project":"otherproj","type":"message","body":"Hello"}[[/RELAY]]\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]).toMatchObject({ + to: 'agent2', + project: 'otherproj', + kind: 'message', + body: 'Hello', + }); + }); + + it('parses cross-project in block format with colon syntax in to field', () => { + const result = parser.parse('[[RELAY]]{"to":"myproj:agent2","type":"message","body":"Hi"}[[/RELAY]]\n'); + + expect(result.commands).toHaveLength(1); + expect(result.commands[0]).toMatchObject({ + to: 'agent2', + project: 'myproj', + kind: 'message', + body: 'Hi', + }); + }); + + it('explicit project field takes precedence over colon syntax in block format', () => { + const result = parser.parse('[[RELAY]]{"to":"ignored:agent2","project":"explicit","type":"message","body":"Test"}[[/RELAY]]\n'); + + expect(result.commands).toHaveLength(1); + // When explicit project is set, the to field is used as-is + expect(result.commands[0].to).toBe('ignored:agent2'); + expect(result.commands[0].project).toBe('explicit'); + }); + }); + describe('Configurable prefix', () => { it('uses default ->relay: prefix', () => { const defaultParser = new OutputParser(); diff --git a/src/wrapper/parser.ts b/src/wrapper/parser.ts index f4bf8a351..a38c93a9e 100644 --- a/src/wrapper/parser.ts +++ b/src/wrapper/parser.ts @@ -22,6 +22,8 @@ export interface ParsedCommand { data?: Record; /** Optional thread ID for grouping related messages */ thread?: string; + /** Optional project for cross-project messaging (e.g., ->relay:project:agent) */ + project?: string; raw: string; meta?: ParsedMessageMetadata; } @@ -89,6 +91,29 @@ function buildEscapePattern(prefix: string, thinkingPrefix: string): RegExp { // eslint-disable-next-line no-control-regex const ANSI_PATTERN = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)|\r/g; +/** + * Parse a target string that may contain cross-project syntax. + * Supports: "agent" (local) or "project:agent" (cross-project) + * + * @param target The raw target string from the relay command + * @returns Object with `to` (agent name) and optional `project` + */ +function parseTarget(target: string): { to: string; project?: string } { + // Check for cross-project syntax: project:agent + // Only split on FIRST colon to allow agent names with colons + const colonIndex = target.indexOf(':'); + + if (colonIndex > 0 && colonIndex < target.length - 1) { + // Has a colon with content on both sides + const project = target.substring(0, colonIndex); + const agent = target.substring(colonIndex + 1); + return { to: agent, project }; + } + + // Local target (no colon or malformed) + return { to: target }; +} + /** * Strip ANSI escape codes from a string for pattern matching. */ @@ -288,13 +313,33 @@ export class OutputParser { return CODE_FENCE.test(line) || line.includes('[[RELAY]]') || BLOCK_END.test(line); }; - const shouldStopContinuation = (line: string): boolean => { + const shouldStopContinuation = (line: string, continuationCount: number, lines: string[], currentIndex: number): boolean => { const trimmed = line.trim(); - if (trimmed === '') return true; // Blank line ends the message if (isInlineStart(line)) return true; if (isBlockMarker(line)) return true; if (PROMPTISH_LINE.test(trimmed)) return true; if (RELAY_INJECTION_PREFIX.test(line)) return true; // Avoid swallowing injected inbound messages + + // Allow blank lines only in structured content like tables or between numbered sections + if (trimmed === '') { + // If we haven't started continuation yet, stop on blank + if (continuationCount === 0) return true; + + // Look ahead to see if there's more content that looks like structured markdown + for (let j = currentIndex + 1; j < lines.length; j++) { + const nextLine = lines[j].trim(); + if (nextLine === '') { + // Double blank line always stops + return true; + } + // Only continue for table rows or numbered list items after blank + if (/^\|/.test(nextLine)) return false; // Table row + if (/^\d+[.)]\s/.test(nextLine)) return false; // Numbered list like "1." or "2)" + // Stop for anything else after a blank line + return true; + } + return true; // No more content, stop + } return false; }; @@ -304,7 +349,7 @@ export class OutputParser { prevStripped: string, continuationCount: number ): boolean => { - if (shouldStopContinuation(stripped)) return false; + // Note: shouldStopContinuation is already checked in the main loop before calling this if (/^[ \t]/.test(original)) return true; // Indented lines from TUI wrapping if (BULLET_OR_NUMBERED_LIST.test(stripped)) return true; // Bullet/numbered lists after ->relay: const prevTrimmed = prevStripped.trimEnd(); @@ -357,7 +402,7 @@ export class OutputParser { const prevStripped = stripAnsi(rawLines[rawLines.length - 1] ?? ''); // Stop if this line clearly marks a new block, prompt, or inline command - if (shouldStopContinuation(nextStripped)) { + if (shouldStopContinuation(nextStripped, continuationLines, lines, i + 1)) { break; } @@ -482,12 +527,14 @@ export class OutputParser { const relayMatch = stripped.match(this.inlineRelayPattern); if (relayMatch) { const [raw, target, threadId, body] = relayMatch; + const { to, project } = parseTarget(target); return { command: { - to: target, + to, kind: 'message', body, thread: threadId || undefined, // undefined if no thread specified + project, // undefined if local, set if cross-project raw, }, output: null, // Don't output relay commands @@ -497,12 +544,14 @@ export class OutputParser { const thinkingMatch = stripped.match(this.inlineThinkingPattern); if (thinkingMatch) { const [raw, target, threadId, body] = thinkingMatch; + const { to, project } = parseTarget(target); return { command: { - to: target, + to, kind: 'thinking', body, thread: threadId || undefined, + project, raw, }, output: null, @@ -549,12 +598,25 @@ export class OutputParser { return { command: null, remaining, metadata: null }; } + // Handle cross-project syntax in block format + // Supports both explicit "project" field and "project:agent" in "to" field + let to = parsed.to; + let project = parsed.project; + + if (!project && typeof to === 'string') { + // Check if "to" field uses project:agent syntax + const targetParsed = parseTarget(to); + to = targetParsed.to; + project = targetParsed.project; + } + const command: ParsedCommand = { - to: parsed.to, + to, kind: parsed.type as PayloadKind, body: parsed.body ?? parsed.text ?? '', data: parsed.data, thread: parsed.thread || undefined, + project: project || undefined, raw: jsonStr, meta: this.lastParsedMetadata || undefined, // Attach last parsed metadata }; diff --git a/src/wrapper/tmux-wrapper.ts b/src/wrapper/tmux-wrapper.ts index a65897ba0..57afc82a9 100644 --- a/src/wrapper/tmux-wrapper.ts +++ b/src/wrapper/tmux-wrapper.ts @@ -13,6 +13,7 @@ */ import { exec, execSync, spawn, ChildProcess } from 'node:child_process'; +import crypto from 'node:crypto'; import { promisify } from 'node:util'; import { RelayClient } from './client.js'; import { OutputParser, type ParsedCommand, parseSummaryWithDetails, parseSessionEndFromOutput, ParsedMessageMetadata } from './parser.js'; @@ -75,6 +76,14 @@ export interface TmuxWrapperConfig { mouseMode?: boolean; /** Relay prefix pattern (default: '->relay:') */ relayPrefix?: string; + /** Callback for spawn commands (@relay:spawn WorkerName cli "task") */ + onSpawn?: (name: string, cli: string, task: string) => Promise; + /** Callback for release commands (@relay:release WorkerName) */ + onRelease?: (name: string) => Promise; + /** Max time to wait for stable pane output before injection (ms) */ + outputStabilityTimeoutMs?: number; + /** Poll interval when checking pane stability before injection (ms) */ + outputStabilityPollMs?: number; } /** @@ -116,6 +125,8 @@ export class TmuxWrapper { private pendingRelayCommands: ParsedCommand[] = []; private queuedMessageHashes: Set = new Set(); // For offline queue dedup private readonly MAX_PENDING_RELAY_COMMANDS = 50; + private processedSpawnCommands: Set = new Set(); // Dedup spawn commands + private processedReleaseCommands: Set = new Set(); // Dedup release commands constructor(config: TmuxWrapperConfig) { this.config = { @@ -128,6 +139,8 @@ export class TmuxWrapper { debugLogIntervalMs: 0, mouseMode: true, // Enable mouse scroll passthrough by default activityIdleThresholdMs: 30_000, // Consider idle after 30s with no output + outputStabilityTimeoutMs: 2000, + outputStabilityPollMs: 200, ...config, }; @@ -315,19 +328,18 @@ export class TmuxWrapper { } } - // Harden session against accidental copy-mode / mouse capture that interrupts agents - const tmuxCopyModeBlockers = [ - 'unbind -T prefix [', // Disable prefix-[ copy-mode - 'unbind -T prefix PageUp', // Disable PageUp copy-mode entry - 'unbind -T root WheelUpPane', // Stop wheel from entering copy-mode + // Mouse scroll should work for both TUIs (alternate screen) and plain shells. + // If the pane is in alternate screen, pass scroll to the app; otherwise enter copy-mode and scroll tmux history. + const tmuxMouseBindings = [ + 'unbind -T root WheelUpPane', 'unbind -T root WheelDownPane', 'unbind -T root MouseDrag1Pane', - 'bind -T root WheelUpPane send-keys -M', // Pass wheel events through to app - 'bind -T root WheelDownPane send-keys -M', - 'bind -T root MouseDrag1Pane send-keys -M', + 'bind -T root WheelUpPane if-shell -F "#{alternate_on}" "send-keys -M" "copy-mode -e; send-keys -X scroll-up"', + 'bind -T root WheelDownPane if-shell -F "#{alternate_on}" "send-keys -M" "send-keys -X scroll-down"', + 'bind -T root MouseDrag1Pane if-shell -F "#{alternate_on}" "send-keys -M" "copy-mode -e"', ]; - for (const setting of tmuxCopyModeBlockers) { + for (const setting of tmuxMouseBindings) { try { execSync(`tmux ${setting}`, { stdio: 'pipe' }); } catch { @@ -532,6 +544,9 @@ export class TmuxWrapper { // Check for [[SESSION_END]] blocks to explicitly close session this.parseSessionEndAndClose(cleanContent); + // Check for @relay:spawn and @relay:release commands (lead mode) + this.parseSpawnReleaseCommands(cleanContent); + this.updateActivityState(); // Also check for injection opportunity @@ -806,6 +821,56 @@ export class TmuxWrapper { }); } + /** + * Parse ->relay:spawn and ->relay:release commands from output. + * Format: + * ->relay:spawn WorkerName cli "task description" + * ->relay:release WorkerName + */ + private parseSpawnReleaseCommands(content: string): void { + // Only process if callbacks are configured (lead mode) + if (!this.config.onSpawn && !this.config.onRelease) return; + + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + + // Match ->relay:spawn WorkerName cli "task" + // Pattern: ->relay:spawn "" or ->relay:spawn '' + const spawnMatch = trimmed.match(/^->relay:spawn\s+(\S+)\s+(\S+)\s+["'](.+)["']$/); + if (spawnMatch && this.config.onSpawn) { + const [, name, cli, task] = spawnMatch; + const spawnKey = `${name}:${cli}:${task}`; + + // Dedup - only process each spawn once + if (!this.processedSpawnCommands.has(spawnKey)) { + this.processedSpawnCommands.add(spawnKey); + this.logStderr(`Spawn command: ${name} (${cli}) - "${task.substring(0, 50)}..."`); + this.config.onSpawn(name, cli, task).catch(err => { + this.logStderr(`Spawn failed: ${err.message}`, true); + }); + } + continue; + } + + // Match ->relay:release WorkerName + const releaseMatch = trimmed.match(/^->relay:release\s+(\S+)$/); + if (releaseMatch && this.config.onRelease) { + const [, name] = releaseMatch; + + // Dedup - only process each release once + if (!this.processedReleaseCommands.has(name)) { + this.processedReleaseCommands.add(name); + this.logStderr(`Release command: ${name}`); + this.config.onRelease(name).catch(err => { + this.logStderr(`Release failed: ${err.message}`, true); + }); + } + } + } + } + /** * Handle incoming message from relay */ @@ -888,6 +953,19 @@ export class TmuxWrapper { await this.sleep(30); } + // Ensure pane output is stable to avoid interleaving with active generation + const stablePane = await this.waitForStablePane( + this.config.outputStabilityTimeoutMs ?? 2000, + this.config.outputStabilityPollMs ?? 200 + ); + if (!stablePane) { + this.logStderr('Output still active, re-queuing injection'); + this.messageQueue.unshift(msg); + this.isInjecting = false; + setTimeout(() => this.checkForInjectionOpportunity(), this.config.injectRetryMs ?? 500); + return; + } + // For Gemini: check if we're at a shell prompt ($) vs chat prompt (>) // If at shell prompt, skip injection to avoid shell command execution if (this.cliType === 'gemini') { @@ -912,9 +990,9 @@ export class TmuxWrapper { msg.importance !== undefined && msg.importance > 50 ? ' [!]' : ''; const injection = `Relay message from ${msg.from} ${idTag}${threadHint}${importanceHint}: ${sanitizedBody}${truncationHint}`; - // Type the message as literal text - await this.sendKeysLiteral(injection); - await this.sleep(50); + // Paste message as a bracketed paste to avoid interleaving with active output + await this.pasteLiteral(injection); + await this.sleep(30); // Submit await this.sendKeys('Enter'); @@ -954,6 +1032,24 @@ export class TmuxWrapper { await execAsync(`tmux send-keys -t ${this.sessionName} -l "${escaped}"`); } + /** + * Paste text using tmux buffer with bracketed paste (-p) to avoid interleaving with ongoing output. + */ + private async pasteLiteral(text: string): Promise { + // Sanitize newlines to keep injection single-line inside paste buffer + const sanitized = text.replace(/[\r\n]+/g, ' '); + const escaped = sanitized + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/`/g, '\\`') + .replace(/!/g, '\\!'); + + // Set tmux buffer then paste with -p (bracketed paste) + await execAsync(`tmux set-buffer -- "${escaped}"`); + await execAsync(`tmux paste-buffer -t ${this.sessionName} -p`); + } + private sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } @@ -1089,6 +1185,52 @@ export class TmuxWrapper { return false; } + /** + * Capture a signature of the current pane content for stability checks. + * Uses hash+length to cheaply detect changes without storing full content. + */ + private async capturePaneSignature(): Promise { + try { + const { stdout } = await execAsync( + `tmux capture-pane -t ${this.sessionName} -p -J -S - 2>/dev/null` + ); + const hash = crypto.createHash('sha1').update(stdout).digest('hex'); + return `${stdout.length}:${hash}`; + } catch { + return null; + } + } + + /** + * Wait for pane output to stabilize before injecting to avoid interleaving with ongoing output. + */ + private async waitForStablePane(maxWaitMs = 2000, pollIntervalMs = 200, requiredStablePolls = 2): Promise { + const start = Date.now(); + let lastSig = await this.capturePaneSignature(); + if (!lastSig) return false; + + let stableCount = 0; + + while (Date.now() - start < maxWaitMs) { + await this.sleep(pollIntervalMs); + const sig = await this.capturePaneSignature(); + if (!sig) continue; + + if (sig === lastSig) { + stableCount++; + if (stableCount >= requiredStablePolls) { + return true; + } + } else { + stableCount = 0; + lastSig = sig; + } + } + + this.logStderr(`waitForStablePane: timed out after ${maxWaitMs}ms`); + return false; + } + /** * Stop and cleanup */ diff --git a/tsconfig.json b/tsconfig.json index e6cca9a93..76d98918a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "sourceMap": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/dashboard/frontend/**/*"] } diff --git a/vitest.config.ts b/vitest.config.ts index db3ec5700..8a209d57b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts'], + // Use jsdom environment for frontend tests + environmentMatchGlobs: [ + ['src/dashboard/frontend/**/*.test.ts', 'jsdom'], + ], coverage: { provider: 'v8', reporter: ['text', 'lcov', 'html'],