From a846c3949a13d5c3e6149c6a189091d1191cb83b Mon Sep 17 00:00:00 2001
From: iliya
Date: Thu, 5 Mar 2026 22:36:14 +0200
Subject: [PATCH 01/14] refactor: improve message deduplication and UI
components for better user experience
- Updated handleGetData to collect text fingerprints from all message sources for deduplication of lead_process messages.
- Removed outdated leadSessionId assignment logic in TeamDataService to streamline message processing.
- Enhanced CollapsibleTeamSection and TeamDetailView to support an afterBadge prop for additional UI elements.
- Refactored ActivityTimeline to optimize pagination by excluding lead thoughts from visible message counts.
- Improved LeadThoughtsGroup to ensure single thoughts are rendered as distinct groups, enhancing clarity in the timeline.
---
src/main/ipc/teams.ts | 20 +++++--
src/main/services/team/TeamDataService.ts | 11 ----
.../team/CollapsibleTeamSection.tsx | 4 ++
.../components/team/TeamDetailView.tsx | 60 +++++++++----------
.../team/activity/ActivityTimeline.tsx | 47 ++++++++-------
.../team/activity/LeadThoughtsGroup.tsx | 37 +++++-------
src/renderer/hooks/useAttachments.ts | 2 -
7 files changed, 92 insertions(+), 89 deletions(-)
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index edc4da79..188ffbc5 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -402,11 +402,13 @@ async function handleGetData(
}
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
- const leadSessionTextFingerprints = new Set();
+
+ // Collect text fingerprints from ALL non-live messages (inbox, lead_session, sentMessages)
+ // so we can dedup lead_process live messages against them.
+ const existingTextFingerprints = new Set();
for (const msg of data.messages) {
- if ((msg as { source?: unknown }).source !== 'lead_session') continue;
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
- leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
+ existingTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
}
const keyFor = (m: {
@@ -421,14 +423,24 @@ async function handleGetData(
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
};
+ // Text-based fingerprints for lead_process messages to catch duplicates
+ // with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text)
+ const leadProcessTextFingerprints = new Set();
+
const merged: typeof data.messages = [];
const seen = new Set();
for (const msg of [...data.messages, ...live]) {
if ((msg as { source?: unknown }).source === 'lead_process') {
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
- if (leadSessionTextFingerprints.has(fp)) {
+ // Skip if same text already exists from any source (inbox, lead_session, etc.)
+ if (existingTextFingerprints.has(fp)) {
continue;
}
+ // Dedup lead_process messages with same text but different messageIds
+ if (leadProcessTextFingerprints.has(fp)) {
+ continue;
+ }
+ leadProcessTextFingerprints.add(fp);
}
const key = keyFor(msg);
if (seen.has(key)) continue;
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index ec3c8a43..83e3f8ca 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -297,17 +297,6 @@ export class TeamDataService {
});
}
- // Enrich messages without leadSessionId: assign current session for lead_process/user_sent.
- // lead_process messages surviving dedup are from the current session;
- // user_sent messages written before this feature lack the field.
- if (config.leadSessionId) {
- for (const msg of messages) {
- if (!msg.leadSessionId && (msg.source === 'lead_process' || msg.source === 'user_sent')) {
- msg.leadSessionId = config.leadSessionId;
- }
- }
- }
-
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = [];
diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx
index 583ba823..5b513f4d 100644
--- a/src/renderer/components/team/CollapsibleTeamSection.tsx
+++ b/src/renderer/components/team/CollapsibleTeamSection.tsx
@@ -19,6 +19,8 @@ interface CollapsibleTeamSectionProps {
badge?: string | number;
/** Secondary badge (e.g. unread count). Shown next to main badge when defined. */
secondaryBadge?: number;
+ /** Element rendered immediately after secondary badge (e.g. mark-all-read button). */
+ afterBadge?: React.ReactNode;
/** Extra element rendered inline after badges (e.g. notification icon). */
headerExtra?: React.ReactNode;
defaultOpen?: boolean;
@@ -40,6 +42,7 @@ export const CollapsibleTeamSection = ({
icon,
badge,
secondaryBadge,
+ afterBadge,
headerExtra,
defaultOpen = true,
forceOpen,
@@ -109,6 +112,7 @@ export const CollapsibleTeamSection = ({
{secondaryBadge} new
)}
+ {afterBadge}
{headerExtra}
{action && {action}
}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index 7b6cb95d..b64f145a 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -1415,44 +1415,44 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
? messagesUnreadCount
: undefined
}
- headerExtra={
- <>
+ afterBadge={
+ messagesUnreadCount > 0 ? (
- {
e.stopPropagation();
- void window.electronAPI.openExternal(
- 'https://github.com/777genius/claude-notifications-go'
- );
+ handleMarkAllRead();
}}
>
-
-
+
+
- Desktop notifications plugin
+ Mark all as read
- {messagesUnreadCount > 0 && (
-
-
- {
- e.stopPropagation();
- handleMarkAllRead();
- }}
- >
-
-
-
- Mark all as read
-
- )}
- >
+ ) : undefined
+ }
+ headerExtra={
+
+
+ {
+ e.stopPropagation();
+ void window.electronAPI.openExternal(
+ 'https://github.com/777genius/claude-notifications-go'
+ );
+ }}
+ >
+
+
+
+ Desktop notifications plugin
+
}
defaultOpen
action={
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index cb8a7331..63e0864f 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
-import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
+import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
import type { TimelineItem } from './LeadThoughtsGroup';
@@ -134,11 +134,6 @@ export const ActivityTimeline = ({
const isInitializedRef = useRef(false);
const prevVisibleCountRef = useRef(visibleCount);
- // Track whether the user was seeing ALL messages (no hidden ones).
- // If so, auto-expand when new messages push count past the limit,
- // so previously visible messages don't silently disappear.
- const wasShowingAllRef = useRef(messages.length <= MESSAGES_PAGE_SIZE);
-
const colorMap = members ? buildMemberColorMap(members) : new Map();
const memberInfo = new Map();
if (members) {
@@ -169,22 +164,33 @@ export const ActivityTimeline = ({
if (member) onMemberClick?.(member);
};
- const hiddenCount = Math.max(0, messages.length - visibleCount);
+ // Pagination counts only significant (non-thought) messages so that lead thoughts
+ // don't consume the page limit — they collapse into a single visual group anyway.
+ const { visibleMessages, hiddenCount } = useMemo(() => {
+ const total = messages.length;
+ if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
- // Auto-expand when user was seeing all and new messages arrive — derived state sync.
- // Reading/updating ref during render is intentional (React docs: derived state sync).
- /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
+ let significantSeen = 0;
+ let cutoff = total;
+ for (let i = 0; i < total; i++) {
+ if (!isLeadThought(messages[i])) {
+ significantSeen++;
+ if (significantSeen > visibleCount) {
+ cutoff = i;
+ break;
+ }
+ }
+ }
- const wasShowingAll = wasShowingAllRef.current;
- if (wasShowingAll && hiddenCount > 0) {
- setVisibleCount(messages.length);
- }
- wasShowingAllRef.current = hiddenCount === 0;
-
- const visibleMessages = useMemo(
- () => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
- [messages, visibleCount, hiddenCount]
- );
+ const significantTotal =
+ significantSeen +
+ (cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
+ const hidden = Math.max(0, significantTotal - visibleCount);
+ return {
+ visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
+ hiddenCount: hidden,
+ };
+ }, [messages, visibleCount]);
// Group consecutive lead thoughts into collapsible blocks.
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]);
@@ -209,6 +215,7 @@ export const ActivityTimeline = ({
}, [timelineItems]);
// Determine which items are "new" (should animate).
+ /* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
const newItemKeys = useMemo(() => {
const getItemKey = (item: TimelineItem): string => {
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index 0d85a943..5228cb88 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -22,7 +22,7 @@ export interface LeadThoughtGroup {
*/
export function isLeadThought(msg: InboxMessage): boolean {
if (msg.source === 'lead_session') return true;
- if (msg.source === 'lead_process' && msg.messageId?.startsWith('lead-text-')) return true;
+ if (msg.source === 'lead_process') return true;
return false;
}
@@ -31,8 +31,8 @@ export type TimelineItem =
| { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] };
/**
- * Group consecutive lead thoughts into collapsible blocks.
- * Single thoughts remain as regular messages.
+ * Group consecutive lead thoughts into compact blocks.
+ * Even a single thought gets its own group (rendered as LeadThoughtsGroupRow).
*/
export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const result: TimelineItem[] = [];
@@ -41,19 +41,11 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const flushThoughts = (): void => {
if (pendingThoughts.length === 0) return;
- if (pendingThoughts.length === 1) {
- result.push({
- type: 'message',
- message: pendingThoughts[0],
- originalIndex: pendingIndices[0],
- });
- } else {
- result.push({
- type: 'lead-thoughts',
- group: { type: 'lead-thoughts', thoughts: pendingThoughts },
- originalIndices: pendingIndices,
- });
- }
+ result.push({
+ type: 'lead-thoughts',
+ group: { type: 'lead-thoughts', thoughts: pendingThoughts },
+ originalIndices: pendingIndices,
+ });
pendingThoughts = [];
pendingIndices = [];
};
@@ -121,11 +113,8 @@ export const LeadThoughtsGroupRow = ({
// Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
- // Live indicator: newest thought is from lead_process and recent
- const computeIsLive = useCallback(
- () => newest.source === 'lead_process' && isRecentTimestamp(newest.timestamp),
- [newest.source, newest.timestamp]
- );
+ // Live indicator: newest thought is recent (actively streaming)
+ const computeIsLive = useCallback(() => isRecentTimestamp(newest.timestamp), [newest.timestamp]);
const [isLive, setIsLive] = useState(computeIsLive);
useEffect(() => {
@@ -173,7 +162,11 @@ export const LeadThoughtsGroupRow = ({
}, []);
return (
-
+
{
const raw = await draftStorage.loadDraft(persistenceKey);
From 7afe5d4d59b193cda196cc9312e05a729a362c64 Mon Sep 17 00:00:00 2001
From: iliya
Date: Thu, 5 Mar 2026 23:39:50 +0200
Subject: [PATCH 02/14] feat: enhance message handling and UI components for
improved user experience
- Implemented lead session ID propagation in TeamDataService to enrich inbox messages without existing leadSessionId.
- Added source and leadSessionId fields to message payloads in TeamInboxReader for better tracking.
- Updated CSS for compact CLI logs display, improving item density in the UI.
- Refactored CliLogsRichView and LeadThoughtsGroupRow to support new UI features and enhance message visibility.
- Adjusted ActivityTimeline to include zebra striping for better readability of messages.
---
src/main/services/team/TeamDataService.ts | 25 ++++++++++++++++
src/main/services/team/TeamInboxReader.ts | 2 ++
.../services/team/TeamProvisioningService.ts | 16 +++++-----
.../components/team/CliLogsRichView.tsx | 29 ++++++++++++++-----
.../team/activity/ActivityTimeline.tsx | 3 +-
.../team/activity/LeadThoughtsGroup.tsx | 29 ++++++++++++++++---
.../team/dialogs/SendMessageDialog.tsx | 1 -
.../team/dialogs/TaskCommentsSection.tsx | 1 -
.../components/team/members/MemberLogsTab.tsx | 9 +++---
src/renderer/index.css | 15 ++++++++++
10 files changed, 103 insertions(+), 27 deletions(-)
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 83e3f8ca..964e620c 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -297,6 +297,31 @@ export class TeamDataService {
});
}
+ // Enrich inbox messages without leadSessionId by propagating from neighboring
+ // messages that have it (lead_session, user_sent). Sort chronologically (asc),
+ // sweep forward, then sweep backward so orphans at the start also get a session.
+ if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
+ messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
+ // Forward pass: propagate leadSessionId from earlier messages to later ones
+ let currentSessionId: string | undefined;
+ for (const msg of messages) {
+ if (msg.leadSessionId) {
+ currentSessionId = msg.leadSessionId;
+ } else if (currentSessionId) {
+ msg.leadSessionId = currentSessionId;
+ }
+ }
+ // Backward pass: fill messages before the first known session
+ currentSessionId = undefined;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].leadSessionId) {
+ currentSessionId = messages[i].leadSessionId;
+ } else if (currentSessionId) {
+ messages[i].leadSessionId = currentSessionId;
+ }
+ }
+ }
+
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = [];
diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts
index b4e9cc5e..0e9a8849 100644
--- a/src/main/services/team/TeamInboxReader.ts
+++ b/src/main/services/team/TeamInboxReader.ts
@@ -99,6 +99,8 @@ export class TeamInboxReader {
summary: typeof row.summary === 'string' ? row.summary : undefined,
color: typeof row.color === 'string' ? row.color : undefined,
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
+ source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
+ leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
});
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 2376468d..5b7e882c 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -2876,7 +2876,7 @@ export class TeamProvisioningService {
const hasSendMessageToUser = (content ?? []).some((part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
- const input = (part as Record).input;
+ const input = part.input;
if (!input || typeof input !== 'object') return false;
return (input as Record).recipient === 'user';
});
@@ -2885,7 +2885,7 @@ export class TeamProvisioningService {
.filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string);
if (textParts.length > 0) {
- const text = textParts.join('');
+ const text = textParts.join('\n');
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
this.handleAuthFailureInOutput(run, text, 'assistant');
@@ -2908,7 +2908,7 @@ export class TeamProvisioningService {
clearTimeout(capture.idleHandle);
}
capture.idleHandle = setTimeout(() => {
- const combined = capture.textParts.join('').trim();
+ const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined);
}, capture.idleMs);
}
@@ -2918,7 +2918,7 @@ export class TeamProvisioningService {
// SendMessage and avoid duplicating it as a separate lead text entry.
if (!run.silentUserDmForward && !hasSendMessageToUser) {
run.directReplyParts.push(text);
- const raw = run.directReplyParts.join('');
+ const raw = run.directReplyParts.join('\n');
const cleanText = stripAgentBlocks(raw).trim();
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
const leadName =
@@ -2927,11 +2927,13 @@ export class TeamProvisioningService {
if (!run.leadTurnMessageTimestamp) {
run.leadTurnMessageTimestamp = nowIso();
}
+ // Update timestamp on each text block so the live indicator stays fresh
+ const currentTimestamp = nowIso();
const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`;
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
- timestamp: run.leadTurnMessageTimestamp,
+ timestamp: currentTimestamp,
read: true,
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId,
@@ -3098,11 +3100,11 @@ export class TeamProvisioningService {
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
- const combined = capture.textParts.join('').trim();
+ const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined);
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
// Finalize the current live lead turn message (single messageId per turn).
- const rawReply = run.directReplyParts.join('').trim();
+ const rawReply = run.directReplyParts.join('\n').trim();
run.directReplyParts = [];
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx
index 971faecd..8371ee99 100644
--- a/src/renderer/components/team/CliLogsRichView.tsx
+++ b/src/renderer/components/team/CliLogsRichView.tsx
@@ -108,7 +108,7 @@ const StreamGroup = ({
{searchQueryOverride && searchQueryOverride.trim().length > 0
- ? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, {
- forceAllActive: true,
- })
+ ? highlightQueryInText(
+ group.summary,
+ searchQueryOverride,
+ `${group.id}:group-summary`,
+ {
+ forceAllActive: true,
+ }
+ )
: group.summary}
{isExpanded && (
-
+
{
const el = e.currentTarget;
- onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
+ onScroll?.({
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ clientHeight: el.clientHeight,
+ });
}}
>
{hasContent ? (
@@ -242,10 +251,14 @@ export const CliLogsRichView = ({
scrollRef.current = el;
containerRefCallback?.(el);
}}
- className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}
+ className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
onScroll={(e) => {
const el = e.currentTarget;
- onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
+ onScroll?.({
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ clientHeight: el.clientHeight,
+ });
}}
>
{visibleGroups.map((group) =>
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index 63e0864f..0f29d8ef 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -5,8 +5,8 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
-import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
import type { TimelineItem } from './LeadThoughtsGroup';
+import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps {
messages: InboxMessage[];
@@ -324,6 +324,7 @@ export const ActivityTimeline = ({
memberColor={info?.color}
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
+ zebraShade={zebraShadeSet.has(index)}
/>
);
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index 5228cb88..1ffaad78 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -3,11 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import {
CARD_BG,
+ CARD_BG_ZEBRA,
CARD_BORDER_STYLE,
CARD_ICON_MUTED,
CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
+import { useStore } from '@renderer/store';
import type { InboxMessage } from '@shared/types';
@@ -65,7 +67,7 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
}
const VIEWPORT_THRESHOLD = 0.15;
-const LIVE_WINDOW_MS = 10_000;
+const LIVE_WINDOW_MS = 5_000;
const AUTO_SCROLL_THRESHOLD = 30;
interface LeadThoughtsGroupRowProps {
@@ -73,6 +75,8 @@ interface LeadThoughtsGroupRowProps {
memberColor?: string;
isNew?: boolean;
onVisible?: (message: InboxMessage) => void;
+ /** When true, apply a subtle lighter background for zebra-striped lists. */
+ zebraShade?: boolean;
}
function formatTime(timestamp: string): string {
@@ -98,10 +102,20 @@ export const LeadThoughtsGroupRow = ({
memberColor,
isNew,
onVisible,
+ zebraShade,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef(null);
const scrollRef = useRef(null);
const isUserScrolledUpRef = useRef(false);
+ const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
+ const leadActivity = useStore((s) => {
+ const teamName = s.selectedTeamName;
+ return teamName ? s.leadActivityByTeam[teamName] : undefined;
+ });
+ const leadContextUpdatedAt = useStore((s) => {
+ const teamName = s.selectedTeamName;
+ return teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined;
+ });
const colors = getTeamColorSet(memberColor ?? '');
const { thoughts } = group;
@@ -113,8 +127,15 @@ export const LeadThoughtsGroupRow = ({
// Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
- // Live indicator: newest thought is recent (actively streaming)
- const computeIsLive = useCallback(() => isRecentTimestamp(newest.timestamp), [newest.timestamp]);
+ // Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
+ const computeIsLive = useCallback(
+ () =>
+ isTeamAlive &&
+ (leadActivity === 'active' ||
+ (leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
+ isRecentTimestamp(newest.timestamp)),
+ [isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
+ );
const [isLive, setIsLive] = useState(computeIsLive);
useEffect(() => {
@@ -170,7 +191,7 @@ export const LeadThoughtsGroupRow = ({
=> {
if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
- return (d?.chunks ?? null) as EnhancedChunk[] | null;
+ return d?.chunks ?? null;
}
const d = await api.getSessionDetail(log.projectId, log.sessionId);
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
diff --git a/src/renderer/index.css b/src/renderer/index.css
index 563130fa..7506603d 100644
--- a/src/renderer/index.css
+++ b/src/renderer/index.css
@@ -734,6 +734,21 @@ body {
background-color: #12131a;
}
+/* CLI Logs compact density — reduces item height inside CliLogsRichView */
+.cli-logs-compact [role='button'] {
+ padding-top: 0.2rem;
+ padding-bottom: 0.2rem;
+ gap: 0.375rem;
+}
+.cli-logs-compact [role='button'] .text-sm {
+ font-size: 0.75rem;
+ line-height: 1rem;
+}
+.cli-logs-compact [role='button'] .text-xs {
+ font-size: 0.625rem;
+ line-height: 0.875rem;
+}
+
:root.light .checkerboard-bg {
background-image:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
From 410cb8a10fbbd5639d6b0da948704e6d4d166e5b Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 00:02:27 +0200
Subject: [PATCH 03/14] fix: address linting issues in LeadThoughtsGroup and
TaskAttachments components
- Added an eslint-disable comment in LeadThoughtsGroupRow to clarify intentional state updates in useEffect.
- Removed unnecessary eslint-disable comment in TaskAttachments to clean up the codebase.
---
src/renderer/components/team/activity/LeadThoughtsGroup.tsx | 1 +
src/renderer/components/team/dialogs/TaskAttachments.tsx | 1 -
2 files changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index 1ffaad78..fcf9b849 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -139,6 +139,7 @@ export const LeadThoughtsGroupRow = ({
const [isLive, setIsLive] = useState(computeIsLive);
useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
setIsLive(computeIsLive());
const id = window.setInterval(() => setIsLive(computeIsLive()), 1000);
return () => window.clearInterval(id);
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx
index 39863d44..5c48993f 100644
--- a/src/renderer/components/team/dialogs/TaskAttachments.tsx
+++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx
@@ -219,7 +219,6 @@ export const TaskAttachments = ({
void handlePreview(att);
}}
onDelete={() => {
- // eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise
void handleDelete(att.id, att.mimeType);
}}
onDataLoaded={handleThumbLoaded}
From 297780e3a8cd8d63139518e9b14592a27024c37f Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 01:24:09 +0200
Subject: [PATCH 04/14] feat: enhance message handling and UI components for
improved user experience
- Updated message deduplication logic in handleGetData to exclude lead_process messages with a 'to' field.
- Added source field to message payloads in TeamAgentToolsInstaller for system notifications.
- Enriched sendMessage function in TeamDataService with leadSessionId for better session tracking.
- Improved UI components including MessageComposer placeholder text and scroll behavior in ActivityTimeline.
- Enhanced LeadThoughtsGroupRow to support live indicators and auto-scroll functionality for new messages.
---
src/main/ipc/teams.ts | 2 +-
.../services/team/TeamAgentToolsInstaller.ts | 11 ++-
src/main/services/team/TeamDataService.ts | 21 ++++-
src/main/services/team/TeamInboxWriter.ts | 1 +
.../services/team/TeamProvisioningService.ts | 12 ++-
.../components/team/ClaudeLogsSection.tsx | 3 +-
.../components/team/CliLogsRichView.tsx | 43 +++++++--
.../team/activity/ActivityTimeline.tsx | 38 +++++++-
.../team/activity/LeadThoughtsGroup.tsx | 25 +++--
.../components/team/members/MemberLogsTab.tsx | 91 +++++++++++++++++--
.../team/messages/MessageComposer.tsx | 2 +-
src/renderer/components/ui/dialog.tsx | 28 +++---
src/shared/types/team.ts | 2 +
13 files changed, 231 insertions(+), 48 deletions(-)
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index 188ffbc5..85f36da7 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -430,7 +430,7 @@ async function handleGetData(
const merged: typeof data.messages = [];
const seen = new Set();
for (const msg of [...data.messages, ...live]) {
- if ((msg as { source?: unknown }).source === 'lead_process') {
+ if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
// Skip if same text already exists from any source (inbox, lead_session, etc.)
if (existingTextFingerprints.has(fp)) {
diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts
index 7868cc1c..a2f7fc04 100644
--- a/src/main/services/team/TeamAgentToolsInstaller.ts
+++ b/src/main/services/team/TeamAgentToolsInstaller.ts
@@ -865,6 +865,7 @@ function sendInboxMessage(paths, teamName, flags) {
summary,
messageId,
};
+ if (flags.source) payload.source = flags.source;
var lastErr;
for (var attempt = 0; attempt < 8; attempt++) {
@@ -915,6 +916,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
text: inboxText,
summary: 'Approved #' + String(taskId),
from,
+ source: 'system_notification',
});
}
@@ -957,6 +959,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
text: inboxText,
summary: 'Fix request for #' + String(taskId),
from,
+ source: 'system_notification',
});
}
@@ -1282,6 +1285,7 @@ async function main() {
text: parts.join('\n'),
summary: 'New task #' + String(task.id) + ' assigned',
from,
+ source: 'system_notification',
});
}
}
@@ -1319,6 +1323,7 @@ async function main() {
text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''),
summary: 'Comment on #' + String(result.taskId),
from: from,
+ source: 'system_notification',
});
} catch (e) { /* best-effort */ }
}
@@ -1396,6 +1401,7 @@ async function main() {
text: parts.join('\n'),
summary: 'Task #' + String(task.id) + ' assigned',
from,
+ source: 'system_notification',
});
}
return;
@@ -1493,7 +1499,10 @@ async function main() {
if (domain === 'message') {
if (action === 'send') {
- const result = sendInboxMessage(paths, teamName, args.flags);
+ // Strip source from agent flags — only internal callers may set it
+ var msgFlags = Object.assign({}, args.flags);
+ delete msgFlags.source;
+ const result = sendInboxMessage(paths, teamName, msgFlags);
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
return;
}
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 964e620c..b6edd939 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -283,6 +283,7 @@ export class TeamDataService {
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
+ // Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
if (leadTexts.length > 0 && sentMessages.length > 0) {
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const leadSessionFingerprints = new Set();
@@ -292,6 +293,8 @@ export class TeamDataService {
}
messages = messages.filter((m) => {
if (m.source !== 'lead_process') return true;
+ // Captured SendMessage messages (with recipient) are real messages — never dedup
+ if (m.to) return true;
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
return !leadSessionFingerprints.has(fp);
});
@@ -311,8 +314,11 @@ export class TeamDataService {
msg.leadSessionId = currentSessionId;
}
}
- // Backward pass: fill messages before the first known session
- currentSessionId = undefined;
+ // Backward pass: fill messages before the first known session.
+ // Seed with config.leadSessionId so that recent messages without an explicit
+ // session ID inherit the current (most recent) session — this ensures that
+ // session boundary separators work even when inbox entries lack the field.
+ currentSessionId = config.leadSessionId;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].leadSessionId) {
currentSessionId = messages[i].leadSessionId;
@@ -1117,6 +1123,17 @@ export class TeamDataService {
}
async sendMessage(teamName: string, request: SendMessageRequest): Promise {
+ // Enrich with leadSessionId so session boundary separators work
+ if (!request.leadSessionId) {
+ try {
+ const config = await this.configReader.getConfig(teamName);
+ if (config?.leadSessionId) {
+ request = { ...request, leadSessionId: config.leadSessionId };
+ }
+ } catch {
+ // non-critical
+ }
+ }
return this.inboxWriter.sendMessage(teamName, request);
}
diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts
index a0776db4..8ac414ec 100644
--- a/src/main/services/team/TeamInboxWriter.ts
+++ b/src/main/services/team/TeamInboxWriter.ts
@@ -30,6 +30,7 @@ export class TeamInboxWriter {
messageId,
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
...(request.source && { source: request.source }),
+ ...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
};
await withInboxLock(inboxPath, async () => {
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 5b7e882c..1156b83a 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -79,8 +79,6 @@ const PREFLIGHT_PING_ARGS = [
'haiku',
'--max-turns',
'1',
- '--max-budget-usd',
- '0.05',
'--no-session-persistence',
] as const;
const PREFLIGHT_EXPECTED = 'PONG';
@@ -2797,6 +2795,16 @@ export class TeamProvisioningService {
}
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
+ // Enrich with leadSessionId if missing — needed for session boundary separators
+ if (!message.leadSessionId) {
+ const runId = this.activeByTeam.get(teamName);
+ if (runId) {
+ const run = this.runs.get(runId);
+ if (run?.detectedSessionId) {
+ message.leadSessionId = run.detectedSessionId;
+ }
+ }
+ }
const MAX = 100;
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
const id = typeof message.messageId === 'string' ? message.messageId.trim() : '';
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx
index d8cfa28e..e2a42c43 100644
--- a/src/renderer/components/team/ClaudeLogsSection.tsx
+++ b/src/renderer/components/team/ClaudeLogsSection.tsx
@@ -330,7 +330,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
badge={badge}
headerExtra={headerExtra}
defaultOpen
- contentClassName="pt-0"
+ // Prevent scroll anchoring from "pulling" the parent container when logs update.
+ contentClassName="pt-0 [overflow-anchor:none]"
>
diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx
index 8371ee99..986a763b 100644
--- a/src/renderer/components/team/CliLogsRichView.tsx
+++ b/src/renderer/components/team/CliLogsRichView.tsx
@@ -17,9 +17,11 @@ import { Bot, ChevronRight } from 'lucide-react';
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
+type CliLogsOrder = 'oldest-first' | 'newest-first';
+
interface CliLogsRichViewProps {
cliLogsTail: string;
- order?: 'oldest-first' | 'newest-first';
+ order?: CliLogsOrder;
onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
containerRefCallback?: (el: HTMLDivElement | null) => void;
/** Optional local search query override for inline highlighting */
@@ -156,6 +158,8 @@ export const CliLogsRichView = ({
className,
}: CliLogsRichViewProps): React.JSX.Element => {
const scrollRef = useRef(null);
+ const stickToEdgeRef = useRef(true);
+ const lastOrderRef = useRef(order);
// Tracks groups manually collapsed by user (default: all auto-expanded)
const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set());
const [expandedItemIds, setExpandedItemIds] = useState>(new Set());
@@ -173,15 +177,38 @@ export const CliLogsRichView = ({
return expanded;
}, [groups, collapsedGroupIds]);
- // Auto-scroll to bottom on new content
- useEffect(() => {
- if (scrollRef.current) {
+ const computeShouldStickToEdge = useCallback(
+ (el: HTMLDivElement): boolean => {
+ // Small threshold makes it feel "sticky" but still allows reading slightly away from the edge
+ const thresholdPx = 16;
if (order === 'newest-first') {
- scrollRef.current.scrollTop = 0;
- } else {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ return el.scrollTop <= thresholdPx;
}
+ const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
+ return distanceFromBottom <= thresholdPx;
+ },
+ [order]
+ );
+
+ // Auto-scroll only when user is pinned to the edge.
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+
+ // If the sort order changes, always snap once (expectation: show the "newest edge").
+ if (lastOrderRef.current !== order) {
+ lastOrderRef.current = order;
+ stickToEdgeRef.current = true;
}
+
+ if (!stickToEdgeRef.current) return;
+
+ if (order === 'newest-first') {
+ el.scrollTop = 0;
+ return;
+ }
+
+ el.scrollTop = el.scrollHeight;
}, [cliLogsTail, order]);
const handleGroupToggle = useCallback((groupId: string) => {
@@ -223,6 +250,7 @@ export const CliLogsRichView = ({
)}
onScroll={(e) => {
const el = e.currentTarget;
+ stickToEdgeRef.current = computeShouldStickToEdge(el);
onScroll?.({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
@@ -254,6 +282,7 @@ export const CliLogsRichView = ({
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
onScroll={(e) => {
const el = e.currentTarget;
+ stickToEdgeRef.current = computeShouldStickToEdge(el);
onScroll?.({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index 0f29d8ef..1a1a19a7 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -287,13 +287,40 @@ export const ActivityTimeline = ({
? item.group.thoughts[0].leadSessionId
: item.message.leadSessionId;
+ // Pin the newest thought group (if first) so it stays at the top and doesn't jump.
+ const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
+ const startIndex = pinnedThoughtGroup ? 1 : 0;
+
return (
- {timelineItems.map((item, index) => {
+ {/* Pinned (newest) thought group — always at top */}
+ {pinnedThoughtGroup &&
+ (() => {
+ const { group } = pinnedThoughtGroup;
+ const firstThought = group.thoughts[0];
+ const info = memberInfo.get(firstThought.from);
+ const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
+ return (
+
+ );
+ })()}
+
+ {/* Remaining items */}
+ {timelineItems.slice(startIndex).map((item, index) => {
+ const realIndex = index + startIndex;
+
// Session boundary separator (messages sorted desc — new on top)
let sessionSeparator: React.JSX.Element | null = null;
- if (index > 0) {
- const prevSessionId = getItemSessionId(timelineItems[index - 1]);
+ if (realIndex > 0) {
+ const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
const currSessionId = getItemSessionId(item);
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
sessionSeparator = (
@@ -322,9 +349,10 @@ export const ActivityTimeline = ({
);
@@ -350,7 +378,7 @@ export const ActivityTimeline = ({
recipientColor={recipientColor}
isUnread={isUnread}
isNew={newItemKeys.has(messageKey)}
- zebraShade={zebraShadeSet.has(index)}
+ zebraShade={zebraShadeSet.has(realIndex)}
memberColorMap={colorMap}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index fcf9b849..e114915f 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import {
CARD_BG,
@@ -75,6 +76,8 @@ interface LeadThoughtsGroupRowProps {
memberColor?: string;
isNew?: boolean;
onVisible?: (message: InboxMessage) => void;
+ /** When false, the live indicator is always off (for historical thought groups). */
+ canBeLive?: boolean;
/** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean;
}
@@ -102,6 +105,7 @@ export const LeadThoughtsGroupRow = ({
memberColor,
isNew,
onVisible,
+ canBeLive,
zebraShade,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef
(null);
@@ -130,11 +134,12 @@ export const LeadThoughtsGroupRow = ({
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback(
() =>
+ canBeLive !== false &&
isTeamAlive &&
(leadActivity === 'active' ||
(leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
isRecentTimestamp(newest.timestamp)),
- [isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
+ [canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
);
const [isLive, setIsLive] = useState(computeIsLive);
@@ -168,13 +173,16 @@ export const LeadThoughtsGroupRow = ({
return () => observer.disconnect();
}, [onVisible, thoughts]);
- // Auto-scroll to bottom when new thoughts arrive
+ // Stable ref for auto-scroll trigger: track content changes (new thoughts + text growth)
+ const newestTextLength = newest.text.length;
+
+ // Auto-scroll to bottom when new thoughts arrive or text grows
useEffect(() => {
if (isUserScrolledUpRef.current) return;
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
- }, [thoughts.length]);
+ }, [thoughts.length, newestTextLength]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
@@ -195,7 +203,6 @@ export const LeadThoughtsGroupRow = ({
backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
- opacity: isLive ? undefined : 0.75,
}}
>
{/* Header */}
@@ -236,9 +243,13 @@ export const LeadThoughtsGroupRow = ({
{formatTimeWithSec(thought.timestamp)}
-
- {thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text}
-
+
+
+
))}
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx
index 62cc2f56..c55619e9 100644
--- a/src/renderer/components/team/members/MemberLogsTab.tsx
+++ b/src/renderer/components/team/members/MemberLogsTab.tsx
@@ -61,17 +61,40 @@ export const MemberLogsTab = ({
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
[taskWorkIntervals]
);
+ const isMountedRef = useRef(true);
const hasLoadedRef = useRef(false);
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
+ const refreshCountRef = useRef(0);
const [error, setError] = useState(null);
const [expandedId, setExpandedId] = useState(null);
+ const expandedIdRef = useRef(null);
const [detailChunks, setDetailChunks] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [previewChunks, setPreviewChunks] = useState(null);
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ expandedIdRef.current = expandedId;
+ }, [expandedId]);
+
+ const beginRefreshing = useCallback((): void => {
+ refreshCountRef.current += 1;
+ if (isMountedRef.current) setRefreshing(true);
+ }, []);
+
+ const endRefreshing = useCallback((): void => {
+ refreshCountRef.current = Math.max(0, refreshCountRef.current - 1);
+ if (isMountedRef.current) setRefreshing(refreshCountRef.current > 0);
+ }, []);
+
const getRowId = useCallback((log: MemberLogSummary): string => {
return log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
@@ -173,6 +196,7 @@ export const MemberLogsTab = ({
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
const load = async (): Promise => {
+ let didBeginRefreshing = false;
try {
if (taskId == null && !memberName) {
if (!cancelled) setLogs([]);
@@ -181,7 +205,8 @@ export const MemberLogsTab = ({
if (!hasLoadedRef.current) {
setLoading(true);
} else {
- setRefreshing(true);
+ beginRefreshing();
+ didBeginRefreshing = true;
}
setError(null);
@@ -193,10 +218,22 @@ export const MemberLogsTab = ({
intervals: taskWorkIntervals,
})
: await api.teams.getMemberLogs(teamName, memberName!);
+ const nextLogs = Array.isArray(result) ? [...result] : [];
+
if (!cancelled) {
- setLogs(Array.isArray(result) ? [...result] : []);
+ setLogs(nextLogs);
hasLoadedRef.current = true;
}
+
+ // Keep expanded session details in sync with the same refresh
+ // cadence as the summary (counts/titles) while "Updating..." is shown.
+ if (!cancelled && didBeginRefreshing) {
+ try {
+ await refreshExpandedDetailFromLogs(nextLogs);
+ } catch {
+ // Keep last successful detail view; avoid flicker on transient failures.
+ }
+ }
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unknown error');
@@ -204,7 +241,7 @@ export const MemberLogsTab = ({
} finally {
if (!cancelled) {
setLoading(false);
- setRefreshing(false);
+ if (didBeginRefreshing) endRefreshing();
}
}
};
@@ -232,6 +269,26 @@ export const MemberLogsTab = ({
[]
);
+ const refreshExpandedDetailFromLogs = useCallback(
+ async (nextLogs: MemberLogSummary[]): Promise => {
+ const rowId = expandedIdRef.current;
+ if (!rowId) return;
+ if (!isMountedRef.current) return;
+
+ const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId);
+ if (!nextExpanded) return;
+
+ const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
+ if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return;
+
+ const next = await fetchDetailForLog(nextExpanded);
+ if (!isMountedRef.current) return;
+ // Ensure new reference so memoized transforms update.
+ setDetailChunks(next ? [...next] : null);
+ },
+ [fetchDetailForLog, getRowId, taskId, taskStatus]
+ );
+
useEffect(() => {
if (!shouldShowPreview) {
setPreviewChunks(null);
@@ -268,12 +325,15 @@ export const MemberLogsTab = ({
let cancelled = false;
const interval = setInterval(async () => {
+ beginRefreshing();
try {
const next = await fetchDetailForLog(previewLog);
if (cancelled) return;
setPreviewChunks(next ? [...next] : null);
} catch {
// keep last successful preview
+ } finally {
+ endRefreshing();
}
}, 5000);
@@ -281,16 +341,27 @@ export const MemberLogsTab = ({
cancelled = true;
clearInterval(interval);
};
- }, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]);
+ }, [
+ beginRefreshing,
+ endRefreshing,
+ fetchDetailForLog,
+ previewLog,
+ shouldShowPreview,
+ taskStatus,
+ ]);
useEffect(() => {
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!expandedLogSummary) return;
- if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
+ // When task logs are auto-refreshing, the summary refresh loop also refreshes
+ // expanded details to keep everything in sync (and avoid duplicate requests).
+ if (shouldAutoRefreshSummary) return;
+ if (!expandedLogSummary.isOngoing) return;
let cancelled = false;
- const interval = setInterval(async () => {
+ const refreshDetail = async (): Promise => {
+ beginRefreshing();
try {
const next = await fetchDetailForLog(expandedLogSummary);
if (cancelled) return;
@@ -298,14 +369,18 @@ export const MemberLogsTab = ({
setDetailChunks(next ? [...next] : null);
} catch {
// Keep last successful data; avoid flicker during transient errors.
+ } finally {
+ endRefreshing();
}
- }, 5000);
+ };
+
+ const interval = setInterval(() => void refreshDetail(), 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
- }, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
+ }, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
const handleExpand = useCallback(
async (log: MemberLogSummary) => {
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index 5d918032..89563741 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -414,7 +414,7 @@ export const MessageComposer = ({
(({ className, children, ...props }, ref) => (
-
-
-
+
+
+
Close
+
+ {children}
+
- {children}
-
+
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 06220230..7a330125 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -208,6 +208,8 @@ export interface SendMessageRequest {
from?: string;
attachments?: AttachmentPayload[];
source?: InboxMessage['source'];
+ /** Lead session ID for session boundary detection. */
+ leadSessionId?: string;
}
export interface SendMessageResult {
From b093e87c89a8d5675dc894bc6520ab63c4bd49fa Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 10:40:45 +0200
Subject: [PATCH 05/14] feat: add options for bypassing cache in session and
subagent detail retrieval
- Updated handleGetSessionDetail and handleGetSubagentDetail functions to accept an optional options parameter for bypassing cache.
- Modified IPC and API methods to support the new options parameter, enhancing flexibility in data retrieval.
- Adjusted MemberLogsTab to utilize the bypassCache option when fetching log details, improving data freshness.
---
src/main/ipc/sessions.ts | 5 +-
src/main/ipc/subagents.ts | 5 +-
src/main/services/team/TeamDataService.ts | 71 +++++++++++++------
src/preload/index.ts | 12 ++--
src/renderer/api/httpClient.ts | 9 ++-
.../components/team/members/MemberLogsTab.tsx | 64 ++++++++++++++---
src/shared/types/api.ts | 9 ++-
7 files changed, 134 insertions(+), 41 deletions(-)
diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts
index e99caa7c..cea9d76e 100644
--- a/src/main/ipc/sessions.ts
+++ b/src/main/ipc/sessions.ts
@@ -198,7 +198,8 @@ async function handleGetSessionsByIds(
async function handleGetSessionDetail(
_event: IpcMainInvokeEvent,
projectId: string,
- sessionId: string
+ sessionId: string,
+ options?: { bypassCache?: boolean }
): Promise {
try {
const validatedProject = validateProjectId(projectId);
@@ -220,7 +221,7 @@ async function handleGetSessionDetail(
// Check cache first
let sessionDetail = dataCache.get(cacheKey);
- if (sessionDetail) {
+ if (sessionDetail && !options?.bypassCache) {
return sessionDetail;
}
diff --git a/src/main/ipc/subagents.ts b/src/main/ipc/subagents.ts
index 747605d3..9dc10d52 100644
--- a/src/main/ipc/subagents.ts
+++ b/src/main/ipc/subagents.ts
@@ -56,7 +56,8 @@ async function handleGetSubagentDetail(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string,
- subagentId: string
+ subagentId: string,
+ options?: { bypassCache?: boolean }
): Promise {
try {
const validatedProject = validateProjectId(projectId);
@@ -85,7 +86,7 @@ async function handleGetSubagentDetail(
// Check cache first
let subagentDetail = dataCache.getSubagent(cacheKey);
- if (subagentDetail) {
+ if (subagentDetail && !options?.bypassCache) {
return subagentDetail;
}
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index b6edd939..7f9bea48 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -300,30 +300,61 @@ export class TeamDataService {
});
}
- // Enrich inbox messages without leadSessionId by propagating from neighboring
- // messages that have it (lead_session, user_sent). Sort chronologically (asc),
- // sweep forward, then sweep backward so orphans at the start also get a session.
+ // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
+ // session ID (by timestamp). This avoids the old forward-only propagation bug where
+ // messages between two sessions always inherited the *earlier* session, causing a
+ // spurious "New session" divider even when the message is chronologically closer to
+ // the later session.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
- // Forward pass: propagate leadSessionId from earlier messages to later ones
- let currentSessionId: string | undefined;
- for (const msg of messages) {
- if (msg.leadSessionId) {
- currentSessionId = msg.leadSessionId;
- } else if (currentSessionId) {
- msg.leadSessionId = currentSessionId;
+
+ // Collect indices of messages that already have a leadSessionId (anchors).
+ const anchors: { index: number; time: number; sessionId: string }[] = [];
+ for (let i = 0; i < messages.length; i++) {
+ if (messages[i].leadSessionId) {
+ anchors.push({
+ index: i,
+ time: Date.parse(messages[i].timestamp),
+ sessionId: messages[i].leadSessionId!,
+ });
}
}
- // Backward pass: fill messages before the first known session.
- // Seed with config.leadSessionId so that recent messages without an explicit
- // session ID inherit the current (most recent) session — this ensures that
- // session boundary separators work even when inbox entries lack the field.
- currentSessionId = config.leadSessionId;
- for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i].leadSessionId) {
- currentSessionId = messages[i].leadSessionId;
- } else if (currentSessionId) {
- messages[i].leadSessionId = currentSessionId;
+
+ if (anchors.length > 0) {
+ // For each message without leadSessionId, find the closest anchor by timestamp
+ // and inherit its sessionId.
+ let anchorIdx = 0;
+ for (let i = 0; i < messages.length; i++) {
+ if (messages[i].leadSessionId) {
+ // Advance anchorIdx to track current position for efficient lookup
+ while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
+ anchorIdx++;
+ }
+ continue;
+ }
+
+ const msgTime = Date.parse(messages[i].timestamp);
+
+ // Find closest anchor by timestamp (binary-search-like scan from current position)
+ let bestAnchor = anchors[0];
+ let bestDist = Math.abs(msgTime - bestAnchor.time);
+ for (let a = 0; a < anchors.length; a++) {
+ const dist = Math.abs(msgTime - anchors[a].time);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestAnchor = anchors[a];
+ } else if (dist > bestDist && anchors[a].time > msgTime) {
+ // Anchors are sorted by index (asc time) — once distance grows past the
+ // message time, further anchors will only be farther.
+ break;
+ }
+ }
+ messages[i].leadSessionId = bestAnchor.sessionId;
+ }
+ } else if (config.leadSessionId) {
+ // No anchors at all — fall back to config.leadSessionId for everything.
+ for (const msg of messages) {
+ msg.leadSessionId = config.leadSessionId;
}
}
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 687132ad..9f361333 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -347,14 +347,18 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
searchAllProjects: (query: string, maxResults?: number) =>
ipcRenderer.invoke('search-all-projects', query, maxResults),
- getSessionDetail: (projectId: string, sessionId: string) =>
- ipcRenderer.invoke('get-session-detail', projectId, sessionId),
+ getSessionDetail: (projectId: string, sessionId: string, options?: { bypassCache?: boolean }) =>
+ ipcRenderer.invoke('get-session-detail', projectId, sessionId, options),
getSessionMetrics: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-metrics', projectId, sessionId),
getWaterfallData: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-waterfall-data', projectId, sessionId),
- getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) =>
- ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId),
+ getSubagentDetail: (
+ projectId: string,
+ sessionId: string,
+ subagentId: string,
+ options?: { bypassCache?: boolean }
+ ) => ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId, options),
getSessionGroups: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-groups', projectId, sessionId),
getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) =>
diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts
index 7fc64c7c..033bebe5 100644
--- a/src/renderer/api/httpClient.ts
+++ b/src/renderer/api/httpClient.ts
@@ -246,7 +246,11 @@ export class HttpAPIClient implements ElectronAPI {
return this.get(`/api/search?${params}`);
};
- getSessionDetail = (projectId: string, sessionId: string): Promise =>
+ getSessionDetail = (
+ projectId: string,
+ sessionId: string,
+ _options?: { bypassCache?: boolean }
+ ): Promise =>
this.get(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
);
@@ -264,7 +268,8 @@ export class HttpAPIClient implements ElectronAPI {
getSubagentDetail = (
projectId: string,
sessionId: string,
- subagentId: string
+ subagentId: string,
+ _options?: { bypassCache?: boolean }
): Promise =>
this.get(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx
index c55619e9..3cf05dbf 100644
--- a/src/renderer/components/team/members/MemberLogsTab.tsx
+++ b/src/renderer/components/team/members/MemberLogsTab.tsx
@@ -57,6 +57,7 @@ export const MemberLogsTab = ({
showLeadPreview = false,
onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => {
+ const MIN_REFRESH_VISIBLE_MS = 250;
const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
[taskWorkIntervals]
@@ -68,6 +69,8 @@ export const MemberLogsTab = ({
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const refreshCountRef = useRef(0);
+ const refreshBeganAtRef = useRef(null);
+ const refreshHideTimeoutRef = useRef | null>(null);
const [error, setError] = useState(null);
const [expandedId, setExpandedId] = useState(null);
const expandedIdRef = useRef(null);
@@ -78,6 +81,10 @@ export const MemberLogsTab = ({
useEffect(() => {
return () => {
isMountedRef.current = false;
+ if (refreshHideTimeoutRef.current) {
+ clearTimeout(refreshHideTimeoutRef.current);
+ refreshHideTimeoutRef.current = null;
+ }
};
}, []);
@@ -86,13 +93,40 @@ export const MemberLogsTab = ({
}, [expandedId]);
const beginRefreshing = useCallback((): void => {
+ if (refreshCountRef.current === 0) {
+ refreshBeganAtRef.current = Date.now();
+ if (refreshHideTimeoutRef.current) {
+ clearTimeout(refreshHideTimeoutRef.current);
+ refreshHideTimeoutRef.current = null;
+ }
+ }
refreshCountRef.current += 1;
if (isMountedRef.current) setRefreshing(true);
}, []);
const endRefreshing = useCallback((): void => {
refreshCountRef.current = Math.max(0, refreshCountRef.current - 1);
- if (isMountedRef.current) setRefreshing(refreshCountRef.current > 0);
+ if (refreshCountRef.current > 0) {
+ if (isMountedRef.current) setRefreshing(true);
+ return;
+ }
+
+ const beganAt = refreshBeganAtRef.current;
+ refreshBeganAtRef.current = null;
+ const elapsed = beganAt ? Date.now() - beganAt : Number.POSITIVE_INFINITY;
+
+ if (!isMountedRef.current) return;
+ if (elapsed >= MIN_REFRESH_VISIBLE_MS) {
+ setRefreshing(false);
+ return;
+ }
+
+ const remaining = Math.max(0, MIN_REFRESH_VISIBLE_MS - elapsed);
+ refreshHideTimeoutRef.current = setTimeout(() => {
+ refreshHideTimeoutRef.current = null;
+ if (!isMountedRef.current) return;
+ if (refreshCountRef.current === 0) setRefreshing(false);
+ }, remaining);
}, []);
const getRowId = useCallback((log: MemberLogSummary): string => {
@@ -258,12 +292,20 @@ export const MemberLogsTab = ({
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
const fetchDetailForLog = useCallback(
- async (log: MemberLogSummary): Promise => {
+ async (
+ log: MemberLogSummary,
+ options?: { bypassCache?: boolean }
+ ): Promise => {
if (log.kind === 'subagent') {
- const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
+ const d = await api.getSubagentDetail(
+ log.projectId,
+ log.sessionId,
+ log.subagentId,
+ options
+ );
return d?.chunks ?? null;
}
- const d = await api.getSessionDetail(log.projectId, log.sessionId);
+ const d = await api.getSessionDetail(log.projectId, log.sessionId, options);
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
},
[]
@@ -281,7 +323,7 @@ export const MemberLogsTab = ({
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return;
- const next = await fetchDetailForLog(nextExpanded);
+ const next = await fetchDetailForLog(nextExpanded, { bypassCache: true });
if (!isMountedRef.current) return;
// Ensure new reference so memoized transforms update.
setDetailChunks(next ? [...next] : null);
@@ -327,7 +369,7 @@ export const MemberLogsTab = ({
const interval = setInterval(async () => {
beginRefreshing();
try {
- const next = await fetchDetailForLog(previewLog);
+ const next = await fetchDetailForLog(previewLog, { bypassCache: true });
if (cancelled) return;
setPreviewChunks(next ? [...next] : null);
} catch {
@@ -363,7 +405,7 @@ export const MemberLogsTab = ({
const refreshDetail = async (): Promise => {
beginRefreshing();
try {
- const next = await fetchDetailForLog(expandedLogSummary);
+ const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true });
if (cancelled) return;
// Ensure new reference so memoized transforms update.
setDetailChunks(next ? [...next] : null);
@@ -395,7 +437,11 @@ export const MemberLogsTab = ({
setDetailChunks(null);
setDetailLoading(true);
try {
- const chunks = await fetchDetailForLog(log);
+ const shouldBypassCache = log.isOngoing || taskStatus === 'in_progress';
+ const chunks = await fetchDetailForLog(
+ log,
+ shouldBypassCache ? { bypassCache: true } : undefined
+ );
setDetailChunks(chunks ? [...chunks] : null);
} catch {
setDetailChunks(null);
@@ -403,7 +449,7 @@ export const MemberLogsTab = ({
setDetailLoading(false);
}
},
- [expandedId, fetchDetailForLog, getRowId]
+ [expandedId, fetchDetailForLog, getRowId, taskStatus]
);
if (loading && logs.length === 0) {
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index d8227f7a..2b43e502 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -614,13 +614,18 @@ export interface ElectronAPI {
maxResults?: number
) => Promise;
searchAllProjects: (query: string, maxResults?: number) => Promise;
- getSessionDetail: (projectId: string, sessionId: string) => Promise;
+ getSessionDetail: (
+ projectId: string,
+ sessionId: string,
+ options?: { bypassCache?: boolean }
+ ) => Promise;
getSessionMetrics: (projectId: string, sessionId: string) => Promise;
getWaterfallData: (projectId: string, sessionId: string) => Promise;
getSubagentDetail: (
projectId: string,
sessionId: string,
- subagentId: string
+ subagentId: string,
+ options?: { bypassCache?: boolean }
) => Promise;
getSessionGroups: (projectId: string, sessionId: string) => Promise;
getSessionsByIds: (
From 30b4e74924822713e6aefaf851f2781c4032ba13 Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 13:00:08 +0200
Subject: [PATCH 06/14] feat: enhance file path validation and member stats
computation
- Refactored isValidFilePath function to improve validation logic, including trimming whitespace and stripping trailing punctuation.
- Updated MemberStatsComputer to filter out invalid file paths from perFileStats, ensuring only valid paths are included in the final stats.
- Added unit tests for isValidFilePath to cover various edge cases, enhancing reliability of file path handling.
- Improved overall code readability and maintainability in MemberStatsComputer.
---
src/main/services/team/MemberStatsComputer.ts | 32 +++++--
src/main/services/team/TeamDataService.ts | 41 +++++---
.../services/team/TeamProvisioningService.ts | 95 +++----------------
.../components/layout/SortableTab.tsx | 4 +-
.../team/activity/LeadThoughtsGroup.tsx | 51 +++++++---
.../attachments/AttachmentPreviewList.tsx | 15 ++-
.../team/messages/MessageComposer.tsx | 2 +
src/renderer/hooks/useAttachments.ts | 6 ++
src/renderer/index.css | 16 ++++
test/main/ipc/teams.test.ts | 2 -
.../services/team/MemberStatsComputer.test.ts | 45 ++++++++-
11 files changed, 185 insertions(+), 124 deletions(-)
diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts
index 6443436b..738cd80e 100644
--- a/src/main/services/team/MemberStatsComputer.ts
+++ b/src/main/services/team/MemberStatsComputer.ts
@@ -9,8 +9,12 @@ import type { FileLineStats, MemberFullStats } from '@shared/types';
const logger = createLogger('Service:MemberStatsComputer');
-function isValidFilePath(value: string): boolean {
- return value.length > 0 && value !== 'null' && value !== 'undefined' && value !== 'None';
+const TRAILING_PUNCT = /[;.,]+$/;
+const INVALID_NAMES = new Set(['null', 'undefined', 'None', 'false', 'true', '']);
+
+export function isValidFilePath(value: string): boolean {
+ const cleaned = value.trim().replace(TRAILING_PUNCT, '');
+ return cleaned.length > 1 && !INVALID_NAMES.has(cleaned) && cleaned.includes('/');
}
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
@@ -73,11 +77,19 @@ export class MemberStatsComputer {
.filter(isValidFilePath)
.sort((a, b) => a.localeCompare(b));
+ // Also filter perFileStats keys to exclude invalid paths
+ const filteredFileStats: Record = {};
+ for (const [fp, fls] of Object.entries(perFileStats)) {
+ if (isValidFilePath(fp)) {
+ filteredFileStats[fp] = fls;
+ }
+ }
+
const stats: MemberFullStats = {
linesAdded,
linesRemoved,
filesTouched: validFiles,
- fileStats: perFileStats,
+ fileStats: filteredFileStats,
toolUsage,
inputTokens,
outputTokens,
@@ -121,18 +133,24 @@ export class MemberStatsComputer {
// Track last known content per file for accurate Write/NotebookEdit diffs
const fileLastContent = new Map();
+ const cleanPath = (fp: string): string => fp.trim().replace(TRAILING_PUNCT, '');
+
const trackFile = (fp: string): void => {
- if (typeof fp === 'string' && isValidFilePath(fp)) filesTouchedSet.add(fp);
+ if (typeof fp === 'string') {
+ const cleaned = cleanPath(fp);
+ if (isValidFilePath(cleaned)) filesTouchedSet.add(cleaned);
+ }
};
const addFileLines = (fp: string, added: number, removed: number): void => {
- if (!isValidFilePath(fp)) return;
- const existing = perFileStats[fp];
+ const cleaned = cleanPath(fp);
+ if (!isValidFilePath(cleaned)) return;
+ const existing = perFileStats[cleaned];
if (existing) {
existing.added += added;
existing.removed += removed;
} else {
- perFileStats[fp] = { added, removed };
+ perFileStats[cleaned] = { added, removed };
}
};
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 7f9bea48..cd31e560 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -9,7 +9,11 @@ import {
} from '@main/utils/pathDecoder';
import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill';
-import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
+import {
+ AGENT_BLOCK_CLOSE,
+ AGENT_BLOCK_OPEN,
+ stripAgentBlocks,
+} from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
@@ -60,7 +64,7 @@ import type {
const logger = createLogger('Service:TeamDataService');
const MIN_TEXT_LENGTH = 30;
-const MAX_LEAD_TEXTS = 50;
+const MAX_LEAD_TEXTS = 150;
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024;
const TASK_MAP_YIELD_EVERY = 250;
@@ -1472,20 +1476,31 @@ export class TeamDataService {
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
+ const textParts: string[] = [];
for (const block of content as Record[]) {
if (block.type !== 'text' || typeof block.text !== 'string') continue;
- const text = block.text.trim();
- if (text.length < MIN_TEXT_LENGTH) continue;
- textsReversed.push({
- from: leadName,
- text,
- timestamp,
- read: true,
- source: 'lead_session',
- leadSessionId: config.leadSessionId,
- });
- if (textsReversed.length >= MAX_LEAD_TEXTS) break;
+ textParts.push(block.text);
}
+ if (textParts.length === 0) continue;
+
+ const combined = stripAgentBlocks(textParts.join('\n')).trim();
+ if (combined.length < MIN_TEXT_LENGTH) continue;
+
+ // Stable messageId: timestamp + text prefix (survives tail-scan range changes)
+ const textPrefix = combined
+ .slice(0, 50)
+ .replace(/[^\p{L}\p{N}]/gu, '')
+ .slice(0, 20);
+
+ textsReversed.push({
+ from: leadName,
+ text: combined,
+ timestamp,
+ read: true,
+ source: 'lead_session',
+ leadSessionId: config.leadSessionId,
+ messageId: `lead-session-${timestamp}-${textPrefix}`,
+ });
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 1156b83a..77cf5cfc 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -161,15 +161,8 @@ interface ProvisioningRun {
rejectOnce: (error: string) => void;
timeoutHandle: NodeJS.Timeout;
} | null;
- /**
- * Accumulates assistant text for direct user→lead messages (no relay capture active).
- * Flushed to liveLeadProcessMessages on result.success.
- */
- directReplyParts: string[];
- /** Monotonic counter for stream-json turns (incremented on result). */
- leadTurnSeq: number;
- /** Stable timestamp used for the current aggregated lead turn message. */
- leadTurnMessageTimestamp: string | null;
+ /** Monotonic counter for individual lead assistant messages. */
+ leadMsgSeq: number;
/** Throttle timestamp for emitting inbox refresh events for lead text. */
lastLeadTextEmitMs: number;
/**
@@ -1750,9 +1743,7 @@ export class TeamProvisioningService {
isLaunch: false,
fsPhase: 'waiting_config',
leadRelayCapture: null,
- directReplyParts: [],
- leadTurnSeq: 0,
- leadTurnMessageTimestamp: null,
+ leadMsgSeq: 0,
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@@ -2051,9 +2042,7 @@ export class TeamProvisioningService {
isLaunch: true,
fsPhase: 'waiting_members',
leadRelayCapture: null,
- directReplyParts: [],
- leadTurnSeq: 0,
- leadTurnMessageTimestamp: null,
+ leadMsgSeq: 0,
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@@ -2491,8 +2480,6 @@ export class TeamProvisioningService {
},
};
run.leadRelayCapture = capture;
- // Clear any direct reply parts — relay capture takes priority
- run.directReplyParts = [];
});
try {
@@ -2921,27 +2908,21 @@ export class TeamProvisioningService {
}, capture.idleMs);
}
} else if (run.provisioningComplete) {
- // Accumulate assistant text for a single "live lead turn" message in Messages.
- // If the same assistant message includes SendMessage(to:"user"), prefer the captured
- // SendMessage and avoid duplicating it as a separate lead text entry.
+ // Push each assistant text block as a separate live message (per-message pattern).
+ // When the same assistant message includes SendMessage(to:"user"), skip text —
+ // captureSendMessageToUser() handles it separately.
if (!run.silentUserDmForward && !hasSendMessageToUser) {
- run.directReplyParts.push(text);
- const raw = run.directReplyParts.join('\n');
- const cleanText = stripAgentBlocks(raw).trim();
+ const cleanText = stripAgentBlocks(text).trim();
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
+ run.leadMsgSeq += 1;
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
- if (!run.leadTurnMessageTimestamp) {
- run.leadTurnMessageTimestamp = nowIso();
- }
- // Update timestamp on each text block so the live indicator stays fresh
- const currentTimestamp = nowIso();
- const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`;
+ const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
- timestamp: currentTimestamp,
+ timestamp: nowIso(),
read: true,
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId,
@@ -2962,13 +2943,6 @@ export class TeamProvisioningService {
});
}
}
- } else if (hasSendMessageToUser) {
- run.directReplyParts = [];
- run.leadTurnMessageTimestamp = null;
- this.removeLiveLeadProcessMessage(
- run.teamName,
- `lead-turn-${run.runId}-${run.leadTurnSeq}`
- );
}
}
}
@@ -3110,47 +3084,6 @@ export class TeamProvisioningService {
const capture = run.leadRelayCapture;
const combined = capture.textParts.join('\n').trim();
capture.resolveOnce(combined);
- } else if (run.provisioningComplete && run.directReplyParts.length > 0) {
- // Finalize the current live lead turn message (single messageId per turn).
- const rawReply = run.directReplyParts.join('\n').trim();
- run.directReplyParts = [];
- const leadName =
- run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
- 'team-lead';
- const replyText = stripAgentBlocks(rawReply).trim();
- const finalText =
- replyText.length > 0
- ? replyText
- : rawReply.length > 0
- ? '(Message received and processed)'
- : '';
- if (finalText.length > 0) {
- if (!run.leadTurnMessageTimestamp) {
- run.leadTurnMessageTimestamp = nowIso();
- }
- const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`;
- const replyMsg: InboxMessage = {
- from: leadName,
- text: finalText,
- timestamp: run.leadTurnMessageTimestamp,
- read: true,
- summary: finalText.length > 60 ? finalText.slice(0, 57) + '...' : finalText,
- messageId,
- source: 'lead_process',
- };
- this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
- this.teamChangeEmitter?.({
- type: 'inbox',
- teamName: run.teamName,
- detail: 'lead-turn-final',
- });
- }
- }
- // Turn boundary: advance lead turn sequence.
- if (run.provisioningComplete) {
- run.leadTurnSeq += 1;
- run.leadTurnMessageTimestamp = null;
- run.directReplyParts = [];
}
// Clear silent relay flag after any successful turn.
run.silentUserDmForward = null;
@@ -3168,12 +3101,6 @@ export class TeamProvisioningService {
if (run.leadRelayCapture) {
run.leadRelayCapture.rejectOnce(errorMsg);
}
- // Turn boundary: advance lead turn sequence.
- if (run.provisioningComplete) {
- run.leadTurnSeq += 1;
- run.leadTurnMessageTimestamp = null;
- run.directReplyParts = [];
- }
// Clear silent relay flag after any errored turn.
run.silentUserDmForward = null;
if (run.silentUserDmForwardClearHandle) {
diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx
index bf366be6..7ee062cf 100644
--- a/src/renderer/components/layout/SortableTab.tsx
+++ b/src/renderer/components/layout/SortableTab.tsx
@@ -125,7 +125,7 @@ export const SortableTab = ({
role="tab"
tabIndex={0}
aria-selected={isActive}
- className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
+ className="group flex min-w-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
style={style}
onClick={(e) => onTabClick(tab.id, e)}
onMouseDown={(e) => onMouseDown(tab.id, e)}
@@ -188,7 +188,7 @@ export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
return (
- {formatTime(oldest.timestamp)}–{formatTime(newest.timestamp)}
+ {formatTime(oldest.timestamp) === formatTime(newest.timestamp)
+ ? formatTime(oldest.timestamp)
+ : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
{/* Scrollable body — fixed height, always visible */}
{chronologicalThoughts.map((thought, idx) => (
-
-
- {formatTimeWithSec(thought.timestamp)}
-
-
-
+
+ {idx > 0 && (
+
+
+
+ {formatTimeWithSec(thought.timestamp)}
+
+
+
+ )}
+
))}
diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx
index cb86dec8..362fe301 100644
--- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx
+++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx
@@ -1,4 +1,4 @@
-import { AlertCircle } from 'lucide-react';
+import { AlertCircle, X } from 'lucide-react';
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
@@ -8,6 +8,7 @@ interface AttachmentPreviewListProps {
attachments: AttachmentPayload[];
onRemove: (id: string) => void;
error?: string | null;
+ onDismissError?: () => void;
/** When true, previews are overlaid with a disabled indicator (recipient doesn't support attachments). */
disabled?: boolean;
/** Hint text shown when disabled and attachments are present. */
@@ -18,6 +19,7 @@ export const AttachmentPreviewList = ({
attachments,
onRemove,
error,
+ onDismissError,
disabled,
disabledHint,
}: AttachmentPreviewListProps): React.JSX.Element | null => {
@@ -49,7 +51,16 @@ export const AttachmentPreviewList = ({
{error ? (
-
{error}
+
{error}
+ {onDismissError ? (
+
+
+
+ ) : null}
) : null}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index 89563741..94259ef8 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -128,6 +128,7 @@ export const MessageComposer = ({
addFiles,
removeAttachment,
clearAttachments,
+ clearError: clearAttachmentError,
handlePaste,
handleDrop,
} = useAttachments({ persistenceKey: `compose:${teamName}:attachments` });
@@ -408,6 +409,7 @@ export const MessageComposer = ({
attachments={attachments}
onRemove={removeAttachment}
error={attachmentError}
+ onDismissError={clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts
index 6c2154c6..ca17ffb3 100644
--- a/src/renderer/hooks/useAttachments.ts
+++ b/src/renderer/hooks/useAttachments.ts
@@ -23,6 +23,7 @@ interface UseAttachmentsReturn {
addFiles: (files: FileList | File[]) => Promise
;
removeAttachment: (id: string) => void;
clearAttachments: () => void;
+ clearError: () => void;
handlePaste: (event: React.ClipboardEvent) => void;
handleDrop: (event: React.DragEvent) => void;
}
@@ -219,6 +220,10 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
[schedulePersist]
);
+ const clearError = useCallback(() => {
+ setError(null);
+ }, []);
+
const clearAttachments = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
@@ -280,6 +285,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
addFiles,
removeAttachment,
clearAttachments,
+ clearError,
handlePaste,
handleDrop,
};
diff --git a/src/renderer/index.css b/src/renderer/index.css
index 7506603d..fec49dd6 100644
--- a/src/renderer/index.css
+++ b/src/renderer/index.css
@@ -642,6 +642,22 @@ body {
animation: chat-message-enter 350ms ease-out both;
}
+@keyframes thought-expand {
+ from {
+ max-height: 0;
+ opacity: 0;
+ overflow: hidden;
+ }
+ to {
+ max-height: 500px;
+ opacity: 1;
+ }
+}
+
+.thought-expand-in {
+ animation: thought-expand 350ms ease-out both;
+}
+
.skeleton-card {
animation: skeleton-fade-in 0.4s ease-out both;
position: relative;
diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts
index 522dd469..959bc7cf 100644
--- a/test/main/ipc/teams.test.ts
+++ b/test/main/ipc/teams.test.ts
@@ -274,7 +274,6 @@ describe('ipc teams handlers', () => {
messages: [
{
from: 'team-lead',
- to: 'user',
text: 'Hello there',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
@@ -287,7 +286,6 @@ describe('ipc teams handlers', () => {
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
- to: 'user',
text: 'Hello there',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
diff --git a/test/main/services/team/MemberStatsComputer.test.ts b/test/main/services/team/MemberStatsComputer.test.ts
index 5c62acf7..058db67d 100644
--- a/test/main/services/team/MemberStatsComputer.test.ts
+++ b/test/main/services/team/MemberStatsComputer.test.ts
@@ -1,6 +1,49 @@
import { describe, expect, it } from 'vitest';
-import { estimateBashLinesChanged } from '../../../../src/main/services/team/MemberStatsComputer';
+import {
+ estimateBashLinesChanged,
+ isValidFilePath,
+} from '../../../../src/main/services/team/MemberStatsComputer';
+
+describe('isValidFilePath', () => {
+ it('rejects null-like values', () => {
+ expect(isValidFilePath('null')).toBe(false);
+ expect(isValidFilePath('null;')).toBe(false);
+ expect(isValidFilePath('null;,')).toBe(false);
+ expect(isValidFilePath('undefined')).toBe(false);
+ expect(isValidFilePath('None')).toBe(false);
+ expect(isValidFilePath('false')).toBe(false);
+ expect(isValidFilePath('true')).toBe(false);
+ expect(isValidFilePath('')).toBe(false);
+ });
+
+ it('rejects paths without slash', () => {
+ expect(isValidFilePath('somefile')).toBe(false);
+ expect(isValidFilePath('a')).toBe(false);
+ });
+
+ it('rejects very short paths', () => {
+ expect(isValidFilePath('/')).toBe(false);
+ });
+
+ it('accepts valid file paths', () => {
+ expect(isValidFilePath('/tmp/file.txt')).toBe(true);
+ expect(isValidFilePath('/Users/dev/project/src/main.ts')).toBe(true);
+ expect(isValidFilePath('./src/index.ts')).toBe(true);
+ expect(isValidFilePath('src/utils/helper.ts')).toBe(true);
+ });
+
+ it('strips trailing punctuation before validation', () => {
+ expect(isValidFilePath('/tmp/file.txt;')).toBe(true);
+ expect(isValidFilePath('/tmp/file.txt,')).toBe(true);
+ expect(isValidFilePath('/tmp/file.txt.')).toBe(true);
+ });
+
+ it('handles whitespace', () => {
+ expect(isValidFilePath(' /tmp/file.txt ')).toBe(true);
+ expect(isValidFilePath(' null ')).toBe(false);
+ });
+});
describe('estimateBashLinesChanged', () => {
it('returns zero for simple non-writing commands', () => {
From 9368e7639d5ce7ff57364fe4b44d000b26d05e1f Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 13:51:37 +0200
Subject: [PATCH 07/14] feat: enhance tool summary integration in messaging
services and UI components
- Added tool summary generation in TeamDataService and TeamProvisioningService to capture tool usage details in messages.
- Updated InboxMessage type to include toolSummary for better tracking of tool usage in assistant messages.
- Enhanced LeadThoughtsGroupRow to aggregate and display total tool usage across thoughts, improving visibility of tool interactions.
- Refactored TeamModelSelector to incorporate provider icons and improve user interface for model selection.
- Updated README and CLAUDE.md to reflect new command usage and features related to tool summaries.
---
CLAUDE.md | 1 +
README.md | 8 +-
docs/research/model-agnostic-proxy.md | 467 ++++++++++++++++++
src/main/services/team/TeamDataService.ts | 4 +
.../services/team/TeamProvisioningService.ts | 5 +-
.../services/team/TeamSentMessagesStore.ts | 1 +
.../team/activity/LeadThoughtsGroup.tsx | 22 +
.../team/dialogs/TeamModelSelector.tsx | 228 ++++++++-
src/shared/types/team.ts | 2 +
src/shared/utils/toolSummary.ts | 47 ++
10 files changed, 756 insertions(+), 29 deletions(-)
create mode 100644 docs/research/model-agnostic-proxy.md
create mode 100644 src/shared/utils/toolSummary.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index a5d486ae..beb60a43 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,6 +8,7 @@ Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
## Commands
Always use pnpm (not npm/yarn) for this project.
Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel.
+When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`).
- `pnpm install` - Install dependencies
- `pnpm dev` - Dev server with hot reload
diff --git a/README.md b/README.md
index 788f3ac4..1cb536fe 100644
--- a/README.md
+++ b/README.md
@@ -38,8 +38,9 @@ A new approach to task management with AI agents.
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
+- **Solo mode** — a one-member team: a single agent (regular claude process) that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time
+- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states
-- **Solo mode** — a one-member team: a single agent that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time
- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box
- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app
- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree
@@ -204,8 +205,11 @@ pnpm dist # macOS + Windows + Linux
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
-- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills)
+- [ ] Install skills, MCP, and integrations via an intuitive UI, and only for selected agents
+- [ ] Planning mode to organize agent plans before execution
+- [ ] Сurate what context each agent sees (files, docs, MCP servers, skills)
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
+- [ ]
---
diff --git a/docs/research/model-agnostic-proxy.md b/docs/research/model-agnostic-proxy.md
new file mode 100644
index 00000000..7b74d6e5
--- /dev/null
+++ b/docs/research/model-agnostic-proxy.md
@@ -0,0 +1,467 @@
+# Model-Agnostic Proxy для Claude Code Agent Teams
+
+Дата исследования: 2026-03-06
+
+## Цель
+
+Сделать Claude Code Agent Teams model-agnostic: lead остаётся на Claude, а teammates могут работать на GPT-4o, Gemini, DeepSeek, Kimi K2.5 и других моделях через прокси, который транслирует Anthropic Messages API в формат целевого провайдера.
+
+## Ключевой механизм
+
+Claude Code обращается к Anthropic Messages API (`/v1/messages`). Переменная `ANTHROPIC_BASE_URL` позволяет перенаправить запросы на локальный прокси. Прокси:
+
+1. Принимает запрос в формате Anthropic Messages API
+2. Транслирует в формат целевого провайдера (OpenAI Chat Completions и др.)
+3. Пересылает провайдеру
+4. Получает SSE-стрим ответа
+5. Транслирует обратно в формат Anthropic SSE-событий
+6. Отдаёт Claude Code CLI как будто это ответ от Claude
+
+Team tools (TeamCreate, TaskCreate, SendMessage, TaskGet, TaskList, TaskUpdate, TeamDelete) исполняются **локально Claude Code CLI**. LLM только генерирует `tool_use` блоки. Значит прокси не нужно знать о team-семантике — достаточно корректно транслировать tool_use формат.
+
+---
+
+## Исследованные проекты
+
+### 1. HydraTeams
+
+- **URL**: https://github.com/Pickle-Pixel/HydraTeams
+- **Назначение**: Прокси-переводчик API специально для Agent Teams
+- **Язык**: TypeScript (~580 строк, 8 файлов)
+- **Зависимости**: Zero runtime dependencies (только Node.js builtins)
+- **Лицензия**: Не указана явно
+- **Stars**: 33 | **Forks**: 9 | **Commits**: 4 | **Создан**: 2026-02-08
+
+#### Архитектура
+
+```
+src/
+ index.ts (35) — точка входа, banner, graceful shutdown
+ proxy.ts (280) — HTTP-сервер, маршрутизация, retry
+ config.ts (95) — CLI-аргументы, env vars, Codex JWT
+ logger.ts (190) — логирование, идентификация сессий
+ translators/
+ types.ts (120) — интерфейсы Anthropic + OpenAI
+ messages.ts (85) — конвертация истории сообщений
+ request.ts (65) — Anthropic req -> OpenAI Chat Completions req
+ request-responses.ts (145) — Anthropic req -> ChatGPT Responses API req
+ response.ts (185) — OpenAI SSE -> Anthropic SSE
+ response-responses.ts (235) — Responses API SSE -> Anthropic SSE
+```
+
+#### Два конвейера трансляции
+
+1. **OpenAI Chat Completions API** (`--provider openai`) — для GPT-4o, GPT-4o-mini, o3-mini
+2. **ChatGPT Responses API** (`--provider chatgpt`) — для ChatGPT Subscription ($0 дополнительных затрат)
+
+#### Lead vs Teammate детекция
+
+- Lead: маркер `` в CLAUDE.md (попадает в system prompt)
+- Teammate: фраза `"the user interacts primarily with the team lead"` в system prompt
+- Lead-запросы passthrough на настоящий Anthropic API
+- Teammate-запросы транслируются на целевую модель
+
+#### Что работает правильно
+
+- Tool definitions: `input_schema` -> `parameters`, `tool_choice` маппинг корректен
+- tool_use блоки ассистанта -> `tool_calls` формат OpenAI
+- tool_result -> `role: "tool"` сообщения
+- Streaming: StreamState отслеживает blockIndex, activeToolCalls, textBlockStarted
+- Правильная SSE-последовательность: message_start -> content_block_start -> content_block_delta -> content_block_stop -> message_delta -> message_stop
+- Retry с exponential backoff на 429 (до 5 попыток)
+
+#### Найденные баги (code review)
+
+| # | Баг | Серьёзность |
+|---|-----|-------------|
+| 1 | **response-responses.ts**: `content_index` используется вместо Anthropic `blockIndex` в `response.output_text.done` — content_block_stop уйдёт с неправильным index | Высокая |
+| 2 | **proxy.ts**: `shouldPassthrough()` получает `parsed.system` (может быть массив `AnthropicSystemBlock[]`) как string — `count_tokens` passthrough для lead не сработает | Средняя |
+| 3 | **proxy.ts**: Non-streaming `JSON.parse(tc.function.arguments)` — если OpenAI вернёт невалидный JSON, exception убьёт весь запрос | Средняя |
+| 4 | **proxy.ts**: `shouldPassthrough("*")` означает "все Claude модели", а не "всё" — контринтуитивно | Низкая |
+| 5 | **logger.ts**: Warmup-запросы определяются по `toolCount === 0` — может ложно классифицировать обычные запросы | Низкая |
+
+#### Критические проблемы
+
+| Проблема | Серьёзность |
+|----------|-------------|
+| 0 тестов | Критично |
+| Нет таймаутов на upstream — fetch() без AbortController, зависнет навсегда | Критично |
+| Слушает на 0.0.0.0 — доступен в сети, релеит auth headers | Критично |
+| Teammate детекция по строке "the user interacts primarily with the team lead" — сломается при обновлении Claude Code | Высокая |
+| Spoofed model hardcoded `claude-sonnet-4-5-20250929` — устареет | Средняя |
+| Token counting = `JSON.length / 4` — грубая заглушка | Средняя |
+| Нет extended thinking — thinking блоки игнорируются | Средняя |
+| Нет image/multimodal — молча теряются | Низкая |
+
+#### Безопасность
+
+- Сервер слушает на 0.0.0.0 (все интерфейсы) — в сети это дыра
+- Passthrough релеит auth headers (x-api-key, authorization, cookie) к api.anthropic.com
+- JWT парсинг без валидации подписи
+- Нет rate limiting на входящие запросы
+- Логи могут содержать API-ключи в ответах об ошибках
+
+#### Итоговые оценки
+
+| Аспект | Оценка |
+|--------|:------:|
+| Архитектура | 7/10 |
+| API трансляция | 6/10 |
+| Lead/Teammate детекция | 5/10 |
+| Обработка ошибок | 4/10 |
+| Streaming | 7/10 |
+| Безопасность | 3/10 |
+| Production readiness | 3/10 |
+| **Общая** | **5/10** |
+
+---
+
+### 2. free-claude-code
+
+- **URL**: https://github.com/Alishahryar1/free-claude-code
+- **Назначение**: Прокси для использования Claude Code с бесплатными моделями
+- **Язык**: Python (FastAPI + uvicorn)
+- **Stars**: 814 | **Forks**: 95 | **Issues**: 4 | **Создан**: 2026-01-28
+- **Лицензия**: MIT
+- **Тесты**: 85+ файлов, pytest, GitHub Actions CI
+
+#### Архитектура
+
+```
+server.py — точка входа (uvicorn)
+api/
+ app.py — FastAPI factory + lifespan
+ routes.py — POST /v1/messages, GET /health
+ detection.py — эвристики определения типа запроса
+ optimization_handlers.py — 5 fast-path перехватчиков
+ request_utils.py — подсчёт токенов (tiktoken cl100k_base)
+ models/anthropic.py — Pydantic модели Anthropic request
+providers/
+ base.py — BaseProvider (ABC)
+ openai_compat.py — OpenAICompatibleProvider (основная логика)
+ common/
+ message_converter.py — Anthropic <-> OpenAI конвертер (~200 строк)
+ sse_builder.py — SSE event builder Anthropic формат (~300 строк)
+ think_parser.py — парсер тегов (~80 строк)
+ heuristic_tool_parser.py — парсер tool calls из текста
+ nvidia_nim/, open_router/, lmstudio/ — конкретные провайдеры
+```
+
+#### Провайдеры и модели
+
+##### NVIDIA NIM (бесплатно, 40 req/min)
+
+**Tier S (флагманы):**
+
+| Model ID | Thinking | Tool Calling |
+|----------|:--------:|:------------:|
+| `moonshotai/kimi-k2.5` | Да | Native |
+| `qwen/qwen3-coder-480b-a35b-instruct` | Да | Native |
+| `z-ai/glm5` | Да | Native |
+| `deepseek-ai/deepseek-v3.2` | Да | Native |
+| `mistralai/mistral-large-3-675b-instruct` | Да | Native |
+| `minimaxai/minimax-m2.5` | Да | Native |
+
+**Tier A:**
+
+| Model ID | Thinking | Tool Calling |
+|----------|:--------:|:------------:|
+| `z-ai/glm4.7` | Да | Native |
+| `mistralai/devstral-2-123b-instruct` | Да | Native |
+| `openai/gpt-oss-120b` | Да | Native |
+| `meta/llama-3.1-405b-instruct` | Нет | Native |
+
+**Tier B (быстрые):**
+
+| Model ID | Thinking | Tool Calling |
+|----------|:--------:|:------------:|
+| `qwen/qwen2.5-coder-32b-instruct` | Нет | Native |
+| `stepfun-ai/step-3.5-flash` | Да | Native |
+| `meta/llama-3.3-70b-instruct` | Нет | Native |
+
+Всего в каталоге NIM **185 моделей**, все бесплатные. max_tokens: 81920.
+
+##### OpenRouter (free модели с суффиксом `:free`)
+
+**С Tool Calling + Thinking:**
+
+| Model ID | Context | Max Output |
+|----------|:-------:|:----------:|
+| `openai/gpt-oss-120b:free` | 131K | 131K |
+| `stepfun/step-3.5-flash:free` | 256K | 256K |
+| `qwen/qwen3-coder:free` | 262K | 262K |
+| `qwen/qwen3-235b-a22b-thinking-2507:free` | 131K | — |
+| `z-ai/glm-4.5-air:free` | 131K | 96K |
+
+**С Tool Calling, без Thinking:**
+
+| Model ID | Context |
+|----------|:-------:|
+| `meta-llama/llama-3.3-70b-instruct:free` | 128K |
+| `mistralai/mistral-small-3.1-24b-instruct:free` | 128K |
+| `google/gemma-3-27b-it:free` | 131K |
+
+Всего **28 бесплатных моделей** + мета-роутер `openrouter/free`.
+
+##### LM Studio (полностью локально, без лимитов)
+
+| Модель | VRAM | Качество кода |
+|--------|:----:|:---:|
+| `unsloth/MiniMax-M2.5-GGUF` | 48GB+ | 7/10 |
+| `unsloth/Qwen3-Coder-480B-A35B-Instruct-GGUF` | 48GB+ | 8/10 |
+| `unsloth/Qwen3.5-35B-A3B-GGUF` | 24GB | 6/10 |
+| `unsloth/GLM-4.7-Flash-GGUF` | 24GB | 6/10 |
+| `unsloth/Qwen2.5-Coder-32B-Instruct-GGUF` | 24GB | 6/10 |
+
+#### Что лучше чем HydraTeams
+
+| Аспект | free-claude-code | HydraTeams |
+|--------|:---:|:---:|
+| Тесты | **85+ файлов**, CI | 0 |
+| Thinking blocks | **Два пути** (native + `` парсер) | Нет |
+| Token counting | **tiktoken** (реальный подсчёт) | `JSON.length / 4` |
+| Provider abstraction | **ABC + наследование** | Hardcoded if/else |
+| Error handling | **Graceful shutdown**, rate limiter | Базовый try/catch |
+| Heuristic tool parser | **Есть** (для моделей без native tool use) | Нет |
+
+#### Проблемы для Agent Teams
+
+1. **Task tool patching** — принудительно ставит `run_in_background=False` в трёх местах. Опасная мина для team coordination
+2. **Optimization interceptors** — 5 эвристик могут ложно сработать на teammate messages
+3. **Общий rate limiter** — singleton `GlobalRateLimiter` (40 req/min, max_concurrency=5). 5 teammates = мгновенный bottleneck. Один 429 блокирует ВСЕХ
+4. **Python 3.14 requirement** — ещё в beta, проблема для bundling
+5. **tiktoken** требует Rust-скомпилированный .so — кросс-платформенная сборка сложная
+6. **Нет lead/teammate разделения** — все запросы на один провайдер
+
+#### Bundling с Electron
+
+| Критерий | free-claude-code (Python) | HydraTeams (TypeScript) |
+|----------|:---:|:---:|
+| Bundling в Electron | PyInstaller ~100-150MB, Python 3.14, Rust deps | Прямо в main process, 0 deps |
+| Размер | ~50MB минимум | ~580 строк, КБ |
+| Кросс-платформа | tiktoken .so для каждой платформы | Нативный Node.js |
+| Запуск | Child process + Python runtime | Просто import |
+
+#### Переиспользуемое ядро (~900 строк Python)
+
+- `message_converter.py` (~200 строк) — полностью независим
+- `sse_builder.py` (~300 строк) — почти независим (убрать Task patching)
+- `think_parser.py` (~80 строк) — полностью независим
+- `openai_compat.py` (~250 строк) — stream_response логика
+
+#### Итоговые оценки
+
+| Критерий | Оценка |
+|----------|:------:|
+| Качество кода | 7/10 |
+| Тесты | 8/10 |
+| Bundling с Electron | 2/10 |
+| Agent Teams совместимость | 3/10 |
+| Адаптация под наш стек | 3/10 |
+
+---
+
+### 3. LiteLLM Proxy
+
+- **URL**: https://github.com/BerriAI/litellm
+- **Лицензия**: MIT (enterprise-фичи проприетарные)
+- **Stars**: ~38,000 | **Forks**: ~6,200
+- **Язык**: Python
+- **Провайдеры**: 100+ (OpenAI, Gemini, Bedrock, Azure, Groq, DeepSeek...)
+
+#### Как работает с Claude Code
+
+```bash
+export ANTHROPIC_BASE_URL="http://0.0.0.0:4000"
+export ANTHROPIC_AUTH_TOKEN="$LITELLM_MASTER_KEY"
+claude --model gpt-4o
+```
+
+Нативный Anthropic SDK-совместимый endpoint `/messages`. Полная трансляция tool_use, streaming SSE.
+
+#### Почему НЕ подходит для Electron
+
+| Проблема | Детали |
+|----------|--------|
+| Python | 70+ зависимостей, Prisma с Node binaries, grpcio |
+| Размер | ~2 ГБ (Docker-образ) |
+| RAM | Рекомендация 8 ГБ для production |
+| Bundling | Никто никогда не бандлил с desktop app |
+
+#### Известные баги с Claude Code
+
+- [#21446](https://github.com/BerriAI/litellm/issues/21446) — Gemini не работает через LiteLLM
+- [#14194](https://github.com/BerriAI/litellm/issues/14194) — Bedrock thinking + tools конфликтуют
+- [#12222](https://github.com/BerriAI/litellm/issues/12222) — Gemini падает на tools с optional args
+- [#18730](https://github.com/BerriAI/litellm/issues/18730) — Concurrent requests обходят rate limits
+
+**Вердикт: enterprise-серверный gateway, не для десктопа. 1/10 для нашего кейса.**
+
+---
+
+### 4. Bifrost (Maxim AI)
+
+- **URL**: https://github.com/maximhq/bifrost
+- **Лицензия**: Apache 2.0
+- **Stars**: ~2,700 | **Commits**: 3,341
+- **Язык**: Go (11 мкс overhead при 5000 RPS)
+- **Провайдеры**: 20+ (OpenAI, Gemini, Bedrock, Azure, Mistral, Ollama...)
+
+#### Bundling с Electron
+
+Bifrost компилируется в единый статический Go-бинарник. npm-пакет `@maximhq/bifrost` скачивает prebuilt binary с CDN под нужную платформу.
+
+```
+https://downloads.getmaxim.ai/bifrost/{version}/{platform}/{arch}/bifrost-http
+```
+
+Поддерживаемые платформы:
+- darwin/arm64 (macOS Apple Silicon)
+- darwin/amd64 (macOS Intel)
+- linux/amd64, linux/386
+- windows/amd64, windows/arm64
+
+Размер: ~30-60 MB. Запуск: `child_process.spawn(bifrostBinaryPath)`.
+
+**Bundling: 9/10** — скачать бинарник, положить рядом, запустить как child process.
+
+#### Проблемы для Agent Teams
+
+| Issue | Описание | Статус |
+|-------|----------|--------|
+| [#1164](https://github.com/maximhq/bifrost/issues/1164) | Parallel tool calls через Bedrock не работают | Открыт |
+| [#1829](https://github.com/maximhq/bifrost/issues/1829) | Streaming tool call deltas мёрджатся | Закрыт |
+| [#1804](https://github.com/maximhq/bifrost/issues/1804) | Streaming tool calls с агентскими клиентами не работают | Открыт |
+| [#828](https://github.com/maximhq/bifrost/issues/828) | Goroutine leak при context cancellation | Открыт |
+| [#1613](https://github.com/maximhq/bifrost/issues/1613) | SSE streaming от Gemini ломается | Открыт |
+
+Не использует anthropic-go-sdk, дублирует типы вручную (Discussion #1259).
+
+**Никто не тестировал Bifrost с Agent Teams.**
+
+#### Итоговые оценки
+
+| Критерий | Оценка |
+|----------|:------:|
+| Bundling с Electron | 9/10 |
+| Agent Teams (passthrough) | 8/10 |
+| Agent Teams (трансляция) | 4/10 |
+| Зрелость | 7/10 |
+
+---
+
+## Сравнение качества моделей vs Claude
+
+| Модель | Кодинг | vs Sonnet | vs Opus |
+|--------|:------:|:---------:|:-------:|
+| Kimi K2.5 (NIM) | 8/10 | ~80% | ~60% |
+| Qwen3 Coder 480B (NIM) | 8/10 | ~80% | ~60% |
+| GLM-5 (NIM) | 7/10 | ~70% | ~50% |
+| GPT-OSS 120B (NIM/OR) | 7/10 | ~70% | ~50% |
+| GLM-4.7 (NIM) | 7/10 | ~65% | ~45% |
+| Step 3.5 Flash (OR) | 6/10 | ~55% | ~35% |
+| Llama 3.3 70B (OR) | 5/10 | ~45% | ~30% |
+
+Ни одна бесплатная модель не дотягивает до Claude по качеству агентного кодинга.
+
+---
+
+## Юридические аспекты
+
+### Что разрешено Anthropic
+
+- `ANTHROPIC_BASE_URL` — **официально поддерживается** для LLM Gateway
+- Использование прокси с собственными API-ключами других провайдеров — легально
+- Документация описывает LLM Gateway конфигурацию: endpoint должен реализовывать Anthropic Messages API
+
+### Что запрещено
+
+- Использование OAuth-токенов от Claude Free/Pro/Max подписок в сторонних продуктах
+- Anthropic активно блокирует несанкционированное использование подписочных токенов
+- Использование Claude для обучения конкурирующих моделей (Section D.4 Commercial Terms)
+
+### ChatGPT backend API (HydraTeams)
+
+- `chatgpt.com/backend-api/codex/responses` — недокументированный API
+- Нарушение ToS ChatGPT при автоматизации через backend API
+- Может быть заблокирован в любой момент
+
+---
+
+## Влияние на наш проект (Claude DevTools)
+
+JSONL формат сессий **не меняется** — Claude Code CLI генерирует одинаковую структуру независимо от backend-модели. TeamCreate, TaskCreate, SendMessage и прочие team tools остаются теми же. Парсинг и chunk building будет работать без изменений.
+
+Единственное потенциальное отличие — metadata о модели в сообщениях (model field).
+
+---
+
+## Рекомендация по реализации
+
+### Лучший путь: форк HydraTeams + hardening
+
+HydraTeams — единственный проект заточенный под Agent Teams, на TypeScript (наш стек), zero deps, встраивается в Electron main process.
+
+**Что нужно починить обязательно:**
+1. Localhost-only binding (`127.0.0.1`)
+2. AbortController + таймауты на upstream fetch
+3. Баги с indices в response-responses.ts
+4. Тесты на трансляцию (unit-тесты на каждый translator)
+5. Убрать hardcoded spoofModel -> сделать конфигурируемым
+
+**Что заимствовать из free-claude-code:**
+1. ThinkTagParser — переписать на TS (~50 строк)
+2. Provider abstraction — интерфейс `TranslationProvider`
+3. Структуру тестов
+4. Heuristic tool parser для моделей без native tool calling
+
+**Что переосмыслить:**
+- Lead/teammate детекция — маркер по строке хрупкий. Мы знаем роли из TeamDataService, можно передавать через env var `HYDRA_ROLE=lead|teammate`
+
+### Оценка трудозатрат
+
+~2-3 дня на hardening + тесты. Итого: TypeScript-пакет ~800-1000 строк с тестами, встраиваемый в Electron main process.
+
+### Альтернативы
+
+| Вариант | Надёжность | Уверенность |
+|---------|:----------:|:-----------:|
+| Форк HydraTeams + hardening | 7/10 | 8/10 |
+| Свой proxy с нуля на TS (вдохновлённый обоими) | 8/10 | 7/10 |
+| Bifrost binary + thin TS translator | 5/10 | 6/10 |
+| LiteLLM как Docker sidecar | 6/10 | 7/10 |
+| free-claude-code (Python child process) | 3/10 | 8/10 |
+
+---
+
+## Другие найденные проекты
+
+| Проект | Описание | Применимость |
+|--------|----------|:---:|
+| [1rgs/claude-code-proxy](https://github.com/1rgs/claude-code-proxy) | На базе LiteLLM, BIG/SMALL model маппинг | 5/10 |
+| [fuergaosi233/claude-code-proxy](https://github.com/fuergaosi233/claude-code-proxy) | Anthropic -> OpenAI конвертер | 4/10 |
+| [nielspeter/claude-code-proxy](https://github.com/nielspeter/claude-code-proxy) | Легковесный бинарник, OpenRouter | 4/10 |
+| [9router](https://github.com/decolua/9router) | Smart router с fallback-каскадом | 5/10 |
+| [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) | MCP-сервер, реимплементация Agent Teams | 3/10 |
+
+---
+
+## Архитектурная схема целевого решения
+
+```
+[Lead Agent Process]
+ ANTHROPIC_BASE_URL=http://127.0.0.1:{port}
+ HYDRA_ROLE=lead
+ |
+ v
+[Proxy (TypeScript, в Electron main process)]
+ if role=lead --> passthrough к api.anthropic.com
+ if role=teammate --> трансляция к целевому провайдеру
+ |
+ v
+[Teammate 1] --> OpenAI API (GPT-4o)
+[Teammate 2] --> NVIDIA NIM (Kimi K2.5)
+[Teammate 3] --> Local (LM Studio)
+```
+
+Stream-json протокол (stdin/stdout между lead и teammates) **не затрагивается** — прокси работает на уровне HTTP API запросов к LLM.
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index cd31e560..533a041d 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -17,6 +17,7 @@ import {
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
+import { buildToolSummary } from '@shared/utils/toolSummary';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@@ -1486,6 +1487,8 @@ export class TeamDataService {
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
+ const toolSummary = buildToolSummary(content as Record[]);
+
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
const textPrefix = combined
.slice(0, 50)
@@ -1500,6 +1503,7 @@ export class TeamDataService {
source: 'lead_session',
leadSessionId: config.leadSessionId,
messageId: `lead-session-${timestamp}-${textPrefix}`,
+ toolSummary,
});
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 77cf5cfc..09ff87c7 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -21,6 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
+import { buildToolSummary } from '@shared/utils/toolSummary';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
@@ -2913,12 +2914,13 @@ export class TeamProvisioningService {
// captureSendMessageToUser() handles it separately.
if (!run.silentUserDmForward && !hasSendMessageToUser) {
const cleanText = stripAgentBlocks(text).trim();
- if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
+ if (cleanText.length > 0) {
run.leadMsgSeq += 1;
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
+ const toolSummary = buildToolSummary(content ?? []);
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
@@ -2927,6 +2929,7 @@ export class TeamProvisioningService {
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
messageId,
source: 'lead_process',
+ toolSummary,
};
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts
index 88ef5b9e..04a61528 100644
--- a/src/main/services/team/TeamSentMessagesStore.ts
+++ b/src/main/services/team/TeamSentMessagesStore.ts
@@ -76,6 +76,7 @@ export class TeamSentMessagesStore {
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
+ toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
});
}
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index f5d26b0f..d5672beb 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -11,6 +11,7 @@ import {
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
+import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import type { InboxMessage } from '@shared/types';
@@ -131,6 +132,22 @@ export const LeadThoughtsGroupRow = ({
// Chronological order for rendering (oldest at top, newest at bottom)
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
+ // Aggregate tool usage across all thoughts in this group
+ const totalToolSummary = useMemo(() => {
+ const merged: Record = {};
+ let total = 0;
+ for (const t of thoughts) {
+ const parsed = parseToolSummary(t.toolSummary);
+ if (!parsed) continue;
+ total += parsed.total;
+ for (const [name, count] of Object.entries(parsed.byName)) {
+ merged[name] = (merged[name] ?? 0) + count;
+ }
+ }
+ if (total === 0) return null;
+ return formatToolSummary({ total, byName: merged });
+ }, [thoughts]);
+
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback(
() =>
@@ -225,6 +242,11 @@ export const LeadThoughtsGroupRow = ({
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
+ {totalToolSummary && (
+
+ {totalToolSummary}
+
+ )}
{/* Scrollable body — fixed height, always visible */}
diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
index cb38faa5..cf4bd2c9 100644
--- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx
+++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
@@ -1,10 +1,82 @@
-import React from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
+import { Check, ChevronDown } from 'lucide-react';
+
+// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
+
+/** Anthropic — official "A" lettermark (Simple Icons) */
+const AnthropicIcon: React.FC<{ className?: string }> = ({ className }) => (
+
+
+
+);
+
+/** OpenAI — official hexagonal knot logo (Simple Icons) */
+const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
+
+
+
+);
+
+/** Google Gemini — official sparkle/star mark (Simple Icons) */
+const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
+
+
+
+);
+
+/** Local — server rack icon */
+const LocalIcon: React.FC<{ className?: string }> = ({ className }) => (
+
+
+
+
+
+
+
+
+);
+
+// --- Provider definitions ---
+
+interface ProviderDef {
+ id: string;
+ label: string;
+ icon: React.FC<{ className?: string }>;
+ comingSoon: boolean;
+}
+
+const PROVIDERS: ProviderDef[] = [
+ { id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
+ { id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true },
+ { id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true },
+ { id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true },
+];
+
+const ACTIVE_PROVIDER = PROVIDERS[0];
+
+// --- Model options (Anthropic only for now) ---
const MODEL_OPTIONS = [
- { value: '', label: 'Default (account setting)' },
+ { value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'haiku', label: 'Haiku 4.5' },
@@ -35,28 +107,132 @@ export const TeamModelSelector: React.FC
= ({
value,
onValueChange,
id,
-}) => (
-
-
- Model (optional)
-
-
- {MODEL_OPTIONS.map((opt) => (
-
onValueChange(opt.value)}
- >
- {opt.label}
-
- ))}
+}) => {
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const containerRef = useRef
(null);
+
+ // Close dropdown on click outside
+ useEffect(() => {
+ if (!dropdownOpen) return;
+
+ const handleClickOutside = (event: MouseEvent): void => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setDropdownOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [dropdownOpen]);
+
+ const ProviderIcon = ACTIVE_PROVIDER.icon;
+
+ return (
+
+
+ Model (optional)
+
+
+
+ {/* Provider button */}
+
setDropdownOpen(!dropdownOpen)}
+ >
+
+ {ACTIVE_PROVIDER.label}
+
+
+
+ {/* Model pills */}
+ {MODEL_OPTIONS.map((opt) => (
+
onValueChange(opt.value)}
+ >
+ {opt.label}
+
+ ))}
+
+
+ {/* Provider dropdown */}
+ {dropdownOpen && (
+
+ {PROVIDERS.map((provider, index) => {
+ const Icon = provider.icon;
+ const isActive = provider.id === ACTIVE_PROVIDER.id;
+ const isFirst = index === 0;
+ const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
+
+ return (
+
+ {prevWasActive && !isFirst && (
+
+ )}
+ {
+ if (!provider.comingSoon) {
+ setDropdownOpen(false);
+ }
+ }}
+ className={cn(
+ 'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
+ isActive && 'bg-indigo-500/10 text-indigo-400',
+ provider.comingSoon && 'cursor-not-allowed opacity-40',
+ !isActive && !provider.comingSoon && 'hover:bg-white/5'
+ )}
+ style={
+ !isActive && !provider.comingSoon
+ ? { color: 'var(--color-text-secondary)' }
+ : undefined
+ }
+ >
+
+ {provider.label}
+ {provider.comingSoon && (
+
+ Coming Soon
+
+ )}
+ {isActive && }
+
+
+ );
+ })}
+
+ )}
+
-
-);
+ );
+};
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 7a330125..b60a159f 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -199,6 +199,8 @@ export interface InboxMessage {
attachments?: AttachmentMeta[];
/** Lead session ID that produced this message (for session boundary detection). */
leadSessionId?: string;
+ /** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */
+ toolSummary?: string;
}
export interface SendMessageRequest {
diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts
new file mode 100644
index 00000000..84bccbb5
--- /dev/null
+++ b/src/shared/utils/toolSummary.ts
@@ -0,0 +1,47 @@
+export interface ToolSummaryData {
+ total: number;
+ byName: Record
;
+}
+
+export function buildToolSummary(content: Record[]): string | undefined {
+ const counts = new Map();
+ for (const block of content) {
+ if (
+ block &&
+ typeof block === 'object' &&
+ block.type === 'tool_use' &&
+ typeof block.name === 'string'
+ ) {
+ counts.set(block.name, (counts.get(block.name) ?? 0) + 1);
+ }
+ }
+ const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
+ if (total === 0) return undefined;
+ const parts = Array.from(counts.entries())
+ .map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
+ .join(', ');
+ return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
+}
+
+export function parseToolSummary(summary: string | undefined): ToolSummaryData | null {
+ if (!summary) return null;
+ const match = summary.match(/^(\d+)\s+tools?\s+\(([^)]+)\)$/);
+ if (!match) return null;
+ const byName: Record = {};
+ for (const part of match[2].split(', ')) {
+ const m = part.match(/^(\d+)\s+(.+)$/);
+ if (m) {
+ byName[m[2]] = parseInt(m[1], 10);
+ } else {
+ byName[part] = 1;
+ }
+ }
+ return { total: parseInt(match[1], 10), byName };
+}
+
+export function formatToolSummary(data: ToolSummaryData): string {
+ const parts = Object.entries(data.byName)
+ .map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
+ .join(', ');
+ return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`;
+}
From 79ea5476747ce0492489147be70b8cd076415774 Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 15:27:46 +0200
Subject: [PATCH 08/14] feat: improve tool summary handling and UI enhancements
across services and components
- Replaced buildToolSummary with formatToolSummaryFromMap in TeamDataService and TeamProvisioningService to streamline tool summary generation.
- Enhanced JSONL file path handling in TeamDataService to accommodate variations in project directory naming.
- Introduced pendingToolCounts in TeamProvisioningService to accumulate tool usage data for better message context.
- Updated SortableTab and DragOverlayTab components for improved styling and responsiveness.
- Adjusted ClaudeLogsSection to refine log display height for better usability.
- Enhanced TeamDetailView to filter out lead-to-user messages temporarily, improving message clarity.
- Added model selection UI in MemberDraftRow with informative tooltips for user guidance.
- Improved MentionableTextarea with rotating tips to enhance user experience during message composition.
---
src/main/services/team/TeamDataService.ts | 58 ++++-
.../services/team/TeamProvisioningService.ts | 28 ++-
.../components/layout/SortableTab.tsx | 27 ++-
src/renderer/components/layout/TabBar.tsx | 2 +-
.../components/team/ClaudeLogsSection.tsx | 2 +-
.../components/team/TeamDetailView.tsx | 22 +-
.../team/activity/LeadThoughtsGroup.tsx | 23 +-
.../team/members/MemberDraftRow.tsx | 31 ++-
.../team/messages/MessageComposer.tsx | 213 +++++++++---------
.../components/ui/MentionableTextarea.tsx | 36 ++-
src/shared/utils/toolSummary.ts | 10 +
11 files changed, 308 insertions(+), 144 deletions(-)
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 533a041d..097a8239 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -17,7 +17,7 @@ import {
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
-import { buildToolSummary } from '@shared/utils/toolSummary';
+import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@@ -1424,13 +1424,29 @@ export class TeamDataService {
const projectId = encodePath(config.projectPath);
const baseDir = extractBaseDir(projectId);
- const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
+ let jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
try {
await fs.promises.access(jsonlPath, fs.constants.F_OK);
} catch {
- logger.debug(`Lead session JSONL not found: ${jsonlPath}`);
- return [];
+ // Claude Code encodes underscores as hyphens in project directory names;
+ // our encodePath only handles slashes. Try the underscore-to-hyphen variant.
+ const altBaseDir = baseDir.replace(/_/g, '-');
+ if (altBaseDir !== baseDir) {
+ const altPath = path.join(
+ getProjectsBasePath(),
+ altBaseDir,
+ `${config.leadSessionId}.jsonl`
+ );
+ try {
+ await fs.promises.access(altPath, fs.constants.F_OK);
+ jsonlPath = altPath;
+ } catch {
+ return [];
+ }
+ } else {
+ return [];
+ }
}
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
@@ -1441,6 +1457,7 @@ export class TeamDataService {
const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB
const textsReversed: InboxMessage[] = [];
+ const seenMessageIds = new Set();
const handle = await fs.promises.open(jsonlPath, 'r');
try {
const stat = await handle.stat();
@@ -1487,7 +1504,32 @@ export class TeamDataService {
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
- const toolSummary = buildToolSummary(content as Record[]);
+ // Count tool_use blocks from following lines (text and tool_use are separate in JSONL).
+ // tool_result (type=user) lines are interleaved between tool_use lines — skip them.
+ const toolCounts = new Map();
+ const lookaheadLimit = Math.min(i + 200, lines.length);
+ for (let j = i + 1; j < lookaheadLimit; j++) {
+ const tLine = lines[j]?.trim();
+ if (!tLine) continue;
+ let tMsg: Record;
+ try {
+ tMsg = JSON.parse(tLine) as Record;
+ } catch {
+ continue;
+ }
+ if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines
+ const tMessage = (tMsg.message ?? tMsg) as Record;
+ const tContent = tMessage.content;
+ if (!Array.isArray(tContent)) continue;
+ const tBlocks = tContent as Record[];
+ if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
+ for (const b of tBlocks) {
+ if (b.type === 'tool_use' && typeof b.name === 'string') {
+ toolCounts.set(b.name, (toolCounts.get(b.name) ?? 0) + 1);
+ }
+ }
+ }
+ const toolSummary = formatToolSummaryFromMap(toolCounts);
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
const textPrefix = combined
@@ -1495,6 +1537,10 @@ export class TeamDataService {
.replace(/[^\p{L}\p{N}]/gu, '')
.slice(0, 20);
+ const messageId = `lead-session-${timestamp}-${textPrefix}`;
+ if (seenMessageIds.has(messageId)) continue;
+ seenMessageIds.add(messageId);
+
textsReversed.push({
from: leadName,
text: combined,
@@ -1502,7 +1548,7 @@ export class TeamDataService {
read: true,
source: 'lead_session',
leadSessionId: config.leadSessionId,
- messageId: `lead-session-${timestamp}-${textPrefix}`,
+ messageId,
toolSummary,
});
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 09ff87c7..ec737a22 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -21,7 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
-import { buildToolSummary } from '@shared/utils/toolSummary';
+import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
@@ -61,7 +61,7 @@ const STDOUT_RING_LIMIT = 64 * 1024;
const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000;
-const PROBE_CACHE_TTL_MS = 10 * 60_000;
+const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
const PREFLIGHT_TIMEOUT_MS = 60000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
@@ -164,6 +164,8 @@ interface ProvisioningRun {
} | null;
/** Monotonic counter for individual lead assistant messages. */
leadMsgSeq: number;
+ /** Accumulated tool_use counts between text messages (tool name → count). */
+ pendingToolCounts: Map;
/** Throttle timestamp for emitting inbox refresh events for lead text. */
lastLeadTextEmitMs: number;
/**
@@ -1745,6 +1747,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config',
leadRelayCapture: null,
leadMsgSeq: 0,
+ pendingToolCounts: new Map(),
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@@ -2044,6 +2047,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members',
leadRelayCapture: null,
leadMsgSeq: 0,
+ pendingToolCounts: new Map(),
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@@ -2920,7 +2924,12 @@ export class TeamProvisioningService {
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
- const toolSummary = buildToolSummary(content ?? []);
+ // Attach accumulated tool counts from preceding tool_use messages, then reset.
+ const toolSummary =
+ run.pendingToolCounts.size > 0
+ ? formatToolSummaryFromMap(run.pendingToolCounts)
+ : undefined;
+ run.pendingToolCounts.clear();
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
@@ -2950,6 +2959,19 @@ export class TeamProvisioningService {
}
}
+ // Accumulate tool_use counts from tool-only messages (text + tool_use are separate in stream-json).
+ // These counts will be attached to the next text message as toolSummary.
+ if (run.provisioningComplete) {
+ for (const block of content ?? []) {
+ if (block?.type === 'tool_use' && typeof block.name === 'string') {
+ run.pendingToolCounts.set(
+ block.name as string,
+ (run.pendingToolCounts.get(block.name as string) ?? 0) + 1
+ );
+ }
+ }
+ }
+
// Capture SendMessage(to: "user") tool_use blocks from assistant output.
// Claude Code's internal teamContext may route to "default" instead of the real team
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx
index 7ee062cf..2f2e3f64 100644
--- a/src/renderer/components/layout/SortableTab.tsx
+++ b/src/renderer/components/layout/SortableTab.tsx
@@ -96,8 +96,15 @@ export const SortableTab = ({
? teamColorSet
? teamColorSet.badge
: 'var(--color-surface-overlay)'
- : 'transparent',
- color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
+ : teamColorSet
+ ? teamColorSet.badge
+ : 'transparent',
+ color:
+ isActive || isHovered
+ ? 'var(--color-text)'
+ : teamColorSet
+ ? teamColorSet.text
+ : 'var(--color-text-muted)',
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
outlineOffset: '-1px',
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
@@ -125,7 +132,7 @@ export const SortableTab = ({
role="tab"
tabIndex={0}
aria-selected={isActive}
- className="group flex min-w-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
+ className="group flex shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
style={style}
onClick={(e) => onTabClick(tab.id, e)}
onMouseDown={(e) => onMouseDown(tab.id, e)}
@@ -150,7 +157,11 @@ export const SortableTab = ({
)}
- {tab.label}
+ 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`}
+ >
+ {tab.label}
+
{isTeamTab && (
{
return (
{
}}
>
- {tab.label}
+ 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`}
+ >
+ {tab.label}
+
);
};
diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx
index 74b5f215..5b59053c 100644
--- a/src/renderer/components/layout/TabBar.tsx
+++ b/src/renderer/components/layout/TabBar.tsx
@@ -352,7 +352,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
Gives users a reliable window-drag target regardless of how many tabs are open.
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
{
logContainerRef.current = el;
}}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index b64f145a..94f1adde 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -574,9 +574,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return result;
}, [data, timeWindow, kanbanFilter.selectedOwners]);
+ const activeMembers = useMemo(
+ () => (data?.members ?? []).filter((m) => !m.removedAt),
+ [data?.members]
+ );
+
+ const leadMemberName = useMemo(
+ () => activeMembers.find((m) => m.agentType === 'team-lead')?.name,
+ [activeMembers]
+ );
+
const filteredMessages = useMemo(() => {
if (!data) return [];
let list = data.messages;
+ // Temporarily hide lead→user messages from the UI
+ // (notifications and other processing still receive them via data.messages)
+ if (leadMemberName) {
+ list = list.filter((m) => !(m.to?.trim() === 'user' && m.from?.trim() === leadMemberName));
+ }
if (timeWindow) {
list = list.filter((m) => {
const ts = new Date(m.timestamp).getTime();
@@ -603,7 +618,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
});
}
return list;
- }, [data, timeWindow, messagesFilter, messagesSearchQuery]);
+ }, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]);
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
const messagesUnreadCount = useMemo(
@@ -627,11 +642,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return filterKanbanTasks(filteredTasks, query);
}, [filteredTasks, kanbanSearch]);
- const activeMembers = useMemo(
- () => (data?.members ?? []).filter((m) => !m.removedAt),
- [data?.members]
- );
-
const activeTeammateCount = useMemo(
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
[activeMembers]
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index d5672beb..a32e105d 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -190,16 +190,17 @@ export const LeadThoughtsGroupRow = ({
return () => observer.disconnect();
}, [onVisible, thoughts]);
- // Stable ref for auto-scroll trigger: track content changes (new thoughts + text growth)
- const newestTextLength = newest.text.length;
-
- // Auto-scroll to bottom when new thoughts arrive or text grows
+ // Auto-scroll via ResizeObserver — fires after CSS animations expand content
useEffect(() => {
- if (isUserScrolledUpRef.current) return;
const el = scrollRef.current;
if (!el) return;
- el.scrollTop = el.scrollHeight;
- }, [thoughts.length, newestTextLength]);
+ const observer = new ResizeObserver(() => {
+ if (isUserScrolledUpRef.current) return;
+ el.scrollTop = el.scrollHeight;
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps -- scrollRef is stable
const handleScroll = useCallback(() => {
const el = scrollRef.current;
@@ -297,6 +298,14 @@ export const LeadThoughtsGroupRow = ({
/>
+ {thought.toolSummary && (
+
+ 🔧 {thought.toolSummary}
+
+ )}
))}
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx
index 470c5562..f18f485a 100644
--- a/src/renderer/components/team/members/MemberDraftRow.tsx
+++ b/src/renderer/components/team/members/MemberDraftRow.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
+import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
@@ -9,7 +10,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { getMemberColor } from '@shared/constants/memberColors';
-import { ChevronDown, ChevronRight } from 'lucide-react';
+import { ChevronDown, ChevronRight, Info } from 'lucide-react';
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
@@ -48,6 +49,7 @@ export const MemberDraftRow = ({
}: MemberDraftRowProps): React.JSX.Element => {
const memberColorSet = getTeamColorSet(getMemberColor(index));
const [workflowExpanded, setWorkflowExpanded] = useState(false);
+ const [modelExpanded, setModelExpanded] = useState(false);
// Pre-warm file list cache when workflow section is expanded
useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null);
@@ -162,6 +164,19 @@ export const MemberDraftRow = ({
Workflow
) : null}
+ setModelExpanded((prev) => !prev)}
+ >
+ {modelExpanded ? (
+
+ ) : (
+
+ )}
+ Model
+
) : null}
+ {modelExpanded && (
+
+
+ {}} />
+
+
+
+
+ Claude Code doesn't support per-member model selection yet — all teammates
+ inherit the team launch model. We plan to solve this via a local proxy.
+
+
+
+ )}
);
};
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index 94259ef8..ec12000b 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -156,7 +156,7 @@ export const MessageComposer = ({
// const leadContext = useStore((s) =>
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// );
- const supportsAttachments = isLeadRecipient && !!isTeamAlive;
+ const supportsAttachments = isLeadRecipient;
const canAttach = supportsAttachments && canAddMore;
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
const canSend =
@@ -263,104 +263,6 @@ export const MessageComposer = ({
-
-
-
- {recipient ? (
-
- ) : (
- Select...
- )}
-
-
-
- {
- e.preventDefault();
- setRecipientSearch('');
- setTimeout(() => recipientSearchRef.current?.focus(), 0);
- }}
- >
- {members.length > 5 && (
-
-
- setRecipientSearch(e.target.value)}
- />
-
- )}
-
- {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
- {(() => {
- const query = recipientSearch.toLowerCase().trim();
- const filtered = query
- ? members.filter((m) => m.name.toLowerCase().includes(query))
- : members;
- if (filtered.length === 0) {
- return (
-
- No results
-
- );
- }
- return filtered.map((m) => {
- const resolvedColor = colorMap.get(m.name);
- const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
- const isSelected = m.name === recipient;
- return (
-
{
- setRecipient(m.name);
- setRecipientOpen(false);
- setRecipientSearch('');
- }}
- >
-
- {role ? (
-
- {role}
-
- ) : null}
- {isSelected ? (
-
- ) : null}
-
- );
- });
- })()}
-
-
-
-
{isLeadRecipient ? (
<>
) : null}
- {!isTeamAlive ? (
-
- Team offline
-
- ) : null}
+
+ {!isTeamAlive ? (
+
+ Team offline
+
+ ) : null}
+
+
+
+
+ {recipient ? (
+
+ ) : (
+ Select...
+ )}
+
+
+
+ {
+ e.preventDefault();
+ setRecipientSearch('');
+ setTimeout(() => recipientSearchRef.current?.focus(), 0);
+ }}
+ >
+ {members.length > 5 && (
+
+
+ setRecipientSearch(e.target.value)}
+ />
+
+ )}
+
+ {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
+ {(() => {
+ const query = recipientSearch.toLowerCase().trim();
+ const filtered = query
+ ? members.filter((m) => m.name.toLowerCase().includes(query))
+ : members;
+ if (filtered.length === 0) {
+ return (
+
+ No results
+
+ );
+ }
+ return filtered.map((m) => {
+ const resolvedColor = colorMap.get(m.name);
+ const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
+ const isSelected = m.name === recipient;
+ return (
+
{
+ setRecipient(m.name);
+ setRecipientOpen(false);
+ setRecipientSearch('');
+ }}
+ >
+
+ {role ? (
+
+ {role}
+
+ ) : null}
+ {isSelected ? (
+
+ ) : null}
+
+ );
+ });
+ })()}
+
+
+
+
-
- Mention "create a task" to add it to the board
-
{sendError ? (
diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx
index c51e7d7d..c20fc142 100644
--- a/src/renderer/components/ui/MentionableTextarea.tsx
+++ b/src/renderer/components/ui/MentionableTextarea.tsx
@@ -595,11 +595,30 @@ export const MentionableTextarea = React.forwardRef [
+ 'Tip: Use @ to mention team members or search files',
+ 'Tip: Mention "create a task" to add it to the board',
+ "Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
+ ],
+ []
+ );
+ const [tipIndex, setTipIndex] = React.useState(0);
+ const [tipVisible, setTipVisible] = React.useState(true);
+
+ React.useEffect(() => {
+ const interval = setInterval(() => {
+ setTipVisible(false);
+ setTimeout(() => {
+ setTipIndex((prev) => (prev + 1) % rotatingTips.length);
+ setTipVisible(true);
+ }, 300);
+ }, 10000);
+ return () => clearInterval(interval);
+ }, [rotatingTips.length]);
+
+ const resolvedHintText = hintText ?? rotatingTips[tipIndex];
const showHintRow = showHint && (suggestions.length > 0 || enableFiles);
const showFooter = showHintRow || footerRight;
@@ -683,7 +702,12 @@ export const MentionableTextarea = React.forwardRef
{showHintRow ? (
- {resolvedHintText}
+
+ {resolvedHintText}
+
) : (
)}
diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts
index 84bccbb5..ef50bfbc 100644
--- a/src/shared/utils/toolSummary.ts
+++ b/src/shared/utils/toolSummary.ts
@@ -45,3 +45,13 @@ export function formatToolSummary(data: ToolSummaryData): string {
.join(', ');
return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`;
}
+
+/** Format tool summary directly from a Map. */
+export function formatToolSummaryFromMap(counts: Map): string | undefined {
+ const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
+ if (total === 0) return undefined;
+ const parts = Array.from(counts.entries())
+ .map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
+ .join(', ');
+ return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
+}
From 161c675aaa7cad0efc289952dd530d850eaceb90 Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 15:54:55 +0200
Subject: [PATCH 09/14] feat: update pnpm lockfile and workspace configuration,
enhance tool usage tracking and UI components
- Added new dependencies for mcp-server in pnpm-lock.yaml, including fastmcp and zod.
- Updated pnpm-workspace.yaml to include mcp-server in the workspace packages.
- Modified TeamDataService and TeamProvisioningService to exclude 'SendMessage' from tool usage counts, improving accuracy in tool tracking.
- Enhanced ChatHistory component to display context injection percentages, improving user feedback on context usage.
- Updated TeamDetailView to ensure session details are fetched periodically for active tabs, enhancing data freshness.
- Improved ActivityTimeline styling for better visual separation of sessions and messages.
---
mcp-server/package.json | 31 +
mcp-server/src/index.ts | 20 +
mcp-server/src/output-parser.ts | 52 +
mcp-server/src/schemas.ts | 115 ++
mcp-server/src/teamctl-runner.ts | 155 +++
mcp-server/src/tools/index.ts | 42 +
mcp-server/src/tools/kanban-move.ts | 45 +
mcp-server/src/tools/kanban-reviewers.ts | 46 +
mcp-server/src/tools/message-send.ts | 39 +
mcp-server/src/tools/review-action.ts | 50 +
mcp-server/src/tools/task-attach.ts | 42 +
mcp-server/src/tools/task-briefing.ts | 32 +
mcp-server/src/tools/task-comment.ts | 34 +
mcp-server/src/tools/task-create.ts | 52 +
mcp-server/src/tools/task-get.ts | 30 +
mcp-server/src/tools/task-link.ts | 51 +
mcp-server/src/tools/task-list.ts | 31 +
mcp-server/src/tools/task-set-owner.ts | 38 +
mcp-server/src/tools/task-set-status.ts | 35 +
mcp-server/test/output-parser.test.ts | 76 ++
mcp-server/test/schemas.test.ts | 220 ++++
mcp-server/test/teamctl-runner.test.ts | 78 ++
mcp-server/test/tools/kanban-move.test.ts | 42 +
.../test/tools/kanban-reviewers.test.ts | 61 +
mcp-server/test/tools/message-send.test.ts | 48 +
mcp-server/test/tools/register-all.test.ts | 28 +
mcp-server/test/tools/review-action.test.ts | 66 +
mcp-server/test/tools/task-attach.test.ts | 49 +
mcp-server/test/tools/task-briefing.test.ts | 33 +
mcp-server/test/tools/task-comment.test.ts | 33 +
mcp-server/test/tools/task-create.test.ts | 65 +
mcp-server/test/tools/task-get.test.ts | 29 +
mcp-server/test/tools/task-link.test.ts | 44 +
mcp-server/test/tools/task-list.test.ts | 29 +
mcp-server/test/tools/task-set-owner.test.ts | 42 +
mcp-server/test/tools/task-set-status.test.ts | 31 +
mcp-server/test/tools/test-helpers.ts | 58 +
mcp-server/tsconfig.json | 21 +
mcp-server/tsup.config.ts | 14 +
mcp-server/vitest.config.ts | 10 +
pnpm-lock.yaml | 1108 +++++++++++++++++
pnpm-workspace.yaml | 2 +
src/main/services/team/TeamDataService.ts | 2 +-
.../services/team/TeamProvisioningService.ts | 6 +-
src/renderer/components/chat/ChatHistory.tsx | 8 +-
.../components/team/TeamDetailView.tsx | 11 +-
.../team/activity/ActivityTimeline.tsx | 14 +-
.../team/activity/LeadThoughtsGroup.tsx | 54 +-
48 files changed, 3198 insertions(+), 24 deletions(-)
create mode 100644 mcp-server/package.json
create mode 100644 mcp-server/src/index.ts
create mode 100644 mcp-server/src/output-parser.ts
create mode 100644 mcp-server/src/schemas.ts
create mode 100644 mcp-server/src/teamctl-runner.ts
create mode 100644 mcp-server/src/tools/index.ts
create mode 100644 mcp-server/src/tools/kanban-move.ts
create mode 100644 mcp-server/src/tools/kanban-reviewers.ts
create mode 100644 mcp-server/src/tools/message-send.ts
create mode 100644 mcp-server/src/tools/review-action.ts
create mode 100644 mcp-server/src/tools/task-attach.ts
create mode 100644 mcp-server/src/tools/task-briefing.ts
create mode 100644 mcp-server/src/tools/task-comment.ts
create mode 100644 mcp-server/src/tools/task-create.ts
create mode 100644 mcp-server/src/tools/task-get.ts
create mode 100644 mcp-server/src/tools/task-link.ts
create mode 100644 mcp-server/src/tools/task-list.ts
create mode 100644 mcp-server/src/tools/task-set-owner.ts
create mode 100644 mcp-server/src/tools/task-set-status.ts
create mode 100644 mcp-server/test/output-parser.test.ts
create mode 100644 mcp-server/test/schemas.test.ts
create mode 100644 mcp-server/test/teamctl-runner.test.ts
create mode 100644 mcp-server/test/tools/kanban-move.test.ts
create mode 100644 mcp-server/test/tools/kanban-reviewers.test.ts
create mode 100644 mcp-server/test/tools/message-send.test.ts
create mode 100644 mcp-server/test/tools/register-all.test.ts
create mode 100644 mcp-server/test/tools/review-action.test.ts
create mode 100644 mcp-server/test/tools/task-attach.test.ts
create mode 100644 mcp-server/test/tools/task-briefing.test.ts
create mode 100644 mcp-server/test/tools/task-comment.test.ts
create mode 100644 mcp-server/test/tools/task-create.test.ts
create mode 100644 mcp-server/test/tools/task-get.test.ts
create mode 100644 mcp-server/test/tools/task-link.test.ts
create mode 100644 mcp-server/test/tools/task-list.test.ts
create mode 100644 mcp-server/test/tools/task-set-owner.test.ts
create mode 100644 mcp-server/test/tools/task-set-status.test.ts
create mode 100644 mcp-server/test/tools/test-helpers.ts
create mode 100644 mcp-server/tsconfig.json
create mode 100644 mcp-server/tsup.config.ts
create mode 100644 mcp-server/vitest.config.ts
diff --git a/mcp-server/package.json b/mcp-server/package.json
new file mode 100644
index 00000000..ac6f5a7d
--- /dev/null
+++ b/mcp-server/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@claude-team/mcp-server",
+ "version": "1.0.0",
+ "description": "MCP server for managing Claude Agent Teams kanban board and tasks",
+ "type": "module",
+ "main": "dist/index.js",
+ "bin": {
+ "team-mcp-server": "dist/index.js"
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsx src/index.ts",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "fastmcp": "^3.34.0",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "tsup": "^8.5.1",
+ "tsx": "^4.21.0",
+ "typescript": "^5.8.2",
+ "vitest": "^3.1.4",
+ "@types/node": "^22.15.18"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts
new file mode 100644
index 00000000..a30a5b06
--- /dev/null
+++ b/mcp-server/src/index.ts
@@ -0,0 +1,20 @@
+import { FastMCP } from 'fastmcp';
+import { TeamctlRunner } from './teamctl-runner.js';
+import { registerAllTools } from './tools/index.js';
+
+const server = new FastMCP({
+ name: 'claude-team-tools',
+ version: '1.0.0',
+ instructions: `MCP server for managing Claude Agent Teams kanban board and tasks.
+
+Provides 13 tools for task CRUD, kanban board management, code reviews, and team messaging.
+All operations are backed by teamctl.js — the battle-tested CLI tool from Claude Agent Teams UI.
+
+Data is stored as JSON files in ~/.claude/tasks/{teamName}/ and ~/.claude/teams/{teamName}/.`,
+});
+
+const runner = new TeamctlRunner();
+
+registerAllTools(server, runner);
+
+server.start({ transportType: 'stdio' });
diff --git a/mcp-server/src/output-parser.ts b/mcp-server/src/output-parser.ts
new file mode 100644
index 00000000..e2b7c1a5
--- /dev/null
+++ b/mcp-server/src/output-parser.ts
@@ -0,0 +1,52 @@
+/**
+ * Parses teamctl stdout into structured results.
+ *
+ * teamctl outputs in three formats:
+ * 1. JSON — task create/get/list, attach, message send, kanban reviewers list
+ * 2. "OK ..." text — status changes, comments, links, kanban moves, reviews
+ * 3. Plain text — task briefing (multi-line human-readable report)
+ */
+
+/** Parse JSON from teamctl stdout (task create/get/list, attach, message send) */
+export function parseJsonOutput(stdout: string): T {
+ const trimmed = stdout.trim();
+ if (!trimmed) {
+ throw new Error('Empty output from teamctl (expected JSON)');
+ }
+ try {
+ return JSON.parse(trimmed) as T;
+ } catch {
+ throw new Error(
+ `Failed to parse teamctl JSON output: ${trimmed.slice(0, 200)}`,
+ );
+ }
+}
+
+/** Parse "OK ..." acknowledgment lines from teamctl */
+export function parseOkOutput(stdout: string): string {
+ const trimmed = stdout.trim();
+ if (trimmed.startsWith('OK ')) {
+ return trimmed.slice(3); // Strip "OK " prefix, keep the rest
+ }
+ // Some commands output just "OK\n"
+ if (trimmed === 'OK') {
+ return 'OK';
+ }
+ // Return as-is if format is unexpected — don't throw
+ return trimmed;
+}
+
+/** Return plain text as-is (briefing, help output) */
+export function parseTextOutput(stdout: string): string {
+ return stdout.trim();
+}
+
+/**
+ * Format teamctl stderr into a user-friendly error message.
+ * teamctl writes errors to stderr via `die(message)` and exits with code 1.
+ */
+export function formatError(stderr: string, stdout: string): string {
+ const msg = stderr.trim() || stdout.trim();
+ if (!msg) return 'Unknown teamctl error';
+ return msg;
+}
diff --git a/mcp-server/src/schemas.ts b/mcp-server/src/schemas.ts
new file mode 100644
index 00000000..cc808e0b
--- /dev/null
+++ b/mcp-server/src/schemas.ts
@@ -0,0 +1,115 @@
+import { z } from 'zod';
+import { sep, isAbsolute } from 'node:path';
+
+// ---------------------------------------------------------------------------
+// Identifiers
+// ---------------------------------------------------------------------------
+
+/**
+ * Matches teamctl's `isSafePathSegment()`:
+ * rejects empty, '.', '..', and strings containing '/', '\\', '\0', or '..'
+ */
+const safePathSegment = (label: string) =>
+ z
+ .string()
+ .min(1)
+ .max(128)
+ .refine(
+ (v) =>
+ v.trim().length > 0 &&
+ v !== '.' &&
+ v !== '..' &&
+ !v.includes('/') &&
+ !v.includes('\\') &&
+ !v.includes('\0') &&
+ !v.includes('..'),
+ { message: `Invalid ${label}: must be a safe path segment` },
+ );
+
+/** Team name — folder inside `~/.claude/teams/` */
+export const teamNameSchema = safePathSegment('team name').describe(
+ 'Team name (folder in ~/.claude/teams/)',
+);
+
+/** Numeric task ID produced by teamctl's highwatermark counter */
+export const taskIdSchema = z
+ .string()
+ .regex(/^\d{1,10}$/, 'Task ID must be a positive integer (e.g. "1", "42")')
+ .describe('Numeric task ID');
+
+/** Team member name — folder inside inboxes, safe path segment */
+export const memberNameSchema = safePathSegment('member name').describe(
+ 'Team member name',
+);
+
+// ---------------------------------------------------------------------------
+// Enums — match teamctl's normalizeStatus / normalizeColumn / etc.
+// ---------------------------------------------------------------------------
+
+export const taskStatusSchema = z.enum([
+ 'pending',
+ 'in_progress',
+ 'completed',
+ 'deleted',
+]);
+
+export const kanbanColumnSchema = z
+ .enum(['review', 'approved'])
+ .describe('Kanban column to move task to');
+
+export const clarificationSchema = z
+ .enum(['lead', 'user', 'clear'])
+ .describe('Who needs to clarify: lead, user, or clear the flag');
+
+export const linkTypeSchema = z
+ .enum(['blocked-by', 'blocks', 'related'])
+ .describe('Relationship type between tasks');
+
+export const linkOperationSchema = z.enum(['link', 'unlink']);
+
+export const reviewDecisionSchema = z.enum(['approve', 'request-changes']);
+
+export const reviewerOperationSchema = z.enum(['list', 'add', 'remove']);
+
+// ---------------------------------------------------------------------------
+// Composite schemas
+// ---------------------------------------------------------------------------
+
+/** Comma-separated task IDs sent as a single CLI argument */
+export const taskIdsArraySchema = z
+ .array(taskIdSchema)
+ .describe('Array of task IDs (e.g. ["1", "3"])');
+
+// ---------------------------------------------------------------------------
+// File / attachment schemas — defence-in-depth for CLI arguments
+// ---------------------------------------------------------------------------
+
+/** Absolute file path without traversal sequences */
+export const filePathSchema = z
+ .string()
+ .min(1)
+ .refine((p) => isAbsolute(p), { message: 'Path must be absolute' })
+ .refine((p) => !p.split(sep).includes('..'), {
+ message: 'Path must not contain traversal sequences (..)',
+ })
+ .refine((p) => !p.includes('\0'), {
+ message: 'Path must not contain null bytes',
+ });
+
+/** Safe filename — no path separators, no null bytes, reasonable length */
+export const safeFilenameSchema = z
+ .string()
+ .min(1)
+ .max(255)
+ .refine(
+ (f) => !f.includes('/') && !f.includes('\\') && !f.includes('\0'),
+ { message: 'Filename must not contain path separators or null bytes' },
+ );
+
+/** MIME type — standard type/subtype format */
+export const mimeTypeSchema = z
+ .string()
+ .regex(
+ /^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/,
+ 'Invalid MIME type format (expected type/subtype)',
+ );
diff --git a/mcp-server/src/teamctl-runner.ts b/mcp-server/src/teamctl-runner.ts
new file mode 100644
index 00000000..d9892106
--- /dev/null
+++ b/mcp-server/src/teamctl-runner.ts
@@ -0,0 +1,155 @@
+import { spawn } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface TeamctlResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+}
+
+export interface ITeamctlRunner {
+ execute(args: string[]): Promise;
+}
+
+export interface TeamctlRunnerOptions {
+ /** Explicit path to teamctl.js. Falls back to TEAMCTL_PATH env, then default. */
+ teamctlPath?: string;
+ /** Max concurrent subprocess calls (default: 5) */
+ maxConcurrent?: number;
+ /** Subprocess timeout in ms (default: 10 000) */
+ timeoutMs?: number;
+}
+
+// ---------------------------------------------------------------------------
+// Semaphore — limits concurrent subprocess spawns
+// ---------------------------------------------------------------------------
+
+class Semaphore {
+ private current = 0;
+ private queue: Array<() => void> = [];
+
+ constructor(private readonly max: number) {}
+
+ async acquire(): Promise {
+ if (this.current < this.max) {
+ this.current++;
+ return;
+ }
+ return new Promise((resolve) => {
+ this.queue.push(() => {
+ this.current++;
+ resolve();
+ });
+ });
+ }
+
+ release(): void {
+ this.current--;
+ const next = this.queue.shift();
+ if (next) next();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// TeamctlRunner
+// ---------------------------------------------------------------------------
+
+export class TeamctlRunner implements ITeamctlRunner {
+ readonly teamctlPath: string;
+ private readonly timeoutMs: number;
+ private readonly semaphore: Semaphore;
+
+ constructor(options?: TeamctlRunnerOptions) {
+ this.teamctlPath = resolveTeamctlPath(options?.teamctlPath);
+ this.timeoutMs = options?.timeoutMs ?? 10_000;
+ this.semaphore = new Semaphore(options?.maxConcurrent ?? 5);
+
+ // Fail fast if teamctl.js doesn't exist
+ if (!existsSync(this.teamctlPath)) {
+ throw new Error(
+ `teamctl.js not found at ${this.teamctlPath}. ` +
+ 'Make sure Claude Agent Teams UI has been run at least once, ' +
+ 'or set the TEAMCTL_PATH environment variable.',
+ );
+ }
+ }
+
+ async execute(args: string[]): Promise {
+ await this.semaphore.acquire();
+ try {
+ return await this.spawn(args);
+ } finally {
+ this.semaphore.release();
+ }
+ }
+
+ private spawn(args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn('node', [this.teamctlPath, ...args], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout: this.timeoutMs,
+ env: { ...process.env },
+ });
+
+ const stdoutChunks: Buffer[] = [];
+ const stderrChunks: Buffer[] = [];
+
+ child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
+ child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
+
+ let settled = false;
+
+ child.on('error', (err) => {
+ if (!settled) {
+ settled = true;
+ reject(new Error(`Failed to spawn teamctl: ${err.message}`));
+ }
+ });
+
+ child.on('close', (code, signal) => {
+ if (settled) return;
+ settled = true;
+
+ const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
+
+ if (signal === 'SIGTERM') {
+ const partial = stdout.slice(0, 500) || stderr.slice(0, 500);
+ reject(
+ new Error(
+ `teamctl timed out after ${this.timeoutMs}ms` +
+ (partial ? `. Partial output: ${partial}` : ''),
+ ),
+ );
+ return;
+ }
+
+ resolve({
+ stdout,
+ stderr,
+ exitCode: code ?? 1,
+ });
+ });
+ });
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Path resolution
+// ---------------------------------------------------------------------------
+
+function resolveTeamctlPath(explicit?: string): string {
+ if (explicit) return explicit;
+
+ const fromEnv = process.env['TEAMCTL_PATH'];
+ if (fromEnv) return fromEnv;
+
+ // Default: ~/.claude/tools/teamctl.js
+ return join(homedir(), '.claude', 'tools', 'teamctl.js');
+}
diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts
new file mode 100644
index 00000000..6a4da6f9
--- /dev/null
+++ b/mcp-server/src/tools/index.ts
@@ -0,0 +1,42 @@
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+
+import { register as taskCreate } from './task-create.js';
+import { register as taskSetStatus } from './task-set-status.js';
+import { register as taskSetOwner } from './task-set-owner.js';
+import { register as taskGet } from './task-get.js';
+import { register as taskList } from './task-list.js';
+import { register as taskComment } from './task-comment.js';
+import { register as taskLink } from './task-link.js';
+import { register as taskBriefing } from './task-briefing.js';
+import { register as taskAttach } from './task-attach.js';
+import { register as kanbanMove } from './kanban-move.js';
+import { register as kanbanReviewers } from './kanban-reviewers.js';
+import { register as reviewAction } from './review-action.js';
+import { register as messageSend } from './message-send.js';
+
+const ALL_TOOLS = [
+ taskCreate,
+ taskSetStatus,
+ taskSetOwner,
+ taskGet,
+ taskList,
+ taskComment,
+ taskLink,
+ taskBriefing,
+ taskAttach,
+ kanbanMove,
+ kanbanReviewers,
+ reviewAction,
+ messageSend,
+] as const;
+
+/**
+ * Register all 13 MCP tools with the server.
+ * Each tool wraps a teamctl CLI command via the runner.
+ */
+export function registerAllTools(server: FastMCP, runner: ITeamctlRunner): void {
+ for (const register of ALL_TOOLS) {
+ register(server, runner);
+ }
+}
diff --git a/mcp-server/src/tools/kanban-move.ts b/mcp-server/src/tools/kanban-move.ts
new file mode 100644
index 00000000..565a836f
--- /dev/null
+++ b/mcp-server/src/tools/kanban-move.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseOkOutput } from '../output-parser.js';
+import { teamNameSchema, taskIdSchema, kanbanColumnSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'kanban_move',
+ description: `Move a task to a kanban column or clear it from the kanban board.
+
+Columns: "review" (awaiting code review) or "approved" (review passed).
+Use operation "clear" to remove a task from the kanban board (returns to status-based display).
+Moving to a kanban column also sets the task status to "completed".`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ operation: z.enum(['set-column', 'clear']).describe('"set-column" to move, "clear" to remove from kanban'),
+ column: kanbanColumnSchema.optional().describe('Target column (required for set-column)'),
+ }),
+ execute: async (args) => {
+ let cliArgs: string[];
+
+ if (args.operation === 'set-column') {
+ if (!args.column) {
+ throw new UserError('column is required when operation is "set-column"');
+ }
+ cliArgs = ['--team', args.team, 'kanban', 'set-column', args.task_id, args.column];
+ } else {
+ cliArgs = ['--team', args.team, 'kanban', 'clear', args.task_id];
+ }
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to update kanban: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseOkOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/kanban-reviewers.ts b/mcp-server/src/tools/kanban-reviewers.ts
new file mode 100644
index 00000000..03569247
--- /dev/null
+++ b/mcp-server/src/tools/kanban-reviewers.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseJsonOutput, parseOkOutput } from '../output-parser.js';
+import { teamNameSchema, memberNameSchema, reviewerOperationSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'kanban_reviewers',
+ description: `Manage the kanban board's reviewer list.
+
+Operations:
+- "list": returns JSON array of reviewer names
+- "add": adds a reviewer (name required)
+- "remove": removes a reviewer (name required)`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ operation: reviewerOperationSchema.describe('"list", "add", or "remove"'),
+ name: memberNameSchema.optional().describe('Reviewer name (required for add/remove)'),
+ }),
+ execute: async (args) => {
+ if (args.operation !== 'list' && !args.name) {
+ throw new UserError(`name is required for "${args.operation}" operation`);
+ }
+
+ const cliArgs = ['--team', args.team, 'kanban', 'reviewers', args.operation];
+ if (args.name) cliArgs.push(args.name);
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to manage reviewers: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+
+ // "list" returns JSON array, "add"/"remove" return "OK ..." text
+ if (args.operation === 'list') {
+ return parseJsonOutput(result.stdout);
+ }
+ return parseOkOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/message-send.ts b/mcp-server/src/tools/message-send.ts
new file mode 100644
index 00000000..0693bd33
--- /dev/null
+++ b/mcp-server/src/tools/message-send.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseJsonOutput } from '../output-parser.js';
+import { teamNameSchema, memberNameSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'message_send',
+ description: `Send an inbox message to a team member. Returns delivery confirmation JSON.
+
+Messages appear in the member's inbox and can trigger notifications.
+The "source" field is automatically stripped for security — external callers cannot impersonate system notifications.`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ to: memberNameSchema.describe('Recipient member name'),
+ text: z.string().min(1).max(10000).describe('Message text'),
+ summary: z.string().max(200).optional().describe('Short summary for notification preview'),
+ from: memberNameSchema.optional().describe('Sender name'),
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'message', 'send', '--to', args.to, '--text', args.text];
+
+ if (args.summary) cliArgs.push('--summary', args.summary);
+ if (args.from) cliArgs.push('--from', args.from);
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to send message: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseJsonOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/review-action.ts b/mcp-server/src/tools/review-action.ts
new file mode 100644
index 00000000..64bad45d
--- /dev/null
+++ b/mcp-server/src/tools/review-action.ts
@@ -0,0 +1,50 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseOkOutput } from '../output-parser.js';
+import { teamNameSchema, taskIdSchema, memberNameSchema, reviewDecisionSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'review_action',
+ description: `Approve a task or request changes.
+
+- "approve": marks the task as approved in kanban, optionally posts a review comment
+- "request-changes": removes from kanban, resets status to "in_progress", notifies the task owner with the change request comment`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ decision: reviewDecisionSchema.describe('"approve" or "request-changes"'),
+ comment: z.string().max(5000).optional().describe('Review comment (required for request-changes)'),
+ from: memberNameSchema.optional().describe('Reviewer name'),
+ notify_owner: z.boolean().optional().describe('Notify the task owner (for approve)'),
+ }),
+ execute: async (args) => {
+ if (args.decision === 'request-changes' && !args.comment) {
+ throw new UserError('comment is required when requesting changes');
+ }
+
+ const cliArgs = ['--team', args.team, 'review', args.decision, args.task_id];
+
+ // approve uses --note for optional comment; request-changes uses --comment
+ if (args.decision === 'request-changes' && args.comment) {
+ cliArgs.push('--comment', args.comment);
+ } else if (args.decision === 'approve' && args.comment) {
+ cliArgs.push('--note', args.comment);
+ }
+ if (args.from) cliArgs.push('--from', args.from);
+ if (args.notify_owner) cliArgs.push('--notify-owner');
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to ${args.decision}: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseOkOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-attach.ts b/mcp-server/src/tools/task-attach.ts
new file mode 100644
index 00000000..a0e9830d
--- /dev/null
+++ b/mcp-server/src/tools/task-attach.ts
@@ -0,0 +1,42 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseJsonOutput } from '../output-parser.js';
+import { teamNameSchema, taskIdSchema, memberNameSchema, filePathSchema, safeFilenameSchema, mimeTypeSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_attach',
+ description: `Attach a file to a task. Returns attachment metadata JSON.
+
+Supports copy (default) or hardlink mode. MIME type is auto-detected from file content (PNG, JPEG, GIF, WebP, PDF, ZIP) with fallback to application/octet-stream. Max file size: 20 MB.`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ file: filePathSchema.describe('Absolute path to the file to attach'),
+ filename: safeFilenameSchema.optional().describe('Override stored filename'),
+ mime_type: mimeTypeSchema.optional().describe('Override MIME type (auto-detected by default)'),
+ mode: z.enum(['copy', 'link']).optional().describe('Storage mode: copy (default) or hardlink'),
+ from: memberNameSchema.optional().describe('Uploader name'),
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'attach', args.task_id, '--file', args.file];
+
+ if (args.filename) cliArgs.push('--filename', args.filename);
+ if (args.mime_type) cliArgs.push('--mime-type', args.mime_type);
+ if (args.mode) cliArgs.push('--mode', args.mode);
+ if (args.from) cliArgs.push('--from', args.from);
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to attach file: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseJsonOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-briefing.ts b/mcp-server/src/tools/task-briefing.ts
new file mode 100644
index 00000000..9273a6d7
--- /dev/null
+++ b/mcp-server/src/tools/task-briefing.ts
@@ -0,0 +1,32 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseTextOutput } from '../output-parser.js';
+import { teamNameSchema, memberNameSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_briefing',
+ description: `Generate a text briefing for a team member showing their assigned tasks vs the team board.
+
+Returns a human-readable multi-line report. Automatically filters out internal bookkeeping tasks.`,
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ member: memberNameSchema.describe('Member name to generate briefing for'),
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'briefing', '--for', args.member];
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to get briefing: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseTextOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-comment.ts b/mcp-server/src/tools/task-comment.ts
new file mode 100644
index 00000000..951ef0a9
--- /dev/null
+++ b/mcp-server/src/tools/task-comment.ts
@@ -0,0 +1,34 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseOkOutput } from '../output-parser.js';
+import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_comment',
+ description: `Add a comment to a task. Sends an inbox notification to the task owner (unless the commenter is the owner).`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ text: z.string().min(1).max(10000).describe('Comment text'),
+ from: memberNameSchema.optional().describe('Author name (skips self-notification)'),
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'comment', args.task_id, '--text', args.text];
+
+ if (args.from) cliArgs.push('--from', args.from);
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to add comment: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseOkOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-create.ts b/mcp-server/src/tools/task-create.ts
new file mode 100644
index 00000000..5c08e4e7
--- /dev/null
+++ b/mcp-server/src/tools/task-create.ts
@@ -0,0 +1,52 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseJsonOutput } from '../output-parser.js';
+import { teamNameSchema, memberNameSchema, taskIdsArraySchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_create',
+ description: `Create a new task in a team's task board. Returns the created task JSON.
+
+Behavior:
+- If owner is set and no blockers → status defaults to "in_progress"
+- If blocked_by specified → status defaults to "pending" (even with owner)
+- If notify is true, sends an inbox notification to the assigned owner`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ subject: z.string().min(1).max(500).describe('Task title'),
+ description: z.string().max(5000).optional().describe('Detailed task description'),
+ owner: memberNameSchema.optional().describe('Assign to a team member'),
+ blocked_by: taskIdsArraySchema.optional().describe('Task IDs that block this task'),
+ related: taskIdsArraySchema.optional().describe('Related (non-blocking) task IDs'),
+ status: z.enum(['pending', 'in_progress']).optional().describe('Initial status override'),
+ active_form: z.string().max(200).optional().describe('Active form hint for CLI display'),
+ notify: z.boolean().optional().describe('Send inbox notification to owner'),
+ from: memberNameSchema.optional().describe('Author name for notifications'),
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'create', '--subject', args.subject];
+
+ if (args.description) cliArgs.push('--description', args.description);
+ if (args.owner) cliArgs.push('--owner', args.owner);
+ if (args.blocked_by?.length) cliArgs.push('--blocked-by', args.blocked_by.join(','));
+ if (args.related?.length) cliArgs.push('--related', args.related.join(','));
+ if (args.status) cliArgs.push('--status', args.status);
+ if (args.active_form) cliArgs.push('--activeForm', args.active_form);
+ if (args.notify) cliArgs.push('--notify');
+ if (args.from) cliArgs.push('--from', args.from);
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to create task: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseJsonOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-get.ts b/mcp-server/src/tools/task-get.ts
new file mode 100644
index 00000000..cf56ec6d
--- /dev/null
+++ b/mcp-server/src/tools/task-get.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseJsonOutput } from '../output-parser.js';
+import { teamNameSchema, taskIdSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_get',
+ description: `Get a single task by its ID. Returns the full task JSON including status, owner, comments, dependencies, work intervals, and status history.`,
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'get', args.task_id];
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to get task: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseJsonOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-link.ts b/mcp-server/src/tools/task-link.ts
new file mode 100644
index 00000000..0d43021c
--- /dev/null
+++ b/mcp-server/src/tools/task-link.ts
@@ -0,0 +1,51 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseOkOutput } from '../output-parser.js';
+import {
+ teamNameSchema,
+ taskIdSchema,
+ linkTypeSchema,
+ linkOperationSchema,
+} from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_link',
+ description: `Link or unlink task dependencies.
+
+Relationship types:
+- "blocked-by": this task is blocked by the target
+- "blocks": this task blocks the target
+- "related": non-blocking relationship
+
+Links are bidirectional: linking A blocked-by B also sets B blocks A.
+Circular dependencies are detected and rejected.`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ operation: linkOperationSchema.describe('"link" to add, "unlink" to remove'),
+ relationship: linkTypeSchema,
+ target_id: taskIdSchema.describe('The other task ID to link/unlink'),
+ }),
+ execute: async (args) => {
+ const cliArgs = [
+ '--team', args.team,
+ 'task', args.operation,
+ args.task_id,
+ `--${args.relationship}`, args.target_id,
+ ];
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to ${args.operation} tasks: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseOkOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-list.ts b/mcp-server/src/tools/task-list.ts
new file mode 100644
index 00000000..37c05bfd
--- /dev/null
+++ b/mcp-server/src/tools/task-list.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseJsonOutput } from '../output-parser.js';
+import { teamNameSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_list',
+ description: `List all tasks for a team. Returns a JSON array of task objects.
+
+Note: includes internal bookkeeping tasks (metadata._internal). Filter client-side if needed.`,
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'list'];
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to list tasks: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseJsonOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-set-owner.ts b/mcp-server/src/tools/task-set-owner.ts
new file mode 100644
index 00000000..4cfd04bf
--- /dev/null
+++ b/mcp-server/src/tools/task-set-owner.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseOkOutput } from '../output-parser.js';
+import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_set_owner',
+ description: `Assign a task to a team member or clear the assignment.
+
+Pass owner="clear" to unassign. Optionally sends an inbox notification to the new owner.`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ owner: z.union([memberNameSchema, z.literal('clear')]).describe('Member name to assign, or "clear" to unassign'),
+ notify: z.boolean().optional().describe('Send inbox notification to new owner'),
+ from: memberNameSchema.optional().describe('Author name for notification'),
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'set-owner', args.task_id, args.owner];
+
+ if (args.notify) cliArgs.push('--notify');
+ if (args.from) cliArgs.push('--from', args.from);
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to set owner: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseOkOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/src/tools/task-set-status.ts b/mcp-server/src/tools/task-set-status.ts
new file mode 100644
index 00000000..a5b96294
--- /dev/null
+++ b/mcp-server/src/tools/task-set-status.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+import { UserError } from 'fastmcp';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner } from '../teamctl-runner.js';
+import { parseOkOutput } from '../output-parser.js';
+import { teamNameSchema, taskIdSchema, taskStatusSchema } from '../schemas.js';
+
+export function register(server: FastMCP, runner: ITeamctlRunner): void {
+ server.addTool({
+ name: 'task_set_status',
+ description: `Change the status of a task.
+
+Valid transitions: pending → in_progress → completed → deleted (and back).
+Shortcuts: status "in_progress" is equivalent to "task start", "completed" to "task complete".
+Records status history and tracks work intervals (time spent in_progress).`,
+ annotations: {
+ readOnlyHint: false,
+ destructiveHint: false,
+ },
+ parameters: z.object({
+ team: teamNameSchema,
+ task_id: taskIdSchema,
+ status: taskStatusSchema.describe('New status for the task'),
+ }),
+ execute: async (args) => {
+ const cliArgs = ['--team', args.team, 'task', 'set-status', args.task_id, args.status];
+
+ const result = await runner.execute(cliArgs);
+ if (result.exitCode !== 0) {
+ throw new UserError(`Failed to set status: ${result.stderr.trim() || result.stdout.trim()}`);
+ }
+ return parseOkOutput(result.stdout);
+ },
+ });
+}
diff --git a/mcp-server/test/output-parser.test.ts b/mcp-server/test/output-parser.test.ts
new file mode 100644
index 00000000..52f8179f
--- /dev/null
+++ b/mcp-server/test/output-parser.test.ts
@@ -0,0 +1,76 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseJsonOutput,
+ parseOkOutput,
+ parseTextOutput,
+ formatError,
+} from '../src/output-parser.js';
+
+describe('parseJsonOutput', () => {
+ it('parses valid JSON object', () => {
+ const input = '{"id":"42","subject":"Fix bug"}\n';
+ expect(parseJsonOutput(input)).toEqual({ id: '42', subject: 'Fix bug' });
+ });
+
+ it('parses valid JSON array', () => {
+ const input = '[{"id":"1"},{"id":"2"}]\n';
+ expect(parseJsonOutput(input)).toEqual([{ id: '1' }, { id: '2' }]);
+ });
+
+ it('trims whitespace', () => {
+ const input = ' \n {"ok":true} \n ';
+ expect(parseJsonOutput(input)).toEqual({ ok: true });
+ });
+
+ it('throws on empty output', () => {
+ expect(() => parseJsonOutput('')).toThrow('Empty output');
+ expect(() => parseJsonOutput(' \n ')).toThrow('Empty output');
+ });
+
+ it('throws on invalid JSON', () => {
+ expect(() => parseJsonOutput('not json')).toThrow('Failed to parse');
+ });
+});
+
+describe('parseOkOutput', () => {
+ it('strips "OK " prefix', () => {
+ expect(parseOkOutput('OK task #1 status=completed\n')).toBe('task #1 status=completed');
+ });
+
+ it('handles bare "OK"', () => {
+ expect(parseOkOutput('OK\n')).toBe('OK');
+ });
+
+ it('returns as-is for unexpected format', () => {
+ expect(parseOkOutput('Something else')).toBe('Something else');
+ });
+
+ it('trims whitespace', () => {
+ expect(parseOkOutput(' OK kanban #1 cleared \n')).toBe('kanban #1 cleared');
+ });
+});
+
+describe('parseTextOutput', () => {
+ it('trims and returns text', () => {
+ const briefing = '=== Task Briefing for alice ===\nTask #1: Fix bug\n';
+ expect(parseTextOutput(briefing)).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug');
+ });
+
+ it('handles empty string', () => {
+ expect(parseTextOutput('')).toBe('');
+ });
+});
+
+describe('formatError', () => {
+ it('uses stderr when available', () => {
+ expect(formatError('Task not found: #42\n', '')).toBe('Task not found: #42');
+ });
+
+ it('falls back to stdout', () => {
+ expect(formatError('', 'Unexpected error\n')).toBe('Unexpected error');
+ });
+
+ it('returns default for empty', () => {
+ expect(formatError('', '')).toBe('Unknown teamctl error');
+ });
+});
diff --git a/mcp-server/test/schemas.test.ts b/mcp-server/test/schemas.test.ts
new file mode 100644
index 00000000..cfd8b5f6
--- /dev/null
+++ b/mcp-server/test/schemas.test.ts
@@ -0,0 +1,220 @@
+import { describe, it, expect } from 'vitest';
+import {
+ teamNameSchema,
+ taskIdSchema,
+ memberNameSchema,
+ taskStatusSchema,
+ kanbanColumnSchema,
+ clarificationSchema,
+ linkTypeSchema,
+ linkOperationSchema,
+ reviewDecisionSchema,
+ reviewerOperationSchema,
+ taskIdsArraySchema,
+ filePathSchema,
+ safeFilenameSchema,
+ mimeTypeSchema,
+} from '../src/schemas.js';
+
+describe('teamNameSchema', () => {
+ it('accepts valid team names', () => {
+ expect(teamNameSchema.parse('acme')).toBe('acme');
+ expect(teamNameSchema.parse('my-team')).toBe('my-team');
+ expect(teamNameSchema.parse('My_Team')).toBe('My_Team');
+ expect(teamNameSchema.parse('team123')).toBe('team123');
+ });
+
+ it('rejects empty', () => {
+ expect(() => teamNameSchema.parse('')).toThrow();
+ });
+
+ it('rejects path traversal', () => {
+ expect(() => teamNameSchema.parse('..')).toThrow();
+ expect(() => teamNameSchema.parse('.')).toThrow();
+ expect(() => teamNameSchema.parse('a/b')).toThrow();
+ expect(() => teamNameSchema.parse('a\\b')).toThrow();
+ expect(() => teamNameSchema.parse('a..b')).toThrow();
+ });
+
+ it('rejects null bytes', () => {
+ expect(() => teamNameSchema.parse('a\0b')).toThrow();
+ });
+
+ it('rejects too long', () => {
+ expect(() => teamNameSchema.parse('a'.repeat(129))).toThrow();
+ });
+});
+
+describe('taskIdSchema', () => {
+ it('accepts numeric IDs', () => {
+ expect(taskIdSchema.parse('1')).toBe('1');
+ expect(taskIdSchema.parse('42')).toBe('42');
+ expect(taskIdSchema.parse('1234567890')).toBe('1234567890');
+ });
+
+ it('accepts zero', () => {
+ expect(taskIdSchema.parse('0')).toBe('0');
+ });
+
+ it('accepts leading zeros', () => {
+ expect(taskIdSchema.parse('007')).toBe('007');
+ });
+
+ it('rejects non-numeric', () => {
+ expect(() => taskIdSchema.parse('abc')).toThrow();
+ expect(() => taskIdSchema.parse('')).toThrow();
+ expect(() => taskIdSchema.parse('1.5')).toThrow();
+ expect(() => taskIdSchema.parse('-1')).toThrow();
+ });
+
+ it('rejects too long', () => {
+ expect(() => taskIdSchema.parse('12345678901')).toThrow();
+ });
+});
+
+describe('memberNameSchema', () => {
+ it('accepts valid member names', () => {
+ expect(memberNameSchema.parse('alice')).toBe('alice');
+ expect(memberNameSchema.parse('bob-smith')).toBe('bob-smith');
+ expect(memberNameSchema.parse('user_1')).toBe('user_1');
+ });
+
+ it('rejects path traversal', () => {
+ expect(() => memberNameSchema.parse('..')).toThrow();
+ expect(() => memberNameSchema.parse('a/b')).toThrow();
+ expect(() => memberNameSchema.parse('a\\b')).toThrow();
+ expect(() => memberNameSchema.parse('a..b')).toThrow();
+ });
+
+ it('rejects null bytes', () => {
+ expect(() => memberNameSchema.parse('a\0b')).toThrow();
+ });
+
+ it('rejects too long', () => {
+ expect(() => memberNameSchema.parse('a'.repeat(129))).toThrow();
+ });
+
+ it('rejects empty', () => {
+ expect(() => memberNameSchema.parse('')).toThrow();
+ });
+});
+
+describe('enum schemas', () => {
+ it('taskStatusSchema accepts valid values', () => {
+ expect(taskStatusSchema.parse('pending')).toBe('pending');
+ expect(taskStatusSchema.parse('in_progress')).toBe('in_progress');
+ expect(taskStatusSchema.parse('completed')).toBe('completed');
+ expect(taskStatusSchema.parse('deleted')).toBe('deleted');
+ expect(() => taskStatusSchema.parse('invalid')).toThrow();
+ });
+
+ it('kanbanColumnSchema accepts valid values', () => {
+ expect(kanbanColumnSchema.parse('review')).toBe('review');
+ expect(kanbanColumnSchema.parse('approved')).toBe('approved');
+ expect(() => kanbanColumnSchema.parse('todo')).toThrow();
+ expect(() => kanbanColumnSchema.parse('')).toThrow();
+ });
+
+ it('clarificationSchema accepts valid values', () => {
+ expect(clarificationSchema.parse('lead')).toBe('lead');
+ expect(clarificationSchema.parse('user')).toBe('user');
+ expect(clarificationSchema.parse('clear')).toBe('clear');
+ expect(() => clarificationSchema.parse('nobody')).toThrow();
+ });
+
+ it('linkTypeSchema accepts valid values', () => {
+ expect(linkTypeSchema.parse('blocked-by')).toBe('blocked-by');
+ expect(linkTypeSchema.parse('blocks')).toBe('blocks');
+ expect(linkTypeSchema.parse('related')).toBe('related');
+ });
+
+ it('linkOperationSchema accepts valid values', () => {
+ expect(linkOperationSchema.parse('link')).toBe('link');
+ expect(linkOperationSchema.parse('unlink')).toBe('unlink');
+ });
+
+ it('reviewDecisionSchema accepts valid values', () => {
+ expect(reviewDecisionSchema.parse('approve')).toBe('approve');
+ expect(reviewDecisionSchema.parse('request-changes')).toBe('request-changes');
+ });
+
+ it('reviewerOperationSchema accepts valid values', () => {
+ expect(reviewerOperationSchema.parse('list')).toBe('list');
+ expect(reviewerOperationSchema.parse('add')).toBe('add');
+ expect(reviewerOperationSchema.parse('remove')).toBe('remove');
+ });
+});
+
+describe('taskIdsArraySchema', () => {
+ it('accepts valid arrays', () => {
+ expect(taskIdsArraySchema.parse(['1', '2', '3'])).toEqual(['1', '2', '3']);
+ expect(taskIdsArraySchema.parse([])).toEqual([]);
+ });
+
+ it('rejects arrays with invalid IDs', () => {
+ expect(() => taskIdsArraySchema.parse(['abc'])).toThrow();
+ expect(() => taskIdsArraySchema.parse(['1', 'bad'])).toThrow();
+ });
+});
+
+describe('filePathSchema', () => {
+ it('accepts absolute paths', () => {
+ expect(filePathSchema.parse('/home/user/file.txt')).toBe('/home/user/file.txt');
+ expect(filePathSchema.parse('/tmp/attachment.pdf')).toBe('/tmp/attachment.pdf');
+ });
+
+ it('rejects relative paths', () => {
+ expect(() => filePathSchema.parse('relative/path.txt')).toThrow();
+ expect(() => filePathSchema.parse('./file.txt')).toThrow();
+ });
+
+ it('rejects path traversal', () => {
+ expect(() => filePathSchema.parse('/home/user/../etc/passwd')).toThrow();
+ expect(() => filePathSchema.parse('/home/../../../etc/shadow')).toThrow();
+ });
+
+ it('rejects null bytes', () => {
+ expect(() => filePathSchema.parse('/home/user/\0evil')).toThrow();
+ });
+
+ it('rejects empty', () => {
+ expect(() => filePathSchema.parse('')).toThrow();
+ });
+});
+
+describe('safeFilenameSchema', () => {
+ it('accepts valid filenames', () => {
+ expect(safeFilenameSchema.parse('report.pdf')).toBe('report.pdf');
+ expect(safeFilenameSchema.parse('my-file_v2.tar.gz')).toBe('my-file_v2.tar.gz');
+ });
+
+ it('rejects path separators', () => {
+ expect(() => safeFilenameSchema.parse('../../evil')).toThrow();
+ expect(() => safeFilenameSchema.parse('dir/file')).toThrow();
+ expect(() => safeFilenameSchema.parse('dir\\file')).toThrow();
+ });
+
+ it('rejects null bytes', () => {
+ expect(() => safeFilenameSchema.parse('file\0name')).toThrow();
+ });
+
+ it('rejects too long', () => {
+ expect(() => safeFilenameSchema.parse('a'.repeat(256))).toThrow();
+ });
+});
+
+describe('mimeTypeSchema', () => {
+ it('accepts valid MIME types', () => {
+ expect(mimeTypeSchema.parse('application/pdf')).toBe('application/pdf');
+ expect(mimeTypeSchema.parse('image/png')).toBe('image/png');
+ expect(mimeTypeSchema.parse('text/plain')).toBe('text/plain');
+ expect(mimeTypeSchema.parse('application/octet-stream')).toBe('application/octet-stream');
+ });
+
+ it('rejects invalid formats', () => {
+ expect(() => mimeTypeSchema.parse('invalid')).toThrow();
+ expect(() => mimeTypeSchema.parse('/pdf')).toThrow();
+ expect(() => mimeTypeSchema.parse('application/')).toThrow();
+ expect(() => mimeTypeSchema.parse('')).toThrow();
+ });
+});
diff --git a/mcp-server/test/teamctl-runner.test.ts b/mcp-server/test/teamctl-runner.test.ts
new file mode 100644
index 00000000..54b703f7
--- /dev/null
+++ b/mcp-server/test/teamctl-runner.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect, vi } from 'vitest';
+import { TeamctlRunner } from '../src/teamctl-runner.js';
+import type { ITeamctlRunner, TeamctlResult } from '../src/teamctl-runner.js';
+
+// We can't easily test the real subprocess without teamctl.js installed,
+// so we test the interface contract and error handling.
+
+describe('TeamctlRunner', () => {
+ it('throws if teamctl.js does not exist', () => {
+ expect(
+ () => new TeamctlRunner({ teamctlPath: '/nonexistent/teamctl.js' }),
+ ).toThrow('teamctl.js not found');
+ });
+
+ it('resolves path from TEAMCTL_PATH env', () => {
+ const original = process.env['TEAMCTL_PATH'];
+ try {
+ process.env['TEAMCTL_PATH'] = '/tmp/test-teamctl.js';
+ // Will throw because file doesn't exist, but we can check the error message
+ expect(
+ () => new TeamctlRunner(),
+ ).toThrow('/tmp/test-teamctl.js');
+ } finally {
+ if (original !== undefined) {
+ process.env['TEAMCTL_PATH'] = original;
+ } else {
+ delete process.env['TEAMCTL_PATH'];
+ }
+ }
+ });
+});
+
+// Mock runner for tool tests
+export function createMockRunner(
+ responses: Map | TeamctlResult,
+): ITeamctlRunner {
+ return {
+ execute: vi.fn(async (args: string[]): Promise => {
+ if (responses instanceof Map) {
+ const key = args.join(' ');
+ const result = responses.get(key);
+ if (result) return result;
+ // Fallback: check if any key is a prefix
+ for (const [k, v] of responses) {
+ if (key.startsWith(k)) return v;
+ }
+ return { stdout: '', stderr: 'No mock for: ' + key, exitCode: 1 };
+ }
+ return responses;
+ }),
+ };
+}
+
+describe('ITeamctlRunner interface', () => {
+ it('mock runner returns success', async () => {
+ const runner = createMockRunner({
+ stdout: '{"id":"1","subject":"Test"}\n',
+ stderr: '',
+ exitCode: 0,
+ });
+
+ const result = await runner.execute(['--team', 'test', 'task', 'create']);
+ expect(result.exitCode).toBe(0);
+ expect(JSON.parse(result.stdout)).toHaveProperty('id', '1');
+ });
+
+ it('mock runner returns error', async () => {
+ const runner = createMockRunner({
+ stdout: '',
+ stderr: 'Task not found: #99\n',
+ exitCode: 1,
+ });
+
+ const result = await runner.execute(['--team', 'test', 'task', 'get', '99']);
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('Task not found');
+ });
+});
diff --git a/mcp-server/test/tools/kanban-move.test.ts b/mcp-server/test/tools/kanban-move.test.ts
new file mode 100644
index 00000000..e0468131
--- /dev/null
+++ b/mcp-server/test/tools/kanban-move.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/kanban-move.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('kanban_move', () => {
+ function setup(response = ok('OK kanban #1 column=review\n')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('kanban_move')! };
+ }
+
+ it('builds set-column CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', operation: 'set-column', column: 'review' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'kanban', 'set-column', '1', 'review',
+ ]);
+ });
+
+ it('builds clear CLI args', async () => {
+ const { runner, tool } = setup(ok('OK kanban #1 cleared\n'));
+ await tool.execute({ team: 'acme', task_id: '1', operation: 'clear' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'kanban', 'clear', '1',
+ ]);
+ });
+
+ it('throws UserError when set-column called without column', async () => {
+ const { tool } = setup();
+ await expect(
+ tool.execute({ team: 'acme', task_id: '1', operation: 'set-column' }),
+ ).rejects.toThrow('column is required');
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('task not found'));
+ await expect(
+ tool.execute({ team: 'acme', task_id: '99', operation: 'clear' }),
+ ).rejects.toThrow('Failed to update kanban');
+ });
+});
diff --git a/mcp-server/test/tools/kanban-reviewers.test.ts b/mcp-server/test/tools/kanban-reviewers.test.ts
new file mode 100644
index 00000000..ee854535
--- /dev/null
+++ b/mcp-server/test/tools/kanban-reviewers.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/kanban-reviewers.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('kanban_reviewers', () => {
+ function setup(response = ok('["alice","bob"]')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('kanban_reviewers')! };
+ }
+
+ it('builds list CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', operation: 'list' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'kanban', 'reviewers', 'list',
+ ]);
+ });
+
+ it('returns JSON array for list', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme', operation: 'list' });
+ expect(result).toEqual(['alice', 'bob']);
+ });
+
+ it('builds add CLI args with name', async () => {
+ const { runner, tool } = setup(ok('OK reviewer added\n'));
+ await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'kanban', 'reviewers', 'add', 'charlie',
+ ]);
+ });
+
+ it('returns OK text for add/remove', async () => {
+ const { tool } = setup(ok('OK reviewer added\n'));
+ const result = await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
+ expect(result).toBe('reviewer added');
+ });
+
+ it('throws UserError when add called without name', async () => {
+ const { tool } = setup();
+ await expect(
+ tool.execute({ team: 'acme', operation: 'add' }),
+ ).rejects.toThrow('name is required');
+ });
+
+ it('throws UserError when remove called without name', async () => {
+ const { tool } = setup();
+ await expect(
+ tool.execute({ team: 'acme', operation: 'remove' }),
+ ).rejects.toThrow('name is required');
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('reviewer not found'));
+ await expect(
+ tool.execute({ team: 'acme', operation: 'remove', name: 'nobody' }),
+ ).rejects.toThrow('Failed to manage reviewers');
+ });
+});
diff --git a/mcp-server/test/tools/message-send.test.ts b/mcp-server/test/tools/message-send.test.ts
new file mode 100644
index 00000000..8bb206e8
--- /dev/null
+++ b/mcp-server/test/tools/message-send.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/message-send.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('message_send', () => {
+ const deliveryJson = '{"deliveredToInbox":true,"messageId":"msg_abc"}';
+
+ function setup(response = ok(deliveryJson)) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('message_send')! };
+ }
+
+ it('builds minimal CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'message', 'send', '--to', 'alice', '--text', 'Hello!',
+ ]);
+ });
+
+ it('includes summary and from flags', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({
+ team: 'acme', to: 'alice', text: 'Task done',
+ summary: 'Completed task #1', from: 'bob',
+ });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).toContain('--summary');
+ expect(args[args.indexOf('--summary') + 1]).toBe('Completed task #1');
+ expect(args).toContain('--from');
+ expect(args[args.indexOf('--from') + 1]).toBe('bob');
+ });
+
+ it('returns parsed JSON', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
+ expect(result).toEqual({ deliveredToInbox: true, messageId: 'msg_abc' });
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('recipient inbox not found'));
+ await expect(
+ tool.execute({ team: 'acme', to: 'nobody', text: 'Hi' }),
+ ).rejects.toThrow('Failed to send message');
+ });
+});
diff --git a/mcp-server/test/tools/register-all.test.ts b/mcp-server/test/tools/register-all.test.ts
new file mode 100644
index 00000000..d562525e
--- /dev/null
+++ b/mcp-server/test/tools/register-all.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest';
+import { registerAllTools } from '../../src/tools/index.js';
+import { createMockRunner, createMockServer } from './test-helpers.js';
+
+describe('registerAllTools', () => {
+ it('registers exactly 13 tools', () => {
+ const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
+ const { server, tools } = createMockServer();
+ registerAllTools(server, runner);
+ expect(tools.size).toBe(13);
+ });
+
+ it('registers all expected tool names', () => {
+ const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
+ const { server, tools } = createMockServer();
+ registerAllTools(server, runner);
+
+ const expected = [
+ 'task_create', 'task_set_status', 'task_set_owner',
+ 'task_get', 'task_list', 'task_comment', 'task_link',
+ 'task_briefing', 'task_attach', 'kanban_move',
+ 'kanban_reviewers', 'review_action', 'message_send',
+ ];
+ for (const name of expected) {
+ expect(tools.has(name), `missing tool: ${name}`).toBe(true);
+ }
+ });
+});
diff --git a/mcp-server/test/tools/review-action.test.ts b/mcp-server/test/tools/review-action.test.ts
new file mode 100644
index 00000000..952f1ae9
--- /dev/null
+++ b/mcp-server/test/tools/review-action.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/review-action.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('review_action', () => {
+ function setup(response = ok('OK review #1 approved\n')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('review_action')! };
+ }
+
+ it('builds approve CLI args (no comment)', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', decision: 'approve' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'review', 'approve', '1',
+ ]);
+ });
+
+ it('builds approve CLI args with --note (not --comment)', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', decision: 'approve', comment: 'LGTM' });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ // approve uses --note, NOT --comment
+ expect(args).toContain('--note');
+ expect(args[args.indexOf('--note') + 1]).toBe('LGTM');
+ expect(args).not.toContain('--comment');
+ });
+
+ it('builds request-changes CLI args with --comment (not --note)', async () => {
+ const { runner, tool } = setup(ok('OK review #1 requested changes\n'));
+ await tool.execute({
+ team: 'acme', task_id: '1', decision: 'request-changes', comment: 'Fix tests',
+ });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).toContain('--comment');
+ expect(args[args.indexOf('--comment') + 1]).toBe('Fix tests');
+ expect(args).not.toContain('--note');
+ });
+
+ it('throws when request-changes has no comment', async () => {
+ const { tool } = setup();
+ await expect(
+ tool.execute({ team: 'acme', task_id: '1', decision: 'request-changes' }),
+ ).rejects.toThrow('comment is required when requesting changes');
+ });
+
+ it('includes from and notify_owner flags', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({
+ team: 'acme', task_id: '1', decision: 'approve',
+ from: 'alice', notify_owner: true,
+ });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).toContain('--from');
+ expect(args).toContain('--notify-owner');
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('task not in review'));
+ await expect(
+ tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }),
+ ).rejects.toThrow('Failed to approve');
+ });
+});
diff --git a/mcp-server/test/tools/task-attach.test.ts b/mcp-server/test/tools/task-attach.test.ts
new file mode 100644
index 00000000..e8b63fe5
--- /dev/null
+++ b/mcp-server/test/tools/task-attach.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-attach.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_attach', () => {
+ const attachJson = '{"id":"att_123","filename":"report.pdf","mimeType":"application/pdf"}';
+
+ function setup(response = ok(attachJson)) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_attach')! };
+ }
+
+ it('builds minimal CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'attach', '1', '--file', '/tmp/report.pdf',
+ ]);
+ });
+
+ it('includes all optional flags', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({
+ team: 'acme', task_id: '1', file: '/tmp/file.pdf',
+ filename: 'renamed.pdf', mime_type: 'application/pdf',
+ mode: 'link', from: 'alice',
+ });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).toContain('--filename');
+ expect(args).toContain('--mime-type');
+ expect(args).toContain('--mode');
+ expect(args).toContain('--from');
+ });
+
+ it('returns parsed JSON', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
+ expect(result).toEqual({ id: 'att_123', filename: 'report.pdf', mimeType: 'application/pdf' });
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('file too large'));
+ await expect(
+ tool.execute({ team: 'acme', task_id: '1', file: '/tmp/huge.bin' }),
+ ).rejects.toThrow('Failed to attach file');
+ });
+});
diff --git a/mcp-server/test/tools/task-briefing.test.ts b/mcp-server/test/tools/task-briefing.test.ts
new file mode 100644
index 00000000..b915e994
--- /dev/null
+++ b/mcp-server/test/tools/task-briefing.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-briefing.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_briefing', () => {
+ const briefingText = '=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]\n';
+
+ function setup(response = ok(briefingText)) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_briefing')! };
+ }
+
+ it('builds correct CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', member: 'alice' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'briefing', '--for', 'alice',
+ ]);
+ });
+
+ it('returns trimmed plain text', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme', member: 'alice' });
+ expect(result).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]');
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('member not found'));
+ await expect(tool.execute({ team: 'acme', member: 'nobody' })).rejects.toThrow('Failed to get briefing');
+ });
+});
diff --git a/mcp-server/test/tools/task-comment.test.ts b/mcp-server/test/tools/task-comment.test.ts
new file mode 100644
index 00000000..2c340e48
--- /dev/null
+++ b/mcp-server/test/tools/task-comment.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-comment.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_comment', () => {
+ function setup(response = ok('OK comment added to task #1\n')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_comment')! };
+ }
+
+ it('builds correct CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', text: 'Looking good!' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'comment', '1', '--text', 'Looking good!',
+ ]);
+ });
+
+ it('includes from flag', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', text: 'Done', from: 'alice' });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).toContain('--from');
+ expect(args[args.indexOf('--from') + 1]).toBe('alice');
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('task not found'));
+ await expect(tool.execute({ team: 'acme', task_id: '99', text: 'Hi' })).rejects.toThrow('Failed to add comment');
+ });
+});
diff --git a/mcp-server/test/tools/task-create.test.ts b/mcp-server/test/tools/task-create.test.ts
new file mode 100644
index 00000000..5866e9f9
--- /dev/null
+++ b/mcp-server/test/tools/task-create.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-create.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_create', () => {
+ function setup(response = ok('{"id":"1","subject":"Fix bug","status":"in_progress"}')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_create')! };
+ }
+
+ it('builds minimal CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', subject: 'Fix bug' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'create', '--subject', 'Fix bug',
+ ]);
+ });
+
+ it('includes all optional flags', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({
+ team: 'acme',
+ subject: 'Big task',
+ description: 'Details here',
+ owner: 'alice',
+ blocked_by: ['1', '2'],
+ related: ['3'],
+ status: 'pending',
+ active_form: 'Fixing bug',
+ notify: true,
+ from: 'bob',
+ });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).toContain('--description');
+ expect(args).toContain('--owner');
+ expect(args).toContain('--blocked-by');
+ expect(args[args.indexOf('--blocked-by') + 1]).toBe('1,2');
+ expect(args).toContain('--related');
+ expect(args[args.indexOf('--related') + 1]).toBe('3');
+ expect(args).toContain('--status');
+ expect(args).toContain('--activeForm');
+ expect(args).toContain('--notify');
+ expect(args).toContain('--from');
+ });
+
+ it('skips empty blocked_by array', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', subject: 'Task', blocked_by: [] });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).not.toContain('--blocked-by');
+ });
+
+ it('returns parsed JSON', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme', subject: 'Fix bug' });
+ expect(result).toEqual({ id: '1', subject: 'Fix bug', status: 'in_progress' });
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('team not found'));
+ await expect(tool.execute({ team: 'bad', subject: 'X' })).rejects.toThrow('Failed to create task');
+ });
+});
diff --git a/mcp-server/test/tools/task-get.test.ts b/mcp-server/test/tools/task-get.test.ts
new file mode 100644
index 00000000..79a3d97f
--- /dev/null
+++ b/mcp-server/test/tools/task-get.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-get.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_get', () => {
+ function setup(response = ok('{"id":"42","subject":"Test","status":"pending"}')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_get')! };
+ }
+
+ it('builds correct CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '42' });
+ expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'get', '42']);
+ });
+
+ it('returns parsed JSON', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme', task_id: '42' });
+ expect(result).toEqual({ id: '42', subject: 'Test', status: 'pending' });
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('Task not found: #99'));
+ await expect(tool.execute({ team: 'acme', task_id: '99' })).rejects.toThrow('Failed to get task');
+ });
+});
diff --git a/mcp-server/test/tools/task-link.test.ts b/mcp-server/test/tools/task-link.test.ts
new file mode 100644
index 00000000..7e16b802
--- /dev/null
+++ b/mcp-server/test/tools/task-link.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-link.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_link', () => {
+ function setup(response = ok('OK task #1 blocked-by #2\n')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_link')! };
+ }
+
+ it('builds link CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({
+ team: 'acme', task_id: '1', operation: 'link',
+ relationship: 'blocked-by', target_id: '2',
+ });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'link', '1', '--blocked-by', '2',
+ ]);
+ });
+
+ it('builds unlink CLI args', async () => {
+ const { runner, tool } = setup(ok('OK task #1 unlinked from #2\n'));
+ await tool.execute({
+ team: 'acme', task_id: '1', operation: 'unlink',
+ relationship: 'related', target_id: '3',
+ });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'unlink', '1', '--related', '3',
+ ]);
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('circular dependency'));
+ await expect(
+ tool.execute({
+ team: 'acme', task_id: '1', operation: 'link',
+ relationship: 'blocked-by', target_id: '1',
+ }),
+ ).rejects.toThrow('Failed to link tasks');
+ });
+});
diff --git a/mcp-server/test/tools/task-list.test.ts b/mcp-server/test/tools/task-list.test.ts
new file mode 100644
index 00000000..be1a25af
--- /dev/null
+++ b/mcp-server/test/tools/task-list.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-list.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_list', () => {
+ function setup(response = ok('[{"id":"1"},{"id":"2"}]')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_list')! };
+ }
+
+ it('builds correct CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme' });
+ expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'list']);
+ });
+
+ it('returns parsed JSON array', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme' });
+ expect(result).toEqual([{ id: '1' }, { id: '2' }]);
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('team dir not found'));
+ await expect(tool.execute({ team: 'bad' })).rejects.toThrow('Failed to list tasks');
+ });
+});
diff --git a/mcp-server/test/tools/task-set-owner.test.ts b/mcp-server/test/tools/task-set-owner.test.ts
new file mode 100644
index 00000000..d9859066
--- /dev/null
+++ b/mcp-server/test/tools/task-set-owner.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-set-owner.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_set_owner', () => {
+ function setup(response = ok('OK task #1 owner=alice\n')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_set_owner')! };
+ }
+
+ it('builds args for assignment', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', owner: 'alice' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'set-owner', '1', 'alice',
+ ]);
+ });
+
+ it('builds args for clear', async () => {
+ const { runner, tool } = setup(ok('OK task #1 owner=cleared\n'));
+ await tool.execute({ team: 'acme', task_id: '1', owner: 'clear' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'set-owner', '1', 'clear',
+ ]);
+ });
+
+ it('includes notify and from flags', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', owner: 'alice', notify: true, from: 'bob' });
+ const args = runner.execute.mock.calls[0]![0] as string[];
+ expect(args).toContain('--notify');
+ expect(args).toContain('--from');
+ expect(args[args.indexOf('--from') + 1]).toBe('bob');
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('member not found'));
+ await expect(tool.execute({ team: 'acme', task_id: '1', owner: 'nobody' })).rejects.toThrow('Failed to set owner');
+ });
+});
diff --git a/mcp-server/test/tools/task-set-status.test.ts b/mcp-server/test/tools/task-set-status.test.ts
new file mode 100644
index 00000000..0ca68d0d
--- /dev/null
+++ b/mcp-server/test/tools/task-set-status.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect } from 'vitest';
+import { register } from '../../src/tools/task-set-status.js';
+import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
+
+describe('task_set_status', () => {
+ function setup(response = ok('OK task #1 status=completed\n')) {
+ const runner = createMockRunner(response);
+ const { server, tools } = createMockServer();
+ register(server, runner);
+ return { runner, tool: tools.get('task_set_status')! };
+ }
+
+ it('builds correct CLI args', async () => {
+ const { runner, tool } = setup();
+ await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
+ expect(runner.execute).toHaveBeenCalledWith([
+ '--team', 'acme', 'task', 'set-status', '1', 'completed',
+ ]);
+ });
+
+ it('returns parsed OK text', async () => {
+ const { tool } = setup();
+ const result = await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
+ expect(result).toBe('task #1 status=completed');
+ });
+
+ it('throws on CLI failure', async () => {
+ const { tool } = setup(fail('invalid transition'));
+ await expect(tool.execute({ team: 'acme', task_id: '1', status: 'deleted' })).rejects.toThrow('Failed to set status');
+ });
+});
diff --git a/mcp-server/test/tools/test-helpers.ts b/mcp-server/test/tools/test-helpers.ts
new file mode 100644
index 00000000..fb0a8e01
--- /dev/null
+++ b/mcp-server/test/tools/test-helpers.ts
@@ -0,0 +1,58 @@
+import { vi } from 'vitest';
+import type { FastMCP } from 'fastmcp';
+import type { ITeamctlRunner, TeamctlResult } from '../../src/teamctl-runner.js';
+
+/**
+ * Creates a mock ITeamctlRunner that records calls and returns predetermined results.
+ */
+export function createMockRunner(
+ response: TeamctlResult | ((args: string[]) => TeamctlResult),
+): ITeamctlRunner & { execute: ReturnType } {
+ return {
+ execute: vi.fn(async (args: string[]): Promise => {
+ return typeof response === 'function' ? response(args) : response;
+ }),
+ };
+}
+
+/** Success response helpers */
+export const ok = (stdout: string): TeamctlResult => ({
+ stdout,
+ stderr: '',
+ exitCode: 0,
+});
+
+export const fail = (stderr: string): TeamctlResult => ({
+ stdout: '',
+ stderr,
+ exitCode: 1,
+});
+
+/**
+ * Captures registered tools via a mock FastMCP-like server.
+ * Returns a map of tool name → { execute function, parameters schema }.
+ */
+export interface CapturedTool {
+ name: string;
+ execute: (args: Record) => Promise;
+ parameters: unknown;
+}
+
+export function createMockServer(): {
+ server: FastMCP;
+ tools: Map;
+} {
+ const tools = new Map();
+
+ const server = {
+ addTool: (def: { name: string; execute: CapturedTool['execute']; parameters: unknown }) => {
+ tools.set(def.name, {
+ name: def.name,
+ execute: def.execute,
+ parameters: def.parameters,
+ });
+ },
+ } as unknown as FastMCP;
+
+ return { server, tools };
+}
diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json
new file mode 100644
index 00000000..2bd3eb89
--- /dev/null
+++ b/mcp-server/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "isolatedModules": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist", "test"]
+}
diff --git a/mcp-server/tsup.config.ts b/mcp-server/tsup.config.ts
new file mode 100644
index 00000000..5cd234bb
--- /dev/null
+++ b/mcp-server/tsup.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ format: ['esm'],
+ target: 'node20',
+ outDir: 'dist',
+ clean: true,
+ sourcemap: true,
+ dts: false,
+ banner: {
+ js: '#!/usr/bin/env node',
+ },
+});
diff --git a/mcp-server/vitest.config.ts b/mcp-server/vitest.config.ts
new file mode 100644
index 00000000..6497809d
--- /dev/null
+++ b/mcp-server/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['test/**/*.test.ts'],
+ testTimeout: 15_000,
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 147ae6f3..80ad3475 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -370,6 +370,31 @@ importers:
specifier: ^3.1.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0)
+ mcp-server:
+ dependencies:
+ fastmcp:
+ specifier: ^3.34.0
+ version: 3.34.0
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
+ devDependencies:
+ '@types/node':
+ specifier: ^22.15.18
+ version: 22.19.15
+ tsup:
+ specifier: ^8.5.1
+ version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
+ typescript:
+ specifier: ^5.8.2
+ version: 5.9.3
+ vitest:
+ specifier: ^3.1.4
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.0.2)(terser@5.46.0)
+
packages:
7zip-bin@5.2.0:
@@ -479,6 +504,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
+ '@borewit/text-codec@0.2.1':
+ resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
+
'@boundaries/elements@1.1.2':
resolution: {integrity: sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==}
engines: {node: '>=18.18'}
@@ -1063,6 +1091,12 @@ packages:
'@gar/promisify@1.1.3':
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
+ '@hono/node-server@1.19.11':
+ resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
+ engines: {node: '>=18.14.1'}
+ peerDependencies:
+ hono: ^4
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -1203,6 +1237,16 @@ packages:
'@mermaid-js/parser@1.0.0':
resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==}
+ '@modelcontextprotocol/sdk@1.27.1':
+ resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@cfworker/json-schema': ^4.1.1
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@cfworker/json-schema':
+ optional: true
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -1893,10 +1937,20 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@sec-ant/readable-stream@0.4.1':
+ resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
+
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
+ '@sindresorhus/merge-streams@4.0.0':
+ resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
+ engines: {node: '>=18'}
+
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@@ -1915,6 +1969,13 @@ packages:
'@tanstack/virtual-core@3.13.18':
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
+ '@tokenizer/inflate@0.4.1':
+ resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
+ engines: {node: '>=18'}
+
+ '@tokenizer/token@0.3.0':
+ resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+
'@tootallnate/once@2.0.0':
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
engines: {node: '>= 10'}
@@ -2078,6 +2139,9 @@ packages:
'@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
+ '@types/node@22.19.15':
+ resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
+
'@types/node@24.10.12':
resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==}
@@ -2346,6 +2410,14 @@ packages:
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
+ accepts@1.3.8:
+ resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+ engines: {node: '>= 0.6'}
+
+ accepts@2.0.0:
+ resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+ engines: {node: '>= 0.6'}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -2570,6 +2642,9 @@ packages:
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
engines: {node: '>=4'}
+ axios@1.13.6:
+ resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -2607,6 +2682,10 @@ packages:
bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
+ body-parser@2.2.2:
+ resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
+ engines: {node: '>=18'}
+
boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
@@ -2669,6 +2748,12 @@ packages:
resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
engines: {node: '>=6'}
+ bundle-require@5.1.0:
+ resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ peerDependencies:
+ esbuild: '>=0.18'
+
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -2805,6 +2890,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
+ cliui@9.0.1:
+ resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
+ engines: {node: '>=20'}
+
clone-response@1.0.3:
resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==}
@@ -2886,6 +2975,10 @@ packages:
config-file-ts@0.2.8-rc1:
resolution: {integrity: sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==}
+ consola@3.4.2:
+ resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
+ engines: {node: ^14.18.0 || >=16.10.0}
+
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
@@ -2893,19 +2986,39 @@ packages:
resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==}
engines: {node: '>=18'}
+ content-type@1.0.5:
+ resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+ engines: {node: '>= 0.6'}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie-signature@1.2.2:
+ resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+ engines: {node: '>=6.6.0'}
+
+ cookie@0.7.2:
+ resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+ engines: {node: '>= 0.6'}
+
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
+ cookies@0.9.1:
+ resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==}
+ engines: {node: '>= 0.8'}
+
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+ cors@2.8.6:
+ resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+ engines: {node: '>= 0.10'}
+
cose-base@1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@@ -3148,6 +3261,9 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
+ deep-equal@1.0.1:
+ resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==}
+
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -3176,6 +3292,10 @@ packages:
delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
+ depd@1.1.2:
+ resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
+ engines: {node: '>= 0.6'}
+
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -3184,6 +3304,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
+ destroy@1.2.0:
+ resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+ engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -3251,6 +3375,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+ ee-first@1.1.1:
+ resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
ejs@3.1.10:
resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
engines: {node: '>=0.10.0'}
@@ -3301,6 +3428,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ encodeurl@2.0.0:
+ resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+ engines: {node: '>= 0.8'}
+
encoding@0.1.13:
resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==}
@@ -3544,9 +3675,25 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ etag@1.8.1:
+ resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+ engines: {node: '>= 0.6'}
+
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+ eventsource-parser@3.0.6:
+ resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
+ execa@9.6.1:
+ resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
+ engines: {node: ^18.19.0 || >=20.5.0}
+
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -3554,6 +3701,16 @@ packages:
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
+ express-rate-limit@8.3.0:
+ resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
+ express@5.2.1:
+ resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+ engines: {node: '>= 18'}
+
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -3597,6 +3754,15 @@ packages:
fastify@5.7.4:
resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==}
+ fastmcp@3.34.0:
+ resolution: {integrity: sha512-xKOXjU+MK7OZy91BY3FS5aenSiclJBCRMaZtXb3HYaKZVFbq4qYvAlFu6xYI3UU1NGLtv+h8izoStnOQ1By0BA==}
+ hasBin: true
+ peerDependencies:
+ jose: ^5.0.0
+ peerDependenciesMeta:
+ jose:
+ optional: true
+
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -3615,10 +3781,18 @@ packages:
picomatch:
optional: true
+ figures@6.1.0:
+ resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
+ engines: {node: '>=18'}
+
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ file-type@21.3.0:
+ resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
+ engines: {node: '>=20'}
+
filelist@1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
@@ -3626,6 +3800,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ finalhandler@2.1.1:
+ resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+ engines: {node: '>= 18.0.0'}
+
find-my-way@9.4.0:
resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==}
engines: {node: '>=20'}
@@ -3634,6 +3812,9 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
+ fix-dts-default-cjs-exports@1.0.1:
+ resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
+
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@@ -3641,6 +3822,15 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+ follow-redirects@1.15.11:
+ resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
@@ -3658,9 +3848,21 @@ packages:
engines: {node: '>=18.3.0'}
hasBin: true
+ forwarded@0.2.0:
+ resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+ engines: {node: '>= 0.6'}
+
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+ fresh@0.5.2:
+ resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
+ engines: {node: '>= 0.6'}
+
+ fresh@2.0.0:
+ resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+ engines: {node: '>= 0.8'}
+
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -3709,6 +3911,10 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+ fuse.js@7.1.0:
+ resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
+ engines: {node: '>=10'}
+
gauge@4.0.4:
resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -3746,6 +3952,10 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
+ get-stream@9.0.1:
+ resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
+ engines: {node: '>=18'}
+
get-symbol-description@1.1.0:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
@@ -3885,6 +4095,10 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
+ hono@4.12.5:
+ resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==}
+ engines: {node: '>=16.9.0'}
+
hosted-git-info@4.1.0:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
@@ -3898,9 +4112,17 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+ http-assert@1.5.0:
+ resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==}
+ engines: {node: '>= 0.8'}
+
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
+ http-errors@1.8.1:
+ resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==}
+ engines: {node: '>= 0.6'}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -3925,6 +4147,13 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
+ human-readable-ids@1.0.4:
+ resolution: {integrity: sha512-h1zwThTims8A/SpqFGWyTx+jG1+WRMJaEeZgbtPGrIpj2AZjsOgy8Y+iNzJ0yAyN669Q6F02EK66WMWcst+2FA==}
+
+ human-signals@8.0.1:
+ resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
+ engines: {node: '>=18.18.0'}
+
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
@@ -3942,6 +4171,10 @@ packages:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
+ iconv-lite@0.7.2:
+ resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
+ engines: {node: '>=0.10.0'}
+
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
@@ -3996,6 +4229,10 @@ packages:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
+ ipaddr.js@1.9.1:
+ resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+ engines: {node: '>= 0.10'}
+
ipaddr.js@2.3.0:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
@@ -4106,6 +4343,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
+ is-promise@4.0.0:
+ resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -4118,6 +4358,10 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
+ is-stream@4.0.1:
+ resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
+ engines: {node: '>=18'}
+
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@@ -4134,6 +4378,10 @@ packages:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
+ is-unicode-supported@2.1.0:
+ resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
+ engines: {node: '>=18'}
+
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -4207,6 +4455,13 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jose@6.2.0:
+ resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==}
+
+ joycon@3.1.1:
+ resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
+ engines: {node: '>=10'}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -4234,6 +4489,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+ json-schema-typed@8.0.2:
+ resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -4267,6 +4525,10 @@ packages:
resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==}
hasBin: true
+ keygrip@1.1.0:
+ resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
+ engines: {node: '>= 0.6'}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -4281,6 +4543,21 @@ packages:
'@types/node': '>=18'
typescript: '>=5.0.4 <7'
+ knuth-shuffle@1.0.8:
+ resolution: {integrity: sha512-IdC4Hpp+mx53zTt6VAGsAtbGM0g4BV9fP8tTcviCosSwocHcRDw9uG5Rnv6wLWckF4r72qeXFoK9NkvV1gUJCQ==}
+
+ koa-compose@4.1.0:
+ resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==}
+
+ koa-router@14.0.0:
+ resolution: {integrity: sha512-Ue8f/PRsLLNm6b7y+eS6xkqvsG2xH11d2VB1HPcfdfW6p5736kCHf2pXaq8q9XPQ01x0Dk7V/P5Il9pe+tGTxA==}
+ engines: {node: '>= 20'}
+ deprecated: 'Please use @koa/router instead, starting from v9! '
+
+ koa@3.1.2:
+ resolution: {integrity: sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==}
+ engines: {node: '>= 18'}
+
langium@4.2.1:
resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==}
engines: {node: '>=20.10.0', npm: '>=10.2.3'}
@@ -4328,6 +4605,10 @@ packages:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
+ load-tsconfig@0.2.5:
+ resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -4445,6 +4726,10 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
+ mcp-proxy@6.4.1:
+ resolution: {integrity: sha512-vzKLpJEZRqkbxz9sBqYSOWo9mJq0aGYQQLJoaMe+egYF/EA1PvcKvLx9pA1/bRpV/hfguEuB67gcs0C5KJiROQ==}
+ hasBin: true
+
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -4490,6 +4775,14 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+ media-typer@1.1.0:
+ resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+ engines: {node: '>= 0.8'}
+
+ merge-descriptors@2.0.0:
+ resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+ engines: {node: '>=18'}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -4589,10 +4882,18 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
+ mime-db@1.54.0:
+ resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+ engines: {node: '>= 0.6'}
+
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ mime-types@3.0.2:
+ resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+ engines: {node: '>=18'}
+
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
@@ -4723,6 +5024,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+ negotiator@0.6.3:
+ resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+ engines: {node: '>= 0.6'}
+
negotiator@0.6.4:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
@@ -4789,6 +5094,10 @@ packages:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'}
+ npm-run-path@6.0.0:
+ resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
+ engines: {node: '>=18'}
+
npmlog@6.0.2:
resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -4834,6 +5143,10 @@ packages:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
+ on-finished@2.4.1:
+ resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+ engines: {node: '>= 0.8'}
+
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -4893,9 +5206,17 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
+ parse-ms@4.0.0:
+ resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
+ engines: {node: '>=18'}
+
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+ parseurl@1.3.3:
+ resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+ engines: {node: '>= 0.8'}
+
path-data-parser@0.1.0:
resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
@@ -4911,6 +5232,10 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
+ path-key@4.0.0:
+ resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+ engines: {node: '>=12'}
+
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -4922,6 +5247,9 @@ packages:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
+ path-to-regexp@8.3.0:
+ resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -4966,10 +5294,19 @@ packages:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
+ pipenet@1.4.0:
+ resolution: {integrity: sha512-Uc3EH2i8hnJUD0Eupj9z2jaZPjjAbooaiHGh0iFdExbE8/BDt6Lf0919Dtwx5VM83elHNWFzCOsvzsViTD5YZg==}
+ engines: {node: '>=22.0.0'}
+ hasBin: true
+
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -5102,6 +5439,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-ms@9.3.0:
+ resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
+ engines: {node: '>=18'}
+
proc-log@5.0.0:
resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -5137,6 +5478,13 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+ proxy-addr@2.0.7:
+ resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+ engines: {node: '>= 0.10'}
+
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
@@ -5144,6 +5492,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qs@6.15.0:
+ resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
+ engines: {node: '>=0.6'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -5154,6 +5506,14 @@ packages:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
+ range-parser@1.2.1:
+ resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+ engines: {node: '>= 0.6'}
+
+ raw-body@3.0.2:
+ resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+ engines: {node: '>= 0.10'}
+
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -5296,6 +5656,10 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
+ resolve-from@5.0.0:
+ resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
+ engines: {node: '>=8'}
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -5354,6 +5718,10 @@ packages:
roughjs@4.6.6:
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
+ router@2.2.0:
+ resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+ engines: {node: '>= 18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -5425,10 +5793,18 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ send@1.2.1:
+ resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+ engines: {node: '>= 18'}
+
serialize-error@7.0.1:
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
engines: {node: '>=10'}
+ serve-static@2.2.1:
+ resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+ engines: {node: '>= 18'}
+
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
@@ -5533,6 +5909,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
+ source-map@0.7.6:
+ resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
+ engines: {node: '>= 12'}
+
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -5569,6 +5949,10 @@ packages:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'}
+ statuses@1.5.0:
+ resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
+ engines: {node: '>= 0.6'}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -5580,6 +5964,9 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
+ strict-event-emitter-types@2.0.0:
+ resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
+
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -5644,6 +6031,10 @@ packages:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
+ strip-final-newline@4.0.0:
+ resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
+ engines: {node: '>=18'}
+
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -5655,6 +6046,10 @@ packages:
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+ strtok3@10.3.4:
+ resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
+ engines: {node: '>=18'}
+
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
@@ -5762,6 +6157,10 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
+ tldjs@2.3.2:
+ resolution: {integrity: sha512-EORDwFMSZKrHPUVDhejCMDeAovRS5d8jZKiqALFiPp3cjKjEldPkxBY39ZSx3c45awz3RpKwJD1cCgGxEfy8/A==}
+ engines: {node: '>= 20'}
+
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
@@ -5781,6 +6180,14 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ token-types@6.1.2:
+ resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
+ engines: {node: '>=14.16'}
+
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -5809,6 +6216,29 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tsscmp@1.0.6:
+ resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
+ engines: {node: '>=0.6.x'}
+
+ tsup@8.5.1:
+ resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ '@microsoft/api-extractor': ^7.36.0
+ '@swc/core': ^1
+ postcss: ^8.4.12
+ typescript: '>=4.5.0'
+ peerDependenciesMeta:
+ '@microsoft/api-extractor':
+ optional: true
+ '@swc/core':
+ optional: true
+ postcss:
+ optional: true
+ typescript:
+ optional: true
+
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
@@ -5825,6 +6255,10 @@ packages:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}
+ type-is@2.0.1:
+ resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
+ engines: {node: '>= 0.6'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -5861,6 +6295,10 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
+ uint8array-extras@1.5.0:
+ resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
+ engines: {node: '>=18'}
+
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -5874,6 +6312,14 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ undici@7.22.0:
+ resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
+ engines: {node: '>=20.18.1'}
+
+ unicorn-magic@0.3.0:
+ resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
+ engines: {node: '>=18'}
+
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -5919,6 +6365,10 @@ packages:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
+ unpipe@1.0.0:
+ resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+ engines: {node: '>= 0.8'}
+
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
@@ -5931,6 +6381,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ uri-templates@0.2.0:
+ resolution: {integrity: sha512-EWkjYEN0L6KOfEoOH6Wj4ghQqU7eBZMJqRHQnxQAq+dSEzRPClkWjf8557HkWQXF6BrAUoLSAyy9i3RVTliaNg==}
+
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -5966,6 +6419,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
+ vary@1.1.2:
+ resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+ engines: {node: '>= 0.8'}
+
verror@1.10.1:
resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==}
engines: {node: '>=0.6.0'}
@@ -6140,6 +6597,29 @@ packages:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
+ xsschema@0.4.3:
+ resolution: {integrity: sha512-8MFtONewz49S+R5ZfOGfJzEaPAmJ+Gw1ILjvuerYG0mOMVYe7OrsVQNlpiUTct4L3toAJAKD5NMwmCfmTIGYHQ==}
+ peerDependencies:
+ '@valibot/to-json-schema': ^1.0.0
+ arktype: ^2.1.20
+ effect: ^3.16.0
+ sury: ^10.0.0
+ zod: ^3.25.0 || ^4.0.0
+ zod-to-json-schema: ^3.25.0
+ peerDependenciesMeta:
+ '@valibot/to-json-schema':
+ optional: true
+ arktype:
+ optional: true
+ effect:
+ optional: true
+ sury:
+ optional: true
+ zod:
+ optional: true
+ zod-to-json-schema:
+ optional: true
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6163,10 +6643,18 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs-parser@22.0.0:
+ resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
+ yargs@18.0.0:
+ resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
@@ -6188,10 +6676,19 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ yoctocolors@2.1.2:
+ resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
+ engines: {node: '>=18'}
+
zip-stream@4.1.1:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}
+ zod-to-json-schema@3.25.1:
+ resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
+ peerDependencies:
+ zod: ^3.25 || ^4
+
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -6354,6 +6851,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
+ '@borewit/text-codec@0.2.1': {}
+
'@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))':
dependencies:
eslint-import-resolver-node: 0.3.9
@@ -7081,6 +7580,10 @@ snapshots:
'@gar/promisify@1.1.3': {}
+ '@hono/node-server@1.19.11(hono@4.12.5)':
+ dependencies:
+ hono: 4.12.5
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -7272,6 +7775,28 @@ snapshots:
dependencies:
langium: 4.2.1
+ '@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
+ dependencies:
+ '@hono/node-server': 1.19.11(hono@4.12.5)
+ ajv: 8.18.0
+ ajv-formats: 3.0.1(ajv@8.18.0)
+ content-type: 1.0.5
+ cors: 2.8.6
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.0.6
+ express: 5.2.1
+ express-rate-limit: 8.3.0(express@5.2.1)
+ hono: 4.12.5
+ jose: 6.2.0
+ json-schema-typed: 8.0.2
+ pkce-challenge: 5.0.1
+ raw-body: 3.0.2
+ zod: 4.3.6
+ zod-to-json-schema: 3.25.1(zod@4.3.6)
+ transitivePeerDependencies:
+ - supports-color
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@@ -7891,8 +8416,14 @@ snapshots:
'@rtsao/scc@1.1.0': {}
+ '@sec-ant/readable-stream@0.4.1': {}
+
'@sindresorhus/is@4.6.0': {}
+ '@sindresorhus/merge-streams@4.0.0': {}
+
+ '@standard-schema/spec@1.1.0': {}
+
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
@@ -7910,6 +8441,15 @@ snapshots:
'@tanstack/virtual-core@3.13.18': {}
+ '@tokenizer/inflate@0.4.1':
+ dependencies:
+ debug: 4.4.3
+ token-types: 6.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@tokenizer/token@0.3.0': {}
+
'@tootallnate/once@2.0.0': {}
'@tybys/wasm-util@0.10.1':
@@ -8113,6 +8653,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/node@22.19.15':
+ dependencies:
+ undici-types: 6.21.0
+
'@types/node@24.10.12':
dependencies:
undici-types: 7.16.0
@@ -8402,6 +8946,16 @@ snapshots:
abstract-logging@2.0.1: {}
+ accepts@1.3.8:
+ dependencies:
+ mime-types: 2.1.35
+ negotiator: 0.6.3
+
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -8720,6 +9274,14 @@ snapshots:
axe-core@4.11.1: {}
+ axios@1.13.6(debug@4.4.3):
+ dependencies:
+ follow-redirects: 1.15.11(debug@4.4.3)
+ form-data: 4.0.5
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
axobject-query@4.1.0: {}
bail@2.0.2: {}
@@ -8750,6 +9312,20 @@ snapshots:
bluebird@3.7.2: {}
+ body-parser@2.2.2:
+ dependencies:
+ bytes: 3.1.2
+ content-type: 1.0.5
+ debug: 4.4.3
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ on-finished: 2.4.1
+ qs: 6.15.0
+ raw-body: 3.0.2
+ type-is: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
boolean@3.2.0:
optional: true
@@ -8857,6 +9433,11 @@ snapshots:
builtin-modules@3.3.0: {}
+ bundle-require@5.1.0(esbuild@0.27.2):
+ dependencies:
+ esbuild: 0.27.2
+ load-tsconfig: 0.2.5
+
bytes@3.1.2: {}
cac@6.7.14: {}
@@ -9030,6 +9611,12 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
+ cliui@9.0.1:
+ dependencies:
+ string-width: 7.2.0
+ strip-ansi: 7.1.2
+ wrap-ansi: 9.0.2
+
clone-response@1.0.3:
dependencies:
mimic-response: 1.0.1
@@ -9102,19 +9689,37 @@ snapshots:
glob: 10.5.0
typescript: 5.9.3
+ consola@3.4.2: {}
+
console-control-strings@1.1.0: {}
content-disposition@1.0.1: {}
+ content-type@1.0.5: {}
+
convert-source-map@2.0.0: {}
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
cookie@1.1.1: {}
+ cookies@0.9.1:
+ dependencies:
+ depd: 2.0.0
+ keygrip: 1.1.0
+
core-util-is@1.0.2:
optional: true
core-util-is@1.0.3: {}
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
cose-base@1.0.3:
dependencies:
layout-base: 1.0.2
@@ -9379,6 +9984,8 @@ snapshots:
deep-eql@5.0.2: {}
+ deep-equal@1.0.1: {}
+
deep-is@0.1.4: {}
defaults@1.0.4:
@@ -9407,10 +10014,14 @@ snapshots:
delegates@1.0.0: {}
+ depd@1.1.2: {}
+
depd@2.0.0: {}
dequal@2.0.3: {}
+ destroy@1.2.0: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -9491,6 +10102,8 @@ snapshots:
eastasianwidth@0.2.0: {}
+ ee-first@1.1.1: {}
+
ejs@3.1.10:
dependencies:
jake: 10.9.4
@@ -9587,6 +10200,8 @@ snapshots:
emoji-regex@9.2.2: {}
+ encodeurl@2.0.0: {}
+
encoding@0.1.13:
dependencies:
iconv-lite: 0.6.3
@@ -10019,12 +10634,73 @@ snapshots:
esutils@2.0.3: {}
+ etag@1.8.1: {}
+
eventemitter3@5.0.4: {}
+ eventsource-parser@3.0.6: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.0.6
+
+ execa@9.6.1:
+ dependencies:
+ '@sindresorhus/merge-streams': 4.0.0
+ cross-spawn: 7.0.6
+ figures: 6.1.0
+ get-stream: 9.0.1
+ human-signals: 8.0.1
+ is-plain-obj: 4.1.0
+ is-stream: 4.0.1
+ npm-run-path: 6.0.0
+ pretty-ms: 9.3.0
+ signal-exit: 4.1.0
+ strip-final-newline: 4.0.0
+ yoctocolors: 2.1.2
+
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
+ express-rate-limit@8.3.0(express@5.2.1):
+ dependencies:
+ express: 5.2.1
+ ip-address: 10.1.0
+
+ express@5.2.1:
+ dependencies:
+ accepts: 2.0.0
+ body-parser: 2.2.2
+ content-disposition: 1.0.1
+ content-type: 1.0.5
+ cookie: 0.7.2
+ cookie-signature: 1.2.2
+ debug: 4.4.3
+ depd: 2.0.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ finalhandler: 2.1.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ merge-descriptors: 2.0.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ once: 1.4.0
+ parseurl: 1.3.3
+ proxy-addr: 2.0.7
+ qs: 6.15.0
+ range-parser: 1.2.1
+ router: 2.2.0
+ send: 1.2.1
+ serve-static: 2.2.1
+ statuses: 2.0.2
+ type-is: 2.0.1
+ vary: 1.1.2
+ transitivePeerDependencies:
+ - supports-color
+
extend@3.0.2: {}
extract-zip@2.0.1:
@@ -10091,6 +10767,30 @@ snapshots:
semver: 7.7.3
toad-cache: 3.7.0
+ fastmcp@3.34.0:
+ dependencies:
+ '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6)
+ '@standard-schema/spec': 1.1.0
+ execa: 9.6.1
+ file-type: 21.3.0
+ fuse.js: 7.1.0
+ hono: 4.12.5
+ mcp-proxy: 6.4.1
+ strict-event-emitter-types: 2.0.0
+ undici: 7.22.0
+ uri-templates: 0.2.0
+ xsschema: 0.4.3(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6)
+ yargs: 18.0.0
+ zod: 4.3.6
+ zod-to-json-schema: 3.25.1(zod@4.3.6)
+ transitivePeerDependencies:
+ - '@cfworker/json-schema'
+ - '@valibot/to-json-schema'
+ - arktype
+ - effect
+ - supports-color
+ - sury
+
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -10107,10 +10807,23 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ figures@6.1.0:
+ dependencies:
+ is-unicode-supported: 2.1.0
+
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
+ file-type@21.3.0:
+ dependencies:
+ '@tokenizer/inflate': 0.4.1
+ strtok3: 10.3.4
+ token-types: 6.1.2
+ uint8array-extras: 1.5.0
+ transitivePeerDependencies:
+ - supports-color
+
filelist@1.0.4:
dependencies:
minimatch: 5.1.7
@@ -10119,6 +10832,17 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ finalhandler@2.1.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
find-my-way@9.4.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -10130,6 +10854,12 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
+ fix-dts-default-cjs-exports@1.0.1:
+ dependencies:
+ magic-string: 0.30.21
+ mlly: 1.8.0
+ rollup: 4.55.1
+
flat-cache@4.0.1:
dependencies:
flatted: 3.3.3
@@ -10137,6 +10867,10 @@ snapshots:
flatted@3.3.3: {}
+ follow-redirects@1.15.11(debug@4.4.3):
+ optionalDependencies:
+ debug: 4.4.3
+
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
@@ -10158,8 +10892,14 @@ snapshots:
dependencies:
fd-package-json: 2.0.0
+ forwarded@0.2.0: {}
+
fraction.js@5.3.4: {}
+ fresh@0.5.2: {}
+
+ fresh@2.0.0: {}
+
fs-constants@1.0.0: {}
fs-extra@10.1.0:
@@ -10215,6 +10955,8 @@ snapshots:
functions-have-names@1.2.3: {}
+ fuse.js@7.1.0: {}
+
gauge@4.0.4:
dependencies:
aproba: 2.1.0
@@ -10258,6 +11000,11 @@ snapshots:
dependencies:
pump: 3.0.3
+ get-stream@9.0.1:
+ dependencies:
+ '@sec-ant/readable-stream': 0.4.1
+ is-stream: 4.0.1
+
get-symbol-description@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -10478,6 +11225,8 @@ snapshots:
highlight.js@11.11.1: {}
+ hono@4.12.5: {}
+
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
@@ -10488,8 +11237,21 @@ snapshots:
html-void-elements@3.0.0: {}
+ http-assert@1.5.0:
+ dependencies:
+ deep-equal: 1.0.1
+ http-errors: 1.8.1
+
http-cache-semantics@4.2.0: {}
+ http-errors@1.8.1:
+ dependencies:
+ depd: 1.1.2
+ inherits: 2.0.4
+ setprototypeof: 1.2.0
+ statuses: 1.5.0
+ toidentifier: 1.0.1
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -10532,6 +11294,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ human-readable-ids@1.0.4:
+ dependencies:
+ knuth-shuffle: 1.0.8
+
+ human-signals@8.0.1: {}
+
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
@@ -10548,6 +11316,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
+ iconv-lite@0.7.2:
+ dependencies:
+ safer-buffer: 2.1.2
+
idb-keyval@6.2.2: {}
ieee754@1.2.1: {}
@@ -10588,6 +11360,8 @@ snapshots:
ip-address@10.1.0: {}
+ ipaddr.js@1.9.1: {}
+
ipaddr.js@2.3.0: {}
is-alphabetical@2.0.1: {}
@@ -10694,6 +11468,8 @@ snapshots:
is-plain-obj@4.1.0: {}
+ is-promise@4.0.0: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -10707,6 +11483,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
+ is-stream@4.0.1: {}
+
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -10724,6 +11502,8 @@ snapshots:
is-unicode-supported@0.1.0: {}
+ is-unicode-supported@2.1.0: {}
+
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -10795,6 +11575,10 @@ snapshots:
jiti@2.6.1: {}
+ jose@6.2.0: {}
+
+ joycon@3.1.1: {}
+
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -10815,6 +11599,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
+ json-schema-typed@8.0.2: {}
+
json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-safe@5.0.1:
@@ -10849,6 +11635,10 @@ snapshots:
dependencies:
commander: 8.3.0
+ keygrip@1.1.0:
+ dependencies:
+ tsscmp: 1.0.6
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -10872,6 +11662,40 @@ snapshots:
typescript: 5.9.3
zod: 4.3.6
+ knuth-shuffle@1.0.8: {}
+
+ koa-compose@4.1.0: {}
+
+ koa-router@14.0.0:
+ dependencies:
+ debug: 4.4.3
+ http-errors: 2.0.1
+ koa-compose: 4.1.0
+ path-to-regexp: 8.3.0
+ transitivePeerDependencies:
+ - supports-color
+
+ koa@3.1.2:
+ dependencies:
+ accepts: 1.3.8
+ content-disposition: 1.0.1
+ content-type: 1.0.5
+ cookies: 0.9.1
+ delegates: 1.0.0
+ destroy: 1.2.0
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ fresh: 0.5.2
+ http-assert: 1.5.0
+ http-errors: 2.0.1
+ koa-compose: 4.1.0
+ mime-types: 3.0.2
+ on-finished: 2.4.1
+ parseurl: 1.3.3
+ statuses: 2.0.2
+ type-is: 2.0.1
+ vary: 1.1.2
+
langium@4.2.1:
dependencies:
chevrotain: 11.1.2
@@ -10930,6 +11754,8 @@ snapshots:
rfdc: 1.4.1
wrap-ansi: 9.0.2
+ load-tsconfig@0.2.5: {}
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -11064,6 +11890,12 @@ snapshots:
math-intrinsics@1.1.0: {}
+ mcp-proxy@6.4.1:
+ dependencies:
+ pipenet: 1.4.0
+ transitivePeerDependencies:
+ - supports-color
+
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -11217,6 +12049,10 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ media-typer@1.1.0: {}
+
+ merge-descriptors@2.0.0: {}
+
merge2@1.4.1: {}
mermaid@11.12.3:
@@ -11440,10 +12276,16 @@ snapshots:
mime-db@1.52.0: {}
+ mime-db@1.54.0: {}
+
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
+ mime-types@3.0.2:
+ dependencies:
+ mime-db: 1.54.0
+
mime@2.6.0: {}
mime@3.0.0: {}
@@ -11559,6 +12401,8 @@ snapshots:
natural-compare@1.4.0: {}
+ negotiator@0.6.3: {}
+
negotiator@0.6.4: {}
negotiator@1.0.0: {}
@@ -11634,6 +12478,11 @@ snapshots:
normalize-url@6.1.0: {}
+ npm-run-path@6.0.0:
+ dependencies:
+ path-key: 4.0.0
+ unicorn-magic: 0.3.0
+
npmlog@6.0.2:
dependencies:
are-we-there-yet: 3.0.1
@@ -11687,6 +12536,10 @@ snapshots:
on-exit-leak-free@2.1.2: {}
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -11783,10 +12636,14 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
+ parse-ms@4.0.0: {}
+
parse5@7.3.0:
dependencies:
entities: 6.0.1
+ parseurl@1.3.3: {}
+
path-data-parser@0.1.0: {}
path-exists@4.0.0: {}
@@ -11795,6 +12652,8 @@ snapshots:
path-key@3.1.1: {}
+ path-key@4.0.0: {}
+
path-parse@1.0.7: {}
path-scurry@1.11.1:
@@ -11807,6 +12666,8 @@ snapshots:
lru-cache: 11.2.6
minipass: 7.1.2
+ path-to-regexp@8.3.0: {}
+
pathe@2.0.3: {}
pathval@2.0.1: {}
@@ -11845,8 +12706,23 @@ snapshots:
sonic-boom: 4.2.1
thread-stream: 4.0.0
+ pipenet@1.4.0:
+ dependencies:
+ axios: 1.13.6(debug@4.4.3)
+ debug: 4.4.3
+ human-readable-ids: 1.0.4
+ koa: 3.1.2
+ koa-router: 14.0.0
+ pump: 3.0.3
+ tldjs: 2.3.2
+ yargs: 18.0.0
+ transitivePeerDependencies:
+ - supports-color
+
pirates@4.0.7: {}
+ pkce-challenge@5.0.1: {}
+
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -11889,6 +12765,15 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
+ postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2):
+ dependencies:
+ lilconfig: 3.1.3
+ optionalDependencies:
+ jiti: 2.6.1
+ postcss: 8.5.6
+ tsx: 4.21.0
+ yaml: 2.8.2
+
postcss-nested@6.2.0(postcss@8.5.6):
dependencies:
postcss: 8.5.6
@@ -11920,6 +12805,10 @@ snapshots:
prettier@3.8.1: {}
+ pretty-ms@9.3.0:
+ dependencies:
+ parse-ms: 4.0.0
+
proc-log@5.0.0: {}
process-nextick-args@2.0.1: {}
@@ -11945,6 +12834,13 @@ snapshots:
property-information@7.1.0: {}
+ proxy-addr@2.0.7:
+ dependencies:
+ forwarded: 0.2.0
+ ipaddr.js: 1.9.1
+
+ proxy-from-env@1.1.0: {}
+
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
@@ -11952,12 +12848,25 @@ snapshots:
punycode@2.3.1: {}
+ qs@6.15.0:
+ dependencies:
+ side-channel: 1.1.0
+
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
quick-lru@5.1.1: {}
+ range-parser@1.2.1: {}
+
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ unpipe: 1.0.0
+
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -12155,6 +13064,8 @@ snapshots:
resolve-from@4.0.0: {}
+ resolve-from@5.0.0: {}
+
resolve-pkg-maps@1.0.0: {}
resolve@1.22.11:
@@ -12245,6 +13156,16 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
+ router@2.2.0:
+ dependencies:
+ debug: 4.4.3
+ depd: 2.0.0
+ is-promise: 4.0.0
+ parseurl: 1.3.3
+ path-to-regexp: 8.3.0
+ transitivePeerDependencies:
+ - supports-color
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -12313,11 +13234,36 @@ snapshots:
semver@7.7.4: {}
+ send@1.2.1:
+ dependencies:
+ debug: 4.4.3
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ etag: 1.8.1
+ fresh: 2.0.0
+ http-errors: 2.0.1
+ mime-types: 3.0.2
+ ms: 2.1.3
+ on-finished: 2.4.1
+ range-parser: 1.2.1
+ statuses: 2.0.2
+ transitivePeerDependencies:
+ - supports-color
+
serialize-error@7.0.1:
dependencies:
type-fest: 0.13.1
optional: true
+ serve-static@2.2.1:
+ dependencies:
+ encodeurl: 2.0.0
+ escape-html: 1.0.3
+ parseurl: 1.3.3
+ send: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+
set-blocking@2.0.0: {}
set-cookie-parser@2.7.2: {}
@@ -12448,6 +13394,8 @@ snapshots:
source-map@0.6.1: {}
+ source-map@0.7.6: {}
+
space-separated-tokens@2.0.2: {}
split2@4.2.0: {}
@@ -12479,6 +13427,8 @@ snapshots:
stat-mode@1.0.0: {}
+ statuses@1.5.0: {}
+
statuses@2.0.2: {}
std-env@3.10.0: {}
@@ -12488,6 +13438,8 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
+ strict-event-emitter-types@2.0.0: {}
+
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -12586,6 +13538,8 @@ snapshots:
strip-bom@3.0.0: {}
+ strip-final-newline@4.0.0: {}
+
strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {}
@@ -12594,6 +13548,10 @@ snapshots:
dependencies:
js-tokens: 9.0.1
+ strtok3@10.3.4:
+ dependencies:
+ '@tokenizer/token': 0.3.0
+
style-mod@4.1.3: {}
style-to-js@1.1.21:
@@ -12737,6 +13695,10 @@ snapshots:
tinyspy@4.0.4: {}
+ tldjs@2.3.2:
+ dependencies:
+ punycode: 2.3.1
+
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.5
@@ -12751,6 +13713,14 @@ snapshots:
toidentifier@1.0.1: {}
+ token-types@6.1.2:
+ dependencies:
+ '@borewit/text-codec': 0.2.1
+ '@tokenizer/token': 0.3.0
+ ieee754: 1.2.1
+
+ tree-kill@1.2.2: {}
+
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -12776,6 +13746,36 @@ snapshots:
tslib@2.8.1: {}
+ tsscmp@1.0.6: {}
+
+ tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
+ dependencies:
+ bundle-require: 5.1.0(esbuild@0.27.2)
+ cac: 6.7.14
+ chokidar: 4.0.3
+ consola: 3.4.2
+ debug: 4.4.3
+ esbuild: 0.27.2
+ fix-dts-default-cjs-exports: 1.0.1
+ joycon: 3.1.1
+ picocolors: 1.1.1
+ postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
+ resolve-from: 5.0.0
+ rollup: 4.55.1
+ source-map: 0.7.6
+ sucrase: 3.35.1
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.15
+ tree-kill: 1.2.2
+ optionalDependencies:
+ postcss: 8.5.6
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - jiti
+ - supports-color
+ - tsx
+ - yaml
+
tsx@4.21.0:
dependencies:
esbuild: 0.27.2
@@ -12792,6 +13792,12 @@ snapshots:
type-fest@0.13.1:
optional: true
+ type-is@2.0.1:
+ dependencies:
+ content-type: 1.0.5
+ media-typer: 1.1.0
+ mime-types: 3.0.2
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -12843,6 +13849,8 @@ snapshots:
uglify-js@3.19.3:
optional: true
+ uint8array-extras@1.5.0: {}
+
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -12856,6 +13864,10 @@ snapshots:
undici-types@7.16.0: {}
+ undici@7.22.0: {}
+
+ unicorn-magic@0.3.0: {}
+
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -12914,6 +13926,8 @@ snapshots:
universalify@2.0.1: {}
+ unpipe@1.0.0: {}
+
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.4
@@ -12948,6 +13962,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ uri-templates@0.2.0: {}
+
use-callback-ref@1.3.3(@types/react@18.3.27)(react@18.3.1):
dependencies:
react: 18.3.1
@@ -12973,6 +13989,8 @@ snapshots:
uuid@11.1.0: {}
+ vary@1.1.2: {}
+
verror@1.10.1:
dependencies:
assert-plus: 1.0.0
@@ -12995,6 +14013,24 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ vite-node@3.2.4(@types/node@22.19.15)(terser@5.46.0):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3
+ es-module-lexer: 1.7.0
+ pathe: 2.0.3
+ vite: 5.4.21(@types/node@22.19.15)(terser@5.46.0)
+ transitivePeerDependencies:
+ - '@types/node'
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vite-node@3.2.4(@types/node@25.0.7)(terser@5.46.0):
dependencies:
cac: 6.7.14
@@ -13013,6 +14049,16 @@ snapshots:
- supports-color
- terser
+ vite@5.4.21(@types/node@22.19.15)(terser@5.46.0):
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.6
+ rollup: 4.55.1
+ optionalDependencies:
+ '@types/node': 22.19.15
+ fsevents: 2.3.3
+ terser: 5.46.0
+
vite@5.4.21(@types/node@25.0.7)(terser@5.46.0):
dependencies:
esbuild: 0.21.5
@@ -13023,6 +14069,46 @@ snapshots:
fsevents: 2.3.3
terser: 5.46.0
+ vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.0.2)(terser@5.46.0):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/expect': 3.2.4
+ '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)(terser@5.46.0))
+ '@vitest/pretty-format': 3.2.4
+ '@vitest/runner': 3.2.4
+ '@vitest/snapshot': 3.2.4
+ '@vitest/spy': 3.2.4
+ '@vitest/utils': 3.2.4
+ chai: 5.3.3
+ debug: 4.4.3
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.15
+ tinypool: 1.1.1
+ tinyrainbow: 2.0.0
+ vite: 5.4.21(@types/node@22.19.15)(terser@5.46.0)
+ vite-node: 3.2.4(@types/node@22.19.15)(terser@5.46.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/debug': 4.1.12
+ '@types/node': 22.19.15
+ happy-dom: 20.0.2
+ transitivePeerDependencies:
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0):
dependencies:
'@types/chai': 5.2.3
@@ -13176,6 +14262,11 @@ snapshots:
xmlbuilder@15.1.1: {}
+ xsschema@0.4.3(zod-to-json-schema@3.25.1(zod@4.3.6))(zod@4.3.6):
+ optionalDependencies:
+ zod: 4.3.6
+ zod-to-json-schema: 3.25.1(zod@4.3.6)
+
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -13188,6 +14279,8 @@ snapshots:
yargs-parser@21.1.1: {}
+ yargs-parser@22.0.0: {}
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
@@ -13198,6 +14291,15 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
+ yargs@18.0.0:
+ dependencies:
+ cliui: 9.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ string-width: 7.2.0
+ y18n: 5.0.8
+ yargs-parser: 22.0.0
+
yauzl@2.10.0:
dependencies:
buffer-crc32: 0.2.13
@@ -13213,12 +14315,18 @@ snapshots:
yocto-queue@0.1.0: {}
+ yoctocolors@2.1.2: {}
+
zip-stream@4.1.1:
dependencies:
archiver-utils: 3.0.4
compress-commons: 4.1.2
readable-stream: 3.6.2
+ zod-to-json-schema@3.25.1(zod@4.3.6):
+ dependencies:
+ zod: 4.3.6
+
zod-validation-error@4.0.2(zod@4.3.6):
dependencies:
zod: 4.3.6
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c5739b74..0caafff1 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,4 @@
+packages:
+ - mcp-server
ignoredBuiltDependencies:
- esbuild
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 097a8239..ecec4db3 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -1524,7 +1524,7 @@ export class TeamDataService {
const tBlocks = tContent as Record[];
if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
for (const b of tBlocks) {
- if (b.type === 'tool_use' && typeof b.name === 'string') {
+ if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
toolCounts.set(b.name, (toolCounts.get(b.name) ?? 0) + 1);
}
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index ec737a22..f9eadff4 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -2963,7 +2963,11 @@ export class TeamProvisioningService {
// These counts will be attached to the next text message as toolSummary.
if (run.provisioningComplete) {
for (const block of content ?? []) {
- if (block?.type === 'tool_use' && typeof block.name === 'string') {
+ if (
+ block?.type === 'tool_use' &&
+ typeof block.name === 'string' &&
+ block.name !== 'SendMessage'
+ ) {
run.pendingToolCounts.set(
block.name as string,
(run.pendingToolCounts.get(block.name as string) ?? 0) + 1
diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx
index 243a635e..0b5ad6ab 100644
--- a/src/renderer/components/chat/ChatHistory.tsx
+++ b/src/renderer/components/chat/ChatHistory.tsx
@@ -20,6 +20,7 @@ import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
import { ChatHistoryItem } from './ChatHistoryItem';
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
+import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import type { ContextInjection } from '@renderer/types/contextInjection';
/**
@@ -190,6 +191,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
}, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]);
+ const visibleContextPercentLabel = useMemo(() => {
+ const visibleTokens = sumContextInjectionTokens(allContextInjections);
+ return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
+ }, [allContextInjections, lastAiGroupTotalTokens]);
+
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
const [isNavigationHighlight, setIsNavigationHighlight] = useState(false);
const navigationHighlightTimerRef = useRef | null>(null);
@@ -828,7 +834,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
: 'var(--color-text-secondary)',
}}
>
- Context ({allContextInjections.length})
+ {visibleContextPercentLabel ?? `Context (${allContextInjections.length})`}
)}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index 94f1adde..c4dffc6b 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -456,16 +456,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
// Keep lead-session context fresh in the background while the team tab is active.
// This keeps the button value current even when the panel is closed.
+ // For offline teams: fetch once on mount so the percentage shows immediately.
+ // For alive teams: fetch on mount + periodic refresh every 30s.
useEffect(() => {
if (!isThisTabActive) return;
if (!tabId || !projectId || !leadSessionId) return;
+
+ void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
+
if (!data?.isAlive) return;
- const tick = (): void => {
+ const id = window.setInterval(() => {
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
- };
- tick();
- const id = window.setInterval(tick, 30_000);
+ }, 30_000);
return () => window.clearInterval(id);
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index 1a1a19a7..136f3f86 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -326,13 +326,11 @@ export const ActivityTimeline = ({
sessionSeparator = (
-
-
- New session
-
-
+
+
New session
+
);
}
@@ -413,7 +411,7 @@ export const ActivityTimeline = ({
+{hiddenCount} older
-
+
{hiddenCount > MESSAGES_PAGE_SIZE && (
<>
-
+
{summary};
+
+ const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
+
+ return (
+
+
+ {parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
+
+ {sorted.map(([name, count]) => (
+
+ {name}
+ {count}
+
+ ))}
+
+ );
+}
+
export const LeadThoughtsGroupRow = ({
group,
memberColor,
@@ -244,9 +266,16 @@ export const LeadThoughtsGroupRow = ({
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
{totalToolSummary && (
-
- {totalToolSummary}
-
+
+
+
+ {totalToolSummary}
+
+
+
+
+
+
)}
@@ -299,12 +328,19 @@ export const LeadThoughtsGroupRow = ({
{thought.toolSummary && (
-
- 🔧 {thought.toolSummary}
-
+
+
+
+ 🔧 {thought.toolSummary}
+
+
+
+
+
+
)}
))}
From b0211e5e089f4143f9558af9e820080e36ed07bc Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 16:02:33 +0200
Subject: [PATCH 10/14] feat: enhance README and UI components for improved
user experience
- Updated README to clarify that the app runs entirely locally and added a comprehensive table of contents.
- Enhanced the description of the Claude Agent Teams UI to better convey its functionality and target audience.
- Improved the SortableTab component to ensure immediate rendering of team colors based on available data.
- Filtered out team lead members in the TeamDetailView to streamline member display.
- Eagerly patched team data in the state management to improve UI responsiveness and data accuracy.
---
README.md | 131 +++++---
mcp-server/README.md | 301 ++++++++++++++++++
.../components/layout/SortableTab.tsx | 7 +-
.../components/team/TeamDetailView.tsx | 2 +-
src/renderer/store/slices/teamSlice.ts | 20 ++
5 files changed, 418 insertions(+), 43 deletions(-)
create mode 100644 mcp-server/README.md
diff --git a/README.md b/README.md
index 1cb536fe..4c3bb632 100644
--- a/README.md
+++ b/README.md
@@ -14,53 +14,87 @@
- 100% free, open source. No API keys. No configuration.
+ 100% free, open source. No API keys. No configuration. Runs entirely locally.
+## Table of Contents
+
+- [What is this](#what-is-this)
+- [Quick start](#quick-start)
+- [Installation](#installation)
+- [FAQ](#faq)
+- [Development](#development)
+- [Roadmap](#roadmap)
+- [Links](#links)
+- [Contributing](#contributing)
+- [Security](#security)
+- [License](#license)
+
+---
+
## What is this
-A new approach to task management with AI agents.
+**Claude Agent Teams UI** is a desktop app that turns Claude Code's "Orchestrate Teams" feature into a full task management experience. Create agent teams, watch them work on a kanban board, review their code changes, and stay in control — all running locally on your machine.
-- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
-- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
-- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
-- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
-- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
-- **Live process section** — see which agents are running processes and open URLs directly in the browser
-- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
+### Who is this for
+
+- **Developers** who want AI agents to handle tasks in parallel while they oversee progress
+- **Teams** using Claude Code and needing a shared task board, code review workflow, and team messaging
+- **Anyone** who wants to browse and analyze Claude Code session history without running agents
+
+### How it works
+
+1. **Create a team** — Define roles (e.g. lead, frontend, backend) and a provisioning prompt. The app spawns Claude Code sessions as autonomous team members.
+2. **Tasks flow automatically** — Agents create tasks, assign each other, move cards, leave comments, and send messages. You see everything on the kanban board in real time.
+3. **Review like in Cursor** — When a task is done, you see the diff, approve, reject, or request changes. Agents get notified and can fix issues.
+4. **Stay in control** — Send a direct message to any agent, add a comment on a task, or use quick actions on cards whenever you need to clarify or add work.
+
+### Key features
+
+| Feature | Description |
+|---------|-------------|
+| **Kanban board** | Tasks move through columns as agents work. Drag-and-drop, filters, quick actions. |
+| **Code review** | Full diff view per task. Approve, reject, or request changes. Agents get notified. |
+| **Team messaging** | Agents send direct messages. Inbox, notifications, clarification flags. |
+| **Tool visibility** | See exactly which tools agents used (Bash, Read, Edit, etc.) for each task. |
+| **Live processes** | See which agents are running. Open URLs directly in the browser. |
+| **Session analysis** | Deep breakdown of each Claude session: commands, reasoning, subprocesses. |
More features
-- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
-- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
-- **Solo mode** — a one-member team: a single agent (regular claude process) that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time
-- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
-- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states
-- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box
-- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app
-- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree
-- **Team member stats** — global performance statistics for every member of the team
-- **Attach code context** — reference files or code snippets in your messages, just like in Cursor
-- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
+- **Recent tasks across projects** — Browse the latest completed tasks from all your projects in one place
+- **Solo mode** — One-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
+- **Advanced context monitoring** — Token breakdown: user messages, CLAUDE.md, tool outputs, thinking text, team coordination. See usage, context window %, and cost per category
+- **Smart task-to-log matching** — Links Claude session logs to tasks by status change timestamps, even when tasks move back and forth
+- **Zero-setup onboarding** — Built-in Claude Code installation and authentication
+- **Built-in code editor** — Edit project files with Git support without leaving the app
+- **Branch strategy** — Choose via prompt: single branch or git worktree per agent
+- **Team member stats** — Global performance statistics per member
+- **Attach code context** — Reference files or snippets in messages, like in Cursor
+- **Notification system** — Configurable alerts when tasks complete, agents need attention, or errors occur
+- **MCP integration** — Built-in [mcp-server](./mcp-server) for Cursor, Claude Desktop, and other MCP clients. 13 tools for task CRUD, kanban, reviews, and messaging — see [mcp-server/README.md](./mcp-server/README.md)
----
+### Tech stack
-
## Installation
@@ -102,6 +136,8 @@ No prerequisites — Claude Code can be installed and configured directly from t
+**System requirements:** macOS 10.15+, Windows 10+, or Linux (glibc 2.28+). Node.js is not required for the desktop app.
+
---
## FAQ
@@ -115,19 +151,19 @@ No. The app includes built-in installation and authentication — just launch an
Does it read or upload my code?
-No. Everything runs locally on your machine. The app reads Claude Code's session logs from ~/.claude/ — your source code is never sent anywhere.
+No. Everything runs locally. The app reads Claude Code's session logs from ~/.claude/ — your source code is never sent anywhere.
Can agents communicate with each other?
-Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated automatically through Claude Code's team protocol.
+Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated through Claude Code's team protocol.
Is it free?
-Yes, completely free and open source. The app itself requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
+Yes, completely free and open source. The app requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
@@ -139,13 +175,13 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
What happens if an agent gets stuck?
-You can send a direct message to any agent at any time to course-correct, or stop and restart it from the process dashboard. If an agent needs your input, you'll get a notification and the task will be marked with a distinct badge on the board so you won't miss it.
+Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
Can I use it just to view past sessions without running agents?
-Yes. The app works as a session viewer too — browse, search, and analyze any Claude Code session history.
+Yes. The app works as a session viewer — browse, search, and analyze any Claude Code session history.
@@ -172,9 +208,9 @@ pnpm install
pnpm dev
```
-The app auto-discovers your Claude Code projects from `~/.claude/`.
+The app auto-discovers Claude Code projects from `~/.claude/`.
-#### Build for Distribution
+### Build for distribution
```bash
pnpm dist:mac:arm64 # macOS Apple Silicon (.dmg)
@@ -184,32 +220,45 @@ pnpm dist:linux # Linux (AppImage/.deb/.rpm/.pacman)
pnpm dist # macOS + Windows + Linux
```
-#### Scripts
+### Scripts
| Command | Description |
|---------|-------------|
| `pnpm dev` | Development with hot reload |
| `pnpm build` | Production build |
| `pnpm typecheck` | TypeScript type checking |
+| `pnpm lint` | Lint (no auto-fix) |
| `pnpm lint:fix` | Lint and auto-fix |
+| `pnpm format` | Format code with Prettier |
| `pnpm test` | Run all tests |
| `pnpm test:watch` | Watch mode |
| `pnpm test:coverage` | Coverage report |
+| `pnpm test:coverage:critical` | Critical path coverage |
| `pnpm check` | Full quality gate (types + lint + test + build) |
+| `pnpm fix` | Lint fix + format |
+| `pnpm quality` | Full check + format check + knip |
---
-## TODO
+## Roadmap
-- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
+- [ ] CLI runtime: Run in headless/console environments (web UI), e.g. VPS, remote server
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
- [ ] Install skills, MCP, and integrations via an intuitive UI, and only for selected agents
- [ ] Planning mode to organize agent plans before execution
-- [ ] Сurate what context each agent sees (files, docs, MCP servers, skills)
+- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
-- [ ]
+
+---
+
+## Links
+
+- [Homepage](https://github.com/777genius/claude_agent_teams_ui)
+- [Releases](https://github.com/777genius/claude_agent_teams_ui/releases)
+- [Issues](https://github.com/777genius/claude_agent_teams_ui/issues)
+- [MCP Server](./mcp-server) — Use Claude Agent Teams UI tools from Cursor, Claude Desktop, and other MCP clients
---
@@ -223,4 +272,4 @@ IPC handlers validate all inputs with strict path containment checks. File reads
## License
-[MIT](LICENSE)
+[AGPL-3.0](LICENSE)
diff --git a/mcp-server/README.md b/mcp-server/README.md
new file mode 100644
index 00000000..f6b0d7de
--- /dev/null
+++ b/mcp-server/README.md
@@ -0,0 +1,301 @@
+# @claude-team/mcp-server
+
+**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.**
+
+Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app.
+
+---
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui)
+- [Quick start](#quick-start)
+- [Prerequisites](#prerequisites)
+- [Installation](#installation)
+- [Configuration](#configuration)
+- [Tools Reference](#tools-reference)
+- [Data Storage](#data-storage)
+- [Development](#development)
+- [Testing](#testing)
+- [Troubleshooting](#troubleshooting)
+- [License](#license)
+
+---
+
+## Overview
+
+Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which:
+
+- Stores task data as JSON under `~/.claude/tasks/{teamName}/`
+- Stores team config under `~/.claude/teams/{teamName}/`
+- Is installed automatically when you run Claude Agent Teams UI at least once
+
+### Use cases
+
+| Scenario | Description |
+|----------|-------------|
+| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat |
+| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use |
+| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages |
+
+### Architecture
+
+```
+┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐
+│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │
+│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │
+│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │
+└─────────────────────┘ └──────────────────────┘ └─────────────────┘
+```
+
+---
+
+## Relationship to Claude Agent Teams UI
+
+| Component | Role |
+|-----------|------|
+| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. |
+| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. |
+| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. |
+
+Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data.
+
+---
+
+## Quick start
+
+1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`)
+2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build`
+3. Add to Cursor MCP config (`~/.cursor/mcp.json`):
+
+```json
+{
+ "mcpServers": {
+ "claude-team-tools": {
+ "command": "node",
+ "args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
+ }
+ }
+}
+```
+
+4. Restart Cursor. The 13 tools will appear for the AI assistant.
+
+---
+
+## Prerequisites
+
+- **Node.js 20+**
+- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`)
+
+If `teamctl.js` is missing, the server throws at startup:
+
+```
+teamctl.js not found at ~/.claude/tools/teamctl.js.
+Make sure Claude Agent Teams UI has been run at least once,
+or set the TEAMCTL_PATH environment variable.
+```
+
+---
+
+## Installation
+
+### From the monorepo (development)
+
+```bash
+cd mcp-server
+pnpm install
+pnpm build
+```
+
+### As a dependency
+
+```bash
+pnpm add @claude-team/mcp-server
+# or
+npm install @claude-team/mcp-server
+```
+
+---
+
+## Configuration
+
+### Cursor
+
+Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
+
+```json
+{
+ "mcpServers": {
+ "claude-team-tools": {
+ "command": "node",
+ "args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
+ }
+ }
+}
+```
+
+With global install (`pnpm link` or `npm link`):
+
+```json
+{
+ "mcpServers": {
+ "claude-team-tools": {
+ "command": "team-mcp-server"
+ }
+ }
+}
+```
+
+### Claude Desktop
+
+Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent:
+
+```json
+{
+ "mcpServers": {
+ "claude-team-tools": {
+ "command": "node",
+ "args": ["/absolute/path/to/mcp-server/dist/index.js"]
+ }
+ }
+}
+```
+
+### Environment variables
+
+| Variable | Description |
+|----------|-------------|
+| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` |
+
+---
+
+## Tools Reference
+
+All tools require a `team` parameter (team name, folder under `~/.claude/teams/`).
+
+### Task CRUD
+
+| Tool | Description |
+|------|-------------|
+| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` |
+| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) |
+| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) |
+| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals |
+| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` |
+
+### Task collaboration
+
+| Tool | Description |
+|------|-------------|
+| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` |
+| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected |
+| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board |
+| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB |
+
+### Kanban board
+
+| Tool | Description |
+|------|-------------|
+| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` |
+| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` |
+
+### Code review
+
+| Tool | Description |
+|------|-------------|
+| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) |
+
+### Messaging
+
+| Tool | Description |
+|------|-------------|
+| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications |
+
+---
+
+## Data Storage
+
+| Location | Contents |
+|----------|----------|
+| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) |
+| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes |
+
+Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes).
+
+---
+
+## Development
+
+### Scripts
+
+| Command | Description |
+|---------|-------------|
+| `pnpm build` | Build with tsup → `dist/index.js` |
+| `pnpm dev` | Run with tsx (no build) |
+| `pnpm test` | Run Vitest tests |
+| `pnpm test:watch` | Watch mode |
+| `pnpm typecheck` | TypeScript check |
+
+### Project structure
+
+```
+mcp-server/
+├── src/
+│ ├── index.ts # FastMCP server, registers all tools
+│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess
+│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout
+│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.)
+│ └── tools/
+│ ├── index.ts # registerAllTools()
+│ ├── task-create.ts
+│ ├── task-get.ts
+│ ├── ...
+│ └── message-send.ts
+├── test/
+│ ├── tools/ # Per-tool tests
+│ ├── teamctl-runner.test.ts
+│ ├── output-parser.test.ts
+│ └── schemas.test.ts
+├── package.json
+├── tsup.config.ts
+└── vitest.config.ts
+```
+
+### Adding a new tool
+
+1. Create `src/tools/your-tool.ts` with `register(server, runner)`
+2. Add to `ALL_TOOLS` in `src/tools/index.ts`
+3. Add Zod parameters and map to `teamctl` CLI args
+4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts`
+5. Add tests in `test/tools/your-tool.test.ts`
+
+---
+
+## Testing
+
+Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required):
+
+```bash
+pnpm test
+```
+
+For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`.
+
+---
+
+## Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` |
+| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes |
+| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it |
+| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) |
+
+---
+
+## License
+
+Same as the parent project: [AGPL-3.0](../LICENSE).
diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx
index 2f2e3f64..7fafcfd5 100644
--- a/src/renderer/components/layout/SortableTab.tsx
+++ b/src/renderer/components/layout/SortableTab.tsx
@@ -70,7 +70,12 @@ export const SortableTab = ({
const teamColor = useStore((s) => {
if (tab.type !== 'team' || !tab.teamName) return null;
const team = s.teamByName[tab.teamName];
- return team?.color ?? null;
+ if (team?.color) return team.color;
+ // Fallback: selectedTeamData may be available before teamByName is populated
+ if (s.selectedTeamName === tab.teamName && s.selectedTeamData?.config.color) {
+ return s.selectedTeamData.config.color;
+ }
+ return null;
});
const teamColorSet = teamColor ? getTeamColorSet(teamColor) : null;
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index c4dffc6b..f80056e7 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -1662,7 +1662,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
currentName={data.config.name}
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
- currentMembers={data.members}
+ currentMembers={data.members.filter((m) => m.agentType !== 'team-lead')}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 1ca674fc..c00da33a 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -602,6 +602,26 @@ export const createTeamSlice: StateCreator = (set,
if (get().selectedTeamName !== teamName) {
return;
}
+ // Eagerly patch teamByName with color/displayName from detailed data
+ // so that tab color renders immediately without waiting for fetchTeams()
+ const prevByName = get().teamByName;
+ const existingEntry = prevByName[teamName];
+ const configColor = data.config.color;
+ if (configColor && (!existingEntry || existingEntry.color !== configColor)) {
+ const patched: TeamSummary = existingEntry
+ ? { ...existingEntry, color: configColor, displayName: data.config.name || teamName }
+ : {
+ teamName,
+ displayName: data.config.name || teamName,
+ description: data.config.description ?? '',
+ color: configColor,
+ memberCount: data.members.length,
+ taskCount: 0,
+ lastActivity: null,
+ };
+ set({ teamByName: { ...prevByName, [teamName]: patched } });
+ }
+
set({
selectedTeamName: teamName,
selectedTeamData: data,
From ff5e877023a6787745c50d17206b940c24cb9f24 Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 16:15:19 +0200
Subject: [PATCH 11/14] feat: enhance tool usage tracking and UI components
across services
- Refactored TeamDataService and TeamProvisioningService to replace tool usage counts with structured tool call details, improving accuracy and clarity in tool tracking.
- Introduced ToolCallMeta type for better representation of tool call metadata, including name and preview.
- Updated tool summary generation to utilize new tool call details, enhancing the visibility of tool interactions in messages.
- Enhanced LeadThoughtsGroupRow to aggregate tool calls for improved tooltip display, providing users with clearer insights into tool usage.
- Modified UI components to accommodate changes in tool summary handling, ensuring a consistent user experience.
---
src/main/services/team/TeamDataService.ts | 17 ++--
.../services/team/TeamProvisioningService.ts | 36 ++++----
.../services/team/TeamSentMessagesStore.ts | 13 +++
.../components/layout/SortableTab.tsx | 17 ++--
src/renderer/components/team/TeamListView.tsx | 16 ++--
.../team/activity/LeadThoughtsGroup.tsx | 84 ++++++++++++++-----
src/shared/types/team.ts | 10 +++
src/shared/utils/toolSummary.ts | 62 ++++++++++++++
8 files changed, 197 insertions(+), 58 deletions(-)
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index ecec4db3..7fa3fda3 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -17,7 +17,7 @@ import {
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
-import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
+import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@@ -60,6 +60,7 @@ import type {
TeamTaskStatus,
TeamTaskWithKanban,
UpdateKanbanPatch,
+ ToolCallMeta,
} from '@shared/types';
const logger = createLogger('Service:TeamDataService');
@@ -1504,9 +1505,9 @@ export class TeamDataService {
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
- // Count tool_use blocks from following lines (text and tool_use are separate in JSONL).
+ // Collect tool_use details from following lines (text and tool_use are separate in JSONL).
// tool_result (type=user) lines are interleaved between tool_use lines — skip them.
- const toolCounts = new Map();
+ const toolCallsList: ToolCallMeta[] = [];
const lookaheadLimit = Math.min(i + 200, lines.length);
for (let j = i + 1; j < lookaheadLimit; j++) {
const tLine = lines[j]?.trim();
@@ -1525,11 +1526,16 @@ export class TeamDataService {
if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
for (const b of tBlocks) {
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
- toolCounts.set(b.name, (toolCounts.get(b.name) ?? 0) + 1);
+ const input = (b.input ?? {}) as Record;
+ toolCallsList.push({
+ name: b.name as string,
+ preview: extractToolPreview(b.name as string, input),
+ });
}
}
}
- const toolSummary = formatToolSummaryFromMap(toolCounts);
+ const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
+ const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
const textPrefix = combined
@@ -1550,6 +1556,7 @@ export class TeamDataService {
leadSessionId: config.leadSessionId,
messageId,
toolSummary,
+ toolCalls,
});
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index f9eadff4..81038ae3 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -21,7 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
-import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
+import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
@@ -50,6 +50,7 @@ import type {
TeamProvisioningProgress,
TeamProvisioningState,
TeamTask,
+ ToolCallMeta,
} from '@shared/types';
const logger = createLogger('Service:TeamProvisioning');
@@ -164,8 +165,8 @@ interface ProvisioningRun {
} | null;
/** Monotonic counter for individual lead assistant messages. */
leadMsgSeq: number;
- /** Accumulated tool_use counts between text messages (tool name → count). */
- pendingToolCounts: Map;
+ /** Accumulated tool_use details between text messages. */
+ pendingToolCalls: ToolCallMeta[];
/** Throttle timestamp for emitting inbox refresh events for lead text. */
lastLeadTextEmitMs: number;
/**
@@ -1747,7 +1748,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_config',
leadRelayCapture: null,
leadMsgSeq: 0,
- pendingToolCounts: new Map(),
+ pendingToolCalls: [],
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@@ -2047,7 +2048,7 @@ export class TeamProvisioningService {
fsPhase: 'waiting_members',
leadRelayCapture: null,
leadMsgSeq: 0,
- pendingToolCounts: new Map(),
+ pendingToolCalls: [],
lastLeadTextEmitMs: 0,
silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
@@ -2924,12 +2925,11 @@ export class TeamProvisioningService {
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
- // Attach accumulated tool counts from preceding tool_use messages, then reset.
- const toolSummary =
- run.pendingToolCounts.size > 0
- ? formatToolSummaryFromMap(run.pendingToolCounts)
- : undefined;
- run.pendingToolCounts.clear();
+ // Attach accumulated tool call details from preceding tool_use messages, then reset.
+ const toolCalls =
+ run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
+ const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
+ run.pendingToolCalls = [];
const leadMsg: InboxMessage = {
from: leadName,
text: cleanText,
@@ -2939,6 +2939,7 @@ export class TeamProvisioningService {
messageId,
source: 'lead_process',
toolSummary,
+ toolCalls,
};
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
@@ -2959,8 +2960,8 @@ export class TeamProvisioningService {
}
}
- // Accumulate tool_use counts from tool-only messages (text + tool_use are separate in stream-json).
- // These counts will be attached to the next text message as toolSummary.
+ // Accumulate tool_use details from tool-only messages (text + tool_use are separate in stream-json).
+ // These details will be attached to the next text message as toolCalls/toolSummary.
if (run.provisioningComplete) {
for (const block of content ?? []) {
if (
@@ -2968,10 +2969,11 @@ export class TeamProvisioningService {
typeof block.name === 'string' &&
block.name !== 'SendMessage'
) {
- run.pendingToolCounts.set(
- block.name as string,
- (run.pendingToolCounts.get(block.name as string) ?? 0) + 1
- );
+ const input = (block.input ?? {}) as Record;
+ run.pendingToolCalls.push({
+ name: block.name as string,
+ preview: extractToolPreview(block.name as string, input),
+ });
}
}
}
diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts
index 04a61528..6e7e5bd4 100644
--- a/src/main/services/team/TeamSentMessagesStore.ts
+++ b/src/main/services/team/TeamSentMessagesStore.ts
@@ -77,6 +77,19 @@ export class TeamSentMessagesStore {
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
+ toolCalls: Array.isArray(row.toolCalls)
+ ? (row.toolCalls as unknown[])
+ .filter(
+ (tc): tc is { name: string; preview?: string } =>
+ tc != null &&
+ typeof tc === 'object' &&
+ typeof (tc as Record).name === 'string'
+ )
+ .map((tc) => ({
+ name: tc.name,
+ preview: typeof tc.preview === 'string' ? tc.preview : undefined,
+ }))
+ : undefined,
});
}
diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx
index 7fafcfd5..b6cb0345 100644
--- a/src/renderer/components/layout/SortableTab.tsx
+++ b/src/renderer/components/layout/SortableTab.tsx
@@ -8,6 +8,7 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { getTeamColorSet } from '@renderer/constants/teamColors';
+import { nameColorSet } from '@renderer/utils/projectColor';
import { useStore } from '@renderer/store';
import {
Activity,
@@ -67,17 +68,17 @@ export const SortableTab = ({
)
);
- const teamColor = useStore((s) => {
+ const teamColorSet = useStore((s) => {
if (tab.type !== 'team' || !tab.teamName) return null;
const team = s.teamByName[tab.teamName];
- if (team?.color) return team.color;
- // Fallback: selectedTeamData may be available before teamByName is populated
- if (s.selectedTeamName === tab.teamName && s.selectedTeamData?.config.color) {
- return s.selectedTeamData.config.color;
- }
- return null;
+ const explicitColor =
+ team?.color ??
+ (s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
+ if (explicitColor) return getTeamColorSet(explicitColor);
+ // Fallback: deterministic color derived from display name
+ const displayName = team?.displayName ?? tab.label;
+ return nameColorSet(displayName);
});
- const teamColorSet = teamColor ? getTeamColorSet(teamColor) : null;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx
index 7524b49d..9633d7af 100644
--- a/src/renderer/components/team/TeamListView.tsx
+++ b/src/renderer/components/team/TeamListView.tsx
@@ -561,9 +561,7 @@ export const TeamListView = (): React.JSX.Element => {
void fetchTeams();
}}
>
- {teamsLoading ? (
-
- ) : null}
+ {teamsLoading ? : null}
Refresh
@@ -671,7 +669,7 @@ export const TeamListView = (): React.JSX.Element => {
key={team.teamName}
role="button"
tabIndex={0}
- className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
+ className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
@@ -695,7 +693,11 @@ export const TeamListView = (): React.JSX.Element => {
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
-
+
@@ -779,6 +781,8 @@ export const TeamListView = (): React.JSX.Element => {
Members: {team.memberCount}
)}
+
+
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
@@ -831,8 +835,8 @@ export const TeamListView = (): React.JSX.Element => {
);
})()}
+ {renderTeamRecentPaths(team, status)}
- {renderTeamRecentPaths(team, status)}
);
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index 0578c3df..fe0d2540 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
-import type { InboxMessage } from '@shared/types';
+import type { InboxMessage, ToolCallMeta } from '@shared/types';
export interface LeadThoughtGroup {
type: 'lead-thoughts';
@@ -102,25 +102,50 @@ function isRecentTimestamp(timestamp: string): boolean {
return Date.now() - t <= LIVE_WINDOW_MS;
}
-function ToolSummaryTooltipContent({ summary }: { summary: string }): JSX.Element {
- const parsed = parseToolSummary(summary);
- if (!parsed) return {summary} ;
-
- const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
-
- return (
-
-
- {parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
-
- {sorted.map(([name, count]) => (
-
-
{name}
-
{count}
+function ToolSummaryTooltipContent({
+ toolCalls,
+ toolSummary,
+}: {
+ toolCalls?: ToolCallMeta[];
+ toolSummary?: string;
+}): JSX.Element {
+ if (toolCalls && toolCalls.length > 0) {
+ return (
+
+
+ {toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
- ))}
-
- );
+ {toolCalls.map((tc, i) => (
+
+ {tc.name}
+ {tc.preview && {tc.preview} }
+
+ ))}
+
+ );
+ }
+
+ if (toolSummary) {
+ const parsed = parseToolSummary(toolSummary);
+ if (parsed) {
+ const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
+ return (
+
+
+ {parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
+
+ {sorted.map(([name, count]) => (
+
+ {name}
+ ×{count}
+
+ ))}
+
+ );
+ }
+ }
+
+ return
{toolSummary ?? ''} ;
}
export const LeadThoughtsGroupRow = ({
@@ -170,6 +195,15 @@ export const LeadThoughtsGroupRow = ({
return formatToolSummary({ total, byName: merged });
}, [thoughts]);
+ // Aggregate all toolCalls across thoughts for header tooltip
+ const allToolCalls = useMemo(() => {
+ const calls: ToolCallMeta[] = [];
+ for (const t of thoughts) {
+ if (t.toolCalls) calls.push(...t.toolCalls);
+ }
+ return calls.length > 0 ? calls : undefined;
+ }, [thoughts]);
+
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
const computeIsLive = useCallback(
() =>
@@ -273,7 +307,10 @@ export const LeadThoughtsGroupRow = ({
-
+
)}
@@ -337,8 +374,11 @@ export const LeadThoughtsGroupRow = ({
🔧 {thought.toolSummary}
-
-
+
+
)}
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index b60a159f..660eafb4 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -186,6 +186,14 @@ export interface AttachmentFileData {
mimeType: AttachmentMediaType;
}
+/** Lightweight metadata for a single tool call (for UI display in tooltips). */
+export interface ToolCallMeta {
+ /** Tool name, e.g. "Read", "Bash", "Grep" */
+ name: string;
+ /** Human-readable preview extracted from input args, e.g. "index.ts", "grep -r foo" */
+ preview?: string;
+}
+
export interface InboxMessage {
from: string;
to?: string;
@@ -201,6 +209,8 @@ export interface InboxMessage {
leadSessionId?: string;
/** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */
toolSummary?: string;
+ /** Structured tool call details for tooltip display. */
+ toolCalls?: ToolCallMeta[];
}
export interface SendMessageRequest {
diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts
index ef50bfbc..df3c72be 100644
--- a/src/shared/utils/toolSummary.ts
+++ b/src/shared/utils/toolSummary.ts
@@ -1,3 +1,5 @@
+import type { ToolCallMeta } from '@shared/types';
+
export interface ToolSummaryData {
total: number;
byName: Record;
@@ -55,3 +57,63 @@ export function formatToolSummaryFromMap(counts: Map): string |
.join(', ');
return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
}
+
+/** Format tool summary from an array of ToolCallMeta. */
+export function formatToolSummaryFromCalls(calls: ToolCallMeta[]): string | undefined {
+ if (calls.length === 0) return undefined;
+ const counts = new Map();
+ for (const c of calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1);
+ return formatToolSummaryFromMap(counts);
+}
+
+function baseName(filePath: string): string {
+ return filePath.split(/[/\\]/).pop() ?? filePath;
+}
+
+function truncateStr(str: string, max: number): string {
+ return str.length <= max ? str : str.slice(0, max) + '...';
+}
+
+/** Extract a short human-readable preview from tool_use input arguments. */
+export function extractToolPreview(
+ name: string,
+ input: Record
+): string | undefined {
+ switch (name) {
+ case 'Read':
+ case 'Edit':
+ case 'Write':
+ return typeof input.file_path === 'string' ? baseName(input.file_path) : undefined;
+ case 'Bash':
+ return typeof input.description === 'string'
+ ? truncateStr(input.description, 60)
+ : typeof input.command === 'string'
+ ? truncateStr(input.command, 60)
+ : undefined;
+ case 'Grep':
+ case 'Glob':
+ return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
+ case 'Agent':
+ case 'TaskCreate':
+ return typeof input.prompt === 'string'
+ ? truncateStr(input.prompt, 60)
+ : typeof input.description === 'string'
+ ? truncateStr(input.description, 60)
+ : undefined;
+ case 'WebFetch':
+ if (typeof input.url === 'string') {
+ try {
+ return new URL(input.url).hostname;
+ } catch {
+ return truncateStr(input.url, 40);
+ }
+ }
+ return undefined;
+ case 'WebSearch':
+ return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined;
+ default: {
+ const v = input.name ?? input.path ?? input.file ?? input.query ?? input.command;
+ return typeof v === 'string' ? truncateStr(v, 50) : undefined;
+ }
+ }
+}
From f61077d4ee776a708b9547ceb4205fceafe7349e Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 16:21:27 +0200
Subject: [PATCH 12/14] feat: enhance LeadThoughtsGroup UI and tool preview
handling
- Updated LeadThoughtsGroup component to visually distinguish 'Agent' and 'TaskCreate' tool calls with specific styling and icons.
- Increased the character limit for tool previews from 60 to 200 in the extractToolPreview function, allowing for more detailed descriptions.
- Improved overall tooltip display for better user insights into tool usage.
---
.../team/activity/LeadThoughtsGroup.tsx | 24 ++++++++++++++-----
src/shared/utils/toolSummary.ts | 4 ++--
2 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index fe0d2540..cc42e2e9 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -115,12 +115,24 @@ function ToolSummaryTooltipContent({
{toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
- {toolCalls.map((tc, i) => (
-
- {tc.name}
- {tc.preview && {tc.preview} }
-
- ))}
+ {toolCalls.map((tc, i) => {
+ const isAgent = tc.name === 'Agent' || tc.name === 'TaskCreate';
+ return (
+
+
+ {isAgent ? '🤖 ' : ''}
+ {tc.name}
+
+ {tc.preview && (
+
+ {tc.preview}
+
+ )}
+
+ );
+ })}
);
}
diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts
index df3c72be..8f8c3ce2 100644
--- a/src/shared/utils/toolSummary.ts
+++ b/src/shared/utils/toolSummary.ts
@@ -96,9 +96,9 @@ export function extractToolPreview(
case 'Agent':
case 'TaskCreate':
return typeof input.prompt === 'string'
- ? truncateStr(input.prompt, 60)
+ ? truncateStr(input.prompt, 200)
: typeof input.description === 'string'
- ? truncateStr(input.description, 60)
+ ? truncateStr(input.description, 200)
: undefined;
case 'WebFetch':
if (typeof input.url === 'string') {
From baabae0594f228ea951975b89a53fdc2e69bd370 Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 16:36:37 +0200
Subject: [PATCH 13/14] fix: resolve all ESLint errors breaking CI validate job
- Fix import sort order in 6 files (TeamDataService, TeamProvisioningService,
ChatHistory, SortableTab, LeadThoughtsGroup, MemberDraftRow)
- Replace slow regex with string manipulation in MemberStatsComputer
- Use RegExp.exec() instead of String.match() in toolSummary
- Fix non-backtracking regex pattern in toolSummary parser
- Remove unnecessary type assertions in TeamDataService and TeamProvisioningService
- Convert for-loop to for-of in TeamDataService
- Fix no-param-reassign in TeamDataService.sendMessage
- Convert function component to arrow function in LeadThoughtsGroup
- Fix nested functions depth in MentionableTextarea tip rotation
- Use optional chain in teamSlice
---
src/main/services/team/MemberStatsComputer.ts | 12 ++++++---
src/main/services/team/TeamDataService.ts | 21 ++++++++-------
.../services/team/TeamProvisioningService.ts | 6 ++---
src/renderer/components/chat/ChatHistory.tsx | 3 ++-
.../components/layout/SortableTab.tsx | 2 +-
.../team/activity/LeadThoughtsGroup.tsx | 26 +++++++++++--------
.../team/members/MemberDraftRow.tsx | 2 +-
.../components/ui/MentionableTextarea.tsx | 12 +++++----
src/renderer/store/slices/teamSlice.ts | 2 +-
src/shared/utils/toolSummary.ts | 8 +++---
10 files changed, 54 insertions(+), 40 deletions(-)
diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts
index 738cd80e..96ce9994 100644
--- a/src/main/services/team/MemberStatsComputer.ts
+++ b/src/main/services/team/MemberStatsComputer.ts
@@ -9,11 +9,17 @@ import type { FileLineStats, MemberFullStats } from '@shared/types';
const logger = createLogger('Service:MemberStatsComputer');
-const TRAILING_PUNCT = /[;.,]+$/;
+const TRAILING_PUNCT_CHARS = new Set([';', '.', ',']);
const INVALID_NAMES = new Set(['null', 'undefined', 'None', 'false', 'true', '']);
+function stripTrailingPunct(s: string): string {
+ let end = s.length;
+ while (end > 0 && TRAILING_PUNCT_CHARS.has(s[end - 1])) end--;
+ return end === s.length ? s : s.slice(0, end);
+}
+
export function isValidFilePath(value: string): boolean {
- const cleaned = value.trim().replace(TRAILING_PUNCT, '');
+ const cleaned = stripTrailingPunct(value.trim());
return cleaned.length > 1 && !INVALID_NAMES.has(cleaned) && cleaned.includes('/');
}
@@ -133,7 +139,7 @@ export class MemberStatsComputer {
// Track last known content per file for accurate Write/NotebookEdit diffs
const fileLastContent = new Map();
- const cleanPath = (fp: string): string => fp.trim().replace(TRAILING_PUNCT, '');
+ const cleanPath = (fp: string): string => stripTrailingPunct(fp.trim());
const trackFile = (fp: string): void => {
if (typeof fp === 'string') {
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 7fa3fda3..5b4d3ede 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -59,8 +59,8 @@ import type {
TeamTask,
TeamTaskStatus,
TeamTaskWithKanban,
- UpdateKanbanPatch,
ToolCallMeta,
+ UpdateKanbanPatch,
} from '@shared/types';
const logger = createLogger('Service:TeamDataService');
@@ -344,12 +344,12 @@ export class TeamDataService {
// Find closest anchor by timestamp (binary-search-like scan from current position)
let bestAnchor = anchors[0];
let bestDist = Math.abs(msgTime - bestAnchor.time);
- for (let a = 0; a < anchors.length; a++) {
- const dist = Math.abs(msgTime - anchors[a].time);
+ for (const anchor of anchors) {
+ const dist = Math.abs(msgTime - anchor.time);
if (dist < bestDist) {
bestDist = dist;
- bestAnchor = anchors[a];
- } else if (dist > bestDist && anchors[a].time > msgTime) {
+ bestAnchor = anchor;
+ } else if (dist > bestDist && anchor.time > msgTime) {
// Anchors are sorted by index (asc time) — once distance grows past the
// message time, further anchors will only be farther.
break;
@@ -1161,17 +1161,18 @@ export class TeamDataService {
async sendMessage(teamName: string, request: SendMessageRequest): Promise {
// Enrich with leadSessionId so session boundary separators work
- if (!request.leadSessionId) {
+ let enrichedRequest = request;
+ if (!enrichedRequest.leadSessionId) {
try {
const config = await this.configReader.getConfig(teamName);
if (config?.leadSessionId) {
- request = { ...request, leadSessionId: config.leadSessionId };
+ enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
}
} catch {
// non-critical
}
}
- return this.inboxWriter.sendMessage(teamName, request);
+ return this.inboxWriter.sendMessage(teamName, enrichedRequest);
}
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
@@ -1528,8 +1529,8 @@ export class TeamDataService {
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
const input = (b.input ?? {}) as Record;
toolCallsList.push({
- name: b.name as string,
- preview: extractToolPreview(b.name as string, input),
+ name: b.name,
+ preview: extractToolPreview(b.name, input),
});
}
}
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 81038ae3..e80a3342 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -21,8 +21,8 @@ import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
-import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
+import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
@@ -2971,8 +2971,8 @@ export class TeamProvisioningService {
) {
const input = (block.input ?? {}) as Record;
run.pendingToolCalls.push({
- name: block.name as string,
- preview: extractToolPreview(block.name as string, input),
+ name: block.name,
+ preview: extractToolPreview(block.name, input),
});
}
}
diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx
index 0b5ad6ab..20a6f69a 100644
--- a/src/renderer/components/chat/ChatHistory.tsx
+++ b/src/renderer/components/chat/ChatHistory.tsx
@@ -16,11 +16,12 @@ const SCROLL_THRESHOLD = 300;
/** Must match the `w-80` (320px) context panel width used in the layout below. */
const CONTEXT_PANEL_WIDTH_PX = 320;
+import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
+
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
import { ChatHistoryItem } from './ChatHistoryItem';
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
-import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
import type { ContextInjection } from '@renderer/types/contextInjection';
/**
diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx
index b6cb0345..499849f4 100644
--- a/src/renderer/components/layout/SortableTab.tsx
+++ b/src/renderer/components/layout/SortableTab.tsx
@@ -8,8 +8,8 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { getTeamColorSet } from '@renderer/constants/teamColors';
-import { nameColorSet } from '@renderer/utils/projectColor';
import { useStore } from '@renderer/store';
+import { nameColorSet } from '@renderer/utils/projectColor';
import {
Activity,
Bell,
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index cc42e2e9..67f41d31 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
CARD_BG,
CARD_BG_ZEBRA,
@@ -10,7 +11,6 @@ import {
CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
@@ -102,13 +102,13 @@ function isRecentTimestamp(timestamp: string): boolean {
return Date.now() - t <= LIVE_WINDOW_MS;
}
-function ToolSummaryTooltipContent({
+const ToolSummaryTooltipContent = ({
toolCalls,
toolSummary,
-}: {
+}: Readonly<{
toolCalls?: ToolCallMeta[];
toolSummary?: string;
-}): JSX.Element {
+}>): JSX.Element => {
if (toolCalls && toolCalls.length > 0) {
return (
@@ -118,14 +118,14 @@ function ToolSummaryTooltipContent({
{toolCalls.map((tc, i) => {
const isAgent = tc.name === 'Agent' || tc.name === 'TaskCreate';
return (
-
+
{isAgent ? '🤖 ' : ''}
{tc.name}
{tc.preview && (
{tc.preview}
@@ -158,7 +158,7 @@ function ToolSummaryTooltipContent({
}
return
{toolSummary ?? ''} ;
-}
+};
export const LeadThoughtsGroupRow = ({
group,
@@ -268,7 +268,7 @@ export const LeadThoughtsGroupRow = ({
});
observer.observe(el);
return () => observer.disconnect();
- }, []); // eslint-disable-line react-hooks/exhaustive-deps -- scrollRef is stable
+ }, []);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
@@ -318,7 +318,7 @@ export const LeadThoughtsGroupRow = ({
{totalToolSummary}
-
+
(
{idx > 0 && (
-
+
-
+
{
+ setTipIndex((prev) => (prev + 1) % rotatingTips.length);
+ setTipVisible(true);
+ }, [rotatingTips.length]);
+
React.useEffect(() => {
const interval = setInterval(() => {
setTipVisible(false);
- setTimeout(() => {
- setTipIndex((prev) => (prev + 1) % rotatingTips.length);
- setTipVisible(true);
- }, 300);
+ setTimeout(advanceTip, 300);
}, 10000);
return () => clearInterval(interval);
- }, [rotatingTips.length]);
+ }, [advanceTip]);
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
const showHintRow = showHint && (suggestions.length > 0 || enableFiles);
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index c00da33a..204f0fe2 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -607,7 +607,7 @@ export const createTeamSlice: StateCreator = (set,
const prevByName = get().teamByName;
const existingEntry = prevByName[teamName];
const configColor = data.config.color;
- if (configColor && (!existingEntry || existingEntry.color !== configColor)) {
+ if (configColor && (!existingEntry || existingEntry?.color !== configColor)) {
const patched: TeamSummary = existingEntry
? { ...existingEntry, color: configColor, displayName: data.config.name || teamName }
: {
diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts
index 8f8c3ce2..72ee83be 100644
--- a/src/shared/utils/toolSummary.ts
+++ b/src/shared/utils/toolSummary.ts
@@ -27,11 +27,11 @@ export function buildToolSummary(content: Record[]): string | u
export function parseToolSummary(summary: string | undefined): ToolSummaryData | null {
if (!summary) return null;
- const match = summary.match(/^(\d+)\s+tools?\s+\(([^)]+)\)$/);
+ const match = /^(\d+)\s+tools?\s+\(([^)]+)\)$/.exec(summary);
if (!match) return null;
const byName: Record = {};
for (const part of match[2].split(', ')) {
- const m = part.match(/^(\d+)\s+(.+)$/);
+ const m = /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part);
if (m) {
byName[m[2]] = parseInt(m[1], 10);
} else {
@@ -96,9 +96,9 @@ export function extractToolPreview(
case 'Agent':
case 'TaskCreate':
return typeof input.prompt === 'string'
- ? truncateStr(input.prompt, 200)
+ ? input.prompt
: typeof input.description === 'string'
- ? truncateStr(input.description, 200)
+ ? input.description
: undefined;
case 'WebFetch':
if (typeof input.url === 'string') {
From 795e1248aac126f215f288176a981ab7bc299d81 Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 6 Mar 2026 17:24:52 +0200
Subject: [PATCH 14/14] fix: replace ResizeObserver with useEffect for
auto-scroll in LeadThoughtsGroup
ResizeObserver stops firing once scroll container reaches maxHeight,
breaking auto-scroll for long-running live thought groups. Use useEffect
on chronologicalThoughts dependency instead.
Co-Authored-By: Claude Opus 4.6
---
.../components/team/activity/LeadThoughtsGroup.tsx | 13 ++++---------
1 file changed, 4 insertions(+), 9 deletions(-)
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index 67f41d31..9e247eed 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -258,17 +258,12 @@ export const LeadThoughtsGroupRow = ({
return () => observer.disconnect();
}, [onVisible, thoughts]);
- // Auto-scroll via ResizeObserver — fires after CSS animations expand content
+ // Auto-scroll when new thoughts arrive
useEffect(() => {
const el = scrollRef.current;
- if (!el) return;
- const observer = new ResizeObserver(() => {
- if (isUserScrolledUpRef.current) return;
- el.scrollTop = el.scrollHeight;
- });
- observer.observe(el);
- return () => observer.disconnect();
- }, []);
+ if (!el || isUserScrolledUpRef.current) return;
+ el.scrollTop = el.scrollHeight;
+ }, [chronologicalThoughts]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;