Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
297 changes: 182 additions & 115 deletions packages/agent-teams-lead/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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" });
}
Comment on lines +200 to +203

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
STORE=$(rg --files | rg 'packages/agent-teams-lead/src/store\.ts$' | head -n1)

echo "== $STORE =="
rg -n -C3 'class TeamStore|async load\(|getTeam\(|addTeammate\(|removeTeammate\(|this\.team\s*=' "$STORE"

Repository: arvoreeducacao/arvore-mcp-servers

Length of output: 2487


🏁 Script executed:

cat -n packages/agent-teams-lead/src/tools.ts | sed -n '190,235p'

Repository: arvoreeducacao/arvore-mcp-servers

Length of output: 2045


Refresh team inside the polling loop to see roster changes.

The team variable is snapshots at line 200 before the loop, but load() at line 210 creates a new Team object (via this.team = await this.readJson(...)), leaving the loop's reference stale. Changes from addTeammate() or removeTeammate() won't be reflected. Either call team = this.store.getTeam() after each load(), or use this.store.getTeam().teammates at line 222-224 instead of reusing the snapshot.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/agent-teams-lead/src/tools.ts` around lines 200 - 203, The local
team variable is captured before the polling loop so it becomes stale after
load() replaces this.team; update the loop to refresh the current team from the
store after each load by calling this.store.getTeam() (or directly reference
this.store.getTeam().teammates when checking teammates at the lines around
222-224) so roster changes from addTeammate()/removeTeammate() are reflected; in
short, stop reusing the initial team snapshot and retrieve the latest team from
this.store inside the polling iteration after load().


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 Math.min(pollInterval, remainingMs) instead of the fixed interval.

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
Verify each finding against the current code and only fix it if needed.

In `@packages/agent-teams-lead/src/tools.ts` around lines 205 - 207, The polling
loop currently always awaits the fixed pollInterval (3000) which can exceed the
requested timeout; update the wait logic (where timeoutMs, pollInterval and
startTime are used) to compute remainingMs = timeoutMs - (Date.now() -
startTime), clamp remainingMs to >=0, and await Math.min(pollInterval,
remainingMs) instead of the fixed pollInterval; also ensure you break/return
immediately if remainingMs <= 0 to avoid an extra sleep — apply the same change
to the other sleep call(s) referenced (the occurrences around the other
poll/sleep calls).


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Check unreadQuestions before declaring the team done.

If there are no pending or in-progress tasks, Line 229 returns done: true before Line 247 considers teammate questions. That still reproduces the PR's reported case where someone asks a question before any task exists. Move the unreadQuestions branch above this fast path, or make allTasksResolved contingent on there being nothing awaiting a lead response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/agent-teams-lead/src/tools.ts` around lines 226 - 250, The current
fast-path uses allTasksResolved (computed from inProgress and pending) and
returns done: true before checking unreadQuestions, which lets teammate
questions slip through; update the logic in the method (around the
allTasksResolved / unreadQuestions branch) so that either (a) the
unreadQuestions check runs before the allTasksResolved return, or (b) expand
allTasksResolved to also require unreadQuestions.length === 0 — then keep the
existing return block that uses startTime, completed and blocked unchanged. This
ensures unreadQuestions is considered before returning done: true.

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 {
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-teams-ui/index.html
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>
33 changes: 33 additions & 0 deletions packages/agent-teams-ui/package.json
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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))
PY

Repository: arvoreeducacao/arvore-mcp-servers

Length of output: 148


Align @types/express with the installed Express major version.

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
Verify each finding against the current code and only fix it if needed.

In `@packages/agent-teams-ui/package.json` around lines 15 - 22, The package.json
currently depends on "express" v4.x but lists "@types/express" v5.x; update the
devDependency for "@types/express" to the 4.x typings to match the runtime
Express major version (e.g., change the "@types/express" version spec from
^5.0.0 to a 4.x range such as ^4.17.0) so TypeScript validates against the
correct Express API surface.

"@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"
}
}
6 changes: 6 additions & 0 deletions packages/agent-teams-ui/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Loading
Loading