-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Agent Teams UI Monitor + Fix waitForTeam stuck on teammate questions #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -98,61 +98,74 @@ export class LeadTools { | |
| } | ||
|
|
||
| async teamStatus(): Promise<McpToolResult> { | ||
| try { | ||
| await this.store.load(); | ||
| const team = this.store.getTeam(); | ||
| if (!team) { | ||
| return this.ok({ active: false, message: "No active team" }); | ||
| } | ||
| try { | ||
| await this.store.load(); | ||
| const team = this.store.getTeam(); | ||
| if (!team) { | ||
| return this.ok({ active: false, message: "No active team" }); | ||
| } | ||
|
|
||
| const tasks = this.store.getTasks(); | ||
| const messages = this.store.getMessages({ to: "lead" }); | ||
| const unreadMessages = this.store.getMessages({ | ||
| to: "lead", | ||
| unread_by: "lead", | ||
| }); | ||
| const tasks = this.store.getTasks(); | ||
| const messages = this.store.getMessages({ to: "lead" }); | ||
| const unreadMessages = this.store.getMessages({ | ||
| to: "lead", | ||
| unread_by: "lead", | ||
| }); | ||
|
|
||
| const taskSummary = { | ||
| total: tasks.length, | ||
| pending: tasks.filter((t) => t.status === "pending").length, | ||
| in_progress: tasks.filter((t) => t.status === "in_progress").length, | ||
| completed: tasks.filter((t) => t.status === "completed").length, | ||
| blocked: tasks.filter((t) => t.status === "blocked").length, | ||
| }; | ||
| const unansweredQuestions = unreadMessages.filter( | ||
| (m) => m.kind === "question" || m.kind === "blocker" | ||
| ); | ||
|
|
||
| return this.ok({ | ||
| active: true, | ||
| team_id: team.id, | ||
| objective: team.objective, | ||
| teammates: team.teammates | ||
| .filter((t) => t.status !== "removed") | ||
| .map((t) => ({ | ||
| const taskSummary = { | ||
| total: tasks.length, | ||
| pending: tasks.filter((t) => t.status === "pending").length, | ||
| in_progress: tasks.filter((t) => t.status === "in_progress").length, | ||
| completed: tasks.filter((t) => t.status === "completed").length, | ||
| blocked: tasks.filter((t) => t.status === "blocked").length, | ||
| }; | ||
|
|
||
| return this.ok({ | ||
| active: true, | ||
| team_id: team.id, | ||
| objective: team.objective, | ||
| teammates: team.teammates | ||
| .filter((t) => t.status !== "removed") | ||
| .map((t) => ({ | ||
| id: t.id, | ||
| name: t.name, | ||
| role: t.role, | ||
| status: t.status, | ||
| running: this.spawner.isRunning(t.id), | ||
| })), | ||
| task_summary: taskSummary, | ||
| tasks: tasks.map((t) => ({ | ||
| id: t.id, | ||
| name: t.name, | ||
| role: t.role, | ||
| title: t.title, | ||
| status: t.status, | ||
| running: this.spawner.isRunning(t.id), | ||
| assigned_to: t.assigned_to, | ||
| })), | ||
| task_summary: taskSummary, | ||
| tasks: tasks.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| status: t.status, | ||
| assigned_to: t.assigned_to, | ||
| })), | ||
| unread_messages: unreadMessages.length, | ||
| recent_messages: messages.slice(-10).map((m) => ({ | ||
| id: m.id, | ||
| from_name: m.from_name, | ||
| kind: m.kind, | ||
| subject: m.subject, | ||
| created_at: m.created_at, | ||
| })), | ||
| }); | ||
| } catch (error) { | ||
| return this.errorResult(error); | ||
| unread_messages: unreadMessages.length, | ||
| needs_response: unansweredQuestions.length > 0, | ||
| unanswered_questions: unansweredQuestions.map((m) => ({ | ||
| id: m.id, | ||
| from_name: m.from_name, | ||
| kind: m.kind, | ||
| subject: m.subject, | ||
| body: m.body, | ||
| created_at: m.created_at, | ||
| })), | ||
| recent_messages: messages.slice(-10).map((m) => ({ | ||
| id: m.id, | ||
| from_name: m.from_name, | ||
| kind: m.kind, | ||
| subject: m.subject, | ||
| created_at: m.created_at, | ||
| })), | ||
| }); | ||
| } catch (error) { | ||
| return this.errorResult(error); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async sendMessage(params: SendMessageParams): Promise<McpToolResult> { | ||
| try { | ||
|
|
@@ -183,82 +196,136 @@ export class LeadTools { | |
| } | ||
|
|
||
| async waitForTeam(params: WaitForTeamParams): Promise<McpToolResult> { | ||
| try { | ||
| const team = this.store.getTeam(); | ||
| if (!team) { | ||
| return this.ok({ error: "No active team" }); | ||
| } | ||
| try { | ||
| const team = this.store.getTeam(); | ||
| if (!team) { | ||
| return this.ok({ error: "No active team" }); | ||
| } | ||
|
|
||
| const timeoutMs = params.timeout_seconds * 1000; | ||
| const pollInterval = 3000; | ||
| const startTime = Date.now(); | ||
| const timeoutMs = params.timeout_seconds * 1000; | ||
| const pollInterval = 3000; | ||
| const startTime = Date.now(); | ||
|
Comment on lines
+205
to
+207
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clamp the final sleep to the remaining timeout. Line 300 always waits 3000 ms, so a caller asking for a 1-second timeout can still block for about 3 seconds. Sleep Proposed fix const timeoutMs = params.timeout_seconds * 1000;
const pollInterval = 3000;
const startTime = Date.now();
@@
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ const remainingMs = timeoutMs - (Date.now() - startTime);
+ if (remainingMs <= 0) {
+ break;
+ }
+
+ await new Promise((resolve) =>
+ setTimeout(resolve, Math.min(pollInterval, remainingMs))
+ );Also applies to: 300-301 🤖 Prompt for AI Agents |
||
|
|
||
| while (Date.now() - startTime < timeoutMs) { | ||
| await this.store.load(); | ||
| const tasks = this.store.getTasks(); | ||
| while (Date.now() - startTime < timeoutMs) { | ||
| await this.store.load(); | ||
| const tasks = this.store.getTasks(); | ||
|
|
||
| const pending = tasks.filter((t) => t.status === "pending"); | ||
| const inProgress = tasks.filter((t) => t.status === "in_progress"); | ||
| const completed = tasks.filter((t) => t.status === "completed"); | ||
| const blocked = tasks.filter((t) => t.status === "blocked"); | ||
|
|
||
| const unreadQuestions = this.store | ||
| .getMessages({ to: "lead", unread_by: "lead" }) | ||
| .filter((m) => m.kind === "question" || m.kind === "blocker"); | ||
|
|
||
| const allTeammatesDone = team.teammates | ||
| .filter((t) => t.status === "active") | ||
| .every((t) => !this.spawner.isRunning(t.id)); | ||
|
|
||
| const allTasksResolved = | ||
| inProgress.length === 0 && pending.length === 0; | ||
|
|
||
| const pending = tasks.filter((t) => t.status === "pending"); | ||
| const inProgress = tasks.filter((t) => t.status === "in_progress"); | ||
| const completed = tasks.filter((t) => t.status === "completed"); | ||
| const blocked = tasks.filter((t) => t.status === "blocked"); | ||
|
|
||
| const allTeammatesDone = team.teammates | ||
| .filter((t) => t.status === "active") | ||
| .every((t) => !this.spawner.isRunning(t.id)); | ||
|
|
||
| if ( | ||
| (inProgress.length === 0 && pending.length === 0) || | ||
| allTeammatesDone | ||
| ) { | ||
| return this.ok({ | ||
| done: true, | ||
| reason: | ||
| inProgress.length === 0 && pending.length === 0 | ||
| ? "all_tasks_resolved" | ||
| if (allTasksResolved) { | ||
| return this.ok({ | ||
| done: true, | ||
| reason: "all_tasks_resolved", | ||
| elapsed_seconds: Math.round((Date.now() - startTime) / 1000), | ||
| completed: completed.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| summary: t.summary, | ||
| })), | ||
| blocked: blocked.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| notes: t.notes, | ||
| })), | ||
| }); | ||
| } | ||
|
|
||
| if (unreadQuestions.length > 0) { | ||
| return this.ok({ | ||
| done: false, | ||
| reason: "teammates_need_response", | ||
|
Comment on lines
+226
to
+250
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check If there are no pending or in-progress tasks, Line 229 returns 🤖 Prompt for AI Agents |
||
| elapsed_seconds: Math.round((Date.now() - startTime) / 1000), | ||
| unanswered_messages: unreadQuestions.map((m) => ({ | ||
| id: m.id, | ||
| from: m.from, | ||
| from_name: m.from_name, | ||
| kind: m.kind, | ||
| subject: m.subject, | ||
| body: m.body, | ||
| created_at: m.created_at, | ||
| })), | ||
| tasks: tasks.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| status: t.status, | ||
| assigned_to: t.assigned_to, | ||
| })), | ||
| }); | ||
| } | ||
|
|
||
| if (allTeammatesDone) { | ||
| const hasPendingWork = pending.length > 0 || inProgress.length > 0; | ||
| return this.ok({ | ||
| done: !hasPendingWork, | ||
| reason: hasPendingWork | ||
| ? "teammates_exited_with_pending_work" | ||
| : "all_teammates_finished", | ||
| elapsed_seconds: Math.round((Date.now() - startTime) / 1000), | ||
| completed: completed.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| summary: t.summary, | ||
| })), | ||
| blocked: blocked.map((t) => ({ | ||
| elapsed_seconds: Math.round((Date.now() - startTime) / 1000), | ||
| pending: pending.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| })), | ||
| in_progress: inProgress.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| assigned_to: t.assigned_to, | ||
| })), | ||
| completed: completed.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| summary: t.summary, | ||
| })), | ||
| blocked: blocked.map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| notes: t.notes, | ||
| })), | ||
| }); | ||
| } | ||
|
|
||
| await new Promise((resolve) => setTimeout(resolve, pollInterval)); | ||
| } | ||
|
|
||
| const tasks = this.store.getTasks(); | ||
| return this.ok({ | ||
| done: false, | ||
| timed_out: true, | ||
| elapsed_seconds: params.timeout_seconds, | ||
| pending: tasks | ||
| .filter((t) => t.status === "pending") | ||
| .map((t) => ({ id: t.id, title: t.title })), | ||
| in_progress: tasks | ||
| .filter((t) => t.status === "in_progress") | ||
| .map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| notes: t.notes, | ||
| assigned_to: t.assigned_to, | ||
| })), | ||
| }); | ||
| } | ||
|
|
||
| await new Promise((resolve) => setTimeout(resolve, pollInterval)); | ||
| completed: tasks | ||
| .filter((t) => t.status === "completed") | ||
| .map((t) => ({ id: t.id, title: t.title })), | ||
| blocked: tasks | ||
| .filter((t) => t.status === "blocked") | ||
| .map((t) => ({ id: t.id, title: t.title })), | ||
| }); | ||
| } catch (error) { | ||
| return this.errorResult(error); | ||
| } | ||
|
|
||
| const tasks = this.store.getTasks(); | ||
| return this.ok({ | ||
| done: false, | ||
| timed_out: true, | ||
| elapsed_seconds: params.timeout_seconds, | ||
| pending: tasks | ||
| .filter((t) => t.status === "pending") | ||
| .map((t) => ({ id: t.id, title: t.title })), | ||
| in_progress: tasks | ||
| .filter((t) => t.status === "in_progress") | ||
| .map((t) => ({ | ||
| id: t.id, | ||
| title: t.title, | ||
| assigned_to: t.assigned_to, | ||
| })), | ||
| completed: tasks | ||
| .filter((t) => t.status === "completed") | ||
| .map((t) => ({ id: t.id, title: t.title })), | ||
| blocked: tasks | ||
| .filter((t) => t.status === "blocked") | ||
| .map((t) => ({ id: t.id, title: t.title })), | ||
| }); | ||
| } catch (error) { | ||
| return this.errorResult(error); | ||
| } | ||
| } | ||
|
|
||
| async readArtifact(params: ReadArtifactParams): Promise<McpToolResult> { | ||
| try { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| <!doctype html> | ||
| <html lang="pt-BR"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Agent Teams Monitor</title> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="/src/main.tsx"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| { | ||
| "name": "@arvoretech/agent-teams-ui", | ||
| "version": "0.1.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "concurrently \"vite\" \"tsx src/server/index.ts\"", | ||
| "build": "vite build", | ||
| "preview": "vite preview" | ||
| }, | ||
| "dependencies": { | ||
| "chokidar": "^4.0.0", | ||
| "concurrently": "^9.1.0", | ||
| "cors": "^2.8.5", | ||
| "express": "^4.21.0", | ||
| "framer-motion": "^12.36.0", | ||
| "react": "^19.0.0", | ||
| "react-dom": "^19.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/cors": "^2.8.17", | ||
| "@types/express": "^5.0.0", | ||
|
Comment on lines
+15
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
python - <<'PY'
import json
from pathlib import Path
pkg = json.loads(Path("packages/agent-teams-ui/package.json").read_text())
express = pkg["dependencies"]["express"]
types = pkg["devDependencies"]["@types/express"]
def major(spec: str) -> str:
return spec.lstrip("^~<>=").split(".")[0]
print({"express": express, "@types/express": types})
print("major_mismatch:", major(express) != major(types))
PYRepository: arvoreeducacao/arvore-mcp-servers Length of output: 148 Align Line 15 installs Express 4.x, but line 22 installs the 5.x type package. This causes TypeScript to validate against a different API surface than the runtime uses. Pin the typings to the 4.x line to match the installed Express version. 🤖 Prompt for AI Agents |
||
| "@types/react": "^19.0.0", | ||
| "@types/react-dom": "^19.0.0", | ||
| "@vitejs/plugin-react": "^4.3.0", | ||
| "autoprefixer": "^10.4.20", | ||
| "postcss": "^8.4.49", | ||
| "tailwindcss": "^3.4.17", | ||
| "tsx": "^4.6.0", | ||
| "typescript": "^5.3.0", | ||
| "vite": "^6.0.0" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| export default { | ||
| plugins: { | ||
| tailwindcss: {}, | ||
| autoprefixer: {}, | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: arvoreeducacao/arvore-mcp-servers
Length of output: 2487
🏁 Script executed:
Repository: arvoreeducacao/arvore-mcp-servers
Length of output: 2045
Refresh
teaminside the polling loop to see roster changes.The
teamvariable is snapshots at line 200 before the loop, butload()at line 210 creates a new Team object (viathis.team = await this.readJson(...)), leaving the loop's reference stale. Changes fromaddTeammate()orremoveTeammate()won't be reflected. Either callteam = this.store.getTeam()after eachload(), or usethis.store.getTeam().teammatesat line 222-224 instead of reusing the snapshot.🤖 Prompt for AI Agents