From 60d80cde702014fbf76f63bcd4c6fc8d79c7aa13 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 12:23:57 +0200 Subject: [PATCH 001/113] fix(auth): avoid setting CLAUDE_CONFIG_DIR when it matches default path On macOS, the Claude CLI uses a Keychain namespace derived from the presence of CLAUDE_CONFIG_DIR env var. Setting it to the default ~/.claude creates a different Keychain key than when the var is absent, causing "not logged in" errors even after successful `claude auth login`. Only set CLAUDE_CONFIG_DIR when the user has configured a custom path that differs from the auto-detected default. Fixes #27 --- src/main/services/team/TeamProvisioningService.ts | 9 ++++++--- src/main/utils/cliEnv.ts | 11 +++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 69790de0..ee1f0513 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6885,9 +6885,12 @@ export class TeamProvisioningService { USER: user, LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color', - // Ensure CLI reads/writes from the same Claude root as the app. - // This aligns teams/tasks locations when the app overrides claudeRootPath. - CLAUDE_CONFIG_DIR: getClaudeBasePath(), + // Only set CLAUDE_CONFIG_DIR when the user configured a custom path. + // Setting it to the default ~/.claude changes the macOS Keychain namespace + // for OAuth credential lookup, causing auth failures. (See issue #27) + ...(getClaudeBasePath() !== getAutoDetectedClaudeBasePath() + ? { CLAUDE_CONFIG_DIR: getClaudeBasePath() } + : {}), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; diff --git a/src/main/utils/cliEnv.ts b/src/main/utils/cliEnv.ts index 6ad5ca15..b39d76ed 100644 --- a/src/main/utils/cliEnv.ts +++ b/src/main/utils/cliEnv.ts @@ -9,7 +9,7 @@ */ import { buildMergedCliPath } from '@main/utils/cliPathMerge'; -import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder'; import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; import { userInfo } from 'os'; @@ -29,13 +29,20 @@ export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv osUsername || ''; + // Only set CLAUDE_CONFIG_DIR when the user has configured a custom path. + // Setting it to the default ~/.claude changes the macOS Keychain namespace + // that the CLI uses for OAuth credential lookup, causing "not logged in" + // even though `claude auth login` succeeded without the env var. + const configDir = getClaudeBasePath(); + const isCustomConfigDir = configDir !== getAutoDetectedClaudeBasePath(); + return { ...process.env, ...(shellEnv ?? {}), HOME: home, USERPROFILE: home, PATH: buildMergedCliPath(binaryPath), - CLAUDE_CONFIG_DIR: getClaudeBasePath(), + ...(isCustomConfigDir ? { CLAUDE_CONFIG_DIR: configDir } : {}), ...(user ? { USER: user, From b8aa2d9f148c5f00b2e96d8cc1dd116f737e367d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:36:12 +0200 Subject: [PATCH 002/113] fix(auth): enrich PTY env and invalidate status cache after login - Use buildEnrichedEnv() in PtyTerminalService so login terminal gets full PATH (Homebrew, nvm, etc.) and USER for Keychain lookup - Add cliInstaller:invalidateStatus IPC to clear cached auth status after successful login, preventing stale "not logged in" responses - Show "Verifying authentication..." spinner instead of flashing the "Not logged in" banner between modal close and status refresh Ref #27 --- src/main/ipc/cliInstaller.ts | 8 ++++ .../infrastructure/PtyTerminalService.ts | 5 +-- src/preload/constants/ipcChannels.ts | 3 ++ src/preload/index.ts | 4 ++ src/renderer/api/httpClient.ts | 1 + .../components/dashboard/CliStatusBanner.tsx | 38 ++++++++++++++++++- src/renderer/hooks/useCliInstaller.ts | 3 ++ .../store/slices/cliInstallerSlice.ts | 5 +++ src/shared/types/cliInstaller.ts | 2 + 9 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 64806f83..de4d5fda 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -10,6 +10,7 @@ import { CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, + CLI_INSTALLER_INVALIDATE_STATUS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload } from '@preload/constants/ipcChannels'; import { getErrorMessage } from '@shared/utils/errorHandling'; @@ -39,6 +40,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer export function registerCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus); ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall); + ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus); logger.info('CLI installer handlers registered'); } @@ -49,6 +51,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void { export function removeCliInstallerHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS); ipcMain.removeHandler(CLI_INSTALLER_INSTALL); + ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS); logger.info('CLI installer handlers removed'); } @@ -105,3 +108,8 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise { + cachedStatus = null; + return { success: true, data: undefined }; +} diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index 7e50e3de..d1828bc9 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -7,6 +7,7 @@ import crypto from 'node:crypto'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getHomeDir } from '@main/utils/pathDecoder'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; @@ -65,9 +66,7 @@ export class PtyTerminalService { rows: options?.rows ?? 24, cwd: options?.cwd ?? home, env: { - ...process.env, - HOME: home, - USERPROFILE: home, + ...buildEnrichedEnv(), ...options?.env, } as Record, }); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 19131baf..3f4b47db 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -405,6 +405,9 @@ export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; /** CLI installer progress events (main -> renderer) */ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress'; +/** Invalidate cached CLI status (forces fresh check on next getStatus) */ +export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus'; + // ============================================================================= // Terminal API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 3aa647db..6a5eda34 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import { APP_RELAUNCH, CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, + CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, @@ -1293,6 +1294,9 @@ const electronAPI: ElectronAPI = { install: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_INSTALL); }, + invalidateStatus: async (): Promise => { + return invokeIpcWithResult(CLI_INSTALLER_INVALIDATE_STATUS); + }, onProgress: (callback: (event: unknown, data: CliInstallerProgress) => void): (() => void) => { ipcRenderer.on( CLI_INSTALLER_PROGRESS, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index a73fd8cb..a33577a2 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1045,6 +1045,7 @@ export class HttpAPIClient implements ElectronAPI { install: async (): Promise => { console.warn('[HttpAPIClient] CLI installer not available in browser mode'); }, + invalidateStatus: async (): Promise => {}, onProgress: (): (() => void) => { return () => {}; }, diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index b59ecc40..c5e490cd 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -272,11 +272,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => { installerRawChunks, completedVersion, fetchCliStatus, + invalidateCliStatus, installCli, isBusy, } = useCliInstaller(); const [showLoginTerminal, setShowLoginTerminal] = useState(false); + const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); useEffect(() => { if (!isElectron) return; @@ -526,6 +528,22 @@ export const CliStatusBanner = (): React.JSX.Element | null => { // Installed but not logged in — yellow warning banner if (cliStatus.installed && !cliStatus.authLoggedIn) { + if (isVerifyingAuth) { + return ( +
+ +

+ Verifying authentication... +

+
+ ); + } return ( <>
{ args={['auth', 'login']} onClose={() => { setShowLoginTerminal(false); - void fetchCliStatus(); + setIsVerifyingAuth(true); + void (async () => { + try { + await invalidateCliStatus(); + await fetchCliStatus(); + } finally { + setIsVerifyingAuth(false); + } + })(); }} onExit={() => { - void fetchCliStatus(); + setIsVerifyingAuth(true); + void (async () => { + try { + await invalidateCliStatus(); + await fetchCliStatus(); + } finally { + setIsVerifyingAuth(false); + } + })(); }} autoCloseOnSuccessMs={4000} successMessage="Login complete" diff --git a/src/renderer/hooks/useCliInstaller.ts b/src/renderer/hooks/useCliInstaller.ts index 679d5e23..a7831d6d 100644 --- a/src/renderer/hooks/useCliInstaller.ts +++ b/src/renderer/hooks/useCliInstaller.ts @@ -29,6 +29,7 @@ export function useCliInstaller(): { installerRawChunks: string[]; completedVersion: string | null; fetchCliStatus: () => Promise; + invalidateCliStatus: () => Promise; installCli: () => void; isBusy: boolean; } { @@ -44,6 +45,7 @@ export function useCliInstaller(): { const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); const completedVersion = useStore((s) => s.cliCompletedVersion); const fetchCliStatus = useStore((s) => s.fetchCliStatus); + const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); const installCli = useStore((s) => s.installCli); const isBusy = installerState !== 'idle' && installerState !== 'error'; @@ -61,6 +63,7 @@ export function useCliInstaller(): { installerRawChunks, completedVersion, fetchCliStatus, + invalidateCliStatus, installCli, isBusy, }; diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index 92b25fdb..929b5938 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -42,6 +42,7 @@ export interface CliInstallerSlice { // Actions fetchCliStatus: () => Promise; + invalidateCliStatus: () => Promise; installCli: () => void; } @@ -90,6 +91,10 @@ export const createCliInstallerSlice: StateCreator { + await api.cliInstaller?.invalidateStatus(); + }, + installCli: () => { set({ cliInstallerState: 'checking', diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 91b4bc68..9149ead1 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -83,6 +83,8 @@ export interface CliInstallerAPI { getStatus: () => Promise; /** Start install/update flow. Progress sent via onProgress events. */ install: () => Promise; + /** Invalidate cached status (forces fresh check on next getStatus) */ + invalidateStatus: () => Promise; /** Subscribe to progress events. Returns cleanup function. */ onProgress: (cb: (event: unknown, data: CliInstallerProgress) => void) => () => void; } From 7324b5236dfa1b8df585871d88f7b7b9739a0225 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:38:26 +0200 Subject: [PATCH 003/113] fix(team): update tool references from Task to Agent in provisioning messages - Changed references in messages to use the **Agent** tool instead of the Task tool for spawning teammates. - Added warnings for missing team_name to ensure agents are persistent rather than ephemeral. - Updated documentation within the code to reflect the new requirements for spawning teammates. --- .../services/team/TeamProvisioningService.ts | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ee1f0513..2eeaa9db 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -555,7 +555,7 @@ export function buildAddMemberSpawnMessage( return ( `A new teammate "${member.name}"${roleHint} has been added to the team. ` + - `Please spawn them immediately using the Task tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` + + `Please spawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose", and the exact prompt below:${workflowHint}\n\n` + indentMultiline(prompt, ' ') ); } @@ -680,8 +680,8 @@ function buildPersistentLeadContext(opts: { `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + - `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + - `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + + `\n - ALLOWED: You may use the Agent tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Agent tool with team_name + name.` + `\n - TASK BOARD FIRST (MANDATORY): Do NOT do substantial work silently or off-board.` + `\n - Before you start meaningful implementation, debugging, research, review, or follow-up work, make sure there is a visible team-board task for it and that task is assigned to you.` + `\n - If the user asks for new work, your first move is to create/update the relevant board task(s), then start work from those tasks.` + @@ -901,8 +901,9 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { const step2Block = isSolo ? '2) Skip — this is a solo team with no teammates to spawn.' - : `2) Spawn each member as a live teammate using the Task tool: - - team_name: “${request.teamName}” + : `2) Spawn each member as a live teammate using the **Agent** tool: + CRITICAL: Every Agent call MUST include team_name=”${request.teamName}”. Without team_name the agent becomes an ephemeral subagent that exits immediately instead of a persistent teammate. + - team_name: “${request.teamName}” ← MANDATORY, never omit - name: the member's name (see per-member list below) - subagent_type: “general-purpose” - IMPORTANT: Use the exact prompt shown for each member. @@ -1006,8 +1007,9 @@ function buildLaunchPrompt( }) .join('\n\n'); - step2And3Block = `2) Spawn each existing member as a live teammate using the Task tool: - - team_name: "${request.teamName}" + step2And3Block = `2) Spawn each existing member as a live teammate using the **Agent** tool: + CRITICAL: Every Agent call MUST include team_name="${request.teamName}". Without team_name the agent becomes an ephemeral subagent that exits immediately instead of a persistent teammate. + - team_name: "${request.teamName}" ← MANDATORY, never omit - name: the member's name - subagent_type: "general-purpose" - IMPORTANT: Use the exact prompt shown for each member. @@ -4395,7 +4397,20 @@ export class TeamProvisioningService { const inp = input as Record; const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; - if (!teamName || !memberName) continue; + if (!memberName) continue; + if (!teamName) { + logger.warn( + `[captureTeamSpawnEvents] Agent call for "${memberName}" is missing team_name — ` + + `teammate will be an ephemeral subagent, not a persistent member of "${run.teamName}"` + ); + this.setMemberSpawnStatus( + run, + memberName, + 'error', + `Agent spawn for "${memberName}" is missing team_name — spawned as ephemeral subagent instead of persistent teammate` + ); + continue; + } // Only track spawns for this team if (teamName !== run.teamName) continue; this.setMemberSpawnStatus(run, memberName, 'spawning'); From 4946a65a7b29656ccae54729adebf89ef3ea7385 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:58:38 +0200 Subject: [PATCH 004/113] feat: cherry-pick upstream CollapsibleOutputSection + TriggerMatcher regex cache Cherry-picked from upstream: - 516d0f6b: CollapsibleOutputSection with markdown preview toggle - e51c1fd1: cache compiled regexes in TriggerMatcher (perf) --- src/main/services/error/TriggerMatcher.ts | 43 +++++++++++++- .../linkedTool/CollapsibleOutputSection.tsx | 57 +++++++++++++++++++ .../items/linkedTool/DefaultToolViewer.tsx | 30 ++-------- .../chat/items/linkedTool/ReadToolViewer.tsx | 51 ++++++++++++++--- .../chat/items/linkedTool/WriteToolViewer.tsx | 2 +- .../components/chat/items/linkedTool/index.ts | 1 + 6 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx diff --git a/src/main/services/error/TriggerMatcher.ts b/src/main/services/error/TriggerMatcher.ts index 7e06c47c..dbf53900 100644 --- a/src/main/services/error/TriggerMatcher.ts +++ b/src/main/services/error/TriggerMatcher.ts @@ -11,6 +11,43 @@ import { type ContentBlock, type ParsedMessage } from '@main/types'; import { createSafeRegExp } from '@main/utils/regexValidation'; +// ============================================================================= +// Regex Cache +// ============================================================================= + +const MAX_CACHE_SIZE = 500; + +/** + * Module-level cache for compiled RegExp objects. + * Key: `${pattern}\0${flags}` (null byte separator avoids collisions). + * Value: compiled RegExp, or null if the pattern is invalid/dangerous. + */ +const regexCache = new Map(); + +/** + * Returns a cached RegExp for the given pattern and flags. + * Compiles and caches on first access; returns null for invalid patterns. + * Cache is bounded to MAX_CACHE_SIZE entries (oldest evicted first via Map insertion order). + */ +function getCachedRegex(pattern: string, flags: string): RegExp | null { + const key = `${pattern}\0${flags}`; + if (regexCache.has(key)) { + return regexCache.get(key) ?? null; + } + + // Evict oldest entries when cache is full + if (regexCache.size >= MAX_CACHE_SIZE) { + const firstKey = regexCache.keys().next().value; + if (firstKey !== undefined) { + regexCache.delete(firstKey); + } + } + + const regex = createSafeRegExp(pattern, flags); + regexCache.set(key, regex); + return regex; +} + // ============================================================================= // Pattern Matching // ============================================================================= @@ -18,9 +55,10 @@ import { createSafeRegExp } from '@main/utils/regexValidation'; /** * Checks if content matches a pattern. * Uses validated regex to prevent ReDoS attacks. + * Regex objects are cached to avoid recompilation on repeated calls. */ export function matchesPattern(content: string, pattern: string): boolean { - const regex = createSafeRegExp(pattern, 'i'); + const regex = getCachedRegex(pattern, 'i'); if (!regex) { // Pattern is invalid or potentially dangerous, reject match return false; @@ -31,6 +69,7 @@ export function matchesPattern(content: string, pattern: string): boolean { /** * Checks if content matches any of the ignore patterns. * Uses validated regex to prevent ReDoS attacks. + * Regex objects are cached to avoid recompilation on repeated calls. */ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean { if (!ignorePatterns || ignorePatterns.length === 0) { @@ -38,7 +77,7 @@ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[] } for (const pattern of ignorePatterns) { - const regex = createSafeRegExp(pattern, 'i'); + const regex = getCachedRegex(pattern, 'i'); if (regex?.test(content)) { return true; } diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx new file mode 100644 index 00000000..de6fb699 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -0,0 +1,57 @@ +/** + * CollapsibleOutputSection + * + * Reusable component that wraps tool output in a collapsed-by-default section. + * Shows a clickable header with label, StatusDot, and chevron toggle. + */ + +import React, { useState } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import { type ItemStatus, StatusDot } from '../BaseItem'; + +interface CollapsibleOutputSectionProps { + status: ItemStatus; + children: React.ReactNode; + /** Label shown in the header (default: "Output") */ + label?: string; +} + +export const CollapsibleOutputSection: React.FC = ({ + status, + children, + label = 'Output', +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + {isExpanded && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index 1be3f906..c0a06fcf 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -6,8 +6,9 @@ import React from 'react'; -import { type ItemStatus, StatusDot } from '../BaseItem'; +import { type ItemStatus } from '../BaseItem'; +import { CollapsibleOutputSection } from './CollapsibleOutputSection'; import { renderInput, renderOutput } from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -37,30 +38,11 @@ export const DefaultToolViewer: React.FC = ({ linkedTool
- {/* Output Section */} + {/* Output Section — Collapsed by default */} {!linkedTool.isOrphaned && linkedTool.result && ( -
-
- Output - -
-
- {renderOutput(linkedTool.result.content)} -
-
+ + {renderOutput(linkedTool.result.content)} + )} ); diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx index f0eeb688..4edd8c93 100644 --- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { CodeBlockViewer } from '@renderer/components/chat/viewers'; +import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -54,12 +54,49 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => ? startLine + limit - 1 : undefined; + const isMarkdownFile = /\.mdx?$/i.test(filePath); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + return ( - +
+ {isMarkdownFile && ( +
+ + +
+ )} + {isMarkdownFile && viewMode === 'preview' ? ( + + ) : ( + + )} +
); }; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index d08d7005..14fba8aa 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -21,7 +21,7 @@ export const WriteToolViewer: React.FC = ({ linkedTool }) const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || ''; const isCreate = toolUseResult?.type === 'create'; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); return (
diff --git a/src/renderer/components/chat/items/linkedTool/index.ts b/src/renderer/components/chat/items/linkedTool/index.ts index 5c415dac..83f37b92 100644 --- a/src/renderer/components/chat/items/linkedTool/index.ts +++ b/src/renderer/components/chat/items/linkedTool/index.ts @@ -4,6 +4,7 @@ * Exports all specialized tool viewer components. */ +export { CollapsibleOutputSection } from './CollapsibleOutputSection'; export { DefaultToolViewer } from './DefaultToolViewer'; export { EditToolViewer } from './EditToolViewer'; export { ReadToolViewer } from './ReadToolViewer'; From 6fa075de51a294453848f4ac7287c53d1a7ad04d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:58:48 +0200 Subject: [PATCH 005/113] fix: sidebar header repo/branch not syncing when switching tabs Cherry-picked from upstream d2341d50 --- src/renderer/store/slices/tabSlice.ts | 2 +- test/renderer/store/tabSlice.test.ts | 72 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index 7a4be286..4fc1e8c4 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -292,7 +292,7 @@ export const createTabSlice: StateCreator = (set, ge for (const repo of state.repositoryGroups) { for (const wt of repo.worktrees) { - if (wt.sessions.includes(sessionId)) { + if (wt.id === projectId) { foundRepo = repo.id; foundWorktree = wt.id; break; diff --git a/test/renderer/store/tabSlice.test.ts b/test/renderer/store/tabSlice.test.ts index 582006c3..b1090044 100644 --- a/test/renderer/store/tabSlice.test.ts +++ b/test/renderer/store/tabSlice.test.ts @@ -234,6 +234,78 @@ describe('tabSlice', () => { expect(store.getState().activeTabId).toBe(tab1Id); }); + it('should sync selectedRepositoryId and selectedWorktreeId when switching tabs across repos', () => { + // Setup repositoryGroups with two repos, each with one worktree + store.setState({ + repositoryGroups: [ + { + id: 'repo-A', + identity: null, + name: 'Repo A', + worktrees: [ + { + id: 'worktree-A', + path: '/path/a', + name: 'main', + isMainWorktree: true, + source: 'git', + sessions: [], + createdAt: 0, + }, + ], + totalSessions: 0, + }, + { + id: 'repo-B', + identity: null, + name: 'Repo B', + worktrees: [ + { + id: 'worktree-B', + path: '/path/b', + name: 'develop', + isMainWorktree: true, + source: 'git', + sessions: [], + createdAt: 0, + }, + ], + totalSessions: 0, + }, + ] as never[], + selectedRepositoryId: 'repo-A', + selectedWorktreeId: 'worktree-A', + }); + + // Open tab from repo A + store.getState().openTab({ + type: 'session', + sessionId: 'session-A', + projectId: 'worktree-A', + label: 'Session A', + }); + const tabAId = store.getState().activeTabId; + + // Open tab from repo B + store.getState().openTab({ + type: 'session', + sessionId: 'session-B', + projectId: 'worktree-B', + label: 'Session B', + }); + + // Switch back to tab A + store.getState().setActiveTab(tabAId!); + expect(store.getState().selectedRepositoryId).toBe('repo-A'); + expect(store.getState().selectedWorktreeId).toBe('worktree-A'); + + // Switch to tab B + const tabBId = store.getState().openTabs.find((t) => t.sessionId === 'session-B')?.id; + store.getState().setActiveTab(tabBId!); + expect(store.getState().selectedRepositoryId).toBe('repo-B'); + expect(store.getState().selectedWorktreeId).toBe('worktree-B'); + }); + it('should preserve sidebar state for non-session tabs', () => { // Setup initial state with projects data so setActiveTab can find the project store.setState({ From aaa766b2ba48b0e75d1c944f4ac56ac6feb12130 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:03 +0200 Subject: [PATCH 006/113] perf: replace 8 filter passes with single-pass message categorization Cherry-picked from upstream 9fa2590e --- src/main/services/parsing/SessionParser.ts | 45 ++++++++++++++++------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts index 093dcaaf..fc5c85bd 100644 --- a/src/main/services/parsing/SessionParser.ts +++ b/src/main/services/parsing/SessionParser.ts @@ -85,21 +85,42 @@ export class SessionParser { * Process parsed messages into structured data. */ private processMessages(messages: ParsedMessage[]): ParsedSession { - // Group by type + // Single-pass categorization instead of 8 separate filter passes const byType = { - user: messages.filter((m) => m.type === 'user'), - realUser: messages.filter(isParsedRealUserMessage), - internalUser: messages.filter(isParsedInternalUserMessage), - assistant: messages.filter((m) => m.type === 'assistant'), - system: messages.filter((m) => m.type === 'system'), - other: messages.filter( - (m) => m.type !== 'user' && m.type !== 'assistant' && m.type !== 'system' - ), + user: [] as ParsedMessage[], + realUser: [] as ParsedMessage[], + internalUser: [] as ParsedMessage[], + assistant: [] as ParsedMessage[], + system: [] as ParsedMessage[], + other: [] as ParsedMessage[], }; + const sidechainMessages: ParsedMessage[] = []; + const mainMessages: ParsedMessage[] = []; - // Separate sidechain and main messages - const sidechainMessages = messages.filter((m) => m.isSidechain); - const mainMessages = messages.filter((m) => !m.isSidechain); + for (const m of messages) { + switch (m.type) { + case 'user': + byType.user.push(m); + if (isParsedRealUserMessage(m)) byType.realUser.push(m); + if (isParsedInternalUserMessage(m)) byType.internalUser.push(m); + break; + case 'assistant': + byType.assistant.push(m); + break; + case 'system': + byType.system.push(m); + break; + default: + byType.other.push(m); + break; + } + + if (m.isSidechain) { + sidechainMessages.push(m); + } else { + mainMessages.push(m); + } + } // Calculate metrics const metrics = calculateMetrics(messages); From 81921abfd49844d17243e0b979ade7c14140829d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:17 +0200 Subject: [PATCH 007/113] fix: increase global test timeout for CI environments Cherry-picked from upstream ad25f0f5 --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index 902129c6..6d1ad570 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ test: { globals: true, environment: 'happy-dom', + testTimeout: 15000, setupFiles: ['./test/setup.ts'], include: ['test/**/*.test.ts'], coverage: { From 86d69476c12ee8752bdf545a9c38ba8d23226001 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:27 +0200 Subject: [PATCH 008/113] fix: add retry to temp dir cleanup for Windows CI Cherry-picked from upstream c5d33727 --- test/main/services/discovery/SessionSearcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/main/services/discovery/SessionSearcher.test.ts b/test/main/services/discovery/SessionSearcher.test.ts index fa3dc845..5f18765f 100644 --- a/test/main/services/discovery/SessionSearcher.test.ts +++ b/test/main/services/discovery/SessionSearcher.test.ts @@ -10,7 +10,7 @@ describe('SessionSearcher', () => { afterEach(() => { for (const dir of tempDirs) { - fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } tempDirs.length = 0; }); From e6fa5c6f0698c97667584509778bde28df67cad3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 13:59:43 +0200 Subject: [PATCH 009/113] docs: enhance CONTRIBUTING.md with project philosophy Cherry-picked from upstream d655dddb --- .github/CONTRIBUTING.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4ce39e36..cf3195c2 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -2,6 +2,23 @@ Thanks for contributing to Claude Agent Teams UI. +## Project Philosophy & Scope + +claude-devtools exists to make the invisible parts of Claude Code visible — the token flows, context injections, tool executions, and session dynamics that are otherwise hidden behind the CLI. It is not a general-purpose dashboard or an IDE. + +Our priorities: + +1. **Parity with Claude Code** — When Claude Code ships new capabilities (agent teams, context tracking, new tool types), we adopt them quickly so users always have full visibility. +2. **Context engineering insight** — Features that help users understand *what* is consuming their context window, *how* tokens flow through a session, and *where* to optimize. If it doesn't help someone make better decisions about their Claude Code usage, it probably doesn't belong here. +3. **Stability over novelty** — A reliable, fast tool for professional workflows. We'd rather do fewer things well than many things poorly. + +**What we generally do not accept:** +- Large custom features that don't directly serve context visibility or Claude Code parity. +- Speculative features that add maintenance burden without solving a concrete problem users face today. +- PRs that significantly expand scope without prior discussion in an Issue. + +If you're considering a non-trivial contribution, **open an Issue first** to check alignment with the current roadmap. This saves everyone time and keeps the project focused. + ## Prerequisites - Node.js 20+ - pnpm 10+ From b87082a915fec25793be2e6ebb71e41077660dba Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:05:01 +0200 Subject: [PATCH 010/113] fix: requestId dedup to prevent ~2x cost overcounting + stale session detection Manually ported from upstream: - ef2e0868: deduplicate streaming JSONL entries by requestId - 4f21f267: mark stale ongoing sessions as dead after 5min inactivity --- src/main/services/discovery/ProjectScanner.ts | 8 +++- src/main/types/messages.ts | 2 + src/main/utils/jsonl.ts | 44 ++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 3227604d..15bc62f7 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -984,6 +984,12 @@ export class ProjectScanner { ? firstMessageTimestampMs : birthtimeMs; + // If messages suggest ongoing but the file hasn't been written to in 5+ minutes, + // the session likely crashed/was killed (upstream fix #94) + const STALE_SESSION_THRESHOLD_MS = 5 * 60 * 1000; + const isOngoing = + metadata.isOngoing && Date.now() - effectiveMtime < STALE_SESSION_THRESHOLD_MS; + return { id: sessionId, projectId, @@ -993,7 +999,7 @@ export class ProjectScanner { messageTimestamp: metadata.firstUserMessage?.timestamp, hasSubagents, messageCount: metadata.messageCount, - isOngoing: metadata.isOngoing, + isOngoing, gitBranch: metadata.gitBranch ?? undefined, metadataLevel, contextConsumption: metadata.contextConsumption, diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 7a8c537b..79f6652e 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -103,6 +103,8 @@ export interface ParsedMessage { toolUseResult?: ToolUseResultData; /** Whether this is a compact summary boundary message */ isCompactSummary?: boolean; + /** API request ID for deduplicating streaming entries */ + requestId?: string; } // ============================================================================= diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 653f7755..59fe25d5 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -125,6 +125,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let role: string | undefined; let usage: TokenUsage | undefined; let model: string | undefined; + let requestId: string | undefined; let cwd: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; @@ -163,6 +164,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { usage = entry.message.usage; model = entry.message.model; agentId = entry.agentId; + requestId = entry.requestId; } else if (entry.type === 'system') { isMeta = entry.isMeta ?? false; } @@ -195,6 +197,7 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { sourceToolUseID, sourceToolAssistantUUID, toolUseResult, + requestId, }; } @@ -221,24 +224,61 @@ function parseMessageType(type?: string): MessageType | null { } } +// ============================================================================= +// Streaming Deduplication +// ============================================================================= + +/** + * Deduplicate streaming assistant entries by requestId. + * + * Claude Code writes multiple JSONL entries per API response during streaming, + * each with the same requestId but incrementally increasing output_tokens. + * Only the last entry per requestId has the final, complete token counts. + * + * Messages without a requestId (user, system, etc.) pass through unchanged. + * Returns a new array with only the last entry per requestId kept. + */ +export function deduplicateByRequestId(messages: ParsedMessage[]): ParsedMessage[] { + const lastIndexByRequestId = new Map(); + for (let i = 0; i < messages.length; i++) { + const rid = messages[i].requestId; + if (rid) { + lastIndexByRequestId.set(rid, i); + } + } + + if (lastIndexByRequestId.size === 0) { + return messages; + } + + return messages.filter((msg, i) => { + if (!msg.requestId) return true; + return lastIndexByRequestId.get(msg.requestId) === i; + }); +} + // ============================================================================= // Metrics Calculation // ============================================================================= /** * Calculate session metrics from parsed messages. + * Deduplicates streaming entries by requestId before summing to avoid ~2x cost overcounting. */ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { if (messages.length === 0) { return { ...EMPTY_METRICS }; } + // Deduplicate streaming entries: keep only the last entry per requestId + const dedupedMessages = deduplicateByRequestId(messages); + let inputTokens = 0; let outputTokens = 0; let cacheReadTokens = 0; let cacheCreationTokens = 0; - // Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions) + // Get timestamps for duration from ALL messages (not deduped) for accurate session length const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); let minTime = 0; @@ -255,7 +295,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { // Calculate cost per-message, then sum (tiered pricing applies per-API-call, not to aggregated totals) let costUsd = 0; - for (const msg of messages) { + for (const msg of dedupedMessages) { if (msg.usage) { const msgInputTokens = msg.usage.input_tokens ?? 0; const msgOutputTokens = msg.usage.output_tokens ?? 0; From aeed5a087324c27084820075b53b381812a46594 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:05:53 +0200 Subject: [PATCH 011/113] feat: add syntax highlighting for R, Ruby, PHP, and SQL Cherry-picked from upstream 022c75da with conflict resolution. Merged our codeClassName support with upstream per-line highlighting. --- .../chat/viewers/MarkdownViewer.tsx | 12 +- .../chat/viewers/syntaxHighlighter.ts | 216 +++++++++++++++++- 2 files changed, 224 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 703c07d9..00c5da3f 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -39,6 +39,7 @@ import { highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; +import { highlightLine } from '../viewers/syntaxHighlighter'; import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; @@ -535,12 +536,21 @@ function createViewerMarkdownComponents( const isBlock = (hasLanguage ?? false) || isMultiLine; if (isBlock) { + const lang = codeClassName?.replace('language-', '') ?? ''; + const raw = typeof children === 'string' ? children : ''; + const text = raw.replace(/\n$/, ''); + const lines = text.split('\n'); return ( - {hl(children)} + {lines.map((line, i) => ( + + {hl(highlightLine(line, lang))} + {i < lines.length - 1 ? '\n' : null} + + ))} ); } diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts index 2271d08b..e6e2f6b3 100644 --- a/src/renderer/components/chat/viewers/syntaxHighlighter.ts +++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts @@ -208,6 +208,200 @@ const KEYWORDS: Record> = { 'true', 'false', ]), + r: new Set([ + 'if', + 'else', + 'for', + 'while', + 'repeat', + 'function', + 'return', + 'next', + 'break', + 'in', + 'library', + 'require', + 'source', + 'TRUE', + 'FALSE', + 'NULL', + 'NA', + 'Inf', + 'NaN', + 'NA_integer_', + 'NA_real_', + 'NA_complex_', + 'NA_character_', + ]), + ruby: new Set([ + 'def', + 'class', + 'module', + 'end', + 'do', + 'if', + 'elsif', + 'else', + 'unless', + 'while', + 'until', + 'for', + 'in', + 'begin', + 'rescue', + 'ensure', + 'raise', + 'return', + 'yield', + 'block_given?', + 'require', + 'require_relative', + 'include', + 'extend', + 'attr_accessor', + 'attr_reader', + 'attr_writer', + 'self', + 'super', + 'nil', + 'true', + 'false', + 'and', + 'or', + 'not', + 'then', + 'when', + 'case', + 'lambda', + 'proc', + 'puts', + 'print', + ]), + php: new Set([ + 'function', + 'class', + 'interface', + 'trait', + 'extends', + 'implements', + 'namespace', + 'use', + 'public', + 'private', + 'protected', + 'static', + 'abstract', + 'final', + 'const', + 'var', + 'new', + 'return', + 'if', + 'elseif', + 'else', + 'for', + 'foreach', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'default', + 'try', + 'catch', + 'finally', + 'throw', + 'as', + 'echo', + 'print', + 'require', + 'require_once', + 'include', + 'include_once', + 'true', + 'false', + 'null', + 'array', + 'isset', + 'unset', + 'empty', + 'self', + 'this', + ]), + sql: new Set([ + 'SELECT', + 'FROM', + 'WHERE', + 'INSERT', + 'INTO', + 'UPDATE', + 'SET', + 'DELETE', + 'CREATE', + 'ALTER', + 'DROP', + 'TABLE', + 'INDEX', + 'VIEW', + 'DATABASE', + 'JOIN', + 'INNER', + 'LEFT', + 'RIGHT', + 'OUTER', + 'FULL', + 'CROSS', + 'ON', + 'AND', + 'OR', + 'NOT', + 'IN', + 'EXISTS', + 'BETWEEN', + 'LIKE', + 'IS', + 'NULL', + 'AS', + 'ORDER', + 'BY', + 'GROUP', + 'HAVING', + 'LIMIT', + 'OFFSET', + 'UNION', + 'ALL', + 'DISTINCT', + 'COUNT', + 'SUM', + 'AVG', + 'MIN', + 'MAX', + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'TRANSACTION', + 'PRIMARY', + 'KEY', + 'FOREIGN', + 'REFERENCES', + 'CONSTRAINT', + 'DEFAULT', + 'VALUES', + 'TRUE', + 'FALSE', + 'INTEGER', + 'VARCHAR', + 'TEXT', + 'BOOLEAN', + 'DATE', + 'TIMESTAMP', + ]), }; // Extend tsx/jsx to use typescript/javascript keywords @@ -296,8 +490,23 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (# style for Python/Shell) - if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) { + // Check for comment (# style for Python/Shell/R/Ruby/PHP) + if ( + (language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') && + remaining.startsWith('#') + ) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } }, + remaining + ) + ); + break; + } + + // Check for comment (-- style for SQL) + if (language === 'sql' && remaining.startsWith('--')) { segments.push( React.createElement( 'span', @@ -326,7 +535,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[] const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining); if (wordMatch) { const word = wordMatch[1]; - if (keywords.has(word)) { + // SQL keywords are case-insensitive + if (keywords.has(word) || (language === 'sql' && keywords.has(word.toUpperCase()))) { segments.push( React.createElement( 'span', From e30f6482bf32a5620f9790d687e55e55101621e7 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:22:04 +0200 Subject: [PATCH 012/113] fix: WSL mount path translation + Windows drive letter normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manually ported from upstream: - c239cda6: translateWslMountPath() converts /mnt/X/... → X:/... on Windows - 7f5fbdab: normalizeDriveLetter() uppercases drive letter (c:/ → C:/) Both are no-op on macOS/Linux. Fixes session fragmentation on Windows+WSL. --- src/main/utils/metadataExtraction.ts | 15 ++++++++++++++- src/main/utils/pathDecoder.ts | 22 +++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index f861401e..f28146ff 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -9,11 +9,24 @@ import * as readline from 'readline'; import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider'; import { type ChatHistoryEntry, isTextContent, type UserEntry } from '../types'; +import { translateWslMountPath } from './pathDecoder'; + import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; import type { Readable } from 'stream'; const logger = createLogger('Util:metadataExtraction'); +/** + * Normalize Windows drive letter to uppercase for consistent path comparison. + * CLI uses uppercase (C:\...) while VS Code extension uses lowercase (c:\...). + */ +function normalizeDriveLetter(p: string): string { + if (p.length >= 2 && p[1] === ':') { + return p[0].toUpperCase() + p.slice(1); + } + return p; +} + const defaultProvider = new LocalFileSystemProvider(); const JSONL_HEAD_TIMEOUT_MS = 2000; @@ -100,7 +113,7 @@ export async function extractCwd( } // Only conversational entries have cwd if ('cwd' in entry && entry.cwd) { - return entry.cwd; + return normalizeDriveLetter(translateWslMountPath(entry.cwd)); } } } catch (error) { diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 5b04b068..4590181c 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -69,7 +69,10 @@ export function decodePath(encodedName: string): string { } // Ensure leading slash for POSIX-style absolute paths - return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; + const absolutePath = decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; + + // Translate WSL mount paths to Windows drive-letter paths on Windows + return translateWslMountPath(absolutePath); } /** @@ -91,6 +94,23 @@ export function extractProjectName(encodedName: string, cwdHint?: string): strin return segments[segments.length - 1] || encodedName; } +/** + * Translate WSL mount paths (/mnt/X/...) to Windows drive-letter paths (X:/...) + * when running on Windows. No-op on other platforms. + */ +export function translateWslMountPath(posixPath: string): string { + if (process.platform !== 'win32') { + return posixPath; + } + const match = /^\/mnt\/([a-zA-Z])(\/.*)?$/.exec(posixPath); + if (match) { + const drive = match[1].toUpperCase(); + const rest = match[2] ?? ''; + return `${drive}:${rest}`; + } + return posixPath; +} + // ============================================================================= // Validation // ============================================================================= From c21350713c3d047be10277f167876d0ce416265e Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:32:37 +0200 Subject: [PATCH 013/113] perf: replace remark-based search with plain text indexOf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manually ported from upstream 5c7f921e. Key changes: - SessionSearcher: indexOf instead of remark AST, batch size 8→16 - conversationSlice: indexOf with MAX_SEARCH_MATCHES=500 cap - Item-scoped store selectors (searchMatchItemIds Set) to skip re-renders - Pre-filter in markdownTextSearch (skip parse if no raw match) - SearchTextCache: 200→1000 entries - ProjectScanner: 30s search project cache, batch 4→8 --- src/main/services/discovery/ProjectScanner.ts | 21 ++++++- .../services/discovery/SearchTextCache.ts | 2 +- .../services/discovery/SessionSearcher.ts | 54 ++++++++--------- .../components/chat/LastOutputDisplay.tsx | 16 +++-- .../components/chat/UserChatGroup.tsx | 16 +++-- .../components/chat/searchHighlightUtils.ts | 3 + .../chat/viewers/MarkdownViewer.tsx | 19 +++--- .../store/slices/conversationSlice.ts | 58 ++++++++++++++----- src/shared/utils/markdownTextSearch.ts | 8 ++- .../discovery/SessionSearcher.test.ts | 8 +-- 10 files changed, 128 insertions(+), 77 deletions(-) diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 15bc62f7..0b3ac09b 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -71,6 +71,9 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP const logger = createLogger('Discovery:ProjectScanner'); +/** How long to reuse the cached project list for search (ms) */ +const SEARCH_PROJECT_CACHE_TTL_MS = 30_000; + // IPC payload safety: session ID arrays can be extremely large for long-lived projects. // Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive. // Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list @@ -147,6 +150,9 @@ export class ProjectScanner { private scanCache: { projects: Project[]; timestamp: number } | null = null; private static readonly SCAN_CACHE_TTL_MS = 2000; + /** Cached project list for search — avoids re-scanning disk on every query */ + private searchProjectCache: { projects: Project[]; timestamp: number } | null = null; + // Platform-aware batch sizes to avoid UV thread pool saturation on Windows private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64; private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12; @@ -1329,8 +1335,17 @@ export class ProjectScanner { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; } - // Get all projects - const projects = await this.scan(); + // Use cached project list to avoid re-scanning disk on every keystroke + let projects: Project[]; + if ( + this.searchProjectCache && + Date.now() - this.searchProjectCache.timestamp < SEARCH_PROJECT_CACHE_TTL_MS + ) { + projects = this.searchProjectCache.projects; + } else { + projects = await this.scan(); + this.searchProjectCache = { projects, timestamp: Date.now() }; + } if (projects.length === 0) { return { results: [], totalMatches: 0, sessionsSearched: 0, query }; @@ -1338,7 +1353,7 @@ export class ProjectScanner { // Search across all projects with bounded concurrency const allResults: SearchSessionsResult[] = []; - const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 4; + const searchBatchSize = this.fsProvider.type === 'ssh' ? 2 : 8; for (let i = 0; i < projects.length; i += searchBatchSize) { const batch = projects.slice(i, i + searchBatchSize); diff --git a/src/main/services/discovery/SearchTextCache.ts b/src/main/services/discovery/SearchTextCache.ts index 68c61ba9..e48916bd 100644 --- a/src/main/services/discovery/SearchTextCache.ts +++ b/src/main/services/discovery/SearchTextCache.ts @@ -21,7 +21,7 @@ export class SearchTextCache { private readonly cache = new Map(); private readonly maxSize: number; - constructor(maxSize: number = 200) { + constructor(maxSize: number = 1000) { this.maxSize = maxSize; } diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 5f0615f1..0d1b1857 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -15,10 +15,6 @@ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFile import { parseJsonlFile } from '@main/utils/jsonl'; import { extractBaseDir, extractSessionId } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; -import { - extractMarkdownPlainText, - findMarkdownSearchMatches, -} from '@shared/utils/markdownTextSearch'; import * as path from 'path'; import { startMainSpan } from '../../sentry'; @@ -112,7 +108,7 @@ export class SessionSearcher { sessionFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); // Search session files with bounded concurrency and staged breadth in SSH mode. - const searchBatchSize = fastMode ? 3 : 8; + const searchBatchSize = fastMode ? 3 : 16; const stageBoundaries = fastMode ? this.buildFastSearchStageBoundaries(sessionFiles.length) : [sessionFiles.length]; @@ -236,6 +232,10 @@ export class SessionSearcher { const { entries, sessionTitle } = cached; + // Fast pre-filter: skip sessions where no entry contains the query in raw text + const hasAnyMatch = entries.some((entry) => entry.text.toLowerCase().includes(query)); + if (!hasAnyMatch) return results; + for (const entry of entries) { if (results.length >= maxResults) break; @@ -262,31 +262,20 @@ export class SessionSearcher { sessionId: string, sessionTitle?: string ): void { - const mdMatches = findMarkdownSearchMatches(entry.text, query); - if (mdMatches.length === 0) return; + // Plain indexOf search — no markdown/remark parsing + const lowerText = entry.text.toLowerCase(); + if (!lowerText.includes(query)) return; - // Build plain text once for context snippet extraction - const plainText = extractMarkdownPlainText(entry.text); - const lowerPlain = plainText.toLowerCase(); - - for (const mdMatch of mdMatches) { + // Use raw text directly for context snippets + let pos = 0; + let matchIndex = 0; + while ((pos = lowerText.indexOf(query, pos)) !== -1) { if (results.length >= maxResults) return; - // Find approximate position in plain text for context extraction - let pos = 0; - for (let i = 0; i < mdMatch.matchIndexInItem; i++) { - const idx = lowerPlain.indexOf(query, pos); - if (idx === -1) break; - pos = idx + query.length; - } - const matchPos = lowerPlain.indexOf(query, pos); - const effectivePos = matchPos >= 0 ? matchPos : 0; - - const contextStart = Math.max(0, effectivePos - 50); - const contextEnd = Math.min(plainText.length, effectivePos + query.length + 50); - const context = plainText.slice(contextStart, contextEnd); - const matchedText = - matchPos >= 0 ? plainText.slice(matchPos, matchPos + query.length) : query; + const contextStart = Math.max(0, pos - 50); + const contextEnd = Math.min(entry.text.length, pos + query.length + 50); + const context = entry.text.slice(contextStart, contextEnd); + const matchedText = entry.text.slice(pos, pos + query.length); results.push({ sessionId, @@ -294,15 +283,20 @@ export class SessionSearcher { sessionTitle: sessionTitle ?? 'Untitled Session', matchedText, context: - (contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''), + (contextStart > 0 ? '...' : '') + + context + + (contextEnd < entry.text.length ? '...' : ''), messageType: entry.messageType, timestamp: entry.timestamp, groupId: entry.groupId, itemType: entry.itemType, - matchIndexInItem: mdMatch.matchIndexInItem, - matchStartOffset: effectivePos, + matchIndexInItem: matchIndex, + matchStartOffset: pos, messageUuid: entry.messageUuid, }); + + matchIndex++; + pos += query.length; } } diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx index d4b25355..db710805 100644 --- a/src/renderer/components/chat/LastOutputDisplay.tsx +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -11,7 +11,7 @@ import { CopyButton } from '../common/CopyButton'; import { OngoingBanner } from '../common/OngoingIndicator'; import { createMarkdownComponents, markdownComponents } from './markdownComponents'; -import { createSearchContext } from './searchHighlightUtils'; +import { createSearchContext, EMPTY_SEARCH_MATCHES } from './searchHighlightUtils'; import type { AIGroupLastOutput } from '@renderer/types/groups'; @@ -41,12 +41,16 @@ export const LastOutputDisplay = ({ isLastGroup = false, isSessionOngoing = false, }: Readonly): React.JSX.Element | null => { + // Only re-render if THIS AI group has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(aiGroupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 6cf59051..860a5dc3 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -21,6 +21,7 @@ import { CopyButton } from '../common/CopyButton'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from './searchHighlightUtils'; @@ -401,13 +402,16 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. [teams] ); - // Get search state for highlighting + // Get search state for highlighting — only re-render if THIS item has matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: s.searchQuery, - searchMatches: s.searchMatches, - currentSearchIndex: s.currentSearchIndex, - })) + useShallow((s) => { + const hasMatch = s.searchMatchItemIds.has(groupId); + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); const hasImages = content.images.length > 0; diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 34684f96..39a15be5 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -8,6 +8,9 @@ import React from 'react'; import type { SearchMatch } from '@renderer/store/types'; +/** Stable empty array for item-scoped search selectors (avoids re-renders) */ +export const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; + // Highlight styles matching SearchHighlight.tsx const baseStyles: React.CSSProperties = { borderRadius: '0.125rem', diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 00c5da3f..9636ff2c 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -36,6 +36,7 @@ import { useShallow } from 'zustand/react/shallow'; import { createSearchContext, + EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; @@ -44,8 +45,6 @@ import { highlightLine } from '../viewers/syntaxHighlighter'; import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; -import type { SearchMatch } from '@renderer/store/types'; - // ============================================================================= // Types // ============================================================================= @@ -73,7 +72,6 @@ interface MarkdownViewerProps { const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); -const EMPTY_SEARCH_MATCHES: SearchMatch[] = []; const NOOP_TEAM_CLICK = (): void => undefined; // ============================================================================= @@ -714,13 +712,16 @@ export const MarkdownViewer: React.FC = ({ const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only subscribe to search store when itemId is provided + // Only re-render if THIS item has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => ({ - searchQuery: itemId ? s.searchQuery : '', - searchMatches: itemId ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: itemId ? s.currentSearchIndex : -1, - })) + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) ); // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts index ec40e5db..d6ca21ac 100644 --- a/src/renderer/store/slices/conversationSlice.ts +++ b/src/renderer/store/slices/conversationSlice.ts @@ -4,7 +4,6 @@ import { findLastOutput } from '@renderer/utils/aiGroupEnhancer'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch'; import type { AppState, SearchMatch } from '../types'; import type { AIGroupExpansionLevel } from '@renderer/types/groups'; @@ -17,6 +16,9 @@ import type { StateCreator } from 'zustand'; type DetailItemType = 'thinking' | 'text' | 'linked-tool' | 'subagent'; +/** Maximum number of search matches to track. Beyond this, results are capped. */ +const MAX_SEARCH_MATCHES = 500; + const isSearchDebugEnabled = (): boolean => { if (typeof window === 'undefined') return false; try { @@ -57,6 +59,10 @@ export interface ConversationSlice { searchResultCount: number; currentSearchIndex: number; searchMatches: SearchMatch[]; + /** True when total matches exceeded the cap and results were truncated */ + searchResultsCapped: boolean; + /** Item IDs that contain at least one search match — used by components to skip re-renders */ + searchMatchItemIds: Set; // Auto-expand state for search results /** AI group IDs that should be expanded to show search results */ @@ -126,6 +132,8 @@ export const createConversationSlice: StateCreator { - const mdMatches = findMarkdownSearchMatches(text, lowerQuery); - for (const mdMatch of mdMatches) { + if (capped) return; + const lowerText = text.toLowerCase(); + let pos = 0; + let matchIndexInItem = 0; + while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) { + if (matches.length >= MAX_SEARCH_MATCHES) { + capped = true; + return; + } matches.push({ itemId, itemType, - matchIndexInItem: mdMatch.matchIndexInItem, + matchIndexInItem, globalIndex, displayItemId, }); + matchIndexInItem++; globalIndex++; + pos += lowerQuery.length; } }; for (const item of conversation.items) { + if (capped) break; if (item.type === 'user') { const raw = item.group.content.rawText ?? item.group.content.text ?? ''; const text = stripAgentBlocks(raw); - addMarkdownMatches(text, item.group.id, 'user'); + addPlainTextMatches(text, item.group.id, 'user'); } else if (item.type === 'ai') { - // For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents) const aiGroup = item.group; const itemId = aiGroup.id; const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false); if (lastOutput?.type === 'text' && lastOutput.text) { - // Last output text - displayItemId indicates this is lastOutput content - addMarkdownMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); + addPlainTextMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); } - // Skip tool_result type - only searching text output } - // Skip system items entirely } if (isSearchDebugEnabled()) { @@ -293,11 +309,19 @@ export const createConversationSlice: StateCreator(); + for (const match of matches) { + matchItemIds.add(match.itemId); + } + set({ searchQuery: query, searchResultCount: matches.length, currentSearchIndex: matches.length > 0 ? 0 : -1, searchMatches: matches, + searchResultsCapped: capped, + searchMatchItemIds: matchItemIds, }); }, @@ -406,6 +430,8 @@ export const createConversationSlice: StateCreator(); function getCachedSegments(markdown: string): string[] { @@ -154,6 +154,9 @@ export interface MarkdownSearchMatch { export function findMarkdownSearchMatches(markdown: string, query: string): MarkdownSearchMatch[] { if (!query || !markdown) return []; + // Fast pre-filter: skip expensive markdown parsing if query doesn't appear in raw text + if (!markdown.toLowerCase().includes(query.toLowerCase())) return []; + const segments = getCachedSegments(markdown); const lowerQuery = query.toLowerCase(); const matches: MarkdownSearchMatch[] = []; @@ -178,6 +181,9 @@ export function findMarkdownSearchMatches(markdown: string, query: string): Mark export function countMarkdownSearchMatches(markdown: string, query: string): number { if (!query || !markdown) return 0; + // Fast pre-filter: skip expensive markdown parsing if query doesn't appear in raw text + if (!markdown.toLowerCase().includes(query.toLowerCase())) return 0; + const segments = getCachedSegments(markdown); const lowerQuery = query.toLowerCase(); let count = 0; diff --git a/test/main/services/discovery/SessionSearcher.test.ts b/test/main/services/discovery/SessionSearcher.test.ts index 5f18765f..385fa83c 100644 --- a/test/main/services/discovery/SessionSearcher.test.ts +++ b/test/main/services/discovery/SessionSearcher.test.ts @@ -76,7 +76,7 @@ describe('SessionSearcher', () => { ).toBe(true); }); - it('does not produce phantom matches for code fence language identifiers', async () => { + it('matches text in code fences with plain text search', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-md-')); tempDirs.push(projectsDir); @@ -110,13 +110,11 @@ describe('SessionSearcher', () => { const searcher = new SessionSearcher(projectsDir); const result = await searcher.searchSessions(projectId, 'tsx', 50); - // "tsx" should match in user text ("Show me tsx code") but NOT in the - // code fence language identifier (```tsx). It should also not match in - // the code block content since "const x = 1;" doesn't contain "tsx". + // Plain text search: "tsx" matches in user text AND in the code fence identifier const userResults = result.results.filter((r) => r.itemType === 'user'); const aiResults = result.results.filter((r) => r.itemType === 'ai'); expect(userResults).toHaveLength(1); - expect(aiResults).toHaveLength(0); + expect(aiResults).toHaveLength(1); }); }); From 22387ca0cbfab4dcd26e3a1faa8d7df62cf331c3 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:34:22 +0200 Subject: [PATCH 014/113] perf: add search debounce and increase global search timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchBar: 300ms debounce with local input state, flush on Enter - CommandPalette: global search debounce 200→400ms - Show "500+" when search results are capped - Fix: syncSearchMatchesWithRendered now updates searchMatchItemIds Ported from upstream 5c7f921e (SearchBar/CommandPalette parts). --- .../components/search/CommandPalette.tsx | 2 +- src/renderer/components/search/SearchBar.tsx | 61 +++++++++++++++++-- .../store/slices/conversationSlice.ts | 7 +++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx index 58d8ebe5..86e50668 100644 --- a/src/renderer/components/search/CommandPalette.tsx +++ b/src/renderer/components/search/CommandPalette.tsx @@ -288,7 +288,7 @@ export const CommandPalette = (): React.JSX.Element | null => { setLoading(false); } } - }, 200); + }, 400); return () => clearTimeout(timeoutId); }, [query, selectedProjectId, commandPaletteOpen, searchMode, globalSearchEnabled]); diff --git a/src/renderer/components/search/SearchBar.tsx b/src/renderer/components/search/SearchBar.tsx index d7e6b448..ab620535 100644 --- a/src/renderer/components/search/SearchBar.tsx +++ b/src/renderer/components/search/SearchBar.tsx @@ -1,14 +1,19 @@ /** * SearchBar - In-session search interface component. * Appears at the top of the chat view when Cmd+F is pressed. + * + * Uses a local input state with debouncing to avoid triggering expensive + * search on every keystroke. */ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useStore } from '@renderer/store'; import { ChevronDown, ChevronUp, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +const SEARCH_DEBOUNCE_MS = 300; + interface SearchBarProps { tabId?: string; } @@ -19,6 +24,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = searchVisible, searchResultCount, currentSearchIndex, + searchResultsCapped, conversation, setSearchQuery, hideSearch, @@ -30,6 +36,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = searchVisible: s.searchVisible, searchResultCount: s.searchResultCount, currentSearchIndex: s.currentSearchIndex, + searchResultsCapped: s.searchResultsCapped, conversation: tabId ? (s.tabSessionData[tabId]?.conversation ?? s.conversation) : s.conversation, @@ -40,8 +47,43 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = })) ); + // Local input value for responsive typing — debounced before triggering search + const [localQuery, setLocalQuery] = useState(searchQuery); + const debounceRef = useRef>( + 0 as unknown as ReturnType + ); + const inputRef = useRef(null); + // Sync local state when store query changes externally (e.g., hideSearch clears it) + useEffect(() => { + setLocalQuery(searchQuery); + }, [searchQuery]); + + // Debounced search dispatch + const handleChange = useCallback( + (value: string) => { + setLocalQuery(value); + clearTimeout(debounceRef.current); + + // Clear immediately when input is emptied + if (!value.trim()) { + setSearchQuery('', conversation); + return; + } + + debounceRef.current = setTimeout(() => { + setSearchQuery(value, conversation); + }, SEARCH_DEBOUNCE_MS); + }, + [conversation, setSearchQuery] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => clearTimeout(debounceRef.current); + }, []); + // Auto-focus input when search becomes visible useEffect(() => { if (searchVisible && inputRef.current) { @@ -55,6 +97,11 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = if (e.key === 'Escape') { hideSearch(); } else if (e.key === 'Enter') { + // Flush any pending debounce immediately on Enter + clearTimeout(debounceRef.current); + if (localQuery !== searchQuery) { + setSearchQuery(localQuery, conversation); + } if (e.shiftKey) { previousSearchResult(); } else { @@ -67,14 +114,18 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = return null; } + const resultLabel = searchResultsCapped + ? `${currentSearchIndex + 1} of ${searchResultCount}+` + : `${currentSearchIndex + 1} of ${searchResultCount}`; + return (
{/* Search input */} setSearchQuery(e.target.value, conversation)} + value={localQuery} + onChange={(e) => handleChange(e.target.value)} onKeyDown={handleKeyDown} placeholder="Find in conversation..." className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none" @@ -83,9 +134,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null = {/* Result count */} {searchQuery && ( - {searchResultCount > 0 - ? `${currentSearchIndex + 1} of ${searchResultCount}` - : 'No results'} + {searchResultCount > 0 ? resultLabel : 'No results'} )} diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts index d6ca21ac..218fcae9 100644 --- a/src/renderer/store/slices/conversationSlice.ts +++ b/src/renderer/store/slices/conversationSlice.ts @@ -397,10 +397,17 @@ export const createConversationSlice: StateCreator(); + for (const match of nextMatches) { + syncedMatchItemIds.add(match.itemId); + } + set({ searchMatches: nextMatches, searchResultCount: nextMatches.length, currentSearchIndex: newCurrentIndex, + searchMatchItemIds: syncedMatchItemIds, }); }, From 141d0e22d9232e55b3aa2dc921a26634f840e35d Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:47:27 +0200 Subject: [PATCH 015/113] feat(team): implement startTaskByUser functionality - Added a new IPC handler for starting tasks triggered by users, ensuring that the task owner is always notified. - Introduced `startTaskByUser` method in `TeamDataService` to handle task initiation and notifications. - Updated relevant components and API interfaces to support the new functionality, including changes in the UI to call `startTaskByUser` instead of the previous `startTask`. - Documented agent block usage for internal instructions in CLAUDE.md. This enhancement improves user interaction with task management by providing a clear mechanism for user-initiated task starts. --- CLAUDE.md | 7 +++ src/main/ipc/teams.ts | 21 +++++++ src/main/services/team/TeamDataService.ts | 56 +++++++++++++++++++ .../services/team/TeamProvisioningService.ts | 8 +-- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 8 +++ src/renderer/api/httpClient.ts | 6 ++ .../components/team/TeamDetailView.tsx | 6 +- src/renderer/store/slices/teamSlice.ts | 9 +++ src/shared/constants/agentBlocks.ts | 10 ++++ src/shared/types/api.ts | 1 + 11 files changed, 127 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0445ba0f..adfa3ce2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,13 @@ Path encoding: `/Users/name/project` → `-Users-name-project` ## Critical Concepts +### Agent Blocks +- Use `wrapAgentBlock(text)` from `@shared/constants/agentBlocks` to wrap agent-only content. + Do NOT manually concatenate `AGENT_BLOCK_OPEN/CLOSE` — the wrapper handles trimming and formatting. +- `stripAgentBlocks(text)` — removes agent blocks for UI display +- `unwrapAgentBlock(block)` — extracts content from a single block +- Agent blocks are hidden from the user in UI, used for internal instructions between agents. + ### isMeta Flag - `isMeta: false` = Real user message (creates new chunks) - `isMeta: true` = Internal message (tool results, system-generated) diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f28bb017..1b12e3b9 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -50,6 +50,7 @@ import { TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, + TEAM_START_TASK_BY_USER, TEAM_STOP, TEAM_TOOL_APPROVAL_READ_FILE, TEAM_TOOL_APPROVAL_RESPOND, @@ -332,6 +333,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); + ipcMain.handle(TEAM_START_TASK_BY_USER, handleStartTaskByUser); ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks); ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember); @@ -393,6 +395,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); + ipcMain.removeHandler(TEAM_START_TASK_BY_USER); ipcMain.removeHandler(TEAM_GET_ALL_TASKS); ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_MEMBER); @@ -2116,6 +2119,24 @@ async function handleStartTask( ); } +async function handleStartTaskByUser( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedTaskId = validateTaskId(taskId); + if (!validatedTaskId.valid) { + return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('startTaskByUser', () => + getTeamDataService().startTaskByUser(validatedTeamName.value!, validatedTaskId.value!) + ); +} + async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise> { setCurrentMainOp('team:getAllTasks'); const startedAt = Date.now(); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0070ae59..56e7fb13 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -12,6 +12,7 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, stripAgentBlocks, + wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; @@ -927,6 +928,61 @@ export class TeamDataService { return { notifiedOwner: !!task.owner }; } + /** + * Start a task triggered by the user via UI. + * Unlike startTask(), this always notifies the owner (including the lead in solo teams). + */ + async startTaskByUser(teamName: string, taskId: string): Promise<{ notifiedOwner: boolean }> { + const tasks = await this.taskReader.getTasks(teamName); + const task = tasks.find((t) => t.id === taskId); + if (!task) { + throw new Error(`Task #${taskId} not found`); + } + if (task.status !== 'pending') { + throw new Error(`Task #${taskId} is not pending (current: ${task.status})`); + } + + this.getController(teamName).tasks.startTask(taskId, 'user'); + + if (task.owner) { + try { + const parts = [ + `**start working on task now** ${this.getTaskLabel(task)} "${task.subject}"`, + ]; + if (task.description?.trim()) { + parts.push(`\nDetails:\n${task.description.trim()}`); + } + if (task.prompt?.trim()) { + parts.push(`\nInstructions:\n${task.prompt.trim()}`); + } + parts.push( + '', + wrapAgentBlock( + [ + `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, + `To fetch the full task context (description, comments, attachments) use:`, + `task_get { teamName: "${teamName}", taskId: "${task.id}" }`, + `When done, update task status:`, + `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, + ].join('\n') + ) + ); + await this.sendMessage(teamName, { + member: task.owner, + from: 'user', + text: parts.join('\n'), + taskRefs: task.descriptionTaskRefs, + summary: `Start working on ${this.getTaskLabel(task)}`, + source: 'system_notification', + }); + } catch { + // Best-effort notification + } + } + + return { notifiedOwner: !!task.owner }; + } + async updateTaskStatus( teamName: string, taskId: string, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2eeaa9db..f5ef1501 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,6 +19,7 @@ import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN, stripAgentBlocks, + wrapAgentBlock, } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_PREFIX_TAG, @@ -387,11 +388,8 @@ async function ensureCwdExists(cwd: string): Promise { } } -function wrapInAgentBlock(text: string): string { - const trimmed = text.trim(); - if (trimmed.length === 0) return ''; - return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; -} +/** @deprecated Use wrapAgentBlock from @shared/constants/agentBlocks instead. */ +const wrapInAgentBlock = wrapAgentBlock; function indentMultiline(text: string, indent: string): string { return text diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 3f4b47db..7ced81a6 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -298,6 +298,9 @@ export const TEAM_GET_MEMBER_STATS = 'team:getMemberStats'; /** Start a pending task (transition to in_progress + notify agent) */ export const TEAM_START_TASK = 'team:startTask'; +/** Start a pending task from UI — always notifies owner (including lead in solo teams) */ +export const TEAM_START_TASK_BY_USER = 'team:startTaskByUser'; + /** Get all tasks across all teams */ export const TEAM_GET_ALL_TASKS = 'team:getAllTasks'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 6a5eda34..e9f97771 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -152,6 +152,7 @@ import { TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, + TEAM_START_TASK_BY_USER, TEAM_STOP, TEAM_TOOL_APPROVAL_EVENT, TEAM_TOOL_APPROVAL_READ_FILE, @@ -872,6 +873,13 @@ const electronAPI: ElectronAPI = { startTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult<{ notifiedOwner: boolean }>(TEAM_START_TASK, teamName, taskId); }, + startTaskByUser: async (teamName: string, taskId: string) => { + return invokeIpcWithResult<{ notifiedOwner: boolean }>( + TEAM_START_TASK_BY_USER, + teamName, + taskId + ); + }, processSend: async (teamName: string, message: string) => { return invokeIpcWithResult(TEAM_PROCESS_SEND, teamName, message); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index a33577a2..2160f340 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -756,6 +756,12 @@ export class HttpAPIClient implements ElectronAPI { startTask: async (_teamName: string, _taskId: string): Promise<{ notifiedOwner: boolean }> => { throw new Error('Team start task is not available in browser mode'); }, + startTaskByUser: async ( + _teamName: string, + _taskId: string + ): Promise<{ notifiedOwner: boolean }> => { + throw new Error('Team start task by user is not available in browser mode'); + }, processSend: async (_teamName: string, _message: string): Promise => { throw new Error('Team process communication is not available in browser mode'); }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e6605826..875a1fa8 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -261,7 +261,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendTeamMessage, requestReview, createTeamTask, - startTask, + startTaskByUser, deleteTeam, openTeamsTab, closeTab, @@ -310,7 +310,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sendTeamMessage: s.sendTeamMessage, requestReview: s.requestReview, createTeamTask: s.createTeamTask, - startTask: s.startTask, + startTaskByUser: s.startTaskByUser, deleteTeam: s.deleteTeam, openTeamsTab: s.openTeamsTab, closeTab: s.closeTab, @@ -1537,7 +1537,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onStartTask={(taskId) => { void (async () => { try { - const result = await startTask(teamName, taskId); + const result = await startTaskByUser(teamName, taskId); if (data?.isAlive) { const task = data.tasks.find((t) => t.id === taskId); try { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index d33f2a4e..7d84b85c 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -583,6 +583,7 @@ export interface TeamSlice { ) => Promise; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; + startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise; updateTaskFields: ( @@ -1443,6 +1444,14 @@ export const createTeamSlice: StateCreator = (set, return result; }, + startTaskByUser: async (teamName: string, taskId: string) => { + const result = await unwrapIpc('team:startTaskByUser', () => + api.teams.startTaskByUser(teamName, taskId) + ); + await get().refreshTeamData(teamName); + return result; + }, + updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => { await unwrapIpc('team:updateTaskStatus', () => api.teams.updateTaskStatus(teamName, taskId, status) diff --git a/src/shared/constants/agentBlocks.ts b/src/shared/constants/agentBlocks.ts index 3d596c39..09aa655e 100644 --- a/src/shared/constants/agentBlocks.ts +++ b/src/shared/constants/agentBlocks.ts @@ -81,6 +81,16 @@ export function extractAgentBlockContents(text: string): string[] { */ export const AGENT_BLOCK_REGEX = new RegExp(AGENT_BLOCK_PATTERN, 'g'); +/** + * Wraps text in agent-only block markers. + * Use this instead of manually concatenating AGENT_BLOCK_OPEN/CLOSE. + */ +export function wrapAgentBlock(text: string): string { + const trimmed = text.trim(); + if (trimmed.length === 0) return ''; + return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; +} + /** * Fenced code block marker for reply messages between agents. * diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 8451ab8b..5bae8b21 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -443,6 +443,7 @@ export interface TeamsAPI { fields: { subject?: string; description?: string } ) => Promise; startTask: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; + startTaskByUser: (teamName: string, taskId: string) => Promise<{ notifiedOwner: boolean }>; processSend: (teamName: string, message: string) => Promise; processAlive: (teamName: string) => Promise; aliveList: () => Promise; From 71db7f153bc3bec1f91382b5548c6828a6b884e0 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 14:47:52 +0200 Subject: [PATCH 016/113] feat(research): add comprehensive documentation on AI agent protocols and orchestration tools - Introduced multiple new markdown files detailing the Agent Client Protocol (ACP), AI agent orchestration landscape, and various tools for managing multi-agent systems. - Included in-depth analysis of protocol standards, governance structures, and emerging frameworks relevant to AI agent integration. - Documented key features, architecture, and integration potential of various desktop and CLI orchestrators, enhancing understanding of the current ecosystem. - Provided insights into best practices for integrating multi-provider agent support within the Electron framework. This documentation aims to serve as a foundational resource for developers and stakeholders involved in AI agent orchestration and integration. --- docs/research/acp-deep-dive.md | 847 +++++++++++++++++ .../ai-agent-protocols-and-routing.md | 782 ++++++++++++++++ docs/research/ai-orchestration-tools-part2.md | 705 ++++++++++++++ docs/research/ai-orchestration-tools-part3.md | 861 ++++++++++++++++++ docs/research/ai-orchestration-tools.md | 550 +++++++++++ .../research/best-abstraction-for-electron.md | 726 +++++++++++++++ docs/research/best-integration-approach.md | 406 +++++++++ docs/research/claude-coupling-analysis.md | 536 +++++++++++ docs/research/claude-kanban-dataflow.md | 431 +++++++++ .../research/cli-adapter-exhaustive-search.md | 305 +++++++ docs/research/mastra-integration-analysis.md | 756 +++++++++++++++ docs/research/mastra-vs-direct-mcp.md | 345 +++++++ docs/research/unified-cli-agent-interface.md | 533 +++++++++++ docs/research/unified-llm-api-tools.md | 571 ++++++++++++ docs/research/unified-mcp-architecture.md | 485 ++++++++++ 15 files changed, 8839 insertions(+) create mode 100644 docs/research/acp-deep-dive.md create mode 100644 docs/research/ai-agent-protocols-and-routing.md create mode 100644 docs/research/ai-orchestration-tools-part2.md create mode 100644 docs/research/ai-orchestration-tools-part3.md create mode 100644 docs/research/ai-orchestration-tools.md create mode 100644 docs/research/best-abstraction-for-electron.md create mode 100644 docs/research/best-integration-approach.md create mode 100644 docs/research/claude-coupling-analysis.md create mode 100644 docs/research/claude-kanban-dataflow.md create mode 100644 docs/research/cli-adapter-exhaustive-search.md create mode 100644 docs/research/mastra-integration-analysis.md create mode 100644 docs/research/mastra-vs-direct-mcp.md create mode 100644 docs/research/unified-cli-agent-interface.md create mode 100644 docs/research/unified-llm-api-tools.md create mode 100644 docs/research/unified-mcp-architecture.md diff --git a/docs/research/acp-deep-dive.md b/docs/research/acp-deep-dive.md new file mode 100644 index 00000000..bad16629 --- /dev/null +++ b/docs/research/acp-deep-dive.md @@ -0,0 +1,847 @@ +# Agent Client Protocol (ACP) — Deep Technical Analysis + +> Дата исследования: 2026-03-24 +> Контекст: интеграция ACP в Claude Agent Teams UI (Electron 40.x) + +--- + +## 1. Что такое ACP? + +**Agent Client Protocol (ACP)** — это открытый стандарт коммуникации между редакторами кода (IDE) и AI-агентами. Создан Zed Industries, поддерживается JetBrains с октября 2025. + +**Аналогия:** LSP (Language Server Protocol) стандартизировал интеграцию языковых серверов с редакторами. ACP делает то же самое для AI coding agents. + +**Проблема, которую решает:** +- Каждый редактор делал кастомную интеграцию для каждого агента (M x N) +- Агенты были привязаны к конкретным IDE +- ACP сводит M x N → M + N (агент реализует ACP один раз, работает во всех IDE) + +**Лицензия:** Apache 2.0 +**Governance:** Lead Maintainers — Ben Brandt (Zed Industries), Sergey Ignatov (JetBrains) + +**Источники:** +- Спецификация: https://agentclientprotocol.com/ +- GitHub: https://github.com/agentclientprotocol/agent-client-protocol +- Zed ACP: https://zed.dev/acp + +> **ВАЖНО:** Существует ТРИ разных протокола с аббревиатурой ACP: +> 1. **Agent Client Protocol** (Zed/JetBrains) — редактор ↔ агент. **Это наш фокус.** +> 2. **Agent Communication Protocol** (IBM BeeAI) — агент ↔ агент. Сливается с A2A (Linux Foundation). Не релевантно. +> 3. **Agent Connect Protocol** (Agntcy Collective) — REST API для remote agents. Не релевантно. + +--- + +## 2. Архитектура протокола + +### 2.1 Транспорт + +| Режим | Транспорт | Формат | Статус | +|-------|-----------|--------|--------| +| **Локальный** | stdio (stdin/stdout) | NDJSON (newline-delimited JSON) | Стабильный | +| **TCP** | TCP socket (порт) | NDJSON | Стабильный (Copilot CLI: `--acp --port 8080`) | +| **Remote** | HTTP / WebSocket | JSON-RPC | **Work in progress** | + +Основной режим: **JSON-RPC 2.0 поверх NDJSON через stdio**. Клиент (IDE) spawn'ит агента как subprocess, stdin/stdout становятся транспортом. + +### 2.2 Типы сообщений + +Два типа (JSON-RPC 2.0): +- **Methods** — request-response пары, ожидают result или error +- **Notifications** — односторонние сообщения, без ответа + +### 2.3 Lifecycle + +``` +Client Agent + | | + |------- initialize --------------->| (версия протокола + capabilities) + |<------ InitializeResponse --------| (agent capabilities) + | | + |------- authenticate ------------->| (если требуется) + |<------ AuthenticateResponse ------| + | | + |------- session/new --------------->| (cwd, mcpServers[]) + |<------ NewSessionResponse ---------| (sessionId) + | | + |------- session/prompt ------------->| (prompt content) + |<~~~~~~ session/update (notification)| (streaming chunks, tool calls, plans) + |<~~~~~~ session/update | + |<--request_permission --------------| (tool approval) + |------- permission response ------->| + |<~~~~~~ session/update | + |<------ PromptResponse -------------| (stopReason) + | | + |------- session/prompt (next) ----->| + | ... | +``` + +### 2.4 Session Update Events (стриминг) + +Во время `prompt` агент шлёт `session/update` notifications: + +| Event | Описание | +|-------|----------| +| `agent_message_chunk` | Текстовый чанк от агента (streaming) | +| `agent_thought_chunk` | Мысли агента (thinking) | +| `user_message_chunk` | Эхо пользовательского ввода | +| `tool_call` | Новый вызов инструмента (pending/completed) | +| `tool_call_update` | Обновление статуса вызова инструмента | +| `plan` | План действий с приоритетами и статусами | +| `available_commands_update` | Обновление доступных команд | +| `config_option_update` | Изменение конфигурации | +| `current_mode_update` | Смена режима сессии | +| `session_info_update` | Метаданные сессии (title, activity) | +| `usage_update` | Потребление токенов (draft) | + +### 2.5 Client-Provided Methods + +Клиент (IDE) предоставляет агенту доступ к: + +| Метод | Описание | Required? | +|-------|----------|-----------| +| `session/request_permission` | Запрос разрешения на выполнение инструмента | **Required** | +| `fs/read_text_file` | Чтение файла | Optional | +| `fs/write_text_file` | Запись файла | Optional | +| `terminal/create` | Создание терминала | Optional | +| `terminal/output` | Получение вывода терминала | Optional | +| `terminal/wait_for_exit` | Ожидание завершения | Optional | +| `terminal/kill` | Завершение процесса | Optional | +| `terminal/release` | Освобождение ресурсов | Optional | + +### 2.6 MCP Integration + +ACP переиспользует JSON-представления из MCP где возможно. Агент может принимать MCP сервера при создании сессии: + +```typescript +connection.newSession({ + cwd: '/path/to/project', + mcpServers: [ + { type: 'stdio', command: 'node', args: ['mcp-server.js'] }, + { type: 'http', url: 'https://mcp.example.com', headers: {} }, + { type: 'sse', url: 'https://mcp.example.com/sse', headers: {} }, + ], +}); +``` + +--- + +## 3. TypeScript SDK — API Surface + +### 3.1 Package Info + +| Характеристика | Значение | +|----------------|----------| +| **npm** | `@agentclientprotocol/sdk` | +| **Версия** | 0.16.1 (март 2026) | +| **Размер** | 863 kB | +| **Dependencies** | **0** (zero dependencies!) | +| **Лицензия** | Apache-2.0 | +| **Dependents** | 245+ пакетов | +| **GitHub stars** | 122 | +| **Contributors** | 31 | +| **Commits** | 544 | +| **Used by** | 823+ проектов | + +**Факт:** ранее публиковался как `@zed-industries/agent-client-protocol`, переименован. + +### 3.2 Exported Classes (4) + +```typescript +import { + ClientSideConnection, // Для клиентов (IDE) — наш интерес + AgentSideConnection, // Для агентов (сервер) + TerminalHandle, // Управление терминалом + RequestError, // Типизированная ошибка +} from '@agentclientprotocol/sdk'; +``` + +### 3.3 Exported Interfaces (2) + +```typescript +interface Client { + requestPermission(params: RequestPermissionRequest): Promise; + sessionUpdate(params: SessionNotification): Promise; + writeTextFile?(params: WriteTextFileRequest): Promise; + readTextFile?(params: ReadTextFileRequest): Promise; + // terminal methods... +} + +interface Agent { + initialize(params: InitializeRequest): Promise; + newSession(params: NewSessionRequest): Promise; + authenticate?(params: AuthenticateRequest): Promise; + prompt(params: PromptRequest): Promise; + cancel?(params: CancelNotification): Promise; + setSessionMode?(params: SetSessionModeRequest): Promise; + // ... +} +``` + +### 3.4 Exported Functions (1) + Variables (3) + +```typescript +// Единственная утилитарная функция — создаёт NDJSON stream +function ndJsonStream(input: WritableStream, output: ReadableStream): Stream; + +// Константы +const PROTOCOL_VERSION: string; // Текущая версия протокола +const AGENT_METHODS: string[]; // Список методов агента +const CLIENT_METHODS: string[]; // Список методов клиента +``` + +### 3.5 Type Aliases (~180+) + +Полный список категорий типов: + +- **Content:** `TextContent`, `ImageContent`, `AudioContent`, `Content`, `ContentBlock`, `ContentChunk` +- **Authentication:** `AuthMethod`, `AuthCapabilities`, `AuthenticateRequest/Response` +- **Sessions:** `SessionId`, `SessionInfo`, `SessionCapabilities`, `SessionUpdate`, `SessionMode` +- **Tools:** `ToolCall`, `ToolCallUpdate`, `ToolCallId`, `ToolCallStatus`, `ToolKind` +- **Permissions:** `RequestPermissionRequest/Response`, `PermissionOption`, `PermissionOptionKind` +- **Plans:** `Plan`, `PlanEntry`, `PlanEntryStatus`, `PlanEntryPriority` +- **Diffs:** `Diff` (`path`, `oldText`, `newText`) +- **File System:** `ReadTextFileRequest/Response`, `WriteTextFileRequest/Response` +- **Terminals:** `Terminal`, `CreateTerminalRequest/Response`, `TerminalExitStatus` +- **MCP:** `McpCapabilities`, `McpServer`, `McpServerStdio`, `McpServerHttp`, `McpServerSse` +- **Protocol:** `InitializeRequest/Response`, `PromptRequest/Response`, `StopReason`, `Cost`, `Usage` +- **Elicitation (draft):** `ElicitationRequest/Response`, `ElicitationSchema` — формы ввода от агента +- **Config:** `SessionConfigOption`, `SessionConfigBoolean`, `SessionConfigSelect` +- **Models:** `ModelId`, `ModelInfo` + +### 3.6 ClientSideConnection — Full API + +```typescript +class ClientSideConnection { + constructor(toClient: (agent: Agent) => Client, stream: Stream); + + // Properties + signal: AbortSignal; // Aborts when connection closes + closed: Promise; // Resolves when connection ends + + // Core methods + initialize(params: InitializeRequest): Promise; + newSession(params: NewSessionRequest): Promise; + prompt(params: PromptRequest): Promise; + authenticate(params: AuthenticateRequest): Promise; + + // Session management + loadSession(params: LoadSessionRequest): Promise; // Resume previous + listSessions(params: ListSessionsRequest): Promise; // List available + setSessionMode(params: SetSessionModeRequest): Promise; + setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise; + + // Unstable/experimental + unstable_forkSession(params: ForkSessionRequest): Promise; + unstable_resumeSession(params: ResumeSessionRequest): Promise; + unstable_closeSession(params: CloseSessionRequest): Promise; + unstable_setSessionModel(params: SetSessionModelRequest): Promise; + unstable_logout(params: LogoutRequest): Promise; + + // Notifications + cancel(params: CancelNotification): Promise; // Cancel ongoing prompt + + // Extensibility + extMethod(method: string, params: Record): Promise>; + extNotification(method: string, params: Record): Promise; +} +``` + +--- + +## 4. Какие агенты поддерживают ACP? + +### Подтверждённые (с доказательствами) + +| Агент | Поддержка ACP | Как реализовано | Источник | +|-------|--------------|-----------------|----------| +| **Gemini CLI** | Нативная (reference implementation) | Встроенный ACP-сервер | [zed.dev/acp](https://zed.dev/acp) | +| **Claude Code** | Через адаптер | `@zed-industries/claude-code-acp` (npm, Apache 2.0) | [GitHub](https://github.com/zed-industries/claude-agent-acp) | +| **Codex CLI** | Через community adapter | Zed adapter | [zed.dev/docs/ai/external-agents](https://zed.dev/docs/ai/external-agents) | +| **GitHub Copilot CLI** | Нативная (public preview) | `copilot --acp` / `copilot --acp --port 8080` | [GitHub Blog](https://github.blog/changelog/2026-01-28-acp-support-in-copilot-cli-is-now-in-public-preview/) | +| **Goose** (Block) | Нативная | Встроенный ACP-сервер | [goose blog](https://block.github.io/goose/blog/2025/10/24/intro-to-agent-client-protocol-acp/) | +| **Junie** (JetBrains) | Нативная | Встроена в JetBrains AI Assistant | [JetBrains](https://www.jetbrains.com/help/ai-assistant/acp.html) | +| **Cline** | Нативная | Встроенный ACP-сервер | [DeepWiki](https://deepwiki.com/cline/cline/12.5-agent-client-protocol-(acp)) | +| **Kiro CLI** | Нативная | Встроенный ACP-сервер | [Kiro docs](https://kiro.dev/docs/cli/acp/) | +| **OpenCode** | Нативная | Встроенный ACP-сервер | [opencode.ai](https://opencode.ai/docs/acp/) | +| **Augment Code** | Нативная | ACP Registry | [Registry](https://agentclientprotocol.com/registry) | +| **Qwen Code** | Нативная | ACP Registry | VS Code ACP Client | + +**Claude Code НЕ имеет нативного `--acp` флага** (есть [Feature Request #6686](https://github.com/anthropics/claude-code/issues/6686)). Работает через `@zed-industries/claude-code-acp` адаптер, который использует Claude Agent SDK. + +### IDE/Клиенты с ACP поддержкой + +| Клиент | Статус | +|--------|--------| +| **Zed** | Нативная (создатели протокола) | +| **JetBrains** (IntelliJ, PyCharm и др.) | Нативная (co-maintainer) | +| **Neovim** | Через плагины (CodeCompanion, avante.nvim) | +| **Emacs** | Community extensions | +| **Marimo** (Python notebooks) | Встроенная | +| **VS Code** | **НЕТ** (ключевой вопрос для экосистемы) | +| **Cursor** | **НЕТ** (может появиться если будет спрос) | + +--- + +## 5. Конкретный пример кода (из SDK) + +### Client (IDE side) + +```typescript +import { spawn } from 'node:child_process'; +import { Writable, Readable } from 'node:stream'; +import * as acp from '@agentclientprotocol/sdk'; + +class MyClient implements acp.Client { + async requestPermission(params: acp.RequestPermissionRequest): Promise { + // UI показывает dialog с params.options + return { + outcome: { outcome: 'selected', optionId: params.options[0].optionId }, + }; + } + + async sessionUpdate(params: acp.SessionNotification): Promise { + const update = params.update; + switch (update.sessionUpdate) { + case 'agent_message_chunk': + if (update.content.type === 'text') { + console.log(update.content.text); // Streaming text + } + break; + case 'tool_call': + console.log(`Tool: ${update.title} (${update.status})`); + break; + case 'tool_call_update': + console.log(`Tool ${update.toolCallId}: ${update.status}`); + break; + case 'plan': + // Plan entries with status/priority + break; + } + } + + async readTextFile(params: acp.ReadTextFileRequest): Promise { + const content = fs.readFileSync(params.path, 'utf-8'); + return { content }; + } + + async writeTextFile(params: acp.WriteTextFileRequest): Promise { + fs.writeFileSync(params.path, params.content); + return {}; + } +} + +async function main() { + // Spawn agent process + const agentProcess = spawn('claude', ['--acp'], { + stdio: ['pipe', 'pipe', 'inherit'], + }); + + // Create NDJSON stream over stdio + const input = Writable.toWeb(agentProcess.stdin!); + const output = Readable.toWeb(agentProcess.stdout!) as ReadableStream; + const stream = acp.ndJsonStream(input, output); + + // Create connection + const client = new MyClient(); + const connection = new acp.ClientSideConnection((_agent) => client, stream); + + // Initialize + const initResult = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: { create: true, output: true, kill: true }, + }, + }); + + // Create session + const session = await connection.newSession({ + cwd: '/path/to/project', + mcpServers: [], + }); + + // Send prompt (blocks until agent completes turn) + const result = await connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'Fix the bug in main.ts' }], + }); + + console.log(`Stop reason: ${result.stopReason}`); // 'end_turn' | 'cancelled' | ... +} +``` + +### Process spawning + +**ACP SDK НЕ управляет spawn'ом процесса.** Это ответственность клиента. SDK берёт на себя только протокол поверх уже готового stream (stdin/stdout). + +```typescript +// ACP SDK expects web streams: +const input = Writable.toWeb(childProcess.stdin!); // WritableStream +const output = Readable.toWeb(childProcess.stdout!); // ReadableStream +const stream = acp.ndJsonStream(input, output); // ACP Stream +``` + +--- + +## 6. ACP vs MCP — Различия + +| Аспект | MCP (Model Context Protocol) | ACP (Agent Client Protocol) | +|--------|-----|-----| +| **Создатель** | Anthropic | Zed Industries + JetBrains | +| **Фокус** | Инструменты/данные для модели | Коммуникация IDE ↔ агент | +| **Аналогия** | "Дать человеку лучшие инструменты" | "Собрать команду из людей" | +| **Отношение** | **Что** (доступ к данным/tools) | **Где** (где агент живёт в workflow) | +| **Протокол** | JSON-RPC 2.0 поверх stdio/SSE/HTTP | JSON-RPC 2.0 поверх NDJSON stdio | +| **Типы контента** | Tools, Resources, Prompts | Messages, Tool Calls, Plans, Diffs, Permissions | +| **Стейт** | Stateless на уровне протокола | Stateful (sessions, message history) | +| **Sessions** | Нет (транспортные сессии) | Да (conversation sessions с ID) | +| **Streaming** | Через SSE или notifications | session/update notifications | + +**Ключевое:** ACP и MCP комплементарны. ACP-сессия может принимать MCP-серверы (`mcpServers` в `newSession`). Агент использует MCP для доступа к инструментам, а ACP для общения с IDE. + +--- + +## 7. Зрелость и стабильность + +### Версионирование + +SDK на v0.16.1 (март 2026) — ещё **pre-1.0**. Много `unstable_` методов. + +### Timeline ключевых событий + +| Дата | Событие | +|------|---------| +| Сентябрь 2025 | Zed анонсирует ACP | +| Октябрь 2025 | JetBrains присоединяется | +| Октябрь 2025 | Gemini CLI — первая интеграция | +| Январь 2026 | Copilot CLI ACP public preview | +| Январь 2026 | ACP Registry запущен | +| Февраль 2026 | Session Config Options стабилизированы | +| Март 2026 | session/list + session_info_update стабилизированы | +| Март 2026 | SDK v0.16.1 | + +### Что в Draft (ещё не стабилизировано) + +- `session/close` — закрытие сессий +- `session/fork` — форк сессий +- `session/resume` — возобновление сессий +- Elicitation — формы ввода от агента +- Usage updates — статистика токенов +- Message IDs — идентификаторы сообщений +- Delete in Diff — удаление файлов через diff +- Next Edit Suggestions — предложения следующих правок + +### Breaking Changes + +Протокол на стадии 0.x — breaking changes возможны между минорными версиями. Rename пакета `@zed-industries/agent-client-protocol` → `@agentclientprotocol/sdk` уже произошёл. + +--- + +## 8. Анализ интеграции в наш Electron app + +### 8.1 Текущая архитектура (как мы работаем сейчас) + +Наш стек коммуникации с Claude Code: + +``` +Electron Main Process + └── TeamProvisioningService + ├── spawnCli() → ChildProcess (stream-json) + ├── stdin.write(NDJSON) → Claude CLI + ├── stdout → parse NDJSON lines + │ ├── type: "user" / "assistant" / "result" / "system" + │ ├── type: "control_request" (tool approval) + │ └── result.success → turn complete + └── stderr → logs, error detection +``` + +**Ключевые аргументы CLI:** +``` +--input-format stream-json --output-format stream-json +``` + +**Наша обработка:** +- `HANDLED_STREAM_JSON_TYPES = ['user', 'assistant', 'control_request', 'result', 'system']` +- `stdin.write(message + '\n')` — отправка +- Ручной парсинг NDJSON с carry buffer для неполных строк +- `control_request` → UI dialog для tool approval +- `result.success` → turn complete, process alive +- SIGKILL для остановки (SIGTERM вызывает cleanup) + +### 8.2 Что ACP заменил бы + +| Компонент | Сейчас (stream-json) | С ACP | +|-----------|---------------------|-------| +| **Spawn** | `spawnCli()` | Остаётся наш `spawnCli()` | +| **Transport** | Ручной NDJSON парсинг с carry buffer | `acp.ndJsonStream()` + `ClientSideConnection` | +| **Initialize** | Нет (просто шлём prompt) | `connection.initialize()` — capabilities negotiation | +| **Session** | Нет (implicit) | `connection.newSession()` — explicit session ID | +| **Prompt** | `stdin.write(JSON.stringify({type:'user',...}) + '\n')` | `connection.prompt({sessionId, prompt})` | +| **Streaming** | Ручной парсинг stdout строк | `sessionUpdate()` callback с typed events | +| **Tool approval** | `control_request` парсинг | `requestPermission()` callback | +| **File ops** | Нет (агент делает сам) | `readTextFile()` / `writeTextFile()` callbacks | +| **Terminal** | Нет | `terminal/*` callbacks | +| **Cancel** | SIGKILL | `connection.cancel()` (graceful) | + +### 8.3 Что ACP НЕ решает (нам всё ещё нужно) + +1. **Agent Teams orchestration** — ACP это one-agent ↔ one-client. Оркестрация команд, TaskCreate, SendMessage, TeamCreate — всё это наш domain logic поверх CLI-specific протокола. + +2. **stream-json специфика Claude Code** — Claude Code НЕ поддерживает `--acp` нативно. Он использует `--input-format stream-json --output-format stream-json`. ACP требует адаптер (`@zed-industries/claude-code-acp`), который внутри использует Claude Agent SDK. + +3. **Team file monitoring** — Наш `TeamConfigReader`, `TeamTaskReader`, `TeamInboxReader` мониторят файлы на диске. ACP не имеет concept of teams/tasks. + +4. **Cross-team communication** — Наш `cross_team_send`, inbox relay, sentinel messages — всё это специфика нашей архитектуры. + +5. **Post-compact context recovery** — Наши `pendingPostCompactReminder` и context reinjection — domain-specific. + +6. **Member spawn management** — Трекинг `MemberSpawnStatus`, reconnect, stall detection — наш код. + +7. **MCP config building** — `TeamMcpConfigBuilder` — наш код для сборки MCP конфигов. + +8. **Tool approval auto-resolve** — `shouldAutoAllow()` и custom rules — наша логика. + +### 8.4 Гипотетическая интеграция (Pseudocode) + +```typescript +// === ВАРИАНТ A: ACP для нового multi-agent клиента === +// Если бы Claude Code поддерживал --acp нативно + +import * as acp from '@agentclientprotocol/sdk'; +import { spawnCli } from '@main/utils/childProcess'; + +class TeamAgentClient implements acp.Client { + constructor( + private teamName: string, + private memberName: string, + private onUpdate: (event: SessionUpdate) => void, + private onPermission: (request: ToolApprovalRequest) => Promise, + ) {} + + async requestPermission(params: acp.RequestPermissionRequest) { + // Проксируем в наш UI через существующий tool approval flow + const approval = await this.onPermission(mapToOurFormat(params)); + return mapToAcpResponse(approval); + } + + async sessionUpdate(params: acp.SessionNotification) { + // Маппим ACP events в наши TeamChangeEvent'ы + const update = params.update; + switch (update.sessionUpdate) { + case 'agent_message_chunk': + this.onUpdate({ type: 'agent-text', text: update.content.text }); + break; + case 'tool_call': + this.onUpdate({ type: 'tool-call', ...mapToolCall(update) }); + break; + case 'plan': + this.onUpdate({ type: 'plan-update', entries: update.entries }); + break; + } + } +} + +async function spawnAgentWithAcp(claudePath: string, args: string[], cwd: string) { + // 1. Spawn process (наш существующий код) + const child = spawnCli(claudePath, ['--acp', ...args], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + + // 2. Create ACP connection (заменяет весь ручной NDJSON парсинг) + const input = Writable.toWeb(child.stdin!); + const output = Readable.toWeb(child.stdout!) as ReadableStream; + const stream = acp.ndJsonStream(input, output); + + const client = new TeamAgentClient(teamName, memberName, onUpdate, onPermission); + const connection = new acp.ClientSideConnection((_agent) => client, stream); + + // 3. Initialize + const initResult = await connection.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: { create: true, output: true, kill: true }, + }, + }); + + // 4. Create session with MCP servers + const mcpConfigPath = await mcpBuilder.writeConfigFile(); + const session = await connection.newSession({ + cwd, + mcpServers: [ + { type: 'stdio', command: 'node', args: [mcpServerPath] }, + ], + }); + + // 5. Send prompt + const result = await connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: 'text', text: provisioningPrompt }], + }); + + // 6. Graceful cancel instead of SIGKILL + await connection.cancel({ sessionId: session.sessionId }); + + return { connection, session, child }; +} +``` + +```typescript +// === ВАРИАНТ B: ACP как дополнительный протокол (реалистичный) === +// Claude Code -> stream-json (как сейчас) +// Другие агенты (Gemini, Codex, Copilot) -> ACP +// Наше приложение поддерживает ОБА протокола + +interface AgentConnection { + sendPrompt(text: string): Promise; + onMessage(callback: (msg: AgentMessage) => void): void; + cancel(): Promise; + close(): void; +} + +class StreamJsonConnection implements AgentConnection { + // Существующий код из TeamProvisioningService + // stream-json протокол Claude Code +} + +class AcpConnection implements AgentConnection { + private connection: acp.ClientSideConnection; + private sessionId: string; + + constructor(connection: acp.ClientSideConnection, sessionId: string) { + this.connection = connection; + this.sessionId = sessionId; + } + + async sendPrompt(text: string) { + await this.connection.prompt({ + sessionId: this.sessionId, + prompt: [{ type: 'text', text }], + }); + } + + async cancel() { + await this.connection.cancel({ sessionId: this.sessionId }); + } +} + +function createAgentConnection(agent: AgentType, child: ChildProcess): AgentConnection { + if (agent === 'claude-code') { + return new StreamJsonConnection(child); // Как сейчас + } + // Gemini CLI, Codex CLI, Copilot CLI и др. + const stream = acp.ndJsonStream( + Writable.toWeb(child.stdin!), + Readable.toWeb(child.stdout!) as ReadableStream, + ); + const conn = new acp.ClientSideConnection((_agent) => new AcpClient(), stream); + return new AcpConnection(conn, sessionId); +} +``` + +### 8.5 Ключевые технические проблемы интеграции + +#### Проблема 1: Web Streams vs Node Streams +ACP SDK использует Web Streams API (`ReadableStream`, `WritableStream`). Node.js child_process возвращает Node Streams. Нужна конвертация: +```typescript +const input = Writable.toWeb(child.stdin!); // node:stream → web stream +const output = Readable.toWeb(child.stdout!); // node:stream → web stream +``` +В Electron 40.x (Node 22+) эти конвертации доступны нативно. + +#### Проблема 2: Claude Code не поддерживает ACP +Claude Code использует `stream-json`, не ACP. Для ACP нужен `@zed-industries/claude-code-acp` адаптер (который в свою очередь использует Claude Agent SDK — отдельный npm пакет с Anthropic API key). + +**Наш текущий подход (прямой CLI)** не требует API key — используется auth token пользователя. Адаптер `claude-code-acp` требует `ANTHROPIC_API_KEY`, что делает его непрактичным для нашего zero-config подхода. + +#### Проблема 3: Blocking prompt() +`connection.prompt()` блокирует до завершения turn'а. Streaming идёт через callback'и (`sessionUpdate`). Это отличается от нашего подхода где мы парсим stdout строку за строкой. + +#### Проблема 4: Team orchestration +ACP — это 1:1 (один клиент, один агент). У нас N агентов в команде. Каждый агент = отдельный ACP connection. Координация между ними — полностью наш код. + +--- + +## 9. Что код мы СОХРАНЯЕМ vs что ACP заменяет + +### Сохраняем (наш domain logic): + +| Файл/Модуль | Причина | +|-------------|---------| +| `TeamProvisioningService.ts` (80%) | Team orchestration, member management, task tracking | +| `TeamConfigReader.ts` | File-based team config monitoring | +| `TeamTaskReader.ts` | File-based task monitoring | +| `TeamInboxReader.ts` | File-based inbox monitoring | +| `TeamMcpConfigBuilder.ts` | MCP config generation | +| `TeamMembersMetaStore.ts` | Member metadata | +| `TeamSentMessagesStore.ts` | Sent messages tracking | +| `ClaudeBinaryResolver.ts` | CLI binary resolution | +| `childProcess.ts` | Process spawning (spawnCli, killProcessTree) | +| `toolApprovalRules.ts` | Auto-approval logic | +| `actionModeInstructions.ts` | Agent instructions | +| Cross-team communication | Inbox relay, sentinel messages | +| Post-compact recovery | Context reinjection | +| Stall detection | Watchdog timers | +| Auth retry | Re-spawn on auth failure | + +### ACP заменяет (если бы Claude Code поддерживал): + +| Компонент | Строки кода | Чем заменяет | +|-----------|-------------|--------------| +| NDJSON парсинг stdout | ~200 LOC | `ndJsonStream()` + `ClientSideConnection` | +| Carry buffer логика | ~50 LOC | Автоматически в SDK | +| Message type dispatching | ~150 LOC | Typed `sessionUpdate()` callback | +| Tool approval protocol | ~100 LOC | `requestPermission()` callback | +| Session init handshake | ~30 LOC | `initialize()` + `newSession()` | +| **Итого** | **~530 LOC** | Типизированный SDK | + +**Из ~6000 LOC TeamProvisioningService**, ACP заменяет ~530 LOC (менее 9%). Остальные 91% — domain-specific orchestration. + +--- + +## 10. Честные оценки + +### Сложность интеграции: 6/10 (Уверенность: 8/10) + +- SDK сам по себе простой (0 dependencies, чистый API) +- Проблема: Claude Code не поддерживает ACP нативно +- Нужен маппинг между ACP events и нашими internal types +- Web Streams конвертация в Electron — тривиальна +- Основная сложность: поддержка двух протоколов (stream-json + ACP) + +### Полезность для нашего кейса: 4/10 (Уверенность: 9/10) + +- Наш primary agent (Claude Code) НЕ поддерживает ACP +- 91% нашего кода — domain-specific, ACP не касается +- Выгода: если хотим поддержать ДРУГИЕ агенты (Gemini, Codex, Copilot) — тогда ACP становится очень полезным (8/10) +- Для Claude Code only — бессмысленно, мы уже общаемся напрямую через stream-json + +### Зрелость/стабильность: 5/10 (Уверенность: 7/10) + +- Pre-1.0 (v0.16.1) +- Много `unstable_` методов +- Breaking changes между минорами возможны +- НО: 31 контрибьютор, 544 коммита, JetBrains + Zed backing +- Активная разработка, быстрый темп (18 npm versions) +- Usage updates и session management — ещё в draft + +### Риск adoption: 5/10 (Уверенность: 7/10) + +- Zero dependencies — безопасно для bundle size +- Pre-1.0 → API может измениться +- Claude Code может получить нативную ACP поддержку в будущем (Feature Request существует) +- VS Code не поддерживает ACP — это риск для всей экосистемы +- JetBrains backing — сильный сигнал стабильности + +--- + +## 11. Рекомендация + +### WAIT — Не интегрировать сейчас. Наблюдать. + +**Надёжность решения: 8/10. Уверенность в рекомендации: 9/10.** + +**Почему WAIT, а не ADOPT:** + +1. **Claude Code — наш primary agent и он НЕ говорит по ACP.** Пока Anthropic не добавит `--acp` флаг (или не поменяет `stream-json` на ACP), интеграция ACP не даёт value для Claude Code. + +2. **Мы заменим менее 9% кода.** ROI не оправдывает migration effort + поддержку двух протоколов. + +3. **Pre-1.0 API.** Breaking changes реальны. Лучше подождать стабилизации. + +**Когда стоит ADOPT:** + +1. **Claude Code получит нативную ACP поддержку** — тогда можно мигрировать stream-json → ACP, упростив парсинг. + +2. **Мы решим поддержать multi-agent (Gemini + Codex + Claude)** — тогда ACP станет единым протоколом для не-Claude агентов. Архитектура: stream-json для Claude, ACP для остальных, общий `AgentConnection` интерфейс. + +3. **ACP достигнет 1.0** — стабильный API, можно инвестировать в интеграцию. + +**Что делать прямо сейчас:** + +1. Следить за [Feature Request #6686](https://github.com/anthropics/claude-code/issues/6686) (Claude Code ACP support) +2. Следить за [ACP Updates](https://agentclientprotocol.com/updates) (protocol evolution) +3. Проектировать `AgentConnection` abstraction в нашем коде, чтобы stream-json и ACP могли быть взаимозаменяемы в будущем +4. Если решим поддержать Gemini/Codex — начать с ACP как протокола для них + +--- + +## Приложение A: Полная архитектура ACP Protocol Schema + +### Error Codes (JSON-RPC) + +| Code | Meaning | +|------|---------| +| -32700 | Parse error | +| -32600 | Invalid request | +| -32601 | Method not found | +| -32602 | Invalid params | +| -32603 | Internal error | +| -32000 | Authentication required | +| -32002 | Resource not found | + +### Permission Option Kinds + +| Kind | Описание | +|------|----------| +| `allow_once` | Разрешить один раз | +| `allow_always` | Разрешить всегда | +| `reject_once` | Отклонить один раз | +| `reject_always` | Отклонить всегда | + +### Stop Reasons + +| Reason | Описание | +|--------|----------| +| `end_turn` | Агент завершил turn нормально | +| `cancelled` | Пользователь отменил | +| `max_tokens` | Достигнут лимит токенов | +| `tool_use` | Агент ожидает результат tool (редко в ACP) | + +### Tool Call Kinds + +| Kind | Описание | +|------|----------| +| `read` | Чтение (файл, поиск) | +| `edit` | Редактирование файла | +| `command` | Выполнение команды | +| `tool` | Вызов MCP tool | + +### Diff Format + +```json +{ + "path": "/absolute/path/to/file.ts", + "oldText": "original content (null for new files)", + "newText": "modified content" +} +``` + +--- + +## Приложение B: Ссылки + +### Спецификация и документация +- [ACP Introduction](https://agentclientprotocol.com/get-started/introduction) +- [ACP Protocol Overview](https://agentclientprotocol.com/protocol/overview) +- [ACP Schema](https://agentclientprotocol.com/protocol/schema) +- [ACP Updates](https://agentclientprotocol.com/updates) +- [ACP Registry](https://agentclientprotocol.com/registry) + +### SDK +- [npm: @agentclientprotocol/sdk](https://www.npmjs.com/package/@agentclientprotocol/sdk) +- [GitHub: typescript-sdk](https://github.com/agentclientprotocol/typescript-sdk) +- [API Reference](https://agentclientprotocol.github.io/typescript-sdk/) +- [SDK Examples](https://github.com/agentclientprotocol/typescript-sdk/tree/main/src/examples) + +### Claude Code ACP +- [Feature Request #6686](https://github.com/anthropics/claude-code/issues/6686) +- [Zed Claude Code ACP Adapter](https://github.com/zed-industries/claude-agent-acp) +- [Zed Blog: Claude Code via ACP](https://zed.dev/blog/claude-code-via-acp) + +### Ecosystem +- [Zed ACP](https://zed.dev/acp) +- [JetBrains ACP](https://www.jetbrains.com/acp/) +- [JetBrains ACP Docs](https://www.jetbrains.com/help/ai-assistant/acp.html) +- [GitHub Copilot ACP](https://github.blog/changelog/2026-01-28-acp-support-in-copilot-cli-is-now-in-public-preview/) +- [Goose ACP](https://block.github.io/goose/blog/2025/10/24/intro-to-agent-client-protocol-acp/) +- [Kiro CLI ACP](https://kiro.dev/docs/cli/acp/) +- [OpenCode ACP](https://opencode.ai/docs/acp/) diff --git a/docs/research/ai-agent-protocols-and-routing.md b/docs/research/ai-agent-protocols-and-routing.md new file mode 100644 index 00000000..1365dc84 --- /dev/null +++ b/docs/research/ai-agent-protocols-and-routing.md @@ -0,0 +1,782 @@ +# AI Agent Orchestration Landscape: Protocols, Routing & Desktop Tools + +**Date:** March 24, 2026 +**Status:** Research snapshot (rapidly evolving landscape) + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Protocol-Level Standards](#1-protocol-level-standards) + - [MCP (Model Context Protocol)](#11-mcp--model-context-protocol) + - [A2A (Agent2Agent Protocol)](#12-a2a--agent2agent-protocol) + - [ACP (Agent Communication Protocol)](#13-acp--agent-communication-protocol) + - [AGENTS.md](#14-agentsmd) + - [Protocol Layer Summary](#15-protocol-layer-summary) +3. [Governance: Agentic AI Foundation (AAIF)](#2-governance-agentic-ai-foundation-aaif) +4. [Multi-Model Routing & Proxy Tools](#3-multi-model-routing--proxy-tools) + - [LiteLLM](#31-litellm) + - [OpenRouter](#32-openrouter) +5. [Agent Orchestration Frameworks](#4-agent-orchestration-frameworks) + - [LangGraph](#41-langgraph) + - [CrewAI](#42-crewai) + - [AutoGen / Microsoft Agent Framework](#43-autogen--microsoft-agent-framework) + - [OpenAI Agents SDK](#44-openai-agents-sdk) + - [Google Agent Development Kit (ADK)](#45-google-agent-development-kit-adk) + - [AWS Strands Agents](#46-aws-strands-agents) + - [OpenAgents](#47-openagents) + - [GitAgent](#48-gitagent) + - [Goose (Block)](#49-goose-block) + - [Framework Comparison Table](#410-framework-comparison-table) +6. [Desktop/Local Orchestration Tools](#5-desktoplocal-orchestration-tools) + - [VS Code Multi-Agent Hub](#51-vs-code-multi-agent-hub) + - [Augment Code Intent](#52-augment-code-intent) + - [OpenAI Codex Desktop App](#53-openai-codex-desktop-app) +7. [Relevance for Claude Agent Teams UI](#6-relevance-for-claude-agent-teams-ui) +8. [Sources](#sources) + +--- + +## Executive Summary + +As of March 2026, the AI agent ecosystem has consolidated around three complementary protocol layers: + +| Layer | Protocol | Purpose | Governance | +|-------|----------|---------|------------| +| **Agent-to-Tool** | MCP | Connect agents to tools/data | AAIF (Linux Foundation) | +| **Agent-to-Agent** | A2A | Agents discover/communicate with each other | Linux Foundation | +| **Agent Config** | AGENTS.md | Project-level agent instructions | AAIF (Linux Foundation) | + +All three are open-source, vendor-neutral, and governed by the Linux Foundation. The Agentic AI Foundation (AAIF), co-founded by Anthropic, OpenAI, and Block in December 2025, is the umbrella organization. + +Key numbers: +- **MCP:** 97M monthly SDK downloads, 10,000+ servers, 300+ clients +- **A2A:** 22.7K GitHub stars, 150+ supporting organizations, v0.3 released +- **AGENTS.md:** Adopted by 60,000+ open-source projects, supported by all major coding agents except Claude Code + +The framework landscape is fragmenting into three tiers: +1. **Cloud-vendor SDKs** (OpenAI Agents SDK, Google ADK, AWS Strands, Microsoft Agent Framework) -- production-grade, tied to ecosystems +2. **Independent frameworks** (LangGraph, CrewAI, OpenAgents) -- model-agnostic, community-driven +3. **Portability layers** (GitAgent, MCP, A2A) -- cross-framework interop + +Desktop orchestration is emerging as a new category, with VS Code, Augment Intent, and OpenAI Codex App leading the charge. + +--- + +## 1. Protocol-Level Standards + +### 1.1 MCP -- Model Context Protocol + +| Field | Value | +|-------|-------| +| **URL** | [modelcontextprotocol.io](https://modelcontextprotocol.io/) | +| **GitHub** | [modelcontextprotocol](https://github.com/modelcontextprotocol) | +| **Created by** | Anthropic (November 2024) | +| **Governance** | AAIF / Linux Foundation (donated December 2025) | +| **License** | Apache 2.0 | +| **Maturity** | Production -- spec version 2025-11-25 | +| **Adoption** | 97M monthly SDK downloads, 10,000+ servers, 300+ clients | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**What it enables:** Standardized agent-to-tool communication. Any AI model can connect to any data source or tool through a universal interface (tools, resources, prompts). Often compared to "USB-C for AI." + +**Key facts:** +- Adopted by every major AI platform: Claude, ChatGPT, Cursor, Gemini, Microsoft Copilot, VS Code +- OpenAI adopted MCP across its products in March 2025 +- 2026 roadmap focuses on: transport scalability (remote servers), agent communication upgrades (chunked messages, multipart streams), enterprise readiness (audit trails, SSO) +- Security concerns: prompt injection, tool poisoning, cross-server shadowing identified in April 2025 analysis + +**Relation to A2A:** MCP handles agent-to-tool connections. A2A handles agent-to-agent. Complementary, not competing. A common production pattern: MCP for tool connections + A2A for agent coordination. + +> Source: [A Year of MCP (Pento)](https://www.pento.ai/blog/a-year-of-mcp-2025-review), [The 2026 MCP Roadmap](http://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/), [MCP Wikipedia](https://en.wikipedia.org/wiki/Model_Context_Protocol), [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25), [The New Stack - MCP 2026 Roadmap](https://thenewstack.io/model-context-protocol-roadmap-2026/) + +--- + +### 1.2 A2A -- Agent2Agent Protocol + +| Field | Value | +|-------|-------| +| **URL** | [github.com/a2aproject/A2A](https://github.com/a2aproject/A2A) | +| **Created by** | Google (April 9, 2025, Cloud Next) | +| **Governance** | Linux Foundation (June 2025) | +| **License** | Apache 2.0 | +| **Version** | 0.3 (July 2025) -- added gRPC, signed security cards | +| **GitHub Stars** | 22.7K (main repo) | +| **Supporting Orgs** | 150+ (Atlassian, Salesforce, SAP, PayPal, etc.) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What it enables:** Standardized agent-to-agent communication. Agents discover each other via "Agent Cards" (JSON at `/.well-known/agent.json`), negotiate capabilities, and exchange tasks over HTTP/SSE/JSON-RPC. + +**Key features:** +- **Capability discovery** via Agent Cards (name, endpoint, skills, auth flows) +- **Flexible modalities**: text, audio, video streaming +- **Enterprise auth**: parity with OpenAPI authentication schemes +- **Supports async**: tasks from quick responses to multi-day research +- Protocol: JSON-RPC 2.0 over HTTP(S), SSE for streaming, push notifications + +**ACP merger (August 2025):** IBM's Agent Communication Protocol (ACP) officially merged into A2A under the Linux Foundation. BeeAI platform now uses A2A. + +**Ecosystem:** Native support in Google ADK, AWS Strands, Microsoft Agent Framework, LiteLLM, OpenAgents. CrewAI added A2A support. LangGraph and AutoGen have not yet adopted natively. + +> Source: [Google Developers Blog - A2A](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/), [Google Cloud Blog - A2A Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade), [Linux Foundation - A2A Project](https://www.linuxfoundation.org/press/linux-foundation-launches-the-agent2agent-protocol-project-to-enable-secure-intelligent-communication-between-ai-agents), [IBM - A2A](https://www.ibm.com/think/topics/agent2agent-protocol), [ACP Joins A2A](https://lfaidata.foundation/communityblog/2025/08/29/acp-joins-forces-with-a2a-under-the-linux-foundations-lf-ai-data/) + +--- + +### 1.3 ACP -- Agent Communication Protocol + +| Field | Value | +|-------|-------| +| **URL** | [github.com/i-am-bee/acp](https://github.com/i-am-bee/acp) | +| **Created by** | IBM BeeAI (March 2025) | +| **Status** | **Merged into A2A** (August 2025) | +| **License** | Apache 2.0 | +| **Reliability** | 7/10 (merged, not standalone) | +| **Confidence** | 8/10 | + +**What it was:** A lightweight REST-based protocol for agent-to-agent messaging. No SDK required -- curl/Postman compatible. Key differentiators were offline agent discovery and peer-to-peer interaction. + +**Current status:** ACP merged into A2A. The BeeAI platform now runs on A2A. IBM stated: "By bringing the assets and expertise behind ACP into A2A, we can build a single, more powerful standard." Migration guides are available. + +**Legacy significance:** ACP influenced A2A's design toward simpler REST-based patterns and offline discovery capabilities. + +> Source: [IBM Research - ACP](https://research.ibm.com/blog/agent-communication-protocol-ai), [IBM - What is ACP](https://www.ibm.com/think/topics/agent-communication-protocol), [ACP Joins A2A](https://lfaidata.foundation/communityblog/2025/08/29/acp-joins-forces-with-a2a-under-the-linux-foundations-lf-ai-data/) + +--- + +### 1.4 AGENTS.md + +| Field | Value | +|-------|-------| +| **URL** | [agents.md](https://agents.md/) | +| **Created by** | OpenAI (August 2025) | +| **Governance** | AAIF / Linux Foundation | +| **License** | Open standard (Markdown convention) | +| **Adoption** | 60,000+ repositories | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**What it enables:** A standardized Markdown file that gives AI coding agents project-specific instructions (build commands, coding conventions, testing requirements, boundaries). Like `.gitignore` but for agents. + +**Adoption:** Supported by GitHub Copilot, Cursor, Windsurf, Zed, Warp, VS Code, JetBrains Junie, OpenAI Codex CLI, Google Jules, Gemini CLI, Amp, Devin, Aider, goose, RooCode, Augment Code. + +**Notable exception:** Claude Code uses its own `CLAUDE.md` format. Open issue with 3,000+ upvotes requesting AGENTS.md support, but Anthropic has not committed to it. + +**For monorepos:** Nested AGENTS.md files work (agents parse nearest file in directory tree). OpenAI's main repo has 88 AGENTS.md files. + +> Source: [InfoQ - AGENTS.md](https://www.infoq.com/news/2025/08/agents-md/), [agents.md official site](https://agents.md/), [OpenAI AAIF announcement](https://openai.com/index/agentic-ai-foundation/) + +--- + +### 1.5 Protocol Layer Summary + +``` ++--------------------------------------------------+ +| AGENTS.md / CLAUDE.md | <- Agent config/instructions ++--------------------------------------------------+ +| A2A (Agent-to-Agent Protocol) | <- Agent discovery & communication +| (includes former ACP) | ++--------------------------------------------------+ +| MCP (Model Context Protocol) | <- Agent-to-tool connections ++--------------------------------------------------+ +| HTTP / SSE / JSON-RPC / gRPC | <- Transport layer ++--------------------------------------------------+ +``` + +All three major layers are: +- Open source (Apache 2.0) +- Governed by the Linux Foundation (via AAIF or directly) +- Backed by every major AI company +- Production-ready or approaching it + +--- + +## 2. Governance: Agentic AI Foundation (AAIF) + +| Field | Value | +|-------|-------| +| **URL** | [aaif.io](https://aaif.io/) | +| **Parent** | Linux Foundation | +| **Founded** | December 9, 2025 | +| **Co-founders** | Anthropic, Block, OpenAI | +| **Platinum Members** | AWS, Anthropic, Block, Bloomberg, Cloudflare, Google, Microsoft, OpenAI | +| **Total Members** | 97+ | +| **Board Chair** | David Nalley (AWS) | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**What it does:** Neutral governance body for agentic AI open standards. Hosts MCP, goose, and AGENTS.md as founding projects. A2A is governed separately under the Linux Foundation but aligned. + +**Key principles:** +- Open governance: contributors from all backgrounds shape direction +- Project autonomy: individual projects maintain full technical independence +- Sustainability: neutral infrastructure and funding (not vendor-controlled) +- Focused scope: agentic AI only (not all of AI/ML/data science) + +**Funding model:** "Directed fund" -- companies contribute through membership dues. Roadmaps set by technical steering committees, not sponsors. + +**Government alignment:** NIST launched the "AI Agent Standards Initiative" in February 2026 to foster industry-led technical standards for AI agents. + +**Upcoming event:** MCP Dev Summit North America, April 2-3, 2026, New York City. + +> Source: [Linux Foundation - AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation), [OpenAI - AAIF](https://openai.com/index/agentic-ai-foundation/), [Anthropic - AAIF](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation), [NIST AI Agent Standards Initiative](https://www.nist.gov/news-events/news/2026/02/announcing-ai-agent-standards-initiative-interoperable-and-secure) + +--- + +## 3. Multi-Model Routing & Proxy Tools + +### 3.1 LiteLLM + +| Field | Value | +|-------|-------| +| **URL** | [litellm.ai](https://docs.litellm.ai/) | +| **GitHub** | [BerriAI/litellm](https://github.com/BerriAI/litellm) | +| **Type** | LLM Gateway / Proxy (self-hosted) | +| **License** | MIT (Enterprise features paid) | +| **LLM Support** | 100+ models | +| **Agent Support** | A2A agents (LangGraph, Vertex AI, Azure, Bedrock, Pydantic AI) | +| **MCP Support** | Yes (central endpoint with per-key ACL) | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**What it enables:** +- Unified OpenAI-compatible gateway for 100+ LLMs from all providers +- A2A agent routing through the same gateway +- MCP tool access with per-key access control +- Load balancing: simple-shuffle, least-busy, usage-based, latency-based +- Retry/fallback across deployments +- Cost tracking per key/team/user +- Content filtering, PII masking, guardrails + +**Performance:** 8ms P95 latency at 1K RPS. + +**Known issues (2025-2026):** +- Python GIL limits concurrency under high load +- DB logging degrades after 1M+ logs (GitHub issue #12067) +- Enterprise features (SSO, RBAC, budgets) locked behind paid license +- 800+ open GitHub issues; September 2025 release caused OOM on Kubernetes +- Bifrost (Go-based competitor) claims 50x faster performance + +**Agent routing capability:** LiteLLM supports adding A2A agents as first-class endpoints, meaning you can route to both LLMs and agents through the same gateway. This makes it a potential universal backend for agent orchestration. + +**Relevance for desktop agent UI:** High. Could serve as a unified backend that routes requests to different LLM providers and A2A agents through a single API. The self-hosted nature and OpenAI-compatible API make it easy to integrate. + +> Source: [LiteLLM Docs](https://docs.litellm.ai/docs/), [LiteLLM GitHub](https://github.com/BerriAI/litellm), [Top 5 LiteLLM Alternatives 2026](https://www.getmaxim.ai/articles/top-5-litellm-alternatives-in-2026/) + +--- + +### 3.2 OpenRouter + +| Field | Value | +|-------|-------| +| **URL** | [openrouter.ai](https://openrouter.ai/) | +| **Type** | Cloud-hosted LLM routing service | +| **Models** | 500+ from 60+ providers | +| **Scale** | 250K+ apps, 4.2M+ users | +| **API** | OpenAI SDK compatible | +| **License** | Proprietary (cloud service) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What it enables:** +- Single API for 500+ models (OpenAI, Anthropic, Google, Meta, Mistral, etc.) +- Auto-routing: cheap models for simple queries, premium for complex +- Automatic provider fallback for reliability +- Low latency: ~15ms overhead (edge infrastructure) +- 29 free models available (no credit card) + +**Agent support:** Supports building agentic workflows through the API, but no native A2A/MCP protocol support. It is an LLM routing layer, not an agent orchestration layer. + +**Multi-model strategy for agents:** The recommended approach is to use different models for different tasks (e.g., Devstral for coding, MiniMax for agents, DeepSeek for general). OpenRouter's auto-routing facilitates this. + +**Relevance for desktop agent UI:** Medium. Excellent for LLM routing (choosing models per task), but lacks native agent orchestration. Would need to be paired with an agent framework. Not self-hostable. + +> Source: [OpenRouter](https://openrouter.ai/), [OpenRouter Review 2026](https://aiagentslist.com/agents/openrouter), [Building Agentic AI with OpenRouter](https://dev.to/allanninal/building-your-first-agentic-ai-workflow-with-openrouter-api-1fo6) + +--- + +## 4. Agent Orchestration Frameworks + +### 4.1 LangGraph + +| Field | Value | +|-------|-------| +| **GitHub** | [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph) | +| **Architecture** | Graph-based workflows (nodes + edges) | +| **Languages** | Python, JavaScript/TypeScript | +| **License** | MIT | +| **Best for** | Production-grade stateful systems | +| **MCP/A2A** | No native support yet | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Key strengths:** +- Most control over execution flow (conditional logic, branching, parallel) +- Best debugging/observability via LangSmith companion tooling +- Production-proven with enterprise deployments +- Model-agnostic: assign different models to different agent nodes +- Mature checkpointing and state persistence + +**Key weaknesses:** +- Steepest learning curve (requires graph theory knowledge) +- No native MCP/A2A support yet +- Higher initial development time vs. CrewAI + +> Source: [DataCamp - Framework Comparison](https://www.datacamp.com/tutorial/crewai-vs-langgraph-vs-autogen), [DEV - Agent Showdown 2026](https://dev.to/topuzas/the-great-ai-agent-showdown-of-2026-openai-autogen-crewai-or-langgraph-1ea8) + +--- + +### 4.2 CrewAI + +| Field | Value | +|-------|-------| +| **URL** | [crewai.com](https://crewai.com/) | +| **Architecture** | Role-based teams (roles, goals, backstories) | +| **Languages** | Python | +| **License** | MIT | +| **Best for** | Quick prototyping, team-based workflows | +| **A2A** | Added A2A support | +| **MCP** | Not natively | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Key strengths:** +- Most beginner-friendly (40% faster time-to-production vs. LangGraph) +- Role-based metaphor mirrors real organizations +- YAML config keeps agent definitions readable +- Active development (unlike AutoGen) +- Added A2A support for interoperability + +**Key weaknesses:** +- Less mature monitoring/observability tooling +- Python-only +- Less granular control than LangGraph for complex workflows + +> Source: [CrewAI](https://crewai.com/), [OpenAgents Blog - Frameworks Compared](https://openagents.org/blog/posts/2026-02-23-open-source-ai-agent-frameworks-compared) + +--- + +### 4.3 AutoGen / Microsoft Agent Framework + +| Field | Value | +|-------|-------| +| **URL** | [github.com/microsoft/agent-framework](https://github.com/microsoft/agent-framework) | +| **Previous** | AutoGen + Semantic Kernel (merged October 2025) | +| **Languages** | Python, .NET | +| **License** | MIT | +| **Status** | Release Candidate (February 2026), GA target end of Q1 2026 | +| **MCP/A2A** | Both supported natively | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What happened:** +- Microsoft merged AutoGen and Semantic Kernel into a unified "Microsoft Agent Framework" in October 2025 +- AutoGen is now in maintenance mode (bug fixes/security only) +- Semantic Kernel features are being absorbed +- GA 1.0 targeted for end of Q1 2026 + +**Key features:** +- Unified programming model: Python and .NET +- Graph-based workflows: sequential, concurrent, handoff, group chat patterns +- Multi-provider: Azure OpenAI, OpenAI, Anthropic, AWS Bedrock, Ollama, etc. +- Native interoperability: A2A, AG-UI, MCP, OpenAPI +- Enterprise: session-based state management, middleware, telemetry + +**Key concern:** Community disruption from the merge. AutoGen users forced to migrate. Strategic shift raises questions about long-term stability of Microsoft's agent strategy. + +> Source: [Visual Studio Magazine - Agent Framework](https://visualstudiomagazine.com/articles/2025/10/01/semantic-kernel-autogen--open-source-microsoft-agent-framework.aspx), [Microsoft Learn - Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/), [Microsoft Azure Blog](https://azure.microsoft.com/en-us/blog/introducing-microsoft-agent-framework/) + +--- + +### 4.4 OpenAI Agents SDK + +| Field | Value | +|-------|-------| +| **URL** | [openai.github.io/openai-agents-python](https://openai.github.io/openai-agents-python/) | +| **GitHub** | [openai/openai-agents-python](https://github.com/openai/openai-agents-python) | +| **Languages** | Python, TypeScript/JavaScript | +| **License** | MIT | +| **Version** | 0.13.0 (March 2026) | +| **Maturity** | Production-ready | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Core primitives:** Agents, Handoffs, Tools (functions + MCP + hosted), Guardrails, Human-in-the-loop, Sessions, Tracing, Realtime Agents (voice). + +**Provider-agnostic:** Supports OpenAI Responses/Chat APIs and 100+ other LLMs despite being OpenAI-branded. + +**Orchestration patterns:** Agents-as-tools (bounded subtask) and handoffs (specialist takes over). + +**MCP support:** Native. Agents can use MCP servers as tool providers. + +> Source: [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/), [Agents SDK Review (mem0)](https://mem0.ai/blog/openai-agents-sdk-review), [OpenAI Developers 2025](https://developers.openai.com/blog/openai-for-developers-2025/) + +--- + +### 4.5 Google Agent Development Kit (ADK) + +| Field | Value | +|-------|-------| +| **URL** | [google.github.io/adk-docs](https://google.github.io/adk-docs/) | +| **GitHub** | [google/adk-python](https://github.com/google/adk-python) (17.8K stars) | +| **Languages** | Python, Go | +| **License** | Apache 2.0 | +| **A2A** | Native integration | +| **MCP** | Native support | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Key strengths:** +- Same framework powering Google's Agentspace and Customer Engagement Suite +- Native A2A + MCP: first-party protocol support +- Rich tool ecosystem: built-in tools, MCP servers, LangChain/LlamaIndex integration, agents as tools +- LiteLLM integration for multi-provider model access (Anthropic, Meta, Mistral, etc.) +- Deploy anywhere: Cloud Run, Vertex AI Agent Engine, GKE +- 3.3M monthly downloads + +**Key weakness:** Optimized for Gemini/Google ecosystem. Model-agnostic in theory, but best experience with Google Cloud. + +> Source: [Google Developers Blog - ADK](https://developers.googleblog.com/en/agent-development-kit-easy-to-build-multi-agent-applications/), [ADK Docs](https://google.github.io/adk-docs/), [ADK + A2A](https://google.github.io/adk-docs/a2a/) + +--- + +### 4.6 AWS Strands Agents + +| Field | Value | +|-------|-------| +| **URL** | [strandsagents.com](https://strandsagents.com/) | +| **GitHub** | [strands-agents](https://github.com/strands-agents) (2,000+ stars) | +| **Languages** | Python, TypeScript | +| **License** | Apache 2.0 | +| **Version** | 1.0 (production-ready) | +| **A2A** | Native support | +| **MCP** | First-class support | +| **Downloads** | 150K+ on PyPI | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**Key features:** +- Model-driven approach: model reasons about when to use sub-agents +- Multi-agent patterns: Graph, Swarm, Workflow +- Native A2A: expose agents as A2A servers, communicate with other A2A agents +- First-class MCP: thousands of tools accessible +- Model-agnostic: Bedrock, Anthropic, Gemini, LiteLLM, Ollama, OpenAI, and more +- Deploy: Lambda, Fargate, EKS, Bedrock AgentCore, Docker, Kubernetes +- OpenTelemetry observability built-in + +**Key concern:** Newer entrant (May 2025), smaller community than LangGraph/CrewAI. AWS ecosystem-optimized. + +> Source: [AWS Blog - Strands Agents](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/), [Strands 1.0](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-1-0-production-ready-multi-agent-orchestration-made-simple/), [AWS - A2A on Strands](https://aws.amazon.com/blogs/opensource/open-protocols-for-agent-interoperability-part-4-inter-agent-communication-on-a2a/) + +--- + +### 4.7 OpenAgents + +| Field | Value | +|-------|-------| +| **URL** | [openagents.org](https://openagents.org/) | +| **GitHub** | [openagents-org/openagents](https://github.com/openagents-org/openagents) | +| **Languages** | Python | +| **License** | Open source | +| **A2A** | Native support | +| **MCP** | Native support | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Unique positioning:** Only framework with native first-class support for BOTH MCP and A2A protocols. Purpose-built for interoperable agent networks. + +**Key features:** +- Persistent agent communities (not one-shot pipelines) +- LLM-agnostic (any model provider) +- Agent discovery: agents find each other in workspaces +- @mention delegation between agents +- Manages Claude, Codex, Aider, and more from a single CLI +- Self-hosted agent networks via SDK + +**Key concern:** Smaller community and less production-hardened than LangGraph/CrewAI. Newer project. + +> Source: [OpenAgents Blog - Comparison](https://openagents.org/blog/posts/2026-02-23-open-source-ai-agent-frameworks-compared), [OpenAgents GitHub](https://github.com/openagents-org/openagents) + +--- + +### 4.8 GitAgent + +| Field | Value | +|-------|-------| +| **URL** | [github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent) | +| **Created** | March 2026 (very new) | +| **Type** | Framework-agnostic agent definition format | +| **License** | Open source | +| **Reliability** | 5/10 | +| **Confidence** | 6/10 | + +**What it does:** "Docker for AI Agents" -- a universal format to define an agent once and export it to any framework. + +**Export targets:** `gitagent export -f [framework]` supports OpenAI, Claude Code, LangChain/LangGraph, CrewAI, AutoGen. + +**Key innovation:** +- Agent identity in SOUL.md + skills/ directories +- Git-native state management (Markdown files, not vector DBs) +- Human-in-the-loop via standard PRs (not custom dashboards) +- Enterprise compliance (FINRA, SEC) built-in + +**What ports:** Prompts, persona, constraints, tool schemas, role policies, model preferences. +**What stays:** Runtime orchestration, state machines, live tool execution, memory I/O. + +**Key concern:** Brand new (March 2026). No production track record. Early-stage community. + +> Source: [MarkTechPost - GitAgent](https://www.marktechpost.com/2026/03/22/meet-gitagent-the-docker-for-ai-agents-that-is-finally-solving-the-fragmentation-between-langchain-autogen-and-claude-code/), [GitAgent GitHub](https://github.com/open-gitagent/gitagent) + +--- + +### 4.9 Goose (Block) + +| Field | Value | +|-------|-------| +| **URL** | [block.github.io/goose](https://block.github.io/goose/) | +| **GitHub** | [block/goose](https://github.com/block/goose) (30,000+ stars, 350+ contributors) | +| **Created by** | Block (January 2025) | +| **Governance** | AAIF / Linux Foundation | +| **License** | Apache 2.0 | +| **Type** | Local-first AI agent (CLI + Desktop) | +| **MCP** | Core architecture built on MCP | +| **LLM Support** | 25+ providers (commercial + local models) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**What it does:** An extensible, local-first AI agent. Goes beyond code suggestions -- runs shell commands, edits files, executes code, orchestrates multi-step workflows. Reference implementation for MCP. + +**Key facts:** +- 110+ releases since January 2025 +- 3,000+ MCP servers available in the ecosystem +- Founding project of AAIF alongside MCP and AGENTS.md +- Works with any LLM (multi-model config for cost optimization) +- Modular via MCP extensions + +> Source: [Block - Introducing Goose](https://block.xyz/inside/block-open-source-introduces-codename-goose), [Goose GitHub](https://github.com/block/goose), [Linux Foundation - AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) + +--- + +### 4.10 Framework Comparison Table + +| Framework | MCP | A2A | Multi-Provider | Languages | Architecture | Maturity | GitHub Stars | +|-----------|-----|-----|----------------|-----------|-------------|----------|-------------| +| **LangGraph** | No | No | Yes | Py, JS/TS | Graph-based | High | ~40K | +| **CrewAI** | No | Yes | Yes | Py | Role-based | Medium-High | ~30K | +| **MS Agent Framework** | Yes | Yes | Yes | Py, .NET | Graph + Conversational | Medium (RC) | ~40K (combined) | +| **OpenAI Agents SDK** | Yes | No | Yes (100+ LLMs) | Py, TS/JS | Handoff-based | High | N/A | +| **Google ADK** | Yes | Yes | Yes (via LiteLLM) | Py, Go | Hierarchical | Medium-High | ~18K | +| **AWS Strands** | Yes | Yes | Yes | Py, TS | Model-driven | Medium | ~2K | +| **OpenAgents** | Yes | Yes | Yes | Py | Network-based | Low | ~1K | +| **Goose** | Yes (core) | No | Yes (25+) | Rust/TS | MCP-based | Medium-High | ~30K | +| **GitAgent** | No | No | Yes (portability) | Universal | Format/spec | Very Low | New | + +--- + +## 5. Desktop/Local Orchestration Tools + +### 5.1 VS Code Multi-Agent Hub + +| Field | Value | +|-------|-------| +| **URL** | [code.visualstudio.com](https://code.visualstudio.com/blogs/2026/02/05/multi-agent-development) | +| **Release** | January 2026 (v1.109) | +| **Agents** | GitHub Copilot + Claude + Codex | +| **Subagents** | Parallel execution | +| **MCP** | Full MCP Apps support | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**What it is:** VS Code as a multi-agent command center. Run Claude, Codex, and Copilot side by side from a single interface. + +**Key features (v1.109+):** +- **Agent Sessions view**: orchestrate multiple AI assistants, delegate tasks, compare outputs +- **Parallel subagents**: fire off multiple independent tasks simultaneously +- **Agent types**: local (interactive), background (CLI/worktrees), cloud (GitHub PRs), third-party +- **Custom agents**: specialized roles (research, implementation, security) with defined tools, instructions, and models +- **MCP Apps**: tool calls return interactive UI components (dashboards, forms, visualizations) +- **Copilot Memory**: context retention across interactions + +**Agent HQ (GitHub):** Announced at GitHub Universe 2025, launched February 2026. Assign issues to Copilot, Claude, Codex, or all three to compare results. + +**Agent strengths differentiation:** +- Copilot: fast autocomplete, repo-specific patterns, inline experience +- Claude: thorough, trade-off analysis, multi-file changes +- Codex: fast generation, algorithmic tasks, concise output + +> Source: [VS Code Blog - Multi-Agent](https://code.visualstudio.com/blogs/2026/02/05/multi-agent-development), [The New Stack - VS Code Multi-Agent](https://thenewstack.io/vs-code-becomes-multi-agent-command-center-for-developers/), [GitHub Blog - Agent HQ](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/) + +--- + +### 5.2 Augment Code Intent + +| Field | Value | +|-------|-------| +| **URL** | [augmentcode.com](https://www.augmentcode.com/blog/intent-a-workspace-for-agent-orchestration) | +| **Platform** | macOS (public beta, February 2026); Windows waitlist | +| **Type** | Standalone desktop app | +| **Architecture** | Living Spec + three-tier agents (Coordinator, Specialists, Verifier) | +| **BYOA** | Yes (Claude Code, Codex, OpenCode) | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Unique concept: Living Spec.** A shared document that acts as the canonical source of truth. Reduces prompt drift, stale assumptions, and conflicting parallel work. Coordinator breaks requirements into tasks, specialists execute in isolated git worktrees, verifier checks results against spec. + +**BYOA (Bring Your Own Agent):** Use Claude Code, Codex, or OpenCode inside Intent's workspace. Free tier for BYOA; Context Engine requires subscription. + +**Context Engine:** Processes 400,000+ files through semantic dependency analysis. Agents gain understanding of service boundaries, API contracts, dependency relationships. + +**Benchmark claims:** SWE-bench Pro: Auggie 51.80% vs Claude Code 49.75% vs Cursor 50.21%. + +**Relevance to Claude Agent Teams UI:** Intent is the closest conceptual competitor. Both aim to be a desktop UI for multi-agent coding orchestration. Key differences: +- Intent uses living specs; our app uses kanban boards +- Intent is macOS-only; our app is cross-platform (Electron) +- Intent is commercial (freemium); ours is 100% free/open-source +- Intent requires BYOA agents; ours is Claude Code-native with potential for multi-provider + +> Source: [Augment Code - Intent](https://www.augmentcode.com/blog/intent-a-workspace-for-agent-orchestration), [Intent vs Claude Code](https://www.augmentcode.com/tools/intent-vs-claude-code), [Best AI Coding Desktop Apps 2026](https://www.augmentcode.com/tools/best-ai-coding-agent-desktop-apps) + +--- + +### 5.3 OpenAI Codex Desktop App + +| Field | Value | +|-------|-------| +| **Created** | February 2, 2026 | +| **Platform** | macOS only (Windows late 2026) | +| **Type** | Standalone desktop app | +| **Architecture** | "Command center for agents" | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**What it does:** Centralizes multiple AI coding agents in a single interface. Manage parallel AI workflows, review automated changes, run long-running background tasks. + +**Key gap vs. our app:** Codex Desktop is OpenAI-only. No multi-provider agent support. No kanban board. No team collaboration features. + +> Source: [IntuitionLabs - Codex App](https://intuitionlabs.ai/articles/openai-codex-app-ai-coding-agents), [Augment Code - Desktop Apps Comparison](https://www.augmentcode.com/tools/best-ai-coding-agent-desktop-apps) + +--- + +## 6. Relevance for Claude Agent Teams UI + +### Could any of these serve as a universal backend for a desktop AI team management UI? + +**Highest relevance tools:** + +| Tool | Why Relevant | Integration Path | Effort | +|------|-------------|------------------|--------| +| **MCP** | Our agents already use MCP. Universal tool protocol. | Already integrated via Claude Code | Low | +| **A2A** | Could enable cross-provider agent communication (Claude + Codex + Gemini agents) | Implement A2A client/server in Electron main process | Medium-High | +| **LiteLLM** | Unified routing to any LLM. A2A agent support. Self-hosted. | Spawn local proxy, route all requests through it | Medium | +| **OpenAgents** | Native MCP + A2A. Manages Claude, Codex, Aider from single CLI. | Could replace/augment Claude Code CLI orchestration | High | +| **AGENTS.md** | Would make our kanban tasks/specs consumable by any agent | Generate AGENTS.md from team config | Low | + +### Strategic positioning + +Our app (Claude Agent Teams UI) has unique advantages that no competitor offers: + +1. **Kanban board** -- nobody else has this for agent orchestration +2. **100% free, open-source, local-first** -- vs. Augment Intent (freemium), Codex App (OpenAI-only), VS Code (ecosystem lock-in) +3. **Claude Code-native** -- deepest integration with Claude's agent teams feature +4. **Cross-team communication** -- agents coordinate across teams, not just within + +### Potential evolution path + +``` +Phase 1 (Current): Claude Code-native orchestration + | +Phase 2: Add AGENTS.md export (make teams consumable by other agents) + | +Phase 3: Add A2A server (expose our teams as A2A-discoverable agents) + | +Phase 4: Add multi-provider support via LiteLLM/A2A + (Claude + Codex + Gemini agents on same kanban board) + | +Phase 5: Full "universal AI team management" platform +``` + +**Key risk:** The VS Code multi-agent hub (Agent HQ) has massive distribution advantage. Our differentiation must come from superior UX (kanban), deeper team management, and open-source community. + +### Market context +- Gartner: 40% of enterprise apps will feature AI agents by end of 2026 (up from 5%) +- IDC: agentic AI spending to exceed $1.3T by 2029 (31.9% CAGR) +- UiPath: 65% of organizations piloting agentic systems by mid-2025 + +--- + +## Sources + +### Protocols & Standards +- [Google Developers Blog - A2A Protocol](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) +- [Google Cloud Blog - A2A Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade) +- [Linux Foundation - A2A Project](https://www.linuxfoundation.org/press/linux-foundation-launches-the-agent2agent-protocol-project-to-enable-secure-intelligent-communication-between-ai-agents) +- [A2A GitHub](https://github.com/a2aproject/A2A) +- [MCP Official Site](https://modelcontextprotocol.io/) +- [MCP 2026 Roadmap](http://blog.modelcontextprotocol.io/posts/2026-mcp-roadmap/) +- [MCP Specification 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) +- [Pento - A Year of MCP](https://www.pento.ai/blog/a-year-of-mcp-2025-review) +- [The New Stack - MCP Roadmap 2026](https://thenewstack.io/model-context-protocol-roadmap-2026/) +- [MCP Wikipedia](https://en.wikipedia.org/wiki/Model_Context_Protocol) +- [IBM - ACP](https://www.ibm.com/think/topics/agent-communication-protocol) +- [IBM Research - ACP](https://research.ibm.com/blog/agent-communication-protocol-ai) +- [ACP Joins A2A](https://lfaidata.foundation/communityblog/2025/08/29/acp-joins-forces-with-a2a-under-the-linux-foundations-lf-ai-data/) +- [AGENTS.md Official Site](https://agents.md/) +- [InfoQ - AGENTS.md](https://www.infoq.com/news/2025/08/agents-md/) +- [IBM - What is BeeAI](https://www.ibm.com/think/topics/beeai) +- [NIST - AI Agent Standards Initiative](https://www.nist.gov/news-events/news/2026/02/announcing-ai-agent-standards-initiative-interoperable-and-secure) + +### Governance +- [Linux Foundation - AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) +- [OpenAI - AAIF](https://openai.com/index/agentic-ai-foundation/) +- [Anthropic - AAIF](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) +- [Block - AAIF](https://block.xyz/inside/block-anthropic-and-openai-launch-the-agentic-ai-foundation) +- [AAIF Official Site](https://aaif.io/) + +### Frameworks & SDKs +- [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) +- [OpenAI Agents SDK GitHub](https://github.com/openai/openai-agents-python) +- [Google ADK Docs](https://google.github.io/adk-docs/) +- [Google ADK GitHub](https://github.com/google/adk-python) +- [AWS Strands Agents](https://strandsagents.com/) +- [AWS - Introducing Strands](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/) +- [AWS - Strands 1.0](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-1-0-production-ready-multi-agent-orchestration-made-simple/) +- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) +- [Microsoft Learn - Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/) +- [Visual Studio Magazine - Agent Framework](https://visualstudiomagazine.com/articles/2025/10/01/semantic-kernel-autogen--open-source-microsoft-agent-framework.aspx) +- [LangGraph](https://github.com/langchain-ai/langgraph) +- [CrewAI](https://crewai.com/) +- [OpenAgents](https://openagents.org/) +- [OpenAgents GitHub](https://github.com/openagents-org/openagents) +- [GitAgent GitHub](https://github.com/open-gitagent/gitagent) +- [MarkTechPost - GitAgent](https://www.marktechpost.com/2026/03/22/meet-gitagent-the-docker-for-ai-agents-that-is-finally-solving-the-fragmentation-between-langchain-autogen-and-claude-code/) +- [Goose GitHub](https://github.com/block/goose) +- [Block - Introducing Goose](https://block.xyz/inside/block-open-source-introduces-codename-goose) + +### Routing & Gateways +- [LiteLLM Docs](https://docs.litellm.ai/) +- [LiteLLM GitHub](https://github.com/BerriAI/litellm) +- [OpenRouter](https://openrouter.ai/) +- [Top 5 LiteLLM Alternatives 2026](https://www.getmaxim.ai/articles/top-5-litellm-alternatives-in-2026/) + +### Desktop Tools +- [VS Code Blog - Multi-Agent](https://code.visualstudio.com/blogs/2026/02/05/multi-agent-development) +- [The New Stack - VS Code Multi-Agent](https://thenewstack.io/vs-code-becomes-multi-agent-command-center-for-developers/) +- [GitHub Blog - Agent HQ](https://github.blog/news-insights/company-news/pick-your-agent-use-claude-and-codex-on-agent-hq/) +- [Augment Code - Intent](https://www.augmentcode.com/blog/intent-a-workspace-for-agent-orchestration) +- [Augment Code - Best Desktop Apps](https://www.augmentcode.com/tools/best-ai-coding-agent-desktop-apps) +- [IntuitionLabs - Codex App](https://intuitionlabs.ai/articles/openai-codex-app-ai-coding-agents) + +### Framework Comparisons +- [DataCamp - CrewAI vs LangGraph vs AutoGen](https://www.datacamp.com/tutorial/crewai-vs-langgraph-vs-autogen) +- [OpenAgents Blog - Frameworks Compared](https://openagents.org/blog/posts/2026-02-23-open-source-ai-agent-frameworks-compared) +- [DEV - Agent Showdown 2026](https://dev.to/topuzas/the-great-ai-agent-showdown-of-2026-openai-autogen-crewai-or-langgraph-1ea8) +- [Shakudo - Top 9 AI Agent Frameworks](https://www.shakudo.io/blog/top-9-ai-agent-frameworks) +- [AIMultiple - Top 5 Agentic Frameworks 2026](https://aimultiple.com/agentic-frameworks) + +### Market Research +- [Gravitee - A2A vs MCP](https://www.gravitee.io/blog/googles-agent-to-agent-a2a-and-anthropics-model-context-protocol-mcp) +- [RUH.AI - AI Agent Protocols 2026 Complete Guide](https://www.ruh.ai/blogs/ai-agent-protocols-2026-complete-guide) +- [Thoughtworks - MCP Impact 2025](https://www.thoughtworks.com/en-us/insights/blog/generative-ai/model-context-protocol-mcp-impact-2025) +- [Shipyard - Claude Code Multi-Agent 2026](https://shipyard.build/blog/claude-code-multi-agent/) diff --git a/docs/research/ai-orchestration-tools-part2.md b/docs/research/ai-orchestration-tools-part2.md new file mode 100644 index 00000000..374387d2 --- /dev/null +++ b/docs/research/ai-orchestration-tools-part2.md @@ -0,0 +1,705 @@ +# AI Agent Orchestrators & Dispatchers — Part 2 + +> Research date: 2026-03-24 +> Focus: Provider-agnostic agent abstraction layers, dispatch systems, and multi-agent coding orchestrators +> Scope: NEW tools not covered in Part 1 + +--- + +## Tier 1: Desktop Apps & ADEs (Agentic Development Environments) + +These are the most relevant to our product — desktop applications that provide a UI layer for managing multiple coding agents. + +### 1. Emdash (YC W26) + +- **GitHub:** https://github.com/generalaction/emdash +- **Stars:** ~2,700+ +- **License:** Open source (exact license TBD) +- **Language:** Electron-based desktop app +- **Unique:** First YC-backed "Agentic Development Environment" (ADE). Run multiple coding agents in parallel, each isolated in its own git worktree, either locally or over SSH. + +**Agent providers:** 22 CLI agents supported — Claude Code, Qwen Code, Amp, Codex, Gemini CLI, and more. + +**Architecture:** +- Each agent runs in its own git worktree with full isolation +- Built-in ticket integrations: Linear, GitHub, Jira — pass tickets directly to agents +- Remote development via SSH/SFTP with secure keychain credential storage +- Built-in diff review, PR creation, CI/CD checks, and merge +- Privacy-first: Emdash itself sends no code/chat data to any servers + +**Integration potential:** DIRECT COMPETITOR. Very similar concept to our app. Key differences: Emdash is more a "parallel agent launcher" while we focus on team orchestration with inter-agent communication and kanban management. + +**Maturity:** Active development, YC-backed, growing fast (966 -> 2700 stars in weeks). Available for macOS (Apple Silicon + Intel) and Linux. + +**Source:** [GitHub](https://github.com/generalaction/emdash) | [emdash.sh](https://www.emdash.sh/) | [YC profile](https://www.ycombinator.com/companies/emdash) + +--- + +### 2. Constellagent + +- **GitHub:** https://github.com/owengretzinger/constellagent +- **Stars:** TBD (listed in awesome-agent-orchestrators) +- **License:** Open source +- **Language:** macOS desktop app +- **Unique:** Each agent gets its own terminal, editor, and git worktree — all in one window. macOS-native UI. + +**Agent providers:** Any CLI-based coding agent (Claude Code, Codex, Gemini CLI, etc.) + +**Architecture:** +- Side-by-side agent sessions with isolated git worktrees +- Built-in terminal + code editor per agent +- macOS-native (not Electron) + +**Integration potential:** Simpler than our app but validates the "multi-agent desktop UI" market. macOS-only limits audience. + +**Source:** [GitHub](https://github.com/owengretzinger/constellagent) + +--- + +## Tier 2: CLI Orchestrators with Provider Abstraction + +### 3. ORCH + +- **GitHub:** https://www.orch.one/ (listed in awesome-agent-orchestrators) +- **Stars:** TBD +- **License:** MIT +- **Language:** TypeScript +- **Unique:** CLI runtime with formal STATE MACHINE for task lifecycle (`todo -> in_progress -> review -> done`). Agents talk to each other, share context, and run 24/7 as a daemon. + +**Agent providers:** 5 built-in adapters — Claude (Anthropic), OpenCode (multi-provider via OpenRouter), Codex (OpenAI), Cursor, and a universal Shell adapter (anything that takes a prompt). + +**Architecture:** +- Each AI tool wrapped in adapter implementing common interface (`src/infrastructure/adapters/`) +- Event bus with wildcard subscriptions for TUI activity feed +- Git worktree isolation per agent +- Inter-agent messaging + shared context +- All state stored locally in `.orchestry/` — no telemetry +- "Set goal at 10pm, wake up to pull requests" + +**Integration potential:** Very interesting adapter pattern. The common interface + event bus architecture is close to what we'd need for a provider abstraction layer. Could study their adapter implementations. + +**Source:** [orch.one](https://www.orch.one/) | [DEV article](https://dev.to/oxgeneral/orchestrating-a-team-of-ai-agents-from-a-single-cli-4h6) + +--- + +### 4. Agent Swarm (Desplega AI) + +- **GitHub:** https://github.com/desplega-ai/agent-swarm +- **Stars:** Notable stargazers (Andrew Ng, Chip Huyen). Exact count TBD. +- **License:** MIT +- **Language:** TypeScript +- **Unique:** Full lead/worker coordination with Docker isolation, compounding memory, persistent agent identity (SOUL.md, IDENTITY.md), and DAG-based workflow engine. + +**Agent providers:** Claude Code (primary), pi-mono. Provider adapter pattern via `HARNESS_PROVIDER=claude|pi`. Codex, Gemini CLI support planned. + +**Architecture:** +- Lead agent decomposes tasks, delegates to worker agents in Docker containers +- MCP API server backed by SQLite for communication and state +- Persistent searchable filesystem shared across swarm (agent-fs) +- Compounding memory: agents learn from every session via summaries + OpenAI embeddings +- Persistent identity: agents have evolving SOUL.md/IDENTITY.md files +- DAG-based workflow engine with triggers, conditions, checkpoint durability +- Integrations: Slack, GitHub, GitLab, Email, Linear +- Dashboard UI with real-time monitoring + debug dashboard with SQL query interface + +**Integration potential:** Most feature-rich orchestrator found. The persistent identity and compounding memory concepts are innovative. Dashboard UI could inspire features. + +**Source:** [GitHub](https://github.com/desplega-ai/agent-swarm) | [Docs](https://docs.agent-swarm.dev) | [Dashboard](https://agent-swarm.desplega.sh/) + +--- + +### 5. Kodo + +- **GitHub:** Listed in awesome-agent-orchestrators +- **Stars:** ~37 +- **License:** Open source +- **Unique:** SWE-bench verified. Autonomous multi-agent orchestrator with independent architect and tester verification stages in work cycles. + +**Agent providers:** Claude Code, Codex, Gemini CLI + +**Architecture:** +- Directs agents through work cycles +- Independent architect verification +- Independent tester verification +- SWE-bench validated results + +**Integration potential:** Small project but interesting verification-centric workflow approach. + +**Source:** [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) + +--- + +### 6. AgentFactory (Supaku) + +- **GitHub:** https://github.com/supaku/agentfactory +- **Stars:** TBD +- **License:** Open source +- **Language:** TypeScript +- **Unique:** "Software factory" with assembly-line pipeline (dev -> QA -> acceptance). Distributed worker pool via Redis. Exposes fleet as MCP server. Implements A2A protocol v0.3.0. + +**Agent providers:** Claude, Codex, Spring AI (via `AgentProvider` interface) + +**Architecture:** +- `AgentProvider` interface for pluggable agent backends +- Pipeline: development -> QA -> acceptance (like CI/CD for agents) +- Distributed worker pool: webhook server + Redis queue + multiple worker nodes +- MCP server exposure: any MCP-aware client can interact with fleet +- A2A protocol support (v0.3.0) — operates as both client and server +- Spring AI Bench integration for benchmarking +- Scaffolding: `@supaku/create-agentfactory-app` +- One-click deploy to Vercel/Railway +- Linear integration for issue tracking + +**Integration potential:** The A2A + MCP server approach is very forward-looking. Enterprise Java teams can use Spring AI agents alongside Claude/Codex. + +**Source:** [GitHub](https://github.com/supaku/agentfactory) + +--- + +## Tier 3: Framework-Level Abstraction Layers + +### 7. Mozilla any-agent + +- **GitHub:** https://github.com/mozilla-ai/any-agent +- **Stars:** ~1,100+ +- **License:** Open source (Mozilla) +- **Language:** Python +- **Unique:** META-FRAMEWORK. Build agent once, switch frameworks by changing `AgentFramework` config parameter. Normalized logging via open-inference. Trace-first evaluation with LLM-as-judge. + +**Agent frameworks supported:** Abstraction over multiple agent frameworks (not providers) — lets you swap between different frameworks without rewriting agent code. + +**Architecture:** +- Single interface to different agent frameworks +- Normalized logging regardless of framework +- Trace-first evaluation approach +- Multi-agent via "Agents-As-Tools" pattern +- Companion projects: `any-llm` (LLM provider abstraction), `any-guardrail`, `Agent Factory` (natural language to agents), `mcpd` ("requirements.txt for agentic systems") + +**Integration potential:** Different abstraction level than what we need. Useful if we want to abstract over agent frameworks rather than coding agent CLIs. The `mcpd` tool for MCP server management is interesting. + +**Source:** [GitHub](https://github.com/mozilla-ai/any-agent) | [Blog](https://blog.mozilla.ai/introducing-any-agent-an-abstraction-layer-between-your-code-and-the-many-agentic-frameworks/) | [Docs](https://mozilla-ai.github.io/any-agent/) + +--- + +### 8. VoltAgent + +- **GitHub:** https://github.com/VoltAgent/voltagent +- **Stars:** TBD (active GitHub org with multiple repos) +- **License:** MIT +- **Language:** TypeScript +- **Unique:** "Refine.dev for AI agents" — TypeScript-first with n8n-style visual debugging console. Multi-agent orchestration with resumable streaming and voice support. + +**Agent providers:** OpenAI, Anthropic, Google, and others — swap by changing config, not code. + +**Architecture:** +- LLM-agnostic: provider swap via config +- Memory adapters (durable, cross-run) +- Resumable streaming: clients reconnect to in-flight streams after refresh +- RAG + Knowledge Base: managed document ingestion, chunking, embeddings, search +- Guardrails: runtime input/output validation +- Evals: built-in eval suites +- Voice: TTS/STT with OpenAI, ElevenLabs, custom providers +- VoltOps Console: observability, automation, deployment, evals (cloud & self-hosted) +- MCP docs server for AI coding assistants + +**Integration potential:** Great TypeScript framework if we want to build our own agent abstraction. The resumable streaming pattern is relevant for Electron apps. + +**Source:** [GitHub](https://github.com/VoltAgent/voltagent) | [voltagent.dev](https://voltagent.dev/) + +--- + +### 9. Mastra + +- **GitHub:** https://github.com/mastra-ai/mastra +- **Stars:** 7,500+ (as of early reports, likely higher now) +- **License:** Open source (EE features source-available under enterprise license) +- **Language:** TypeScript +- **Created by:** Team behind Gatsby (YC-backed) +- **Unique:** "Batteries-included TypeScript AI framework." Used by Replit Agent 3 (improved task success 80% -> 96%). Supports 81 LLM providers and 2,436+ models via Vercel AI SDK. + +**Agent providers:** 40+ providers via Vercel AI SDK (OpenAI, Anthropic, Gemini, etc.) + +**Architecture:** +- Model routing: 40+ providers through one interface +- Human-in-the-loop: suspend/resume with stored execution state +- Context management: conversation history, data retrieval, working + semantic memory +- MCP servers: expose agents/tools/resources via MCP +- Integration with React, Next.js, Node.js +- Serverless deployment: Vercel, Cloudflare, Netlify, or Mastra hosting +- `npm create mastra@latest` for quick start + +**Integration potential:** Very mature TypeScript SDK. Could be used as an underlying agent framework in our Electron app. The human-in-the-loop suspend/resume is exactly what we need for kanban workflows. + +**Source:** [GitHub](https://github.com/mastra-ai/mastra) | [mastra.ai](https://mastra.ai/) | [YC profile](https://www.ycombinator.com/companies/mastra) + +--- + +## Tier 4: Coding Agent Platforms (Individual Agents with Multi-Provider Support) + +### 10. Goose (Block) + +- **GitHub:** https://github.com/block/goose +- **Stars:** 27,000+ +- **License:** Apache 2.0 +- **Language:** Rust +- **Unique:** By Block (Square, Cash App). 25+ LLM providers, 3,000+ MCP servers. Contributed to Linux Foundation's Agentic AI Foundation alongside Anthropic's MCP and OpenAI's AGENTS.md. + +**Agent providers:** 25+ LLM providers (OpenAI, Anthropic, Google, DeepSeek, local via Ollama). Can even use Claude Code as a model provider inside Goose. + +**Architecture:** +- Multi-provider with multi-model configuration (use different models for different tasks in same session) +- Subagents for parallel task execution with isolated workspaces +- MCP-native (among first agents to support MCP) +- CLI + Desktop app (not IDE-locked) +- Recipes system for reusable workflows +- Completely free + open source; you only pay LLM API costs + +**Integration potential:** Goose itself is a coding agent, not an orchestrator. But its multi-provider architecture and MCP integration patterns are worth studying. Could be one of the agents our UI orchestrates. + +**Source:** [GitHub](https://github.com/block/goose) | [block.github.io/goose](https://block.github.io/goose/) | [AI Tool Analysis Review](https://aitoolanalysis.com/goose-ai-review/) + +--- + +### 11. OpenCode + +- **GitHub:** https://github.com/opencode-ai/opencode +- **Stars:** 95K-120K+ (massive growth, surpassed Claude Code in stars) +- **License:** Open source +- **Language:** Go (Bubble Tea TUI) +- **Created by:** Team behind SST (Serverless Stack) and terminal.shop +- **Unique:** Go-based terminal agent with 75+ LLM providers. Built-in TUI with Vim-like editor. 5M+ monthly developers. + +**Agent providers:** 75+ providers — OpenAI, Anthropic, Google Gemini, AWS Bedrock, Groq, Azure OpenAI, OpenRouter, and more. + +**Architecture:** +- Interactive TUI built with Bubble Tea +- Session management with persistent SQLite storage +- Multiple agent types: plan agent (analysis), general-purpose agent (full tool access) +- Parallel work units +- MCP integration for external tools +- LSP integration for code intelligence +- Provider-agnostic philosophy: "as models evolve, being provider-agnostic is important" + +**Integration potential:** OpenCode is a single-agent tool, not an orchestrator. However, it's the most popular open-source alternative to Claude Code. Worth considering as a supported runtime for our orchestrator. + +**Source:** [GitHub](https://github.com/opencode-ai/opencode) | [opencode.ai](https://opencode.ai/) | [OpenCode Docs - Agents](https://opencode.ai/docs/agents/) | [OpenCode Docs - Providers](https://opencode.ai/docs/providers/) + +--- + +### 12. OpenHands (formerly OpenDevin) + +- **GitHub:** https://github.com/OpenHands/OpenHands +- **Stars:** 68,600+ +- **License:** MIT +- **Language:** Python +- **Unique:** Cloud coding agent platform with $18.8M Series A. Solves 87% of bug tickets same day. Event stream architecture with typed events. + +**Agent providers:** 100+ providers via LiteLLM (OpenAI, Anthropic, Google, etc.). Git providers: GitHub, GitLab, Bitbucket, Azure DevOps, Forgejo. + +**Architecture:** +- Event stream architecture: all agent-environment interactions as typed events through central hub +- Agent -> Runtime -> EventStream -> LLM pipeline +- Hierarchical agent coordination via delegation tool +- Sub-agents as independent conversations inheriting parent config +- Distributed deployment: WebSocket for agent/runtime communication +- Isolated Docker/Kubernetes environments +- V1 SDK transition: moving from mandatory Docker to optional sandboxing +- Software Agent SDK for building custom agents + +**Integration potential:** Enterprise-grade platform. The event stream architecture and typed events pattern could inspire our agent communication protocol. + +**Source:** [GitHub](https://github.com/OpenHands/OpenHands) | [openhands.dev](https://openhands.dev/) | [Software Agent SDK paper](https://arxiv.org/html/2511.03690v1) + +--- + +## Tier 5: Specialized Multi-Agent Coding Systems + +### 13. Liza (Disciplined Multi Coding Agent System) + +- **GitHub:** https://github.com/liza-mas/liza +- **Stars:** TBD +- **License:** Open source +- **Unique:** "Lisa Simpson vs Ralph Wiggum" philosophy. 55+ LLM failure modes mapped to countermeasures. Behavioral contracts, blackboard coordination, and explicit state machine. MOST disciplined approach to multi-agent coding. + +**Architecture:** +- Behavioral contract with Tier 0 invariants (never violated) +- Blackboard coordination: shared file tracks goals, tasks, assignments, history +- Stateless agents with external specs for context handoff +- Approval Request mechanism forces reasoning before acting +- Deterministic pre/post hooks at role transitions +- Orchestrator-routed model selection +- Agent roles: Coder, Security Auditor, Security Audit Reviewer +- Sprint-based workflow: autonomous within sprints, human reviews between sprints +- CLI: `liza setup`, `liza init`, `liza agent coder`, `liza validate`, `liza watch`, `liza sprint-checkpoint` + +**Integration potential:** The behavioral contract and blackboard coordination concepts are academically interesting and could improve agent reliability. + +**Source:** [GitHub](https://github.com/liza-mas/liza) + +--- + +### 14. Multi-Agent Coding System (Danau5tin) + +- **GitHub:** https://github.com/Danau5tin/multi-agent-coding-system +- **Stars:** TBD +- **License:** Open source +- **Unique:** Reached #13 on Stanford's TerminalBench (slightly above Claude Code). Novel "Context Store" for multi-agent knowledge sharing. RL-trained 14B Orca-Agent model. + +**Architecture:** +- Orchestrator + Explorer + Coder agents with knowledge artifacts +- Context Store: persistent knowledge layer with selective injection +- Trust Calibration Strategy: adaptive delegation based on task complexity +- Orchestrator cannot read/modify code directly — operates at architectural level only +- Companion project: Orca-Agent-RL (14B model, trained on 32x H100s) + +**Integration potential:** The Context Store pattern for multi-agent knowledge sharing is a novel approach worth studying. + +**Source:** [GitHub](https://github.com/Danau5tin/multi-agent-coding-system) | [Hacker News](https://news.ycombinator.com/item?id=45113348) + +--- + +### 15. Open SWE (LangChain) + +- **GitHub:** https://github.com/langchain-ai/open-swe +- **Stars:** 7,700+ +- **License:** MIT +- **Language:** Python +- **Unique:** Built on LangGraph Deep Agents framework. Multi-agent architecture (Manager, Planner, Programmer, Reviewer). Captures patterns used by Stripe, Ramp, Coinbase for internal coding agents. + +**Agent providers:** Any LLM via LangGraph. Multiple sandbox providers: Modal, Daytona, Runloop, LangSmith. + +**Architecture:** +- Manager -> Planner -> Programmer -> Reviewer pipeline +- Isolated Daytona sandboxes per task +- Subagent orchestration via Deep Agents task tool +- Middleware hooks: deterministic middleware around agent loop +- AGENTS.md support: read from sandbox, injected into system prompt +- Async & cloud-native: multiple tasks in parallel, "double texting" support +- Integrations: Linear, Slack, GitHub + +**Integration potential:** Enterprise-grade coding agent framework. The middleware hook pattern and AGENTS.md support are interesting patterns. + +**Source:** [GitHub](https://github.com/langchain-ai/open-swe) | [LangChain Blog](https://blog.langchain.com/introducing-open-swe-an-open-source-asynchronous-coding-agent/) + +--- + +### 16. DeerFlow 2.0 (ByteDance) + +- **GitHub:** https://github.com/bytedance/deer-flow +- **Stars:** 37,000+ +- **License:** MIT +- **Language:** Python +- **Unique:** ByteDance's "SuperAgent harness." Ground-up rewrite of v1. Multi-service architecture with Nginx reverse proxy. Skills system for extensibility. #1 GitHub Trending within 24h of launch. + +**Agent providers:** Model-agnostic — any OpenAI-compatible API (GPT-4, Claude, Gemini, DeepSeek, local models via Ollama). + +**Architecture:** +- Harness (core): agent orchestration, tools, sandbox, models, MCP, skills, config +- App layer: FastAPI Gateway API + IM channel integrations (Feishu, Slack, Telegram) +- Lead agent decomposes tasks, spawns sub-agents with scoped contexts +- Docker-sandboxed execution per sub-agent (own filesystem, bash terminal) +- Skills system: Markdown-based workflow definitions with best practices +- Persistent JSON memory system (user context, history, facts with confidence scores) +- Three sandbox modes (configurable via config.yaml) +- MCP servers with OAuth token flows + +**Integration potential:** Impressive scale and ByteDance backing. Skills system is interesting — Markdown-based workflow definitions could be adapted for our agent team recipes. + +**Source:** [GitHub](https://github.com/bytedance/deer-flow) | [deerflow.tech](https://deerflow.tech/) | [DeepWiki analysis](https://deepwiki.com/bytedance/deer-flow) + +--- + +## Tier 6: Infrastructure & Runtime Frameworks + +### 17. Dapr Agents (CNCF) + +- **GitHub:** https://github.com/dapr/dapr-agents +- **Stars:** Part of Dapr ecosystem (34K+ stars for main Dapr project) +- **License:** Open source (CNCF) +- **Language:** Python (only) +- **Unique:** v1.0 GA announced at KubeCon Europe 2026. DurableAgent class: every LLM call and tool execution is a checkpoint. Kill process mid-workflow, resume from last saved point. + +**Agent providers:** LLM provider decoupling via Dapr Conversation API — swap LLMs without code changes (OpenAI, Anthropic, AWS Bedrock, etc.) + +**Architecture:** +- Kubernetes-native: distribute thousands of agents across pods/nodes +- DurableAgent with checkpoint/resume +- Multi-agent via Dapr pub/sub messaging +- Coordination models: LLM-based, random, round-robin +- SPIFFE identity for agent-to-agent authorization +- Distributed tracing via OTEL + Prometheus metrics +- mTLS encrypted communication +- Enterprise adoption: ZEISS, EU logistics companies + +**Integration potential:** Overkill for desktop app, but the DurableAgent checkpoint/resume pattern could inspire our agent crash recovery. Python-only is a limitation. + +**Source:** [GitHub](https://github.com/dapr/dapr-agents) | [Diagrid Blog](https://www.diagrid.io/blog/dapr-agents-1-0-durable-cloud-native-production-ready) | [KubeCon announcement](https://jangwook.net/en/blog/en/dapr-agents-v1-cncf-production-ai-framework/) + +--- + +### 18. Sandcastle + +- **GitHub:** https://github.com/gizmax/Sandcastle +- **Stars:** TBD +- **License:** Open source +- **Language:** Python +- **Unique:** EU AI Act compliance built-in. 63 integrations. YAML-defined workflows. Smart model routing (quality/cost/latency constraints per step). 118 built-in + 118 community workflow templates. + +**Agent providers:** OpenAI, Anthropic, plus many more via multi-provider routing. Budget pressure detection forces cheaper models. + +**Architecture:** +- YAML workflow definitions with DAG dependencies and parallel branches +- 4 sandbox backends: E2B cloud microVMs, Docker, Cloudflare Workers edge, local subprocess +- Smart model routing with historical performance data +- 5 browser automation modes (Playwright, Computer Use, DOM Extract, LightPanda, Browserbase) +- Real-time SSE dashboard (runs, costs, schedules, approvals, experiments) +- A/B testing models and prompts per step with auto-deployment +- Replay & checkpoints: re-run from any step +- PII redaction and tamper-evident audit trail +- Agent runtime with circuit breaker and pool management + +**Integration potential:** Enterprise-grade workflow orchestrator. The smart model routing and A/B testing capabilities could be interesting for our team management feature. + +**Source:** [GitHub](https://github.com/gizmax/Sandcastle) | [gizmax.cz/sandcastle](https://gizmax.cz/sandcastle/) + +--- + +### 19. AgentScope + Runtime (Alibaba/Tongyi Lab) + +- **GitHub:** https://github.com/agentscope-ai/agentscope (~18,900+ stars) + https://github.com/agentscope-ai/agentscope-runtime +- **License:** Open source +- **Language:** Python (+ Java implementation) +- **Unique:** Production-ready agent platform with SEPARATE runtime framework. Framework-agnostic runtime (not tied to AgentScope itself). "Agent as API" approach. Java SDK available. + +**Agent providers:** OpenAI, DashScope, Gemini, Anthropic, self-hosted open-source models. Provider-agnostic via formatter system. + +**Architecture:** +- AgentScope: agent development framework with multi-agent collaboration +- AgentScope Runtime: separate deployment infrastructure (sandboxing, state management, memory) +- Runtime is framework-agnostic — works with other agent frameworks too +- Agent-as-API: white-box development experience +- Multi-layer hook system for observability (OpenTelemetry integration) +- Serverless deployment support (Alibaba Cloud FC) +- Java implementation (Spring AI Alibaba, Langchain4j) +- ReAct agent built implementation-agnostic + +**Integration potential:** The separation of agent framework from runtime is architecturally clean. The framework-agnostic runtime concept aligns with our need for a provider-neutral orchestration layer. + +**Source:** [GitHub (main)](https://github.com/agentscope-ai/agentscope) | [GitHub (runtime)](https://github.com/agentscope-ai/agentscope-runtime) + +--- + +### 20. OpenAgentsControl (OAC) + +- **GitHub:** https://github.com/darrenhinde/OpenAgentsControl +- **Stars:** ~2,900 +- **License:** Open source +- **Language:** Built on OpenCode +- **Unique:** Plan-first, approval-based execution. "Minimal Viable Information" (MVI) principle = 80% token reduction. Editable agents via Markdown files. + +**Agent providers:** Model-agnostic — Claude, GPT, Gemini, local models (Ollama, LM Studio). Built on OpenCode. + +**Architecture:** +- Propose -> Approve -> Execute model +- MVI principle: load only relevant patterns per task (80% token savings) +- Editable agents: modify behavior by editing Markdown files +- Custom Agent System Builder wizard +- Coding patterns committed to repos (team consistency) +- Multi-language: TypeScript, Python, Go, Rust + +**Integration potential:** The MVI token reduction technique and editable Markdown agents are useful ideas. Plan-first approach aligns with structured team workflows. + +**Source:** [GitHub](https://github.com/darrenhinde/OpenAgentsControl) | [BrightCoding review](https://www.blog.brightcoding.dev/2026/02/19/openagentscontrol-the-revolutionary-ai-agent-framework) + +--- + +### 21. NeuroLink (Juspay) + +- **GitHub:** https://github.com/juspay/neurolink +- **Stars:** ~119 +- **License:** MIT +- **Language:** TypeScript +- **Unique:** Enterprise-grade unified API for 12 major AI providers and 100+ models. Extracted from production systems at Juspay. Multi-provider failover and automatic cost optimization. + +**Agent providers:** 12 providers unified: OpenAI, Google, Anthropic, AWS, Azure, Groq, Together AI, Mistral, Cohere, Fireworks, Cloudflare, Ollama. 300+ models via OpenRouter integration. + +**Architecture:** +- Single API for 12+ providers (switch with one parameter change) +- 64+ built-in tools and MCP servers +- Multi-step agentic loops with per-step tool execution control +- Persistent memory (Redis/S3/SQLite) +- HITL workflows +- Structured output with Zod schemas +- Auto cost optimization and multi-provider failover +- LiteLLM integration for 100+ models +- TypeScript SDK + professional CLI + +**Integration potential:** Good TypeScript SDK for unified LLM access. If we need to add direct LLM provider abstraction (beyond just spawning CLI agents), NeuroLink's approach is solid. + +**Source:** [GitHub](https://github.com/juspay/neurolink) + +--- + +### 22. Pi-mono (badlogic) + +- **GitHub:** https://github.com/badlogic/pi-mono +- **Stars:** TBD +- **License:** Open source +- **Language:** TypeScript (npm packages) +- **Unique:** Minimal terminal coding harness with 4 modes: interactive, print/JSON, RPC, and SDK for embedding. Extensible via TypeScript Extensions, Skills, Prompt Templates, and Themes. + +**Agent providers:** Multi-provider via `Api` type union. Providers added by extending the API identifier system. + +**Architecture:** +- Monorepo with multiple packages (`packages/coding-agent`, etc.) +- 4 modes: interactive, print/JSON, RPC (process integration), SDK (embedding) +- OpenClaw SDK integration for real-world use +- Extension system: TypeScript Extensions, Skills, Prompt Templates, Themes +- Packaged as npm packages for sharing +- Used as a provider in Agent Swarm (`HARNESS_PROVIDER=pi`) + +**Integration potential:** The RPC and SDK modes are interesting for embedding a coding agent into our Electron app. Minimal footprint philosophy is appealing. + +**Source:** [GitHub](https://github.com/badlogic/pi-mono) + +--- + +### 23. Agentic Fleet (Qredence) + +- **GitHub:** https://github.com/Qredence/agentic-fleet +- **Stars:** TBD +- **License:** Open source +- **Language:** Python (backend) + React 19 + TypeScript (frontend) +- **Unique:** Built on Microsoft Agent Framework's Magentic Fleet pattern. Five-phase pipeline: analysis -> routing -> execution -> progress -> quality. + +**Architecture:** +- Backend: Python 3.12/3.13, FastAPI, Typer CLI, DSPy, Microsoft Agent Framework +- Frontend: React 19, TypeScript, Vite, Tailwind CSS, Radix UI, Shadcn UI +- ToolRegistry adapters (Tavily search, browser automation, code execution, MCP) +- Real-time SSE/WebSocket streaming +- Five-phase task pipeline + +**Integration potential:** Good example of combining Microsoft Agent Framework with a React frontend. The ToolRegistry adapter pattern is relevant. + +**Source:** [GitHub](https://github.com/Qredence/agentic-fleet) + +--- + +### 24. Plandex + +- **GitHub:** https://github.com/plandex-ai/plandex +- **Stars:** 15,086 +- **License:** MIT +- **Language:** Go +- **Unique:** Terminal-based AI coding with 2M token context, full version control for AI plans (branches, diff review), and cumulative diff review sandbox. + +**Agent providers:** Combine models from Anthropic, OpenAI, Google, and open source providers. + +**Architecture:** +- 2M token context handling (~100k per file) +- Tree-sitter project maps for 20M+ token directories +- Version control for plans (branches, compare models) +- Cumulative diff review sandbox (changes separate until approved) +- Full autonomy capable but highly configurable step-by-step review +- Git integration with auto-commit + +**Integration potential:** Single agent, not an orchestrator. But the plan version control and diff sandbox concepts are relevant to our code review feature. + +**Source:** [GitHub](https://github.com/plandex-ai/plandex) | [plandex.ai](https://plandex.ai/) + +--- + +## Tier 7: Evolving / Archived (Notable Mentions) + +### 25. ControlFlow -> Marvin 3.0 (PrefectHQ) + +- **GitHub:** https://github.com/PrefectHQ/ControlFlow (archived) -> https://github.com/PrefectHQ/marvin +- **Unique:** Task-centric architecture with Prefect 3.0 observability. Evolved into Marvin 3.0 using Pydantic AI for LLM interactions (full range of providers). +- **Note:** ControlFlow is archived, Marvin 3.0 is the successor with broader provider support. + +**Source:** [GitHub (ControlFlow)](https://github.com/PrefectHQ/ControlFlow) | [GitHub (Marvin)](https://github.com/PrefectHQ/marvin) + +--- + +## Summary Comparison Table + +| Tool | Type | Stars | Language | Agent Providers | Desktop App | Key Differentiator | +|------|------|-------|----------|----------------|-------------|-------------------| +| **Emdash** | ADE | 2,700+ | Electron | 22 CLI agents | Yes | YC W26, tickets integration | +| **Constellagent** | ADE | TBD | macOS native | Any CLI agent | Yes (macOS only) | Terminal+editor+worktree per agent | +| **ORCH** | CLI | TBD | TypeScript | 5 adapters | TUI | State machine, inter-agent messaging | +| **Agent Swarm** | CLI+Dashboard | TBD | TypeScript | Claude, Pi | Dashboard UI | Compounding memory, persistent identity | +| **AgentFactory** | CLI+Web | TBD | TypeScript | Claude, Codex, Spring AI | Dashboard | A2A protocol, MCP server, Redis pool | +| **Goose** | Agent | 27K+ | Rust | 25+ LLM providers | Desktop+CLI | Linux Foundation, MCP-native | +| **OpenCode** | Agent | 95K+ | Go | 75+ providers | TUI | Fastest-growing, Bubble Tea UI | +| **OpenHands** | Platform | 68K+ | Python | 100+ via LiteLLM | Web UI | $18.8M Series A, event stream arch | +| **DeerFlow** | Harness | 37K+ | Python | Any OpenAI-compatible | Web UI | ByteDance, skills system | +| **Open SWE** | Framework | 7,700+ | Python | Any via LangGraph | No | LangChain, enterprise patterns | +| **Mastra** | Framework | 7,500+ | TypeScript | 40+ providers | No | By Gatsby team, used by Replit | +| **Mozilla any-agent** | Meta-framework | 1,100+ | Python | Framework abstraction | No | Switch frameworks, not providers | +| **VoltAgent** | Framework | TBD | TypeScript | OpenAI, Anthropic, Google | Console UI | Resumable streaming, voice | +| **Dapr Agents** | Runtime | Part of 34K+ | Python | Via Conversation API | No | CNCF, Kubernetes-native, durable agents | +| **Liza** | System | TBD | CLI | Any LLM | No | Behavioral contracts, 55+ failure modes | +| **Sandcastle** | Orchestrator | TBD | Python | Multi-provider routing | Dashboard | EU AI Act, YAML workflows, 118 templates | + +--- + +## Key Architectural Patterns Observed + +### 1. Agent Runtime Interface Pattern +**Used by:** ORCH, Overstory, Agent Swarm, AgentFactory +- Define a common interface (spawn, configure, detect readiness, parse transcript) +- Each agent provider gets an adapter implementing this interface +- Swap providers without changing orchestration logic + +### 2. Git Worktree Isolation Pattern +**Used by:** Emdash, Constellagent, ORCH, Agent Swarm, ComposioHQ +- Standard approach for multi-agent parallel work +- Each agent gets its own worktree + branch +- Merge back via PR/conflict resolution + +### 3. Event Stream / Pub-Sub Architecture +**Used by:** OpenHands, ORCH, Dapr Agents +- All agent interactions as typed events through central hub +- Enables observability, replay, and debugging + +### 4. Checkpoint/Resume (Durable Execution) +**Used by:** Dapr Agents, Sandcastle, Mastra +- Every step saves a checkpoint +- Kill process mid-workflow -> resume from last saved point +- Critical for production reliability + +### 5. Lead-Worker Decomposition +**Used by:** Agent Swarm, DeerFlow, Open SWE, Claude Agent Teams (ours) +- Lead agent decomposes tasks +- Workers execute in isolation +- Results stitched back together + +--- + +## Integration Relevance for Claude Agent Teams UI + +### Direct Competitors (UI level) +1. **Emdash** — Most direct competitor. YC-backed. 22 agents. But lacks kanban, inter-agent communication, and team orchestration. +2. **Constellagent** — macOS-only. Simpler scope. + +### Architectural Inspiration +1. **ORCH** — Adapter interface pattern for agent providers + state machine for task lifecycle +2. **Agent Swarm** — Compounding memory + persistent identity + dashboard UI +3. **AgentFactory** — A2A protocol + MCP server exposure + pipeline stages +4. **VoltAgent** — TypeScript-first framework with resumable streaming (relevant for Electron) +5. **Mastra** — Human-in-the-loop suspend/resume via stored state + +### Worth Studying +1. **Liza** — Behavioral contracts for agent reliability +2. **Mozilla any-agent** — Meta-framework approach +3. **OpenHands** — Event stream architecture at scale +4. **DeerFlow** — Skills system (Markdown-based workflow definitions) + +### Key Competitive Advantages We Have +- **Kanban board** — NO ONE else has this for agent orchestration +- **Inter-agent communication** — Most tools only have lead-worker, not peer-to-peer +- **Code review workflow** — Diff view per task with approve/reject +- **Claude Code Agent Teams native support** — Built specifically for Claude's team protocol +- **Context monitoring** — Token usage tracking by category (unique) +- **Zero-setup onboarding** — Built-in Claude Code installation diff --git a/docs/research/ai-orchestration-tools-part3.md b/docs/research/ai-orchestration-tools-part3.md new file mode 100644 index 00000000..3fc536c4 --- /dev/null +++ b/docs/research/ai-orchestration-tools-part3.md @@ -0,0 +1,861 @@ +# AI Orchestration Tools Research — Part 3 + +**Date:** 2026-03-24 +**Focus:** Emerging/niche agent orchestrators, infrastructure-level tools, protocol-first frameworks, TypeScript/Node-based solutions, fleet managers + +--- + +## Table of Contents + +1. [TypeScript-First Agent Frameworks](#1-typescript-first-agent-frameworks) +2. [Infrastructure & Gateway Layer](#2-infrastructure--gateway-layer) +3. [Durable Execution & Workflow Engines](#3-durable-execution--workflow-engines) +4. [Visual & Low-Code Agent Builders](#4-visual--low-code-agent-builders) +5. [Protocol Standards & Ecosystem](#5-protocol-standards--ecosystem) +6. [Coding Agent Fleet Managers](#6-coding-agent-fleet-managers) +7. [Python-First Frameworks (with TS relevance)](#7-python-first-frameworks-with-ts-relevance) +8. [Summary Matrix](#8-summary-matrix) +9. [Recommendations for Claude Agent Teams UI](#9-recommendations-for-claude-agent-teams-ui) + +--- + +## 1. TypeScript-First Agent Frameworks + +### 1.1 Mastra AI + +- **URL:** https://github.com/mastra-ai/mastra +- **Stars:** ~22.3k (March 2026) +- **npm downloads:** 300k+/week +- **License:** Apache 2.0 +- **Funding:** $13M seed (YC W25, Paul Graham, Gradient Ventures) +- **Source:** [Mastra GitHub](https://github.com/mastra-ai/mastra), [Mastra Docs](https://mastra.ai/docs), [The New Stack](https://thenewstack.io/mastra-empowers-web-devs-to-build-ai-agents-in-typescript/) + +**What it is:** From the team behind Gatsby — a full-featured TypeScript framework for AI agents, workflows, RAG, and memory. Model routing to 40+ providers through one interface (OpenAI, Anthropic, Gemini, etc.). + +**Architecture highlights:** +- **Agents** — autonomous entities with LLM + tools + system instructions +- **Workflows** — graph-based state machines with discrete steps, inputs/outputs +- **Memory** — short-term and long-term memory across threads and sessions +- **Mastra Studio** — local developer playground for visualization/debugging +- **Production tools** — built-in evals, observability, tracing + +**Enterprise adoption:** Replit (Agent 3), SoftBank, Marsh McLennan (75k employees), PayPal, Adobe, Docker. + +**Relevance for Electron integration:** +- Pure TypeScript, runs on Node.js natively +- Can deploy as standalone server or embed in existing Node apps +- Most mature TS agent framework by adoption metrics +- Workflow engine could serve as orchestration backend +- **Confidence: 9/10, Reliability: 9/10** + +--- + +### 1.2 Inngest AgentKit + +- **URL:** https://github.com/inngest/agent-kit +- **Stars:** ~793 +- **npm:** `@inngest/agent-kit` +- **License:** Apache 2.0 (core), proprietary cloud +- **Source:** [AgentKit Docs](https://agentkit.inngest.com/overview), [Inngest Blog](https://www.inngest.com/blog/ai-orchestration-with-agentkit-step-ai) + +**What it is:** TypeScript library for building multi-agent networks with deterministic routing, MCP tooling, and durable execution through Inngest's workflow engine. + +**Architecture highlights:** +- **Agents** — LLM calls with prompts, tools, and MCP +- **Networks** — agents collaborate with shared State and handoff +- **Routers** — from code-based to LLM-based (ReAct) orchestration +- **State** — typed state machine combined with conversation history +- **Tracing** — built-in debug/optimize locally and in cloud +- **React hooks** — `@inngest/use-agent` for frontend integration +- Supports OpenAI, Anthropic, Gemini, and OpenAI-compatible models + +**Key differentiator:** Backed by Inngest's durable execution engine — agents survive crashes, can pause/resume, and handle long-running tasks with automatic retries. This is critical for production reliability. + +**Relevance for Electron integration:** +- Pure TypeScript, lightweight +- Good abstraction for multi-agent networks with routing +- Durable execution is exactly what production agent teams need +- React hooks for UI integration +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 1.3 VoltAgent + +- **URL:** https://github.com/VoltAgent/voltagent +- **Stars:** ~5.1k (March 2026) +- **License:** MIT +- **Source:** [VoltAgent site](https://voltagent.dev/), [GitHub](https://github.com/VoltAgent/voltagent), [MarkTechPost](https://www.marktechpost.com/2025/04/22/meet-voltagent-a-typescript-ai-framework-for-building-and-orchestrating-scalable-ai-agents/) + +**What it is:** Observability-first TypeScript AI agent framework with Memory, RAG, Guardrails, Tools, MCP, Voice, Workflow support. + +**Architecture highlights:** +- **VoltOps Console** — like n8n but for debugging AI agents (cloud & self-hosted) +- Multi-agent workflows via Chain API — compose, branch, orchestrate +- Workflow steps typed with Zod schemas (compile-time safety + runtime validation) +- Human-in-the-loop with pause/resume +- MCP support, bring-your-own LLMs + +**Key differentiator:** Observability as a first-class concern. The VoltOps console provides real-time monitoring, debugging, and workflow visualization — useful for our kanban-style task monitoring. + +**Relevance for Electron integration:** +- MIT license, TypeScript-first, Node.js native +- Observability features could complement our session analysis +- Zod-based typing aligns with our codebase patterns +- **Confidence: 7/10, Reliability: 6/10** + +--- + +### 1.4 HazelJS + +- **URL:** https://github.com/hazel-js/hazeljs +- **Stars:** Small (early alpha) +- **npm:** `@hazeljs/core`, `@hazeljs/agent`, `@hazeljs/ai`, etc. (38+ packages) +- **License:** Apache 2.0 +- **Source:** [HazelJS site](https://hazeljs.ai/), [DEV.to](https://dev.to/arslan_mecom/from-beta-to-alpha-the-hazeljs-journey-in-38-packages-3nad) + +**What it is:** AI-native backend framework with production-grade Agent Runtime, Agentic RAG, and persistent memory. NestJS-style decorator-based API. + +**Architecture highlights:** +- Modular: 40+ installable npm packages (core, ai, agent, rag, memory, flow, auth, cache...) +- **AgentGraph** + **SupervisorAgent** for multi-agent orchestration +- **@hazeljs/flow** — durable workflow engine with wait/resume, idempotency, retries +- **@hazeljs/memory** — pluggable user memory (in-memory, Postgres, Redis, Prisma, vector) +- Decorator-based: `@Agent`, `@Tool`, `@Controller`, `@SemanticSearch` +- Supports OpenAI, Anthropic, Ollama + +**Key differentiator:** Full backend framework approach (not just agents), NestJS-inspired architecture. Combines web framework + agent runtime + durable workflows in one stack. + +**Relevance for Electron integration:** +- TypeScript-first, modular npm packages +- Durable flow engine could be useful +- Very early (alpha) — risky for production +- **Confidence: 5/10, Reliability: 4/10** + +--- + +### 1.5 Agentica + +- **URL:** https://github.com/wrtnlabs/agentica +- **npm:** `@agentica/core`, `@agentica/rpc` +- **License:** MIT +- **Source:** [Agentica Docs](https://wrtnlabs.io/agentica/), [GitHub](https://github.com/wrtnlabs/agentica) + +**What it is:** TypeScript framework specialized in LLM Function Calling, enhanced by the TypeScript compiler. By Wrtn Technologies. + +**Architecture highlights:** +- **Compiler-driven development** — constructs function calling schemas automatically from TypeScript types via `typia` +- Auto-converts Swagger/OpenAPI/MCP documents into function calling schemas +- **Validation feedback** — detects and corrects AI mistakes in argument composition +- **Selector agent** — filters candidate functions to minimize context/tokens +- Supports embedded controllers: Google Calendar, GitHub, Reddit, Slack, etc. + +**Key differentiator:** Instead of complex agent graphs/workflows, you just list TypeScript class types or OpenAPI docs, and Agentica handles function calling automatically. The compiler does the heavy lifting. + +**Relevance for Electron integration:** +- MIT license, TypeScript-native +- Interesting approach for auto-generating tool interfaces +- Could be useful for generating agent tool schemas from existing code +- **Confidence: 6/10, Reliability: 5/10** + +--- + +### 1.6 Strands Agents (AWS) + +- **URL:** https://github.com/strands-agents +- **Downloads:** 14M+ total (since May 2025) +- **License:** Open source (Apache 2.0) +- **Source:** [Strands site](https://strandsagents.com/), [AWS Blog](https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/) + +**What it is:** Open source SDK from AWS for building AI agents in Python and TypeScript. Model-driven approach — works with Bedrock, Anthropic, OpenAI, and more. + +**Architecture highlights:** +- TypeScript SDK (preview, December 2025) with full type safety, async/await +- Native tools for AWS service interactions +- Edge device support (sub-100ms latency, ARM/x86, offline with llama.cpp) +- **Steering** — modular prompt mechanism to guide agents mid-execution +- **Evaluations** — validate agent behavior +- Multi-agent patterns: Agent-as-Tool, Swarm + +**Key differentiator:** AWS backing, production-tested at enterprise scale. TypeScript support enables browser/server/Lambda deployment. Edge device support is unique. + +**Relevance for Electron integration:** +- TypeScript SDK available +- AWS-heavy ecosystem may add unwanted dependencies +- Good multi-agent patterns (Agent-as-Tool, Swarm) +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 1.7 OpenAI Agents SDK (TypeScript) + +- **URL:** https://github.com/openai/openai-agents-js +- **Stars:** ~2.1k +- **npm downloads:** ~128k/week +- **License:** MIT +- **Source:** [OpenAI Agents SDK TS](https://openai.github.io/openai-agents-js/) + +**What it is:** Official OpenAI framework for multi-agent workflows and voice agents in TypeScript. + +**Architecture highlights:** +- Agents as tools / Handoffs for cross-agent delegation +- Guardrails for input validation, run in parallel with agent execution +- Function tools with Zod-powered validation and automatic schema generation +- Built-in MCP server tool integration +- TypeScript-first: orchestrate agents using native language features + +**Key differentiator:** Official OpenAI support, lightweight but powerful. Handoff mechanism is well-designed for multi-agent coordination. + +**Relevance for Electron integration:** +- MIT license, pure TypeScript +- Strong typing with Zod +- Model-locked to OpenAI (primary limitation) +- **Confidence: 8/10, Reliability: 7/10** + +--- + +### 1.8 Google ADK for TypeScript + +- **URL:** https://developers.googleblog.com/introducing-agent-development-kit-for-typescript-build-ai-agents-with-the-power-of-a-code-first-approach/ +- **Stars:** ~581 (December 2025 launch) +- **npm downloads:** ~5k/week +- **License:** Apache 2.0 +- **Source:** [Google Developers Blog](https://developers.googleblog.com/introducing-agent-development-kit-for-typescript-build-ai-agents-with-the-power-of-a-code-first-approach/) + +**What it is:** Google's open-source TypeScript framework for building AI agents and multi-agent systems. Code-first approach. + +**Architecture highlights:** +- First-class MCP and A2A protocol support +- Multi-agent coordination +- Code-first TypeScript development + +**Key differentiator:** Google backing, first-class A2A support. Strong protocol-first approach. + +**Relevance for Electron integration:** +- Pure TypeScript, Apache 2.0 +- Still young (December 2025 launch) +- A2A support could be important for future interop +- **Confidence: 6/10, Reliability: 5/10** + +--- + +## 2. Infrastructure & Gateway Layer + +### 2.1 AgentGateway + +- **URL:** https://github.com/agentgateway/agentgateway +- **Stars:** ~2k+ (hit 1M image pulls, 115 contributors) +- **License:** Open source (Linux Foundation) +- **Language:** Rust +- **Source:** [AgentGateway site](https://agentgateway.dev/), [GitHub](https://github.com/agentgateway/agentgateway), [Solo.io Blog](https://www.solo.io/blog/updated-a2a-and-mcp-gateway) + +**What it is:** Next-generation agentic proxy for AI agents and MCP servers. A production-ready gateway for the agentic era, written in Rust. + +**Architecture highlights:** +- **MCP + A2A protocol support** — deep protocol awareness +- **RBAC** — robust role-based access control for MCP/A2A +- **Multi-tenancy** — each tenant with own resources and users +- **Dynamic config via xDS** — no downtime updates +- **Kubernetes-native** — built-in Kubernetes controller via Gateway API +- **LLM routing** — can route traffic to OpenAI, Anthropic, Gemini, Bedrock +- **Legacy API translation** — transforms OpenAPI specs into MCP tools automatically +- **v1.0 released** — production-ready milestone + +**Key differentiator:** The infrastructure layer between agents and their tools/peers. Not an agent framework itself, but the network fabric that makes multi-agent systems work in production. Backed by Solo.io (Envoy/Istio experts), donated to Linux Foundation. + +**Relevance for Electron integration:** +- Written in Rust — not directly embeddable in Node.js +- Could be used as a sidecar/proxy process alongside Electron +- OpenAPI-to-MCP translation is very useful for tool integration +- **Confidence: 6/10, Reliability: 8/10** + +--- + +### 2.2 MCP Gateway & Registry + +- **URL:** https://github.com/agentic-community/mcp-gateway-registry +- **License:** Open source +- **Source:** [GitHub](https://github.com/agentic-community/mcp-gateway-registry) + +**What it is:** Enterprise-ready MCP Gateway & Registry that centralizes AI development tools with OAuth authentication, dynamic tool discovery, and unified access for AI agents and coding assistants. + +**Architecture highlights:** +- Unified MCP Server Gateway — single access point +- MCP Servers Registry — dynamic tool discovery +- Agent Registry & A2A Communication Hub +- Dual authentication: human user + machine-to-machine agent auth +- Keycloak/Entra integration for enterprise SSO + +**Key differentiator:** Governance layer for MCP servers — transforms "scattered MCP server chaos into governed, auditable tool access." This is the missing middleware between agents and tools. + +**Relevance for Electron integration:** +- Could solve MCP server management for team agents +- OAuth/auth layer would be useful for enterprise deployments +- **Confidence: 5/10, Reliability: 5/10** + +--- + +### 2.3 Invariant Gateway + +- **URL:** https://github.com/invariantlabs-ai/invariant-gateway +- **License:** Open source +- **Source:** [GitHub](https://github.com/invariantlabs-ai/invariant-gateway) + +**What it is:** LLM proxy to observe and debug what AI agents are doing. Supports MCP (stdio, SSE, Streamable HTTP) tool calling. Integrates with LiteLLM. + +**Key differentiator:** Focused on observability and debugging of agent tool calls — complementary to our session analysis features. + +--- + +## 3. Durable Execution & Workflow Engines + +### 3.1 Temporal + +- **URL:** https://github.com/temporalio/temporal +- **Stars:** 13k+ +- **Valuation:** $5B (Series D, February 2026, led by a16z) +- **License:** MIT +- **Source:** [Temporal Blog](https://temporal.io/blog/of-course-you-can-build-dynamic-ai-agents-with-temporal), [Temporal A16Z Funding](https://temporal.io/blog/temporal-raises-usd300m-series-d-at-a-usd5b-valuation) + +**What it is:** The foundational durable execution platform. Separates Workflows (orchestration) from Activities (actual work like LLM calls). Agents survive crashes and resume exactly where they left off. + +**Architecture highlights:** +- **Workflow/Activity separation** — deterministic orchestration + non-deterministic LLM calls +- **Event History** — full record of past decisions for crash recovery +- **OpenAI Agents SDK integration** (public preview) — durable agents out of the box +- **PydanticAI integration** — durable Python agents +- **Handles 150k+ actions/second** — battle-tested at scale + +**Enterprise adoption:** OpenAI (Codex runs on Temporal), Replit, Lovable, ADP, Abridge, Washington Post, Block. + +**Key differentiator:** The gold standard for durable execution. If AI agents need to run for hours/days, survive crashes, and handle human-in-the-loop — Temporal is the infrastructure layer that makes it work. + +**Relevance for Electron integration:** +- TypeScript SDK available +- Requires a server component (can self-host or use cloud) +- Adds significant operational complexity +- Best for server-side orchestration, not embedded in Electron +- **Confidence: 9/10, Reliability: 10/10** + +--- + +### 3.2 Trigger.dev + +- **URL:** https://github.com/triggerdotdev/trigger.dev +- **Stars:** ~13.9k +- **License:** Apache 2.0 +- **Source:** [Trigger.dev site](https://trigger.dev/), [AI Agents docs](https://trigger.dev/product/ai-agents), [GitHub](https://github.com/triggerdotdev/trigger.dev) + +**What it is:** Platform for building and deploying fully-managed AI agents and workflows. Durable execution with checkpoint-resume (CRIU). + +**Architecture highlights:** +- **Orchestrator pattern** — breaks jobs into smaller tasks, assigns to specialists +- **Realtime streaming** — live status updates, LLM response streaming to frontend +- **Vercel AI SDK integration** — `ai.tool` creates tools from tasks +- **MCP Server** — interact with projects from Claude Code, Cursor, etc. +- **batch.triggerByTaskAndWait** — efficient parallel coordination +- **Elastic infrastructure** — auto-scaling, concurrency control + +**Key differentiator:** Durable execution + realtime streaming + MCP server. The MCP server integration means agents in our app could trigger/monitor Trigger.dev tasks. + +**Relevance for Electron integration:** +- TypeScript-native +- Server-side platform (not embeddable in Electron directly) +- Good as external orchestration backend +- MCP integration is a natural bridge +- **Confidence: 7/10, Reliability: 8/10** + +--- + +### 3.3 Hatchet + +- **URL:** https://github.com/hatchet-dev/hatchet +- **Stars:** ~4.5k+ +- **License:** MIT +- **SDKs:** Python, TypeScript, Golang +- **Source:** [Hatchet site](https://hatchet.run/), [Docs](https://docs.hatchet.run/v1), [GitHub](https://github.com/hatchet-dev/hatchet) + +**What it is:** Open-source platform for AI agent orchestration, background tasks, and mission-critical workflows. YC W24. + +**Architecture highlights:** +- General-purpose: queue + DAG orchestrator + durable execution engine +- **AI agent primitives** — retries, parallel tool calls, state management, guardrails +- **Fairness** — distributes requests fairly, prevents busy-user overwhelm +- **Concurrency control** — FIFO, LIFO, Round Robin, Priority Queues +- **Human-in-the-loop** — eventing for signaling and streaming +- Built on PostgreSQL — simple self-hosting +- Web UI for monitoring + +**Key differentiator:** Lower operational overhead than Temporal (just PostgreSQL), while providing similar durable execution guarantees. The fairness and concurrency controls are specifically designed for AI agent workloads. + +**Relevance for Electron integration:** +- TypeScript SDK available +- Simpler to self-host than Temporal +- Could be bundled with Electron app (just needs PostgreSQL) +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 3.4 Windmill + +- **URL:** https://github.com/windmill-labs/windmill +- **Stars:** ~13k+ +- **License:** AGPLv3 +- **Source:** [Windmill site](https://www.windmill.dev/), [AI Agents Blog](https://www.windmill.dev/blog/ai-agents) + +**What it is:** Open-source developer platform for building internal tools, workflows, and automations. Supports 20+ languages including TypeScript (Bun runtime). + +**Architecture highlights:** +- **AI Agent Steps** — any Windmill script becomes a tool the AI agent can invoke +- **Automatic tool definitions** — JSON schema from scripts becomes agent tool definitions +- **Multi-language tools** — Python, TypeScript, Go, Rust, PHP, Bash, SQL, etc. +- **MCP integration** — agents connect to external MCP servers +- **Visual DAG editor** + workflows-as-code (Python/TypeScript) +- **~50ms added latency** — very performant + +**Key differentiator:** Any script in any language automatically becomes an agent tool. The "scripts as tools" approach is uniquely pragmatic — no separate tool registration needed. + +**Relevance for Electron integration:** +- AGPLv3 license (restrictive for embedding) +- Docker-based deployment +- Better as external orchestration service +- **Confidence: 6/10, Reliability: 7/10** + +--- + +## 4. Visual & Low-Code Agent Builders + +### 4.1 Dify + +- **URL:** https://github.com/langgenius/dify +- **Stars:** ~129.8k (most-starred agent framework on GitHub) +- **License:** Apache 2.0 (core) +- **Source:** [Dify site](https://dify.ai/), [GitHub](https://github.com/langgenius/dify), [Medium](https://medium.com/@gptproto.official/dify-the-open-source-standard-for-ai-orchestration-777a7bae3bb4) + +**What it is:** Open-source LLM app development platform with visual workflow builder, RAG pipeline, agent capabilities, and model management. + +**Architecture highlights:** +- **Visual canvas** for building AI workflows +- **Hundreds of LLM integrations** — any OpenAI-compatible model +- **50+ built-in tools** for agents +- **MCP integration** — supports HTTP-based MCP services (protocol 2025-03-26) +- Can turn Dify workflows/agents into MCP servers +- **Backend-as-a-Service** — all features via REST API +- 180k+ developers, 59k+ end users + +**Key differentiator:** The most popular open-source agent platform by stars. Strong visual workflow editor. Can expose workflows as MCP servers — meaning our app could consume Dify workflows as tools. + +**Relevance for Electron integration:** +- Python/Docker backend — not embeddable in Electron +- REST API could be consumed from our Electron app +- MCP server mode is very interesting for integration +- **Confidence: 7/10, Reliability: 8/10** + +--- + +### 4.2 n8n + +- **URL:** https://github.com/n8n-io/n8n +- **Stars:** ~180.7k +- **License:** Fair-code (Sustainable Use License) +- **Source:** [n8n site](https://n8n.io/), [AI Agents](https://n8n.io/ai-agents/), [GitHub](https://github.com/n8n-io/n8n) + +**What it is:** Fair-code workflow automation platform with native AI capabilities. 400+ integrations, visual builder + code. + +**Architecture highlights:** +- **AI Agent node** — connects to LLMs, integrates with tools +- **MCP Server** — call n8n workflows from other AI systems +- **Human-in-the-loop** — approval at any workflow point +- **Multi-agent & RAG support** +- Full observability: inspect prompts, responses, execution flow + +**Limitations:** Lacks persistent memory, autonomous planning, and dynamic decision-making. Better for structured tasks than truly autonomous agents. + +**Relevance for Electron integration:** +- TypeScript-based (Node.js) +- Could theoretically be embedded, but it's a full platform +- Fair-code license may be restrictive +- Better as external orchestration service consumed via MCP +- **Confidence: 6/10, Reliability: 7/10** + +--- + +### 4.3 Rivet + +- **URL:** https://github.com/Ironclad/rivet +- **Stars:** ~3.9k +- **License:** Open source +- **Source:** [Rivet site](https://rivet.ironcladapp.com/), [GitHub](https://github.com/Ironclad/rivet) + +**What it is:** Visual AI programming environment for building AI agents with LLMs. By Ironclad. Desktop app + TypeScript runtime library. + +**Architecture highlights:** +- **Node-based visual editor** — drag-and-drop AI chains +- **Real-time debugging** — watch graph execute step-by-step, remote debugging +- **Graph nesting** — modular, reusable components +- **Graphs as YAML** — version control, code review +- **TypeScript runtime library** (`rivet-core`) — run graphs programmatically +- **`rivet serve`** — expose any graph as HTTP endpoint +- **Plugin ecosystem** — Anthropic, HuggingFace, MongoDB plugins + +**Key differentiator:** Desktop Electron app with visual AI chain builder + TypeScript runtime. The "graphs as YAML + TypeScript execution" approach is very relevant — could potentially embed Rivet's runtime in our app. + +**Relevance for Electron integration:** +- TypeScript runtime library for programmatic execution +- Already built as an Electron app — proven pattern +- YAML-based graph definitions could be stored/versioned +- Plugin architecture for extensibility +- **Confidence: 7/10, Reliability: 6/10** + +--- + +## 5. Protocol Standards & Ecosystem + +### 5.1 Protocol Landscape (2026) + +The AI agent ecosystem has converged on a layered protocol stack: + +| Protocol | Owner | Focus | Spec | +|----------|-------|-------|------| +| **MCP** (Model Context Protocol) | Anthropic / AAIF | Agent-to-Tool | Tool access, context | +| **A2A** (Agent-to-Agent) | Google / AAIF | Agent-to-Agent | Task delegation | +| **ACP** (Agent Communication Protocol) | IBM BeeAI / LF | Agent Communication | REST-based, merged into A2A Aug 2025 | +| **AG-UI** (Agent-to-User) | Community | Agent-to-User | Real-time interactivity | +| **AGNTCY** | Cisco / LF | Agent Infrastructure | Discovery, identity, security | + +**Sources:** [DEV.to MCP vs A2A](https://dev.to/pockit_tools/mcp-vs-a2a-the-complete-guide-to-ai-agent-protocols-in-2026-30li), [Agentic AI Foundation](https://intuitionlabs.ai/articles/agentic-ai-foundation-open-standards), [Pento MCP Review](https://www.pento.ai/blog/a-year-of-mcp-2025-review) + +**Key facts (March 2026):** +- MCP: 97M+ monthly SDK downloads (Python + TypeScript combined) +- AAIF (Agentic AI Foundation): Co-founded by OpenAI, Anthropic, Google, Microsoft, AWS, Block — hosts both MCP and A2A +- TypeScript MCP SDK: v1.27.1 (March 2026) +- A2A Agent Cards: `/.well-known/agent.json` for discovery +- Consensus architecture: MCP for tools, A2A for agents, AG-UI for humans + +**Key insight for our product:** "If your agents are all within the same organization, running in the same infrastructure — you don't need A2A. Use simpler orchestration. A2A's overhead isn't justified for single-org setups." ([Source](https://dev.to/pockit_tools/mcp-vs-a2a-the-complete-guide-to-ai-agent-protocols-in-2026-30li)) + +--- + +### 5.2 Semantic Router (Aurelio AI) + +- **URL:** https://github.com/aurelio-labs/semantic-router +- **License:** MIT +- **Language:** Python +- **Source:** [Aurelio AI](https://www.aurelio.ai/semantic-router), [GitHub](https://github.com/aurelio-labs/semantic-router) + +**What it is:** Superfast decision-making layer for LLMs and agents. Routes requests using semantic vector space instead of slow LLM calls. + +**Key capability:** Tool selection, guardrails, intent routing — all without LLM calls. Scales to thousands of tools. + +### 5.3 vLLM Semantic Router + +- **URL:** https://github.com/vllm-project/semantic-router +- **License:** Open source +- **Language:** Rust +- **Source:** [vLLM Blog](https://blog.vllm.ai/2026/01/05/vllm-sr-iris.html), [Red Hat](https://developers.redhat.com/articles/2025/09/11/vllm-semantic-router-improving-efficiency-ai-reasoning) + +**What it is:** System-level intelligent router for Mixture-of-Models. Routes queries to the best model based on complexity analysis. + +**v0.1 "Iris" release (January 2026):** Production-ready, 600+ PRs merged, 300+ issues, 50+ engineers. Supports OpenAI Responses API with conversation state for intelligent routing in multi-turn agent apps. + +**Key stats:** +10.2% accuracy on complex tasks, -47.1% latency, -48.5% token usage. + +--- + +## 6. Coding Agent Fleet Managers + +### 6.1 Angy + +- **URL:** Product Hunt (recent launch, ~1 week ago) +- **License:** Open source +- **Source:** [Product Hunt](https://www.producthunt.com/products/angy) + +**What it is:** Open-source fleet manager and IDE for Claude Code. Orchestrates a deterministic multi-phase pipeline (Plan -> Build -> Test) with adversarial verification. + +**Architecture:** +- **Adversarial Counterpart agent** that strictly verifies code +- **Git worktree isolation** for parallel agent execution +- **Scheduler** for running epics overnight +- **Multi-phase pipeline:** Architect -> Counterpart -> Build -> Test +- Self-bootstrapped after one day of initial work + +--- + +### 6.2 GitHub Agent HQ + +- **URL:** https://github.blog/news-insights/company-news/welcome-home-agents/ +- **Source:** [GitHub Blog](https://github.blog/news-insights/company-news/welcome-home-agents/), [Eficode](https://www.eficode.com/blog/why-github-agent-hq-matters-for-engineering-teams-in-2026) + +**What it is:** GitHub's platform for orchestrating AI agent fleets. Multi-agent support with Claude Code, Codex, and custom agents. + +**Architecture:** +- **Mission Control** — unified command center across GitHub, VS Code, mobile, CLI +- **Fleet of specialized agents** — security, testing, refactoring specialists +- **Multi-vendor:** Anthropic, OpenAI, Google, Cognition, xAI +- **Governance controls** — branch controls, identity, agent access policies +- **Squad** — coordinated AI teams inside repositories + +--- + +### 6.3 Hephaestus + +- **URL:** https://github.com/Ido-Levi/Hephaestus +- **License:** Open source (alpha) +- **Source:** [GitHub](https://github.com/Ido-Levi/Hephaestus), [HN](https://news.ycombinator.com/item?id=45796897) + +**What it is:** Semi-structured agentic framework where workflows build themselves as agents discover what needs to be done. + +**Architecture:** +- Define phase types (Analyze -> Implement -> Test), agents dynamically create tasks +- **Ticket-based coordination** — tickets flow through workflow carrying context +- **Guardian system** — LLM-powered coherence scoring for alignment checking +- **Parallel agents** in isolated Claude Code sessions +- Real-time observability + +**Key differentiator:** Emergent workflows — agents discover tasks rather than following predefined plans. Interesting alternative to rigid kanban task assignment. + +--- + +### 6.4 KAOS (Kubernetes Agent Orchestration System) + +- **URL:** https://github.com/axsaucedo/kaos +- **License:** Open source +- **Source:** [GitHub](https://github.com/axsaucedo/kaos), [HN](https://news.ycombinator.com/item?id=46688521) + +**What it is:** Kubernetes-native framework for deploying and orchestrating AI agents at scale. + +**Architecture:** +- **Golang control plane** — manages Agentic CRDs (Custom Resource Definitions) +- **Python data plane** — implements A2A, memory, tool/model management +- **React UI** — CRUD + debugging +- **PAIS** — enterprise wrapper for Pydantic AI with OpenAI-compatible HTTP API +- **A2A discovery** built in +- **OpenTelemetry** instrumentation + +**Key differentiator:** Kubernetes-native multi-agent system for hundreds/thousands of services. Production infrastructure approach. + +--- + +## 7. Python-First Frameworks (with TS relevance) + +### 7.1 BeeAI Framework (IBM) + +- **URL:** https://github.com/i-am-bee/beeai-framework +- **Stars:** 3k+ +- **License:** Open source (Linux Foundation governance) +- **Source:** [IBM Think](https://www.ibm.com/think/news/beeai-open-source-multiagent), [BeeAI Docs](https://framework.beeai.dev/) + +**What it is:** IBM's open-source framework for production-grade multi-agent systems. **Dual language: Python AND TypeScript with complete feature parity.** + +**Architecture:** +- 10+ LLM providers including Ollama, OpenAI, Watsonx.ai +- **MCP tool integration** +- **A2A protocol support** (ACP merged into A2A) +- **Agent Stack** — framework-agnostic deployment (BeeAI, LangGraph, CrewAI, custom) +- Built-in constraint enforcement and rule-based governance +- Each agent runs in its own container with resource limits +- OpenTelemetry observability + +**Key differentiator:** TypeScript with feature parity is rare among IBM projects. Linux Foundation governance ensures long-term stability. The Agent Stack deploy layer is uniquely framework-agnostic. + +**Relevance for Electron integration:** +- TypeScript SDK with full feature parity +- Framework-agnostic Agent Stack could deploy any agent +- MCP + A2A support aligns with protocol trends +- **Confidence: 7/10, Reliability: 7/10** + +--- + +### 7.2 Letta (formerly MemGPT) + +- **URL:** https://github.com/letta-ai/letta +- **Stars:** 16.2k+ +- **License:** Open source +- **Source:** [Letta site](https://www.letta.com/), [GitHub](https://github.com/letta-ai/letta) + +**What it is:** Platform for stateful agents with advanced memory that learn and self-improve over time. + +**Architecture:** +- **Self-editing memory** — agents manage their own memory blocks +- **Sleep-time compute** — agents "think" during downtime, rewrite memory +- **Skill learning** — agents learn new skills from experience +- **Letta Code** — #1 model-agnostic open source agent on Terminal-Bench +- **REST API + TypeScript SDK** +- Model-agnostic: OpenAI, Anthropic, local models + +**Key differentiator:** Memory-first architecture is unique. Sleep-time compute and skill learning are research-frontier features. TypeScript SDK available. + +**Relevance for Electron integration:** +- TypeScript SDK for client-side integration +- REST API for server-side +- Memory architecture could inform our agent context management +- **Confidence: 7/10, Reliability: 6/10** + +--- + +### 7.3 CAMEL-AI + +- **URL:** https://github.com/camel-ai/camel +- **Stars:** Growing (active research community) +- **License:** Apache 2.0 (code), CC BY NC 4.0 (datasets) +- **Source:** [CAMEL-AI site](https://www.camel-ai.org/), [GitHub](https://github.com/camel-ai/camel) + +**What it is:** The first open-source multi-agent framework, focused on dialog-driven collaboration and scaling laws of agents. + +**Architecture:** +- **Role-based agents** — structured conversations between assigned roles +- **OWL** — Optimized Workforce Learning, #1 on GAIA benchmark (69.09%) +- **OASIS** — simulations with 1M agents +- **MCPify** — project for MCP integration +- Accepted at NeurIPS 2025 + +**Key differentiator:** Research-first approach focused on scaling laws of multi-agent systems. OWL's GAIA benchmark performance is state-of-the-art. Python only. + +--- + +### 7.4 Julep AI + +- **URL:** https://github.com/julep-ai/julep +- **License:** Open source +- **Source:** [Julep site](https://julep.ai/), [GitHub](https://github.com/julep-ai/julep), [Temporal Blog](https://temporal.io/blog/julep-ai-future-ai-workflows) + +**What it is:** "Firebase for AI agents" — serverless platform for multi-step AI workflows. Persistent memory, modular workflows (YAML or code), built-in retries. + +**Status:** Hosted backend shut down December 31, 2025. Open-source self-hosting available. Team pivoted to **memory.store**. + +**Note:** Python and Node.js SDKs available, but future unclear given the pivot. + +--- + +### 7.5 ChatDev 2.0 + +- **URL:** https://github.com/OpenBMB/ChatDev +- **License:** Apache 2.0 +- **Source:** [GitHub](https://github.com/OpenBMB/ChatDev), [IBM](https://www.ibm.com/think/topics/chatdev) + +**What it is:** Zero-code multi-agent orchestration platform simulating a virtual software company. ChatDev 2.0 (January 2026) transforms rigid structures into flexible workflow systems. + +**Architecture:** +- **Visual canvas (Workflow)** — drag-and-drop multi-agent system design +- **Python SDK** (PyPI: chatdev) — run YAML workflows in Python +- **MacNet** — multi-agent collaboration networks for complex topologies +- **Puppeteer** — dynamic orchestration with RL-optimized agent sequencing +- FastAPI backend + Vue 3 frontend + +**Key differentiator:** NeurIPS 2025 accepted research, zero-code visual approach, software company simulation metaphor. Python + Vue only. + +--- + +### 7.6 Haystack (deepset) + +- **URL:** https://github.com/deepset-ai/haystack +- **Stars:** High (enterprise adoption: Airbus, NVIDIA, Comcast) +- **License:** Apache 2.0 +- **Source:** [Haystack site](https://haystack.deepset.ai/), [Haystack Docs](https://docs.haystack.deepset.ai/docs/agents) + +**What it is:** Open-source AI orchestration framework for production-ready LLM applications. Modular pipelines + agent workflows. + +**Architecture:** +- **Context engineering** — explicit control over retrieval, ranking, filtering, routing +- **Universal Agent** component with Chat Generator + tools +- **ComponentTool** — wrap any Haystack component as a callable tool +- **@tool decorator** — create tools from Python functions +- **Hayhooks** — expose pipelines/agents via HTTP or MCP +- **AgentSnapshot** — stepwise debugging with breakpoints +- Model-agnostic: OpenAI, Anthropic, Cohere, HuggingFace, Azure, Bedrock +- Latest: v2.25 (March 2026) + +**Key differentiator:** Enterprise-grade, context-engineering focused. The MCP exposure via Hayhooks means our app could consume Haystack agents as tools. + +--- + +### 7.7 ControlFlow (Prefect) -> Marvin + +- **URL:** https://github.com/PrefectHQ/ControlFlow (archived) +- **License:** Apache 2.0 +- **Source:** [Prefect Blog](https://www.prefect.io/blog/controlflow-intro) + +**What it is:** Task-centric AI workflow framework built on Prefect 3.0. **Archived** — merged into Marvin framework. + +**Key ideas (preserved in Marvin):** +- Tasks, Agents, Flows as core abstractions +- "AI agents are most effective when applied to small, well-defined tasks" +- Multi-agent collaboration strategies: Round-robin, Random, Moderated +- Every flow is a Prefect flow — full orchestration + observability + +--- + +## 8. Summary Matrix + +| Tool | Language | Stars | License | MCP | A2A | Multi-Agent | Electron-Ready | Maturity | +|------|----------|-------|---------|-----|-----|-------------|----------------|----------| +| **Mastra** | TypeScript | 22.3k | Apache 2.0 | Yes | -- | Yes | **Native** | Production | +| **Inngest AgentKit** | TypeScript | 793 | Apache 2.0 | Yes | -- | Yes (Networks) | **Native** | Beta | +| **VoltAgent** | TypeScript | 5.1k | MIT | Yes | -- | Yes (Chain API) | **Native** | Early | +| **HazelJS** | TypeScript | Small | Apache 2.0 | -- | -- | Yes (AgentGraph) | **Native** | Alpha | +| **Agentica** | TypeScript | Small | MIT | Yes | -- | No | **Native** | Beta | +| **Strands (AWS)** | Python+TS | 14M DL | Apache 2.0 | Yes | -- | Yes (Swarm) | TS SDK | Preview | +| **OpenAI Agents SDK** | TypeScript | 2.1k | MIT | Yes | -- | Yes (Handoffs) | **Native** | GA | +| **Google ADK TS** | TypeScript | 581 | Apache 2.0 | Yes | Yes | Yes | **Native** | Early | +| **BeeAI** | Python+TS | 3k | Open (LF) | Yes | Yes | Yes | TS SDK | Production | +| **AgentGateway** | Rust | 2k | Open (LF) | Yes | Yes | -- (infra) | Sidecar | v1.0 | +| **Temporal** | Multi | 13k | MIT | -- | -- | -- (infra) | TS SDK | Production | +| **Trigger.dev** | TypeScript | 13.9k | Apache 2.0 | Yes | -- | Yes | Server-side | v4 | +| **Hatchet** | Multi | 4.5k | MIT | -- | -- | -- (infra) | TS SDK | Production | +| **Dify** | Python | 129.8k | Apache 2.0 | Yes | -- | Yes | REST API | Production | +| **n8n** | TypeScript | 180.7k | Fair-code | Yes | -- | Yes (basic) | Heavy | Production | +| **Rivet** | TypeScript | 3.9k | Open | -- | -- | -- | **Electron app** | v4.1 | +| **Letta** | Python+TS | 16.2k | Open | -- | -- | -- | TS SDK | Production | +| **CAMEL-AI** | Python | Growing | Apache 2.0 | -- | -- | Yes | -- | Research | +| **ChatDev 2.0** | Python | Growing | Apache 2.0 | -- | -- | Yes | -- | v2.0 | +| **Haystack** | Python | High | Apache 2.0 | Yes | -- | Yes | REST/MCP | v2.25 | + +--- + +## 9. Recommendations for Claude Agent Teams UI + +### Tier 1: Most Relevant for Integration (TypeScript-native, embeddable) + +1. **Mastra** — The most mature TS agent framework. Could serve as orchestration backend for agent workflows, multi-model routing, and memory management. Proven at scale (Replit, PayPal). + +2. **Inngest AgentKit** — Lightweight multi-agent networks with durable execution. The Agent -> Network -> Router -> State model maps well to our team/agent/task architecture. + +3. **OpenAI Agents SDK (TS)** — If we want to support OpenAI models natively. Handoff mechanism is clean for agent-to-agent delegation. + +4. **VoltAgent** — Observability-first approach complements our session analysis. Chain API for multi-agent workflows is well-designed. + +### Tier 2: Protocol & Infrastructure Integration + +5. **AgentGateway** — Could be bundled as a sidecar process. Handles MCP/A2A protocol routing, OpenAPI-to-MCP translation, multi-tenancy. + +6. **MCP Gateway Registry** — Solves MCP server governance for enterprise deployments. + +7. **Rivet** — TypeScript runtime library for visual AI chain execution. Already an Electron app. + +### Tier 3: External Services (consume via API/MCP) + +8. **Dify** — Expose visual workflows as MCP servers that our app consumes. +9. **Trigger.dev** — Durable execution backend via MCP server integration. +10. **Hatchet** — Lightweight durable execution (just PostgreSQL). + +### Key Architectural Insight + +The emerging pattern for 2026 is a **layered architecture**: +- **Protocol layer:** MCP (tools) + A2A (agents) + AG-UI (humans) +- **Execution layer:** Durable workflows (Temporal/Hatchet/Inngest) +- **Agent layer:** Framework-specific (Mastra/AgentKit/custom) +- **Orchestration layer:** Fleet management (our kanban board / Agent HQ / Hephaestus) +- **Gateway layer:** AgentGateway for routing, security, observability + +Our product (Claude Agent Teams UI) sits at the **orchestration layer** — the kanban-based fleet management interface. The key opportunity is to become framework-agnostic by integrating with the protocol layer (MCP/A2A) and supporting multiple agent frameworks underneath. + +### Unique Competitive Advantages We Have + +Based on this research, no tool combines ALL of: +1. Kanban-based task management (visual orchestration) +2. Multi-agent team coordination with real-time communication +3. Code review (diff view) per task +4. Deep session analysis (bash commands, reasoning, tokens) +5. Desktop-native (Electron) with zero-setup + +The closest competitors are GitHub Agent HQ (platform-level, not desktop) and Angy (fleet manager, but IDE-focused not kanban). Our kanban + code review + session analysis combination remains unique. diff --git a/docs/research/ai-orchestration-tools.md b/docs/research/ai-orchestration-tools.md new file mode 100644 index 00000000..9928836b --- /dev/null +++ b/docs/research/ai-orchestration-tools.md @@ -0,0 +1,550 @@ +# AI Agent Orchestration Tools & Frameworks (March 2026) + +> Research date: 2026-03-24 +> Focus: Multi-provider AI coding agent orchestration — tools that coordinate Claude Code, Codex CLI, Gemini CLI, and other AI agents together. + +## Executive Summary + +The multi-agent AI orchestration market has exploded in 2025-2026. Gartner reports a **1,445% surge** in multi-agent system inquiries from Q1 2024 to Q2 2025. The AI agent market reached **$7.84B in 2025**, projected to hit **$52.62B by 2030** (CAGR 46.3%). + +The landscape splits into three distinct categories: +1. **Desktop orchestrators** — Electron/Tauri apps managing parallel coding agents with kanban boards, diff viewers, git worktree isolation +2. **CLI/framework orchestrators** — Command-line tools and Python/TypeScript frameworks for multi-agent coordination +3. **General-purpose multi-agent frameworks** — Provider-agnostic frameworks for building any multi-agent system (not coding-specific) + +**Key finding for our project:** Multiple direct competitors have emerged with kanban boards + multi-agent orchestration (Vibe Kanban, Dorothy, Mozzie). However, none combine all of: multi-provider agent support + kanban + code review + team communication + Electron desktop app in the way Claude Agent Teams UI does. + +--- + +## Category 1: Desktop Orchestrators (Most Relevant to Our Project) + +### 1.1 Vibe Kanban (BloopAI) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/BloopAI/vibe-kanban](https://github.com/BloopAI/vibe-kanban) | +| **Stars** | ~23,700 | +| **License** | Open source (free) | +| **Tech Stack** | Rust (backend) + TypeScript/React (frontend) | +| **AI Providers** | Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, Qwen Code (10+) | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**Architecture:** Cross-platform orchestration platform (CLI + web UI) with kanban board. Each agent gets its own git worktree and branch. Implements MCP both as client and server — the kanban board itself becomes an API for AI agents. + +**Key features:** +- Kanban board with drag-and-drop task management +- Parallel agent execution in isolated workspaces +- Built-in diff review with inline comments +- Built-in browser preview with devtools +- MCP server — other agents can create tasks, move cards, read board status +- PR creation and merge from UI +- Install via `npx vibe-kanban` + +**Relevance to us:** **DIRECT COMPETITOR.** Has kanban + multi-agent + diff review. Key differences: no team communication/messaging between agents, no session analysis, no context monitoring. Uses Rust backend (not Electron). + +--- + +### 1.2 Dorothy + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/Charlie85270/Dorothy](https://github.com/Charlie85270/Dorothy) | +| **Website** | [dorothyai.app](https://dorothyai.app/) | +| **License** | Open source | +| **Tech Stack** | Electron + React/Next.js | +| **AI Providers** | Claude Code, Codex, Gemini CLI | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Electron desktop app with isolated PTY terminal sessions per agent. Features a "Super Agent" orchestrator that programmatically controls all other agents via MCP tools. + +**Key features:** +- Kanban board with drag-and-drop, agents auto-pick work by skill +- 5 MCP servers (40+ tools) for programmatic agent control +- Super Agent meta-orchestrator that delegates across agent pool +- GitHub, JIRA, Telegram, Slack integrations +- Google Workspace integration (Gmail, Drive, Sheets, Calendar) +- Community skill plugins from skills.sh +- 3D animated agent visualization +- Agent automations (trigger on GitHub PRs, issues, events) +- Scheduling and recurring agent tasks + +**Relevance to us:** **DIRECT COMPETITOR.** Electron + kanban + multi-agent + MCP. Most similar to our architecture. Lacks: team-level communication, deep session analysis, context token tracking, structured code review workflow. + +--- + +### 1.3 Superset + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/superset-sh/superset](https://github.com/superset-sh/superset) | +| **Website** | [superset.sh](https://superset.sh/) | +| **Stars** | ~7,800 | +| **License** | Elastic License 2.0 (ELv2) — NOT MIT/Apache | +| **Tech Stack** | Electron + React + xterm.js + TailwindCSS v4, Bun + Turborepo | +| **AI Providers** | Claude Code, Codex, OpenCode, Cursor Agent — any CLI agent | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Electron desktop terminal environment. Each task gets its own git worktree. Built-in diff viewer and editor. Same terminal stack as VS Code (xterm.js). + +**Key features:** +- Run 10+ agents simultaneously +- Git worktree isolation per task +- Built-in diff viewer +- Workspace presets (automate env setup, deps) +- One-click open in external IDE +- Agent status monitoring and notifications + +**Relevance to us:** Competitor in the parallel-agent-desktop space. Less feature-rich (no kanban, no team messaging, no code review workflow). More of a "terminal multiplexer for agents" than a full management platform. + +--- + +### 1.4 Mozzie + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/usemozzie/mozzie](https://github.com/usemozzie/mozzie) | +| **License** | Open source | +| **Tech Stack** | Tauri (Rust) + Node + pnpm | +| **AI Providers** | Claude Code, Gemini CLI, Codex CLI, custom scripts | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Tauri desktop app with LLM orchestrator. Agents communicate via ACP (Agent Communication Protocol) over stdio. Persistent orchestrator conversation history. + +**Key features:** +- LLM orchestrator that creates work items, sets dependencies, assigns agents +- Git worktree isolation per work item +- Dependency graph with cycle detection +- Sub-work-items with stacked branches +- Review workflow (approve to push, reject with feedback) +- Live streaming of agent output with tool-call visualization +- Agents learn from rejection history + +**Relevance to us:** Competitor. Tauri-based (lighter than Electron). Has dependency management and review workflow. No kanban board per se, more of a work-item queue. + +--- + +### 1.5 Parallel Code + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/johannesjo/parallel-code](https://github.com/johannesjo/parallel-code) | +| **License** | MIT | +| **AI Providers** | Claude Code, Codex CLI, Gemini CLI | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Desktop app with automatic git worktree creation per task. Keyboard-first design. + +**Key features:** +- Automatic branch + worktree per task +- 5+ agents in parallel, zero conflicts +- Unified session view +- Built-in diff viewer with one-click merge +- Mobile monitoring via QR code (Wi-Fi/Tailscale) +- Keyboard-first, mouse optional + +**Relevance to us:** Simpler competitor focused on parallel execution + diff review. No kanban, no team communication. + +--- + +## Category 2: CLI/Framework Orchestrators for Coding Agents + +### 2.1 MCO (Multi-CLI Orchestrator) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/mco-org/mco](https://github.com/mco-org/mco) | +| **License** | Open source | +| **Language** | TypeScript/Node | +| **AI Providers** | Claude Code, Codex CLI, Gemini CLI, OpenCode, Qwen Code | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**Architecture:** Neutral orchestration layer. Dispatches prompts to multiple agent CLIs in parallel, aggregates results, returns structured output (JSON, SARIF, PR-ready Markdown). No vendor lock-in. + +**Key concept:** "Work like a Tech Lead" — assign one task to multiple agents, run in parallel, compare outcomes. Designed to be called by any IDE or agent (Cursor, Trae, Copilot, Windsurf). + +**Integration potential:** Could be used as a backend dispatch layer. MCO handles the multi-agent fan-out; our UI handles the visualization and management. + +--- + +### 2.2 Agent Orchestrator (ComposioHQ) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/ComposioHQ/agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator) | +| **Stars** | ~4,500 | +| **License** | MIT | +| **Language** | TypeScript | +| **AI Providers** | Claude Code, Codex, Aider (agent-agnostic plugin system) | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Plugin-based orchestrator managing fleets of coding agents. 8 pluggable abstraction slots: agent, runtime, tracker, reviewer, etc. Each agent gets own git worktree, branch, and PR. + +**Key features:** +- Agent-agnostic (Claude Code, Codex, Aider) +- Runtime-agnostic (tmux, Docker) +- Tracker-agnostic (GitHub, Linear) +- Auto-fix CI failures and address review comments +- Centralized dashboard for monitoring +- 100% AI co-authored codebase (impressive dogfooding) +- 30 concurrent agents at peak + +**Impressive stat:** 8 days from first commit to 43K lines of TypeScript, 91 commits, 61 PRs merged, 84% of PRs created by AI agent sessions. + +--- + +### 2.3 AWS CLI Agent Orchestrator (CAO) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/awslabs/cli-agent-orchestrator](https://github.com/awslabs/cli-agent-orchestrator) | +| **License** | Open source | +| **Language** | Python | +| **AI Providers** | Amazon Q CLI, Claude Code (Codex CLI, Gemini CLI, Qwen CLI planned) | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Hierarchical multi-agent system with Supervisor Agent coordinating Worker Agents. Each agent in isolated tmux session. Communication via MCP servers. Local HTTP server processes orchestration requests. + +**Orchestration patterns:** +- Handoff (synchronous task transfer) +- Assign (async parallel execution) +- Send Message (direct agent communication) +- Flow — scheduled cron-like runs + +**Caveat:** Supervisor runs on Amazon Bedrock — requires AWS credentials and account. Open source code but can't run without AWS infrastructure. + +--- + +### 2.4 MetaSwarm + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/dsifry/metaswarm](https://github.com/dsifry/metaswarm) | +| **License** | Open source | +| **Language** | TypeScript/Node | +| **AI Providers** | Claude Code, Gemini CLI, Codex CLI | +| **Reliability** | 7/10 | +| **Confidence** | 7/10 | + +**Architecture:** Self-improving multi-agent orchestration with 18 specialized agent personas, 13 skills, 15 commands. 9-phase workflow from issue to merged PR. + +**Key features:** +- Recursive orchestration (swarm of swarms) +- Cross-model review (writer reviewed by different AI model) +- Per-task and per-session USD budget circuit breakers +- TDD enforcement, quality gates +- Git worktree isolation with sandbox protection +- Auto-detects Team Mode when multiple sessions active +- Install via `npx metaswarm init` + +--- + +### 2.5 Overstory + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/jayminwest/overstory](https://github.com/jayminwest/overstory) | +| **License** | Open source | +| **Language** | TypeScript (Bun) | +| **AI Providers** | Claude Code, Pi, Gemini CLI, Aider, Goose, Amp (11 runtime adapters) | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Pluggable `AgentRuntime` interface. Tmux isolation per agent in git worktrees. SQLite WAL-mode mail system for inter-agent messaging (~1-5ms per query). Two-layer instruction system (Base + per-task Overlay). + +**Key features:** +- 11 runtime adapters +- FIFO merge queue with 4-tier conflict resolution +- Tiered watchdog system (mechanical daemon + AI triage + monitor agent) +- Instruction overlays for orchestrated workers +- Honest self-critique in project docs (refreshing transparency) + +--- + +### 2.6 Claude Octopus + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/nyldn/claude-octopus](https://github.com/nyldn/claude-octopus) | +| **License** | Open source | +| **AI Providers** | Codex, Gemini, Claude, Perplexity, OpenRouter, Copilot, Qwen, Ollama (8 providers) | +| **Reliability** | 6/10 | +| **Confidence** | 7/10 | + +**Architecture:** Multi-LLM orchestration plugin for Claude Code. 75% consensus gate catches disagreements before production. 32 specialized personas, 47 commands, 50 skills. Zero providers required to start — add them one at a time. + +--- + +### 2.7 agtx + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/fynnfluegge/agtx](https://github.com/fynnfluegge/agtx) | +| **License** | Open source | +| **AI Providers** | Claude Code, Codex, Gemini CLI, OpenCode, Cursor | +| **Reliability** | 6/10 | +| **Confidence** | 6/10 | + +**Architecture:** Multi-session AI coding terminal manager. Orchestrator agent picks up tasks, plans, and delegates to multiple coding agents running in parallel. + +--- + +## Category 3: General-Purpose Multi-Agent Frameworks + +### 3.1 CrewAI + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/crewAIInc/crewAI](https://github.com/crewAIInc/crewAI) | +| **Stars** | ~45,900 | +| **License** | MIT | +| **Language** | Python | +| **AI Providers** | OpenAI, Anthropic, Gemini, Ollama, any via LiteLLM | +| **Maturity** | Production-ready, 100K+ certified developers | +| **Reliability** | 9/10 | +| **Confidence** | 9/10 | + +**Architecture:** Role-based metaphor (role, goal, backstory per agent). Three process types: sequential, hierarchical, consensual. Native MCP and A2A support. Two approaches: Crews (autonomy) and Flows (enterprise production). + +**Electron integration potential:** Python-based, so would need a subprocess/API bridge. Not designed for desktop UI integration but could serve as an orchestration backend. + +--- + +### 3.2 Microsoft Agent Framework (AutoGen + Semantic Kernel) + +| Attribute | Details | +|-----------|---------| +| **URL** | [learn.microsoft.com/en-us/agent-framework](https://learn.microsoft.com/en-us/agent-framework/overview/) | +| **Stars** | AutoGen: ~52,000 | +| **License** | Open source (MIT) | +| **Language** | Python, .NET | +| **AI Providers** | OpenAI, Azure OpenAI, Anthropic, Gemini, local models | +| **Maturity** | GA targeted end Q1 2026 | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Architecture:** Unified SDK + runtime merging AutoGen + Semantic Kernel. Orchestration patterns: sequential, concurrent, group chat, handoff, Magentic (dynamic task ledger). Event-driven core, async-first. + +**Electron integration potential:** Primarily Python/.NET. Could use as a backend runtime via API. + +--- + +### 3.3 Agno + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/agno-agi/agno](https://github.com/agno-agi/agno) | +| **Stars** | ~38,900 | +| **License** | Apache-2.0 | +| **Language** | Python | +| **AI Providers** | OpenAI, Anthropic, Groq, and many more | +| **Maturity** | Production-ready (AgentOS + FastAPI runtime) | +| **Reliability** | 8/10 | +| **Confidence** | 8/10 | + +**Architecture:** Three-layer design: framework (agents, teams, workflows), runtime (stateless FastAPI backends), monitoring. Claims 529x faster instantiation than LangGraph. Teams with automatic agent-to-agent communication, context passing, result aggregation. + +**Electron integration potential:** FastAPI backend makes it easy to integrate via HTTP API. + +--- + +### 3.4 OpenAI Agents SDK (successor to Swarm) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/openai/openai-agents-python](https://github.com/openai/openai-agents-python) | +| **License** | MIT | +| **Language** | Python | +| **AI Providers** | OpenAI + 100+ LLMs via provider-agnostic design | +| **Maturity** | Production-ready (launched March 2025) | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**Architecture:** Core primitives: Agents, Handoffs, Guardrails, Function tools, MCP server tool calling, Sessions, Tracing. Handoff pattern: agents transfer control explicitly, carrying conversation context. Built-in MCP integration. + +--- + +### 3.5 LangGraph (by LangChain) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/langchain-ai/langgraph](https://github.com/langchain-ai/langgraph) | +| **License** | MIT | +| **Language** | Python, TypeScript | +| **AI Providers** | Model-agnostic (plug different LLMs into different nodes) | +| **Maturity** | Production-ready, LangSmith observability | +| **Reliability** | 8/10 | +| **Confidence** | 9/10 | + +**Architecture:** Graph-based design. Each agent is a node maintaining its own state. Conditional edges, multi-team coordination, hierarchical control. Supervisor nodes for scalable orchestration. + +--- + +### 3.6 AWS Agent Squad (formerly Multi-Agent Orchestrator) + +| Attribute | Details | +|-----------|---------| +| **URL** | [github.com/awslabs/agent-squad](https://github.com/awslabs/agent-squad) | +| **License** | Open source | +| **Language** | Python, TypeScript (dual) | +| **AI Providers** | AWS Bedrock, extensible | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Intelligent intent classification routes queries dynamically. Streaming + non-streaming support. Context management across agents. Universal deployment (Lambda to any cloud). + +--- + +### 3.7 Google ADK (Agent Development Kit) + +| Attribute | Details | +|-----------|---------| +| **URL** | [cloud.google.com](https://cloud.google.com/blog/products/ai-machine-learning/unlock-ai-agent-collaboration-convert-adk-agents-for-a2a) | +| **License** | Open source | +| **Language** | Python | +| **AI Providers** | Gemini (primary), extensible | +| **Reliability** | 7/10 | +| **Confidence** | 8/10 | + +**Architecture:** Hierarchical agent tree. Native A2A protocol support — agents from different frameworks can discover and invoke each other. + +--- + +### 3.8 OpenAI Symphony (New — March 2026) + +| Attribute | Details | +|-----------|---------| +| **URL** | See [Medium article](https://medium.com/@georgethomasm_89397/openai-symphony-the-new-orchestration-framework-for-multi-agent-systems-2ec991ee74cc) | +| **License** | Open source | +| **Language** | Python | +| **Maturity** | Very early (released March 5, 2026) | +| **Reliability** | 4/10 | +| **Confidence** | 5/10 | + +**Architecture:** Hierarchical delegation, iterative refinement, composable workflows. Checkpoint-based recovery — if agent fails mid-execution, workflow resumes from last checkpoint. Documentation sparse, community small, but growing. + +--- + +## Key Protocols & Standards + +### Google A2A (Agent-to-Agent Protocol) + +| Attribute | Details | +|-----------|---------| +| **URL** | [a2a-protocol.org](https://a2a-protocol.org/latest/) | +| **GitHub** | [github.com/a2aproject/A2A](https://github.com/a2aproject/A2A) | +| **Status** | v0.3 (July 2025), donated to Linux Foundation | +| **Supporters** | 150+ organizations (Google, Atlassian, Salesforce, SAP, etc.) | +| **Confidence** | 9/10 | + +**Purpose:** Agent-to-agent communication standard. Complementary to MCP (agent-to-tool). Agent Cards (JSON) for capability discovery. HTTP + gRPC transport. Becoming the de facto interop standard. + +### Anthropic MCP (Model Context Protocol) + +Already integrated into our project. MCP = agent-to-tool communication. A2A = agent-to-agent communication. The two are complementary. + +--- + +## Comparison Matrix: Desktop Orchestrators + +| Feature | **Our App** | **Vibe Kanban** | **Dorothy** | **Superset** | **Mozzie** | +|---------|------------|-----------------|-------------|--------------|------------| +| **Kanban board** | Yes | Yes | Yes | No | No | +| **Multi-provider agents** | Claude only* | 10+ agents | 3 agents | Any CLI | 3+ agents | +| **Code review / diff** | Yes | Yes | No | Yes | Yes | +| **Team communication** | Yes | No | Via Super Agent | No | No | +| **Session analysis** | Yes (deep) | No | No | No | No | +| **Context monitoring** | Yes | No | No | No | No | +| **MCP integration** | Yes | Yes (client+server) | Yes (5 servers) | No | ACP | +| **Agent-to-agent messaging** | Yes | Via MCP | Via Super Agent | No | Via ACP | +| **Dependency graph** | No | No | No | No | Yes | +| **External integrations** | No | GitHub | GitHub, JIRA, Slack, Telegram | IDE integration | No | +| **Tech stack** | Electron/React | Rust/React | Electron/React | Electron/React | Tauri | +| **License** | MIT | Free/OSS | OSS | ELv2 | OSS | +| **GitHub stars** | ~small | ~23,700 | Unknown | ~7,800 | Unknown | + +*Currently Claude-only, but the architecture could support multi-provider agents. + +--- + +## Strategic Recommendations + +### Immediate Opportunities + +1. **Multi-provider support is the #1 gap.** Every competitor now supports Claude + Codex + Gemini. Our single-provider approach is a significant limitation. Priority: HIGH. + +2. **MCP server exposure.** Dorothy and Vibe Kanban expose their kanban board as an MCP server — agents can programmatically create tasks, move cards, check status. This is a powerful pattern we should adopt. + +3. **A2A protocol awareness.** The A2A standard (150+ orgs, Linux Foundation) is becoming the agent-to-agent interop standard. We should monitor and potentially implement it. + +### Integration Paths for Multi-Provider Support + +| Approach | Description | Effort | Reliability | +|----------|-------------|--------|-------------| +| **Direct CLI integration** | Spawn Codex CLI / Gemini CLI alongside Claude Code in separate processes | Medium | 8/10 | +| **MCO as dispatch layer** | Use MCO to fan out tasks across multiple agent CLIs | Low | 7/10 | +| **Plugin architecture** | Build pluggable AgentRuntime interface (like Overstory) | High | 9/10 | +| **A2A protocol** | Implement A2A for cross-agent communication | High | 7/10 | + +### Unique Differentiators We Should Protect + +1. **Deep session analysis** (bash commands, reasoning, subprocesses) — nobody else has this +2. **Context monitoring** (token usage by category) — unique feature +3. **Team communication model** (lead + teammates with direct messaging) — only Dorothy's Super Agent comes close +4. **Post-compact context recovery** — unique +5. **Code review workflow** (accept/reject/comment per task) — Vibe Kanban is closest competitor here + +### Tools Worth Investigating Further + +1. **Vibe Kanban** — most direct competitor, 23.7K stars, Rust backend, mature feature set +2. **Dorothy** — Electron architecture closest to ours, MCP-heavy, good integration model +3. **Agent Orchestrator (ComposioHQ)** — plugin architecture is excellent, could inspire our multi-provider design +4. **MCO** — lightweight dispatch layer we could integrate as-is +5. **Overstory** — SQLite mail system for inter-agent messaging is elegant + +--- + +## Curated Resource Lists + +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) — Comprehensive list of orchestration tools +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) — 80+ CLI coding agents + orchestration harnesses +- [awesome-ai-agents-2026](https://github.com/caramaschiHG/awesome-ai-agents-2026) — 300+ resources across 20+ categories + +--- + +## Sources + +- [Top 5 Open-Source Agentic AI Frameworks in 2026](https://aimultiple.com/agentic-frameworks) +- [Top 9 AI Agent Frameworks — Shakudo](https://www.shakudo.io/blog/top-9-ai-agent-frameworks) +- [Best Open Source Frameworks for AI Agents — Firecrawl](https://www.firecrawl.dev/blog/best-open-source-agent-frameworks) +- [Microsoft Agent Framework Announcement](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/) +- [OpenAI Symphony — Medium](https://medium.com/@georgethomasm_89397/openai-symphony-the-new-orchestration-framework-for-multi-agent-systems-2ec991ee74cc) +- [CrewAI Open Source](https://crewai.com/open-source) +- [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) +- [AWS CLI Agent Orchestrator](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) +- [Google A2A Protocol](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) +- [A2A Protocol v0.3 Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade) +- [Warp Oz Platform](https://www.warp.dev/blog/oz-orchestration-platform-cloud-agents) +- [Vibe Kanban](https://vibekanban.com/) +- [Dorothy AI](https://dorothyai.app/) +- [Superset IDE](https://superset.sh/) +- [MCO — mco-org/mco](https://github.com/mco-org/mco) +- [Agent Orchestrator — ComposioHQ](https://github.com/ComposioHQ/agent-orchestrator) +- [MetaSwarm](https://github.com/dsifry/metaswarm) +- [Overstory](https://github.com/jayminwest/overstory) +- [Claude Octopus](https://github.com/nyldn/claude-octopus) +- [Mozzie](https://github.com/usemozzie/mozzie) +- [Parallel Code](https://github.com/johannesjo/parallel-code) +- [Orchestral AI Paper](https://arxiv.org/abs/2601.02577) +- [LLM Orchestration 2026 — AIMultiple](https://aimultiple.com/llm-orchestration) +- [Multi-Agent Frameworks 2026 — GuruSup](https://gurusup.com/blog/best-multi-agent-frameworks-2026) +- [Agno Framework](https://github.com/agno-agi/agno) +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) diff --git a/docs/research/best-abstraction-for-electron.md b/docs/research/best-abstraction-for-electron.md new file mode 100644 index 00000000..9af717cc --- /dev/null +++ b/docs/research/best-abstraction-for-electron.md @@ -0,0 +1,726 @@ +# Best Abstraction Tool for Multi-Provider Agent Support in Electron + +**Date**: 2026-03-24 +**Branch**: `dev` +**Based on**: actual source analysis of `TeamProvisioningService.ts` (7,982 LOC), `childProcess.ts`, `TeamMcpConfigBuilder.ts`, `PtyTerminalService.ts`, `agent-teams-controller/`, and prior research in `docs/research/` + +--- + +## Context: What We Have Today + +Our Electron app (40.x) manages Claude Code CLI processes via: + +| Component | File | Role | +|-----------|------|------| +| `spawnCli()` | `src/main/utils/childProcess.ts` | child_process.spawn wrapper with Windows EINVAL fallback, injects `CLI_ENV_DEFAULTS` | +| `TeamProvisioningService` | `src/main/services/team/TeamProvisioningService.ts` | 7,982 LOC monolith: process lifecycle, stream-json NDJSON parsing, prompt engineering, stall watchdog, tool approval relay | +| `ClaudeBinaryResolver` | `src/main/services/team/ClaudeBinaryResolver.ts` | Resolves `claude` binary across PATH, NVM, platform dirs | +| `TeamMcpConfigBuilder` | `src/main/services/team/TeamMcpConfigBuilder.ts` | Builds `--mcp-config` JSON for every spawned process | +| `PtyTerminalService` | `src/main/services/infrastructure/PtyTerminalService.ts` | node-pty for embedded terminal (used separately, NOT for agent processes) | +| `agent-teams-controller` | `agent-teams-controller/` | Provider-agnostic file CRUD (tasks, kanban, inbox, reviews) | +| `killTeamProcess()` | TeamProvisioningService | Uses SIGKILL to prevent Claude CLI SIGTERM cleanup deleting team files | + +**Current protocol**: Claude CLI `--input-format stream-json --output-format stream-json` — proprietary NDJSON with types: `user`, `assistant`, `control_request`, `result`, `system`. + +**Current coupling**: 9/10 to Claude Code CLI (see `best-integration-approach.md` for full coupling map). + +--- + +## Two Distinct Needs + +### Level 1: CLI Agent Process Management +Spawn external CLI agents (Claude Code, Codex CLI, Gemini CLI, Goose) as child processes, each with its own protocol, binary resolution, health monitoring, and MCP config. + +### Level 2: Programmatic LLM API Calls +Call LLM APIs directly for lightweight tasks (code review bot, triage bot, task planning, MCP tool calling). No CLI process — just HTTP to provider APIs. + +These are **fundamentally different problems** and should use **different solutions**. + +--- + +## Level 1: CLI Agent Process Management + +### The Candidates + +#### Option A: Own Adapter Pattern (Overstory-style) +**Reliability: 9/10 | Confidence: 9/10** + +Build a thin `AgentCliAdapter` interface with per-CLI implementations. + +```typescript +// src/main/services/agent/AgentCliAdapter.ts +export interface AgentCliAdapter { + readonly providerId: string; // 'claude' | 'codex' | 'gemini' | 'goose' + + /** Resolve binary path on this machine */ + resolveBinary(): Promise; + + /** Build spawn args for creating/launching a team */ + buildSpawnArgs(request: AgentSpawnRequest): string[]; + + /** Build env vars for the spawned process */ + buildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv; + + /** Parse a line of stdout. Returns typed event or null (skip). */ + parseStdoutLine(line: string): AgentOutputEvent | null; + + /** Format a user message for stdin */ + formatUserMessage(text: string): string; + + /** Process exit semantics: what does exit code mean? */ + interpretExitCode(code: number | null): 'success' | 'error' | 'killed'; + + /** Kill semantics: SIGTERM vs SIGKILL */ + killProcess(child: ChildProcess): void; + + /** Whether this CLI needs MCP config file */ + needsMcpConfig: boolean; + + /** Build MCP config in the format this CLI expects */ + buildMcpConfig?(servers: Record): object; +} +``` + +Per-provider implementations: + +```typescript +// src/main/services/agent/adapters/ClaudeCliAdapter.ts +export class ClaudeCliAdapter implements AgentCliAdapter { + readonly providerId = 'claude'; + readonly needsMcpConfig = true; + + async resolveBinary(): Promise { + return new ClaudeBinaryResolver().resolve(); + } + + buildSpawnArgs(request: AgentSpawnRequest): string[] { + return [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--setting-sources', 'user,project,local', + '--mcp-config', request.mcpConfigPath!, + '--disallowedTools', 'TeamDelete,TodoWrite', + ...(request.skipPermissions + ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] + : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), + ...(request.model ? ['--model', request.model] : []), + ]; + } + + buildEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...base, CLAUDE_HOOK_JUDGE_MODE: 'true' }; + } + + parseStdoutLine(line: string): AgentOutputEvent | null { + const msg = JSON.parse(line); + // Existing 60+ branch logic from handleStreamJsonMessage() + switch (msg.type) { + case 'assistant': return { kind: 'text', content: extractText(msg) }; + case 'result': return { kind: 'result', success: msg.subtype !== 'error' }; + case 'control_request': return { kind: 'approval', request: msg }; + // ... etc + } + } + + formatUserMessage(text: string): string { + return JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }) + '\n'; + } + + killProcess(child: ChildProcess): void { + killProcessTree(child, 'SIGKILL'); // SIGKILL to prevent cleanup + } +} +``` + +```typescript +// src/main/services/agent/adapters/CodexCliAdapter.ts +export class CodexCliAdapter implements AgentCliAdapter { + readonly providerId = 'codex'; + readonly needsMcpConfig = false; // Codex uses MCP differently + + async resolveBinary(): Promise { + // which codex + return resolveWhich('codex'); + } + + buildSpawnArgs(request: AgentSpawnRequest): string[] { + return ['app-server']; // JSON-RPC mode + } + + parseStdoutLine(line: string): AgentOutputEvent | null { + // JSON-RPC notification parsing + const msg = JSON.parse(line); + if (msg.method === 'item/agentMessage/delta') { + return { kind: 'text_delta', content: msg.params.delta }; + } + // ... + } + + formatUserMessage(text: string): string { + // JSON-RPC request for turn/start + return JSON.stringify({ + jsonrpc: '2.0', id: nextId(), + method: 'turn/start', + params: { message: text }, + }) + '\n'; + } + + killProcess(child: ChildProcess): void { + killProcessTree(child, 'SIGTERM'); // Codex handles SIGTERM gracefully + } +} +``` + +```typescript +// src/main/services/agent/adapters/GeminiCliAdapter.ts +export class GeminiCliAdapter implements AgentCliAdapter { + readonly providerId = 'gemini'; + readonly needsMcpConfig = false; + + async resolveBinary(): Promise { + return resolveWhich('gemini'); + } + + buildSpawnArgs(request: AgentSpawnRequest): string[] { + return [ + '--output-format', 'stream-json', + '-p', request.prompt, + ]; + } + + parseStdoutLine(line: string): AgentOutputEvent | null { + // Gemini NDJSON events + const event = JSON.parse(line); + // ... + } + + formatUserMessage(text: string): string { + // Gemini headless doesn't support multi-turn stdin in stream-json + // (one-shot with -p flag). For multi-turn, need new process per turn. + throw new Error('Gemini CLI does not support multi-turn stdin'); + } + + killProcess(child: ChildProcess): void { + killProcessTree(child, 'SIGTERM'); + } +} +``` + +**Pros:** +- Zero new dependencies +- Perfectly fits existing `spawnCli()` / `killProcessTree()` infrastructure +- Each adapter is ~100-200 LOC — easy to test in isolation +- Can be extracted incrementally from the existing TeamProvisioningService +- No framework overhead in the Electron main process +- Each CLI's quirks handled explicitly (Claude SIGKILL vs Codex SIGTERM, stream-json vs JSON-RPC) + +**Cons:** +- We write the adapter code ourselves (~500 LOC total for 4 adapters) +- No built-in CLI discovery / health check framework + +**Effort**: ~800 LOC (interface + 4 adapters + factory), 3-5 days + +--- + +#### Option B: node-pty Based Approach +**Reliability: 5/10 | Confidence: 4/10** + +Use pseudo-terminal for all CLI agents (captures raw terminal output). + +```typescript +import * as pty from 'node-pty'; + +const proc = pty.spawn('claude', ['--verbose'], { + name: 'xterm-256color', + cols: 120, rows: 40, + cwd: projectPath, + env: process.env, +}); + +proc.onData((data) => { + // Problem: raw terminal output with ANSI codes, cursor movement, etc. + // We'd need to strip all that to parse structured JSON +}); +``` + +**Pros:** +- Already have `node-pty` in dependencies (for embedded terminal) +- Works with any CLI that has a TUI mode + +**Cons:** +- node-pty is a native addon requiring electron-rebuild (fragile across platforms) +- All CLIs output ANSI escape codes in TTY mode — parsing structured data from raw terminal output is extremely unreliable +- We ALREADY use stream-json/JSON-RPC specifically to AVOID the TTY problem +- Memory overhead of full PTY per agent process +- Claude Code, Codex, and Gemini all have headless/programmatic modes — PTY is the WRONG abstraction + +**Verdict: REJECT.** PTY is for interactive terminals, not programmatic agent management. We already learned this — `PtyTerminalService` is used only for the embedded terminal, not for agent processes. + +--- + +#### Option C: MCO / Third-Party Orchestrator Library +**Reliability: 3/10 | Confidence: 3/10** + +No mature, production-ready TypeScript library exists for "spawn and manage multiple AI CLI agents as child processes." The closest is `pi-builder` from the `awesome-cli-coding-agents` ecosystem, but it's a young project (~100 stars) with no stability guarantees. + +**Verdict: REJECT.** The problem is too niche and CLI-specific for a generic library. Each CLI has its own protocol (Claude stream-json, Codex JSON-RPC, Gemini NDJSON, Goose recipes). A generic library would either be too thin to be useful or too opinionated to handle the differences. + +--- + +#### Level 1 Recommendation: Option A (Own Adapter Pattern) + +| Criteria | Score | +|----------|-------| +| Fit with existing code patterns | 10/10 — mirrors how `spawnCli()` and `ClaudeBinaryResolver` already work | +| Lines of code to integrate | ~800 LOC (interface + 4 adapters + factory) | +| Heavy dependencies added | 0 | +| Runs in Electron main process | Yes (pure Node.js) | +| License compatibility | N/A (our own code, AGPL-3.0) | +| Active maintenance | By us — full control | + +**Migration path**: Extract current Claude-specific logic from `TeamProvisioningService` into `ClaudeCliAdapter`, then add adapters for other CLIs one by one. The monster 7,982 LOC monolith gets decomposed as a side effect. + +--- + +## Level 2: Programmatic LLM API Calls + +### The Candidates + +#### Option A: Vercel AI SDK (`ai` + `@ai-sdk/*`) +**Reliability: 9/10 | Confidence: 9/10** (Recommended) + +The leading TypeScript LLM abstraction. 20M+ monthly npm downloads, backed by Vercel, 30K+ GitHub stars. + +```typescript +// src/main/services/llm/LlmService.ts +import { generateText, streamText, tool } from 'ai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { openai } from '@ai-sdk/openai'; +import { google } from '@ai-sdk/google'; +import { z } from 'zod'; + +// Simple code review — runs in Electron main process +export async function reviewCode(diff: string, model = 'anthropic/claude-sonnet-4-20250514') { + const { text } = await generateText({ + model: anthropic('claude-sonnet-4-20250514'), + system: 'You are a code reviewer. Be concise.', + prompt: `Review this diff:\n\n${diff}`, + }); + return text; +} + +// Streaming task planning with tool calling — relayed to renderer via IPC +export async function planTasks( + description: string, + onChunk: (text: string) => void, +) { + const result = streamText({ + model: openai('gpt-4o'), + system: 'You are a project planner.', + prompt: description, + tools: { + createTask: tool({ + description: 'Create a new task on the kanban board', + parameters: z.object({ + title: z.string(), + assignee: z.string().optional(), + column: z.enum(['backlog', 'todo', 'in_progress']), + }), + execute: async ({ title, assignee, column }) => { + // Call our agent-teams-controller to create task + return controller.createTask({ title, assignee, column }); + }, + }), + }, + maxSteps: 10, // Allow multi-step tool calling loops + }); + + for await (const chunk of result.textStream) { + onChunk(chunk); + } +} + +// Triage incoming issue — pick best team member +export async function triageTask(taskDescription: string) { + const { object } = await generateObject({ + model: google('gemini-2.5-flash'), + schema: z.object({ + assignee: z.string(), + priority: z.enum(['low', 'medium', 'high', 'critical']), + reasoning: z.string(), + }), + prompt: `Triage this task: ${taskDescription}\nAvailable members: alice (frontend), bob (backend), carol (devops)`, + }); + return object; // Typed: { assignee: string; priority: string; reasoning: string } +} +``` + +**What we install:** +```bash +pnpm add ai @ai-sdk/anthropic @ai-sdk/openai @ai-sdk/google zod +# ai: 67.5 kB gzipped (core) +# @ai-sdk/anthropic: ~15 kB gzipped +# @ai-sdk/openai: ~19.5 kB gzipped +# @ai-sdk/google: ~15 kB gzipped +# Total: ~117 kB gzipped — very reasonable for Electron +``` + +**Pros:** +- Unified `generateText()` / `streamText()` / `generateObject()` API across ALL providers +- Swap provider with one line change: `anthropic('claude-sonnet-4-20250514')` → `openai('gpt-4o')` +- First-class tool calling with Zod schema validation +- Streaming works perfectly in Node.js (Electron main process) +- Sentry already has `vercelAIIntegration` for Electron — we already use `@sentry/electron` +- TypeScript-first: full type inference for tool parameters and structured outputs +- AI SDK 6 `Agent` class for reusable agent patterns +- 20M+ monthly downloads, extremely active maintenance, battle-tested +- Apache-2.0 license — compatible with our AGPL-3.0 + +**Cons:** +- Adds ~4 new deps (ai, 3 providers) — but they're lightweight +- Learning curve for Zod schemas (though Zod is industry standard) +- AI SDK 5→6 had some breaking changes — minor version churn risk + +**Electron main process integration:** +```typescript +// src/main/ipc/llm.ts — IPC handlers for renderer +import { wrapHandler } from './utils'; +import { streamText } from 'ai'; +import { anthropic } from '@ai-sdk/anthropic'; + +export function registerLlmHandlers() { + // One-shot generation + ipcMain.handle('llm:generate', wrapHandler(async (_event, params) => { + const { text } = await generateText({ + model: resolveModel(params.model), // 'anthropic/claude-sonnet-4-20250514' → anthropic('claude-sonnet-4-20250514') + system: params.system, + prompt: params.prompt, + }); + return { text }; + })); + + // Streaming — emit chunks via webContents.send() + ipcMain.handle('llm:stream', wrapHandler(async (event, params) => { + const result = streamText({ + model: resolveModel(params.model), + system: params.system, + prompt: params.prompt, + }); + + const sender = event.sender; + for await (const chunk of result.textStream) { + sender.send('llm:chunk', { requestId: params.requestId, chunk }); + } + sender.send('llm:done', { requestId: params.requestId }); + return { started: true }; + })); +} +``` + +--- + +#### Option B: Mastra (LLM layer only) +**Reliability: 6/10 | Confidence: 5/10** + +Mastra is a full agent framework (workflows, RAG, memory, server). Using "just the LLM layer" means using Mastra's `Agent` class which internally uses AI SDK anyway. + +```typescript +import { Agent } from '@mastra/core/agent'; + +const reviewer = new Agent({ + id: 'code-reviewer', + instructions: 'You are a code reviewer.', + model: 'anthropic/claude-sonnet-4-20250514', +}); + +const result = await reviewer.generate('Review this diff...'); +``` + +**Pros:** +- Nice `Agent` abstraction with built-in memory and workflow support +- Uses AI SDK internally — same providers +- TypeScript-native + +**Cons:** +- `@mastra/core` pulls in significant dependencies (server framework, storage adapters, DI container) +- Overkill for our use case — we need `generateText()`, not the full agent runtime +- Our agent runtime IS the CLI process management layer, not Mastra's in-process loop +- Less mature than AI SDK (smaller community, fewer downloads) +- Adds unnecessary abstraction layer on top of AI SDK +- YC-backed startup — could pivot or die; AI SDK is backed by Vercel ($3.2B company) + +**See also:** `docs/research/mastra-integration-analysis.md` (full analysis, verdict 6/10 feasibility) + +--- + +#### Option C: LangChain.js +**Reliability: 4/10 | Confidence: 3/10** + +```typescript +import { ChatAnthropic } from '@langchain/anthropic'; +import { ChatOpenAI } from '@langchain/openai'; + +const chat = new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }); +const result = await chat.invoke('Review this diff...'); +``` + +**Pros:** +- Largest ecosystem (chains, agents, RAG, memory) +- Many tutorials and examples + +**Cons:** +- **101 kB gzipped** — 3x the size of OpenAI SDK, 1.5x AI SDK +- Heavy dependency tree (infamous for bloat) +- Frequent breaking changes between versions +- Overcomplicated abstractions for simple LLM calls +- Edge runtime incompatible (uses Node `fs`) +- Community frustration well-documented: "LangChain adds unnecessary complexity" +- For our use case (simple API calls with tool calling), it's a 10-ton truck for a bicycle ride + +--- + +#### Option D: LiteLLM (via proxy) +**Reliability: 5/10 | Confidence: 4/10** + +Run a Python proxy process, point OpenAI SDK at it. + +```typescript +import OpenAI from 'openai'; + +const client = new OpenAI({ + baseURL: 'http://localhost:4000', // LiteLLM proxy + apiKey: 'sk-anything', +}); + +const result = await client.chat.completions.create({ + model: 'anthropic/claude-sonnet-4-20250514', + messages: [{ role: 'user', content: 'Review this diff...' }], +}); +``` + +**Pros:** +- 100+ providers through OpenAI-compatible API +- Rate limiting, fallbacks, cost tracking built-in +- Established in production at many companies + +**Cons:** +- **Requires Python runtime** — catastrophic for an Electron desktop app +- Another long-lived process to manage (proxy lifecycle) +- Performance degrades under concurrency (Python GIL) +- Extra latency hop: Electron → proxy → provider → proxy → Electron +- Enterprise features (SSO, RBAC) behind paid license +- Electron users expect a self-contained app, not "also install Python 3.11" + +--- + +#### Option E: Direct Provider SDKs with Thin Wrapper +**Reliability: 7/10 | Confidence: 7/10** + +```typescript +import Anthropic from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; + +async function callLlm(provider: string, prompt: string) { + switch (provider) { + case 'anthropic': { + const client = new Anthropic(); + const msg = await client.messages.create({ + model: 'claude-sonnet-4-20250514', + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }); + return msg.content[0].type === 'text' ? msg.content[0].text : ''; + } + case 'openai': { + const client = new OpenAI(); + const result = await client.chat.completions.create({ + model: 'gpt-4o', + messages: [{ role: 'user', content: prompt }], + }); + return result.choices[0]?.message?.content ?? ''; + } + // ...each provider has different API shape + } +} +``` + +**Pros:** +- Each SDK is lightweight and well-maintained +- No abstraction overhead — direct control + +**Cons:** +- Must implement unified tool calling ourselves (Anthropic tools format ≠ OpenAI function calling ≠ Google tool format) +- Must implement streaming ourselves for each provider +- Must implement structured output extraction per-provider +- Maintenance burden grows linearly with each new provider +- This is literally what AI SDK already does, but worse + +--- + +### Level 2 Recommendation: Option A (Vercel AI SDK) + +| Criteria | Score | +|----------|-------| +| Fit with existing code patterns | 9/10 — pure TypeScript, Node.js-compatible, modular | +| Lines of code to integrate | ~200 LOC (LlmService + IPC handlers) | +| Heavy dependencies added | No — ~117 kB gzipped total for core + 3 providers | +| Runs in Electron main process | Yes — confirmed by Sentry Electron integration docs | +| License compatibility | Apache-2.0 → compatible with our AGPL-3.0 | +| Active maintenance | 10/10 — 20M+ monthly downloads, Vercel-backed | + +--- + +## Combined Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Electron Main Process │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Level 1: CLI Process Management │ │ +│ │ │ │ +│ │ AgentCliAdapter (interface) │ │ +│ │ ├─ ClaudeCliAdapter (stream-json NDJSON) │ │ +│ │ ├─ CodexCliAdapter (app-server JSON-RPC) │ │ +│ │ ├─ GeminiCliAdapter (stream-json NDJSON) │ │ +│ │ └─ GooseCliAdapter (stdin recipes) │ │ +│ │ │ │ +│ │ spawnCli() + killProcessTree() (unchanged) │ │ +│ │ TeamMcpConfigBuilder (unchanged) │ │ +│ │ TeamProvisioningService (refactored to use │ │ +│ │ adapter.parseStdoutLine() etc.) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Level 2: Programmatic LLM API Calls │ │ +│ │ │ │ +│ │ Vercel AI SDK (ai + @ai-sdk/*) │ │ +│ │ ├─ generateText() → code review, triage │ │ +│ │ ├─ streamText() → task planning, chat │ │ +│ │ ├─ generateObject()→ structured extraction │ │ +│ │ └─ tool() → MCP tool bridges │ │ +│ │ │ │ +│ │ LlmService.ts (~200 LOC) │ │ +│ │ IPC handlers → renderer │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Shared: agent-teams-controller │ │ +│ │ (provider-agnostic task/kanban/inbox CRUD) │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Comparison Matrix + +### Level 1: CLI Process Management + +| Criterion | Own Adapter | node-pty | MCO/Third-Party | +|-----------|-------------|----------|-----------------| +| Reliability | 9/10 | 5/10 | 3/10 | +| Confidence | 9/10 | 4/10 | 3/10 | +| Fit with codebase | 10/10 | 4/10 | 3/10 | +| New dependencies | 0 | 0 (already have) | Unknown | +| LOC to integrate | ~800 | ~600 | ~1000+ | +| Electron compatible | Yes | Yes (fragile) | Unknown | +| Handles protocol diffs | Explicit | No (raw PTY) | Generic/lossy | + +### Level 2: Programmatic LLM API Calls + +| Criterion | AI SDK | Mastra | LangChain | LiteLLM | Direct SDKs | +|-----------|--------|--------|-----------|---------|-------------| +| Reliability | 9/10 | 6/10 | 4/10 | 5/10 | 7/10 | +| Confidence | 9/10 | 5/10 | 3/10 | 4/10 | 7/10 | +| Fit with codebase | 9/10 | 5/10 | 3/10 | 2/10 | 7/10 | +| Bundle size | 117 kB | ~400+ kB | 101 kB + deps | N/A (Python) | ~80 kB | +| Tool calling | Unified | Unified (via AI SDK) | Unified | OpenAI-compat | Per-provider | +| Streaming | Async iterator | Async iterator | Chains | SSE proxy | Per-provider | +| Providers | 20+ | 94 (via AI SDK) | 20+ | 100+ | Each separate | +| Electron main proc | Confirmed | Untested | Problematic | Requires Python | Yes | +| License | Apache-2.0 | Elastic-2.0 / AGPL-3.0 | MIT | MIT | Varies | +| Maintenance | Vercel (huge team) | Startup (small) | Community | Community | Per-vendor | + +--- + +## Final Recommendation + +### Level 1: Own Adapter Pattern +- **0 new dependencies**, ~800 LOC +- Extract Claude-specific logic from the 7,982 LOC monolith into `ClaudeCliAdapter` +- Add `CodexCliAdapter`, `GeminiCliAdapter`, `GooseCliAdapter` incrementally +- Each adapter handles that CLI's unique protocol, binary resolution, spawn args, kill semantics +- Decomposes the monolith as a beneficial side effect + +### Level 2: Vercel AI SDK (`ai` + `@ai-sdk/*`) +- **4 lightweight deps** (~117 kB gzipped total), ~200 LOC integration +- `generateText()` for one-shot tasks, `streamText()` for interactive, `generateObject()` for structured extraction +- Unified tool calling with Zod schemas +- Swap any provider with one line change +- Apache-2.0 compatible with our AGPL-3.0 +- Already used by 20M+ monthly projects, confirmed Electron compatibility + +### Implementation Order + +1. **Week 1**: Create `AgentCliAdapter` interface, extract `ClaudeCliAdapter` from `TeamProvisioningService` +2. **Week 1**: Install AI SDK, create `LlmService.ts` with `generateText()` wrapper, add IPC handlers +3. **Week 2**: Add `CodexCliAdapter` (app-server JSON-RPC mode) +4. **Week 2**: Build code review bot using AI SDK + MCP tools +5. **Week 3**: Add `GeminiCliAdapter`, `GooseCliAdapter` +6. **Week 3**: Build triage bot, task planning with `streamText()` + tool calling + +**Total effort**: ~3 weeks for full multi-provider support at both levels. + +--- + +## Sources + +### AI SDK (Vercel) +- [AI SDK Introduction](https://ai-sdk.dev/docs/introduction) +- [AI SDK 6 Announcement](https://vercel.com/blog/ai-sdk-6) +- [Node.js Getting Started](https://ai-sdk.dev/docs/getting-started/nodejs) +- [Providers and Models](https://ai-sdk.dev/docs/foundations/providers-and-models) +- [Sentry Electron + Vercel AI Integration](https://docs.sentry.io/platforms/javascript/guides/electron/configuration/integrations/vercelai/) +- [Generating Text](https://ai-sdk.dev/docs/ai-sdk-core/generating-text) +- [npm: ai](https://www.npmjs.com/package/ai) +- [GitHub: vercel/ai](https://github.com/vercel/ai) + +### Codex CLI +- [Codex SDK](https://developers.openai.com/codex/sdk) +- [Codex App Server](https://developers.openai.com/codex/app-server) +- [npm: @openai/codex-sdk](https://www.npmjs.com/package/@openai/codex-sdk) +- [CLI Reference](https://developers.openai.com/codex/cli/reference) + +### Gemini CLI +- [Headless Mode Reference](https://geminicli.com/docs/cli/headless/) +- [GitHub: google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) + +### Goose +- [GitHub: block/goose](https://github.com/block/goose) +- [CLI Commands](https://block.github.io/goose/docs/guides/goose-cli-commands/) + +### Mastra +- [GitHub: mastra-ai/mastra](https://github.com/mastra-ai/mastra) +- [Mastra Docs: Models](https://mastra.ai/models) + +### LangChain.js +- [LangChain vs Vercel AI SDK vs OpenAI SDK: 2026 Guide](https://strapi.io/blog/langchain-vs-vercel-ai-sdk-vs-openai-sdk-comparison-guide) +- [Bundle Size Issue #809](https://github.com/langchain-ai/langchainjs/issues/809) +- [LangChain Criticism](https://community.latenode.com/t/why-im-avoiding-langchain-in-2025/39046) + +### LiteLLM +- [LiteLLM Proxy Docs](https://docs.litellm.ai/docs/simple_proxy) +- [Best LiteLLM Alternatives 2026](https://www.getmaxim.ai/articles/best-litellm-alternatives-in-2026/) + +### License Compatibility +- [Apache License and GPL Compatibility](https://www.apache.org/licenses/GPL-compatibility.html) +- [Apache 2.0 Compatible Licenses Guide](https://licensecheck.io/guides/apache-compatible) + +### Ecosystem +- [CLI Coding Agents Comparison 2026](https://www.tembo.io/blog/coding-cli-tools-comparison) +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) diff --git a/docs/research/best-integration-approach.md b/docs/research/best-integration-approach.md new file mode 100644 index 00000000..45cbb764 --- /dev/null +++ b/docs/research/best-integration-approach.md @@ -0,0 +1,406 @@ +# Best Integration Approach for Multi-Provider Agent Support + +**Date**: 2026-03-24 +**Branch**: `dev` +**Based on**: deep codebase analysis of actual source files + +--- + +## Executive Summary + +After analyzing 21,584 LOC in `src/main/services/team/`, 2,973 LOC in `src/main/ipc/teams.ts`, 1,245 LOC in `mcp-server/src/`, and all prompt engineering in `TeamProvisioningService.ts` (7,982 LOC), the recommendation is: + +**Option 7: Hybrid approach** — keep Claude Code native support as-is, enhance the existing MCP server to be the universal integration point for other agents. + +This is the only approach that ships incrementally, preserves our working architecture, and provides real multi-provider value within 2-3 weeks. + +--- + +## Architecture Deep Dive + +### Coupling Map (actual file references) + +#### Layer 1: Process Management (9/10 coupling to Claude) +- `src/main/services/team/ClaudeBinaryResolver.ts` (292 LOC) — resolves `claude` binary across PATH, NVM, platform-specific dirs +- `src/main/services/team/TeamProvisioningService.ts` (7,982 LOC) — the monolith: process spawn, stream-json parsing, prompt engineering, inbox relay, tool approval, stall detection, auth retry +- `src/main/utils/childProcess.ts` — `spawnCli()` injects `CLAUDE_HOOK_JUDGE_MODE` env var +- Claude CLI flags hardcoded: `--input-format stream-json`, `--output-format stream-json`, `--verbose`, `--setting-sources`, `--mcp-config`, `--disallowedTools`, `--dangerously-skip-permissions`, `--permission-prompt-tool`, `--permission-mode`, `--model`, `--effort`, `--worktree`, `--resume` +- Kill semantics: `killTeamProcess()` uses SIGKILL because Claude CLI SIGTERM cleanup **deletes team files** + +#### Layer 2: Protocol (10/10 coupling to Claude) +- stream-json protocol is entirely Claude-proprietary +- `HANDLED_STREAM_JSON_TYPES` = `user`, `assistant`, `control_request`, `result`, `system` +- Input format: `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}\n` +- Output parsing: 60+ branches in `handleStreamJsonMessage()` (lines 4858-5294) +- `control_request` for tool approval — Claude Code-specific flow +- Teammate message format: `content` + +#### Layer 3: Prompt Engineering (10/10 coupling to Claude) +- `buildProvisioningPrompt()` (lines 860-953) — tells Claude to use `TeamCreate` built-in tool, then `Task` tool with `team_name` parameter to spawn teammates +- `buildMemberSpawnPrompt()` (lines 444-478) — instructs member to call `member_briefing` MCP tool first, then work with MCP task tools +- `buildPersistentLeadContext()` (lines 664-766) — 100+ line constraint block teaching Claude about kanban, review workflow, delegation-first behavior, agent block policy, cross-team messaging +- `buildTeamCtlOpsInstructions()` (lines 563-662) — exact MCP tool call examples: `task_create`, `task_get`, `kanban_set_column`, `review_approve`, etc. +- `buildActionModeProtocol()` — imports from `agent-teams-controller` via `protocols.buildActionModeProtocolText()` + +**Key insight**: The prompt teaches Claude to use two categories of tools: +1. **Claude Code built-in tools**: `TeamCreate`, `TeamDelete`, `TaskCreate` (the CLI's internal Task tool for spawning subagents), `SendMessage` — these exist ONLY in Claude Code +2. **MCP tools**: `task_create`, `task_get`, `task_list`, `kanban_get`, `review_approve`, `message_send`, etc. — these come from our `agent-teams-mcp` server and are **provider-agnostic** + +#### Layer 4: Data Layer (5/10 coupling — mostly agnostic) +- `agent-teams-controller` (workspace package) — **provider-agnostic** file-based CRUD for tasks, kanban, reviews, messages, processes +- `TeamDataService.ts` (1,953 LOC) — reads team data, invokes controller. Most logic is generic +- `TeamInboxWriter.ts` — writes JSON inbox files. No Claude-specific code +- `TeamTaskReader.ts`, `TeamTaskWriter.ts` — file-based task CRUD via controller +- `TeamKanbanManager.ts` — kanban state management via controller +- `TeamConfigReader.ts` — reads `config.json` from `~/.claude/teams//` +- Path dependency: `~/.claude/teams/` and `~/.claude/tasks/` via `pathDecoder.ts` + +#### Layer 5: MCP Server (0/10 coupling — fully agnostic) +- `mcp-server/src/` (1,245 LOC) — FastMCP server exposing 30+ tools +- **Already exposed tools**: + - Tasks: `task_create`, `task_get`, `task_get_comment`, `task_list`, `task_set_status`, `task_start`, `task_complete`, `task_set_owner`, `task_add_comment`, `task_attach_file`, `task_attach_comment_file`, `task_set_clarification`, `task_link`, `task_unlink`, `member_briefing`, `task_briefing` + - Kanban: `kanban_get`, `kanban_set_column`, `kanban_clear`, `kanban_list_reviewers`, `kanban_add_reviewer`, `kanban_remove_reviewer` + - Review: `review_request`, `review_start`, `review_approve`, `review_request_changes` + - Messages: `message_send` + - Processes: `process_register`, `process_list`, `process_unregister`, `process_stop` + - Cross-team: `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` + - Runtime: `team_launch`, `team_stop` +- Uses `agent-teams-controller` directly — no Claude Code dependency in MCP tools +- All tools take `teamName` + `claudeDir` as context parameters + +#### Layer 6: HTTP Control API (2/10 coupling) +- `src/main/http/teams.ts` — REST API for `POST /api/teams/:teamName/launch` and `/stop` +- `TeamControlApiState.ts` — publishes control endpoint to `~/.claude/team-control-api.json` +- Thin wrapper over `TeamProvisioningService` — the provisioning itself is Claude-coupled, but the HTTP API shape is generic + +--- + +## Approach Evaluation + +### 1. Mastra (TS-native orchestration framework) + +**Confidence: 4/10 | Reliability: 5/10** + +- **What it is**: Full TS-native agent framework with workflows, tools, memory, RAG +- **Effort**: 8-12 weeks +- **What breaks**: Everything. Mastra has its own agent lifecycle, tool system, and workflow engine. Our entire `TeamProvisioningService` (8K LOC), `TeamDataService` (2K LOC), prompt engineering, stream-json protocol, inbox system, kanban logic would need to be replaced or wrapped +- **What stays**: UI components (renderer), shared types, some utility code +- **Reusable code**: ~20% (UI, types, file watching) +- **Risk**: Very High. Mastra is designed for API-based agents (OpenAI, Anthropic API), not CLI-based agents. Claude Code Agent Teams runs as a CLI process with stream-json — Mastra has no concept of this. Would require either: + - Abandoning Claude Code CLI in favor of raw Anthropic API calls (losing Agent Teams, built-in tools, session persistence) + - Building a massive adapter layer to make Claude Code CLI look like a Mastra "agent" +- **Quality**: Medium. Multi-provider support would be good, but we'd lose all Claude Code-specific features that make the product unique +- **Verdict**: Massive rewrite for uncertain benefit. Our product IS Claude Code Agent Teams UI — Mastra would replace the foundation + +### 2. MCO (dispatch layer) + +**Confidence: 3/10 | Reliability: 4/10** + +- **What it is**: Lightweight dispatch layer for routing tasks to different agent providers +- **Effort**: 6-8 weeks +- **What breaks**: Same fundamental problem as Mastra — MCO dispatches to "agents" but doesn't understand Claude Code's CLI protocol, stream-json, Agent Teams, or our inbox system +- **What stays**: Data layer, UI, some services +- **Reusable code**: ~30% +- **Risk**: High. MCO is minimal and would require us to build most of the integration ourselves anyway +- **Quality**: Low-Medium. MCO is too thin to solve the real problems (protocol translation, process management, prompt adaptation) +- **Verdict**: All the work of a custom solution without the benefit of framework support + +### 3. Overstory Pattern (AgentRuntime interface + SQLite mail) + +**Confidence: 5/10 | Reliability: 6/10** + +- **What it is**: Abstract `AgentRuntime` interface with SQLite-backed message queue +- **Effort**: 6-10 weeks +- **What breaks**: Process management, protocol layer, prompt engineering +- **What stays**: UI, kanban logic, data layer structure (would migrate from JSON files to SQLite) +- **Reusable code**: ~35% +- **Risk**: High. Major architectural change (JSON files -> SQLite, inbox files -> SQLite mail queue). All of `TeamProvisioningService` would need rewriting for each provider +- **Quality**: Good long-term architecture, but: + - We already HAVE a working message system (JSON inbox files + file watchers) + - SQLite migration would break compatibility with Claude Code CLI's native file format + - Claude Code reads/writes `~/.claude/teams//inboxes/.json` directly — switching to SQLite means Claude Code can't participate without a shim +- **Verdict**: Architecturally elegant but fights against Claude Code's native file-based protocol + +### 4. mozilla/any-agent (meta-framework) + +**Confidence: 3/10 | Reliability: 3/10** + +- **What it is**: Python meta-framework to switch agent providers via config +- **Effort**: 10-14 weeks +- **What breaks**: Language barrier — our entire codebase is TypeScript/Electron. any-agent is Python +- **What stays**: UI (renderer) +- **Reusable code**: ~15% (UI only) +- **Risk**: Very High. Would need either: + - Python backend + IPC bridge to Electron renderer (architectural nightmare) + - Port any-agent concepts to TypeScript (then it's really option 5) +- **Quality**: Theoretically good multi-provider support, but wrong language ecosystem +- **Verdict**: Non-starter for a TypeScript/Electron project + +### 5. Our own AgentRuntime abstraction + +**Confidence: 6/10 | Reliability: 7/10** + +- **What it is**: Custom `AgentRuntime` interface inspired by the patterns above, implemented in TypeScript +- **Effort**: 8-12 weeks for full implementation, 4-6 weeks for MVP +- **What breaks**: `TeamProvisioningService` would be refactored into multiple provider-specific implementations +- **What stays**: Data layer (`agent-teams-controller`, TeamDataService, MCP server), UI, kanban, review, cross-team +- **Reusable code**: ~55-60% +- **Risk**: Medium-High. The abstraction must account for fundamentally different agent lifecycles: + - Claude Code: CLI process, stream-json, Agent Teams built-in, teammate spawning via Task tool + - Codex: subprocess, different CLI protocol, no native team tools + - Gemini CLI: yet another protocol + - API-based agents: HTTP calls, no process management at all +- **Quality**: Could be excellent if done right. But the abstraction boundary is extremely hard to get right because Claude Code's Agent Teams is so deeply integrated +- **Key interfaces needed**: + +```typescript +interface AgentRuntime { + name: string; + spawn(config: AgentSpawnConfig): Promise; + sendMessage(process: AgentProcess, message: string): Promise; + parseOutput(line: string): ParsedAgentOutput; + kill(process: AgentProcess): void; + checkAuth(): Promise; + buildPrompt(context: PromptContext): string; +} + +interface AgentProcess { + pid: number; + stdin: Writable; + stdout: Readable; + stderr: Readable; + on(event: 'exit', handler: (code: number) => void): void; +} +``` + +- **The hard part**: `TeamProvisioningService` is 7,982 LOC of deeply intertwined logic. Splitting it into provider-agnostic + provider-specific parts is a multi-week refactoring effort. The `handleStreamJsonMessage()` method alone (lines 4858-5294) handles 15+ message types with side effects throughout +- **Verdict**: Right direction, but expensive and risky as a first step + +### 6. MCP-Based Approach (expose kanban as MCP server for external agents) + +**Confidence: 8/10 | Reliability: 8/10** + +- **What it is**: Enhance our existing MCP server so external agents (Codex, Gemini, any MCP-capable agent) connect TO us and use our kanban, tasks, messages, review system +- **Effort**: 2-3 weeks +- **What breaks**: Nothing. This is additive +- **What stays**: Everything. 100% of existing code remains unchanged +- **Reusable code**: 100% +- **Risk**: Low. We already have a working MCP server with 30+ tools +- **Quality**: Surprisingly good for the effort level. Here's why: + - **MCP is a cross-vendor standard** — Codex, Gemini CLI, Cursor, and many others already support MCP + - **Our MCP server already exposes the full API**: tasks, kanban, review, messages, cross-team, processes + - **External agents don't need our prompts** — they bring their own intelligence. They just need tools to interact with our kanban board + - **The user experience is**: open our app, see the kanban board, agents from different providers create tasks, update statuses, send messages, request reviews — all visible on the same board + +What's missing from the current MCP server for this to work: +1. **Team creation/config via MCP** — currently only `team_launch`/`team_stop` exist as runtime tools; need `team_create_config` MCP tool +2. **Member registration via MCP** — external agents need to register themselves as team members without Claude Code's `TeamCreate` built-in +3. **Agent identification** — MCP tools need a way for agents to identify themselves (which provider, which model) +4. **Task assignment notifications** — when a task is assigned to an external agent, something needs to notify that agent (webhook? polling? SSE?) +5. **Standalone MCP server mode** — currently our MCP server is spawned as a child process by `TeamMcpConfigBuilder`. For external agents, it needs to run standalone (it already can via `agent-teams-mcp` bin) + +- **Verdict**: Best bang for the buck. Low risk, high reuse, ships fast, provider-agnostic by design + +### 7. Hybrid: Native Claude Code + MCP Server for Others (RECOMMENDED) + +**Confidence: 9/10 | Reliability: 9/10** + +- **What it is**: Keep Claude Code Agent Teams as the primary (optimized) path. Enhance MCP server as the universal integration point for all other agents. Eventually, even Claude Code agents could use MCP tools (they already do via `--mcp-config`) +- **Effort**: 3-4 weeks for Phase 1, incremental thereafter +- **What breaks**: Nothing +- **What stays**: Everything +- **Reusable code**: 100% +- **Risk**: Very Low + +#### Why this is the right answer + +1. **We already have 90% of the infrastructure**: + - `mcp-server/` with 30+ tools covering tasks, kanban, review, messages, cross-team, processes + - `agent-teams-controller` as provider-agnostic data layer + - HTTP control API for launch/stop + - File watcher system that detects changes from ANY source (not just Claude Code) + +2. **Claude Code is our strongest path — don't break it**: + - `TeamProvisioningService` (8K LOC) is battle-tested, handles edge cases (auth retry, stall detection, post-compact context recovery, tool approval) + - The prompt engineering works. It took months to tune delegation-first behavior, task board discipline, review workflow, cross-team messaging + - Replacing this with a generic abstraction would lose all these optimizations + +3. **MCP is the industry standard for tool interop**: + - Claude Code already uses our MCP tools via `--mcp-config` + - OpenAI Codex supports MCP (announced 2025) + - Google Gemini supports MCP + - Cursor/Windsurf support MCP + - Any MCP-capable agent can connect today + +4. **The prompt is NOT a blocker for other agents**: + - Our prompts teach Claude Code agents how to use MCP tools (`task_create`, `kanban_set_column`, etc.) + - External agents using MCP don't need our prompts — MCP tool descriptions ARE the prompt + - Each MCP tool already has a `description` field that tells any agent what it does + +5. **Incremental delivery**: + - Phase 1: Publish `agent-teams-mcp` as standalone npm package, add missing tools + - Phase 2: Add UI support for "external member" type, show provider badge + - Phase 3: Add notification/polling mechanism for task assignments + - Phase 4: Optionally abstract `TeamProvisioningService` for a second native provider + +--- + +## Implementation Plan + +### Phase 1: MCP Server Enhancement (Week 1-2) + +**Goal**: Any MCP-capable agent can join an existing team and work on tasks. + +New MCP tools to add to `mcp-server/src/tools/`: + +``` +team_join — register external agent as team member +team_leave — unregister from team +team_list_teams — discover available teams +team_get_config — get team configuration +member_register — register with provider/model metadata +member_heartbeat — keepalive for external agents +task_poll_assigned — poll for newly assigned tasks (for agents without push) +task_claim — claim an unassigned task +``` + +Files to modify: +- `mcp-server/src/tools/index.ts` — register new tool modules +- `mcp-server/src/tools/memberTools.ts` — NEW: member lifecycle tools +- `mcp-server/src/tools/teamDiscoveryTools.ts` — NEW: team discovery +- `mcp-server/package.json` — prepare for standalone npm publish +- `mcp-server/src/agent-teams-controller.d.ts` — extend controller types if needed + +Files unchanged (0 modifications to core): +- `src/main/services/team/TeamProvisioningService.ts` — untouched +- `src/main/services/team/TeamDataService.ts` — untouched +- `src/main/ipc/teams.ts` — untouched +- All prompt engineering — untouched + +### Phase 2: UI Support for External Agents (Week 2-3) + +**Goal**: External agents appear on the kanban board with provider badges. + +- `src/shared/types/team.ts` — add `provider?: string`, `model?: string` to `TeamMember` +- `src/renderer/components/team/` — show provider icon/badge next to member name +- `src/main/services/team/TeamDataService.ts` — recognize external members in data reads +- File watchers already detect changes from any source — no changes needed + +### Phase 3: Notification Mechanism (Week 3-4) + +**Goal**: External agents get notified of task assignments without polling. + +Options (ranked): +1. **SSE endpoint** — `GET /api/teams/:teamName/events` — server-sent events for task changes. Reliability: 8/10, Confidence: 8/10 +2. **Webhook** — configure callback URL per member. Reliability: 7/10, Confidence: 7/10 +3. **Polling** — `task_poll_assigned` MCP tool (already planned in Phase 1). Reliability: 9/10, Confidence: 9/10 + +Recommend: Start with polling (simplest), add SSE later. + +### Phase 4: Optional Native Provider (Week 6+, if demand exists) + +**Goal**: Add a second native CLI provider (e.g., Codex) with process management. + +Only NOW would we extract the `AgentRuntime` abstraction from option 5, but scoped: +- Extract binary resolution from `ClaudeBinaryResolver` into `CliProvider` interface +- Extract process spawn from `TeamProvisioningService.createTeam()`/`launchTeam()` into provider-specific implementations +- Keep `TeamProvisioningService` as `ClaudeProvisioningService` (rename) +- Create `CodexProvisioningService` implementing same interface + +This is the expensive part (6-8 weeks), but by Phase 4 we'll know if there's actual demand. + +--- + +## Comparison Table + +| Criterion | Mastra | MCO | Overstory | any-agent | AgentRuntime | MCP-Only | **Hybrid** | +|---|---|---|---|---|---|---|---| +| Effort (weeks) | 8-12 | 6-8 | 6-10 | 10-14 | 8-12 | 2-3 | **3-4** | +| Code reuse | 20% | 30% | 35% | 15% | 55% | 100% | **100%** | +| Risk | Very High | High | High | Very High | Medium-High | Low | **Very Low** | +| Breaks existing? | Yes | Yes | Yes | Yes | Partially | No | **No** | +| Multi-provider quality | Good | Low-Med | Good | Good | Good | Good | **Good** | +| Incremental? | No | No | No | No | Partially | Yes | **Yes** | +| Ships fast? | No | No | No | No | No | Yes | **Yes** | +| Keeps Claude optimized? | No | No | No | No | Partially | Yes | **Yes** | +| Industry standard? | Custom | Custom | Custom | Python | Custom | MCP | **MCP** | +| Confidence | 4/10 | 3/10 | 5/10 | 3/10 | 6/10 | 8/10 | **9/10** | +| Reliability | 5/10 | 4/10 | 6/10 | 3/10 | 7/10 | 8/10 | **9/10** | + +--- + +## Prompt Engineering Analysis + +### What percentage is Claude-specific vs generic? + +| Prompt Section | Claude-Specific? | LOC | Purpose | +|---|---|---|---| +| `buildProvisioningPrompt()` | **100% Claude** | ~95 | Uses TeamCreate built-in, Task tool for spawning | +| `buildMemberSpawnPrompt()` | **30% Claude** | ~35 | MCP tool calls are generic; `Task tool` spawn is Claude | +| `buildPersistentLeadContext()` | **20% Claude** | ~100 | Constraints are generic; `TeamCreate`/`TeamDelete` refs are Claude | +| `buildTeamCtlOpsInstructions()` | **0% Claude** | ~100 | Pure MCP tool examples — any agent can use these | +| `buildActionModeProtocol()` | **0% Claude** | ~30 | Generic action mode behavior | +| `buildAgentBlockUsagePolicy()` | **50% Claude** | ~30 | Agent block format is Claude-specific; concept is generic | +| `buildReconnectMemberSpawnPrompt()` | **30% Claude** | ~50 | Similar to spawn prompt | + +**Overall**: ~35% of prompt content is Claude-specific (spawning, built-in tools). ~65% is generic task management behavior that any agent needs (use MCP tools, update task status, post comments before completing, notify lead after completion). + +**For MCP-based external agents**: The MCP tool `description` fields already serve as the "prompt". External agents don't need our big prompt — they discover tools via MCP protocol and use tool descriptions. The only thing missing is a "bootstrap briefing" MCP tool that gives a new agent its role, workflow instructions, and team context — and we already have `member_briefing` for this. + +--- + +## Risk Analysis for Recommended Approach (Hybrid) + +| Risk | Probability | Impact | Mitigation | +|---|---|---|---| +| MCP adoption stalls | Low | Medium | MCP is already adopted by Claude, Codex, Gemini, Cursor | +| External agents can't follow task workflow | Medium | Low | `member_briefing` provides onboarding; tool descriptions guide behavior | +| Performance with many external agents | Low | Medium | MCP server is lightweight; file I/O is the bottleneck (same as now) | +| Breaking changes in MCP protocol | Very Low | High | MCP spec is stable (v1.0+), FastMCP library handles protocol | +| External agent quality varies | High | Medium | This is a feature, not a bug — user chooses which agents to use | +| Path coupling (`~/.claude/`) | Low | Low | `claudeDir` parameter already supported in all MCP tools | + +--- + +## Final Recommendation + +**Go with Option 7: Hybrid (Claude Code native + MCP for others).** + +Reasoning: +1. **Zero risk to existing product** — nothing changes for Claude Code users +2. **Fastest time to market** — 3-4 weeks for meaningful multi-provider support +3. **100% code reuse** — no refactoring, no migration, no breaking changes +4. **Industry standard** — MCP is the protocol all major AI tools are converging on +5. **Natural evolution** — Phase 4 (native providers) can happen later if justified by demand +6. **Our MCP server already works** — 30+ tools, battle-tested with Claude Code Agent Teams +7. **Competitive advantage** — no one else has a kanban board + MCP server combination + +The key insight is: **we don't need to abstract our process management layer to support multiple providers**. Instead, we expose our **data layer** (tasks, kanban, reviews, messages) via MCP, and let each agent provider bring their own process management. Our app becomes the **collaboration hub** — the kanban board where all agents converge, regardless of provider. + +--- + +## Appendix: Key Source Files Referenced + +| File | LOC | Role | +|---|---|---| +| `src/main/services/team/TeamProvisioningService.ts` | 7,982 | Process lifecycle, prompt engineering, stream-json protocol | +| `src/main/services/team/TeamDataService.ts` | 1,953 | Data reads, controller integration | +| `src/main/ipc/teams.ts` | 2,973 | IPC handlers for all team operations | +| `src/main/services/team/ClaudeBinaryResolver.ts` | 292 | Claude binary resolution | +| `src/main/services/team/TeamInboxWriter.ts` | 80+ | File-based inbox writes | +| `src/main/services/team/TeamMcpConfigBuilder.ts` | 228 | MCP config generation for Claude | +| `src/main/services/team/CrossTeamService.ts` | 60+ | Cross-team messaging | +| `src/main/services/team/actionModeInstructions.ts` | 51 | Action mode protocol | +| `src/main/http/teams.ts` | 160+ | HTTP control API | +| `src/main/utils/childProcess.ts` | 182 | CLI spawn/kill utilities | +| `mcp-server/src/index.ts` | 24 | MCP server entry | +| `mcp-server/src/controller.ts` | 19 | Controller factory | +| `mcp-server/src/tools/taskTools.ts` | 501 | Task MCP tools | +| `mcp-server/src/tools/kanbanTools.ts` | 82 | Kanban MCP tools | +| `mcp-server/src/tools/reviewTools.ts` | 104 | Review MCP tools | +| `mcp-server/src/tools/messageTools.ts` | 60 | Message MCP tools | +| `mcp-server/src/tools/processTools.ts` | 89 | Process MCP tools | +| `mcp-server/src/tools/crossTeamTools.ts` | 81 | Cross-team MCP tools | +| `mcp-server/src/tools/runtimeTools.ts` | 78 | Runtime MCP tools | +| `src/types/agent-teams-controller.d.ts` | 101 | Controller type definitions | +| `src/shared/types/team.ts` | 100+ | Shared team types | diff --git a/docs/research/claude-coupling-analysis.md b/docs/research/claude-coupling-analysis.md new file mode 100644 index 00000000..da8e4b34 --- /dev/null +++ b/docs/research/claude-coupling-analysis.md @@ -0,0 +1,536 @@ +# Claude Coupling Analysis + +Comprehensive analysis of how tightly the Claude Agent Teams UI codebase is coupled to Claude/Claude Code/Claude Agent Teams. The goal is to understand the effort required to abstract the AI provider layer to support other agents (OpenAI Codex, Gemini CLI, etc.). + +**Date**: 2026-03-24 +**Branch**: `dev` +**Commit**: `08be859` + +--- + +## Summary Table + +| Area | Coupling (1-10) | Effort | Key Blockers | +|---|---|---|---| +| 1. Process Management | **9** | High | Binary name, CLI flags, kill semantics | +| 2. Protocol / Communication | **10** | High | stream-json is Claude-proprietary | +| 3. Message Parsing (JSONL) | **9** | High | Schema is Claude Code's internal format | +| 4. Team Management | **10** | Very High | Agent Teams is a Claude Code feature | +| 5. Session Data / Paths | **9** | Medium | `~/.claude/` hardcoded everywhere | +| 6. Authentication | **8** | Medium | `claude auth status`, GCS binary dist | +| 7. MCP Integration | **5** | Low | MCP is a cross-vendor standard | +| 8. UI Components | **6** | Medium | Branding strings, CLAUDE.md references | +| 9. Types / Interfaces | **8** | High | Types mirror Claude Code JSONL schema | +| 10. Configuration | **7** | Medium | Path constants, env vars, config files | +| **Pricing / Cost** | **7** | Medium | pricing.json is Claude-model-centric | +| **Model Parsing** | **9** | Low | `parseModelString()` only handles `claude-*` | + +**Overall Coupling Score: 8.3 / 10** — Deeply coupled to Claude Code at nearly every layer. + +--- + +## 1. Process Management + +**Coupling: 9/10 | Effort: High** + +### Specific Files +- `src/main/services/team/ClaudeBinaryResolver.ts` — resolves the `claude` binary across platforms +- `src/main/utils/childProcess.ts` — `spawnCli()` / `execCli()` wrappers inject `CLAUDE_HOOK_JUDGE_MODE` env var +- `src/main/services/team/TeamProvisioningService.ts` — spawns `claude` with Claude-specific flags +- `src/main/services/infrastructure/CliInstallerService.ts` — downloads `claude` binary from GCS +- `src/main/services/schedule/ScheduledTaskExecutor.ts` — spawns `claude -p` for scheduled tasks + +### What's Claude-specific +1. **Binary name**: `ClaudeBinaryResolver` searches for `claude` binary across PATH, NVM, platform-specific dirs +2. **CLI flags**: `--input-format stream-json`, `--output-format stream-json`, `--verbose`, `--setting-sources`, `--mcp-config`, `--disallowedTools`, `--dangerously-skip-permissions`, `--permission-prompt-tool`, `--model`, `--effort`, `--worktree`, `--resume`, `--no-session-persistence`, `--max-turns`, `--permission-mode` +3. **Env var**: `CLAUDE_HOOK_JUDGE_MODE: 'true'` injected into every CLI process +4. **Env var**: `CLAUDE_CONFIG_DIR` set in `buildEnrichedEnv()` +5. **Env var override**: `CLAUDE_CLI_PATH` for custom binary location +6. **Kill semantics**: `killTeamProcess()` uses SIGKILL specifically because Claude CLI cleanup on SIGTERM deletes team files +7. **GCS distribution**: `CliInstallerService` downloads from `https://storage.googleapis.com/claude-code-dist-.../claude-code-releases` +8. **Version command**: `claude --version` expected to output `"X.Y.Z (Claude Code)"` +9. **Install command**: `claude install` for shell integration + +### Abstraction Approach +Create a `CliProvider` interface: +```typescript +interface CliProvider { + name: string; + resolveBinaryPath(): Promise; + buildSpawnArgs(options: SpawnOptions): string[]; + buildEnv(binaryPath: string): NodeJS.ProcessEnv; + parseVersionOutput(stdout: string): string; + getKillSignal(): NodeJS.Signals; + install(): Promise; + checkAuth(): Promise; +} +``` +Each provider (ClaudeCliProvider, CodexCliProvider, GeminiCliProvider) implements this. `ClaudeBinaryResolver` becomes `ClaudeCliProvider.resolveBinaryPath()`. + +--- + +## 2. Protocol / Communication + +**Coupling: 10/10 | Effort: High** + +### Specific Files +- `src/main/services/team/TeamProvisioningService.ts` (lines 126-132, 2742-2980, 4849-5290) — stream-json parser +- `src/renderer/utils/streamJsonParser.ts` — renderer-side stream-json log parsing +- `src/renderer/components/team/CliLogsRichView.tsx` — renders stream-json output +- `src/shared/utils/teammateMessageParser.ts` — parses `` XML format + +### What's Claude-specific +1. **stream-json protocol**: Claude Code's proprietary newline-delimited JSON over stdin/stdout + - Input: `{"type":"user","message":{"role":"user","content":[...]}}\n` + - Output types: `user`, `assistant`, `control_request`, `result`, `system` + - `result.success` = turn complete, `result.error` = failure + - `control_request` for tool approval prompts +2. **Message envelope**: `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"..."}]}}` +3. **Teammate message format**: XML tags `content` +4. **Preflight ping**: `claude -p "Output only the single word PONG." --output-format text --model haiku --max-turns 1 --no-session-persistence` +5. **Tool approval**: `control_request` type with `tool_input`, `tool_name`, approval via stdin + +### Abstraction Approach +This is the hardest area. Create a `CliProtocol` interface: +```typescript +interface CliProtocol { + formatInputMessage(text: string): string; + parseOutputLine(line: string): ParsedOutputMessage; + isResultSuccess(msg: ParsedOutputMessage): boolean; + isResultError(msg: ParsedOutputMessage): boolean; + isToolApprovalRequest(msg: ParsedOutputMessage): ToolApprovalRequest | null; + formatToolApprovalResponse(approved: boolean): string; + getProtocolFlags(): string[]; // e.g. ['--input-format', 'stream-json', ...] +} +``` +Each agent's protocol would need a distinct implementation. OpenAI Codex uses a different protocol (REST-based sandbox execution, not stdin/stdout). This would require major architectural changes. + +--- + +## 3. Message Parsing (JSONL) + +**Coupling: 9/10 | Effort: High** + +### Specific Files +- `src/main/types/jsonl.ts` — raw JSONL entry types (Claude Code session file format) +- `src/main/types/messages.ts` — parsed message types and type guards +- `src/main/types/domain.ts` — domain types referencing `~/.claude/projects/` structure +- `src/main/types/chunks.ts` — chunk building from parsed messages +- `src/main/utils/jsonl.ts` — JSONL file parser +- `src/main/constants/messageTags.ts` — ``, ``, `` tags + +### What's Claude-specific +1. **JSONL schema**: Entry types (`user`, `assistant`, `system`, `summary`, `file-history-snapshot`, `queue-operation`) are Claude Code's internal format +2. **Content blocks**: `text`, `thinking`, `tool_use`, `tool_result`, `image` — follows Anthropic Messages API schema +3. **`thinking` + `signature`**: Extended thinking is an Anthropic-specific feature +4. **`isMeta` flag**: Claude Code's internal convention for distinguishing real user messages from tool results +5. **`isSidechain`**: Claude Code's flag for subagent messages +6. **`stop_reason`**: `end_turn`, `tool_use`, `max_tokens`, `stop_sequence` — Anthropic API values +7. **XML tags in content**: ``, ``, ``, `` are Claude Code's internal message wrapping +8. **`` model**: Claude Code's marker for system-generated placeholders +9. **`isCompactSummary`**: Claude Code's context compaction mechanism +10. **Usage metadata**: `cache_read_input_tokens`, `cache_creation_input_tokens` — Anthropic cache API + +### Abstraction Approach +Create a `SessionParser` interface that converts provider-specific session data to a normalized `ParsedMessage`: +```typescript +interface SessionDataProvider { + parseSessionFile(path: string): AsyncIterable; + isRealUserMessage(msg: ParsedMessage): boolean; + isToolCall(block: ContentBlock): boolean; + extractToolResult(msg: ParsedMessage): ToolResult | null; +} +``` +The existing `ParsedMessage` type is actually reasonably generic (it has `toolCalls`, `toolResults`, `content`). The provider-specific part is the parsing FROM the raw format TO `ParsedMessage`. New providers would implement different parsers. + +--- + +## 4. Team Management + +**Coupling: 10/10 | Effort: Very High** + +### Specific Files +- `src/main/services/team/TeamProvisioningService.ts` (~7800 lines) — the monolith +- `agent-teams-controller/` — workspace package for file-level team operations +- `src/main/services/team/*.ts` (~35 files) — team data, inbox, tasks, kanban, review, cross-team +- `src/shared/types/team.ts` — TeamConfig, TeamTask, SendMessageRequest, etc. +- `src/main/ipc/teams.ts` — ~65 IPC handlers for team operations +- `src/shared/utils/leadDetection.ts` — detects team lead by `agentType` values + +### What's Claude-specific +1. **Agent Teams is a Claude Code feature**: `TeamCreate`, `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet`, `SendMessage`, `TeamDelete` are Claude Code CLI tools +2. **Team file structure**: `~/.claude/teams/{teamName}/config.json`, `inboxes/{member}.json`, `kanban-state.json`, `processes.json`, `members.meta.json` +3. **Task file structure**: `~/.claude/tasks/{teamName}/{taskId}.json` +4. **Inbox protocol**: File-based message passing — lead reads stdin, teammates read inbox files +5. **Lead/teammate distinction**: Lead uses stream-json, teammates are independent CLI processes +6. **Tool blocking**: `--disallowedTools TeamDelete,TodoWrite` +7. **`agentType` values**: `team-lead`, `lead`, `orchestrator`, `general-purpose` — Claude Code internal values +8. **`teammate_spawned` tool results**: How team member processes are detected +9. **Cross-team communication**: `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` +10. **Action mode instructions**: Custom protocol text injected into team prompts +11. **`agent-teams-controller` package**: Pure JS module that reads Claude Code's team filesystem directly + +### Abstraction Approach +This is by far the hardest area. Agent Teams is a unique Claude Code feature with no equivalent in other CLI agents. Options: +- **Option A**: Keep team management as Claude-only feature, abstract only session viewing +- **Option B**: Build a generic team orchestration layer that wraps different agent CLIs. Would need to implement inbox/task/kanban semantics independently of Claude Code. +- **Option C**: Make team management pluggable — each provider declares `supportsTeams: boolean` and provides a `TeamOrchestrator` implementation if supported + +Option A is the most realistic short-term approach. + +--- + +## 5. Session Data / Paths + +**Coupling: 9/10 | Effort: Medium** + +### Specific Files +- `src/main/utils/pathDecoder.ts` — all path construction (`~/.claude/projects/`, `~/.claude/todos/`, `~/.claude/teams/`, `~/.claude/tasks/`) +- `src/main/services/discovery/ProjectScanner.ts` — scans `~/.claude/projects/` +- `src/main/services/infrastructure/FileWatcher.ts` — watches `~/.claude/projects/`, `~/.claude/todos/`, `~/.claude/teams/`, `~/.claude/tasks/` +- `src/main/services/infrastructure/SshConnectionManager.ts` — hardcodes `~/.claude/projects` for remote +- `src/main/services/infrastructure/ConfigManager.ts` — config at `~/.claude/claude-devtools-config.json` +- `src/main/constants/worktreePatterns.ts` — detects `.claude/worktrees/` pattern + +### What's Claude-specific +1. **Base path**: `~/.claude/` as root for all data +2. **Path encoding**: `/Users/name/project` → `-Users-name-project` (Claude Code's convention) +3. **Session files**: `~/.claude/projects/{encoded-path}/{uuid}.jsonl` +4. **Subagent files**: `~/.claude/projects/{path}/{session_uuid}/agent_{uuid}.jsonl` +5. **Todo files**: `~/.claude/todos/{sessionId}.json` +6. **Team files**: `~/.claude/teams/{teamName}/...` +7. **Task files**: `~/.claude/tasks/{teamName}/{taskId}.json` +8. **Config**: `~/.claude/claude-devtools-config.json` (our config, stored in Claude's directory) +9. **SSH remote**: Hardcoded `/home/{user}/.claude/projects`, `/Users/{user}/.claude/projects`, `/root/.claude/projects` +10. **Worktree patterns**: `.claude/worktrees/` as a known source + +### Abstraction Approach +Path resolution is already partially abstracted via `getClaudeBasePath()` with override support (`setClaudeBasePathOverride`). Extend to: +```typescript +interface DataPathProvider { + getBasePath(): string; // ~/.claude/, ~/.codex/, etc. + getProjectsPath(): string; // {base}/projects/ + getSessionPath(projectId: string, sessionId: string): string; + getSubagentPath(projectId: string, sessionId: string): string; + encodeProjectPath(absolutePath: string): string; + decodeProjectPath(encoded: string): string; +} +``` +Medium effort because path functions are centralized in `pathDecoder.ts`. The SSH remote paths would need provider-specific resolution. + +--- + +## 6. Authentication + +**Coupling: 8/10 | Effort: Medium** + +### Specific Files +- `src/main/services/infrastructure/CliInstallerService.ts` — `claude auth status --output-format json`, `claude --version` +- `src/shared/types/cliInstaller.ts` — `CliInstallationStatus.authLoggedIn`, `authMethod` +- `src/main/utils/cliAuthDiagLog.ts` — diagnostic logging for auth issues +- `src/renderer/components/dashboard/CliStatusBanner.tsx` — shows login status + +### What's Claude-specific +1. **Auth check**: `claude auth status --output-format json` — returns `{loggedIn: boolean, authMethod: string}` +2. **Auth method types**: `"oauth_token"`, `"api_key"` — Claude-specific +3. **Binary distribution**: GCS bucket `claude-code-dist-*` with platform-specific binaries +4. **Install flow**: Downloads binary → SHA256 verify → `claude install` for shell integration +5. **Version parsing**: `"2.1.59 (Claude Code)"` format +6. **Preflight auth check**: Runs `claude -p "PONG"` to verify auth works + +### Abstraction Approach +```typescript +interface CliInstallerProvider { + getLatestVersion(): Promise; + downloadBinary(platform: CliPlatform): Promise; // returns temp path + installBinary(binaryPath: string): Promise; + checkVersion(binaryPath: string): Promise; + checkAuth(binaryPath: string): Promise; +} +``` + +--- + +## 7. MCP Integration + +**Coupling: 5/10 | Effort: Low** + +### Specific Files +- `src/main/services/team/TeamMcpConfigBuilder.ts` — builds MCP config JSON for team processes +- `src/main/services/extensions/install/McpInstallService.ts` — installs MCP servers +- `src/shared/types/extensions/mcp.ts` — MCP types +- `mcp-server/` — built-in MCP server for the app + +### What's Claude-specific +1. **Config file location**: `.claude.json` in home dir, `.mcp.json` in project +2. **CLI flag**: `--mcp-config` to pass config path to CLI +3. **Config format**: Standard MCP format (`{mcpServers: {name: {command, args}}}`) +4. **Built-in server**: `mcp-server/` is our own — not Claude-specific + +### What's NOT Claude-specific +MCP (Model Context Protocol) is becoming a cross-vendor standard. The protocol itself is vendor-neutral. The config format may vary by agent but the server implementation is portable. + +### Abstraction Approach +MCP is already the most abstracted area. The only coupling is the config file naming (`.claude.json`) and the `--mcp-config` flag. A provider interface would specify how to pass MCP config to the CLI. + +--- + +## 8. UI Components + +**Coupling: 6/10 | Effort: Medium** + +### Specific Files +- `src/renderer/index.html` — title "Claude Agent Teams UI" +- `src/renderer/components/common/ErrorBoundary.tsx` — CSS classes `bg-claude-dark-bg`, `text-claude-dark-text` +- `src/renderer/components/team/ClaudeLogsDialog.tsx`, `ClaudeLogsPanel.tsx`, `ClaudeLogsSection.tsx`, `ClaudeLogsFilterPopover.tsx`, `useClaudeLogsController.ts` — "Claude Logs" feature naming +- `src/renderer/types/claudeMd.ts` — CLAUDE.md tracking types +- `src/renderer/utils/claudeMdTracker.ts` (70 occurrences) — CLAUDE.md context tracking +- `src/renderer/utils/contextTracker.ts` (56 occurrences) — references CLAUDE.md sources +- `src/renderer/components/chat/SessionContextPanel/` — CLAUDE.md section +- `src/renderer/components/settings/sections/GeneralSection.tsx` (69 occurrences) — "Claude Root" settings +- `src/renderer/components/dashboard/CliStatusBanner.tsx` — "Claude CLI" status +- `src/renderer/index.css` — comments mentioning "Claude Code" +- `src/shared/constants/cli.ts` — `CLI_NOT_FOUND_MESSAGE = 'Claude CLI not found...'` + +### What's Claude-specific +1. **Branding strings**: "Claude Agent Teams UI", "Claude CLI", "Claude Logs", "Claude Root" +2. **CSS theme variables**: `claude-dark-bg`, `claude-dark-text`, `claude-dark-border`, `claude-dark-surface` in ErrorBoundary +3. **CLAUDE.md feature**: The entire CLAUDE.md tracking system (types, tracker, UI) is Claude Code specific +4. **"Claude Logs"**: 5+ components for viewing CLI logs named "ClaudeLogs*" +5. **Settings**: "Local Claude Root" setting for `~/.claude` override + +### What's NOT Claude-specific +- Chat rendering (UserChunk, AIChunk, SystemChunk) is generic +- Kanban board UI is generic +- Team member list, task management UI is generic +- Tool call visualization is generic (tool_use/tool_result pattern is shared across LLM providers) + +### Abstraction Approach +1. Replace hardcoded strings with a config/branding module +2. Rename `ClaudeLogs*` → `CliLogs*` or `AgentLogs*` +3. Rename `claudeMdTracker` → `instructionFileTracker` (provider specifies filename pattern) +4. CSS variable renaming is mechanical (`claude-dark-*` → `app-dark-*`) +5. "Claude Root" → "Agent Data Directory" + +--- + +## 9. Types / Interfaces + +**Coupling: 8/10 | Effort: High** + +### Specific Files +- `src/main/types/jsonl.ts` — `ChatHistoryEntry` union follows Claude Code JSONL exactly +- `src/main/types/messages.ts` — `ParsedMessage` with Claude-specific fields (`isMeta`, `isSidechain`, `isCompactSummary`) +- `src/main/types/domain.ts` — `MessageType`, `TokenUsage` with `cache_read_input_tokens` +- `src/shared/types/team.ts` — Team types entirely Claude Agent Teams specific +- `src/shared/types/api.ts` — API surface exposes Claude-specific session/team types +- `src/shared/utils/modelParser.ts` — parses `claude-*` model strings only +- `src/shared/utils/pricing.ts` — pricing data is Claude/Anthropic model centric + +### What's Claude-specific +1. **Content block types**: `thinking` with `signature` field — Anthropic extended thinking +2. **Token usage fields**: `cache_read_input_tokens`, `cache_creation_input_tokens` — Anthropic prompt caching +3. **Model string format**: `claude-{family}-{major}-{minor}-{date}` and old `claude-{major}-{family}-{date}` +4. **Model families**: `sonnet`, `opus`, `haiku` — Anthropic model names +5. **`isMeta`/`isSidechain`**: Claude Code's internal conventions +6. **`stop_reason` values**: `end_turn`, `tool_use`, `max_tokens`, `stop_sequence` +7. **Pricing data**: `resources/pricing.json` is Anthropic-model-only (includes Bedrock/Vertex variants) + +### Abstraction Approach +The `ParsedMessage` type is actually fairly close to a generic representation. Key changes: +- Make `thinking` content optional/provider-specific +- Generalize token usage (some fields are Anthropic-specific) +- `modelParser.ts` needs a provider-aware implementation +- Pricing needs multi-provider support (or provider-supplied pricing) + +--- + +## 10. Configuration + +**Coupling: 7/10 | Effort: Medium** + +### Specific Files +- `src/main/services/infrastructure/ConfigManager.ts` — stores config in `~/.claude/claude-devtools-config.json` +- `src/main/utils/cliEnv.ts` — sets `CLAUDE_CONFIG_DIR` env var +- `src/main/utils/pathDecoder.ts` — `getClaudeBasePath()` with override support +- `src/shared/utils/cliArgsParser.ts` — `PROTECTED_CLI_FLAGS` are Claude CLI flags +- `src/main/ipc/config.ts` — configuration IPC handlers +- `src/main/services/team/TeamMcpConfigBuilder.ts` — `.claude.json` user MCP config + +### What's Claude-specific +1. **Config dir**: `~/.claude/` as base +2. **Config filename**: `claude-devtools-config.json` +3. **Env vars**: `CLAUDE_CONFIG_DIR`, `CLAUDE_CLI_PATH`, `CLAUDE_HOOK_JUDGE_MODE` +4. **Protected flags**: `--input-format`, `--output-format`, `--setting-sources`, `--mcp-config`, `--disallowedTools`, `--verbose` +5. **Settings sources**: `user,project,local` — Claude CLI setting hierarchy +6. **User config files**: `.claude.json` (MCP), `~/.claude/settings.json` + +### Abstraction Approach +Already partially abstracted (`setClaudeBasePathOverride` exists). Extend: +```typescript +interface ProviderConfig { + basePath: string; + configFileName: string; + envVars: Record; + protectedFlags: Set; + settingSources?: string; +} +``` + +--- + +## Additional Coupling: `agent-teams-controller` Package + +**Coupling: 10/10 | Effort: High** + +The `agent-teams-controller/` workspace package is a pure JS module that directly reads/writes Claude Code's team filesystem: +- `runtimeHelpers.js`: `getPaths()` returns `~/.claude/teams/{name}/`, `~/.claude/tasks/{name}/` +- `context.js`: `createControllerContext({teamName, claudeDir})` +- `tasks.js`, `kanban.js`, `review.js`, `messages.js`, etc. — all operate on Claude's file structures + +This package would need to be either: +- Made provider-aware (different file layouts per provider) +- Replaced with a generic team data layer + +--- + +## Estimated Overall Effort for Full Abstraction + +| Phase | Scope | Estimated Effort | +|---|---|---| +| **Phase 1**: Session viewing only | Path abstraction + JSONL parser + model parser | 2-3 weeks | +| **Phase 2**: UI de-branding | Rename strings, CSS vars, component names | 1 week | +| **Phase 3**: CLI provider interface | Binary resolution + auth + install | 2 weeks | +| **Phase 4**: Protocol abstraction | stream-json → generic protocol layer | 3-4 weeks | +| **Phase 5**: Team management abstraction | Generic orchestration layer | 4-8 weeks | +| **Total** | Full multi-provider support | 12-18 weeks | + +--- + +## Recommended Abstraction Strategy + +### Priority Order (what to do first) + +1. **Paths first** (low risk, high reward) — `pathDecoder.ts` already has override support. Make `getBasePath()` provider-aware. This unblocks session viewing for other agents. + +2. **Session parser second** — Create `SessionDataProvider` interface. The existing `ParsedMessage` type works as the normalized target. Each provider implements a parser FROM their raw format TO `ParsedMessage`. + +3. **Model/pricing third** — Make `parseModelString()` and pricing lookup provider-aware. Use a registry pattern where each provider registers its models. + +4. **CLI provider fourth** — Abstract binary resolution, auth, install, spawning. This is where protocol differences become critical. + +5. **Team management last** — This is the hardest and most Claude-specific feature. Consider keeping it as a Claude-only feature initially. + +### What's Hardest + +1. **stream-json protocol** — This is Claude Code's proprietary stdin/stdout protocol. Other agents use completely different paradigms (OpenAI Codex uses sandboxed REST API, Gemini CLI may use different protocol). Abstracting this requires a fundamental architectural decision about how the app communicates with agents. + +2. **Agent Teams** — No other CLI agent has an equivalent feature. The entire team management subsystem (~35 service files, ~65 IPC handlers, controller package) is built around Claude Code's Agent Teams. Supporting multi-agent orchestration for other providers would essentially mean building this from scratch. + +3. **JSONL session format** — Claude Code's JSONL format is deeply embedded in the codebase (types, parsers, chunk builders, context trackers). While `ParsedMessage` serves as a reasonable intermediary, the raw parsing layer touching 10+ files would need provider-specific implementations. + +### What's Easiest + +1. **MCP** — Already vendor-neutral. Only config file naming and CLI flag need adjustment. +2. **UI branding** — Mechanical string/CSS replacement. +3. **Path configuration** — Override mechanism already exists. + +--- + +## Architecture Diagram: Provider-Agnostic Layer + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Renderer (UI) │ +│ Components (generic) │ Store (generic) │ Types (generic)│ +└────────────────────────────┬────────────────────────────────┘ + │ IPC +┌────────────────────────────┴────────────────────────────────┐ +│ Provider Manager │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Session │ │ CLI │ │ Team │ │ +│ │ Provider │ │ Provider │ │ Provider │ ← Interfaces │ +│ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ +│ │ │ │ │ +│ ┌─────┴────┐ ┌─────┴────┐ ┌─────┴────────┐ │ +│ │ Claude │ │ Claude │ │ Claude Agent │ │ +│ │ JSONL │ │ CLI │ │ Teams │ ← Impls │ +│ │ Parser │ │ Spawner │ │ Orchestrator │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ Codex │ │ Codex │ │ (not │ │ +│ │ Session │ │ CLI │ │ supported) │ ← Future │ +│ │ Parser │ │ Spawner │ │ │ │ +│ └──────────┘ └──────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────┴────────────────────────────────┐ +│ Data Path Provider │ +│ ~/.claude/ │ ~/.codex/ │ ~/.gemini/ │ etc. │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Interfaces + +``` +CliProvider +├── resolveBinaryPath() → string | null +├── buildSpawnArgs(opts) → string[] +├── buildEnv(binary) → ProcessEnv +├── checkAuth(binary) → AuthStatus +├── getKillSignal() → Signals +└── getProtocolFlags() → string[] + +CliProtocol +├── formatInputMessage(text) → string +├── parseOutputLine(line) → ParsedOutput +├── isSuccess(msg) → boolean +├── isError(msg) → boolean +└── isToolApproval(msg) → ToolApproval | null + +SessionDataProvider +├── parseSessionFile(path) → AsyncIterable +├── getSessionPaths(basePath) → string[] +├── getSubagentPaths(sessionPath) → string[] +└── encodeProjectPath(path) → string + +DataPathProvider +├── getBasePath() → string +├── getProjectsPath() → string +├── getTeamsPath() → string | null +├── getSessionFilePath(project, session) → string +└── getConfigFilePath() → string + +TeamOrchestrator (optional per provider) +├── supportsTeams: boolean +├── createTeam(request) → TeamCreateResponse +├── launchTeam(request) → TeamLaunchResponse +├── sendMessage(team, request) → SendMessageResult +└── stopTeam(teamName) → void + +ModelInfoProvider +├── parseModelString(model) → ModelInfo | null +├── getModelFamilies() → string[] +├── getPricing(model) → Pricing | null +└── getContextWindow(model) → number + +InstructionFileProvider +├── getFilename() → string // "CLAUDE.md", ".codexrc", etc. +├── getGlobalPath() → string +├── getProjectPath(projectDir) → string +└── getSourceTypes() → string[] +``` + +--- + +## Conclusion + +The codebase is deeply coupled to Claude Code at approximately 8.3/10 overall. The coupling is most severe in: +1. **Team management** (10/10) — Claude Agent Teams is a unique feature with no equivalent +2. **Protocol** (10/10) — stream-json is proprietary +3. **Session data** (9/10) — JSONL format, path encoding, file structure +4. **Process management** (9/10) — Claude binary, flags, kill semantics + +The most pragmatic path to multi-provider support would be a phased approach starting with session viewing (paths + JSONL parser abstraction), which delivers value with ~3 weeks effort, before tackling the much harder protocol and team management layers. + +Full abstraction to support other agents with team management would require 12-18 weeks of focused effort, with the protocol and team management layers being the primary engineering challenges. diff --git a/docs/research/claude-kanban-dataflow.md b/docs/research/claude-kanban-dataflow.md new file mode 100644 index 00000000..fe696db9 --- /dev/null +++ b/docs/research/claude-kanban-dataflow.md @@ -0,0 +1,431 @@ +# Claude Kanban Data Flow: Full Architecture Analysis + +## Executive Summary + +Claude Code **does NOT use its own built-in Agent Teams tools** (TaskCreate, TaskUpdate, TaskList, etc.) for kanban management. Instead, our app injects a **custom MCP server** (`agent-teams-mcp`) that provides its own set of tools (`task_create`, `task_list`, `task_start`, `task_complete`, `review_request`, etc.). Claude's built-in `TaskCreate` is explicitly demoted to "optional for private planning only" via the provisioning prompt. + +The data flow is: **Claude calls MCP tools → agent-teams-controller writes JSON files to disk → fs.watch() detects changes → IPC event → React UI updates**. + +--- + +## 1. How the MCP Server Gets Injected + +### TeamMcpConfigBuilder (`src/main/services/team/TeamMcpConfigBuilder.ts`) + +When a team is created or launched, `TeamMcpConfigBuilder.writeConfigFile()` generates a temporary JSON file: + +``` +/tmp/claude-team-mcp/agent-teams-mcp-.json +``` + +Contents: +```json +{ + "mcpServers": { + "agent-teams": { + "command": "node", + "args": ["/path/to/mcp-server/index.js"] + }, + ...userMcpServers + } +} +``` + +This merges the user's `~/.claude.json` MCP servers with the injected `agent-teams` server (our server wins on name collision). + +### CLI Launch Args (`TeamProvisioningService.ts`, lines 2986-2989) + +```typescript +'--mcp-config', mcpConfigPath, +'--disallowedTools', 'TeamDelete,TodoWrite', +``` + +- `--mcp-config` points Claude CLI to our generated config +- `TeamDelete` is blocked to prevent team cleanup +- `TodoWrite` is blocked because Opus tends to use it instead of our MCP tools +- Claude's native `TaskCreate`/`TaskUpdate` are NOT blocked — they are left available but deprioritized via prompt engineering + +### The Provisioning Prompt (line 724) + +``` +- TaskCreate is optional for private planning only; do NOT use it for team-board tasks. +``` + +The prompt then explicitly instructs Claude to use MCP tools: + +``` +Task board operations — use MCP tools directly: +- Get task details: task_get { teamName: "...", taskId: "" } +- Create task: task_create { teamName: "...", subject: "...", ... } +- Start task: task_start { teamName: "...", taskId: "" } +... +``` + +--- + +## 2. What MCP Tools Exist + +### MCP Server Structure (`mcp-server/`) + +``` +mcp-server/ +├── src/ +│ ├── index.ts — FastMCP server, stdio transport +│ ├── controller.ts — wraps agent-teams-controller +│ └── tools/ +│ ├── taskTools.ts — task_create, task_list, task_get, task_set_status, task_start, +│ │ task_complete, task_set_owner, task_add_comment, task_link, etc. +│ ├── kanbanTools.ts — kanban_get, kanban_set_column, kanban_clear, kanban_add_reviewer +│ ├── reviewTools.ts — review_request, review_start, review_approve, review_request_changes +│ ├── messageTools.ts +│ ├── processTools.ts +│ ├── runtimeTools.ts +│ └── crossTeamTools.ts +``` + +### Full MCP Tool List + +| Domain | Tools | +|--------|-------| +| Task | `task_create`, `task_create_from_message`, `task_get`, `task_get_comment`, `task_list`, `task_set_status`, `task_start`, `task_complete`, `task_set_owner`, `task_add_comment`, `task_attach_file`, `task_attach_comment_file`, `task_set_clarification`, `task_link`, `task_unlink`, `member_briefing`, `task_briefing` | +| Kanban | `kanban_get`, `kanban_set_column`, `kanban_clear`, `kanban_list_reviewers`, `kanban_add_reviewer`, `kanban_remove_reviewer` | +| Review | `review_request`, `review_start`, `review_approve`, `review_request_changes` | +| Message | (message-related tools) | +| Process | (process-related tools) | +| Runtime | (runtime-related tools) | +| Cross-team | `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` | + +--- + +## 3. Data Flow: Claude MCP Tool Call → Disk + +### The Shared Library: `agent-teams-controller` + +Both the MCP server and the Electron main process use the same `agent-teams-controller` package (workspace dependency). This is a plain JS library that provides: + +```javascript +// agent-teams-controller/src/controller.js +function createController(options) { + const context = createControllerContext(options); // { teamName, paths } + return { + tasks: bindModule(context, tasks), + kanban: bindModule(context, kanban), + review: bindModule(context, review), + messages: bindModule(context, messages), + ... + }; +} +``` + +### Path Resolution + +```javascript +// agent-teams-controller/src/internal/runtimeHelpers.js +function getPaths(flags, teamName) { + const claudeDir = getClaudeDir(flags); // ~/.claude + return { + teamDir: path.join(claudeDir, 'teams', teamName), + tasksDir: path.join(claudeDir, 'tasks', teamName), + kanbanPath: path.join(claudeDir, 'teams', teamName, 'kanban-state.json'), + ... + }; +} +``` + +So tasks live in `~/.claude/tasks//.json` and kanban state lives in `~/.claude/teams//kanban-state.json`. + +### Task Creation Flow (MCP → Disk) + +1. Claude calls MCP tool: `task_create { teamName: "my-team", subject: "Fix bug" }` +2. `mcp-server/src/tools/taskTools.ts` → `getController(teamName).tasks.createTask(...)` +3. `agent-teams-controller/src/internal/tasks.js` → `taskStore.createTask(context, params)` +4. `agent-teams-controller/src/internal/taskStore.js`: + ```javascript + function writeJson(filePath, value) { + ensureDir(path.dirname(filePath)); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(value, null, 2)); + fs.renameSync(tempPath, filePath); // atomic write + } + ``` +5. Result: `~/.claude/tasks/my-team/.json` is created + +### Kanban State Flow + +The kanban state is a separate JSON file (`kanban-state.json`) in the teams directory. When Claude calls `review_request` or `kanban_set_column`, the controller writes to `~/.claude/teams//kanban-state.json`. + +--- + +## 4. Data Flow: Disk → UI + +### FileWatcher (`src/main/services/infrastructure/FileWatcher.ts`) + +There are **two separate fs.watch()** watchers: + +1. **Teams watcher** — watches `~/.claude/teams/` (recursive) + - Detects: `config.json`, `kanban-state.json`, `inboxes/*.json`, `sentMessages.json`, `processes.json` + +2. **Tasks watcher** — watches `~/.claude/tasks/` (recursive) + - Detects: `/.json` changes + +When a file changes: + +```typescript +// FileWatcher.ts, line 404 +this.tasksWatcher = fs.watch(this.tasksPath, { recursive: true }, (eventType, filename) => { + this.handleTasksChange(eventType, filename); +}); +``` + +`processTasksChange()` (line 1028) parses the filename to extract `teamName` and `detail` (e.g., "12.json"), then emits: + +```typescript +const event: TeamChangeEvent = { type: 'task', teamName, detail: relative }; +this.emit('team-change', event); +``` + +### Event Propagation (`src/main/index.ts`, line 500-608) + +`wireFileWatcherEvents()` listens for `team-change` events: + +```typescript +context.fileWatcher.on('team-change', teamChangeHandler); +``` + +For task events (`row.type === 'task'`): + +1. **Sends IPC to renderer**: `mainWindow.webContents.send(TEAM_CHANGE, event)` (line 502) +2. **Broadcasts to HTTP SSE**: `httpServer?.broadcast('team-change', event)` (line 504) +3. **Reconciles artifacts**: `teamDataService.reconcileTeamArtifacts(teamName)` (line 583) +4. **Notifies lead**: `teamDataService.notifyLeadOnTeammateTaskStart(teamName, taskId)` (line 590) +5. **Backs up task**: `teamBackupService.scheduleTaskBackup(teamName, detail)` (line 606) + +### UI Data Reading + +The renderer (React) receives `TEAM_CHANGE` events and re-fetches task data via IPC: + +- `team:getTasks` → calls `TeamTaskReader.getTasks(teamName)` which reads all `~/.claude/tasks//*.json` files +- `team:updateKanban` → calls `TeamKanbanManager.updateTask()` which reads/writes `kanban-state.json` + +The Electron `TeamTaskReader` (`src/main/services/team/TeamTaskReader.ts`) re-reads all task JSON files from disk, parses them, filters out `_internal` tasks, normalizes fields, and returns `TeamTask[]` to the renderer. + +--- + +## 5. Claude's Built-in Tools vs Our MCP Tools + +### Claude's Native Built-in Tools (Agent Teams Protocol) + +| Native Tool | Purpose | Blocked? | +|-------------|---------|----------| +| `TeamCreate` | Create team structure (config.json, state) | No — used during provisioning | +| `TaskCreate` | Create a task via CLI internal mechanism | No — but deprioritized by prompt ("optional for private planning only") | +| `TaskUpdate` | Update task via CLI internal mechanism | No — but never instructed to use | +| `TaskList` | List tasks via CLI | No — but never instructed to use | +| `TaskGet` | Get task via CLI | No — but never instructed to use | +| `SendMessage` | Send message between agents | No — actively used for inter-agent chat | +| `TeamDelete` | Delete team | **YES — blocked via --disallowedTools** | +| `TodoWrite` | Write todo items | **YES — blocked via --disallowedTools** | +| `Agent` | Spawn subagent/teammate | No — actively used to spawn teammates | + +### Our MCP Tools (agent-teams-mcp) + +| MCP Tool | Purpose | Claude instructed to use? | +|----------|---------|-------------------------| +| `task_create` | Create task on board | **YES** — primary task creation | +| `task_start` | Move task to in_progress | **YES** | +| `task_complete` | Move task to completed | **YES** | +| `task_add_comment` | Add comment to task | **YES** | +| `task_get` | Read task details | **YES** | +| `task_list` | List all tasks | **YES** | +| `review_request` | Move to review column | **YES** | +| `review_approve` | Approve review | **YES** | +| `kanban_set_column` | Move task on kanban | **YES** | + +### Why This Split? + +Claude's native `TaskCreate` writes tasks to `~/.claude/tasks//.json` too — the same location. But: + +1. **Our MCP tools add richer fields** (displayId, workIntervals, historyEvents, comments, attachments, reviewState, sourceMessage, etc.) +2. **Our MCP tools enforce board discipline** (via agent-teams-controller logic) +3. **Our kanban state is a separate file** (`kanban-state.json`) that Claude's native tools don't manage +4. **Review workflow** (review_request → review_start → review_approve / review_request_changes) is entirely our MCP layer + +Claude's native TaskCreate creates simpler task JSON files. The CLI's internal Zod schema requires `description`, `blocks`, `blockedBy` fields — our `TeamTaskWriter.createTask()` (line 68-71) ensures CLI compatibility: +```typescript +const cliCompatibleTask = { + ...task, + description: task.description ?? '', + blocks: task.blocks ?? [], + blockedBy: task.blockedBy ?? [], +}; +``` + +--- + +## 6. The Two-Writer Problem + +Both writers hit the same filesystem: + +| Writer | Writes to | When | +|--------|-----------|------| +| MCP server (agent-teams-controller) | `~/.claude/tasks//.json` | Claude calls `task_create`, `task_set_status`, etc. | +| Electron main (TeamTaskWriter) | `~/.claude/tasks//.json` | UI creates/updates tasks (user clicks "Create Task", drag-drop, etc.) | +| Claude CLI built-in | `~/.claude/tasks//.json` | If Claude uses native TaskCreate (deprioritized) | + +All three write to the same files. Concurrent writes are handled by: +- MCP: `taskStore.writeJson()` uses atomic temp+rename +- Electron: `TeamTaskWriter` uses per-file locks + `atomicWriteAsync()` +- CLI: Its own write mechanism + +There is NO cross-process lock between MCP and Electron — they rely on atomic writes and eventual consistency (file watcher detects changes within ~100ms debounce). + +--- + +## 7. Full Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Claude Code CLI (stream-json process) │ +│ │ +│ ┌─────────────────┐ ┌──────────────────────┐ │ +│ │ Built-in Tools │ │ MCP Tools │ │ +│ │ • SendMessage │ │ (agent-teams-mcp) │ │ +│ │ • Agent │ │ • task_create │ │ +│ │ • TaskCreate(*) │ │ • task_start │ │ +│ │ • Read/Write/Bash │ │ • task_complete │ │ +│ └────────┬──────────┘ │ • task_add_comment │ │ +│ │ │ • review_request │ │ +│ │ │ • kanban_set_column │ │ +│ │ └───────────┬──────────────┘ │ +│ │ │ │ +│ stdout (stream-json) agent-teams-controller │ +│ │ │ │ +└───────────┼───────────────────────────┼─────────────────────────────┘ + │ │ + │ ┌──────▼──────────────────┐ + │ │ File System (disk) │ + │ │ │ + │ │ ~/.claude/tasks// │ + │ │ ├── 1.json │ + │ │ ├── 2.json │ + │ │ └── ... │ + │ │ │ + │ │ ~/.claude/teams// │ + │ │ ├── config.json │ + │ │ ├── kanban-state.json │ + │ │ └── inboxes/ │ + │ └──────┬──────────────────┘ + │ │ + │ fs.watch() (recursive) + │ │ +┌───────────┼───────────────────────────┼─────────────────────────────┐ +│ Electron Main Process │ │ +│ │ │ │ +│ ┌────────▼──────────┐ ┌───────────▼───────────┐ │ +│ │ TeamProvisioning │ │ FileWatcher │ │ +│ │ Service │ │ • tasksWatcher │ │ +│ │ (parses stdout) │ │ • teamsWatcher │ │ +│ │ │ └───────────┬───────────┘ │ +│ │ • captureSendMsg │ │ │ +│ │ • captureSpawnEvt │ TeamChangeEvent { type: 'task' } │ +│ │ • detectSessionId │ │ │ +│ └─────────────────────┘ ┌───────────▼───────────┐ │ +│ │ wireFileWatcherEvents │ │ +│ │ (src/main/index.ts) │ │ +│ ┌──────────────────────┐ └───────────┬───────────┘ │ +│ │ TeamTaskReader │ │ │ +│ │ (re-reads all .json) │◄─────────────┤ │ +│ │ │ │ │ +│ │ TeamKanbanManager │ IPC: TEAM_CHANGE │ +│ │ (reads kanban-state) │ │ │ +│ └──────────────────────┘ │ │ +│ │ │ +│ ┌──────────────────────┐ │ │ +│ │ TeamTaskWriter │ │ │ +│ │ (UI-initiated writes) │ │ │ +│ └──────────────────────┘ │ │ +└────────────────────────────────────────┼────────────────────────────┘ + │ + IPC (webContents.send) + │ +┌────────────────────────────────────────┼────────────────────────────┐ +│ Renderer (React + Zustand) │ │ +│ │ │ +│ team-change event → refetch tasks via IPC → update Zustand store │ +│ → re-render KanbanBoard │ +│ │ +└────────────────────────────────────────────────────────────────────┘ + +(*) TaskCreate — Claude's native tool, deprioritized by prompt. + Writes to same location but lacks our rich metadata. +``` + +--- + +## 8. Key Questions Answered + +### Does Claude currently use MCP for kanban management? + +**YES.** Claude uses our `agent-teams-mcp` MCP server for ALL task board operations. The server is injected via `--mcp-config` when spawning the CLI process. Claude's native `TaskCreate` is not blocked but is explicitly deprioritized ("optional for private planning only") via the system prompt. + +### How does task data flow? + +1. **Claude calls MCP tool** (e.g., `task_create`) via the stdio MCP transport +2. **agent-teams-controller** writes a JSON file to `~/.claude/tasks//.json` (atomic write via temp+rename) +3. **fs.watch()** in FileWatcher detects the change (100ms debounce) +4. **TeamChangeEvent** `{ type: 'task', teamName, detail: '.json' }` emitted +5. **wireFileWatcherEvents()** forwards to renderer via IPC (`webContents.send('team:change', event)`) +6. **Renderer** re-fetches full task list via IPC → `TeamTaskReader.getTasks()` re-reads all JSON files +7. **Zustand store** updates → React components re-render + +### Could we replace Claude's built-in tools with MCP tools? + +**We already did, effectively.** Claude's built-in `TaskCreate`/`TaskUpdate`/`TaskList`/`TaskGet` are NOT blocked, but the prompt instructs Claude to use our MCP tools exclusively. The built-in `SendMessage` and `Agent` tools are still used (they handle inter-agent communication and teammate spawning — responsibilities our MCP server doesn't cover). + +What we CANNOT replace via MCP: +- `SendMessage` — this is Claude's native inter-agent messaging protocol +- `Agent` — this is the tool that spawns teammate subprocesses +- `TeamCreate` — this bootstraps the team structure + +### If Claude also used MCP (like Codex/Gemini would), would that unify the architecture? + +**Partially, but with important nuances:** + +**What's already unified:** +- The `agent-teams-controller` package is the single source of truth for task/kanban/review operations. Both the MCP server and the Electron main process import it. +- Any AI agent (Claude, Codex, Gemini) that connects to our MCP server gets the same tools and writes to the same files. + +**What would still differ per agent:** +- **Team spawning** — Claude uses `Agent(team_name=...)` which is proprietary. Other agents would need their own subprocess spawning mechanism. +- **Inter-agent messaging** — Claude uses `SendMessage` (part of its Agent Teams protocol). Other agents would need a different approach (perhaps MCP-based `send_message` tool). +- **Process lifecycle** — Claude's `--input-format stream-json` / `--output-format stream-json` keeps the CLI alive. Other agents would need different process management. +- **Prompt injection** — Our provisioning prompt is Claude-specific. Other agents would need their own system prompts. + +**To truly unify for multi-agent support:** +1. The MCP server already provides all task/kanban operations — any agent with MCP support can use them +2. We'd need to add MCP tools for messaging (`send_message`, `read_inbox`) to replace Claude-specific `SendMessage` +3. We'd need a generic agent spawning mechanism (not Claude's `Agent` tool) +4. The stdout parsing in `TeamProvisioningService` is Claude-specific — other agents would need different adapters + +--- + +## 9. File Index + +| File | Role | +|------|------| +| `src/main/services/team/TeamProvisioningService.ts` | Spawns Claude CLI, attaches stdout parser, handles stream-json, manages team lifecycle | +| `src/main/services/team/TeamMcpConfigBuilder.ts` | Generates `--mcp-config` JSON file that injects our MCP server | +| `mcp-server/src/index.ts` | FastMCP server entry point (stdio transport) | +| `mcp-server/src/controller.ts` | Wraps `agent-teams-controller` for MCP tools | +| `mcp-server/src/tools/taskTools.ts` | Task CRUD MCP tools (17 tools) | +| `mcp-server/src/tools/kanbanTools.ts` | Kanban state MCP tools (6 tools) | +| `mcp-server/src/tools/reviewTools.ts` | Review workflow MCP tools | +| `agent-teams-controller/src/controller.js` | Shared controller factory — creates context + binds all domain modules | +| `agent-teams-controller/src/internal/taskStore.js` | Low-level task JSON file read/write operations | +| `agent-teams-controller/src/internal/tasks.js` | Task business logic (create, start, complete, comment, etc.) | +| `agent-teams-controller/src/internal/runtimeHelpers.js` | Path resolution (`~/.claude/tasks/`, `~/.claude/teams/`) | +| `src/main/services/infrastructure/FileWatcher.ts` | Watches `~/.claude/tasks/` and `~/.claude/teams/` with fs.watch() | +| `src/main/index.ts` (lines 425-620) | `wireFileWatcherEvents()` — forwards file changes to renderer via IPC | +| `src/main/services/team/TeamTaskReader.ts` | Reads all task JSON files, normalizes, returns `TeamTask[]` | +| `src/main/services/team/TeamTaskWriter.ts` | UI-side writes (create, update status, add comment, etc.) | +| `src/main/services/team/TeamKanbanManager.ts` | Reads/writes `kanban-state.json` for UI kanban overlay | diff --git a/docs/research/cli-adapter-exhaustive-search.md b/docs/research/cli-adapter-exhaustive-search.md new file mode 100644 index 00000000..8dd76217 --- /dev/null +++ b/docs/research/cli-adapter-exhaustive-search.md @@ -0,0 +1,305 @@ +# Exhaustive Search: Unified CLI Agent Adapter Libraries + +**Date:** 2026-03-24 +**Goal:** Find ANY existing library/package that provides a unified interface for spawning and communicating with multiple AI coding CLI agents (Claude Code, Codex, Gemini CLI, Goose, Aider, OpenCode, etc.) +**Verdict:** Multiple viable options now exist. The landscape has changed dramatically since last check. + +--- + +## Executive Summary + +The "nothing exists" conclusion from previous research is **no longer accurate**. As of March 2026, there are at least **6 serious contenders** that provide a unified interface for controlling multiple CLI coding agents. The ecosystem exploded in late 2025 / early 2026 driven by the Agent Client Protocol (ACP) standard and the proliferation of CLI coding agents. + +However, **none of them are a drop-in library for Electron** in the way we need. Each has tradeoffs. The analysis below is ordered from most to least relevant for our use case. + +--- + +## Tier 1: Directly Relevant — Unified Agent Interface Libraries + +### 1. Rivet Sandbox Agent SDK +- **Repo:** https://github.com/rivet-dev/sandbox-agent +- **npm:** `@sandbox-agent/cli` (v0.2.x), `sandbox-agent` (TS SDK) +- **Website:** https://sandboxagent.dev +- **Language:** Rust server + TypeScript SDK +- **Supported agents:** Claude Code, Codex, OpenCode, Cursor, Amp, Pi (6 agents) +- **Last activity:** Active (HN launch Feb 2026) +- **Stars:** High interest (featured on InfoQ, HN front page) +- **TypeScript types:** Yes, full TypeScript SDK with embedded mode +- **Installable via npm:** Yes +- **Can embed in Electron:** Partially. The TS SDK can spawn the Rust binary as a subprocess. However, it's designed for sandboxed environments (Docker, E2B, Daytona), not local Electron apps. +- **How it works:** Rust HTTP server runs inside a sandbox, exposes unified REST + SSE API. TS SDK connects over HTTP or spawns daemon. +- **Universal session schema:** Yes — normalizes all agent events into consistent format (session lifecycle, items, questions, permissions) +- **Reliability:** 8/10 — Backed by Rivet (YC company), clean architecture +- **Confidence this fits our needs:** 5/10 — Sandbox-first design doesn't map well to local Electron. We'd need to run the binary locally without a sandbox. The TS SDK embed mode is promising but untested for our use case. + +### 2. Agent Client Protocol (ACP) + TypeScript SDK +- **Repo:** https://github.com/agentclientprotocol/typescript-sdk +- **npm:** `@agentclientprotocol/sdk` (v0.14.1, 245 dependents) +- **Spec:** https://agentclientprotocol.com +- **Language:** TypeScript +- **Supported agents:** 25+ agents (Claude, Codex, Gemini CLI, Copilot, Goose, OpenCode, Pi, Kiro, Junie, Cline, OpenHands, Qoder, Kimi, and many more) +- **Last publish:** 15 days ago (very active) +- **Stars:** Growing rapidly (Zed-backed, GitHub Copilot adopted it) +- **TypeScript types:** Yes, full TypeScript SDK +- **Installable via npm:** Yes +- **Can embed in Electron:** Yes. The SDK provides `ClientSideConnection` that connects to agents via stdio or TCP. You spawn the agent CLI process and pipe stdio — exactly like what we do now with Claude Code. +- **How it works:** Standardized JSON-RPC protocol over stdio/TCP. Each agent implements ACP server. Client spawns process, communicates via NDJSON. +- **Reliability:** 9/10 — Backed by Zed Industries, adopted by GitHub Copilot CLI, Gemini CLI, Goose, and 20+ agents. This is becoming the industry standard. +- **Confidence this fits our needs:** 7/10 — This is the most promising approach. However: not all agents support ACP natively yet (Claude Code's ACP support is via adapter, not native). The protocol covers editor-agent communication, which is close to but not identical to our CLI orchestration needs. +- **Critical note:** ACP is about standardizing the *protocol* between a client and an agent. It does NOT handle process spawning, worktree management, or team coordination — we'd still build that ourselves on top. + +### 3. @posthog/code-agent +- **Repo:** https://github.com/PostHog/code (monorepo) +- **npm:** `@posthog/code-agent` (v0.2.0) +- **Language:** TypeScript +- **Supported agents:** Claude Code (Anthropic), OpenAI Codex (2 agents) +- **Last publish:** ~3 months ago +- **Stars:** Part of PostHog's code monorepo +- **TypeScript types:** Yes, full TypeScript +- **Installable via npm:** Yes +- **Can embed in Electron:** Yes — it's a pure TypeScript library +- **How it works:** Wraps Anthropic Claude Agent SDK and OpenAI Codex SDK behind a unified interface. Single API for streaming events, tool calls, diffs, permissions. +- **Features:** Unified permissions (strict/auto/permissive), MCP bridge, diff normalization, streaming events, auth discovery +- **Reliability:** 6/10 — Only 2 providers, no community adoption (0 dependents), published by PostHog for their own products +- **Confidence this fits our needs:** 4/10 — Too limited (only 2 agents). Uses official SDKs (not CLI spawn), which means it talks to APIs, not CLI processes. Different paradigm from what we need. + +### 4. one-agent-sdk +- **Repo:** https://github.com/odysa/one-agent-sdk +- **Language:** TypeScript +- **Supported agents:** Claude Code, Codex, Kimi CLI (3 agents) +- **TypeScript types:** Yes +- **Installable via npm:** Appears to be (uses official provider SDKs) +- **Can embed in Electron:** Yes — pure TypeScript +- **How it works:** Wraps official SDKs (@anthropic-ai/claude-agent-sdk, @openai/codex-sdk, @moonshot-ai/kimi-agent-sdk) behind unified interface. Provider-agnostic tools, handoffs, middleware. +- **Reliability:** 4/10 — Very new, minimal community, only 3 providers +- **Confidence this fits our needs:** 3/10 — Same limitation as @posthog/code-agent: uses SDKs not CLI spawn. Only 3 agents. Too narrow. + +### 5. Coder AgentAPI +- **Repo:** https://github.com/coder/agentapi +- **Language:** Go (server), OpenAPI 3.0.3 spec available +- **Supported agents:** Claude Code, Goose, Aider, Gemini, Amp, Codex (6 agents) +- **Stars:** ~996 +- **Latest version:** v0.11.2 +- **TypeScript types:** No official TS SDK, but OpenAPI spec available for generation +- **Installable via npm:** No (Go binary) +- **Can embed in Electron:** Partially. We'd bundle the Go binary and spawn it as subprocess. +- **How it works:** Runs an in-memory terminal emulator. Translates API calls into terminal keystrokes, parses agent outputs into messages. Simple 4-endpoint REST API (POST /message, GET /status, GET /events SSE, GET /messages). +- **Reliability:** 7/10 — Built by Coder (well-funded company), clean design, but terminal emulation approach has inherent limitations +- **Confidence this fits our needs:** 5/10 — Terminal emulation is clever but fragile. We'd need to bundle a Go binary. No native TypeScript SDK. Could generate one from OpenAPI spec. + +--- + +## Tier 2: Standalone Apps with Adapter Architecture (Not Reusable Libraries) + +These projects have interesting adapter/plugin architectures but are **standalone applications**, not importable libraries. + +### 6. Overstory +- **Repo:** https://github.com/jayminwest/overstory +- **Language:** TypeScript (Bun runtime) +- **Architecture:** Pluggable `AgentRuntime` interface at `src/runtimes/types.ts` +- **Supported runtimes:** 11 (Claude Code, Pi, Gemini CLI, Aider, Goose, Amp, and custom) +- **Stars:** Growing +- **Reusable as library:** No. It's a CLI orchestrator (Bun-only, uses tmux). The `AgentRuntime` interface is embedded in the app, not published as a package. +- **Relevance:** The `AgentRuntime` interface design is good reference material for our own adapter pattern. Worth studying `src/runtimes/types.ts`. + +### 7. conductor-oss (by charannyk06) +- **Repo:** https://github.com/charannyk06/conductor-oss +- **npm:** `conductor-oss` (launcher only) +- **Language:** Rust backend + TypeScript frontend (Next.js dashboard) +- **Architecture:** `conductor-executors` crate contains adapters for 10 agents +- **Supported agents:** Claude Code, Codex, Gemini, Qwen Code, Cursor Agent, Amp, OpenCode, Copilot, CCR (10 agents) +- **Reusable as library:** No. The agent adapters are Rust code in a Rust crate. The npm package is just a launcher that starts the Rust server. +- **Relevance:** Good reference for agent adapter patterns. The adapter architecture handles binary detection, launch commands, process monitoring, and prompt delivery. + +### 8. Vibe Kanban +- **Repo:** https://github.com/BloopAI/vibe-kanban +- **npm:** `vibe-kanban` (npx wrapper) +- **Stars:** ~23.4k +- **Language:** Rust backend + TypeScript/React frontend +- **Architecture:** "Executor" plugin pattern for each agent +- **Supported agents:** 10+ (Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, Qwen Code) +- **Reusable as library:** No. Executors are Rust code. TypeScript types are generated from Rust via ts-rs. +- **Relevance:** Closest competitor to our product. Their agent adapter pattern is in Rust, not reusable by us. But: there is a community TypeScript port `@nogataka/coding-agent-mgr` that claims to be a drop-in replacement — worth investigating. + +### 9. Dorothy +- **Repo:** https://github.com/Charlie85270/Dorothy +- **Language:** Electron + React/Next.js +- **Architecture:** Agent Manager using node-pty +- **Supported agents:** Claude Code, Codex, Gemini +- **Reusable as library:** No. Standalone Electron desktop app. +- **Relevance:** Very similar architecture to ours (Electron + node-pty). Good reference for how they handle agent spawning. MCP server integration is interesting. + +### 10. Emdash +- **Repo:** https://github.com/generalaction/emdash +- **Backed by:** Y Combinator W26 +- **Language:** Electron + TypeScript +- **Supported agents:** 23 CLI providers +- **Reusable as library:** No. Standalone Electron app with SQLite/Drizzle. +- **Relevance:** Most similar to our product architecture-wise (Electron + TypeScript). Supports 23 agents. Worth studying their provider integration code for patterns. Auto-detects installed CLIs. + +### 11. ComposioHQ Agent Orchestrator +- **Repo:** https://github.com/ComposioHQ/agent-orchestrator +- **npm:** `@composio/ao` (global CLI) +- **Language:** TypeScript (40,000 LOC) +- **Architecture:** 8 plugin slots (runtime, agent, workspace, tracker, SCM, notifier, terminal, lifecycle) +- **Supported agents:** Claude Code, Codex, Aider (and more via plugins) +- **Reusable as library:** Partially. The plugin interfaces are TypeScript, but the system is designed as a standalone CLI orchestrator. +- **Stars:** Growing (17 plugins, 3,288 tests) +- **Relevance:** The TypeScript plugin interface pattern could be extracted/adapted. + +### 12. Parallel Code +- **Repo:** https://github.com/johannesjo/parallel-code +- **Language:** Desktop app (unspecified stack) +- **Supported agents:** Claude Code, Codex CLI, Gemini CLI +- **Reusable as library:** No. Standalone desktop app. + +--- + +## Tier 3: MCP-Based Orchestrators (Different Paradigm) + +### 13. all-agents-mcp +- **Repo:** https://github.com/Dokkabei97/all-agents-mcp +- **npm:** `all-agents-mcp` (npx) +- **Language:** TypeScript +- **Supported agents:** Claude Code, Codex, Gemini CLI, Copilot CLI (4 agents) +- **Architecture:** MCP server with agent abstraction layer (`src/agents/types.ts`, `base-agent.ts`, per-agent adapters) +- **Reusable as library:** Partially. The agent abstraction layer (`src/agents/`) could be extracted. But it's designed as an MCP server, not a library. +- **TypeScript types:** Yes +- **Relevance:** The `src/agents/` directory contains a clean TypeScript agent abstraction with `types.ts`, `base-agent.ts`, and per-agent implementations. This is the closest to a reusable adapter pattern in pure TypeScript. + +### 14. agents-mcp (d-kimuson) +- **Repo:** https://github.com/d-kimuson/agents-mcp +- **Description:** MCP server for unified AI agents interface +- **Relevance:** Minimal info, likely similar pattern to all-agents-mcp + +--- + +## Tier 4: Protocols / Standards (Not Libraries, But Important Context) + +### 15. Agent Client Protocol (ACP) +- **Spec:** https://agentclientprotocol.com +- **Repo:** https://github.com/agentclientprotocol/agent-client-protocol +- **Created by:** Zed Industries +- **Adopted by:** GitHub Copilot CLI, Gemini CLI, Goose, Pi, OpenClaw, OpenCode, Cline, Codex, and 20+ agents +- **TypeScript SDK:** `@agentclientprotocol/sdk` (v0.14.1, 245 dependents, published 15 days ago) +- **This is becoming THE standard.** JSON-RPC over stdio/TCP. Editor spawns agent process, communicates via NDJSON. +- **Key insight:** If most agents converge on ACP, our adapter layer becomes simpler — we just need an ACP client. + +### 16. agent-protocol (AI Engineers Foundation) +- **npm:** `agent-protocol` (v1.0.5) +- **Last published:** 2 years ago (dead) +- **Relevance:** Superseded by ACP. Not relevant. + +--- + +## Tier 5: Tangentially Related (Process Management / Terminal Control) + +### 17. terminalcp (@mariozechner/terminalcp) +- **Repo:** https://github.com/badlogic/terminalcp +- **npm:** `@mariozechner/terminalcp` +- **What:** "Playwright for the terminal" — MCP server that lets agents spawn and interact with any CLI tool +- **Uses:** node-pty + xterm.js for terminal emulation +- **Relevance:** Not an agent adapter, but the terminal spawn/control pattern (node-pty + xterm.js + Unix socket daemon) is exactly what we'd use if building our own. + +### 18. Network-AI +- **Repo:** https://github.com/jovanSAPFIONEER/Network-AI +- **npm:** `network-ai` +- **Language:** TypeScript +- **What:** Multi-agent orchestrator with 14 adapters (LangChain, AutoGen, CrewAI, OpenAI, etc.) +- **Relevance:** The adapters are for AI *frameworks*, not CLI coding agents. Different domain. + +### 19. execa +- **npm:** `execa` (millions of weekly downloads) +- **What:** Process execution for humans. Wrapper around child_process. +- **Relevance:** Not agent-specific, but the best foundation for spawning CLI processes in Node.js. We already use this pattern. + +--- + +## Comprehensive Comparison Matrix + +| Project | Type | npm pkg? | TS types? | Agents | Electron-safe? | Active? | Our fit | +|---------|------|----------|-----------|--------|-----------------|---------|---------| +| **ACP SDK** | Protocol SDK | Yes | Yes | 25+ | Yes | Very | **Best** | +| **Sandbox Agent SDK** | Unified API | Yes | Yes | 6 | Partial | Active | Good | +| **@posthog/code-agent** | SDK wrapper | Yes | Yes | 2 | Yes | Stale | Poor | +| **one-agent-sdk** | SDK wrapper | Yes | Yes | 3 | Yes | New | Poor | +| **Coder AgentAPI** | HTTP server | No (Go) | OpenAPI | 6 | Partial | Active | OK | +| **all-agents-mcp** | MCP server | Yes | Yes | 4 | Partial | Active | Reference | +| **Overstory** | CLI app | No | Yes | 11 | No (Bun+tmux) | Active | Reference | +| **conductor-oss** | App | Launcher | Rust | 10 | No (Rust) | Active | Reference | +| **Vibe Kanban** | App | Wrapper | Generated | 10+ | No (Rust) | Active | Reference | +| **Dorothy** | Electron app | No | Yes | 3+ | Same arch | Active | Reference | +| **Emdash** | Electron app | No | Yes | 23 | Same arch | Active | Reference | +| **ComposioHQ AO** | CLI app | Global | Yes | 3+ | Partial | Active | Reference | + +--- + +## Recommendation + +### Best Option: ACP SDK (`@agentclientprotocol/sdk`) +- **Reliability:** 9/10 +- **Confidence:** 7/10 + +**Why:** ACP is becoming the industry standard. 25+ agents support it. Backed by Zed, adopted by GitHub Copilot. The TypeScript SDK is mature (v0.14.1, 245 dependents). It handles the protocol layer — we handle process spawning and team coordination on top. + +**Risk:** Claude Code's ACP support is via adapter (not native stream-json). We'd need to verify Claude Code works with ACP in our specific use case (Agent Teams, stream-json mode). The protocol focuses on editor-agent communication, not CLI orchestration. + +### Fallback: Build Our Own Adapter Layer +- **Reliability:** 8/10 +- **Confidence:** 9/10 + +**Why:** Given that: +1. No library perfectly fits our Electron + Agent Teams architecture +2. The adapter layer is relatively thin (spawn process, pipe stdio, parse output) +3. We already have a working Claude Code integration via stream-json +4. ACP can be adopted incrementally as agents converge on it + +We should define our own `IAgentRuntime` interface (inspired by Overstory's `AgentRuntime` and ACP's `AgentSideConnection`), implement Claude Code adapter first, then add ACP-based adapters for other agents. + +### Reference implementations to study: +1. **ACP TypeScript SDK** — Protocol design, event schema, NDJSON streaming +2. **Overstory `src/runtimes/types.ts`** — AgentRuntime interface design for CLI agents +3. **all-agents-mcp `src/agents/`** — Clean TypeScript agent abstraction with base class +4. **Emdash provider integration** — How they handle 23 agents in Electron +5. **Sandbox Agent SDK event schema** — Universal session schema for normalizing agent events + +--- + +## Sources + +### Tier 1 (Libraries/SDKs) +- [Rivet Sandbox Agent SDK](https://github.com/rivet-dev/sandbox-agent) | [Docs](https://sandboxagent.dev/) | [InfoQ](https://www.infoq.com/news/2026/02/rivet-agent-sandbox-sdk/) +- [ACP TypeScript SDK](https://github.com/agentclientprotocol/typescript-sdk) | [npm](https://www.npmjs.com/package/@agentclientprotocol/sdk) | [Spec](https://agentclientprotocol.com) +- [@posthog/code-agent](https://www.npmjs.com/package/@posthog/code-agent) | [PostHog/code](https://github.com/PostHog/code) +- [one-agent-sdk](https://github.com/odysa/one-agent-sdk) +- [Coder AgentAPI](https://github.com/coder/agentapi) + +### Tier 2 (Apps with Adapter Architecture) +- [Overstory](https://github.com/jayminwest/overstory) +- [conductor-oss](https://github.com/charannyk06/conductor-oss) | [npm](https://www.npmjs.com/package/conductor-oss) +- [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) | [npm](https://www.npmjs.com/package/vibe-kanban) +- [Dorothy](https://github.com/Charlie85270/Dorothy) | [Site](https://dorothyai.app/) +- [Emdash](https://github.com/generalaction/emdash) | [Site](https://www.emdash.sh/) +- [ComposioHQ Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) +- [Parallel Code](https://github.com/johannesjo/parallel-code) + +### Tier 3 (MCP Orchestrators) +- [all-agents-mcp](https://github.com/Dokkabei97/all-agents-mcp) + +### Tier 4 (Protocols) +- [Agent Client Protocol](https://agentclientprotocol.com) | [GitHub](https://github.com/agentclientprotocol/agent-client-protocol) | [Copilot ACP](https://github.blog/changelog/2026-01-28-acp-support-in-copilot-cli-is-now-in-public-preview/) +- [AI Code Agents SDK](https://felix-arntz.me/blog/introducing-ai-code-agents-a-typescript-sdk-to-solve-vendor-lock-in-for-coding-agents/) (Vercel AI SDK based, early stage) + +### Tier 5 (Process/Terminal) +- [terminalcp](https://github.com/badlogic/terminalcp) | [npm](https://www.npmjs.com/package/@mariozechner/terminalcp) +- [Network-AI](https://github.com/jovanSAPFIONEER/Network-AI) + +### Curated Lists +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [awesome-cli-coding-agents](https://github.com/bradAGI/awesome-cli-coding-agents) + +### HN Discussions +- [Show HN: Sandbox Agent SDK](https://news.ycombinator.com/item?id=46795584) +- [Show HN: OpenSwarm](https://news.ycombinator.com/item?id=47160980) +- [Show HN: Bridge from Copilot SDK to ACP](https://news.ycombinator.com/item?id=47165572) +- [Ask HN: Why CLI coding agents?](https://news.ycombinator.com/item?id=45115303) diff --git a/docs/research/mastra-integration-analysis.md b/docs/research/mastra-integration-analysis.md new file mode 100644 index 00000000..4a4f2541 --- /dev/null +++ b/docs/research/mastra-integration-analysis.md @@ -0,0 +1,756 @@ +# Mastra Integration Analysis + +> Technical feasibility study for integrating Mastra (TypeScript agent framework) with Claude Agent Teams UI. +> Date: 2026-03-24 + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Mastra Architecture Overview](#mastra-architecture-overview) +3. [Our Codebase Architecture](#our-codebase-architecture) +4. [Integration Points Analysis](#integration-points-analysis) +5. [Concrete Integration Approaches](#concrete-integration-approaches) +6. [Architecture Diagram](#architecture-diagram) +7. [What Stays the Same](#what-stays-the-same) +8. [What Must Change](#what-must-change) +9. [Effort Estimate](#effort-estimate) +10. [Risks and Blockers](#risks-and-blockers) +11. [Recommendations](#recommendations) +12. [Sources](#sources) + +--- + +## Executive Summary + +Mastra is a TypeScript-first agent framework (22K+ stars, $13M seed, YC-backed) from the Gatsby team. It provides unified primitives for agents, tools, workflows, RAG, and multi-agent orchestration with 40+ LLM provider support. + +**Key finding: Mastra operates at a fundamentally different level than Claude CLI.** Our app is a process manager and UI for Claude Code CLI sessions. Mastra is an SDK for building agents programmatically. Integration is possible but requires a significant architectural shift — specifically, replacing Claude CLI process management with in-process Mastra agent runtime. + +**Verdict: 6/10 feasibility, 4/10 reliability of quick integration.** The integration is architecturally sound but represents 6-10 person-weeks of work with significant risk to our core differentiator (Claude Code CLI features: file editing, terminal, git, Agent tool for spawning teammates). + +--- + +## Mastra Architecture Overview + +### Core Packages + +| Package | Purpose | +|---------|---------| +| `@mastra/core` | Agent, Workflow, Tool, Server, Storage, Vector, DI | +| `@mastra/mcp` | MCPClient (consume) + MCPServer (expose) | +| `@mastra/ai-sdk` | AI SDK v5 compatibility layer | +| `@mastra/client-js` | HTTP client for remote Mastra servers | +| `mastra` | CLI for project scaffolding | + +### Agent Definition + +```typescript +import { Agent } from '@mastra/core/agent'; +import { MCPClient } from '@mastra/mcp'; + +const agent = new Agent({ + id: 'team-lead', + name: 'Team Lead', + instructions: 'You coordinate the team...', + model: 'anthropic/claude-sonnet-4-20250514', // any of 40+ providers + tools: { taskCreate, taskUpdate, sendMessage }, +}); + +// Usage +const result = await agent.generate('Create tasks for the frontend sprint'); +const stream = await agent.stream('Review the PR'); +``` + +### Multi-Agent: Supervisor Pattern (recommended as of Feb 2026) + +```typescript +const researcher = new Agent({ + id: 'researcher', + description: 'Researches technical topics', + model: 'anthropic/claude-sonnet-4-20250514', + tools: { webSearch, readFile }, +}); + +const developer = new Agent({ + id: 'developer', + description: 'Implements code changes', + model: 'anthropic/claude-sonnet-4-20250514', + tools: { editFile, runTests, bash }, +}); + +const supervisor = new Agent({ + id: 'supervisor', + name: 'Team Lead', + instructions: 'Coordinate researcher and developer...', + model: 'anthropic/claude-sonnet-4-20250514', + agents: { researcher, developer }, // auto-converted to tools + memory: new Memory(), +}); + +const stream = await supervisor.stream('Fix the authentication bug', { + maxSteps: 20, +}); +``` + +### MCP Integration + +```typescript +import { MCPClient } from '@mastra/mcp'; + +const mcp = new MCPClient({ + servers: { + 'agent-teams': { + command: 'node', + args: ['/path/to/mcp-server/dist/index.js'], + }, + }, +}); + +const agent = new Agent({ + id: 'worker', + model: 'anthropic/claude-sonnet-4-20250514', + instructions: '...', +}); + +// Dynamic tool injection +const response = await agent.stream('Update task #abc to in_progress', { + toolsets: await mcp.listToolsets(), +}); +``` + +--- + +## Our Codebase Architecture + +### Process Management Layer (Claude-specific) + +The core of our backend is `TeamProvisioningService` — an 8000+ line service that manages Claude CLI processes. + +**Key file**: `src/main/services/team/TeamProvisioningService.ts` + +Core flow: +1. **Resolve Claude binary** via `ClaudeBinaryResolver` +2. **Build provisioning prompt** (~100 lines of structured instructions) via `buildProvisioningPrompt()` +3. **Spawn CLI process** with stream-json protocol: + ``` + spawnCli(claudePath, [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--mcp-config', mcpConfigPath, + '--disallowedTools', 'TeamDelete,TodoWrite', + '--dangerously-skip-permissions', + ]) + ``` +4. **Parse stdout** as NDJSON (newline-delimited JSON) — types: `user`, `assistant`, `control_request`, `result`, `system` +5. **Send input via stdin** using stream-json protocol: `{"type":"user","message":{"role":"user","content":[...]}}\n` +6. **Monitor filesystem** for team config, tasks, inboxes written by CLI +7. **Relay messages** between lead and teammates via inbox files + +**Key file**: `src/main/utils/childProcess.ts` — `spawnCli()` and `execCli()` wrappers with Windows shell fallback and EINVAL handling. + +### Prompt/Instruction System + +The prompt system is deeply intertwined with Claude Code's native capabilities: + +**`buildProvisioningPrompt()`** (line ~860) constructs a multi-section prompt: +- Team identity (name, project, lead) +- Step 1: Call **BUILT-IN TeamCreate tool** (Claude Code native) +- Step 2: Spawn teammates via **Agent tool** (Claude Code native) with `team_name` parameter +- Step 3: Create tasks via **MCP board tools** +- Persistent lead context: communication protocol, board MCP operations, agent block policy + +**`buildMemberSpawnPrompt()`** (line ~444) constructs per-teammate instructions: +- Role and workflow injection +- `member_briefing` MCP bootstrap call +- Task lifecycle protocol (comment -> start -> work -> comment -> complete) +- Detailed notification/escalation rules + +**Critical Claude-specific constructs in prompts:** +- `Agent` tool with `team_name` parameter — Claude Code's native teammate spawning +- `TeamCreate` built-in tool — Claude Code's team lifecycle management +- `SendMessage` built-in tool — Claude Code's inter-agent messaging +- `--disallowedTools TeamDelete,TodoWrite` — Claude Code CLI flags +- `--permission-mode bypassPermissions` — Claude Code permission system +- stream-json protocol for bidirectional communication +- Post-compact context reinjection for context window management + +### MCP Server + +**Key files**: `mcp-server/src/` (16 TypeScript files) + +Our MCP server (FastMCP-based) exposes domain tools to agents: + +| Tool Domain | Tools | File | +|------------|-------|------| +| Tasks | task_create, task_get, task_list, task_start, task_complete, etc. | `taskTools.ts` | +| Kanban | kanban_get, kanban_set_column, kanban_clear | `kanbanTools.ts` | +| Review | review_request, review_approve, review_request_changes | `reviewTools.ts` | +| Messages | send messages between agents | `messageTools.ts` | +| Process | process management | `processTools.ts` | +| Cross-team | cross_team_send, cross_team_list_targets | `crossTeamTools.ts` | +| Runtime | runtime state queries | `runtimeTools.ts` | + +All tools delegate to `agent-teams-controller` — a workspace package that manages team state (config.json, tasks/, inboxes/). + +### Message Parsing Pipeline + +**Key files**: +- `src/main/types/jsonl.ts` — Raw JSONL format types (Claude Code session files) +- `src/main/types/messages.ts` — ParsedMessage with type guards +- `src/main/services/analysis/ChunkBuilder.ts` — Builds timeline chunks from parsed messages + +The JSONL parsing is tightly coupled to Claude Code's output format: +- Entry types: `user`, `assistant`, `system`, `summary`, `file-history-snapshot`, `queue-operation` +- Content blocks: `text`, `thinking`, `tool_use`, `tool_result`, `image` +- Usage metadata: `input_tokens`, `output_tokens`, `cache_read_input_tokens` +- Claude-specific fields: `model`, `stop_reason`, `cwd`, `gitBranch`, `agentId`, `isSidechain` + +### IPC Layer + +**Key file**: `src/main/ipc/teams.ts` — 60+ IPC channels for team operations + +The renderer communicates with main process via Electron IPC. The channels include team CRUD, task management, message sending, provisioning control, tool approval, and process lifecycle. + +--- + +## Integration Points Analysis + +### 1. Process Spawning — Claude CLI vs Mastra Agent Runtime + +| Aspect | Current (Claude CLI) | Mastra Integration | +|--------|---------------------|-------------------| +| **Runtime** | External process (`claude` binary) | In-process Node.js (`Agent.stream()`) | +| **Protocol** | stream-json over stdin/stdout | Programmatic TypeScript API | +| **Agent spawning** | `Agent` tool with `team_name` param | `new Agent({ agents: {...} })` supervisor pattern | +| **Tool execution** | Claude Code built-in + MCP | Mastra tools + `@mastra/mcp` MCPClient | +| **File editing** | Claude Code's built-in file tools | Must provide custom tools (Read, Write, Bash) | +| **Terminal** | Claude Code's built-in terminal | Must provide custom Bash tool | +| **Git** | Claude Code's built-in git support | Must provide custom git tools | +| **Context window** | Claude Code manages (200K) | Mastra manages via provider settings | + +**Claude-specificity score: 9/10** — This is the most tightly coupled area. + +### 2. Prompt/Instruction System + +| Aspect | Current | Mastra Equivalent | +|--------|---------|-------------------| +| System prompt | Injected via stream-json first message | `Agent.instructions` property | +| Dynamic instructions | Post-compact reinjection via stdin | `instructions` as function returning dynamic text | +| Built-in tools refs | `TeamCreate`, `Agent`, `SendMessage` in prompt | Must be replaced with Mastra tool calls | +| MCP tool refs | `task_create { teamName: "..." }` | Same MCP tools via `@mastra/mcp` MCPClient | + +**Claude-specificity score: 7/10** — Prompts reference Claude Code native tools extensively. + +### 3. MCP Server + +| Aspect | Current | Mastra Integration | +|--------|---------|-------------------| +| Server framework | FastMCP (stdio transport) | Same — OR convert to Mastra tools directly | +| Tool definitions | `server.addTool({ name, parameters, execute })` | `createTool({ id, inputSchema, execute })` | +| Transport | stdio (spawned by Claude CLI) | Could use `@mastra/mcp` MCPClient or convert to native Mastra tools | +| Controller | `agent-teams-controller` package | **Unchanged** — pure JS, no Claude dependency | + +**Claude-specificity score: 2/10** — MCP is provider-agnostic. Our `agent-teams-controller` is pure business logic. + +### 4. Message Parsing / JSONL Pipeline + +| Aspect | Current | Mastra Integration | +|--------|---------|-------------------| +| Session storage | `~/.claude/projects/{path}/*.jsonl` | Mastra has its own storage/memory system | +| Format | Claude Code JSONL (specific schema) | Mastra streaming chunks (text-delta, tool-call, etc.) | +| Type guards | `isParsedRealUserMessage`, etc. | New type guards for Mastra output format | +| Chunk building | `ChunkBuilder` from JSONL messages | New adapter from Mastra stream events | +| Subagent detection | `SubagentResolver` from tool_use content | Mastra supervisor tracks sub-agent calls natively | + +**Claude-specificity score: 8/10** — The entire analysis pipeline assumes Claude Code JSONL format. + +### 5. Team Lifecycle (config, inboxes, tasks) + +| Aspect | Current | Mastra Integration | +|--------|---------|-------------------| +| Team config | `~/.claude/teams/{name}/config.json` (Claude CLI creates) | Must be managed by our app directly | +| Task storage | `~/.claude/tasks/{name}/` (agent-teams-controller) | **Unchanged** | +| Inbox messaging | `~/.claude/teams/{name}/inboxes/{member}.json` | Replace with Mastra memory or direct tool calls | +| Cross-team comms | Inbox files with relay | Mastra agents can call each other directly | + +**Claude-specificity score: 6/10** — File-based coordination is Claude CLI convention, but our controller is independent. + +--- + +## Concrete Integration Approaches + +### Approach A: Mastra as Agent Runtime (Replace Claude CLI) + +**Confidence: 5/10 | Reliability: 4/10** + +Replace `spawnCli()` with in-process Mastra agents. The lead becomes a `supervisor` Agent, teammates become sub-agents. + +```typescript +// src/main/services/team/MastraTeamRuntime.ts (new file) +import { Agent } from '@mastra/core/agent'; +import { MCPClient } from '@mastra/mcp'; +import { createTool } from '@mastra/core/tools'; + +// Convert our MCP tools to native Mastra tools +const taskCreateTool = createTool({ + id: 'task_create', + description: 'Create a team task', + inputSchema: z.object({ + teamName: z.string(), + subject: z.string(), + description: z.string().optional(), + owner: z.string().optional(), + }), + execute: async (input) => { + const controller = getController(input.teamName); + return controller.tasks.createTask(input); + }, +}); + +// File editing tool (replaces Claude Code built-in) +const editFileTool = createTool({ + id: 'edit_file', + description: 'Edit a file on disk', + inputSchema: z.object({ + path: z.string(), + oldText: z.string(), + newText: z.string(), + }), + execute: async (input) => { + // Must implement file editing logic ourselves + const content = await fs.promises.readFile(input.path, 'utf8'); + const updated = content.replace(input.oldText, input.newText); + await fs.promises.writeFile(input.path, updated); + return { success: true }; + }, +}); + +// Bash tool (replaces Claude Code built-in) +const bashTool = createTool({ + id: 'bash', + description: 'Execute a bash command', + inputSchema: z.object({ command: z.string() }), + execute: async (input) => { + const { stdout, stderr } = await execAsync(input.command); + return { stdout, stderr }; + }, +}); + +// Create teammate agents +function createTeammateAgent(member: TeamMember, teamTools: Record) { + return new Agent({ + id: `teammate-${member.name}`, + name: member.name, + description: member.role || 'Team member', + instructions: buildMemberInstructions(member), // adapted from buildMemberSpawnPrompt + model: 'anthropic/claude-sonnet-4-20250514', + tools: { + ...teamTools, + editFileTool, + bashTool, + readFileTool, + // ... other dev tools + }, + }); +} + +// Create supervisor (lead) agent +function createLeadAgent( + request: TeamCreateRequest, + teammates: Record +) { + return new Agent({ + id: `lead-${request.teamName}`, + name: 'team-lead', + instructions: buildLeadInstructions(request), // adapted from buildPersistentLeadContext + model: request.model || 'anthropic/claude-sonnet-4-20250514', + agents: teammates, // Mastra auto-converts to tools + tools: { + ...teamTools, // task_create, kanban_get, etc. + editFileTool, + bashTool, + readFileTool, + }, + memory: new Memory(), + }); +} +``` + +**What breaks:** +- Claude Code's file editing (diff view, permission system) — must reimplement +- Claude Code's terminal integration +- Claude Code's git support +- Claude Code's extended thinking +- Claude Code's session persistence/resume +- The entire JSONL parsing pipeline +- Tool approval flow (our `control_request` handling) +- Post-compact context reinjection + +### Approach B: Mastra as Middleware / Orchestration Layer (Keep Claude CLI) + +**Confidence: 7/10 | Reliability: 6/10** + +Use Mastra as an orchestration layer that manages routing and coordination, while still spawning Claude CLI processes for actual work. + +```typescript +// src/main/services/team/MastraOrchestrator.ts (new file) +import { Agent } from '@mastra/core/agent'; +import { createTool } from '@mastra/core/tools'; + +// Tool that spawns a Claude CLI process for actual work +const claudeCliTool = createTool({ + id: 'claude_cli_execute', + description: 'Execute a task using Claude Code CLI', + inputSchema: z.object({ + prompt: z.string(), + cwd: z.string(), + model: z.string().optional(), + }), + execute: async (input) => { + // Spawn Claude CLI with -p (one-shot) + const result = await execCli(claudePath, [ + '-p', input.prompt, + '--output-format', 'text', + ...(input.model ? ['--model', input.model] : []), + ], { cwd: input.cwd }); + return { output: result.stdout }; + }, +}); + +// Mastra agent for high-level orchestration +const orchestrator = new Agent({ + id: 'orchestrator', + name: 'Task Orchestrator', + instructions: `You coordinate a development team. + Use claude_cli_execute for actual coding tasks. + Use task tools for board management.`, + model: 'anthropic/claude-sonnet-4-20250514', + tools: { + claudeCliTool, + ...teamBoardTools, + }, +}); + +// Orchestrator decides what to do, Claude CLI does the coding +const stream = await orchestrator.stream(userMessage); +``` + +**What this preserves:** +- Claude CLI's file editing, terminal, git, etc. +- Our existing JSONL pipeline (for CLI-executed tasks) +- MCP server tools (used by CLI processes) + +**What this adds:** +- Model-agnostic orchestration layer +- Ability to use OpenAI/Gemini/etc. for routing decisions +- Mastra's workflow engine for deterministic task flows + +**What breaks / gets complex:** +- Two runtime models (Mastra in-process + Claude CLI processes) +- Doubled complexity for message flow +- Unclear who "owns" the conversation state + +### Approach C: Mastra MCP Bridge (Minimal Integration) + +**Confidence: 8/10 | Reliability: 7/10** + +Use `@mastra/mcp` MCPServer to expose our existing tools to any Mastra-compatible client, and `@mastra/mcp` MCPClient to consume external MCP tools. + +```typescript +// mcp-server/src/mastra-bridge.ts (new file) +import { MCPServer } from '@mastra/mcp'; +import { Agent } from '@mastra/core/agent'; +import { registerTools } from './tools'; + +// Expose our existing tools as an MCP server that Mastra agents can consume +const mcpServer = new MCPServer({ + name: 'agent-teams-mcp', + version: '1.0.0', + tools: { + // Convert FastMCP tools to Mastra tools, or expose via MCP protocol + ...convertFastMcpToMastraTools(registerTools), + }, +}); + +// Any Mastra agent can now use our board tools +const externalAgent = new Agent({ + id: 'external-worker', + model: 'openai/gpt-4o', + instructions: 'You manage tasks on the team board.', + tools: await new MCPClient({ + servers: { + 'agent-teams': { + command: 'node', + args: ['path/to/mcp-server/dist/index.js'], + }, + }, + }).listTools(), +}); +``` + +**What this preserves:** +- Everything — this is additive, not replacement +- Claude CLI remains the primary runtime + +**What this adds:** +- Mastra agents can interact with our board +- Path to multi-provider support +- Future extensibility + +--- + +## Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Electron App (Renderer) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ Kanban │ │ Timeline │ │ Inbox │ │ Code Editor │ │ +│ │ Board │ │ View │ │ Chat │ │ (Diff View) │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───────┬───────┘ │ +│ └──────────────┴─────────────┴────────────────┘ │ +│ │ IPC │ +└──────────────────────────────┼───────────────────────────────────┘ + │ +┌──────────────────────────────┼───────────────────────────────────┐ +│ Electron App (Main) │ +│ │ │ +│ ┌───────────────────────────┴──────────────────────────────┐ │ +│ │ IPC Handler Layer (teams.ts) │ │ +│ └───────────────────────────┬──────────────────────────────┘ │ +│ │ │ +│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ +│ MASTRA MIDDLEWARE LAYER (Approach B / Future) │ +│ │ ┌─────────────────┐ │ ┌─────────────────────┐ │ │ +│ │ Mastra Agent │ │ │ Mastra Workflow │ │ +│ │ │ (Orchestrator) │ │ │ (Task Routing) │ │ │ +│ │ model-agnostic │ │ │ DAG execution │ │ +│ │ └────────┬─────────┘ │ └──────────┬──────────┘ │ │ +│ │ │ │ │ +│ └ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ┼─ ─ ─ ─ ─ ─ ─ ┘ │ +│ │ │ │ │ +│ ┌───────────┴──────────────┴───────────────┴──────────────┐ │ +│ │ TeamProvisioningService (existing) │ │ +│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ │ +│ │ │ spawnCli() │ │ stream-json │ │ FS monitor │ │ │ +│ │ │ (Claude CLI)│ │ parser │ │ (tasks/inbox) │ │ │ +│ │ └──────┬──────┘ └──────┬───────┘ └───────┬───────┘ │ │ +│ └─────────┼────────────────┼───────────────────┼──────────┘ │ +│ │ │ │ │ +│ ┌─────────┴────────────────┴───────────────────┴──────────┐ │ +│ │ agent-teams-controller (pure JS) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │ │ +│ │ │ Tasks │ │ Kanban │ │ Inbox │ │ Config │ │ │ +│ │ │ CRUD │ │ State │ │ Messages │ │ Reader │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────┼───────────────────────────────┐ │ +│ │ MCP Server (agent-teams-mcp) │ │ +│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────────────┐ │ │ +│ │ │ Tasks │ │ Kanban │ │ Review │ │ Messages │ │ │ +│ │ │ Tools │ │ Tools │ │ Tools │ │ & Cross-team │ │ │ +│ │ └────────┘ └────────┘ └────────┘ └────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + │ Claude CLI Process │ ← or Mastra agent.stream() + │ (stream-json) │ + │ ┌────────────────┐ │ + │ │ File Edit │ │ + │ │ Terminal/Bash │ │ + │ │ Git │ │ + │ │ Agent (spawn) │ │ + │ │ SendMessage │ │ + │ │ MCP tools │ │ + │ └────────────────┘ │ + └─────────────────────┘ +``` + +--- + +## What Stays the Same + +These modules are **not Claude-specific** and would survive any integration: + +| Module | Path | Why | +|--------|------|-----| +| `agent-teams-controller` | `agent-teams-controller/` | Pure JS business logic for tasks, kanban, review, inbox. Zero Claude dependency. | +| MCP Server tools | `mcp-server/src/tools/*.ts` | Standard MCP protocol. Works with any MCP-compatible agent. | +| UI components | `src/renderer/` | React/Zustand/Tailwind. Communicates via IPC, agnostic to backend. | +| IPC layer interface | `src/preload/constants/ipcChannels.ts` | Channel names are just strings. | +| Shared types | `src/shared/types/team.ts` | TeamTask, InboxMessage, etc. — domain types. | +| Team data services | `TeamDataService`, `TeamConfigReader`, `TeamTaskReader` | File-based, read team state from disk. | +| TeamMcpConfigBuilder | `src/main/services/team/TeamMcpConfigBuilder.ts` | Builds MCP config files. Could serve Mastra MCPClient too. | +| Notification system | `NotificationManager` | UI notifications, not Claude-specific. | + +--- + +## What Must Change + +### Tier 1: Core Runtime (Required for any Mastra integration) + +| File/Module | Lines | Change Required | +|------------|-------|-----------------| +| `TeamProvisioningService.ts` | ~8000 | Major refactor: abstract `AgentRuntime` interface. spawnCli() becomes one implementation, Mastra becomes another. | +| `childProcess.ts` | 220 | Keep as-is for Claude CLI path. New `MastraRuntime.ts` for in-process agents. | +| `ClaudeBinaryResolver.ts` | ~200 | Keep for Claude CLI path. Not needed for Mastra path. | + +### Tier 2: Message Parsing (Required for Approach A) + +| File/Module | Lines | Change Required | +|------------|-------|-----------------| +| `src/main/types/jsonl.ts` | 200+ | New parallel types for Mastra streaming events. | +| `src/main/types/messages.ts` | 377 | Extend ParsedMessage or create MastraMessage adapter. | +| `ChunkBuilder.ts` | ~600 | Abstract chunk building from JSONL parsing. Mastra adapter produces same chunk types. | +| `SubagentResolver.ts` | ~400 | Mastra supervisor natively tracks sub-agents. Simpler resolver. | +| `SemanticStepExtractor.ts` | ~300 | Mastra tool calls have different structure. Adapter needed. | + +### Tier 3: Prompt System (Required for all approaches) + +| File/Module | Lines | Change Required | +|------------|-------|-----------------| +| `buildProvisioningPrompt()` | ~100 | Remove Claude-specific steps (TeamCreate, Agent tool). Replace with Mastra tool references. | +| `buildMemberSpawnPrompt()` | ~80 | Convert to Mastra Agent `instructions`. Remove Agent tool spawn references. | +| `buildPersistentLeadContext()` | ~100 | Remove Agent tool references. Keep MCP tool instructions (they still apply). | +| `buildTeamCtlOpsInstructions()` | ~100 | Keep — these reference MCP tools which are provider-agnostic. | +| `actionModeInstructions.ts` | 50 | Keep — action modes are prompt-level, not provider-specific. | + +### Tier 4: Tool Approval (Required for Approach A) + +| File/Module | Change Required | +|------------|-----------------| +| Tool approval flow | Mastra has its own `requireApproval: true` on tools + `approveToolCall()`/`declineToolCall()`. Must adapt our UI's approval dialog to use Mastra's API instead of `control_request` stream-json messages. | + +--- + +## Effort Estimate + +### Approach A: Full Mastra Runtime (Replace Claude CLI) + +| Phase | Effort | Risk | +|-------|--------|------| +| Abstract AgentRuntime interface | 2 weeks | Medium — large refactor of 8K line service | +| Implement Mastra runtime adapter | 2 weeks | High — need to reimplement file/terminal/git tools | +| Adapt message parsing pipeline | 1 week | Medium — new adapter for Mastra events | +| Adapt prompt system | 1 week | Low — mostly string template changes | +| Tool approval integration | 1 week | Medium — different approval API | +| Testing + stabilization | 2 weeks | High — regression risk | +| **Total** | **9-10 weeks** | **High** | + +### Approach B: Mastra Middleware (Keep Claude CLI) + +| Phase | Effort | Risk | +|-------|--------|------| +| Mastra orchestrator service | 1 week | Medium | +| Claude CLI adapter tool | 1 week | Low | +| Dual runtime state management | 2 weeks | High — complexity | +| Message flow unification | 1 week | Medium | +| Testing | 1 week | Medium | +| **Total** | **6-7 weeks** | **Medium-High** | + +### Approach C: MCP Bridge (Minimal) + +| Phase | Effort | Risk | +|-------|--------|------| +| @mastra/mcp MCPServer wrapper | 3 days | Low | +| Example Mastra agent consuming our tools | 2 days | Low | +| Documentation + examples | 2 days | Low | +| **Total** | **1-2 weeks** | **Low** | + +--- + +## Risks and Blockers + +### Critical Blockers + +1. **Claude Code's built-in tools are not replicable via Mastra.** + Claude Code has deep integration with the filesystem, terminal, git, and its own Agent tool for spawning teammates. Mastra provides no equivalent — you would need to build `editFile`, `bash`, `readFile`, `glob`, `grep`, `git` tools from scratch. These tools must handle permissions, sandboxing, diff generation, and conflict resolution. This is not just wrapping `fs.writeFile()` — it's thousands of lines of battle-tested code. + +2. **stream-json protocol is Claude Code proprietary.** + Our entire real-time UI (live typing, tool progress, subagent tracking) depends on the stream-json wire format. Mastra's streaming format is different (AI SDK compatible). The translation layer is non-trivial. + +3. **Team/teammate lifecycle is Claude Code's native feature.** + `TeamCreate`, `Agent` with `team_name`, `SendMessage` — these are built into Claude Code CLI. Mastra's supervisor pattern is conceptually similar but mechanically different (in-process sub-agents vs. separate CLI processes). + +4. **Context window management.** + Claude Code manages its own context window, compaction, and session persistence. Mastra delegates this to the model provider's API. Our post-compact reinjection system would need complete redesign. + +### High Risks + +5. **Performance: in-process vs. out-of-process.** + Claude CLI runs as a separate process with its own Node.js runtime. Mastra agents run in-process within Electron's main process. Long-running agent tasks could block the Electron event loop. Would need worker threads or separate Node processes. + +6. **Authentication divergence.** + Claude Code CLI handles its own auth (OAuth, API key). Mastra uses provider API keys directly. Different auth models for different users. + +7. **Losing Claude Code ecosystem.** + Claude Code has CLAUDE.md, settings.json, .mcp.json, hooks, and growing features. Switching to Mastra means losing access to this ecosystem for Claude users. + +### Medium Risks + +8. **Mastra version churn.** + Mastra is pre-1.0 (currently ~1.10.x) and evolving rapidly. The AgentNetwork API was deprecated in favor of supervisor agents in just months. API stability is not guaranteed. + +9. **Dual dependency burden.** + Adding `@mastra/core` (~150KB+ with deps) to an Electron app increases bundle size and potential version conflicts. + +--- + +## Recommendations + +### Short Term (Now): Approach C — MCP Bridge + +**Confidence: 9/10 | Reliability: 8/10** + +- Wrap our MCP server with `@mastra/mcp` MCPServer +- Publish as a standalone MCP endpoint that any Mastra agent can consume +- Zero risk to existing functionality +- Opens the door for external Mastra agents to manage our board +- 1-2 weeks effort + +### Medium Term (Q2-Q3 2026): Abstract AgentRuntime Interface + +**Confidence: 7/10 | Reliability: 6/10** + +- Extract `AgentRuntime` interface from `TeamProvisioningService` +- `ClaudeCliRuntime` implements it (current behavior) +- Prepare the seam for `MastraRuntime` without building it yet +- De-risk the eventual full integration +- 2-3 weeks effort + +### Long Term (Q4 2026+): Approach B — Mastra Middleware + +**Confidence: 6/10 | Reliability: 5/10** + +- Add Mastra as orchestration layer for routing and multi-provider support +- Keep Claude CLI as the "worker" runtime for actual coding +- Use Mastra for decision-making, task routing, and provider switching +- Full multi-model support without losing Claude Code's tooling +- 6-7 weeks effort + +### NOT Recommended: Approach A (Full Replacement) + +**Confidence: 3/10 | Reliability: 2/10** + +Replacing Claude CLI entirely with Mastra-managed agents would lose our core differentiator (deep Claude Code integration: file editing, terminal, git, session persistence, extended thinking, etc.). The effort (~10 weeks) and risk are not justified unless Claude Code CLI is deprecated, which shows no signs of happening. + +--- + +## Sources + +- [Mastra GitHub Repository](https://github.com/mastra-ai/mastra) +- [Mastra Official Documentation](https://mastra.ai/docs) +- [Mastra Agent Overview](https://mastra.ai/docs/agents/overview) +- [Mastra MCP Overview](https://mastra.ai/docs/tools-mcp/mcp-overview) +- [Mastra Agent Network Evolution](https://mastra.ai/blog/agent-network) +- [Mastra vNext Agent Network](https://mastra.ai/blog/vnext-agent-network) +- [Mastra Supervisor Pattern (Feb 2026)](https://mastra.ai/blog/announcing-mastra-improved-agent-orchestration-ai-sdk-v5-support) +- [Mastra Agent Streaming Reference](https://mastra.ai/reference/agents/stream) +- [@mastra/core npm](https://www.npmjs.com/package/@mastra/core) +- [@mastra/mcp npm](https://www.npmjs.com/package/@mastra/mcp) +- [Mastra $13M Seed Round](https://technews180.com/funding-news/mastra-raises-13m-seed-for-typescript-ai-framework/) +- [Mastra on Y Combinator](https://www.ycombinator.com/companies/mastra) diff --git a/docs/research/mastra-vs-direct-mcp.md b/docs/research/mastra-vs-direct-mcp.md new file mode 100644 index 00000000..4a8bc52e --- /dev/null +++ b/docs/research/mastra-vs-direct-mcp.md @@ -0,0 +1,345 @@ +# @mastra/mcp vs Direct MCP: нужна ли нам Mastra как универсальный интеграционный слой? + +**Дата:** 2026-03-24 +**Контекст:** Вопрос пользователя — "Maybe we should use @mastra/mcp since it has many agents built-in?" +**Связанные документы:** +- `docs/research/mastra-integration-analysis.md` — полный технический анализ интеграции Mastra +- `docs/research/best-integration-approach.md` — сравнение всех подходов к мультипровайдерности +- `docs/research/ai-agent-protocols-and-routing.md` — обзор протоколов и фреймворков + +--- + +## Краткий ответ + +**Mastra НЕ даёт нам "many agents built-in" в том смысле, как это звучит.** Mastra — это SDK для создания СВОИХ агентов через API-вызовы к LLM-провайдерам. Она не умеет запускать/управлять CLI-агентами (Claude Code, Codex, Gemini CLI и т.д.) как процессами. Для нашего продукта — Electron-приложения, управляющего CLI-процессами через kanban-доску — прямой MCP остаётся правильным выбором. + +**Итоговая рекомендация: Прямой MCP (Вариант A)** +- Надёжность: 9/10 +- Уверенность: 9/10 + +--- + +## 1. Что такое @mastra/mcp на самом деле + +### Что Mastra НЕ является + +Mastra — это **НЕ** библиотека, которая подключает готовых агентов (Claude Code, Codex, Gemini CLI). Это SDK для создания собственных агентов через API-вызовы. Когда Mastra говорит про "40+ providers" — речь о 40+ LLM-провайдерах (OpenAI, Anthropic, Google и т.д.), к которым можно делать API-запросы, а не о CLI-агентах, которые работают как процессы. + +### Что Mastra ЯВЛЯЕТСЯ + +| Компонент | Описание | +|-----------|----------| +| `@mastra/core` | Agent runtime: создание агентов через `new Agent({model, instructions, tools})` | +| `@mastra/mcp` | MCPClient (подключение к MCP-серверам) + MCPServer (экспорт инструментов) | +| Agent | TS-объект, который вызывает LLM API + tools в цикле | +| Supervisor | Паттерн multi-agent: один агент координирует других | +| Memory | Observational Memory для long-term context | +| Workflows | DAG-based workflow engine | +| ToolSearchProcessor | Динамическая подгрузка инструментов (экономия токенов) | + +### Ключевой момент + +Mastra-агент — это `agent.generate("prompt")` или `agent.stream("prompt")`. Это **HTTP-вызов к LLM API** (OpenAI, Anthropic и т.д.). Это **НЕ** запуск CLI-процесса `claude --input-format stream-json`. + +Наш продукт — менеджер CLI-процессов с kanban-доской. Mastra работает на другом уровне абстракции. + +--- + +## 2. Поддержка MCP у CLI-агентов (март 2026) + +**Ключевой вопрос: если агенты уже поддерживают MCP нативно, зачем нам Mastra как прослойка?** + +| CLI-агент | MCP поддержка | Как настраивается | Источник | +|-----------|---------------|-------------------|----------| +| **Claude Code** | Нативная | `--mcp-config path.json`, `.mcp.json`, `~/.claude.json` | [code.claude.com/docs/en/mcp](https://code.claude.com/docs/en/mcp) | +| **OpenAI Codex** | Нативная | `~/.codex/config.toml`, `codex mcp add` | [developers.openai.com/codex/mcp](https://developers.openai.com/codex/mcp) | +| **Gemini CLI** | Нативная | `~/.gemini/settings.json` | [geminicli.com/docs/tools/mcp-server](https://geminicli.com/docs/tools/mcp-server/) | +| **Goose** | Нативная (MCP — основа расширений) | Built-in, Remote/Stdio/Command | [github.com/block/goose](https://github.com/block/goose) | +| **OpenCode** | Нативная | `opencode.json`, `opencode mcp add` | [opencode.ai/docs/mcp-servers](https://opencode.ai/docs/mcp-servers/) | +| **Kilo Code** | Нативная | `mcp_settings.json`, `.kilocode/mcp.json` | [kilo.ai/docs/automate/mcp/using-in-kilo-code](https://kilo.ai/docs/automate/mcp/using-in-kilo-code) | +| **Aider** | Через адаптеры (mcpm-aider) | MCP-клиент пакеты | [pulsemcp.com/servers/disler-aider](https://www.pulsemcp.com/servers/disler-aider) | + +**Вывод: 6 из 7 основных CLI-агентов уже поддерживают MCP нативно.** Им не нужна Mastra как прослойка — они могут подключиться к нашему MCP-серверу напрямую. + +--- + +## 3. Что Mastra добавляет поверх "сырого" MCP + +### Реальные преимущества Mastra (и почему они нам НЕ нужны) + +| Фича Mastra | Что это | Нужно ли нам? | Почему | +|-------------|---------|---------------|--------| +| **MCPClient** — подключение к нескольким MCP-серверам | Единый клиент для N серверов | Нет | Наш продукт ПРЕДОСТАВЛЯЕТ MCP-сервер, а не потребляет их | +| **MCPServer** — экспорт агентов/инструментов | Expose agents as MCP tools | Нет | У нас уже есть FastMCP сервер с 30+ инструментами | +| **ToolSearchProcessor** — динамический поиск инструментов | Агент ищет нужный инструмент по запросу | Нет | У нас ~30 инструментов, а не сотни. Контекст не проблема | +| **Agent Runtime** — цикл reason-act с memory | Полноценный runtime для API-агентов | Нет | Наши агенты — CLI-процессы (Claude Code, Codex), у них свой runtime | +| **Observability** — MCP_TOOL_CALL spans, Studio UI | Трейсинг MCP-вызовов | Нет | У нас свой UI с timeline, chunks, context tracking | +| **Serverless adapters** — Express/Hono/Koa | Запуск MCP в serverless | Нет | Мы Electron-приложение, не serverless | +| **Multi-registry** — Composio, Smithery | Поиск MCP-серверов в реестрах | Нет | Мы предоставляем один конкретный MCP-сервер | +| **Supervisor pattern** — multi-agent orchestration | Один агент управляет другими | Частично | Но Claude Code Agent Teams УЖЕ делает это нативно через `TeamCreate` + `Agent tool` | +| **600+ моделей** через 40+ провайдеров | Model routing | Нет | CLI-агенты сами решают, какую модель использовать | + +### Что Mastra НЕ может + +| Задача | Может ли Mastra? | Как мы решаем | +|--------|-------------------|---------------| +| Запустить `claude` CLI как процесс | Нет | `spawnCli()` + stream-json | +| Управлять `codex` CLI как subprocess | Нет | Нужен свой ProvisioningService | +| Парсить stream-json stdout | Нет | `handleStreamJsonMessage()` — наш код | +| Использовать Agent Teams built-in tools | Нет | Claude Code нативно | +| Работать с `~/.claude/teams/` файловой системой | Нет | `agent-teams-controller` | +| Показывать kanban-доску | Нет | Наш Electron UI | + +--- + +## 4. Три подхода: сравнение + +### Вариант A: Прямой MCP (наш текущий/рекомендуемый подход) + +**Надёжность: 9/10 | Уверенность: 9/10** + +``` +┌─────────────────────────────┐ +│ Electron App (UI) │ +│ ┌───────┐ ┌──────────┐ │ +│ │Kanban │ │ Timeline │ │ +│ │Board │ │ Messages │ │ +│ └───┬───┘ └────┬─────┘ │ +│ └──────────┘ │ +│ │ IPC │ +├───────────┼─────────────────┤ +│ Main Process │ +│ ┌────────────────────┐ │ +│ │ TeamProvisioning │ │ +│ │ Service │ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ┌─────────┴──────────┐ │ +│ │ MCP Server │ ←── Любой агент подключается сюда +│ │ (agent-teams-mcp) │ через --mcp-config +│ │ 30+ tools │ +│ └─────────────────────┘ │ +└─────────────────────────────┘ + │ │ + ┌────┴────┐ ┌────┴────┐ + │ Claude │ │ Codex / │ + │ Code │ │ Gemini │ + │ CLI │ │ CLI │ + │ (native)│ │ (via │ + │ │ │ MCP) │ + └─────────┘ └─────────┘ +``` + +**Как работает:** +1. Claude Code — нативная интеграция (процесс + stream-json + Agent Teams) +2. Другие агенты (Codex, Gemini, Goose, OpenCode, Kilo) — подключаются к нашему MCP-серверу через свой нативный MCP-клиент +3. Все агенты видят одну kanban-доску, создают задачи, обновляют статусы через MCP tools + +**Трудозатраты:** 0 доп. работы для MCP-части (уже работает). 2-3 недели для новых MCP-инструментов (`team_join`, `task_poll_assigned` и др.) + UI для внешних агентов. + +**Что даёт:** +- Любой MCP-совместимый агент подключается из коробки +- Zero dependency overhead (никаких `@mastra/*` пакетов) +- Наш MCP-сервер — единственная точка интеграции +- Полная совместимость с Claude Code Agent Teams + +**Чего не даёт:** +- Нет встроенного agent-to-agent (A2A) протокола (но он нам и не нужен — у нас inbox-файлы) +- Нет автоматического model routing (но CLI-агенты делают это сами) +- Нет встроенного observability для внешних агентов (но мы видим их действия через MCP-tool calls) + +### Вариант B: @mastra/mcp как обёртка нашего MCP-сервера + +**Надёжность: 5/10 | Уверенность: 4/10** + +``` +┌────────────────────────────────┐ +│ Electron App (UI) │ +├────────────────────────────────┤ +│ Main Process │ +│ ┌────────────────────┐ │ +│ │ TeamProvisioning │ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ┌─────────┴────────────────┐ │ +│ │ @mastra/mcp MCPServer │ │ ← Mastra обёртка +│ │ wraps our FastMCP tools │ │ +│ └─────────┬────────────────┘ │ +│ │ │ +│ ┌─────────┴────────────────┐ │ +│ │ @mastra/mcp MCPClient │ │ ← Mastra клиент +│ │ connects to external │ │ для внешних серверов +│ │ MCP servers │ │ +│ └──────────────────────────┘ │ +└────────────────────────────────┘ +``` + +**Трудозатраты:** 1-2 недели на обёртку + зависимость от `@mastra/core` (~150KB+) + +**Что даёт:** +- Typed MCPClient с auto-detect transport (stdio/HTTP/SSE) +- ToolSearchProcessor для динамического tool loading (если у нас будет 100+ инструментов) +- Tracing integration с Langfuse/LangSmith + +**Чего не даёт:** +- Ничего, что нельзя получить с прямым MCP +- CLI-агенты всё равно подключаются через свой нативный MCP-клиент, а не через Mastra + +**Проблемы:** +- Лишний слой абстракции (FastMCP -> Mastra MCPServer -> MCP protocol -> agent) +- Зависимость от быстро меняющегося фреймворка (Mastra уже менял API agent networks -> supervisors) +- Bundle size increase в Electron (~150KB+ от @mastra/core) +- Нет реальной выгоды: CLI-агенты не используют @mastra/mcp — они используют свои нативные MCP-клиенты + +### Вариант C: Mastra как оркестратор (создаёт/управляет агентами программно) + +**Надёжность: 3/10 | Уверенность: 3/10** + +``` +┌────────────────────────────────┐ +│ Electron App (UI) │ +├────────────────────────────────┤ +│ Main Process │ +│ ┌──────────────────────────┐ │ +│ │ Mastra Supervisor Agent │ │ ← Mastra управляет всем +│ │ model: anthropic/... │ │ +│ │ agents: { worker1, ... }│ │ +│ │ tools: { task_create } │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ ┌──────────┴───────────────┐ │ +│ │ Mastra Sub-Agents │ │ ← API-based, не CLI +│ │ openai/gpt-4o │ │ +│ │ anthropic/claude-sonnet │ │ +│ │ google/gemini-2.5-pro │ │ +│ └──────────────────────────┘ │ +└────────────────────────────────┘ +``` + +**Трудозатраты:** 8-12 недель + +**Что даёт:** +- Полная мультимодельность через API (40+ провайдеров) +- Mastra memory, workflows, evals + +**Чего не даёт:** +- Claude Code Agent Teams (нативные инструменты CLI: file editing, terminal, git, session persistence) +- Управление CLI-процессами +- Парсинг JSONL-сессий +- Всё, что делает наш продукт уникальным + +**Проблемы:** +- **Полностью ломает наш продукт.** Мы перестаём быть "Claude Agent Teams UI" и становимся "ещё один Mastra-based agent manager" +- Нужно заново реализовать file editing, bash, git tools (тысячи строк battle-tested кода в Claude Code) +- Теряем CLAUDE.md, hooks, settings.json, extended thinking — весь экосистемный Claude Code +- Mastra-агенты — API-based. Они НЕ запускаются как CLI-процессы с своим terminal и git integration + +--- + +## 5. Что насчёт "Skills" — 40+ AI агентов в Mastra? + +Это отдельная тема, которая может ввести в заблуждение. + +**"Skills" в Mastra — это НЕ готовые агенты.** Это markdown-файлы с инструкциями (CLAUDE.md, AGENTS.md), которые учат внешних AI coding agents (Claude Code, Cursor, Windsurf, Copilot и т.д.) использовать Mastra API. То есть Mastra генерирует `.cursor/rules` или `CLAUDE.md` с документацией по своему SDK. + +Список из 40+ "агентов" (AdaL, Amp, Antigravity, Augment, CodeBuddy, Crush, Droid, Goose, Kilo, Kimi CLI, Kiro CLI, Kode и т.д.) — это список IDE/CLI tools, для которых Mastra может сгенерировать instruction files. Это **НЕ** то, что Mastra может программно запускать или управлять. + +--- + +## 6. Экосистема: инструменты для оркестрации CLI-агентов + +Для полноты картины — вот что существует в марте 2026 для управления CLI-агентами как процессами (наша задача): + +| Инструмент | Что делает | GitHub | Подходит нам? | +|------------|-----------|--------|---------------| +| **CCManager** | Session manager для Claude/Codex/Gemini/OpenCode/Kilo CLI | [kbwo/ccmanager](https://github.com/kbwo/ccmanager) | Нет — TUI, не Electron; нет kanban | +| **MCO** | Neutral orchestration layer для CLI-агентов | [mco-org/mco](https://github.com/mco-org/mco) | Частично — dispatch layer, но без UI | +| **Mozzie** | Desktop tool для параллельной оркестрации | [ProductHunt](https://www.producthunt.com/products/mozzie) | Конкурент | +| **Nexus MCP** | MCP-сервер для вызова CLI-агентов как tools | [glama.ai](https://glama.ai/mcp/servers/j7an/nexus-mcp) | Интересно — позволяет одному агенту вызывать другой через MCP | +| **claude-code-teams-mcp** | Reimplementation Agent Teams как standalone MCP server | [cs50victor/claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) | Валидирует наш подход — MCP как universal integration layer | + +**Вывод:** Индустрия движется к MCP как универсальному протоколу, а не к Mastra как универсальному фреймворку. Mastra — для создания API-based агентов. MCP — для интеграции любых агентов с инструментами. + +--- + +## 7. Сводная таблица + +| Критерий | Вариант A: Прямой MCP | Вариант B: @mastra/mcp обёртка | Вариант C: Mastra оркестратор | +|----------|----------------------|-------------------------------|------------------------------| +| **Трудозатраты** | 2-3 недели (новые MCP tools) | 3-4 недели | 8-12 недель | +| **Что ломается** | Ничего | Ничего (additive) | Всё | +| **Code reuse** | 100% | 100% | ~20% | +| **Мультипровайдерность** | Любой MCP-совместимый агент | Любой MCP-совместимый агент | 40+ API провайдеров | +| **CLI-агенты** | Нативная поддержка | Нативная поддержка | Не поддерживаются | +| **Bundle size** | +0 KB | +150 KB+ (@mastra/core) | +150 KB+ | +| **Зависимость от Mastra** | Нет | Слабая | Полная | +| **Риск** | Очень низкий | Низкий | Очень высокий | +| **Наш продукт остаётся?** | Да | Да | Нет — становится другим продуктом | +| **Уникальность** | Kanban + Claude Code Teams + MCP | Kanban + Claude Code Teams + MCP | Ещё один Mastra-based agent manager | +| **Надёжность** | 9/10 | 5/10 | 3/10 | +| **Уверенность** | 9/10 | 4/10 | 3/10 | + +--- + +## 8. Финальная рекомендация + +### Не используем @mastra/mcp. Используем прямой MCP. + +**Причины:** + +1. **Mastra решает не нашу проблему.** Mastra — SDK для создания API-based агентов. Наш продукт — менеджер CLI-процессов. Разные домены. + +2. **CLI-агенты уже поддерживают MCP нативно.** Claude Code, Codex, Gemini CLI, Goose, OpenCode, Kilo — все могут подключиться к нашему MCP-серверу без Mastra. + +3. **@mastra/mcp — лишний слой.** CLI-агенты не используют Mastra MCPClient. Они используют свои нативные MCP-клиенты. Mastra MCPServer просто обернёт наш FastMCP-сервер без добавления ценности. + +4. **Наш MCP-сервер уже работает.** 30+ инструментов, battle-tested с Claude Code Agent Teams. Нужно добавить 5-8 новых инструментов для external agents — и готово. + +5. **Zero dependency = zero risk.** Mastra меняет API быстро (agent networks -> supervisors за месяцы). Прямой MCP — стабильный стандарт (v1.0+, AAIF/Linux Foundation). + +6. **Наше конкурентное преимущество — kanban + Claude Code Agent Teams.** Mastra не усиливает это. Mastra превращает нас в generic agent manager, которых уже десятки. + +### Что делать вместо Mastra + +Следовать плану из `docs/research/best-integration-approach.md` — **Option 7: Hybrid**: + +1. **Phase 1 (неделя 1-2):** Добавить MCP-инструменты для external agents: `team_join`, `team_leave`, `task_poll_assigned`, `task_claim`, `member_register`, `member_heartbeat` +2. **Phase 2 (неделя 2-3):** UI-поддержка внешних агентов: provider badge, external member type +3. **Phase 3 (неделя 3-4):** Notification mechanism (polling, SSE) +4. **Phase 4 (по запросу):** Нативная поддержка второго CLI-агента (Codex) через `AgentRuntime` abstraction + +### Когда Mastra МОЖЕТ понадобиться + +- Если мы решим создавать **API-based агентов** для задач, не требующих CLI (code review, planning, triage) — Mastra Agent + наш MCP server +- Если мы решим добавить **ToolSearchProcessor** для discovery среди сотен инструментов (сейчас у нас 30+, не актуально) +- Если мы решим экспортировать наши агенты/workflow как **standalone MCP server** для внешних систем (Mastra MCPServer может быть удобнее FastMCP) +- Если Claude Code CLI будет **deprecated** (никаких признаков этого) + +Но это всё сценарии "если" на далёкое будущее. Сейчас прямой MCP — правильный и достаточный выбор. + +--- + +## Источники + +- [Mastra GitHub Repository (22K+ stars)](https://github.com/mastra-ai/mastra) +- [Mastra MCP Overview](https://mastra.ai/docs/mcp/overview) +- [Mastra Agents Overview](https://mastra.ai/docs/agents/overview) +- [Mastra Agent Networks](https://mastra.ai/docs/agents/networks) +- [@mastra/mcp npm](https://www.npmjs.com/package/@mastra/mcp) +- [Why We're All-In on MCP (Mastra Blog)](https://mastra.ai/blog/mastra-mcp) +- [Mastra 1.0 Announcement (300K+ weekly downloads, 19.4K stars)](https://mastra.ai/blog/announcing-mastra-1) +- [Mastra Changelog 2026-03-12](https://mastra.ai/blog/changelog-2026-03-12) +- [Claude Code MCP Docs](https://code.claude.com/docs/en/mcp) +- [OpenAI Codex MCP](https://developers.openai.com/codex/mcp) +- [Gemini CLI MCP](https://geminicli.com/docs/tools/mcp-server/) +- [Goose — open source AI agent by Block](https://github.com/block/goose) +- [OpenCode MCP](https://opencode.ai/docs/mcp-servers/) +- [Kilo Code MCP](https://kilo.ai/docs/automate/mcp/using-in-kilo-code) +- [Aider MCP Server](https://www.pulsemcp.com/servers/disler-aider) +- [claude-code-teams-mcp (standalone reimplementation)](https://github.com/cs50victor/claude-code-teams-mcp) +- [CCManager (session manager)](https://github.com/kbwo/ccmanager) +- [MCO (multi-agent orchestrator)](https://github.com/mco-org/mco) +- [Nexus MCP (CLI agents as MCP tools)](https://glama.ai/mcp/servers/j7an/nexus-mcp) +- [Mastra ToolSearchProcessor (Feb 2026)](https://mastra.ai/blog/changelog-2026-02-04) +- [Google Official MCP Support Announcement](https://cloud.google.com/blog/products/ai-machine-learning/announcing-official-mcp-support-for-google-services) +- [Agentic AI Foundation (AAIF) — Linux Foundation](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation) diff --git a/docs/research/unified-cli-agent-interface.md b/docs/research/unified-cli-agent-interface.md new file mode 100644 index 00000000..76489a32 --- /dev/null +++ b/docs/research/unified-cli-agent-interface.md @@ -0,0 +1,533 @@ +# Unified CLI Agent Interface — Research (March 2026) + +Research on tools/libraries providing a unified interface for calling multiple AI coding CLI agents abstractly (Claude Code, Codex CLI, Gemini CLI, Goose, OpenCode, Aider, etc.). + +## Summary & Recommendation + +**No single battle-tested npm library exists** that abstracts CLI agent spawning behind a clean TypeScript interface suitable for embedding in an Electron app. The ecosystem is fragmented across ~10 projects, each with tradeoffs. The most relevant options for our use case are: + +| Project | Lang | Approach | Library Use | Agents | Our Fit | +|---------|------|----------|-------------|--------|---------| +| **Coder AgentAPI** | Go | HTTP API over terminal emulation | Via HTTP (language-agnostic) | 11 | 8/10 | +| **all-agents-mcp** | TS | MCP server, child process spawn | npm import or MCP | 4 | 7/10 | +| **Overstory** | TS | AgentRuntime interface + tmux | CLI only (Bun) | 11 | 6/10 | +| **Composio Agent Orchestrator** | TS | Plugin architecture, worktrees | Build from source | 4+ | 5/10 | +| **MCO** | Python | CLI adapter hooks | CLI/MCP only | 5+ | 4/10 | +| **Network-AI** | TS | Blackboard coordination | npm library | 17 adapters* | 3/10 | +| **AWS CAO** | Python | tmux + MCP server | REST API | 7 | 4/10 | + +\* Network-AI adapters are for AI frameworks (LangChain, CrewAI, etc.), not CLI coding agents directly. + +**Recommended approach**: Extract the adapter pattern from Coder AgentAPI or all-agents-mcp, build our own thin `AgentAdapter` interface in TypeScript. + +--- + +## Tier 1 — Most Relevant for Our Use Case + +### 1. Coder AgentAPI + +**The most mature unified interface for controlling CLI coding agents programmatically.** + +- **URL**: https://github.com/coder/agentapi +- **Stars**: ~1,300 +- **Language**: Go (82%), TypeScript (15% — web UI) +- **License**: MIT +- **npm package**: None (Go binary, HTTP API) + +#### How it works +Runs an in-memory terminal emulator (Go). Translates API calls into terminal keystrokes, parses agent output into structured messages. Each agent type has a message formatter in `lib/msgfmt/`. + +#### Supported agents (11) +Claude Code, Goose, Aider, Gemini CLI, GitHub Copilot, AmazonQ, OpenCode, Sourcegraph Amp, Codex, Auggie, Cursor CLI. + +#### API surface +``` +GET /messages — conversation history +POST /message — send message (type: "user" | "raw") +GET /status — "stable" | "running" +GET /events — SSE stream (real-time) +GET /openapi.json — full OpenAPI schema +``` + +#### Integration with Electron +- Spawn `agentapi server --type=claude -- claude` as child process +- Communicate via HTTP (localhost:3284) +- SSE events for real-time status updates +- Can generate TS client from OpenAPI spec using `@hey-api/openapi-ts` +- **Con**: Requires Go binary distribution alongside Electron app +- **Con**: Terminal emulation approach is fragile — keystrokes, not stdin/stdout protocol + +#### Reliability: 7/10 +#### Confidence: 8/10 — well-maintained by Coder (enterprise company), actively updated + +Source: [github.com/coder/agentapi](https://github.com/coder/agentapi) + +--- + +### 2. all-agents-mcp + +**TypeScript MCP server that orchestrates agents via unified stdio interface.** + +- **URL**: https://github.com/Dokkabei97/all-agents-mcp +- **npm**: `all-agents-mcp` +- **Language**: TypeScript (100%) +- **License**: MIT (assumed) + +#### How it works +Invokes each agent's CLI binary as a child process. Each agent implementation extends `BaseAgent` abstract class which handles process spawning, stdin/stdout capture. No API bypass — pure process orchestration. + +#### Key TypeScript interface +``` +src/agents/ + IAgent interface — identity, availability, execution, health + BaseAgent abstract class — spawn logic, stdin/stdout + claude-agent.ts + codex-agent.ts + gemini-agent.ts + copilot-agent.ts +``` + +#### Supported agents (4) +Claude Code, Codex CLI, Gemini CLI, GitHub Copilot CLI. + +#### API surface (MCP tools) +- `ask_agent` — single agent query +- `ask_all` — parallel multi-agent comparison +- `delegate_task` — complexity-based routing +- `cross_verify` — same agent, multiple models +- Plus specialized: code review, debug, explain, test gen, refactor + +#### Integration with Electron +- **Pure TypeScript** — best language fit +- Can import as library or run as MCP server +- Child process spawning maps well to our existing architecture +- `IAgent` interface is close to what we need +- **Con**: Only 4 agents (vs 11 in AgentAPI) +- **Con**: Young project, may lack edge case handling +- **Con**: MCP-first design, not raw process management + +#### Reliability: 5/10 +#### Confidence: 6/10 — concept is solid, but limited agent coverage + +Source: [github.com/Dokkabei97/all-agents-mcp](https://github.com/Dokkabei97/all-agents-mcp) + +--- + +### 3. Overstory + +**Multi-agent orchestration with pluggable AgentRuntime interface — most agents supported.** + +- **URL**: https://github.com/jayminwest/overstory +- **npm**: `@os-eco/overstory-cli` +- **Language**: TypeScript (Bun runtime) +- **License**: MIT + +#### AgentRuntime interface (`src/runtimes/types.ts`) +Defines the contract each adapter must implement: +- Spawning +- Config deployment +- Guard enforcement +- Readiness detection +- Transcript parsing + +#### Supported runtimes (11) +Claude Code, Pi, Copilot, Cursor, Codex, Gemini CLI, Aider, Goose, Amp, OpenCode, Sapling. + +#### Architecture +- Agents run in isolated **git worktrees via tmux** +- Inter-agent messaging via **SQLite** (`.overstory/mail.db`, WAL mode) +- Tiered conflict resolution for merge +- Watchdog daemon for health monitoring +- Hierarchy: Orchestrator → Coordinator → Supervisor → Workers + +#### Integration with Electron +- TypeScript — good language fit +- `AgentRuntime` interface is the cleanest abstraction found +- **Con**: Requires Bun (not Node.js) +- **Con**: Hard dependency on tmux (not available on Windows, awkward in Electron) +- **Con**: Designed as CLI orchestrator, not embeddable library +- **Con**: Heavy — mail system, worktrees, watchdog are overhead we don't need + +#### What we can extract +The `AgentRuntime` interface pattern is the most instructive. We could model our own adapter interface after it, implementing only spawn/communicate/status methods. + +#### Reliability: 6/10 +#### Confidence: 5/10 — great architecture design but tmux/Bun deps make it impractical for Electron + +Source: [github.com/jayminwest/overstory](https://github.com/jayminwest/overstory) + +--- + +## Tier 2 — Useful Reference, Not Direct Import + +### 4. ComposioHQ Agent Orchestrator + +**Enterprise-grade TypeScript orchestrator with plugin architecture.** + +- **URL**: https://github.com/ComposioHQ/agent-orchestrator +- **Language**: TypeScript (91.5%), pnpm monorepo +- **npm**: Not published (build from source, `npm link -g packages/cli`) +- **License**: Not specified +- **Stars**: Growing, backed by Composio (well-funded company) + +#### Plugin architecture (8 slots) +| Slot | Default | Alternatives | +|------|---------|-------------| +| Runtime | tmux | docker, k8s, process | +| Agent | claude-code | codex, aider, opencode | +| Workspace | worktree | clone | +| Tracker | github | linear | +| Notifier | desktop | slack, composio, webhook | +| Terminal | iterm2 | web | + +All interfaces in `packages/core/src/types.ts`. Plugins implement one interface and export a `PluginModule`. + +#### Key stats +40,000 lines of TypeScript, 17 plugins, 3,288 tests. + +#### Integration with Electron +- TypeScript monorepo — compatible +- Plugin interface is clean and extensible +- **Con**: Not published as npm package +- **Con**: Heavy — includes dashboard, CI integration, PR management +- **Con**: tmux as default runtime +- **Con**: Designed for autonomous operation, not interactive control + +#### Reliability: 6/10 +#### Confidence: 5/10 — impressive codebase but too heavy for embedding + +Source: [github.com/ComposioHQ/agent-orchestrator](https://github.com/ComposioHQ/agent-orchestrator) + +--- + +### 5. MCO (Multi-CLI Orchestrator) + +**Python-based neutral orchestration layer for CLI coding agents.** + +- **URL**: https://github.com/mco-org/mco +- **npm**: `@tt-a1i/mco` (Node.js wrapper around Python) +- **Language**: Python (core), Node.js (wrapper) +- **Requires**: Python 3.10+ + +#### Adapter architecture +Adding a new agent CLI requires implementing three hooks: +1. Auth check +2. Command builder +3. Output normalizer + +Supports two transport modes: Shim (stdout parsing) and ACP (JSON-RPC). + +#### Supported agents (5+) +Claude Code, Codex CLI, Gemini CLI, OpenCode, Qwen Code. Custom agents via `.mco/agents.yaml`. + +#### Features +- Parallel dispatch + consensus engine (`agreement_ratio`, `consensus_score`) +- JSON/SARIF/Markdown output +- Debate mode, divide mode (files/dimensions) +- MCP server mode for programmatic access + +#### Integration with Electron +- **Con**: Python dependency — very problematic for Electron distribution +- **Con**: Not a library, primarily CLI +- MCP server mode could work but adds complexity +- The 3-hook adapter pattern is a useful design reference + +#### Reliability: 5/10 +#### Confidence: 4/10 — Python dependency is a dealbreaker for Electron + +Source: [github.com/mco-org/mco](https://github.com/mco-org/mco) + +--- + +### 6. AWS CLI Agent Orchestrator (CAO) + +**AWS-backed orchestrator with supervisor-worker pattern via tmux + MCP.** + +- **URL**: https://github.com/awslabs/cli-agent-orchestrator +- **Language**: Python 3.10+ +- **Install**: `uv tool install` (not on PyPI) +- **License**: Apache 2.0 + +#### Supported providers (7) +Kiro CLI, Claude Code, Codex CLI, Gemini CLI, Kimi CLI, GitHub Copilot CLI, Q CLI. + +#### Orchestration patterns +1. **Handoff** — synchronous task transfer with wait-for-completion +2. **Assign** — asynchronous spawning for parallel execution +3. **Send Message** — direct communication with existing agents + +#### REST API +Server on `localhost:9889` — session management, terminal control, messaging. + +#### Integration with Electron +- **Con**: Python — not suitable for Electron +- **Con**: tmux dependency +- REST API approach could be adapted +- Agent profile system is well-designed (provider key in frontmatter) + +#### Reliability: 7/10 +#### Confidence: 4/10 — solid engineering (AWS) but Python/tmux deps block Electron use + +Source: [github.com/awslabs/cli-agent-orchestrator](https://github.com/awslabs/cli-agent-orchestrator), [AWS Blog](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) + +--- + +### 7. Network-AI + +**TypeScript multi-agent coordination with atomic shared state.** + +- **URL**: https://github.com/jovanSAPFIONEER/Network-AI +- **npm**: `network-ai` +- **Language**: TypeScript +- **License**: MIT + +#### Key concept +Solves the "last-write-wins" problem with atomic `propose -> validate -> commit` semantics using filesystem-based mutual exclusion. + +#### 17 adapters +LangChain, AutoGen, CrewAI, OpenAI Assistants, LlamaIndex, Semantic Kernel, Haystack, DSPy, Agno, MCP, Custom, OpenClaw, A2A, Codex, MiniMax, NemoClaw, APS. + +**Important caveat**: These are adapters for AI *frameworks* (LangChain, CrewAI), not CLI coding agents (Claude Code, Aider). The Codex adapter is for OpenAI API, not Codex CLI. + +#### Library usage +```typescript +import { LockedBlackboard, CustomAdapter, createSwarmOrchestrator } from 'network-ai'; +``` + +#### Integration with Electron +- TypeScript + npm — good language fit +- Importable as library +- **Con**: Solves a different problem (framework coordination, not CLI agent spawning) +- **Con**: No adapters for CLI coding agents specifically +- Blackboard pattern could be useful for inter-agent state + +#### Reliability: 5/10 +#### Confidence: 3/10 — wrong abstraction level for our needs + +Source: [github.com/jovanSAPFIONEER/Network-AI](https://github.com/jovanSAPFIONEER/Network-AI) + +--- + +## Tier 3 — Ecosystem Context + +### 8. Pi (pi-mono) + +**TypeScript monorepo — coding agent toolkit with unified LLM API.** + +- **URL**: https://github.com/badlogic/pi-mono +- **npm**: `@mariozechner/pi-coding-agent` +- **Language**: TypeScript (Bun) +- **Stars**: 25,400+ + +Not a multi-agent orchestrator — it's a coding agent itself (like Claude Code but open source). Relevant because its modular package design (`pi-ai`, `pi-agent-core`, `pi-coding-agent`, `pi-tui`) shows how to abstract agent internals. Supports 15+ LLM providers. + +Source: [github.com/badlogic/pi-mono](https://github.com/badlogic/pi-mono) + +--- + +### 9. AI Code Agents SDK (Felix Arntz) + +**TypeScript SDK for vendor-lock-in-free coding agents.** + +- **Blog**: https://felix-arntz.me/blog/introducing-ai-code-agents-a-typescript-sdk-to-solve-vendor-lock-in-for-coding-agents/ +- **Language**: TypeScript +- **Built on**: Vercel AI SDK +- **Status**: Very early stage (announced November 2025) + +Abstracts **Environment** (sandboxed execution contexts) and **Tools** (file system, commands) behind interfaces. Model-agnostic via Vercel AI SDK. + +**Not a CLI agent spawner** — it's an SDK for *building* coding agents, not orchestrating existing ones. No GitHub repository found (may be private or unreleased). + +#### Reliability: 2/10 (not yet available) +#### Confidence: 3/10 + +--- + +### 10. Claude Code Bridge (ccb) + +**Terminal-based multi-AI collaboration via split panes.** + +- **URL**: https://github.com/bfly123/claude_code_bridge +- **Stars**: 1,759 +- **Language**: **Python** (not TypeScript) + +Orchestrates Claude, Codex, Gemini, OpenCode, Droid through terminal multiplexer (WezTerm/tmux) split panes. 50-200 tokens per call via persistent sessions. + +**Not suitable**: Python, tmux-based, designed for human-visible terminal interaction. + +Source: [github.com/bfly123/claude_code_bridge](https://github.com/bfly123/claude_code_bridge) + +--- + +## Related Infrastructure + +### node-pty + xterm.js (Terminal Emulation in Electron) + +The foundational building blocks if we build our own solution: + +- **node-pty**: `npm install node-pty` — fork pseudoterminals in Node.js. Used by VS Code, Hyper, and many Electron terminal apps. Supports Linux, macOS, Windows (conpty). [github.com/microsoft/node-pty](https://github.com/microsoft/node-pty) +- **xterm.js**: Terminal emulator for the browser/Electron renderer. [github.com/xtermjs/xterm.js](https://github.com/xtermjs/xterm.js) +- **@loopmode/xpty**: React component + helpers for building terminals in Electron with xterm.js + node-pty. [github.com/loopmode/xpty](https://github.com/loopmode/xpty) + +This is essentially what Coder AgentAPI does in Go. We could replicate the approach in TypeScript using node-pty directly. + +**Important**: node-pty is **not thread-safe** and requires native compilation. Already used by many Electron apps successfully. + +--- + +### Anthropic Claude Agent SDK (Official) + +- **npm**: `@anthropic-ai/claude-agent-sdk` +- **URL**: https://github.com/anthropics/claude-agent-sdk-typescript +- **Docs**: https://platform.claude.com/docs/en/agent-sdk/typescript + +Official SDK for spawning Claude Code programmatically. Includes `spawnClaudeCodeProcess` option, `AgentDefinition` for subagents. Only works with Claude Code. + +--- + +### Awesome CLI Coding Agents (Curated List) + +Comprehensive directory of 80+ CLI coding agents + orchestrators: +- **URL**: https://github.com/bradAGI/awesome-cli-coding-agents + +Notable orchestrators from the list: +- **Superset** (7.4k stars) — terminal for coding agents, parallel sessions +- **Claude Squad** (6.4k stars) — tmux multi-session Claude Code +- **Crystal** (3.0k stars) — parallel agents in git worktrees +- **Toad** (2.7k stars) — agent orchestrator for parallel CLI sessions +- **Emdash** (2.7k stars) — concurrent coding agents + +--- + +## Key Findings + +### 1. No universal npm library exists +There is no `npm install universal-agent` that gives you a clean TypeScript interface to spawn and communicate with arbitrary CLI coding agents. The ecosystem is solving this problem in different ways (MCP servers, HTTP APIs, tmux wrappers, CLI tools) but none are designed as embeddable libraries for Electron. + +### 2. Two architectural approaches dominate + +**Terminal emulation** (AgentAPI approach): +- Spawn a PTY, type into it, parse output +- Works with ANY CLI agent without modification +- Fragile — depends on terminal output format +- Message boundaries are hard to detect + +**stdin/stdout protocol** (our current Claude Code approach): +- `--input-format stream-json --output-format stream-json` +- Clean structured communication +- Only works if CLI supports it +- Each agent has its own protocol (or none) + +### 3. Agent protocol fragmentation +Each CLI agent has a different communication protocol: +- **Claude Code**: stream-json stdin/stdout +- **Codex CLI**: `--json` flag, structured output +- **Gemini CLI**: No programmatic API documented +- **Goose**: Custom protocol +- **Aider**: Text-based, `--message` flag +- **OpenCode**: No public programmatic API + +This fragmentation is why projects like AgentAPI resort to terminal emulation — it's the only truly universal approach. + +### 4. MCP as potential unifier +MCP (Model Context Protocol) is emerging as a common integration point. All major coding agents now support MCP for tools, and projects like MCO and all-agents-mcp use MCP as the orchestration transport. However, MCP doesn't solve the agent *spawning* and *lifecycle management* problem. + +### 5. The ACP (Agent Client Protocol) is emerging +The Agent Client Protocol (mentioned in MCO's ACP mode and the Cursor ACP adapter) may become a standard for agent-to-agent communication, but it's too early and not widely adopted. + +--- + +## Proposed Architecture for Our Project + +Based on this research, the recommended approach is to build our own thin abstraction layer: + +```typescript +// AgentAdapter interface (inspired by Overstory's AgentRuntime + all-agents-mcp's IAgent) +interface AgentAdapter { + // Identity + readonly id: string; // "claude-code" | "codex" | "gemini" | etc. + readonly displayName: string; + + // Detection + isInstalled(): Promise; + getVersion(): Promise; + + // Lifecycle + spawn(config: AgentSpawnConfig): Promise; + + // Capabilities + supportsMcp(): boolean; + supportsStreamJson(): boolean; + supportsTeams(): boolean; +} + +interface AgentProcess { + // Communication + sendMessage(text: string): Promise; + onMessage(handler: (msg: AgentMessage) => void): void; + onStatus(handler: (status: AgentStatus) => void): void; + + // Lifecycle + isAlive(): boolean; + kill(): Promise; + + // Process + readonly pid: number; + readonly stdin: Writable; + readonly stdout: Readable; +} + +interface AgentSpawnConfig { + workingDir: string; + mcpConfig?: string; // path to MCP config file + model?: string; + maxTokens?: number; + disallowedTools?: string[]; + env?: Record; + systemPrompt?: string; +} +``` + +### Implementation approaches (ranked) + +**Option A: Direct child_process spawn with per-agent formatters (Recommended)** +- Use Node.js `child_process.spawn()` for each agent +- Each adapter knows the correct CLI flags and I/O format +- Similar to all-agents-mcp's `BaseAgent` approach +- Reliability: 8/10, Confidence: 9/10 + +**Option B: node-pty terminal emulation (AgentAPI approach in TS)** +- Use `node-pty` to spawn PTY for each agent +- Parse terminal output, inject keystrokes +- Works with any agent but fragile +- Reliability: 6/10, Confidence: 7/10 + +**Option C: Wrap Coder AgentAPI as subprocess** +- Spawn `agentapi server` as a sidecar process +- Communicate via HTTP API +- Leverage their 11 agent support +- Reliability: 7/10, Confidence: 6/10 (Go binary distribution complexity) + +**Option D: Fork all-agents-mcp's TypeScript code** +- Take the IAgent/BaseAgent pattern +- Extend with more agents +- Reliability: 6/10, Confidence: 7/10 + +--- + +## Sources + +- [Coder AgentAPI](https://github.com/coder/agentapi) — HTTP API for 11 coding agents (Go) +- [all-agents-mcp](https://github.com/Dokkabei97/all-agents-mcp) — TypeScript MCP server for 4 agents +- [Overstory](https://github.com/jayminwest/overstory) — AgentRuntime interface with 11 runtimes (TS/Bun) +- [ComposioHQ Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) — TS monorepo, plugin architecture +- [MCO](https://github.com/mco-org/mco) — Python multi-CLI orchestrator with adapter hooks +- [AWS CLI Agent Orchestrator](https://github.com/awslabs/cli-agent-orchestrator) — Python, supervisor-worker pattern +- [Network-AI](https://github.com/jovanSAPFIONEER/Network-AI) — TS, 17 framework adapters, npm library +- [Pi (pi-mono)](https://github.com/badlogic/pi-mono) — TS coding agent toolkit +- [Claude Code Bridge](https://github.com/bfly123/claude_code_bridge) — Python multi-AI collaboration +- [Awesome CLI Coding Agents](https://github.com/bradAGI/awesome-cli-coding-agents) — curated directory of 80+ agents +- [node-pty](https://github.com/microsoft/node-pty) — PTY for Node.js (Microsoft) +- [Anthropic Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk-typescript) — Official TS SDK +- [Felix Arntz blog — AI Code Agents SDK](https://felix-arntz.me/blog/introducing-ai-code-agents-a-typescript-sdk-to-solve-vendor-lock-in-for-coding-agents/) — Vendor lock-in abstraction concept +- [AWS Blog — CLI Agent Orchestrator](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) diff --git a/docs/research/unified-llm-api-tools.md b/docs/research/unified-llm-api-tools.md new file mode 100644 index 00000000..3b8e0e8d --- /dev/null +++ b/docs/research/unified-llm-api-tools.md @@ -0,0 +1,571 @@ +# Unified LLM API Libraries for TypeScript/Electron + +> **Date:** 2026-03-24 +> **Goal:** Find the best library that provides a single API for calling multiple LLM providers (OpenAI, Anthropic, Google, etc.) from our Electron app. +> **Requirements:** TypeScript-native, tool calling, streaming, can run in Electron (no server), open source, actively maintained, MCP integration + +--- + +## TL;DR — Recommendation + +**Vercel AI SDK (`ai` + `@ai-sdk/*` providers)** is the clear winner for our use case. + +| Criteria | Winner | +|---|---| +| Best as a library (not framework) | Vercel AI SDK | +| Tool calling across providers | Vercel AI SDK | +| Streaming | Vercel AI SDK | +| TypeScript DX | Vercel AI SDK | +| MCP integration | Vercel AI SDK | +| Runs in Electron (no server) | Vercel AI SDK, multi-llm-ts | +| Community & maintenance | Vercel AI SDK | +| Lightweight / minimal footprint | multi-llm-ts | + +If we need something **even simpler** with zero framework overhead and 12 provider support, `multi-llm-ts` is a solid lightweight alternative (already used by a production Electron app — Witsy). + +--- + +## Candidates Compared + +### 1. Vercel AI SDK (RECOMMENDED) + +| | | +|---|---| +| **Package** | `ai` (core), `@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, etc. | +| **GitHub** | [github.com/vercel/ai](https://github.com/vercel/ai) | +| **Stars** | ~23K | +| **npm downloads** | ~4.5M/week (across `ai` + `@ai-sdk/*` packages) | +| **License** | Apache 2.0 | +| **Latest version** | ai@6.0.138 (March 2026) | +| **TypeScript** | Native TypeScript, written from scratch. Excellent DX. | +| **Contributors** | 597+ | + +**Provider coverage:** +100+ models supported. Official provider packages for: OpenAI, Anthropic, Google (Gemini), Mistral, Cohere, Amazon Bedrock, Azure OpenAI, xAI (Grok), Groq, Perplexity, Fireworks, Together AI, DeepSeek, Ollama (local), and 40+ community providers including OpenRouter, Portkey, etc. + +**Tool calling:** Full support via `generateText` and `streamText`. Multi-step tool execution loops with `stopWhen`. AI SDK 6 introduces `ToolLoopAgent` for automatic tool execution. `needsApproval: true` for human-in-the-loop. Type-safe tool definitions with Zod schemas. + +**Streaming:** First-class streaming via `streamText()` and `streamObject()`. Returns async iterable `textStream`. No custom parsing needed. + +**MCP integration:** Full MCP support since AI SDK 6. Built-in MCP client with `tools()` method that adapts MCP tools to AI SDK tools. Supports HTTP/SSE/stdio transports. OAuth authentication for MCP servers. Elicitation support (MCP servers can request user input). + +**Can run in Electron:** YES. `generateText()` and `streamText()` are pure Node.js functions — no web server required. Work directly in Electron's main process. Confirmed by Sentry's Electron + Vercel AI integration. Community project [electron-ai-chatbot](https://github.com/pashvc/electron-ai-chatbot) exists. + +**Maturity:** Very high. Used by Thomson Reuters, Clay, and "teams ranging from startups to Fortune 500 companies". 20M+ monthly downloads. Active development with frequent releases (multiple per week). + +**Strengths:** +- Most library-like: single function calls (`generateText`, `streamText`, `generateObject`), no framework lock-in +- Switch providers by changing one line of code +- Best TypeScript DX in the category +- Huge ecosystem of provider packages +- Excellent documentation at [ai-sdk.dev](https://ai-sdk.dev/) +- Built-in fallbacks in AI SDK 6 +- DevTools for debugging LLM calls + +**Weaknesses:** +- Provider packages add separate dependencies (though each is small) +- UI hooks (`useChat`, `useCompletion`) are React/web focused — not relevant for our Electron main process use +- Some newer features (AI SDK 6) are still stabilizing + +**Reliability: 9/10 | Confidence: 9/10** + +**Links:** +- [Official docs](https://ai-sdk.dev/docs/introduction) +- [Tool calling docs](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) +- [MCP tools docs](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools) +- [Node.js getting started](https://ai-sdk.dev/docs/getting-started/nodejs) +- [AI SDK 6 announcement](https://vercel.com/blog/ai-sdk-6) +- [npm: ai](https://www.npmjs.com/package/ai) +- [GitHub](https://github.com/vercel/ai) + +--- + +### 2. multi-llm-ts (Lightweight Alternative) + +| | | +|---|---| +| **Package** | `multi-llm-ts` | +| **GitHub** | [github.com/nbonamy/multi-llm-ts](https://github.com/nbonamy/multi-llm-ts) | +| **Stars** | ~50 (small project) | +| **npm downloads** | ~211/week | +| **License** | MIT | +| **Latest version** | 4.6.2 (March 2026) | +| **TypeScript** | Native TypeScript | +| **Maintainers** | 1 | + +**Provider coverage:** +12 providers: OpenAI, Anthropic, Google, Mistral, Groq, Ollama, xAI, DeepSeek, Cerebras, Meta/Llama, Azure AI, OpenRouter. + +**Tool calling:** Built-in plugin/tool system. Define tools with parameter descriptions and execution logic. Tool calling handled automatically across all providers. + +**Streaming:** `complete()` (non-streaming) and `generate()` (streaming) methods. + +**MCP integration:** None built-in. + +**Can run in Electron:** YES. Already powering [Witsy](https://github.com/nbonamy/witsy) — a production Electron desktop AI assistant using 20+ providers through this library. This is the most proven Electron integration of any library on this list. + +**Maturity:** Active development, frequent releases. Small community but proven in production via Witsy. + +**Strengths:** +- Smallest, most focused library — does exactly one thing well +- Already proven in a real Electron desktop app +- MIT license +- Clean abstraction: `igniteEngine()` / `igniteModel()` → `complete()` / `generate()` +- AbortSignal support for cancellation +- Token usage tracking +- Multi-attachment support + +**Weaknesses:** +- Single maintainer — bus factor risk +- Very small community (~211 downloads/week) +- No MCP integration +- No structured output (generateObject equivalent) +- 12 providers vs 100+ in Vercel AI SDK +- Limited documentation + +**Reliability: 6/10 | Confidence: 7/10** + +**Links:** +- [npm: multi-llm-ts](https://www.npmjs.com/package/multi-llm-ts) +- [GitHub](https://github.com/nbonamy/multi-llm-ts) +- [Witsy (Electron app using it)](https://github.com/nbonamy/witsy) + +--- + +### 3. Mastra + +| | | +|---|---| +| **Package** | `@mastra/core` | +| **GitHub** | [github.com/mastra-ai/mastra](https://github.com/mastra-ai/mastra) | +| **Stars** | ~19.8K | +| **npm downloads** | ~300K/week | +| **License** | Apache 2.0 (core), Enterprise License (ee/ features) | +| **Latest version** | 1.x (January 2026 v1.0) | +| **TypeScript** | Native TypeScript, from Gatsby team | + +**Provider coverage:** +3,388 models from 94 providers — because it uses Vercel AI SDK under the hood for model routing. + +**Tool calling:** Full tool calling support. Define tools with schemas and descriptions. `ToolSearchProcessor` lets agents search for and load tools on demand. + +**Streaming:** Yes, via Vercel AI SDK. + +**MCP integration:** Yes, via `@mastra/mcp` package. Acts as both MCP client and server. Supports SSE, HTTP, and Hono-based MCP servers. MCP tool calls are traced with dedicated span types. + +**Can run in Electron:** Partially. Mastra has an official [Electron guide](https://mastra.ai/guides/getting-started/electron). However, it's designed as a server-side framework with HTTP endpoints. Using it in Electron's main process would mean importing a framework designed for servers into a desktop app. + +**Maturity:** High. v1.0 since January 2026. Y Combinator W25 batch ($13M funding). Used by Replit, PayPal, Sanity. + +**Strengths:** +- Huge provider coverage (94 providers through Vercel AI SDK) +- Built-in agents, workflows, memory, evals +- Clean TypeScript DX +- Strong MCP integration including MCP server authoring +- Backed by VC funding and large team +- Official Electron guide exists + +**Weaknesses:** +- It's a FRAMEWORK, not a library — brings entire agent/workflow/memory system +- Heavy dependency graph (`@mastra/core` pulls in many dependencies) +- Enterprise license for some features (RBAC, ACL) +- Designed primarily for server environments +- Overkill if you just need to call LLMs from Electron +- Uses Vercel AI SDK internally — so you'd be adding a framework layer on top of the library we actually need + +**Reliability: 8/10 | Confidence: 6/10** (for our "library" use case — it's a great framework but overkill) + +**Links:** +- [mastra.ai](https://mastra.ai/) +- [Docs](https://mastra.ai/docs) +- [Electron guide](https://mastra.ai/guides/getting-started/electron) +- [npm: @mastra/core](https://www.npmjs.com/package/@mastra/core) +- [GitHub](https://github.com/mastra-ai/mastra) +- [MCP integration docs](https://docs.mcp.run/integrating/tutorials/mcpx-mastra-ts/) + +--- + +### 4. LangChain.js + +| | | +|---|---| +| **Package** | `langchain`, `@langchain/core`, `@langchain/openai`, etc. | +| **GitHub** | [github.com/langchain-ai/langchainjs](https://github.com/langchain-ai/langchainjs) | +| **Stars** | ~17.3K | +| **npm downloads** | ~1M/week | +| **License** | MIT | +| **Latest version** | langchain@1.2.30 (March 2026) | +| **TypeScript** | TypeScript, ported from Python | + +**Provider coverage:** +100+ LLM providers, 50+ vector stores, hundreds of tools. + +**Tool calling:** Standardized `tool_calls` interface on AIMessage. `bind_tools()` and `create_tool_calling_agent()`. Dynamic tools and recovery from hallucinated tool calls (since v1.2.13). Custom Vitest matchers for tool call assertions. + +**Streaming:** Yes, via `streamEvents` and async iterators. Real-time streaming with `StreamEvents`. + +**MCP integration:** Community integrations exist but not first-party like Vercel AI SDK. + +**Can run in Electron:** Yes, technically (it's Node.js), but: +- Heavy: 101.2 kB gzipped bundle +- Designed for server environments +- Many abstractions add overhead + +**Maturity:** Very high. Largest ecosystem. LangSmith for observability. 8 maintainers. + +**Strengths:** +- Largest ecosystem and community +- Most integrations (100+ providers, 50+ vector stores) +- LangSmith for production observability +- LangGraph for complex agent workflows +- Mature, well-documented + +**Weaknesses:** +- Most framework-like — imposes architecture +- Heaviest bundle (101.2 kB gzipped) +- More boilerplate than Vercel AI SDK +- TypeScript feels like a port from Python (Python-first design) +- Frequent breaking changes historically +- "Powerful but sometimes overly complex for straightforward use cases" +- Edge runtime blocked + +**Reliability: 8/10 | Confidence: 5/10** (for our use case — great framework, wrong fit for lightweight Electron integration) + +**Links:** +- [langchain.com](https://www.langchain.com/) +- [JS docs](https://docs.langchain.com/oss/javascript/langchain/overview) +- [npm: langchain](https://www.npmjs.com/package/langchain) +- [GitHub](https://github.com/langchain-ai/langchainjs) +- [Tool calling with LangChain](https://blog.langchain.com/tool-calling-with-langchain/) + +--- + +### 5. Portkey AI Gateway + +| | | +|---|---| +| **Package** | `@portkey-ai/gateway` (self-hosted), `portkey-ai` (SDK), `@portkey-ai/vercel-provider` | +| **GitHub** | [github.com/Portkey-AI/gateway](https://github.com/Portkey-AI/gateway) | +| **Stars** | ~11K | +| **npm downloads** | Low (niche) | +| **License** | MIT | +| **Latest version** | gateway@1.15.2 | +| **TypeScript** | Written in TypeScript | + +**Provider coverage:** +1,600+ models. 200+ LLM providers. 50+ AI guardrails. + +**Tool calling:** Supported via OpenAI-compatible API. Also integrates as [Vercel AI SDK provider](https://ai-sdk.dev/providers/community-providers/portkey). + +**Streaming:** Yes. + +**MCP integration:** Has MCP Gateway feature for centralized MCP server management. + +**Can run in Electron:** PARTIALLY. The gateway itself can run via `npx @portkey-ai/gateway` (starts a local server). The SDK (`portkey-ai`) is a client that needs a running gateway. This means you'd need to either: (a) run the gateway as a subprocess in Electron, or (b) use the hosted Portkey service. Neither is ideal vs just importing a library. + +**Maturity:** High. 10B+ tokens processed daily. SOC2, HIPAA, GDPR compliant. Used by Postman, Haptik, Turing. + +**Strengths:** +- Enterprise-grade: fallbacks, retries, load balancing, guardrails +- 1,600+ models +- <1ms gateway latency, 122kb footprint +- Excellent observability and logging +- MCP Gateway for centralized tool management +- Integrates with Vercel AI SDK as a provider + +**Weaknesses:** +- Gateway architecture — needs a running server/proxy, doesn't work as a pure import +- For Electron, adds unnecessary complexity (subprocess management) +- Best as a production gateway, not as an embedded library +- Hosted service has latency (25-40ms added) +- Primarily designed for server/cloud deployments + +**Reliability: 9/10 | Confidence: 4/10** (excellent product, wrong architecture for embedded Electron use) + +**Links:** +- [portkey.ai](https://portkey.ai/) +- [Gateway docs](https://portkey.ai/docs/product/ai-gateway) +- [npm: @portkey-ai/gateway](https://www.npmjs.com/package/@portkey-ai/gateway) +- [GitHub](https://github.com/Portkey-AI/gateway) +- [Vercel AI SDK provider](https://ai-sdk.dev/providers/community-providers/portkey) + +--- + +### 6. OpenRouter SDK + +| | | +|---|---| +| **Package** | `@openrouter/sdk` | +| **GitHub** | [github.com/OpenRouterTeam/typescript-sdk](https://github.com/OpenRouterTeam/typescript-sdk) | +| **Stars** | ~148 | +| **npm downloads** | ~345K/week | +| **License** | Apache 2.0 | +| **Latest version** | 0.9.11 (beta) | +| **TypeScript** | Auto-generated from OpenAPI spec | + +**Provider coverage:** +300+ models from 60+ providers through OpenRouter's unified endpoint. + +**Tool calling:** Yes, built-in. Clean architecture for agentic workflows. + +**Streaming:** Yes. + +**MCP integration:** Not built-in. OpenRouter is a routing service, not an MCP-aware system. + +**Can run in Electron:** YES, but requires internet connectivity to OpenRouter's API. All requests go through OpenRouter's servers (adds 25-40ms latency). Cannot use API keys directly with providers — must go through OpenRouter. + +**Maturity:** SDK is in BETA. May have breaking changes between versions. + +**Strengths:** +- Simple: one API key, one endpoint, 300+ models +- Auto-generated types always match the API +- High weekly downloads (345K) +- Pay-as-you-go pricing +- Also available as Vercel AI SDK provider (`@openrouter/ai-sdk-provider`, 611 stars) + +**Weaknesses:** +- BETA status — not production-stable +- Requires routing through OpenRouter's servers (vendor dependency) +- Added latency per request +- Cannot use your own API keys directly with providers +- ESM-only (no CommonJS support) +- Not a library — it's a client for a service + +**Reliability: 6/10 | Confidence: 5/10** (good service, but vendor dependency + beta status) + +**Links:** +- [openrouter.ai](https://openrouter.ai/) +- [TypeScript SDK docs](https://openrouter.ai/docs/sdks/typescript) +- [npm: @openrouter/sdk](https://www.npmjs.com/package/@openrouter/sdk) +- [GitHub](https://github.com/OpenRouterTeam/typescript-sdk) +- [AI SDK provider](https://www.npmjs.com/package/@openrouter/ai-sdk-provider) + +--- + +### 7. Google Genkit + +| | | +|---|---| +| **Package** | `genkit` | +| **GitHub** | [github.com/firebase/genkit](https://github.com/firebase/genkit) | +| **Stars** | ~5.7K | +| **npm downloads** | ~moderate (41 dependents) | +| **License** | Apache 2.0 | +| **Latest version** | 1.30.1 | +| **TypeScript** | TypeScript + Go + Python | + +**Provider coverage:** +Google (Gemini), OpenAI, Anthropic, Ollama, AWS Bedrock, Azure OpenAI, Mistral, Cloudflare Workers AI, Hugging Face, and more via plugins. + +**Tool calling:** Full support via `defineTool` API. Interrupts for human-in-the-loop. Multi-agent architectures with sub-agents as tools. + +**Streaming:** Yes. + +**MCP integration:** Yes, supports connecting to external MCP servers for tool discovery and execution. + +**Can run in Electron:** Technically yes (Node.js), but designed for Firebase/Cloud Run deployment. Brings CLI, local dev UI, and server deployment patterns. + +**Maturity:** Built by Google, used in production by Firebase. Active development. + +**Strengths:** +- Built by Google, used in production +- Clean tool calling API +- Multi-agent support +- MCP integration +- Dev UI for debugging + +**Weaknesses:** +- Firebase/Google ecosystem bias +- Server-oriented design (CLI, cloud deployment focus) +- Smaller ecosystem than Vercel AI SDK or LangChain +- Not designed for desktop/Electron apps + +**Reliability: 7/10 | Confidence: 4/10** (good framework, Google-centric, not ideal for Electron) + +**Links:** +- [genkit.dev](https://genkit.dev/) +- [Firebase docs](https://firebase.google.com/docs/genkit) +- [npm: genkit](https://www.npmjs.com/package/genkit) +- [GitHub](https://github.com/firebase/genkit) +- [Tool calling docs](https://genkit.dev/docs/js/tool-calling/) + +--- + +### 8. Bifrost (Maxim AI) + +| | | +|---|---| +| **Package** | `@maximhq/bifrost` (via npx) | +| **GitHub** | [github.com/maximhq/bifrost](https://github.com/maximhq/bifrost) | +| **Stars** | ~2K+ | +| **License** | Source-available (check repo) | +| **Language** | Go (not TypeScript) | + +**Provider coverage:** +15+ providers through OpenAI-compatible API. + +**Tool calling:** Yes, via "Code Mode" — innovative approach reducing token usage by 50%. + +**MCP integration:** Yes, acts as both MCP client and server. Centralized MCP tool management. + +**Can run in Electron:** NO — it's a Go binary that runs as a server. Would need to be spawned as a subprocess and communicated with via HTTP. + +**Strengths:** +- Blazing fast: 11us overhead (50x faster than LiteLLM) +- Code Mode innovation for tool calling +- Strong MCP gateway features + +**Weaknesses:** +- Go binary, not a JS library +- Requires running a separate server process +- Wrong architecture for embedded Electron use + +**Reliability: 7/10 | Confidence: 2/10** (great gateway, completely wrong for our use case) + +**Links:** +- [docs.getbifrost.ai](https://docs.getbifrost.ai/overview) +- [GitHub](https://github.com/maximhq/bifrost) + +--- + +## Comparison Matrix + +| Library | Stars | npm/week | Tool Calling | Streaming | MCP | Electron | TypeScript | License | Library vs Framework | +|---|---|---|---|---|---|---|---|---|---| +| **Vercel AI SDK** | 23K | 4.5M | Excellent | Excellent | Full (v6) | YES | Native | Apache 2.0 | Library | +| **multi-llm-ts** | ~50 | 211 | Good | Good | No | YES (proven) | Native | MIT | Library | +| **Mastra** | 19.8K | 300K | Excellent | Excellent | Full | Partial | Native | Apache 2.0* | Framework | +| **LangChain.js** | 17.3K | 1M | Excellent | Good | Partial | Heavy | Ported | MIT | Framework | +| **Portkey** | 11K | Low | Good | Yes | MCP Gateway | Needs server | Native TS | MIT | Gateway | +| **OpenRouter SDK** | 148 | 345K | Good | Yes | No | Via service | Auto-gen | Apache 2.0 | Service client | +| **Google Genkit** | 5.7K | Moderate | Good | Yes | Yes | Server-focused | Native | Apache 2.0 | Framework | +| **Bifrost** | 2K+ | N/A | Innovative | Yes | Full | No (Go binary) | N/A | Source-avail | Gateway | + +--- + +## Architecture for Our Electron App + +### Recommended Approach: Vercel AI SDK in Electron Main Process + +``` +Renderer (React UI) + │ + │ IPC (ipcMain / ipcRenderer) + │ +Main Process (Node.js) + ├── AI SDK Core (generateText, streamText, generateObject) + │ ├── @ai-sdk/openai → OpenAI API + │ ├── @ai-sdk/anthropic → Anthropic API + │ ├── @ai-sdk/google → Google Gemini API + │ └── @ai-sdk/xai → xAI/Grok API + │ + ├── MCP Client (AI SDK built-in) + │ └── Connect to MCP servers for tool discovery + │ + └── API Key Storage (local, secure) +``` + +### Installation + +```bash +pnpm add ai @ai-sdk/openai @ai-sdk/anthropic @ai-sdk/google +``` + +### Example Usage (Electron Main Process) + +```typescript +import { generateText, streamText } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { google } from '@ai-sdk/google'; + +// Switch provider by changing one line +const model = anthropic('claude-sonnet-4-20250514'); +// const model = openai('gpt-4o'); +// const model = google('gemini-2.0-flash'); + +// Non-streaming +const { text } = await generateText({ + model, + prompt: 'Explain quantum computing', +}); + +// Streaming +const result = streamText({ + model, + prompt: 'Write a story', +}); +for await (const chunk of result.textStream) { + // Send to renderer via IPC + mainWindow.webContents.send('ai:chunk', chunk); +} + +// Tool calling +const { text, toolCalls } = await generateText({ + model, + tools: { + getWeather: { + description: 'Get weather for a location', + parameters: z.object({ city: z.string() }), + execute: async ({ city }) => fetchWeather(city), + }, + }, + prompt: 'What is the weather in Tokyo?', +}); +``` + +--- + +## Decision + +**Primary choice: Vercel AI SDK (`ai` + provider packages)** +- Reliability: 9/10 +- Confidence: 9/10 +- Reason: Best TypeScript DX, most library-like, full MCP support, huge ecosystem, works in Electron main process, active development + +**Fallback / lightweight alternative: `multi-llm-ts`** +- Reliability: 6/10 +- Confidence: 7/10 +- Reason: Already proven in production Electron app (Witsy), minimal footprint, but small community and no MCP + +**NOT recommended for our use case:** +- LangChain.js — too heavy, framework-oriented, Python-first design +- Mastra — excellent framework but overkill (and uses Vercel AI SDK internally anyway) +- Portkey/Bifrost — gateway architecture, needs running server +- OpenRouter SDK — vendor dependency, beta status +- Google Genkit — server/Firebase oriented + +--- + +## Sources + +- [Vercel AI SDK — Official docs](https://ai-sdk.dev/docs/introduction) +- [Vercel AI SDK — GitHub](https://github.com/vercel/ai) +- [AI SDK 6 announcement](https://vercel.com/blog/ai-sdk-6) +- [AI SDK MCP tools](https://ai-sdk.dev/docs/ai-sdk-core/mcp-tools) +- [AI SDK Tool Calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) +- [LangChain.js — GitHub](https://github.com/langchain-ai/langchainjs) +- [LangChain.js — npm](https://www.npmjs.com/package/langchain) +- [LangChain vs Vercel AI SDK vs OpenAI SDK: 2026 Guide](https://strapi.io/blog/langchain-vs-vercel-ai-sdk-vs-openai-sdk-comparison-guide) +- [Mastra — Official site](https://mastra.ai/) +- [Mastra — GitHub](https://github.com/mastra-ai/mastra) +- [Mastra Electron guide](https://mastra.ai/guides/getting-started/electron) +- [Mastra Licensing](https://mastra.ai/docs/community/licensing) +- [Portkey AI Gateway — GitHub](https://github.com/Portkey-AI/gateway) +- [Portkey AI docs](https://portkey.ai/docs/product/ai-gateway) +- [Portkey Vercel provider](https://ai-sdk.dev/providers/community-providers/portkey) +- [OpenRouter — TypeScript SDK docs](https://openrouter.ai/docs/sdks/typescript) +- [OpenRouter — npm](https://www.npmjs.com/package/@openrouter/sdk) +- [Google Genkit — GitHub](https://github.com/firebase/genkit) +- [Genkit Tool Calling](https://genkit.dev/docs/js/tool-calling/) +- [Bifrost — GitHub](https://github.com/maximhq/bifrost) +- [Bifrost docs](https://docs.getbifrost.ai/overview) +- [multi-llm-ts — GitHub](https://github.com/nbonamy/multi-llm-ts) +- [multi-llm-ts — npm](https://www.npmjs.com/package/multi-llm-ts) +- [Witsy (Electron app using multi-llm-ts)](https://github.com/nbonamy/witsy) +- [3 Best Open Source LiteLLM Alternatives in 2026](https://openalternative.co/alternatives/litellm) +- [Best LiteLLM Alternatives in 2026](https://www.getmaxim.ai/articles/best-litellm-alternatives-in-2026/) +- [AI Framework Comparison: Vercel AI SDK, Mastra, Langchain and Genkit](https://komelin.com/blog/ai-framework-comparison) +- [Top 5 TypeScript AI Agent Frameworks 2026](https://blog.agentailor.com/posts/top-typescript-ai-agent-frameworks-2026) +- [Sentry Electron + Vercel AI integration](https://docs.sentry.io/platforms/javascript/guides/electron/configuration/integrations/vercelai/) +- [Electron AI Chatbot](https://github.com/pashvc/electron-ai-chatbot) diff --git a/docs/research/unified-mcp-architecture.md b/docs/research/unified-mcp-architecture.md new file mode 100644 index 00000000..b2c92a00 --- /dev/null +++ b/docs/research/unified-mcp-architecture.md @@ -0,0 +1,485 @@ +# Unified MCP Architecture: Should Claude Also Use MCP for Kanban? + +**Date**: 2026-03-24 +**Branch**: `dev` +**Based on**: deep analysis of `mcp-server/`, `agent-teams-controller/`, `TeamProvisioningService.ts`, `TeamMcpConfigBuilder.ts`, and data flow through file watchers + +--- + +## The Question + +If Codex/Gemini use MCP for kanban management, should Claude also use MCP instead of its native built-in tools? This would unify the architecture into a single code path. + +--- + +## Current State: What Exists Today + +### MCP Server (30+ tools, fully provider-agnostic) + +Our `mcp-server/` package exposes these tools via FastMCP over stdio: + +| Category | Tools | Count | +|----------|-------|-------| +| Tasks | `task_create`, `task_create_from_message`, `task_get`, `task_get_comment`, `task_list`, `task_set_status`, `task_start`, `task_complete`, `task_set_owner`, `task_add_comment`, `task_attach_file`, `task_attach_comment_file`, `task_set_clarification`, `task_link`, `task_unlink`, `member_briefing`, `task_briefing` | 17 | +| Kanban | `kanban_get`, `kanban_set_column`, `kanban_clear`, `kanban_list_reviewers`, `kanban_add_reviewer`, `kanban_remove_reviewer` | 6 | +| Review | `review_request`, `review_start`, `review_approve`, `review_request_changes` | 4 | +| Messages | `message_send` | 1 | +| Processes | `process_register`, `process_list`, `process_unregister`, `process_stop` | 4 | +| Cross-team | `cross_team_send`, `cross_team_list_targets`, `cross_team_get_outbox` | 3 | +| Runtime | `team_launch`, `team_stop` | 2 | +| **Total** | | **37** | + +### Claude's Native Built-in Tools (Claude Code Agent Teams) + +These exist ONLY inside Claude Code CLI and cannot be replaced: + +| Tool | Purpose | Can MCP replace? | +|------|---------|------------------| +| `TeamCreate` | Creates team config on disk, initializes team state | Partially (MCP can write config, but Claude Code uses this to enter "team mode") | +| `TeamDelete` | Deletes team, cleans up processes | Partially | +| `TaskCreate` (Agent tool with `team_name`) | Spawns a teammate subprocess | **NO** -- this is process spawning, not task creation | +| `SendMessage` | Claude's native inbox message delivery | Partially (MCP `message_send` writes to same files) | +| `TaskGet` | Claude's native task query | Yes, `task_get` MCP does the same | +| `TaskList` | Claude's native task listing | Yes, `task_list` MCP does the same | +| `TaskUpdate` | Claude's native task update | Yes, `task_set_status`/`task_set_owner` MCP do the same | + +**Critical insight**: Claude Code's `TaskCreate` with `team_name` parameter is NOT a task-creation tool -- it's a **teammate process spawner**. It tells Claude Code CLI to fork a new subprocess for a teammate. No MCP tool can replace this because it's an internal CLI operation. + +### Data Flow: Where Files Live + +Both Claude's native tools AND our MCP server write to the **same directories**: + +``` +~/.claude/ + teams// + config.json -- team configuration + kanban-state.json -- kanban board state + processes.json -- registered processes + members.meta.json -- member metadata + inboxes/ + .json -- per-member inbox messages + user.json -- messages to the user + task-attachments/ + / -- file attachments + tasks// + .json -- individual task files +``` + +**This is already a shared data layer.** Our MCP server uses `agent-teams-controller` which reads/writes these exact files. Claude Code CLI also reads/writes these files via its built-in Agent Teams feature. The file watchers in `src/main/` detect changes from ANY source. + +### How Claude ALREADY Uses MCP + +Claude Code agents (both lead and teammates) **already** receive our MCP server via `--mcp-config`: + +``` +TeamMcpConfigBuilder.writeConfigFile() + → generates temp JSON config pointing to mcp-server/dist/index.js + → passed to Claude CLI via --mcp-config + → Claude Code loads our MCP tools alongside its built-in tools +``` + +The prompt in `buildTeamCtlOpsInstructions()` teaches Claude to use MCP tools: +``` +Internal task board tooling (MCP): +- Use the board-management MCP tools for tasks that must appear on the team board +``` + +And `buildMemberSpawnPrompt()` instructs teammates: +``` +First call member_briefing to learn your current assigned tasks... +Use task_start/task_complete/task_add_comment to track progress... +``` + +**Claude Code agents already use our MCP tools for task/kanban management.** They use native tools only for: team creation, teammate spawning, and direct messaging (though `message_send` MCP also works). + +--- + +## Three Architectures Compared + +### Architecture A: Dual-Path (Current Proposal for Multi-Provider) + +``` + +-----------------+ + | Kanban UI | + | (Electron) | + +--------+--------+ + | + +--------+--------+ + | File Watchers | + | (chokidar) | + +--------+--------+ + | + +--------------+--------------+ + | | + +---------+----------+ +------------+-----------+ + | ~/.claude/teams/ | | ~/.claude/tasks/ | + | config, kanban, | | .json files | + | inboxes, processes | | | + +----+----------+----+ +-----+------------+-----+ + | | | | + | | | | + +----+----+ +---+--------+ +---+----+ +-----+------+ + | Claude | | MCP Server | | Claude | | MCP Server | + | Native | | (agent- | | Native | | (agent- | + | Tools | | teams-mcp) | | Tools | | teams-mcp) | + +---------+ +-----+------+ +--------+ +-----+------+ + | | | | + +----+----+ +----+-----+ +----+----+ +----+-----+ + | Claude | | Codex/ | | Claude | | Codex/ | + | Code | | Gemini/ | | Code | | Gemini/ | + | CLI | | Any MCP | | CLI | | Any MCP | + +---------+ | Agent | +---------+ | Agent | + +----------+ +----------+ +``` + +**Data flow:** +- Claude -> native built-in tools -> writes directly to `~/.claude/teams/` and `~/.claude/tasks/` +- Claude -> MCP tools -> `agent-teams-controller` -> writes to same files +- Codex/Gemini -> MCP tools -> `agent-teams-controller` -> writes to same files +- File watchers detect ALL changes -> UI updates + +| Criterion | Score | +|-----------|-------| +| Reliability | **9/10** | +| Confidence | **9/10** | +| Effort | 3-4 weeks | +| Risk | Very Low | +| Code reuse | 100% | + +**Pros:** +- Zero risk to existing Claude Code functionality +- Claude uses its battle-tested native tools (TeamCreate, Agent/Task tool for spawning) +- MCP tools handle task/kanban CRUD (Claude already uses these) +- External agents use MCP exclusively +- Both paths write to same files, file watchers don't care who writes +- 30+ MCP tools already exist and are tested + +**Cons:** +- Two "entry points" for writes (native tools + MCP tools), though they share the same data layer +- Claude has redundant tools (native TaskGet + MCP task_get), but the prompt steers which to use +- If agent-teams-controller changes, both native and MCP paths need verification + +--- + +### Architecture B: Unified MCP (ALL agents use MCP only) + +``` + +-----------------+ + | Kanban UI | + | (Electron) | + +--------+--------+ + | + +--------+--------+ + | File Watchers | + | (chokidar) | + +--------+--------+ + | + +--------------+--------------+ + | | + +---------+----------+ +------------+-----------+ + | ~/.claude/teams/ | | ~/.claude/tasks/ | + +--------+-----------+ +--------+---------------+ + | | + +----------+---------------+ + | + +--------+--------+ + | MCP Server | + | (agent-teams- | + | controller) | + +--------+--------+ + | + +-------------+-------------+ + | | | + +----+----+ +----+-----+ +-----+----+ + | Claude | | Codex/ | | Gemini/ | + | Code | | Gemini | | Other | + | CLI | | CLI | | Agents | + +---------+ +----------+ +----------+ +``` + +**Data flow:** +- ALL agents -> MCP tools only -> `agent-teams-controller` -> writes to `~/.claude/tasks/` and `~/.claude/teams/` +- Claude Code's native tools (TeamCreate, TaskCreate, SendMessage) are DISABLED or unused +- File watchers detect changes -> UI updates + +| Criterion | Score | +|-----------|-------| +| Reliability | **4/10** | +| Confidence | **3/10** | +| Effort | 8-12 weeks | +| Risk | Very High | +| Code reuse | ~40% | + +**Pros:** +- Single code path for all agents +- Single set of tools to maintain +- Architecturally "clean" + +**Cons -- and this is where the analysis gets critical:** + +1. **Cannot disable Claude's `TaskCreate` (Agent tool with team_name)** + - This is how Claude Code spawns teammate subprocesses + - There is no MCP equivalent -- MCP tools return JSON responses, they cannot fork processes + - `--disallowedTools TaskCreate` would break teammate spawning entirely + - Our `team_launch` MCP tool talks to the desktop runtime HTTP API -- it's a different mechanism (launches the whole team, not individual teammates) + +2. **Cannot fully replace `TeamCreate`** + - `TeamCreate` puts Claude Code CLI into "team mode" -- it enables Agent Teams features, stdin relay, inbox monitoring + - Writing `config.json` via MCP creates the files but doesn't activate the CLI-side features + - The CLI needs to be told about the team through its own internal protocol + +3. **Cannot fully replace `SendMessage`** + - Our MCP `message_send` writes to inbox files, which works for teammates (they read inbox files directly) + - But the lead reads messages via stdin relay (`relayLeadInboxMessages()`). MCP `message_send` to lead would require the relay to detect the file write and relay it -- this works but is a longer path with more latency + - Risk of message delivery race conditions during high-frequency messaging + +4. **Prompt rewrite is massive and risky** + - `buildProvisioningPrompt()` (95 LOC) teaches Claude to use `TeamCreate` + `Agent` tool -- would need complete rewrite + - `buildPersistentLeadContext()` (100+ LOC) references built-in tools throughout + - `buildMemberSpawnPrompt()` references `member_briefing` MCP tool (this part is already MCP-based) + - Total: ~300 LOC of prompt engineering that took months to tune for delegation-first behavior, task board discipline, review workflow + - Any prompt change risks breaking the finely-tuned agent behavior + +5. **Token overhead from MCP tool descriptions** + - 37 MCP tools * ~50-100 tokens each = 1,850-3,700 additional tokens per turn + - Claude's native tools don't consume context (they're built into the CLI) + - For long sessions this accumulates significantly + +6. **MCP tool discovery overhead** + - Each MCP tool call has stdio round-trip overhead vs native tool calls which are in-process + - For high-frequency operations (agent spawning many tasks) this adds latency + +7. **Loss of Claude Code optimizations** + - Claude Code's built-in tools are optimized for its internal state machine + - `TeamCreate` triggers internal event routing, session persistence, teammate monitoring + - Replacing with MCP tools means these side effects would need to be triggered differently + +--- + +### Architecture C: Hybrid Unified (RECOMMENDED) + +``` + +-----------------+ + | Kanban UI | + | (Electron) | + +--------+--------+ + | + +--------+--------+ + | File Watchers | + | (chokidar) | + +--------+--------+ + | + +--------------+--------------+ + | | + +---------+----------+ +------------+-----------+ + | ~/.claude/teams/ | | ~/.claude/tasks/ | + | config, kanban, | | .json files | + | inboxes, processes | | | + +--+---------+---+---+ +---+---------+---+------+ + | | | | | | + | +----+---+----+ | +----+---+-----+ + | | agent-teams- | | | agent-teams- | + | | controller +-------+ | controller | + | | (shared | | (shared | + | | data layer) | | data layer) | + | +----+---------+ +-----+---------+ + | | | + | +----+---------+ +-----+---------+ + | | MCP Server | | MCP Server | + | | (agent- | | (agent- | + | | teams-mcp) | | teams-mcp) | + | +----+---------+ +-----+---------+ + | | | + +----+----+ | +----------+ +-----+----+ + | Claude | +----+ Codex/ | | Gemini/ | + | Native | | Any MCP | | Other | + | Tools: | | Agent | | Agents | + | Team | +----------+ +----------+ + | Create, | + | Agent | Claude ALSO uses MCP for: + | Spawn, | task_create, task_get, task_list, + | Send | task_set_status, kanban_get, + | Message | kanban_set_column, review_request, + +---------+ review_approve, message_send, etc. +``` + +**Data flow:** +- Claude -> native tools for LIFECYCLE operations (TeamCreate, Agent/Task spawning, SendMessage to lead) +- Claude -> MCP tools for CRUD operations (task management, kanban, review, comments) -- **already happens today** +- Codex/Gemini -> MCP tools for ALL operations +- ALL writes go to the same `~/.claude/` directories via `agent-teams-controller` +- File watchers detect ALL changes regardless of source + +| Criterion | Score | +|-----------|-------| +| Reliability | **9/10** | +| Confidence | **9/10** | +| Effort | 3-4 weeks (same as Architecture A) | +| Risk | Very Low | +| Code reuse | 100% | + +**Pros:** +- Claude keeps its native tools for things MCP cannot do (process spawning, entering team mode) +- Claude uses MCP for task/kanban CRUD -- THIS IS ALREADY THE CASE TODAY +- External agents use MCP exclusively -- works today with our 37 tools +- Single data layer (`agent-teams-controller`) for all writes +- File watchers are source-agnostic +- Zero prompt rewriting for Claude +- Zero risk to existing functionality + +**Cons:** +- Claude has both native + MCP tools available (mild complexity) +- Need to ensure no conflicts when Claude's native tools and MCP tools modify the same task + +--- + +## Critical Finding: Architecture C IS Architecture A + +After thorough analysis, Architectures A and C are **functionally identical** because: + +1. **Claude already uses our MCP tools for kanban/task management** -- the prompt explicitly instructs this via `buildTeamCtlOpsInstructions()` +2. **Claude only uses native tools for what MCP cannot do** -- TeamCreate (entering team mode), Agent tool (spawning subprocesses), SendMessage (lead stdin relay) +3. **Both paths already write to the same files** -- `agent-teams-controller` is the shared data layer + +The "dual-path" concern is a misconception. There aren't two competing paths -- there's one path for **lifecycle operations** (Claude Code native) and one path for **data operations** (MCP), and they already coexist. + +--- + +## Does Our MCP Server Write to the Same Files as Claude's Native Tools? + +**YES, unequivocally.** + +Evidence from source code: + +1. `agent-teams-controller/src/internal/runtimeHelpers.js` line 117-124: +```javascript +function getPaths(flags, teamName) { + const claudeDir = getClaudeDir(flags); // defaults to ~/.claude + const teamDir = path.join(claudeDir, 'teams', safeTeam); + const tasksDir = path.join(claudeDir, 'tasks', safeTeam); + const kanbanPath = path.join(teamDir, 'kanban-state.json'); + const processesPath = path.join(teamDir, 'processes.json'); + return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath }; +} +``` + +2. `mcp-server/src/controller.ts` uses `createController({ teamName })` which calls `getPaths()` above +3. Claude Code's native Agent Teams also writes to `~/.claude/teams//` and `~/.claude/tasks//` +4. Both use the same JSON file format with atomic write (temp file + rename) + +**Conflict risk**: Very low. File writes use atomic rename (`writeJson` creates a temp file then `fs.renameSync`). The `fileLock.js` module provides advisory locking for concurrent writes. Task files are per-task (one JSON per task), so different agents working on different tasks don't collide. + +--- + +## Architecture Decision + +### Architecture B (Unified MCP-only) is NOT viable + +The fundamental blocker: **Claude Code's Agent Teams is a CLI feature, not a data feature.** The built-in tools (TeamCreate, Agent tool for spawning) trigger internal CLI state changes that cannot be replicated via MCP. Disabling them would break: + +- Team mode activation +- Teammate process spawning +- Lead inbox relay +- Tool approval flow +- Post-compact context recovery +- Auth retry logic + +These are 7,982 LOC of battle-tested code in `TeamProvisioningService.ts` that would need to be rebuilt from scratch with worse ergonomics. + +### Architecture A/C (Hybrid) is already the architecture we have + +The "should Claude use MCP?" question has already been answered: **Claude already uses MCP for kanban/task operations.** The prompt instructs it. The `--mcp-config` flag delivers our MCP server to every Claude Code agent (lead and teammates). + +The only remaining question is: what do we need to add to support Codex/Gemini? + +--- + +## What's Actually Needed for Multi-Provider Support + +### Already complete (0 additional work) + +- MCP server with 37 tools +- `agent-teams-controller` as provider-agnostic data layer +- File watchers that detect changes from any source +- Atomic file writes to prevent corruption +- HTTP control API for launch/stop + +### Needed: New MCP tools for external agent lifecycle + +``` +team_join -- register as external team member (provider, model metadata) +team_leave -- unregister from team +team_list_teams -- discover available teams +team_get_config -- get team configuration and member list +member_heartbeat -- keepalive signal for external agents +task_poll_assigned -- poll for tasks assigned to this agent +task_claim -- claim an unassigned pending task +``` + +**Files to add:** +- `mcp-server/src/tools/memberTools.ts` (new) +- `mcp-server/src/tools/teamDiscoveryTools.ts` (new) +- `agent-teams-controller/src/internal/memberLifecycle.js` (new) + +**Files to modify:** +- `mcp-server/src/tools/index.ts` -- register new tool modules +- `agent-teams-controller/src/internal/runtimeHelpers.js` -- member metadata helpers +- `src/shared/types/team.ts` -- add `provider?: string`, `model?: string` fields + +**Files unchanged (0 modifications):** +- `TeamProvisioningService.ts` -- untouched +- `TeamDataService.ts` -- reads data generically, will pick up new fields +- `TeamMcpConfigBuilder.ts` -- untouched (Claude-specific) +- All prompt engineering -- untouched + +### Needed: UI enhancements + +- Provider badge/icon on member cards +- "External agent" indicator on kanban task cards +- Different color/treatment for externally-managed agents + +--- + +## Comparison Matrix + +| Criterion | A: Dual-Path | B: Unified MCP | C: Hybrid (=A) | +|-----------|:---:|:---:|:---:| +| Reliability | 9/10 | 4/10 | **9/10** | +| Confidence | 9/10 | 3/10 | **9/10** | +| Effort (weeks) | 3-4 | 8-12 | **3-4** | +| Risk level | Very Low | Very High | **Very Low** | +| Existing code reuse | 100% | ~40% | **100%** | +| Breaks Claude flow? | No | Yes | **No** | +| Breaks prompts? | No | Yes (300+ LOC rewrite) | **No** | +| Single data layer? | Yes | Yes | **Yes** | +| Claude keeps optimizations? | Yes | No | **Yes** | +| Supports Codex/Gemini? | Yes, via MCP | Yes, via MCP | **Yes, via MCP** | +| Token overhead | None extra | +1.8-3.7K tokens/turn | **None extra** | +| MCP standard compliance | Yes | Yes | **Yes** | +| Incremental delivery? | Yes | No | **Yes** | +| Time to first external agent | 2-3 weeks | 8+ weeks | **2-3 weeks** | + +--- + +## Risks and Mitigations + +| Risk | Probability | Impact | Mitigation | +|------|:-----------:|:------:|------------| +| MCP tool conflicts with native tools | Low | Medium | Tools operate on different task IDs; atomic writes; file-level locking in `fileLock.js` | +| External agent writes corrupt state | Low | High | `agent-teams-controller` validates all inputs; atomic write-rename pattern; per-task file isolation | +| External agent doesn't follow workflow | Medium | Low | `member_briefing` provides onboarding; tool descriptions guide behavior; `task_set_clarification` for issues | +| Performance under many agents | Low | Medium | File I/O is the bottleneck (same as now); no additional overhead | +| Claude Code updates break file format | Low | High | `agent-teams-controller` is our adapter layer -- update it when format changes | +| MCP protocol evolution | Very Low | Low | FastMCP library handles protocol; MCP spec is stable (v1.0+) | + +--- + +## Conclusion + +**Architecture C (Hybrid) is the answer, and it's essentially what we already have.** + +The realization that resolves the question: Claude Code already uses our MCP tools for task/kanban management. The "should Claude use MCP too?" question is already answered with "yes, and it does." Claude keeps its native tools for the things that ONLY Claude Code can do (process spawning, team mode activation), and uses MCP for everything that's shared (tasks, kanban, review, messages, comments). + +For Codex/Gemini, we add ~7 new MCP tools for agent lifecycle management. That's it. No architectural changes, no prompt rewrites, no refactoring. The data layer is already shared, the file watchers are already source-agnostic, and the MCP server already exposes the full API. + +The **single most important insight** from this analysis: the architecture is NOT "dual-path." It's a single shared data layer (`agent-teams-controller`) with two access methods -- native tools for Claude Code internal operations, MCP tools for everything else. Both access methods are complementary, not competing. From 58f3ccd4b437fd84213d7079205a8032021eb5f6 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Mar 2026 15:00:42 +0200 Subject: [PATCH 017/113] feat(dashboard): add auth troubleshooting guide to CLI status banner Add "Already logged in?" button next to Login that expands a step-by-step troubleshooting panel: re-check status, terminal commands, re-login instructions, and binary path display. Also clarifies that browsing works without login. --- .../components/dashboard/CliStatusBanner.tsx | 120 ++++++++++++++++-- 1 file changed, 112 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index c5e490cd..e19d3fe7 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -18,7 +18,10 @@ import { formatBytes } from '@renderer/utils/formatters'; import { AlertTriangle, CheckCircle, + ChevronDown, + ChevronUp, Download, + HelpCircle, Loader2, LogIn, Puzzle, @@ -279,6 +282,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const [showLoginTerminal, setShowLoginTerminal] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); + const [showTroubleshoot, setShowTroubleshoot] = useState(false); useEffect(() => { if (!isElectron) return; @@ -566,15 +570,115 @@ export const CliStatusBanner = (): React.JSX.Element | null => {

- +
+ + +
+ + {showTroubleshoot && ( +
+

+ If you're sure you're logged in, try these steps: +

+
    +
  1. + Click{' '} + {' '} + — sometimes the status is cached for a few seconds +
  2. +
  3. + Open your terminal and run:{' '} + + claude auth status + {' '} + — check if it shows "Logged in" +
  4. +
  5. + If it says logged in but the app doesn't see it, try:{' '} + + claude auth logout + {' '} + then{' '} + + claude auth login + {' '} + again +
  6. +
  7. + Make sure{' '} + + claude + {' '} + in your terminal is the same binary the app uses + {cliStatus.binaryPath && ( + + :{' '} + + {cliStatus.binaryPath} + + + )} +
  8. +
+

+ Browsing sessions and projects works without login. Login is only needed to run + agent teams. +

+
+ )} {showLoginTerminal && cliStatus.binaryPath && ( Date: Wed, 25 Mar 2026 15:03:44 +0200 Subject: [PATCH 018/113] fix(team): notify lead on task create with startImmediately Extract sendUserTaskStartNotification as reusable private method. When a task is created via UI directly in "In Progress" column (startImmediately=true), the controller's maybeNotifyAssignedOwner skips the lead. Now createTask sends the notification to the lead with full description, prompt, and task_get instructions. --- src/main/services/team/TeamDataService.ts | 87 ++++++++++++++--------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 56e7fb13..4d454aec 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -877,6 +877,19 @@ export class TeamDataService { ...(shouldStart ? { startImmediately: true } : {}), }) as TeamTask; + // Controller's maybeNotifyAssignedOwner skips the lead (owner === lead). + // For user-created tasks with startImmediately, ensure the lead also gets notified. + if (shouldStart) { + try { + const leadName = await this.resolveLeadName(teamName); + if (this.isLeadOwner(task.owner!, leadName)) { + await this.sendUserTaskStartNotification(teamName, task); + } + } catch { + /* best-effort */ + } + } + return task; } @@ -945,44 +958,52 @@ export class TeamDataService { this.getController(teamName).tasks.startTask(taskId, 'user'); if (task.owner) { - try { - const parts = [ - `**start working on task now** ${this.getTaskLabel(task)} "${task.subject}"`, - ]; - if (task.description?.trim()) { - parts.push(`\nDetails:\n${task.description.trim()}`); - } - if (task.prompt?.trim()) { - parts.push(`\nInstructions:\n${task.prompt.trim()}`); - } - parts.push( - '', - wrapAgentBlock( - [ - `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, - `To fetch the full task context (description, comments, attachments) use:`, - `task_get { teamName: "${teamName}", taskId: "${task.id}" }`, - `When done, update task status:`, - `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, - ].join('\n') - ) - ); - await this.sendMessage(teamName, { - member: task.owner, - from: 'user', - text: parts.join('\n'), - taskRefs: task.descriptionTaskRefs, - summary: `Start working on ${this.getTaskLabel(task)}`, - source: 'system_notification', - }); - } catch { - // Best-effort notification - } + await this.sendUserTaskStartNotification(teamName, task); } return { notifiedOwner: !!task.owner }; } + /** + * Send a task start notification from the user to the task owner. + * Includes description, prompt, and task_get/task_complete instructions. + * Used by startTaskByUser and createTask (startImmediately). + */ + private async sendUserTaskStartNotification(teamName: string, task: TeamTask): Promise { + if (!task.owner) return; + try { + const parts = [`**start working on task now** ${this.getTaskLabel(task)} "${task.subject}"`]; + if (task.description?.trim()) { + parts.push(`\nDetails:\n${task.description.trim()}`); + } + if (task.prompt?.trim()) { + parts.push(`\nInstructions:\n${task.prompt.trim()}`); + } + parts.push( + '', + wrapAgentBlock( + [ + `Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`, + `To fetch the full task context (description, comments, attachments) use:`, + `task_get { teamName: "${teamName}", taskId: "${task.id}" }`, + `When done, update task status:`, + `task_complete { teamName: "${teamName}", taskId: "${task.id}" }`, + ].join('\n') + ) + ); + await this.sendMessage(teamName, { + member: task.owner, + from: 'user', + text: parts.join('\n'), + taskRefs: task.descriptionTaskRefs, + summary: `Start working on ${this.getTaskLabel(task)}`, + source: 'system_notification', + }); + } catch { + // Best-effort notification + } + } + async updateTaskStatus( teamName: string, taskId: string, From 42040ef886c1092b6913e2abe1aece8dc6703594 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 26 Mar 2026 00:30:58 +0200 Subject: [PATCH 019/113] chore: bump version to 1.1.0 --- docs/RELEASE.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 84e58e5e..c673abcd 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,5 +1,9 @@ # Release Guide +## Published: v1.1.0 (2026-03-26) + +Minor release: React 19 + Electron 40 migration, start-task-by-user, auth troubleshooting guide, syntax highlighting for R/Ruby/PHP/SQL, search performance improvements, cost tracking accuracy, WSL/Windows path fixes. Full list: [CHANGELOG.md](./CHANGELOG.md). + ## Published: v1.0.0 (2026-03-19) Initial release: Claude Agent Teams UI with reliable CLI detection in packaged builds (shell PATH/HOME, `CLAUDE_CONFIG_DIR`, auth output parsing), IPC status cache handling, concurrent binary resolution, capped NDJSON diagnostics. Full list: [CHANGELOG.md](./CHANGELOG.md). diff --git a/package.json b/package.json index 1a0e0b3a..3ecf56ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-agent-teams-ui", "type": "module", - "version": "1.0.0", + "version": "1.1.0", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", "license": "AGPL-3.0", "author": { From 344bf41fe5175b67f40183bee632b58c2fae26d4 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 26 Mar 2026 00:50:12 +0200 Subject: [PATCH 020/113] fix(updater): verify platform asset exists before showing update notification HEAD-request the expected installer URL (DMG/EXE/AppImage) before notifying the user about a new version. If CI hasn't finished uploading the artifact for the current OS yet, the notification is suppressed and retried on the next periodic check. --- .../services/infrastructure/UpdaterService.ts | 76 +++++++++++++++++-- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 51cc4f63..6cd0c7e1 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -3,6 +3,11 @@ * * Forwards update lifecycle events to the renderer via IPC. * Auto-download is disabled so users must confirm before downloading. + * + * Before notifying the renderer about a new version, verifies that the + * platform-specific installer asset actually exists in the GitHub release. + * This prevents showing "update available" while CI is still uploading + * artifacts for the current platform. */ import { getErrorMessage } from '@shared/utils/errorHandling'; @@ -13,9 +18,47 @@ const { autoUpdater } = electronUpdater; import type { UpdaterStatus } from '@shared/types'; import type { BrowserWindow } from 'electron'; +import { net } from 'electron'; const logger = createLogger('UpdaterService'); +const REPO_OWNER = '777genius'; +const REPO_NAME = 'claude_agent_teams_ui'; + +/** + * Build the expected download URL for the platform-specific installer asset. + * Returns null if the current platform is unrecognized. + */ +function getExpectedAssetUrl(version: string): string | null { + const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/v${version}`; + + switch (process.platform) { + case 'darwin': + return process.arch === 'arm64' + ? `${base}/Claude.Agent.Teams.UI-${version}-arm64.dmg` + : `${base}/Claude.Agent.Teams.UI-${version}.dmg`; + case 'win32': + return `${base}/Claude.Agent.Teams.UI.Setup.${version}.exe`; + case 'linux': + return `${base}/Claude.Agent.Teams.UI-${version}.AppImage`; + default: + return null; + } +} + +/** + * Check if a remote URL exists using a HEAD request. + * Follows redirects (GitHub releases use 302 → S3). + */ +async function assetExists(url: string): Promise { + try { + const response = await net.fetch(url, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } +} + export class UpdaterService { private mainWindow: BrowserWindow | null = null; private periodicTimer: ReturnType | null = null; @@ -94,6 +137,33 @@ export class UpdaterService { } } + /** + * Verify that the platform-specific asset exists before notifying the renderer. + * If CI hasn't finished uploading the artifact for this OS yet, suppress the + * notification — the next periodic check will retry. + */ + private async verifyAndNotify(info: { + version: string; + releaseNotes?: string | unknown; + }): Promise { + const url = getExpectedAssetUrl(info.version); + if (url) { + const exists = await assetExists(url); + if (!exists) { + logger.warn( + `Asset not yet available for ${process.platform}/${process.arch}, suppressing update notification (${url})` + ); + return; + } + } + + this.sendStatus({ + type: 'available', + version: info.version, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + }); + } + private bindEvents(): void { autoUpdater.on('checking-for-update', () => { logger.info('Checking for update...'); @@ -102,11 +172,7 @@ export class UpdaterService { autoUpdater.on('update-available', (info) => { logger.info('Update available:', info.version); - this.sendStatus({ - type: 'available', - version: info.version, - releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, - }); + void this.verifyAndNotify(info); }); autoUpdater.on('update-not-available', () => { From 9f8287755c4ab1abed75de43c7f13beb552b4cbf Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 26 Mar 2026 13:13:50 +0200 Subject: [PATCH 021/113] fix(updater): improve update dialog layout and strip downloads section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen dialog (max-w-lg → max-w-2xl), remove prose max-width constraint that caused empty space on the right, and filter out the Downloads heading with everything below it from release notes. --- src/renderer/components/common/UpdateDialog.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index f2f09656..fa5b3465 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -81,6 +81,11 @@ export const UpdateDialog = (): React.JSX.Element | null => { const isDownloaded = updateStatus === 'downloaded'; + // Strip "Downloads" section (and everything after it) from release notes + const filteredNotes = releaseNotes + ? releaseNotes.replace(/\n#{1,3}\s+Downloads[\s\S]*$/i, '').trimEnd() + : releaseNotes; + const releaseUrl = availableVersion ? `https://github.com/777genius/claude_agent_teams_ui/releases/tag/v${availableVersion}` : null; @@ -106,7 +111,7 @@ export const UpdateDialog = (): React.JSX.Element | null => { />
{ {/* Release notes */}
- {releaseNotes ? ( + {filteredNotes ? ( - {releaseNotes} + {filteredNotes} ) : (

From e1413a32bdc090e357a5a03d72ffa8168938827d Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 17:07:40 +0200 Subject: [PATCH 022/113] Guard renderer IPC sends during crash recovery --- src/main/index.ts | 117 +++++++++++------- src/main/ipc/editor.ts | 5 +- src/main/ipc/review.ts | 5 +- .../infrastructure/CliInstallerService.ts | 5 +- .../infrastructure/NotificationManager.ts | 17 ++- .../infrastructure/PtyTerminalService.ts | 6 +- .../services/infrastructure/UpdaterService.ts | 5 +- src/main/utils/safeWebContentsSend.ts | 54 ++++++++ test/main/utils/safeWebContentsSend.test.ts | 94 ++++++++++++++ 9 files changed, 240 insertions(+), 68 deletions(-) create mode 100644 src/main/utils/safeWebContentsSend.ts create mode 100644 test/main/utils/safeWebContentsSend.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index a6eeda5d..b6916518 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -84,6 +84,12 @@ import { TeamInboxReader } from './services/team/TeamInboxReader'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; +import { + clearRendererAvailability, + markRendererReady, + markRendererUnavailable, + safeSendToRenderer, +} from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { CliInstallerService, @@ -382,6 +388,8 @@ let httpServer: HttpServer; let schedulerService: SchedulerService; let skillsWatcherService: SkillsWatcherService | null = null; let teamBackupService: TeamBackupService | null = null; +let rendererRecoveryTimer: ReturnType | null = null; +let rendererRecoveryAttempts = 0; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; @@ -472,9 +480,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { // ignore } - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('file-change', event); - } + safeSendToRenderer(mainWindow, 'file-change', event); httpServer?.broadcast('file-change', event); }; context.fileWatcher.on('file-change', fileChangeHandler); @@ -488,9 +494,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { // Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above) const todoChangeHandler = (event: unknown): void => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('todo-change', event); - } + safeSendToRenderer(mainWindow, 'todo-change', event); httpServer?.broadcast('todo-change', event); }; context.fileWatcher.on('todo-change', todoChangeHandler); @@ -498,9 +502,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { // Forward team-change events to renderer and HTTP SSE const teamChangeHandler = (event: unknown): void => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(TEAM_CHANGE, event); - } + safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); // Process inbox and task change events. @@ -648,13 +650,11 @@ function onContextSwitched(context: ServiceContext): void { rewireContextEvents(context); // Notify renderer of context change - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus()); - mainWindow.webContents.send(CONTEXT_CHANGED, { - id: context.id, - type: context.type, - }); - } + safeSendToRenderer(mainWindow, SSH_STATUS, sshConnectionManager.getStatus()); + safeSendToRenderer(mainWindow, CONTEXT_CHANGED, { + id: context.id, + type: context.type, + }); } /** @@ -835,30 +835,22 @@ function initializeServices(): void { // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). const teamChangeEmitter = (event: TeamChangeEvent): void => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(TEAM_CHANGE, event); - } + safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); // Allow SchedulerService to push schedule events to renderer schedulerService.setChangeEmitter((event) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SCHEDULE_CHANGE, event); - } + safeSendToRenderer(mainWindow, SCHEDULE_CHANGE, event); }); skillsWatcherService.setEmitter((event) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SKILLS_CHANGED, event); - } + safeSendToRenderer(mainWindow, SKILLS_CHANGED, event); }); teamProvisioningService.setToolApprovalEventEmitter((event) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event); - } + safeSendToRenderer(mainWindow, TEAM_TOOL_APPROVAL_EVENT, event); }); teamProvisioningService.setMainWindow(mainWindow); @@ -911,9 +903,7 @@ function initializeServices(): void { // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(SSH_STATUS, status); - } + safeSendToRenderer(mainWindow, SSH_STATUS, status); httpServer.broadcast('ssh:status', status); }); @@ -1061,7 +1051,35 @@ function syncTrafficLightPosition(win: BrowserWindow): void { if (process.platform === 'darwin') { win.setWindowButtonPosition(position); } - win.webContents.send(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); + safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); +} + +function scheduleRendererRecovery(win: BrowserWindow): void { + if (rendererRecoveryTimer) { + return; + } + if (rendererRecoveryAttempts >= 2) { + logger.error('Renderer recovery limit reached; skipping automatic reload'); + return; + } + + rendererRecoveryAttempts += 1; + const delayMs = rendererRecoveryAttempts * 1000; + logger.warn(`Scheduling renderer recovery attempt ${rendererRecoveryAttempts} in ${delayMs}ms`); + + rendererRecoveryTimer = setTimeout(() => { + rendererRecoveryTimer = null; + if (!mainWindow || mainWindow !== win || win.isDestroyed()) { + return; + } + + markRendererUnavailable(win); + try { + win.webContents.reload(); + } catch (error) { + logger.error(`Renderer recovery reload failed: ${String(error)}`); + } + }, delayMs); } /** @@ -1090,6 +1108,7 @@ function createWindow(): void { ...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }), title: 'Claude Agent Teams UI', }); + markRendererUnavailable(mainWindow); // In dev, forward selected renderer console warnings/errors to the main terminal. // Use the new single-argument event payload to avoid Electron deprecation warnings. @@ -1147,25 +1166,29 @@ function createWindow(): void { // Notify renderer when entering/leaving fullscreen (so traffic light padding can be removed) mainWindow.on('enter-full-screen', () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, true); - } + safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, true); }); mainWindow.on('leave-full-screen', () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, false); - } + safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, false); + }); + + mainWindow.webContents.on('did-start-loading', () => { + markRendererUnavailable(mainWindow); }); // Set traffic light position + notify renderer on first load, and auto-check for updates mainWindow.webContents.on('did-finish-load', () => { if (mainWindow && !mainWindow.isDestroyed()) { + markRendererReady(mainWindow); + rendererRecoveryAttempts = 0; + if (rendererRecoveryTimer) { + clearTimeout(rendererRecoveryTimer); + rendererRecoveryTimer = null; + } logger.warn('[startup] renderer did-finish-load'); syncTrafficLightPosition(mainWindow); setTimeout(() => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(WINDOW_FULLSCREEN_CHANGED, mainWindow.isFullScreen()); - } + safeSendToRenderer(mainWindow, WINDOW_FULLSCREEN_CHANGED, mainWindow?.isFullScreen()); }, 0); // Start file watchers now that the window is visible and responsive. // Deferred from initializeServices() to avoid blocking window creation @@ -1234,7 +1257,7 @@ function createWindow(): void { // Prevent Cmd+N / Ctrl+N from opening new window; forward to renderer for review shortcuts if (isMod && input.key.toLowerCase() === 'n') { event.preventDefault(); - mainWindow.webContents.send('review:cmdN'); + safeSendToRenderer(mainWindow, 'review:cmdN'); return; } @@ -1264,6 +1287,11 @@ function createWindow(): void { }); mainWindow.on('closed', () => { + if (rendererRecoveryTimer) { + clearTimeout(rendererRecoveryTimer); + rendererRecoveryTimer = null; + } + clearRendererAvailability(mainWindow); mainWindow = null; // Clear main window references if (notificationManager) { @@ -1290,7 +1318,10 @@ function createWindow(): void { // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) mainWindow.webContents.on('render-process-gone', (_event, details) => { logger.error('Renderer process gone:', details.reason, details.exitCode); - // Could show an error dialog or attempt to reload the window + markRendererUnavailable(mainWindow); + const activeContext = contextRegistry.getActive(); + activeContext?.stopFileWatcher(); + scheduleRendererRecovery(mainWindow); }); // Set main window reference for notification manager and updater diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index 120df2ae..cd85dc94 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -7,6 +7,7 @@ import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { isPathWithinRoot } from '@main/utils/pathValidation'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { EDITOR_CHANGE, EDITOR_CLOSE, @@ -367,9 +368,7 @@ async function handleEditorWatchDir( } // Forward event to renderer - if (mainWindowRef && !mainWindowRef.isDestroyed()) { - mainWindowRef.webContents.send(EDITOR_CHANGE, event); - } + safeSendToRenderer(mainWindowRef, EDITOR_CHANGE, event); }); } else { editorFileWatcher.stop(); diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 7abb5ccc..a2acf0f4 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -8,6 +8,7 @@ import { createIpcWrapper } from '@main/ipc/ipcWrapper'; import { EditorFileWatcher } from '@main/services/editor'; import { ReviewDecisionStore } from '@main/services/team/ReviewDecisionStore'; import { validateFilePath } from '@main/utils/pathValidation'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { REVIEW_APPLY_DECISIONS, REVIEW_CHECK_CONFLICT, @@ -401,9 +402,7 @@ async function handleWatchReviewFiles( reviewFileWatcher.stop(); reviewWatcherProjectRoot = normalizedProjectPath; reviewFileWatcher.start(normalizedProjectPath, (event) => { - if (reviewMainWindowRef && !reviewMainWindowRef.isDestroyed()) { - reviewMainWindowRef.webContents.send(REVIEW_FILE_CHANGE, event); - } + safeSendToRenderer(reviewMainWindowRef, REVIEW_FILE_CHANGE, event); }); } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index b8723175..4e4b0d8f 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -22,6 +22,7 @@ import { appendCliAuthDiag } from '@main/utils/cliAuthDiagLog'; import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getCachedShellEnv, getShellPreferredHome, @@ -678,9 +679,7 @@ export class CliInstallerService { // --------------------------------------------------------------------------- private sendProgress(progress: CliInstallerProgress): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send(CLI_INSTALLER_PROGRESS_CHANNEL, progress); - } + safeSendToRenderer(this.mainWindow, CLI_INSTALLER_PROGRESS_CHANNEL, progress); } private detectPlatform(): CliPlatform { diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index c76543bd..4e3c1db7 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -17,6 +17,7 @@ import { getAppIconPath } from '@main/utils/appIcon'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { createLogger } from '@shared/utils/logger'; @@ -481,7 +482,7 @@ export class NotificationManager extends EventEmitter { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.show(); this.mainWindow.focus(); - this.mainWindow.webContents.send('notification:clicked', stored); + safeSendToRenderer(this.mainWindow, 'notification:clicked', stored); } this.emit('notification-clicked', stored); } @@ -556,9 +557,7 @@ export class NotificationManager extends EventEmitter { * Emits a notification:new event to the renderer. */ private emitNewNotification(notification: StoredNotification): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('notification:new', notification); - } + safeSendToRenderer(this.mainWindow, 'notification:new', notification); this.emit('notification-new', notification); } @@ -567,12 +566,10 @@ export class NotificationManager extends EventEmitter { * Emits a notification:updated event to the renderer. */ private emitNotificationUpdated(): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('notification:updated', { - total: this.notifications.length, - unreadCount: this.getUnreadCountSync(), - }); - } + safeSendToRenderer(this.mainWindow, 'notification:updated', { + total: this.notifications.length, + unreadCount: this.getUnreadCountSync(), + }); this.emit('notification-updated', { total: this.notifications.length, diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index d1828bc9..89008836 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -14,6 +14,8 @@ import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; import type { PtySpawnOptions } from '@shared/types/terminal'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; + import type { BrowserWindow } from 'electron'; const logger = createLogger('PtyTerminalService'); @@ -110,8 +112,6 @@ export class PtyTerminalService { } private send(channel: string, ...args: unknown[]): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send(channel, ...args); - } + safeSendToRenderer(this.mainWindow, channel, ...args); } } diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 6cd0c7e1..170b0ca5 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -10,6 +10,7 @@ * artifacts for the current platform. */ +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; import electronUpdater from 'electron-updater'; @@ -132,9 +133,7 @@ export class UpdaterService { } private sendStatus(status: UpdaterStatus): void { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('updater:status', status); - } + safeSendToRenderer(this.mainWindow, 'updater:status', status); } /** diff --git a/src/main/utils/safeWebContentsSend.ts b/src/main/utils/safeWebContentsSend.ts new file mode 100644 index 00000000..52d118ee --- /dev/null +++ b/src/main/utils/safeWebContentsSend.ts @@ -0,0 +1,54 @@ +import { createLogger } from '@shared/utils/logger'; + +import type { BrowserWindow } from 'electron'; + +const logger = createLogger('safeWebContentsSend'); +const rendererAvailability = new WeakMap(); + +export function markRendererReady(window: BrowserWindow | null | undefined): void { + if (!window || window.isDestroyed()) { + return; + } + rendererAvailability.set(window, true); +} + +export function markRendererUnavailable(window: BrowserWindow | null | undefined): void { + if (!window) { + return; + } + rendererAvailability.set(window, false); +} + +export function clearRendererAvailability(window: BrowserWindow | null | undefined): void { + if (!window) { + return; + } + rendererAvailability.delete(window); +} + +export function safeSendToRenderer( + window: BrowserWindow | null | undefined, + channel: string, + ...args: unknown[] +): boolean { + if (!window || window.isDestroyed()) { + return false; + } + + const contents = window.webContents; + if (!contents || contents.isDestroyed()) { + return false; + } + if (rendererAvailability.get(window) === false) { + return false; + } + + try { + contents.send(channel, ...args); + return true; + } catch (error) { + rendererAvailability.set(window, false); + logger.warn(`Failed to send "${channel}" to renderer: ${String(error)}`); + return false; + } +} diff --git a/test/main/utils/safeWebContentsSend.test.ts b/test/main/utils/safeWebContentsSend.test.ts new file mode 100644 index 00000000..b111af4b --- /dev/null +++ b/test/main/utils/safeWebContentsSend.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { warn } = vi.hoisted(() => ({ + warn: vi.fn(), +})); + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + warn, + }), +})); + +import { + clearRendererAvailability, + markRendererReady, + markRendererUnavailable, + safeSendToRenderer, +} from '../../../src/main/utils/safeWebContentsSend'; + +import type { BrowserWindow } from 'electron'; + +function createWindow(options?: { + windowDestroyed?: boolean; + contentsDestroyed?: boolean; + sendImpl?: (...args: unknown[]) => void; +}): BrowserWindow { + return { + isDestroyed: vi.fn(() => options?.windowDestroyed ?? false), + webContents: { + isDestroyed: vi.fn(() => options?.contentsDestroyed ?? false), + send: vi.fn(options?.sendImpl ?? (() => undefined)), + }, + } as unknown as BrowserWindow; +} + +describe('safeSendToRenderer', () => { + beforeEach(() => { + warn.mockReset(); + }); + + it('sends IPC to a live renderer', () => { + const window = createWindow(); + + const result = safeSendToRenderer(window, 'test:channel', { ok: true }); + + expect(result).toBe(true); + expect(window.webContents.send).toHaveBeenCalledWith('test:channel', { ok: true }); + }); + + it('returns false when window is missing or destroyed', () => { + expect(safeSendToRenderer(null, 'test:channel')).toBe(false); + + const destroyedWindow = createWindow({ windowDestroyed: true }); + expect(safeSendToRenderer(destroyedWindow, 'test:channel')).toBe(false); + expect(destroyedWindow.webContents.send).not.toHaveBeenCalled(); + }); + + it('returns false when webContents is destroyed', () => { + const window = createWindow({ contentsDestroyed: true }); + + const result = safeSendToRenderer(window, 'test:channel'); + + expect(result).toBe(false); + expect(window.webContents.send).not.toHaveBeenCalled(); + }); + + it('swallows renderer disposal errors and logs a warning', () => { + const window = createWindow({ + sendImpl: () => { + throw new Error('Render frame was disposed before WebFrameMain could be accessed'); + }, + }); + + const result = safeSendToRenderer(window, 'test:channel', 123); + + expect(result).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('test:channel'); + }); + + it('blocks sends while renderer is unavailable and resumes after ready', () => { + const window = createWindow(); + + markRendererUnavailable(window); + expect(safeSendToRenderer(window, 'test:channel', 'first')).toBe(false); + expect(window.webContents.send).not.toHaveBeenCalled(); + + markRendererReady(window); + expect(safeSendToRenderer(window, 'test:channel', 'second')).toBe(true); + expect(window.webContents.send).toHaveBeenCalledWith('test:channel', 'second'); + + clearRendererAvailability(window); + }); +}); From ecba775c7641efe57f5f8d216dd8184187d8efb7 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 17:21:18 +0200 Subject: [PATCH 023/113] Improve macOS title bar drag area --- src/renderer/components/layout/TabBar.tsx | 74 ++++++++++++++--------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index c1b99164..9e079e03 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -243,42 +243,53 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { isMacElectron && isLeftmostPane ? 'var(--macos-traffic-light-padding-left, 72px)' : '8px', - WebkitAppRegion: 'no-drag', + WebkitAppRegion: isMacElectron ? 'drag' : 'no-drag', opacity: isFocused || paneCount === 1 ? 1 : 0.7, } as React.CSSProperties } > - {/* Tab list with horizontal scroll, sortable DnD, and droppable area. - Capped at 75% so the drag spacer always has room to the right. */}

{ - scrollContainerRef.current = el; - setDroppableRef(el); - }} - className="scrollbar-none flex min-w-0 flex-1 items-center gap-1" - style={{ - outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', - outlineOffset: '-1px', - overflowX: 'auto', - overflowY: 'hidden', - }} + className="flex min-w-0 shrink items-center gap-1" + style={ + { + WebkitAppRegion: 'no-drag', + flex: '0 1 auto', + maxWidth: 'calc(100% - 32px)', + } as React.CSSProperties + } > - - {openTabs.map((tab) => ( - - ))} - + {/* Keep the sortable list inside a no-drag group so tabs remain clickable, + while any leftover space in the pane segment can drag the window. */} +
{ + scrollContainerRef.current = el; + setDroppableRef(el); + }} + className="scrollbar-none flex min-w-0 flex-1 items-center gap-1" + style={{ + outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', + outlineOffset: '-1px', + overflowX: 'auto', + overflowY: 'hidden', + }} + > + + {openTabs.map((tab) => ( + + ))} + +
{/* Refresh button - show only for session tabs */} {activeTab?.type === 'session' && ( @@ -298,6 +309,9 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )}
+ {/* Guaranteed drag target, even when the tab list is dense. */} +
+ {/* Context menu */} {contextMenu && contextMenuTabId && ( Date: Fri, 27 Mar 2026 17:51:49 +0200 Subject: [PATCH 024/113] Add deep dive research documentation on AI agent orchestration and communication standards - Introduced multiple markdown files covering agent spawn packages, inter-agent communication protocols, and multi-agent orchestration tools. - Detailed analysis of official SDKs for CLI agents (Claude Code, Codex, Gemini) and their integration potential. - Documented various competitor approaches to agent spawning and communication, highlighting strengths and weaknesses. - Provided insights into best practices for implementing multi-provider support within Electron applications. This comprehensive documentation aims to enhance understanding of the current AI agent ecosystem and serve as a resource for developers and stakeholders. --- docs/research/agent-spawn-packages.md | 465 ++++++++++++ docs/research/ai-maestro-deep-dive.md | 334 +++++++++ docs/research/competitor-spawn-patterns.md | 523 +++++++++++++ .../inter-agent-communication-standards.md | 639 ++++++++++++++++ docs/research/minimal-adapter-design.md | 689 ++++++++++++++++++ .../multi-agent-communication-tools.md | 542 ++++++++++++++ docs/research/opencode-deep-dive.md | 480 ++++++++++++ docs/research/orchestrator-as-foundation.md | 284 ++++++++ docs/research/sdk-vs-cli-comparison.md | 376 ++++++++++ 9 files changed, 4332 insertions(+) create mode 100644 docs/research/agent-spawn-packages.md create mode 100644 docs/research/ai-maestro-deep-dive.md create mode 100644 docs/research/competitor-spawn-patterns.md create mode 100644 docs/research/inter-agent-communication-standards.md create mode 100644 docs/research/minimal-adapter-design.md create mode 100644 docs/research/multi-agent-communication-tools.md create mode 100644 docs/research/opencode-deep-dive.md create mode 100644 docs/research/orchestrator-as-foundation.md create mode 100644 docs/research/sdk-vs-cli-comparison.md diff --git a/docs/research/agent-spawn-packages.md b/docs/research/agent-spawn-packages.md new file mode 100644 index 00000000..d1241994 --- /dev/null +++ b/docs/research/agent-spawn-packages.md @@ -0,0 +1,465 @@ +# Agent Spawn Packages — Deep Dive Research + +**Дата:** 2026-03-25 +**Цель:** Найти лучший способ программно запускать CLI-агентов (Claude Code, Codex, Gemini CLI) из Electron-приложения. + +--- + +## TL;DR — Итоговая Рекомендация + +У всех трёх главных CLI-агентов **теперь есть ОФИЦИАЛЬНЫЕ SDK** для программного запуска: + +| Агент | SDK Пакет | Лицензия | Зрелость | +|-------|-----------|----------|----------| +| Claude Code | `@anthropic-ai/claude-agent-sdk` | **Proprietary** (Commercial ToS) | Stable (v0.2.83) | +| Codex | `@openai/codex-sdk` | **Apache-2.0** | Stable (v0.116.0) | +| Gemini CLI | `@google/gemini-cli-sdk` + `@google/gemini-cli-core` | **Apache-2.0** | Early (v0.30.0+) | + +**Вывод:** Вместо форка `@swarmify/agents-mcp` или написания своих spawn-обёрток, лучше использовать **официальные SDK** от каждого провайдера. Они более надёжны, поддерживаются, и дают нативный доступ без хрупкого парсинга stdout. + +--- + +## 1. @swarmify/agents-mcp + +**npm:** https://www.npmjs.com/package/@swarmify/agents-mcp +**Сайт:** https://swarmify.co/ +**GitHub:** НЕ НАЙДЕН (closed-source или приватный репозиторий) + +### Что это +MCP-сервер, который позволяет любому MCP-клиенту (Claude, Codex, Gemini, Cursor) спавнить параллельных агентов. Часть экосистемы Swarmify. + +### Предоставляет +- **4 MCP-тула:** Spawn, Status, Stop, Tasks +- **3 режима:** plan (read-only), edit (can write), ralph (autonomous) +- Фоновые процессы — агенты переживают перезапуск IDE +- Авто-детект Claude, Codex, Gemini CLI при установке + +### Как спавнит агентов +Агенты коммуницируют через файловую систему — каждый агент пишет в свой лог-файл (stdout.log). Тул Status читает эти логи, нормализует события между разными форматами агентов, и возвращает сводку. + +### IAgent / BaseAgent — НЕ НАЙДЕНЫ +Несмотря на множество поисков, интерфейсы `IAgent` и `BaseAgent` **не обнаружены** в публичной документации пакета. Возможно, они существуют внутри скомпилированного npm-пакета (можно проверить через `node_modules`), но исходный код закрыт. + +### Оценка для нас +- **Надёжность: 3/10** — Closed-source, нет GitHub, невозможно форкнуть +- **Уверенность: 2/10** — Без исходного кода невозможно оценить качество +- **Вердикт:** НЕ подходит для нашего проекта + +--- + +## 2. @anthropic-ai/claude-agent-sdk (OFFICIAL) + +**npm:** https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk +**GitHub:** https://github.com/anthropics/claude-agent-sdk-typescript +**Docs:** https://platform.claude.com/docs/en/agent-sdk/typescript +**Версия:** 0.2.83 (25 марта 2026) +**Лицензия:** Proprietary (Anthropic Commercial Terms of Service) +**Звёзды:** ~1000 | **Форки:** ~117 | **Релизы:** 67 +**691 проект** в npm registry используют этот пакет + +### Что это +Официальный SDK от Anthropic для программного запуска Claude Code. Переименован из "Claude Code SDK" в "Claude Agent SDK". Даёт те же инструменты, agent loop и context management, что и Claude Code. + +### Ключевой API + +```typescript +import { query } from "@anthropic-ai/claude-agent-sdk"; + +// Основная функция — async generator +const q = query({ + prompt: "Fix the bug in auth.py", + options: { + model: "opus", + cwd: "/path/to/project", + allowedTools: ["Read", "Edit", "Bash"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 50, + maxBudgetUsd: 5.0, + env: { ANTHROPIC_API_KEY: "..." }, + mcpServers: { /* MCP config */ }, + agents: { + // Программно определяемые субагенты + reviewer: { + description: "Code reviewer agent", + prompt: "Review code for bugs", + model: "sonnet", + tools: ["Read", "Grep", "Glob"], + } + }, + settingSources: ["project"], // Загрузка CLAUDE.md + thinking: { type: "adaptive" }, + } +}); + +// Стриминг событий +for await (const message of q) { + // SDKMessage types: assistant, user, result, system, etc. + console.log(message); +} + +// Query object methods: +// q.interrupt(), q.close(), q.setModel(), q.mcpServerStatus() +// q.initializationResult(), q.supportedModels(), q.supportedAgents() +``` + +### Как спавнит Claude Code +SDK **запускает Claude Code CLI как subprocess** — НЕ чисто API-библиотека. Каждый вызов `query()` спавнит новый процесс (~12 сек overhead). + +Ключевые опции: +- `spawnClaudeCodeProcess` — кастомная функция для запуска процесса (VMs, Docker, remote) +- `pathToClaudeCodeExecutable` — путь к бинарнику Claude Code +- `env` — переменные окружения для subprocess (полезно для Electron) +- `executable` — runtime: `'node'`, `'bun'`, `'deno'` + +### Session Management +```typescript +import { listSessions, getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; + +const sessions = await listSessions({ dir: "/path/to/project", limit: 10 }); +const messages = await getSessionMessages(sessionId, { limit: 20 }); + +// Resume session +const q = query({ + prompt: "Continue working", + options: { resume: sessionId } +}); +``` + +### V2 Preview API (упрощённый интерфейс) +Новый API с `send()` и `stream()` паттернами для multi-turn conversations. + +### Субагенты +Определяются программно через `agents` option в `AgentDefinition`: +```typescript +type AgentDefinition = { + description: string; + prompt: string; + tools?: string[]; + disallowedTools?: string[]; + model?: "sonnet" | "opus" | "haiku" | "inherit"; + mcpServers?: AgentMcpServerSpec[]; + skills?: string[]; + maxTurns?: number; +}; +``` + +### MCP-серверы +Поддерживает in-process MCP серверы: +```typescript +import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; + +const server = createSdkMcpServer({ + name: "my-server", + tools: [ + tool("search", "Search the web", { query: z.string() }, async ({ query }) => { + return { content: [{ type: "text", text: `Results for: ${query}` }] }; + }) + ] +}); +``` + +### Ограничения лицензии +- **Proprietary** — НЕ open-source +- Запрещено использовать OAuth токены Claude Free/Pro/Max — нужен **API key** +- Продукт должен иметь **собственный брендинг** (не Claude Code) +- Anthropic собирает telemetry (usage, feedback, conversations) + +### Оценка для нас +- **Надёжность: 9/10** — Официальный SDK от Anthropic, активно развивается +- **Уверенность: 9/10** — Отлично документирован, 691+ пользователь +- **Риск:** Proprietary лицензия, ~12 сек overhead на query(), зависимость от CLI binary + +--- + +## 3. @openai/codex-sdk (OFFICIAL) + +**npm:** https://www.npmjs.com/package/@openai/codex-sdk +**GitHub:** https://github.com/openai/codex/tree/main/sdk/typescript +**Docs:** https://developers.openai.com/codex/sdk +**Версия:** 0.116.0 +**Лицензия:** Apache-2.0 +**107 проектов** в npm registry используют этот пакет + +### Что это +Официальный TypeScript SDK от OpenAI для программного управления Codex CLI. Оборачивает CLI, обменивается JSONL-событиями через stdin/stdout. + +### Ключевой API + +```typescript +import { Codex } from "@openai/codex-sdk"; + +// Инициализация +const codex = new Codex({ + env: { PATH: "/usr/local/bin" }, // Полезно для Electron + config: { + show_raw_agent_reasoning: true, + sandbox_workspace_write: { network_access: true } + }, + baseUrl: "https://api.example.com" // Optional +}); + +// Thread management +const thread = codex.startThread({ + workingDirectory: "/path/to/project", + skipGitRepoCheck: true // Для non-git environments +}); + +// Buffered response +const turn = await thread.run("Fix the test failure"); +console.log(turn.finalResponse); +console.log(turn.items); + +// Streaming response +const { events } = await thread.runStreamed("Diagnose failures"); +for await (const event of events) { + switch (event.type) { + case "item.completed": console.log("Item:", event.item); break; + case "turn.completed": console.log("Usage:", event.usage); break; + } +} + +// Multi-turn conversations +const turn1 = await thread.run("Diagnose issue"); +const turn2 = await thread.run("Implement the fix"); + +// Resume persisted thread +const thread2 = codex.resumeThread(process.env.CODEX_THREAD_ID!); +``` + +### Как спавнит Codex CLI +SDK спавнит **Codex CLI** (Rust-based `@openai/codex`) как subprocess и обменивается JSONL-событиями через stdin/stdout. + +- Работает **ТОЛЬКО** с Native (Rust) версией Codex +- SDK инжектит `CODEX_API_KEY` поверх переданных env variables +- `env` параметр — полный контроль над переменными (полезно для Electron) +- `config` — JSON → dotted paths → TOML literals → `--config key=value` flags + +### Session Persistence +- Threads сохраняются в `~/.codex/sessions` +- `resumeThread(id)` — восстановление потерянного Thread + +### Structured Output +```typescript +const schema = { + type: "object", + properties: { + summary: { type: "string" }, + status: { type: "string", enum: ["ok", "action_required"] } + }, + required: ["summary", "status"], + additionalProperties: false +} as const; + +const turn = await thread.run("Summarize status", { outputSchema: schema }); +``` + +### Multi-Agent Collaboration +Поддержка spawn_agent, send_input, wait для координации между threads. + +### Оценка для нас +- **Надёжность: 9/10** — Официальный SDK от OpenAI, Apache-2.0 +- **Уверенность: 8/10** — Хорошо документирован, активно развивается +- **Риск:** Только Rust-based Codex, зависимость от Git repo (опционально отключается) + +--- + +## 4. @google/gemini-cli-sdk + @google/gemini-cli-core (OFFICIAL) + +**npm (CLI):** https://www.npmjs.com/package/@google/gemini-cli +**npm (Core):** https://www.npmjs.com/package/@google/gemini-cli-core +**GitHub:** https://github.com/google-gemini/gemini-cli +**Docs:** https://deepwiki.com/google-gemini/gemini-cli/5.9-sdk-and-programmatic-api +**Версия:** SDK появился в v0.30.0 (2026-02-25) +**Лицензия:** Apache-2.0 +**Звёзды:** ~99K + +### Что это +Официальный SDK от Google для программного запуска Gemini CLI. Монорепо-архитектура. + +### Архитектура пакетов +| Пакет | Роль | +|-------|------| +| `@google/gemini-cli-sdk` | Consumer-facing API | +| `@google/gemini-cli-core` | Core orchestration, tools, API | +| `@google/gemini-cli` | Terminal reference implementation | + +### Ключевой API + +```typescript +import { LocalAgentExecutor, LocalAgentDefinition } from '@google/gemini-cli-core'; + +const agentDef: LocalAgentDefinition = { + modelId: 'gemini-2.0-flash', + systemPrompt: 'You are a helpful assistant', + tools: ['read_file', 'write_file', 'run_shell_command'], + maxTurns: 30, + timeoutMs: 600000 // 10 min +}; + +const executor = new LocalAgentExecutor(config, agentDef); + +// Activity monitoring +executor.onActivity((event) => { + console.log('Agent activity:', event); +}); + +const result = await executor.run({ + task: 'Analyze the codebase and suggest improvements' +}); + +console.log('Termination mode:', result.terminateMode); // GOAL | MAX_TURNS | TIMEOUT +console.log('Result:', result.output); +``` + +### Как спавнит Gemini +В отличие от Claude и Codex, SDK Gemini — **НЕ CLI wrapper**, а **нативная библиотека**. Использует core-логику напрямую: +- `GeminiCliAgent` / `LocalAgentExecutor` — primary entity +- Каждый агент получает свой `ToolRegistry` (изоляция) +- `MessageBus` для async events (tool confirmations) +- `Config` class для model selection и auth + +### Tool Management +- Built-in tools (file system, shell, web) +- MCP server tools +- Extension-provided tools +- Tool confirmation через `TOOL_CONFIRMATION_REQUEST` event + +### Agent Termination +```typescript +enum AgentTerminateMode { + GOAL, // Успешно вызвал complete_task + MAX_TURNS, // Достиг лимита (default 30) + TIMEOUT // Превысил время (default 10 min) +} +``` + +### Headless Mode (альтернатива) +```bash +gemini --output-format json -p "Summarize project" +gemini --output-format stream-json -p "Fix bug" +``` + +### Зрелость +- SDK появился в v0.30.0 (2026-02-25) — **очень свежий** +- Feature request #15539 (Dec 2025) формально запрашивал SDK +- API может меняться + +### Оценка для нас +- **Надёжность: 6/10** — Apache-2.0, открытый код, но SDK совсем новый +- **Уверенность: 6/10** — API может меняться, документация неполная +- **Преимущество:** Нативная библиотека (не CLI wrapper), лучшая производительность + +--- + +## 5. Альтернативные Multi-Agent Frameworks + +### jayminwest/overstory +**GitHub:** https://github.com/jayminwest/overstory +**Лицензия:** MIT + +Pluggable `AgentRuntime` интерфейс с **11 адаптерами** (Claude Code, Codex, Gemini CLI, Aider, Goose, Amp и др). Агенты работают в изолированных git worktrees через tmux. + +| Runtime | CLI | Guard Mechanism | +|---------|-----|-----------------| +| Claude Code | `claude` | `settings.local.json` hooks | +| Codex | `codex` | OS-level sandbox | +| Gemini | `gemini` | `--sandbox` flag | +| Aider | `aider` | None (`--yes-always`) | +| Goose | `goose` | Profile-based permissions | +| + 6 others | ... | ... | + +**Интересно, но:** Ориентирован на CLI/tmux workflow, не на Electron SDK. + +### desplega-ai/agent-swarm +**GitHub:** https://github.com/desplega-ai/agent-swarm +**Docs:** https://docs.agent-swarm.dev/ + +Lead-worker паттерн с Docker-изоляцией. Поддерживает Claude Code, с планами на Codex/Gemini. SQLite + bun. + +--- + +## 6. Сравнительная Таблица SDK + +| | Claude Agent SDK | Codex SDK | Gemini CLI SDK | +|---|---|---|---| +| **Пакет** | `@anthropic-ai/claude-agent-sdk` | `@openai/codex-sdk` | `@google/gemini-cli-sdk` | +| **Версия** | 0.2.83 | 0.116.0 | ~0.30.0+ | +| **Лицензия** | Proprietary | Apache-2.0 | Apache-2.0 | +| **Архитектура** | CLI subprocess | CLI subprocess (Rust) | Нативная библиотека | +| **Стриминг** | AsyncGenerator | AsyncGenerator (events) | onActivity callback | +| **Session Resume** | Да (sessionId) | Да (resumeThread) | Да (SessionContext) | +| **Субагенты** | Да (agents option) | Да (spawn_agent) | Да (LocalAgentDefinition) | +| **MCP серверы** | Да (in-process + external) | Нет (native tools only) | Да (ToolRegistry) | +| **Custom Env** | Да (env option) | Да (env option) | Да (Config) | +| **Custom Spawn** | Да (spawnClaudeCodeProcess) | Нет | Нет (нативная) | +| **Structured Output** | Да (JSON Schema) | Да (JSON Schema + Zod) | Да (zod OutputConfig) | +| **Node.js** | 18+ | 18+ | 18+ | +| **Overhead** | ~12s per query() | Не измерен | Минимальный (нативная) | +| **npm Users** | 691 | 107 | N/A (новый) | + +--- + +## 7. Рекомендация для Claude Agent Teams UI + +### Основной подход (Recommended) +**Использовать официальные SDK каждого провайдера** вместо единого абстрактного слоя. + +``` +src/main/services/agents/ +├── types.ts # Общие типы (AgentProcess, AgentEvent, AgentConfig) +├── claude-adapter.ts # Обёртка над @anthropic-ai/claude-agent-sdk +├── codex-adapter.ts # Обёртка над @openai/codex-sdk +├── gemini-adapter.ts # Обёртка над @google/gemini-cli-sdk +└── agent-registry.ts # Реестр доступных агентов +``` + +Тонкий адаптерный слой (~100-150 LOC на адаптер) над каждым SDK, нормализующий: +- Стриминг событий → единый `AgentEvent` формат +- Session management → единый `AgentSession` интерфейс +- Process lifecycle → start/stop/status + +### Почему НЕ @swarmify/agents-mcp +1. Closed-source — невозможно аудировать или форкнуть +2. MCP-only интерфейс — мы уже имеем прямой доступ к процессам +3. Filesystem-based communication — избыточный overhead для Electron + +### Почему НЕ единый CLI spawn +1. Все 3 провайдера выпустили свои SDK +2. SDK дают типизированные события, session management, structured output +3. Raw CLI spawn хрупок (парсинг stdout/ANSI codes) + +### Почему НЕ overstory AgentRuntime +1. Ориентирован на tmux/worktree workflow +2. MIT лицензия хорошая, но архитектура не подходит для Electron +3. 11 адаптеров — избыточно, нам нужны 3 + +### Порядок интеграции +1. **Claude Code** (`@anthropic-ai/claude-agent-sdk`) — у нас уже есть, нужно мигрировать на SDK +2. **Codex** (`@openai/codex-sdk`) — Apache-2.0, простой API, thread-based +3. **Gemini** (`@google/gemini-cli-sdk`) — подождать стабилизации API (SDK очень свежий) + +### Риски +- **Claude SDK Proprietary лицензия** — нужно проверить совместимость с нашим MIT +- **~12s overhead** Claude SDK per query — может потребоваться process pooling +- **Gemini SDK API unstable** — может сломаться в любом релизе + +--- + +## Источники + +- [@swarmify/agents-mcp (npm)](https://www.npmjs.com/package/@swarmify/agents-mcp) +- [Swarmify](https://swarmify.co/) +- [@anthropic-ai/claude-agent-sdk (npm)](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) +- [Claude Agent SDK TypeScript (GitHub)](https://github.com/anthropics/claude-agent-sdk-typescript) +- [Claude Agent SDK Reference](https://platform.claude.com/docs/en/agent-sdk/typescript) +- [Run Claude Code programmatically](https://code.claude.com/docs/en/headless) +- [@openai/codex-sdk (npm)](https://www.npmjs.com/package/@openai/codex-sdk) +- [Codex SDK TypeScript (GitHub)](https://github.com/openai/codex/tree/main/sdk/typescript) +- [Codex SDK Docs](https://developers.openai.com/codex/sdk) +- [@google/gemini-cli (GitHub)](https://github.com/google-gemini/gemini-cli) +- [Gemini CLI SDK (DeepWiki)](https://deepwiki.com/google-gemini/gemini-cli/5.9-sdk-and-programmatic-api) +- [Gemini CLI SDK Feature Request #15539](https://github.com/google-gemini/gemini-cli/issues/15539) +- [overstory (GitHub)](https://github.com/jayminwest/overstory) +- [agent-swarm (GitHub)](https://github.com/desplega-ai/agent-swarm) diff --git a/docs/research/ai-maestro-deep-dive.md b/docs/research/ai-maestro-deep-dive.md new file mode 100644 index 00000000..5267ec2c --- /dev/null +++ b/docs/research/ai-maestro-deep-dive.md @@ -0,0 +1,334 @@ +# AI Maestro — Deep Dive Research + +**Дата исследования:** 2026-03-25 +**Репозиторий:** [23blocks-OS/ai-maestro](https://github.com/23blocks-OS/ai-maestro) +**Сайт:** [ai-maestro.23blocks.com](https://ai-maestro.23blocks.com/) +**Автор:** Juan Pelaez / 23blocks +**Лицензия:** MIT + +--- + +## Общая информация + +AI Maestro — open-source оркестратор AI-агентов с системой навыков (Skills System), дашбордом для управления агентами, собственным протоколом обмена сообщениями (AMP) и поддержкой мультимашинных mesh-сетей. Позиционирует себя как "The Future of Work: Humans + AI Agents". + +### Метрики репозитория (на 25 марта 2026) + +| Метрика | Значение | +|---------|----------| +| Stars | **557** | +| Forks | 77 | +| Open issues | 8 | +| Коммитов | 890+ | +| Контрибьюторов | ~5 (249 от jpelaez-23blocks, далее 9, 7, 4, 2) | +| Создан | 10 октября 2025 | +| Последний коммит | 25 марта 2026 (сегодня!) | +| Последний релиз | v0.26.4 (25 марта 2026) | +| Языки | TypeScript 89%, Shell 6.7%, JS 3.4%, CSS 0.5% | +| Размер репо | ~312 MB | + +**Вывод:** Проект активно развивается, коммиты ежедневные. Но по факту это проект одного человека (Juan Pelaez — 249 из ~270 коммитов от людей). 4 коммита от аккаунта `claude` — что ироничным образом подтверждает AI-происхождение кода. + +--- + +## Origin Story + +Цитата из описания проекта: +> "I had 35 terminals and couldn't tell which was which." + +Автор запускал 35+ AI-агентов одновременно и стал "human mailman" между ними — копировал контекст из одного терминала в другой. Сейчас утверждает, что запускает **80+ агентов** на нескольких компьютерах. + +--- + +## Поддерживаемые агенты + +### Заявленная совместимость: +- **Claude Code** (основной) +- **Aider** +- **Cursor** +- **GitHub Copilot CLI** +- **OpenCode** (через Skills) +- **Любой терминальный AI-агент** + +### Как это работает: +AI Maestro не является "мультипровайдерным" в том смысле, что он сам вызывает API разных LLM. Он работает на уровне **терминалов** — оборачивает tmux-сессии и предоставляет dashboard для управления ими. Любой инструмент, который работает в терминале, может быть "агентом" в AI Maestro. + +**Важное уточнение:** AI Maestro НЕ абстрагирует LLM-провайдеров (как например LiteLLM). Он оркестрирует **процессы в терминале**. Claude Code внутри себя использует Anthropic API, Aider может использовать OpenAI/Anthropic/etc — но AI Maestro этого не контролирует. + +--- + +## Архитектура + +### Tech Stack + +| Компонент | Технология | Роль | +|-----------|-----------|------| +| Frontend | **Next.js** | Web-дашборд | +| Terminal | **xterm.js** | Эмуляция терминала в браузере | +| Database | **CozoDB** | Граф-реляционная БД для памяти и Code Graph | +| Code Analysis | **ts-morph** | Парсинг AST для Code Graph | +| Process Mgmt | **tmux** | Мультиплексор терминалов | +| Networking | **Peer Mesh** | P2P сеть между машинами | + +### CozoDB — выбор базы данных + +CozoDB (3 926 stars) — необычный выбор. Это транзакционная реляционно-графовая-векторная БД, использующая **Datalog** для запросов. Ключевые фичи: +- Реляционная модель + графовые алгоритмы +- Векторный поиск через HNSW-индексы +- Встраиваемая (embedded) +- Time-travel запросы + +Это позволяет хранить и код-граф (структура кодобазы), и память агентов (conversation history), и выполнять векторный поиск — в одной БД. + +### Три уровня "интеллекта" + +1. **Memory** — Персистентная память через CozoDB. Агенты помнят прошлые решения и разговоры. +2. **Code Graph** — Визуализация структуры кодобазы. ts-morph парсит AST, извлекает классы/функции/импорты, строит граф зависимостей. Delta-индексация (переиндексируются только изменённые файлы). +3. **Documentation** — Автогенерируемая документация из кода, доступная агентам для поиска. + +### Мультимашинная mesh-сеть + +- Peer-to-peer топология: каждая машина — равноправный узел +- Нет центрального сервера +- Новая машина автоматически присоединяется к mesh +- Все агенты со всех машин видны в одном дашборде +- Поддержка remote access через Tailscale VPN + +### Структура репозитория + +``` +/app — Application logic +/components — UI-компоненты +/services — Backend-сервисы +/plugin — Система плагинов для Claude Code +/agent-container — Контейнеризированные агенты +/infrastructure/terraform/aws-agent — AWS deployment +/docs — Документация +``` + +--- + +## Agent Messaging Protocol (AMP) + +AMP — это **собственный протокол** 23blocks для межагентной коммуникации. Отдельный репозиторий: [agentmessaging/protocol](https://github.com/agentmessaging/protocol) (20 stars, Apache 2.0). + +### Ключевые характеристики + +| Параметр | Значение | +|----------|----------| +| Версия | 0.1.3-draft | +| Лицензия | Apache 2.0 | +| Безопасность | Ed25519 криптографические подписи | +| Адресация | Email-подобная: `agent-name@tenant.provider` | +| Спецификации | 11 документов | + +### Формат сообщений + +Конверт содержит: +- `from` / `to` — адреса отправителя/получателя +- `subject` — тема +- `priority` — приоритет +- `in_reply_to` — для тредов +- `payload` — произвольный JSON +- `signature` — Ed25519 подпись + +Каноническая подпись: `from|to|subject|priority|in_reply_to|SHA256(payload)` + +### Доставка сообщений + +4 способа: +1. **WebSocket** — реалтайм для подключённых агентов +2. **REST API** — polling +3. **Webhook** — HTTP POST push +4. **Relay queue** — очередь для офлайн-агентов (TTL 7 дней по умолчанию) +5. **Mesh** — локальная маршрутизация без интернета + +### Провайдеры (федеративная модель) + +- **AI Maestro** (self-hosted): `http://localhost:23000/api/v1` — работает +- **crabmail.ai** — "coming soon" +- **lolainbox.com** — "coming soon" + +### Безопасность + +- Ed25519 подписи предотвращают подмену отправителя +- Trust-level аннотации для внешних сообщений +- Key revocation с федеративным распространением +- Защита от prompt injection (34 паттерна) +- SSRF-превенция для webhook + +### Критическая оценка AMP + +**Плюсы:** +- Формально специфицированный протокол (11 документов) +- Криптографическая безопасность по умолчанию +- Федеративная модель +- Поддержка офлайн-агентов + +**Минусы:** +- Всего 20 stars на GitHub +- Единственная реализация — сам AI Maestro +- Федерация заявлена, но 2 из 3 провайдеров "coming soon" +- По факту проприетарный протокол одного проекта, несмотря на Apache 2.0 лицензию +- Не совместим с ACP (Agent Communication Protocol), MCP или другими стандартами + +--- + +## Kanban Board + +AI Maestro включает kanban-доску с: +- **5 колонок** (статусы задач) +- **Drag-and-drop** перемещение карточек +- **Зависимости** между задачами +- **Шаренные задачи** между агентами +- Часть "War Room" — split-pane интерфейс для командных встреч + +**Детали реализации Kanban ограничены** — в документации и на сайте нет скриншотов или подробного описания UX. Описание сводится к маркетинговым фразам: "full Kanban board with drag-and-drop, dependencies, and 5 status columns." + +--- + +## Gateways — внешние интеграции + +AI Maestro поддерживает "Gateways" для подключения к внешним сервисам: +- **Slack** +- **Discord** +- **Email** +- **WhatsApp** + +Маршрутизация через синтаксис `@AIM:agent-name`. Заявлена защита от 34 паттернов prompt injection. + +--- + +## Skills System + +Система плагинов, устанавливаемых через `npx skills add`. Навыки автоматически триггерят: +- Поиск по памяти +- Запросы к Code Graph +- Поиск документации + +Совместим с 30+ агентами через "Agent Skills Standard". + +### Agent Identity (AID) + +Новая фича (v0.26.0, 24 марта 2026): агенты могут аутентифицироваться на OAuth 2.0 серверах используя Ed25519 identity. Без паролей, без API-ключей. + +--- + +## Релизная активность + +Последние 5 релизов (за 2 дня!): + +| Версия | Дата | Описание | +|--------|------|----------| +| v0.26.4 | 25.03.2026 | AMP mesh routing fix | +| v0.26.3 | 24.03.2026 | AID v0.2.0: независим от AMP | +| v0.26.2 | 24.03.2026 | Dynamic discovery для verification | +| v0.26.1 | 24.03.2026 | Переименование installer, auto-discover skills | +| v0.26.0 | 24.03.2026 | Agent Identity (AID) интеграция | + +5 релизов за 2 дня — это очень высокий темп. Может свидетельствовать как об активной разработке, так и о незрелости (частые фиксы только что выпущенных фич). + +--- + +## Сравнение с нашим продуктом (Claude Agent Teams UI) + +### Фундаментальные различия + +| Аспект | AI Maestro | Claude Agent Teams UI | +|--------|-----------|----------------------| +| **Подход** | Терминальный оркестратор (tmux wrapper) | Нативная UI-надстройка над Claude Code Agent Teams | +| **Агенты** | Любой терминальный AI | Claude Code (нативный Agent Teams API) | +| **Мультипровайдер** | Да (на уровне терминалов) | Нет (Claude-only, но с multi-model: Opus/Sonnet/Haiku) | +| **Kanban** | Есть (5 колонок, drag-drop, dependencies) | Есть (5 колонок, drag-drop, real-time) | +| **Межагентная связь** | AMP protocol (собственный) | Нативный Claude Code inbox/task system | +| **Code Review** | Не указан | Diff view с approve/reject/comment | +| **Deep Analytics** | Memory + Code Graph + Docs | Session analysis, context tracking, token usage | +| **Мультимашинность** | Peer mesh network | Нет (локальный) | +| **UI** | Web (Next.js, браузер) | Desktop (Electron) | +| **Процесс** | tmux sessions | stream-json CLI processes | + +### Где AI Maestro сильнее + +1. **Мультимашинность** — peer mesh сеть, агенты на разных компьютерах. У нас этого нет вообще. +2. **Мультиагентность** — поддерживает Claude, Aider, Cursor, Copilot и любой терминальный инструмент. Мы только Claude Code. +3. **Memory System** — CozoDB с графовыми запросами, векторным поиском, персистентной памятью. У нас аналитика сессий, но не полноценная "память" агентов. +4. **Code Graph** — визуализация кодобазы через ts-morph + CozoDB. У нас такого нет. +5. **External Gateways** — Slack, Discord, Email, WhatsApp. У нас встроенный MCP-сервер, но не gateway к мессенджерам. +6. **Scale** — заявляет 80+ агентов. Наш продукт ориентирован на команды 3-8 агентов. + +### Где наш продукт сильнее + +1. **Нативная интеграция с Claude Code** — мы работаем с официальным Agent Teams API, а не просто оборачиваем терминалы. Наши агенты нативно общаются через inbox, шарят задачи, имеют structured task references. +2. **Code Review** — полноценный diff view с accept/reject/comment, как в Cursor. У AI Maestro это не заявлено. +3. **Kanban UX** — у нас real-time обновления, direct messaging на карточках, quick actions, structured task references с кросс-ссылками. AI Maestro заявляет Kanban, но без деталей UX. +4. **Deep Session Analysis** — bash commands, reasoning, subprocesses breakdown, chunk timeline. AI Maestro показывает терминал, но не анализирует сессии. +5. **Context Monitoring** — 6 категорий контекста (CLAUDE.md, tool outputs, thinking, team coordination), token usage by category. Уникальная фича. +6. **Desktop App** — нативный Electron, не браузерная вкладка. +7. **DM to agents** — прямые сообщения конкретному агенту с карточки задачи. +8. **Zero-setup** — встроенная установка Claude Code и аутентификация. AI Maestro требует Node.js + tmux + установку. +9. **Built-in Code Editor** — редактор файлов с Git support. +10. **Post-compact context recovery** — восстановление инструкций после context compaction. + +### Фундаментальная разница в философии + +**AI Maestro** = "Terminal multiplexer on steroids" — оборачивает tmux, добавляет UI и межагентную коммуникацию. Агенты — это просто терминальные сессии. Протокол AMP — собственный, не стандартный. + +**Claude Agent Teams UI** = "CTO dashboard for Claude teams" — нативная надстройка над Claude Code Agent Teams, с глубоким пониманием внутренних протоколов Claude (stream-json, inbox, tasks). Агенты — это структурированные сущности с ролями, задачами и коммуникацией. + +--- + +## Рыночное позиционирование + +AI Maestro позиционирован в [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) в категории **"Parallel Agent Runners"** наряду с 38 другими инструментами. + +### Конкуренты AI Maestro (не наши) + +| Инструмент | Фокус | Stars | +|-----------|-------|-------| +| **Maestro** (RunMaestro) | Desktop orchestrator, Claude/Codex/OpenCode | 2000+ | +| **Vibe Kanban** (BloopAI) | Kanban + Git worktree + MCP | N/A | +| **Claw-Kanban** | Kanban + role-based auto-assignment | N/A | +| **Agent Orchestrator** (ComposioHQ) | Plugin-based, tracker-agnostic | N/A | + +RunMaestro (отдельный проект) — самый серьёзный конкурент для AI Maestro: 2000+ stars, desktop app, Group Chat, Auto Run, Mobile Remote Control. + +--- + +## Оценки + +### Надёжность решения: 6/10 + +- Проект одного разработчика (249 из ~270 коммитов) +- Зависимость от CozoDB (нишевая БД) +- 5 релизов за 2 дня — признак незрелости +- AMP протокол — 20 stars, единственная реализация +- Нет community reviews (Reddit/HN) +- Нет automated tests (не видно в описании) + +### Уровень угрозы для нашего продукта: 4/10 + +- Другая ниша: мультиагентный терминальный оркестратор vs нативный Claude Teams UI +- Наша аудитория — пользователи Claude Code Agent Teams +- Их аудитория — пользователи 3+ разных AI-инструментов +- Пересечение небольшое: только если пользователь Claude Code решит добавить другие инструменты + +### Что стоит позаимствовать + +1. **Memory System** — персистентная память агентов между сессиями. Наши агенты теряют контекст при рестарте. CozoDB — overengineered для нас, но концепция ценная. +2. **Code Graph** — визуализация кодобазы. Можно реализовать через tree-sitter или ts-morph + простое хранение. +3. **Multi-machine** — даже не P2P mesh, но хотя бы возможность подключаться к remote Claude Code сессиям. +4. **External integrations** — Slack/Discord уведомления о прогрессе задач. + +--- + +## Источники + +- [GitHub: 23blocks-OS/ai-maestro](https://github.com/23blocks-OS/ai-maestro) +- [AI Maestro Website](https://ai-maestro.23blocks.com/) +- [AMP Protocol: agentmessaging/protocol](https://github.com/agentmessaging/protocol) +- [Agent Messaging Protocol Website](https://agentmessaging.org/) +- [CozoDB](https://github.com/cozodb/cozo) +- [Medium: "Your AI Agent Has Amnesia"](https://medium.com/23blocks/your-ai-agent-has-amnesia-heres-how-we-fixed-it-49980712f2e4) (paywall) +- [Medium: "From 47 Terminal Windows to One Dashboard"](https://medium.com/23blocks/building-ai-maestro-from-47-terminal-windows-to-one-beautiful-dashboard-64cd25ff3b43) (paywall) +- [awesome-agent-orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [Maestro vs Superpowers vs ECC comparison gist](https://gist.github.com/jeffscottward/de77a769d9e25a8ccdc92b65291b1c34) diff --git a/docs/research/competitor-spawn-patterns.md b/docs/research/competitor-spawn-patterns.md new file mode 100644 index 00000000..73471f08 --- /dev/null +++ b/docs/research/competitor-spawn-patterns.md @@ -0,0 +1,523 @@ +# Competitor Agent Spawn Patterns Research + +**Date**: 2026-03-25 + +## Executive Summary + +Все 4 конкурента построили собственные adapter-слои для spawning CLI-агентов. Ни один не использует готовую библиотеку. Паттерн единый: **интерфейс/trait + per-agent реализация + config-driven overrides**. + +Самый зрелый и переиспользуемый паттерн у **vibe-kanban** (Rust trait `StandardCodingAgentExecutor` + `enum_dispatch` + ACP harness). У **Emdash** паттерн проще (per-service TypeScript классы + auto-discovery). **Dorothy** самый примитивный (node-pty напрямую). **Superset** закрыт ELv2 лицензией. + +--- + +## 1. Vibe Kanban (BloopAI) + +**Repo**: [github.com/BloopAI/vibe-kanban](https://github.com/BloopAI/vibe-kanban) +**Язык**: Rust (backend) + TypeScript (frontend) +**Лицензия**: Apache-2.0 +**Stars**: ~23K +**Поддерживаемые агенты**: Claude Code, Codex, Gemini CLI, Copilot, Amp, Cursor, OpenCode, Droid, QwenCode, Qoder + +### Архитектура + +Самый архитектурно зрелый подход среди конкурентов. + +**Ядро** — Rust trait + enum_dispatch: + +```rust +#[async_trait] +#[enum_dispatch(CodingAgent)] +pub trait StandardCodingAgentExecutor { + async fn spawn(&self, current_dir: &Path, prompt: &str, + env: &ExecutionEnv) -> Result; + + async fn spawn_follow_up(&self, current_dir: &Path, prompt: &str, + session_id: &str, reset_to_message_id: Option<&str>, + env: &ExecutionEnv) -> Result; + + async fn spawn_review(&self, current_dir: &Path, prompt: &str, + session_id: Option<&str>, env: &ExecutionEnv) + -> Result; +} +``` + +**Dispatch через enum** (compile-time, zero-cost): + +```rust +#[enum_dispatch] +#[derive(Clone, Serialize, Deserialize, PartialEq, TS, Display)] +pub enum CodingAgent { + ClaudeCode, Amp, Gemini, Codex, Opencode, + CursorAgent, QwenCode, Copilot, Droid, QaMock +} +``` + +### Структура файлов + +``` +crates/executors/src/ + executors/ + mod.rs — trait + enum_dispatch + CodingAgent enum + claude.rs — ClaudeCode executor + gemini.rs — Gemini executor (через ACP harness) + codex.rs — Codex executor + amp.rs — Amp executor + copilot.rs — GitHub Copilot + cursor.rs — Cursor Agent + opencode.rs — OpenCode + droid.rs — Droid (factory.ai) + qwen.rs — QwenCode + qa_mock.rs — Mock для тестов + utils.rs — Общие утилиты + acp/ — ACP (Agent Communication Protocol) harness + mod.rs + client.rs — ACP client + harness.rs — Spawn + session lifecycle + session.rs — Session management + normalize_logs.rs — Log normalization + claude/ — Claude-specific subdirectory + codex/ — Codex-specific subdirectory + cursor/ — Cursor-specific subdirectory + droid/ — Droid-specific subdirectory + opencode/ — OpenCode-specific subdirectory + command.rs — CommandBuilder (base cmd + params + overrides) + env.rs — ExecutionEnv (env vars, repo context) + executor_discovery.rs — Auto-discovery + caching + mcp_config.rs — MCP server config per agent + model_selector.rs — Model selection logic + profile.rs — Agent profiles (DEFAULT, APPROVALS variants) + approvals.rs — Permission/approval system + stdout_dup.rs — Stdout duplication utilities + lib.rs — Crate root +``` + +### Как добавить нового агента (на примере Qoder PR #1759) + +1. `crates/executors/src/executors/qoder.rs` — реализация trait +2. `mod.rs` — добавить в `CodingAgent` enum +3. `mcp_config.rs` — MCP Passthrough adapter +4. `default_profiles.json` — профили (DEFAULT/APPROVALS) +5. `generate_types.rs` — ts-rs type generation +6. `shared/types.ts` — TypeScript enum (автогенерация) +7. `shared/schemas/qoder.json` — JSON schema +8. `docs/agents/qoder.mdx` — документация + +### Пример: Gemini executor + +``` +Base command: "npx -y @google/gemini-cli@0.29.3" +Flags: --experimental-acp, --model , --yolo (if auto mode) +Harness: AcpAgentHarness — manages spawn, follow-up, session lifecycle +Output: ACP protocol (structured agent communication) +``` + +### Ключевые паттерны + +- **CommandBuilder** (builder pattern): base cmd → params → overrides → platform-specific split → CommandParts +- **ExecutionEnv**: HashMap env vars + repo context, inject into tokio::Command +- **CmdOverrides**: replace base cmd / append params / set env vars (per-profile) +- **ACP Harness**: shared session/spawn logic for ACP-compatible agents (Gemini, Qoder, etc.) +- **executor_discovery**: async discovery + caching по (path, command_key, agent_type) +- **ts-rs**: Rust types → TypeScript types автогенерация + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | ~2000-3000 (весь crate executors) | +| Паттерн | trait + enum_dispatch + CommandBuilder | +| Сложность добавления агента | ~150-200 LOC per agent | +| Можно переиспользовать? | Нет (Rust, другой стек) | +| Можно скопировать паттерн? | ДА — отличный reference | +| Надёжность подхода | 9/10 | + +--- + +## 2. Emdash (General Action) + +**Repo**: [github.com/generalaction/emdash](https://github.com/generalaction/emdash) +**Язык**: TypeScript (Electron) +**Лицензия**: MIT +**Stars**: ~6K +**YC W26** +**Поддерживаемые агенты**: 22+ (Claude Code, Codex, Gemini, Amp, Cursor, Copilot, Goose, Droid, Kiro, Qwen, OpenCode, Cline, Continue, Codebuff, Charm, Kilocode, Kimi, Autohand, Auggie, Rovo Dev, Mistral Vibe, Pi) + +### Архитектура + +Per-service TypeScript классы + auto-discovery. Самый близкий к нашему стеку. + +**Ключевые файлы**: + +``` +src/main/services/ + CodexService.ts — Manages Codex CLI child processes + log streaming + CodexSessionService.ts — Session management for Codex + ClaudeConfigService.ts — Claude-specific configuration + ClaudeHookService.ts — Claude hooks integration + AgentEventService.ts — Agent lifecycle events + TaskLifecycleService.ts — Task state machine + WorkspaceProviderService.ts — Provider workspace management + TerminalConfigParser.ts — CLI terminal config detection + ptyManager.ts — PTY session management (node-pty) + ptyIpc.ts — PTY IPC communication + ConnectionsService.ts — Connection management + RepositoryManager.ts — Git repository management + ProjectSettingsService.ts + DatabaseService.ts — SQLite (drizzle) + AutoUpdateService.ts + __tests__/ — Tests + fs/ — File system services + git-core/ — Git operations + mcp/ — MCP protocol + skills/ — Skills system + ssh/ — SSH remote development +``` + +### Как добавить провайдера (из документации) + +1. Include: provider name, CLI invocation command, auth notes, setup steps +2. Team wires up provider selection in UI and adds to Integrations matrix +3. Providers auto-detected when CLI is in PATH + +### Spawn-паттерн + +- `node:child_process.spawn()` для CLI агентов +- `node-pty` для terminal sessions +- Per-service классы (CodexService, ClaudeConfigService) +- `TerminalConfigParser` для auto-detection CLI в PATH +- `AgentEventService` для lifecycle events (running/waiting/completed/error) +- SQLite (drizzle ORM) для персистенции + +### Ключевые особенности + +- **Auto-discovery**: провайдеры детектятся автоматически по наличию CLI в PATH +- **Native deps**: sqlite3, node-pty, keytar (rebuilt per Electron version) +- **Worktree isolation**: каждый агент в своём git worktree +- **SSH remote**: агенты могут работать на удалённых машинах через SSH/SFTP +- **Best-of-N**: запуск нескольких агентов на одну задачу, выбор лучшего + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | ~1500-2000 (services + pty) | +| Паттерн | Per-service classes + auto-discovery | +| Сложность добавления агента | Средняя (новый service file) | +| Можно переиспользовать код? | Потенциально ДА (MIT, TypeScript, Electron) | +| Можно скопировать паттерн? | ДА — очень близко к нашему стеку | +| Надёжность подхода | 7/10 (less structured than vibe-kanban) | + +--- + +## 3. Dorothy (Charlie85270) + +**Repo**: [github.com/Charlie85270/Dorothy](https://github.com/Charlie85270/Dorothy) +**Язык**: TypeScript (Electron + Next.js) +**Лицензия**: MIT +**Stars**: ~3K +**Поддерживаемые агенты**: Claude Code (primarily), расширяется + +### Архитектура + +Самый простой подход — node-pty напрямую, без абстракции adapter layer. + +``` +electron/ + agent-manager.ts — Agent lifecycle & parallel execution (node-pty) + pty-manager.ts — Terminal session multiplexing + window-manager.ts — Window management + services/ + telegram-bot + slack-bot + kanban-automation + mcp-server-launcher + api-server +mcp-orchestrator/ — Super Agent MCP server +mcp-kanban/ — Kanban automation MCP +``` + +### Spawn-паттерн + +- `node-pty` — каждый агент в изолированной PTY-сессии +- Статус определяется парсингом stdout patterns (running/waiting/completed/error) +- N параллельных агентов с отдельными проектами +- Super Agent (мета-агент) контролирует другие через MCP tools +- Cron-based scheduling для повторяющихся задач +- Skills system для extensibility + +### Ключевые особенности + +- **Нет абстракции агентов**: привязан к Claude Code, нет interface для разных CLI +- **Kanban**: задачи → колонки → automatic agent assignment по skills +- **Automations**: GitHub PR/issue polling → agent spawning +- **Remote control**: Telegram/Slack bot для управления + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | ~500-800 (agent-manager + pty-manager) | +| Паттерн | Direct node-pty, no abstraction | +| Сложность добавления агента | Высокая (нет interface) | +| Можно переиспользовать код? | Да (MIT), но мало что полезного | +| Можно скопировать паттерн? | НЕТ — слишком примитивный | +| Надёжность подхода | 5/10 | + +--- + +## 4. Superset (superset-sh) + +**Repo**: [github.com/superset-sh/superset](https://github.com/superset-sh/superset) +**Язык**: TypeScript (Electron, monorepo Turborepo + Bun) +**Лицензия**: Elastic License 2.0 (ELv2) — НЕ open-source! +**Stars**: ~7.8K +**Поддерживаемые агенты**: Claude Code, Codex, Aider, Copilot, Cursor Agent, Gemini CLI, OpenCode + custom + +### Архитектура + +Monorepo с 6 apps. Multi-process Electron с 5 entry points: + +``` +apps/desktop/src/ + main/ + index.ts — Main app entry + terminal-host/ + index.ts — Persistent daemon for terminal sessions + pty-subprocess.ts — PTY handler (node-pty) + git-task-worker.ts — Worker thread for Git ops + host-service/ + index.ts — Local HTTP server + +packages/ + @superset/trpc — tRPC routers + @superset/ui — Shared React components + @superset/local-db — SQLite (Drizzle) + @superset/db — PostgreSQL (Neon, cloud sync) +``` + +### Spawn-паттерн + +- **Terminal Host daemon**: persistent subprocess managing terminal sessions +- **PTY subprocess**: node-pty forked on-demand +- **Git Worker**: heavy git ops offloaded to worker_threads +- **tRPC over Electron IPC**: renderer ↔ main communication +- **Worktree isolation**: каждая задача в своём git worktree с уникальным branch +- **Port allocation**: SUPERSET_PORT_BASE + 20 портов на workspace + +### Ключевые особенности + +- **Multi-process**: 5 entry points, daemon-based terminal management +- **Dual DB**: local SQLite + cloud PostgreSQL (ElectricSQL sync) +- **Better Auth**: OAuth deep links для десктопа +- **.superset/config.json**: workspace setup/teardown scripts + +### Оценка + +| Метрика | Значение | +|---------|----------| +| LOC adapter layer | Неизвестно (code not browsable) | +| Паттерн | Multi-process + terminal-host daemon | +| Сложность добавления агента | Неизвестно | +| Можно переиспользовать код? | НЕТ (Elastic License 2.0) | +| Можно скопировать паттерн? | Частично (terminal-host daemon idea) | +| Надёжность подхода | 8/10 (production, enterprise users) | + +--- + +## CLI Agent Programmatic Spawn Reference + +### Claude Code + +```bash +claude --input-format stream-json --output-format stream-json --verbose +``` + +- **Bidirectional NDJSON protocol** over stdin/stdout +- Message types: `initialize`, `user`, `control_response` +- `--verbose` REQUIRED with stream-json +- `--print` mode for one-shot (no multi-turn) +- Session hooks do NOT run in `--print` mode +- Official docs: [incomplete](https://github.com/anthropics/claude-code/issues/24594) — community reverse-engineered +- VS Code extension spawns: `claude --output-format stream-json --verbose --input-format stream-json --max-thinking-tokens 0 --model default --permission-prompt-tool stdio` + +### Codex (OpenAI) + +```bash +codex exec --json "prompt" +``` + +- **JSONL output** (one event per line) +- Event types: `thread.started`, `turn.started`, `turn.completed`, `turn.failed`, `item.*` +- Item types: agent_message, reasoning, command_exec, file_change, mcp_tool_call +- `--output-schema` for structured final output +- `codex exec resume --json` for session resumption +- **App-server mode**: `codex app-server` — stateful JSON-RPC over stdio +- Auth: `CODEX_API_KEY` env var for non-interactive +- Schema BREAKING CHANGES between versions (item_type → type, assistant_message → agent_message) + +### Gemini CLI (Google) + +```bash +gemini -p "prompt" --output-format json +# or streaming: +gemini -p "prompt" --output-format stream-json +``` + +- `-p` flag for non-interactive headless mode +- `--output-format json` — full response + stats +- `--output-format stream-json` — real-time JSONL events +- `--yolo` — auto-approve all tool calls +- `--experimental-acp` — Agent Communication Protocol (used by vibe-kanban) +- **Known issues**: response field may contain markdown-wrapped JSON instead of clean JSON +- Stdin piping supported for additional context + +### Amp (Sourcegraph) + +```bash +amp --execute "prompt" --stream-json +``` + +- `--stream-json` — JSONL output (REQUIRES `--execute`) +- `--stream-json-input` — JSONL input via stdin (REQUIRES `--stream-json`) +- `--stream-json-thinking` — includes thinking blocks (extends schema) +- **Claude Code compatible format** (mostly) +- Multi-turn: `amp threads continue [thread-id]` + `--stream-json-input` +- Auth: `AMP_API_KEY` env var for CI/CD +- Elixir SDK exists as reference: spawns CLI + parses stream-json + +### Goose (Block) + +```bash +goose run -t "prompt" +# or from file: +goose run -i instructions.md +``` + +- `goose run` — non-interactive one-shot mode +- `--output-format json` — [feature request #4419, marked Done](https://github.com/block/goose/issues/4419) +- `--format json` — for session/recipe listing +- Max 10 concurrent subagents (hard-coded) +- 5 min default timeout, 25 max turns +- `GOOSE_SUBAGENT_MAX_TURNS` env override + +--- + +## Сравнительная таблица + +| | Vibe Kanban | Emdash | Dorothy | Superset | +|---|---|---|---|---| +| **Язык** | Rust | TypeScript | TypeScript | TypeScript | +| **Лицензия** | Apache-2.0 | MIT | MIT | ELv2 | +| **Используют готовую библиотеку?** | Нет | Нет | Нет | Нет | +| **Паттерн** | trait + enum_dispatch | Per-service classes | Direct node-pty | Multi-process daemon | +| **Абстракция агентов** | `StandardCodingAgentExecutor` trait | Per-service (CodexService, etc.) | Нет | Terminal-host daemon | +| **Количество агентов** | 10+ | 22+ | 1 (Claude) | 8+ | +| **Сложность добавления** | ~150-200 LOC | ~300-500 LOC | Hard (no interface) | Unknown | +| **LOC adapter layer** | ~2000-3000 | ~1500-2000 | ~500-800 | Unknown | +| **Auto-discovery** | Да (executor_discovery) | Да (PATH detection) | Нет | Unknown | +| **MCP support** | Passthrough per agent | Да | MCP servers | Да | +| **ACP protocol** | Да (shared harness) | Нет | Нет | Нет | +| **Type generation** | ts-rs (Rust → TS) | N/A | N/A | N/A | +| **Isolation** | Git worktrees | Git worktrees | Separate projects | Git worktrees | +| **Можно reuse код?** | Нет (Rust) | Да (MIT, TS) | Да (MIT) | Нет (ELv2) | + +--- + +## Выводы и рекомендации для Claude Agent Teams UI + +### 1. Какой паттерн взять за основу? + +**Рекомендация: гибрид vibe-kanban + emdash подходов** + +От vibe-kanban взять: +- **Interface (trait) + per-agent implementation** — TypeScript interface вместо Rust trait +- **CommandBuilder pattern** — построение команды через builder с overrides +- **ExecutionEnv** — управление env vars + repo context +- **Profile system** — DEFAULT/APPROVALS варианты per agent +- **enum-dispatch idea** — в TS реализуется через discriminated union + factory + +От Emdash взять: +- **Auto-discovery** — детекция CLI в PATH +- **Per-service approach** — но с общим interface +- **node-pty integration** — для terminal sessions + +### 2. Предлагаемый TypeScript interface + +```typescript +interface AgentExecutor { + readonly agentType: AgentType; // discriminated union tag + + spawn(params: SpawnParams): Promise; + spawnFollowUp(params: FollowUpParams): Promise; + spawnReview?(params: ReviewParams): Promise; + + discover(): Promise; + isAvailable(): Promise; + + normalizeOutput(raw: string): string; + parseEvent(line: string): AgentEvent | null; +} + +interface SpawnParams { + workDir: string; + prompt: string; + env: ExecutionEnv; + model?: string; + approvalMode: 'auto' | 'supervised'; + mcpConfig?: string; +} + +interface SpawnedAgent { + process: ChildProcess; + sessionId: string; + stdout: ReadableStream; + stderr: ReadableStream; + kill(): Promise; + sendMessage(msg: string): Promise; +} +``` + +### 3. Что НЕ стоит делать + +- **Не использовать node-pty напрямую** (как Dorothy) — нет абстракции, сложно масштабировать +- **Не строить на Rust** (как vibe-kanban) — у нас TypeScript стек, overhead не оправдан +- **Не копировать multi-process daemon** (как Superset) — over-engineering для нашего случая +- **Не привязываться к одному протоколу** — у каждого CLI свой формат (stream-json, --json, --stream-json) + +### 4. Приоритет агентов для поддержки + +| Приоритет | Агент | Протокол | Сложность | +|-----------|-------|----------|-----------| +| P0 | Claude Code | stream-json bidirectional | Уже есть | +| P1 | Codex | `exec --json` JSONL | Средняя | +| P1 | Gemini CLI | `--output-format stream-json` | Средняя | +| P2 | Amp | `--execute --stream-json` | Средняя (CC-compatible) | +| P2 | Goose | `run -t` + `--output-format json` | Средняя | +| P3 | OpenCode | TBD | Исследовать | +| P3 | Cursor Agent | TBD | Исследовать | + +--- + +## Источники + +- [Vibe Kanban (BloopAI)](https://github.com/BloopAI/vibe-kanban) — Apache-2.0, 23K stars +- [Vibe Kanban AGENTS.md](https://github.com/BloopAI/vibe-kanban/blob/main/AGENTS.md) +- [Vibe Kanban executors crate](https://github.com/BloopAI/vibe-kanban/tree/main/crates/executors/src/executors) +- [Vibe Kanban PR #1759 — Qoder executor pattern](https://github.com/BloopAI/vibe-kanban/pull/1759) +- [Emdash (General Action)](https://github.com/generalaction/emdash) — MIT, 6K stars +- [Emdash Providers Documentation](https://docs.emdash.sh/providers) +- [Emdash AGENTS.md](https://github.com/generalaction/emdash/blob/main/AGENTS.md) +- [Dorothy (Charlie85270)](https://github.com/Charlie85270/Dorothy) — MIT, 3K stars +- [Superset (superset-sh)](https://github.com/superset-sh/superset) — ELv2, 7.8K stars +- [Superset DeepWiki Architecture](https://deepwiki.com/superset-sh/superset/1.1-architecture-overview) +- [Codex CLI Reference](https://developers.openai.com/codex/cli/reference) +- [Codex Non-Interactive Mode](https://developers.openai.com/codex/noninteractive) +- [Codex JSON output issues](https://github.com/openai/codex/issues/2288) +- [Gemini CLI Headless Mode](https://google-gemini.github.io/gemini-cli/docs/cli/headless.html) +- [Gemini CLI JSON issues](https://github.com/google-gemini/gemini-cli/issues/9009) +- [Amp Streaming JSON](https://ampcode.com/news/streaming-json) +- [Amp CLI Manual](https://ampcode.com/manual) +- [Goose CLI Commands](https://block.github.io/goose/docs/guides/goose-cli-commands/) +- [Goose JSON output request #4419](https://github.com/block/goose/issues/4419) +- [Claude Code stream-json docs gap #24594](https://github.com/anthropics/claude-code/issues/24594) +- [Claude Code Automation Skill (LobeHub)](https://lobehub.com/it/skills/coreyja-dotfiles-claude-code-automation) diff --git a/docs/research/inter-agent-communication-standards.md b/docs/research/inter-agent-communication-standards.md new file mode 100644 index 00000000..f47ae5f7 --- /dev/null +++ b/docs/research/inter-agent-communication-standards.md @@ -0,0 +1,639 @@ +# Inter-Agent Communication Standards: How Different AI Agents Can Talk to Each Other + +**Date:** 2026-03-25 +**Status:** Research complete +**Goal:** Determine the best way for AI agents (Claude, Codex, Gemini) to communicate with each other + +--- + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Protocol Landscape Overview](#protocol-landscape-overview) +3. [A2A (Agent-to-Agent Protocol)](#1-a2a--agent-to-agent-protocol) +4. [ACP (Agent Communication Protocol) by IBM/BeeAI](#2-acp--agent-communication-protocol-by-ibmbeeai) +5. [Agent Client Protocol (ACP) by Zed](#3-agent-client-protocol-acp-by-zed) +6. [MCP for Inter-Agent Communication](#4-mcp-for-inter-agent-communication) +7. [Agent Network Protocol (ANP)](#5-agent-network-protocol-anp) +8. [MCP Agent Mail](#6-mcp-agent-mail) +9. [File-Based Inbox Pattern](#7-file-based-inbox-pattern-claude-code-agent-teams) +10. [SQLite/Redis Message Bus](#8-sqliteredis-message-bus) +11. [Cross-Provider Orchestration Tools](#9-cross-provider-orchestration-tools) +12. [Comparison Matrix](#comparison-matrix) +13. [Recommendations for Electron App](#recommendations-for-our-electron-app) +14. [Sources](#sources) + +--- + +## Executive Summary + +На март 2026 года НЕ существует единого универсального стандарта для межагентной коммуникации между разными провайдерами (Claude, Codex, Gemini). Однако экосистема быстро консолидируется вокруг нескольких протоколов: + +| Уровень | Протокол | Назначение | +|---------|----------|------------| +| Tool access | **MCP** (Anthropic) | Агент <-> инструменты/данные | +| Agent-to-Agent | **A2A** (Google/Linux Foundation) | Агент <-> агент (сетевой) | +| Editor-to-Agent | **ACP** (Zed) | Редактор <-> CLI-агент | +| Local coordination | **File-based inbox** (Claude Code) | Агент <-> агент (локальный) | +| Local coordination | **MCP Agent Mail** | Агент <-> агент (MCP + SQLite + Git) | + +**Ключевые выводы:** + +1. **A2A** -- самый зрелый протокол для agent-to-agent, но он HTTP/server-based и плохо подходит для чисто локального Electron-приложения без встроенного сервера. +2. **File-based inbox** (как в Claude Code Agent Teams) -- самый простой и проверенный паттерн для локальной коммуникации. Работает в Electron без проблем. +3. **MCP Agent Mail** -- наиболее feature-rich локальное решение (идентичности, mailboxes, file leases, searchable threads), но Python-based. +4. **MCP** сам по себе эволюционирует в сторону inter-agent communication (AWS, Microsoft активно контрибьютят). +5. **OpenCode** -- единственный инструмент, который реально запускает Claude + Codex + Gemini в одной команде через unified inbox pattern. + +--- + +## Protocol Landscape Overview + +``` + ┌─────────────────────────────────────┐ + │ Agent Network Protocol │ + │ (ANP - open internet, P2P, DID) │ + └──────────────────┬──────────────────┘ + │ + ┌──────────────────┴──────────────────┐ + │ Agent-to-Agent Protocol (A2A) │ + │ (Google/LF, HTTP, JSON-RPC, tasks) │ + └──────────────────┬──────────────────┘ + │ + ┌────────────────────────────┼────────────────────────────┐ + │ │ │ +┌─────────┴─────────┐ ┌─────────────┴─────────────┐ ┌─────────┴─────────┐ +│ MCP (Anthropic) │ │ Agent Client Protocol │ │ File-based inbox │ +│ Agent <-> Tools │ │ (Zed, editor <-> agent) │ │ (Claude Code local)│ +└───────────────────┘ └───────────────────────────┘ └───────────────────┘ +``` + +--- + +## 1. A2A -- Agent-to-Agent Protocol + +**Создатель:** Google, теперь под Linux Foundation +**Статус:** v0.3.0 (Draft v1.0), 150+ организаций-участников +**GitHub:** [a2aproject/A2A](https://github.com/a2aproject/A2A) -- 500+ stars (JS SDK) + +### Как работает + +1. Каждый агент публикует **Agent Card** (JSON) по адресу `/.well-known/agent.json` -- имя, навыки, endpoint, auth +2. Клиент-агент отправляет **задачу** серверу-агенту через JSON-RPC 2.0 over HTTPS +3. Задача проходит жизненный цикл: `submitted` -> `working` -> `completed`/`canceled` +4. Поддерживается streaming через SSE (Server-Sent Events) +5. Результат задачи -- **артефакт** (текст, изображения, файлы) + +### TypeScript SDK + +```bash +npm install @a2a-js/sdk +# Для Express-интеграции: +npm install express +``` + +Пакет: [`@a2a-js/sdk`](https://www.npmjs.com/package/@a2a-js/sdk) v0.3.10 +- 88 зависимых проектов на npm +- Поддержка Express, gRPC, in-memory task store +- Полные типы TypeScript + +**Минимальный сервер (Express):** +```typescript +import { AgentCard, AgentExecutor, DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk'; +import { agentCardHandler, jsonRpcHandler } from '@a2a-js/sdk/server/express'; +import express from 'express'; + +const card: AgentCard = { + name: 'MyAgent', + description: 'Example agent', + protocolVersion: '0.3.0', + url: 'http://localhost:4000/a2a/jsonrpc', + skills: [{ id: 'hello', name: 'Hello', description: 'Says hello' }], + capabilities: {}, + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], +}; + +class MyExecutor implements AgentExecutor { + async execute(context) { + context.eventBus.publish({ type: 'message', message: { role: 'agent', parts: [{ type: 'text', text: 'Hello!' }] } }); + context.eventBus.finished(); + } +} + +const handler = new DefaultRequestHandler(card, new InMemoryTaskStore(), new MyExecutor()); +const app = express(); +app.get('/a2a/agent-card', agentCardHandler(handler)); +app.post('/a2a/jsonrpc', jsonRpcHandler(handler)); +app.listen(4000); +``` + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость протокола | 8/10 -- v0.3, Linux Foundation, 150+ организаций | +| TypeScript поддержка | 9/10 -- официальный SDK, полные типы | +| Electron-совместимость | 5/10 -- требует HTTP-сервер, придётся встраивать Express в main process | +| Локальная работа | 4/10 -- спроектирован для сетевого взаимодействия, localhost возможен но overhead | +| Кросс-провайдер | 9/10 -- протокол-агностик по дизайну | + +### Вердикт + +A2A -- правильный протокол для **распределённых сетевых** мультиагентных систем. Для локального Electron-приложения это overkill, но если планируется поддержка **удалённых агентов** в будущем -- имеет смысл держать в архитектуре. + +--- + +## 2. ACP -- Agent Communication Protocol by IBM/BeeAI + +**Создатель:** IBM Research / BeeAI +**Статус:** MERGED WITH A2A под Linux Foundation. Активная разработка свёрнута. +**GitHub:** [i-am-bee/acp](https://github.com/i-am-bee/acp) + +### Как работает + +- REST-native (не JSON-RPC как A2A) -- стандартные HTTP-конвенции +- Не требует SDK -- можно использовать через curl/Postman +- Async по умолчанию (fire-and-forget с taskId, poll/subscribe для прогресса) +- Sync также поддерживается (простой HTTP POST) +- Offline discovery -- метаданные агента встроены в пакет распространения + +### TypeScript SDK + +```bash +npm install @anthropic-ai/beeai-framework # TypeScript starter +``` + +BeeAI Framework предоставляет TypeScript-клиент для ACP. + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 3/10 -- merged into A2A, активная разработка прекращена | +| TypeScript | 6/10 -- клиентский SDK есть | +| Electron | 5/10 -- REST-based, аналогично A2A | +| Рекомендация | НЕ использовать, мигрировать на A2A | + +### Вердикт + +**Устаревший.** Объединён с A2A. Использовать только если уже есть код на ACP -- в таком случае мигрировать на A2A. + +--- + +## 3. Agent Client Protocol (ACP) by Zed + +**ВНИМАНИЕ:** Это ДРУГОЙ протокол с тем же акронимом ACP. Не путать с IBM ACP. + +**Создатель:** Zed Industries +**Статус:** Активный, ACP Registry запущен (2026) +**GitHub:** [agentclientprotocol/agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) +**Сайт:** [agentclientprotocol.com](https://agentclientprotocol.com/) + +### Как работает + +- Стандартизирует связь **редактор <-> CLI-агент** (аналогично LSP для языковых серверов) +- JSON-RPC over stdio (локальные агенты как subprocess) +- JSON-RPC over HTTP/WebSocket (удалённые агенты) +- Переиспользует JSON-представления из MCP где возможно +- Добавляет UX-специфичные типы (diff display, file edits) + +### Поддерживаемые агенты и редакторы + +**Агенты:** +- Claude Code (через Zed SDK adapter) +- Codex CLI +- Gemini CLI (reference implementation) +- OpenCode +- Goose (Block/Square) +- GitHub Copilot CLI +- Kiro CLI + +**Редакторы:** +- Zed (нативная поддержка) +- JetBrains IDEs (скоро) +- Neovim (CodeCompanion, avante.nvim) +- Emacs (agent-shell) +- VS Code (расширение ACP Client) + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 7/10 -- активный, registry, множество интеграций | +| TypeScript | 7/10 -- JSON-RPC, спецификация есть | +| Electron | 8/10 -- stdio-based отлично работает с child_process | +| Назначение | Editor <-> Agent, НЕ agent <-> agent | + +### Вердикт + +ACP (Zed) -- **идеален для связи нашего Electron UI с CLI-агентами**. Но это протокол editor<->agent, не agent<->agent. Для межагентной коммуникации нужен другой протокол поверх. + +--- + +## 4. MCP для Inter-Agent Communication + +**Создатель:** Anthropic +**Статус:** Активно развивается в сторону agent-to-agent (2026 roadmap) + +### Как это работает для inter-agent + +MCP изначально создан для tool integration, но его архитектура позволяет agent-to-agent: + +1. **Агент A запускает MCP-сервер**, объявляя свои capabilities как tools +2. **Агент B подключается как MCP-клиент** и вызывает tools агента A +3. Streaming через SSE для real-time обновлений +4. Session resumability для долгих задач +5. Multi-turn interactions через elicitation + +### Паттерн "Agent as MCP Server" + +``` +Agent A (MCP Client) ──────► Agent B (MCP Server) + │ │ + │ call tool "analyze" │ + ├─────────────────────────►│ + │ │ runs analysis + │ streaming results │ + │◄─────────────────────────┤ +``` + +### Кто продвигает + +- **AWS** активно контрибьютит в inter-agent MCP, работает с LangGraph, CrewAI, LlamaIndex +- **Microsoft** показала, что A2A-коммуникацию можно построить на MCP +- **Block (Square)** -- 1000+ инженеров используют MCP-координацию (Goose) + +### TypeScript SDK + +```bash +npm install @modelcontextprotocol/sdk zod +``` + +Официальный SDK: [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость для inter-agent | 5/10 -- изначально не для этого, но быстро эволюционирует | +| TypeScript | 10/10 -- официальный SDK, отличная поддержка | +| Electron | 9/10 -- stdio transport, уже используется в нашем приложении | +| Кросс-провайдер | 8/10 -- все провайдеры поддерживают MCP | + +### Вердикт + +MCP -- **наиболее практичный выбор** для нашего Electron-приложения. Мы уже используем MCP. Паттерн "agent as MCP server" позволяет любому агенту объявить tools/resources, а другой агент подключается как клиент. Roadmap 2026 явно включает agent-to-agent capabilities. + +--- + +## 5. Agent Network Protocol (ANP) + +**Создатель:** Open-source community +**Статус:** Draft, white paper на arXiv +**GitHub:** [agent-network-protocol/AgentNetworkProtocol](https://github.com/agent-network-protocol/AgentNetworkProtocol) +**Сайт:** [agent-network-protocol.com](https://agent-network-protocol.com/) + +### Как работает + +Трёхуровневая архитектура: +1. **Identity & Encrypted Communication** -- W3C DID (Decentralized Identifiers), end-to-end encryption +2. **Meta-Protocol Layer** -- агенты САМИ договариваются о протоколе коммуникации +3. **Application Protocol** -- JSON-LD для описания capabilities + +Позиционирование: "HTTP для агентного интернета". Peer-to-peer, без центральных серверов. + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 2/10 -- draft, ранняя стадия | +| TypeScript | 2/10 -- нет SDK | +| Electron | 3/10 -- P2P, сложная интеграция | +| Рекомендация | Следить, НЕ использовать сейчас | + +### Вердикт + +ANP -- интересная vision для **открытого агентного интернета** (peer-to-peer discovery, DID), но слишком рано для продакшна. Может стать актуален через 1-2 года. + +--- + +## 6. MCP Agent Mail + +**Создатель:** Jeff Emanuel (Dicklesworthstone) +**Статус:** Активный, первый open-source agent coordination tool (октябрь 2025) +**GitHub:** [Dicklesworthstone/mcp_agent_mail](https://github.com/Dicklesworthstone/mcp_agent_mail) +**Rust version:** [Dicklesworthstone/mcp_agent_mail_rust](https://github.com/Dicklesworthstone/mcp_agent_mail_rust) +**Сайт:** [mcpagentmail.com](https://mcpagentmail.com/) + +### Как работает + +- MCP-сервер, предоставляющий **34 tool** для координации агентов +- Каждый агент получает **идентичность** (memorable name: GreenCastle, BlueLake) +- **Inbox/Outbox** -- асинхронные mailbox для сообщений +- **Advisory File Reservations** -- агенты объявляют file leases (exclusive/shared) на globs +- **Searchable threads** -- FTS через SQLite +- **Git backing** -- все сообщения и артефакты в Git для аудита +- SQLite для индексации, Git как source of truth + +### Cross-Provider Support + +Работает с Claude Code, Codex, Gemini CLI, Factory Droid -- любой MCP-совместимый клиент. + +### Технические детали + +- Python-based сервер (FastMCP) +- Rust-реимплементация доступна (127.0.0.1:8765, TUI console) +- Не npm-пакет, установка через bash-скрипт +- Local-first, no cloud dependencies + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 7/10 -- production-used, хорошо документирован | +| TypeScript | 3/10 -- Python/Rust server, TS клиент через MCP SDK | +| Electron | 6/10 -- можно запустить как sidecar process, но Python/Rust зависимость | +| Feature-richness | 9/10 -- identities, mailboxes, file leases, FTS, Git audit | + +### Вердикт + +Самое feature-rich решение для координации агентов. **Проблема**: Python/Rust dependency. Для нашего Electron-приложения можно: +- Запустить как sidecar process +- Или реализовать ключевые идеи (mailbox, file leases) на TypeScript нативно + +--- + +## 7. File-Based Inbox Pattern (Claude Code Agent Teams) + +**Создатель:** Anthropic (Claude Code) +**Статус:** Production, Claude Code v2.1.32+ + +### Как работает + +Наиболее простой и проверенный паттерн: + +``` +~/.claude/teams/{team-name}/ +├── config.json # member registry +└── inboxes/ + ├── lead.json # lead's inbox + ├── frontend-dev.json # teammate inbox + └── backend-dev.json # teammate inbox +``` + +1. Отправитель **appends** JSON-объект в inbox-файл получателя +2. Получатель **polls** свой inbox-файл между turns +3. Формат сообщения: `{ from, text, timestamp, read }` +4. Broadcast = запись одного сообщения во ВСЕ inbox-файлы + +### Особенности + +- Zero dependencies -- только fs +- Inspectable -- `cat` любой inbox файл в реальном времени +- File I/O масштабируется для 3-5 агентов +- Нет real-time delivery -- получатель увидит сообщение только после текущего turn +- Ownership: каждый агент читает только СВОЙ inbox + +### Inbox/Outbox Pattern (улучшенный) + +``` +agent-a/ +├── inbox.json # входящие сообщения +├── outbox.json # исходящие (для аудита) +└── current-task.json + +agent-b/ +├── inbox.json +├── outbox.json +└── current-task.json +``` + +Правила координации: +- Агент пишет ТОЛЬКО в свой outbox и чужие inbox +- Агент читает ТОЛЬКО свой inbox и current-task +- Boot Sequence: при старте читать inbox.json, resume из current-task.json + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| Зрелость | 9/10 -- production в Claude Code | +| TypeScript | 10/10 -- чистый fs/path, тривиальная реализация | +| Electron | 10/10 -- идеально, никаких зависимостей | +| Масштабируемость | 5/10 -- до ~10 агентов, потом I/O bottleneck | +| Feature-richness | 4/10 -- только messaging, нет identities/leases/FTS | + +### Вердикт + +**Лучший выбор для немедленного использования.** Мы УЖЕ используем этот паттерн в нашем приложении. Для межагентной коммуникации между разными провайдерами -- это самый простой путь: агенты любого провайдера пишут/читают JSON-файлы. + +--- + +## 8. SQLite/Redis Message Bus + +### SQLite Message Bus + +Паттерн из сообщества: Flask + SQLite message bus для ~16 агентов. + +**Особенности:** +- HTTP API для отправки/получения сообщений +- Broadcast messaging (omit "to" field) +- Reply chains через `reply_to` +- Priority levels (normal/high/urgent) +- Read receipts +- `journal_mode=WAL` для конкурентного доступа +- Auto-archiving старых сообщений + +### Redis Approaches + +| Подход | Плюсы | Минусы | +|--------|-------|--------| +| Redis Pub/Sub | Real-time, low latency | Ephemeral -- сообщения теряются | +| Redis Streams | Persistent, consumer groups | Требует Redis server | +| redis-bus | Autodiscovery, cache | Legacy (Python 2.7) | + +### Оценка для Electron + +| Критерий | Оценка | +|----------|--------| +| SQLite bus | 7/10 -- хорошо для Electron (better-sqlite3 уже есть) | +| Redis | 3/10 -- требует отдельный server, overkill для desktop | +| TypeScript | 8/10 (SQLite) / 6/10 (Redis) | +| Масштабируемость | 8/10 (SQLite WAL) / 9/10 (Redis) | + +### Вердикт + +**SQLite message bus** -- отличный апгрейд с file-based inbox, если нужна persistence, FTS, priority, read receipts. `better-sqlite3` уже хорошо работает в Electron. Redis -- overkill для локального desktop-приложения. + +--- + +## 9. Cross-Provider Orchestration Tools + +### OpenCode -- True Multi-Model Agent Teams + +OpenCode -- единственный инструмент, который **реально запускает Claude + Codex + Gemini в одной команде**. + +**Архитектура:** +- Event-driven inbox (не polling как Claude Code) +- Per-agent JSONL файлы: `{ id, from, text, timestamp, read }` +- Session injection для delivery (не file polling) +- Shared task list с claiming + +**Отличия от Claude Code:** +- Multi-model support (Claude, GPT, Gemini в одной команде) +- Peer-to-peer messaging (не только через lead) +- Event-driven (не polling) +- Append-only JSONL (не JSON array) +- Всё in-process (locks в памяти) + +### sub-agents-skills + +GitHub: [shinpr/sub-agents-skills](https://github.com/shinpr/sub-agents-skills) + +Позволяет использовать Codex, Claude Code, Gemini CLI как sub-agents из любого parent session. Cross-LLM делегация задач. + +### ZenFlow (Zencoder) + +Structured handoffs между Claude и Gemini с quality gates. Не open-source. + +### CC Switch + +Unified management: proxy Claude/Codex/Gemini, unified MCP panel, markdown editor с cross-app sync для CLAUDE.md/AGENTS.md/GEMINI.md. + +--- + +## Comparison Matrix + +| | A2A | MCP (inter-agent) | ACP (Zed) | File Inbox | MCP Agent Mail | SQLite Bus | +|---|---|---|---|---|---|---| +| **Зрелость** | 8/10 | 5/10 | 7/10 | 9/10 | 7/10 | 6/10 | +| **TS SDK** | 9/10 | 10/10 | 7/10 | 10/10 | 3/10 | 8/10 | +| **Electron-ready** | 5/10 | 9/10 | 8/10 | 10/10 | 6/10 | 7/10 | +| **Cross-provider** | 9/10 | 8/10 | 9/10 | 10/10 | 9/10 | 10/10 | +| **No server needed** | No | Partial | Yes (stdio) | Yes | No | Yes | +| **Real-time** | Yes (SSE) | Yes (SSE) | Yes | No (polling) | No | Polling | +| **Persistence** | Optional | No | No | File-based | Git+SQLite | SQLite | +| **File coordination** | No | No | No | No | Yes (leases) | No | +| **Identity system** | Agent Cards | No | No | No | Yes | No | +| **Сложность** | High | Medium | Medium | Very Low | High | Low | + +--- + +## Recommendations for Our Electron App + +### Немедленно (Phase 1) -- File-Based Inbox + +**Надёжность: 9/10 | Уверенность: 10/10** + +Мы уже используем file-based inbox для Claude Code Agent Teams. Этот же паттерн работает для ЛЮБОГО CLI-агента (Codex, Gemini CLI). Агенту не нужно знать протокол -- он просто читает/пишет JSON-файлы. + +``` +~/.claude_teams/{team-name}/inboxes/ +├── claude-lead.json +├── codex-worker.json +├── gemini-researcher.json +``` + +**Что нужно для cross-provider:** +1. Unified inbox format (уже есть: `{ from, text, timestamp, read }`) +2. Agent spawner для каждого CLI (Claude Code, Codex CLI, Gemini CLI) +3. Каждый агент получает system prompt с инструкцией читать/писать inbox files +4. Task board (shared JSON files с flock) + +### Среднесрочно (Phase 2) -- SQLite Message Bus + +**Надёжность: 8/10 | Уверенность: 8/10** + +Upgrade с file-based на SQLite для: +- Persistence и searchable history +- Priority levels и read receipts +- Better concurrency (WAL mode) +- FTS для поиска по сообщениям + +`better-sqlite3` уже отлично работает в Electron. + +### Долгосрочно (Phase 3) -- MCP-Based Inter-Agent + +**Надёжность: 6/10 | Уверенность: 6/10** + +Когда MCP roadmap 2026 реализует agent-to-agent capabilities: +- Каждый агент запускает MCP-сервер со своими capabilities +- Другие агенты подключаются как MCP-клиенты +- Streaming, session management, tool negotiation из коробки +- @modelcontextprotocol/sdk уже в нашем стеке + +### Если потребуются удалённые агенты (Phase 4) -- A2A + +**Надёжность: 7/10 | Уверенность: 5/10** + +A2A имеет смысл только если: +- Нужна коммуникация с агентами на других машинах +- Интеграция с enterprise-системами (Salesforce, SAP агенты) +- Cloud-hosted агенты + +В этом случае: встроить Express-сервер в Electron main process, использовать @a2a-js/sdk. + +### Конкретный ответ на вопрос: "Как заставить Claude поговорить с Codex?" + +**Самый простой работающий способ прямо сейчас:** + +1. Spawn Claude Code CLI как child_process +2. Spawn Codex CLI как child_process +3. Оба читают/пишут в общую директорию inbox-файлов +4. System prompt для каждого включает инструкцию: "To communicate with other agents, write to their inbox file at {path}" +5. Наше Electron-приложение выступает оркестратором: следит за inbox-файлами, доставляет сообщения через stdin, обновляет UI + +Это РОВНО то, что делает Claude Code Agent Teams, и ровно то, что OpenCode расширил для multi-provider. + +--- + +## Sources + +### Протоколы и спецификации +- [A2A Protocol Official Site](https://a2a-protocol.org/latest/) +- [A2A GitHub Repository](https://github.com/a2aproject/A2A) +- [A2A JS SDK](https://github.com/a2aproject/a2a-js) -- [@a2a-js/sdk npm](https://www.npmjs.com/package/@a2a-js/sdk) +- [Agent Client Protocol (Zed)](https://agentclientprotocol.com/) -- [GitHub](https://github.com/agentclientprotocol/agent-client-protocol) +- [ACP Registry](https://zed.dev/blog/acp-registry) +- [Agent Communication Protocol (IBM/BeeAI)](https://github.com/i-am-bee/acp) -- [IBM Research](https://research.ibm.com/projects/agent-communication-protocol) +- [Agent Network Protocol](https://agent-network-protocol.com/) -- [GitHub](https://github.com/agent-network-protocol/AgentNetworkProtocol) +- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) + +### Анонсы и статьи +- [Google: Announcing A2A](https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/) +- [Google Cloud: A2A Getting an Upgrade](https://cloud.google.com/blog/products/ai-machine-learning/agent2agent-protocol-is-getting-an-upgrade) +- [Linux Foundation: A2A Project Launch](https://www.linuxfoundation.org/press/linux-foundation-launches-the-agent2agent-protocol-project-to-enable-secure-intelligent-communication-between-ai-agents) +- [IBM: What is A2A?](https://www.ibm.com/think/topics/agent2agent-protocol) +- [IBM: What is ACP?](https://www.ibm.com/think/topics/agent-communication-protocol) +- [AWS: Inter-Agent Communication on MCP](https://aws.amazon.com/blogs/opensource/open-protocols-for-agent-interoperability-part-1-inter-agent-communication-on-mcp/) +- [Microsoft: A2A on MCP](https://developer.microsoft.com/blog/can-you-build-agent2agent-communication-on-mcp-yes) +- [Auth0: MCP vs A2A](https://auth0.com/blog/mcp-vs-a2a/) +- [Developer's Guide to AI Agent Protocols](https://developers.googleblog.com/developers-guide-to-ai-agent-protocols/) + +### Claude Code Agent Teams +- [Official Docs](https://code.claude.com/docs/en/agent-teams) +- [Reverse-Engineering Claude Code Agent Teams](https://dev.to/nwyin/reverse-engineering-claude-code-agent-teams-architecture-and-protocol-o49) +- [How They Work Under the Hood](https://www.claudecodecamp.com/p/claude-code-agent-teams-how-they-work-under-the-hood) + +### Cross-Provider Orchestration +- [OpenCode Agent Teams](https://dev.to/uenyioha/porting-claude-codes-agent-teams-to-opencode-4hol) +- [sub-agents-skills](https://github.com/shinpr/sub-agents-skills) +- [ZenFlow Multi-Agent Orchestration](https://docs.zencoder.ai/user-guides/guides/multi-agent-orchestration-in-zenflow) +- [Zed: External Agents](https://zed.dev/docs/ai/external-agents) + +### Agent Coordination +- [MCP Agent Mail](https://github.com/Dicklesworthstone/mcp_agent_mail) -- [Site](https://mcpagentmail.com/) +- [MCP Agent Mail Rust](https://github.com/Dicklesworthstone/mcp_agent_mail_rust) +- [Inbox/Outbox Pattern](https://earezki.com/ai-news/2026-03-09-the-inbox-outbox-pattern-how-ai-agents-coordinate-without-stepping-on-each-other/) +- [Multi-Agent Communication Patterns](https://dev.to/aureus_c_b3ba7f87cc34d74d49/multi-agent-communication-patterns-that-actually-work-50kp) +- [Agent Message Bus (SQLite)](https://dev.to/linou518/agent-message-bus-communication-infrastructure-for-16-ai-agents-18af) + +### Google ADK +- [ADK with A2A](https://google.github.io/adk-docs/a2a/) +- [ADK Docs](https://google.github.io/adk-docs/agents/models/google-gemini/) + +### Surveys +- [Survey of Agent Interoperability Protocols (arXiv)](https://arxiv.org/abs/2505.02279) +- [Top 5 Open Protocols for Multi-Agent AI Systems](https://onereach.ai/blog/power-of-multi-agent-ai-open-protocols/) +- [10 Modern AI Agent Protocols](https://www.ssonetwork.com/intelligent-automation/columns/ai-agent-protocols-10-modern-standards-shaping-the-agentic-era) diff --git a/docs/research/minimal-adapter-design.md b/docs/research/minimal-adapter-design.md new file mode 100644 index 00000000..774f3736 --- /dev/null +++ b/docs/research/minimal-adapter-design.md @@ -0,0 +1,689 @@ +# Minimal CLI Agent Adapter Design + +**Дата**: 2026-03-25 +**Статус**: Research / Design proposal + +## Цель + +Определить МИНИМАЛЬНО достаточный адаптер для запуска нескольких CLI-агентов (Claude, Codex, Gemini, Goose, OpenCode) из нашего Electron-приложения. Без over-engineering, без "велосипедов". + +--- + +## 1. Что мы уже имеем + +### childProcess.ts (221 LOC) +Уже содержит два ключевых примитива: +- **`spawnCli(binaryPath, args, options)`** — spawn с Windows EINVAL fallback +- **`execCli(binaryPath, args, options)`** — exec для одноразовых команд +- **`killProcessTree(child, signal)`** — kill с Windows taskkill fallback +- **`CLI_ENV_DEFAULTS`** — env-переменные для Claude (CLAUDE_HOOK_JUDGE_MODE) + +### TeamProvisioningService.ts (~8000+ LOC) +Монстр, который делает ВСЁ: +- Spawn через `spawnCli()` +- Конструирование args (`--input-format stream-json`, `--output-format stream-json`, `--mcp-config`, `--verbose`, etc.) +- Парсинг stream-json stdout (newline-delimited JSON) +- Stdin messaging (SDKUserMessage format) +- MCP config merge (через TeamMcpConfigBuilder) +- Filesystem monitoring, stall detection, auth retry, etc. + +### ScheduledTaskExecutor.ts (~200 LOC) +Отдельный, более чистый spawn-path для scheduled tasks: +- Тоже `spawnCli()` + `--output-format stream-json` +- Парсинг stdout для summary extraction +- Простой lifecycle: spawn -> wait -> collect result + +### TeamMcpConfigBuilder.ts (229 LOC) +Генерирует MCP config JSON-файл, мержит с user-серверами из `~/.claude.json`. + +### Общий паттерн spawn (из TeamProvisioningService): +```typescript +const spawnArgs = [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + '--setting-sources', 'user,project,local', + '--mcp-config', mcpConfigPath, + '--disallowedTools', 'TeamDelete,TodoWrite', + ...(skipPermissions ? ['--dangerously-skip-permissions'] : []), + ...(model ? ['--model', model] : []), +]; +child = spawnCli(claudePath, spawnArgs, { + cwd, env, stdio: ['pipe', 'pipe', 'pipe'], +}); +// stdin: send JSON messages +child.stdin.write(JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: prompt }] } +}) + '\n'); +``` + +--- + +## 2. Что РЕАЛЬНО отличается между CLI-агентами + +### Сводная таблица (исследование март 2026) + +| Аспект | Claude Code | Codex (OpenAI) | Gemini CLI | Goose (Block) | OpenCode | +|--------|-------------|-----------------|------------|---------------|----------| +| **Binary** | `claude` | `codex` | `gemini` | `goose` | `opencode` | +| **Programmatic mode** | `--input-format stream-json --output-format stream-json` | `codex exec --json` (NDJSON events) | `--output-format json` (headless) | `goose run --output-format stream-json` | `opencode run --format json` | +| **Stdin messaging** | stream-json protocol (SDKUserMessage) | Нет stdin — одноразовый exec | Нет stdin — одноразовый | Нет stdin — одноразовый `run` | Нет stdin — pipe prompt или `--attach` | +| **Output protocol** | NDJSON (type: user/assistant/result/control_request/system) | NDJSON events | JSON (структура неизвестна) | NDJSON (text/json/stream-json) | JSON events | +| **MCP config** | `--mcp-config /path/to/file.json` | `config.toml` (`codex mcp add`) | `settings.json` (`gemini mcp add`) | `--with-extension "cmd"` (runtime) | Config file (opencode.json) | +| **MCP config format** | `{ mcpServers: { name: { command, args } } }` | TOML (встроенная команда `codex mcp`) | JSON settings.json `{ mcpServers: {...} }` | CLI flags per extension | JSON config | +| **Kill semantics** | SIGKILL (team) / SIGTERM (scheduled) | SIGTERM | SIGTERM | SIGTERM | SIGTERM | +| **Keep-alive** | Да (stream-json stdin/stdout loop) | Нет (exec = one-shot) | Нет (headless = one-shot) | Нет (run = one-shot) | Возможно (`--attach` к serve) | +| **Team/multi-agent** | Нативные Agent Teams (TeamCreate, SendMessage) | Нет встроенного | Нет встроенного | Нет встроенного | Subagents через Task tool | +| **Prompt flag** | Stdin (stream-json) или `-p` (one-shot) | `codex exec "prompt"` (positional) | `-p "prompt"` или pipe | `goose run -t "prompt"` или `-i file` | `opencode run "prompt"` (positional) | + +### Источники +- [Codex CLI Reference](https://developers.openai.com/codex/cli/reference) — `codex exec --json`, NDJSON events +- [Codex MCP Docs](https://developers.openai.com/codex/mcp) — config.toml based MCP +- [Gemini CLI MCP Docs](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html) — settings.json, `gemini mcp add` +- [Goose CLI Commands](https://block.github.io/goose/docs/guides/goose-cli-commands/) — `--output-format stream-json`, `--with-extension` +- [Goose --output-format issue #4419](https://github.com/block/goose/issues/4419) — json/stream-json Done +- [OpenCode CLI Docs](https://opencode.ai/docs/cli/) — `run --format json` +- [OpenCode Agents Docs](https://opencode.ai/docs/agents/) — subagents, Task tool + +--- + +## 3. Ключевой вывод: ГДЕ реальная сложность + +### Что тривиально (просто конфиг): +- **Binary name** — строка +- **Prompt flag** — `-p`, `-t`, позиционный arg, или stdin +- **Output format flag** — `--output-format stream-json`, `--json`, `--format json` +- **Model flag** — `--model`, `-m`, `--provider/--model` +- **Permission flags** — `--dangerously-skip-permissions`, `--full-auto`, `--yolo` +- **Kill signal** — SIGKILL vs SIGTERM + +### Что НЕ тривиально (требует адаптера): +1. **Stdin protocol** — ТОЛЬКО Claude имеет persistent stdin loop (stream-json). Все остальные — one-shot (запустил, получил результат, процесс завершился). Это ФУНДАМЕНТАЛЬНОЕ отличие. +2. **Output parsing** — NDJSON формат похож, но структура объектов разная. Claude: `{type: "assistant", message: {...}}`. Codex: свой формат events. Goose: свой. Gemini: свой. +3. **MCP config injection** — Claude: `--mcp-config file.json`. Codex: нужно `codex mcp add` заранее или config.toml. Gemini: нужно `gemini mcp add` или settings.json. Goose: `--with-extension` per runtime. + +### Честная оценка: что из 8000 LOC TeamProvisioningService нужно для других CLI? + +**НЕ нужно** (Claude-specific, 80% кода): +- stream-json stdin messaging loop +- `control_request` protocol (tool approval) +- Teammate spawn tracking (`memberSpawnStatuses`) +- Agent Teams protocol (TeamCreate, SendMessage, TaskCreate) +- Post-compact context recovery +- Cross-team messaging relay +- Lead activity state machine +- Filesystem monitoring для team files (config.json, inboxes/, tasks/) +- Auth retry через respawn + +**Нужно** (общий ~20% skeleton): +- Binary resolution (`ClaudeBinaryResolver` -> обобщённый) +- Shell env resolution (`resolveInteractiveShellEnv`) +- MCP config generation и injection +- Process spawn + stdio pipes +- stdout/stderr collection +- Kill + cleanup +- Timeout/stall detection +- Progress reporting + +--- + +## 4. Три варианта дизайна + +### Option A: Config-driven (одна функция + конфиг) + +**~120 LOC total** (config object + spawnAgent function + output normalizer) + +```typescript +// src/main/utils/agentConfig.ts (~60 LOC) + +export type AgentType = 'claude' | 'codex' | 'gemini' | 'goose' | 'opencode'; + +export type OutputProtocol = 'stream-json' | 'ndjson-events' | 'json-batch'; + +/** How to inject the user prompt into the CLI */ +export type PromptMode = + | { type: 'stdin-stream-json' } // Claude: persistent stdin loop + | { type: 'flag'; flag: string } // -p "prompt", -t "prompt" + | { type: 'positional' } // codex exec "prompt" + | { type: 'stdin-pipe' }; // echo "prompt" | opencode run + +export interface AgentConfig { + /** Binary name (resolved via PATH or explicit path) */ + bin: string; + /** How to pass the prompt */ + promptMode: PromptMode; + /** CLI flags for programmatic output */ + outputArgs: string[]; + /** How stdout should be parsed */ + outputProtocol: OutputProtocol; + /** How to inject MCP servers */ + mcpInjection: + | { type: 'flag'; flag: string; format: 'claude-json' } // --mcp-config file.json + | { type: 'runtime-flag'; flag: string } // --with-extension "cmd" + | { type: 'config-file'; path: string; format: 'toml' | 'json' } // write to config + | { type: 'cli-command'; command: string[] }; // codex mcp add ... + /** Signal to use for killing */ + killSignal: NodeJS.Signals; + /** Extra env vars */ + env?: Record; + /** Whether the process stays alive for multi-turn (only Claude) */ + persistent: boolean; +} + +export const AGENT_CONFIGS: Record = { + claude: { + bin: 'claude', + promptMode: { type: 'stdin-stream-json' }, + outputArgs: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], + outputProtocol: 'stream-json', + mcpInjection: { type: 'flag', flag: '--mcp-config', format: 'claude-json' }, + killSignal: 'SIGKILL', + env: { CLAUDE_HOOK_JUDGE_MODE: 'true' }, + persistent: true, + }, + codex: { + bin: 'codex', + promptMode: { type: 'positional' }, + outputArgs: ['exec', '--json'], + outputProtocol: 'ndjson-events', + mcpInjection: { type: 'config-file', path: '~/.codex/config.toml', format: 'toml' }, + killSignal: 'SIGTERM', + persistent: false, + }, + gemini: { + bin: 'gemini', + promptMode: { type: 'flag', flag: '-p' }, + outputArgs: ['--output-format', 'json'], + outputProtocol: 'json-batch', + mcpInjection: { type: 'cli-command', command: ['gemini', 'mcp', 'add'] }, + killSignal: 'SIGTERM', + persistent: false, + }, + goose: { + bin: 'goose', + promptMode: { type: 'flag', flag: '-t' }, + outputArgs: ['run', '--output-format', 'stream-json'], + outputProtocol: 'stream-json', + mcpInjection: { type: 'runtime-flag', flag: '--with-extension' }, + killSignal: 'SIGTERM', + persistent: false, + }, + opencode: { + bin: 'opencode', + promptMode: { type: 'positional' }, + outputArgs: ['run', '--format', 'json'], + outputProtocol: 'json-batch', + mcpInjection: { type: 'config-file', path: '.opencode.json', format: 'json' }, + killSignal: 'SIGTERM', + persistent: false, + }, +}; +``` + +```typescript +// src/main/utils/agentSpawn.ts (~60 LOC) + +import { spawnCli, killProcessTree } from './childProcess'; +import { AGENT_CONFIGS, type AgentType, type AgentConfig } from './agentConfig'; + +export interface AgentSpawnOptions { + type: AgentType; + prompt: string; + cwd: string; + env?: NodeJS.ProcessEnv; + model?: string; + mcpConfigPath?: string; // pre-built MCP config file (for Claude-style --mcp-config) + extraArgs?: string[]; +} + +export interface SpawnedAgent { + child: import('child_process').ChildProcess; + config: AgentConfig; + kill: () => void; + /** Send message (only works for persistent agents like Claude) */ + send?: (text: string) => void; +} + +export function spawnAgent(options: AgentSpawnOptions): SpawnedAgent { + const config = AGENT_CONFIGS[options.type]; + const args: string[] = [...config.outputArgs]; + + // Inject MCP config + if (options.mcpConfigPath && config.mcpInjection.type === 'flag') { + args.push(config.mcpInjection.flag, options.mcpConfigPath); + } + + // Extra args + if (options.extraArgs) { + args.push(...options.extraArgs); + } + + // Inject prompt based on mode + switch (config.promptMode.type) { + case 'flag': + args.push(config.promptMode.flag, options.prompt); + break; + case 'positional': + args.push(options.prompt); + break; + case 'stdin-stream-json': + case 'stdin-pipe': + // Handled after spawn + break; + } + + const child = spawnCli(config.bin, args, { + cwd: options.cwd, + env: { ...(options.env ?? process.env), ...(config.env ?? {}) }, + stdio: config.persistent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], + }); + + // Send prompt via stdin if needed + if (config.promptMode.type === 'stdin-stream-json' && child.stdin?.writable) { + const msg = JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: options.prompt }] }, + }); + child.stdin.write(msg + '\n'); + } else if (config.promptMode.type === 'stdin-pipe' && child.stdin) { + child.stdin.write(options.prompt); + child.stdin.end(); + } + + return { + child, + config, + kill: () => killProcessTree(child, config.killSignal), + send: config.persistent + ? (text: string) => { + if (!child.stdin?.writable) return; + const msg = JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }); + child.stdin.write(msg + '\n'); + } + : undefined, + }; +} +``` + +**Плюсы:** +- Минимум кода (~120 LOC в двух файлах) +- Нет классов, нет наследования, нет интерфейсов +- Новый CLI = добавить запись в AGENT_CONFIGS +- Легко тестировать (pure config + one function) +- Не ломает существующий код — TeamProvisioningService может использовать или не использовать + +**Минусы:** +- Output parsing НЕ покрыт (каждый CLI имеет свою структуру NDJSON) +- MCP config injection для Codex/Gemini требует отдельной логики (write to config.toml, run `gemini mcp add`) +- `persistent: true` (Claude) vs one-shot (все остальные) — фундаментально разный lifecycle + +**Надёжность: 7/10** — Покрывает spawn, но не parsing. +**Уверенность: 8/10** — Config-based подход проверен в ScheduledTaskExecutor. + +--- + +### Option B: Thin interface + implementations + +**~200 LOC total** (interface + claude adapter + generic one-shot adapter) + +```typescript +// src/main/adapters/AgentAdapter.ts (~30 LOC) + +import type { ChildProcess } from 'child_process'; + +export interface AgentOutput { + type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'result' | 'error' | 'raw'; + content: string; + raw?: unknown; +} + +export interface AgentAdapter { + readonly agentType: string; + readonly persistent: boolean; + + /** Build CLI args for spawning */ + buildArgs(prompt: string, options: { model?: string; mcpConfigPath?: string; extraArgs?: string[] }): string[]; + + /** Parse a single line/chunk of stdout into normalized output */ + parseOutput(line: string): AgentOutput | null; + + /** Send a follow-up message (only for persistent agents) */ + sendMessage?(child: ChildProcess, text: string): void; + + /** Which signal to use for kill */ + killSignal: NodeJS.Signals; +} +``` + +```typescript +// src/main/adapters/ClaudeAdapter.ts (~60 LOC) +export class ClaudeAdapter implements AgentAdapter { + readonly agentType = 'claude'; + readonly persistent = true; + readonly killSignal = 'SIGKILL' as const; + + buildArgs(prompt: string, options) { + const args = [ + '--input-format', 'stream-json', + '--output-format', 'stream-json', + '--verbose', + ]; + if (options.mcpConfigPath) args.push('--mcp-config', options.mcpConfigPath); + if (options.model) args.push('--model', options.model); + args.push(...(options.extraArgs ?? [])); + return args; + // prompt sent via sendMessage(), not in args + } + + parseOutput(line: string): AgentOutput | null { + try { + const obj = JSON.parse(line); + if (obj.type === 'assistant') return { type: 'text', content: /* extract */, raw: obj }; + if (obj.type === 'result') return { type: 'result', content: obj.result?.text ?? '', raw: obj }; + return { type: 'raw', content: line, raw: obj }; + } catch { return null; } + } + + sendMessage(child: ChildProcess, text: string) { + if (!child.stdin?.writable) return; + child.stdin.write(JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }) + '\n'); + } +} +``` + +```typescript +// src/main/adapters/OneShotAdapter.ts (~80 LOC) +// Generic one-shot adapter configurable for Codex, Goose, Gemini, OpenCode + +export interface OneShotConfig { + agentType: string; + subcommand?: string; // 'exec', 'run', etc. + outputFlag: string[]; // ['--json'], ['--output-format', 'stream-json'], etc. + promptFlag?: string; // '-p', '-t', or undefined for positional + mcpFlag?: string; // '--with-extension' for goose + killSignal?: NodeJS.Signals; +} + +export class OneShotAdapter implements AgentAdapter { + readonly persistent = false; + readonly agentType: string; + readonly killSignal: NodeJS.Signals; + private config: OneShotConfig; + + constructor(config: OneShotConfig) { + this.config = config; + this.agentType = config.agentType; + this.killSignal = config.killSignal ?? 'SIGTERM'; + } + + buildArgs(prompt: string, options) { + const args: string[] = []; + if (this.config.subcommand) args.push(this.config.subcommand); + args.push(...this.config.outputFlag); + if (options.mcpConfigPath && this.config.mcpFlag) { + args.push(this.config.mcpFlag, options.mcpConfigPath); + } + args.push(...(options.extraArgs ?? [])); + if (this.config.promptFlag) { + args.push(this.config.promptFlag, prompt); + } else { + args.push(prompt); // positional + } + return args; + } + + parseOutput(line: string): AgentOutput | null { + try { + const obj = JSON.parse(line); + return { type: 'raw', content: line, raw: obj }; + } catch { return null; } + } +} + +// Pre-built instances: +export const codexAdapter = new OneShotAdapter({ + agentType: 'codex', subcommand: 'exec', outputFlag: ['--json'], killSignal: 'SIGTERM', +}); +export const gooseAdapter = new OneShotAdapter({ + agentType: 'goose', subcommand: 'run', outputFlag: ['--output-format', 'stream-json'], + promptFlag: '-t', mcpFlag: '--with-extension', +}); +export const geminiAdapter = new OneShotAdapter({ + agentType: 'gemini', outputFlag: ['--output-format', 'json'], promptFlag: '-p', +}); +export const opencodeAdapter = new OneShotAdapter({ + agentType: 'opencode', subcommand: 'run', outputFlag: ['--format', 'json'], +}); +``` + +**Плюсы:** +- `parseOutput()` даёт место для нормализации вывода каждого CLI +- Чёткое разделение: Claude (persistent) vs all others (one-shot) +- `OneShotAdapter` — generic, покрывает 4 из 5 CLI одним классом +- Новый CLI = `new OneShotAdapter({ ... })` (одна строка) + +**Минусы:** +- Интерфейс + 2 класса — чуть больше "архитектуры" чем нужно прямо сейчас +- `parseOutput()` для не-Claude CLI будет пустышкой (return raw) пока не изучим их NDJSON формат +- Всё ещё не решает MCP injection для Codex (config.toml) и Gemini (settings.json) + +**Надёжность: 8/10** — Хороший баланс между простотой и расширяемостью. +**Уверенность: 7/10** — Interface-based подход стандартен, но `parseOutput` рискует стать "мёртвым кодом" на начальном этапе. + +--- + +### Option C: Расширить childProcess.ts (минимальные изменения) **(Recommended)** + +**~50 LOC additions** к существующему файлу + **~30 LOC** отдельный config + +```typescript +// Добавить в src/main/utils/childProcess.ts (~25 LOC) + +export type AgentType = 'claude' | 'codex' | 'gemini' | 'goose' | 'opencode'; + +export interface AgentSpawnResult { + child: ChildProcess; + send?: (text: string) => void; + kill: () => void; +} + +/** + * Spawn any supported CLI agent. Thin wrapper over spawnCli that + * handles binary name, output-format flags, and prompt injection. + */ +export function spawnAgent( + type: AgentType, + binaryPath: string, + prompt: string, + options: SpawnOptions & { mcpConfigPath?: string; extraArgs?: string[] } = {} +): AgentSpawnResult { + const cfg = AGENT_SPAWN_CONFIGS[type]; + const args = [...cfg.baseArgs]; + if (options.mcpConfigPath && cfg.mcpFlag) { + args.push(cfg.mcpFlag, options.mcpConfigPath); + } + if (options.extraArgs) args.push(...options.extraArgs); + if (cfg.promptFlag) args.push(cfg.promptFlag, prompt); + else if (!cfg.stdinPrompt) args.push(prompt); + + const child = spawnCli(binaryPath, args, { + ...options, + env: { ...(options.env ?? process.env), ...(cfg.env ?? {}) }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Inject prompt via stdin if needed + if (cfg.stdinPrompt && child.stdin?.writable) { + const msg = cfg.stdinPrompt === 'stream-json' + ? JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: prompt }] } }) + '\n' + : prompt; + child.stdin.write(msg); + if (cfg.stdinPrompt === 'pipe') child.stdin.end(); + } + + return { + child, + send: cfg.stdinPrompt === 'stream-json' + ? (text: string) => { + if (!child.stdin?.writable) return; + child.stdin.write(JSON.stringify({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text }] }, + }) + '\n'); + } + : undefined, + kill: () => killProcessTree(child, cfg.killSignal), + }; +} +``` + +```typescript +// src/main/utils/agentConfigs.ts (~30 LOC) + +interface AgentSpawnConfig { + baseArgs: string[]; + promptFlag?: string; // undefined = positional arg + stdinPrompt?: 'stream-json' | 'pipe'; + mcpFlag?: string; + killSignal: NodeJS.Signals; + env?: Record; +} + +export const AGENT_SPAWN_CONFIGS: Record = { + claude: { + baseArgs: ['--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], + stdinPrompt: 'stream-json', + mcpFlag: '--mcp-config', + killSignal: 'SIGKILL', + env: { CLAUDE_HOOK_JUDGE_MODE: 'true' }, + }, + codex: { + baseArgs: ['exec', '--json'], + killSignal: 'SIGTERM', + }, + gemini: { + baseArgs: ['--output-format', 'json'], + promptFlag: '-p', + killSignal: 'SIGTERM', + }, + goose: { + baseArgs: ['run', '--output-format', 'stream-json'], + promptFlag: '-t', + mcpFlag: '--with-extension', + killSignal: 'SIGTERM', + }, + opencode: { + baseArgs: ['run', '--format', 'json'], + killSignal: 'SIGTERM', + }, +}; +``` + +**Плюсы:** +- Абсолютный минимум нового кода (~55 LOC) +- Не создаёт новую абстракцию — расширяет существующую +- TeamProvisioningService может постепенно мигрировать (или нет) +- Новый CLI = 5 строк в конфиге +- Binary resolution остаётся на вызывающей стороне (как сейчас с ClaudeBinaryResolver) +- Output parsing — ответственность вызывающего кода (не навязываем) + +**Минусы:** +- Не покрывает output parsing (сознательно) +- Не покрывает MCP config injection для Codex/Gemini +- childProcess.ts станет чуть толще (~275 LOC вместо 221) +- Нет типизации вывода (каждый consumer парсит сам) + +**Надёжность: 7/10** — Минимально, но достаточно для spawn. +**Уверенность: 9/10** — Расширение существующего утилитного файла — самый безопасный путь. + +--- + +## 5. Сравнительная таблица + +| Критерий | Option A (config+fn) | Option B (interface) | Option C (extend existing) | +|----------|---------------------|---------------------|---------------------------| +| **LOC** | ~120 | ~200 | ~55 | +| **Новых файлов** | 2 | 3 | 1 | +| **Output parsing** | Нет | Да (заглушка) | Нет | +| **MCP injection** | Описано, не реализовано | Описано, не реализовано | Описано, не реализовано | +| **Расширяемость** | Хорошая (конфиг) | Отличная (интерфейс) | Хорошая (конфиг) | +| **Breaks existing?** | Нет | Нет | Нет | +| **Time to implement** | 1 час | 2 часа | 30 мин | +| **"Велосипед"?** | Нет, это конфиг | Нет, но чуть преждевременно | Нет, это 55 строк клея | + +--- + +## 6. Рекомендация + +### Начать с Option C (extend childProcess.ts), при необходимости вырастить в Option A + +**Почему:** + +1. **55 LOC — это не велосипед.** Это минимальный config-driven dispatcher. Любой проект, интегрирующий несколько CLI, пишет ровно это. Нет смысла тянуть зависимость ради 55 строк. + +2. **Output parsing — отдельная задача.** Парсинг NDJSON от Codex/Gemini/Goose — это ~50-100 LOC на каждый CLI, и его не нужно решать сейчас. Когда понадобится — это будет Option B (interface с `parseOutput()`), но не раньше. + +3. **MCP injection — тоже отдельная задача.** Для Claude у нас уже есть TeamMcpConfigBuilder. Для Goose — это просто `--with-extension`. Для Codex/Gemini — нужно писать в их config files. Это 3 отдельных утилиты, не общий адаптер. + +4. **Persistent vs one-shot — фундаментально разный lifecycle.** Claude (stream-json loop) живёт долго и получает новые сообщения. Все остальные — fire-and-forget. Эту разницу нельзя "спрятать" за единым интерфейсом без того чтобы интерфейс не стал дырявой абстракцией. + +### Эволюционный путь: + +``` +Этап 1 (сейчас): Option C — spawnAgent() в childProcess.ts + agentConfigs.ts + 55 LOC, покрывает spawn для всех 5 CLI + +Этап 2 (когда добавим 2-й CLI): Вынести в отдельный файл если childProcess.ts станет перегруженным + Может стать Option A (~120 LOC) + +Этап 3 (когда нужен output parsing): Добавить parseOutput() per agent + Может стать Option B (~200 LOC) +``` + +--- + +## 7. Честный ответ: "велосипед" или нет? + +**Нет, это НЕ велосипед.** Вот почему: + +1. **Нет готовой библиотеки.** Не существует npm-пакета "universal-cli-agent-spawner". Каждый из этих CLI — молодой продукт (2025-2026), с собственным протоколом. Никто ещё не написал унификатор. + +2. **55-200 LOC клея — это норма.** Для сравнения: + - Docker SDK для Node.js: ~300 LOC для spawn docker CLI + - Terraform CDK: ~200 LOC для spawn terraform binary + - VS Code extensions: ~150 LOC для spawn language server + +3. **Наш существующий spawnCli() — уже 65 LOC** клея для одного Claude CLI. Расширить его до 5 CLI за +55 LOC — это линейное масштабирование, не экспоненциальное. + +4. **Реальный "велосипед" начался бы** если бы мы писали: + - Свой MCP client (~500+ LOC) + - Свой NDJSON parser с backpressure (~200 LOC) + - Свой process supervisor с restart policies (~400 LOC) + - Свой auth token manager per CLI (~300 LOC) + + Мы этого НЕ делаем. Мы пишем config map + одну функцию. + +5. **Большую часть сложности (8000 LOC TeamProvisioningService) мы уже написали** для Claude — и она Claude-specific. Адаптер для других CLI будет использовать ~5% от этого кода. + +--- + +## 8. Что НЕ включать в адаптер + +Явно НЕ входит в scope минимального адаптера: +- Output parsing/normalization (отдельный слой) +- Team protocol (Agent Teams — Claude-only) +- MCP config generation (отдельный builder per CLI) +- Binary auto-discovery/installation (отдельный resolver per CLI) +- Auth management (каждый CLI сам) +- Session persistence (каждый CLI сам) +- Stall/timeout detection (caller responsibility) +- Progress reporting (caller responsibility) + +Это всё валидная функциональность, но она живёт ВЫШЕ адаптера, в orchestration layer (TeamProvisioningService или его аналог). diff --git a/docs/research/multi-agent-communication-tools.md b/docs/research/multi-agent-communication-tools.md new file mode 100644 index 00000000..cae9ded5 --- /dev/null +++ b/docs/research/multi-agent-communication-tools.md @@ -0,0 +1,542 @@ +# Multi-Agent CLI Orchestrators with Inter-Agent Communication + +> Research date: 2026-03-25 +> Focus: tools where Agent A (Claude) can send a message to Agent B (Codex/Gemini), NOT just "fan-out same task to multiple agents" + +## TL;DR + +Ни один инструмент не является зрелым "фундаментом" для замены нашего стека. Все проекты в этом пространстве молоды (< 6 месяцев), быстро меняют API, и ни один не имеет production-grade inter-agent communication для РАЗНЫХ провайдеров CLI-агентов уровня, который мы уже реализовали для Claude Code Agent Teams. + +**Лидеры по inter-agent communication:** + +| Tool | Stars | Inter-Agent Msg | Multi-Provider | Kanban | Наша оценка | +|------|-------|----------------|----------------|--------|------------| +| Ruflo | 25,709 | SQLite + JSON | Claude + Codex | Нет | Hype-driven, раздутые цифры | +| Composio AO | 5,390 | CI feedback routing | Claude, Codex, Aider | Нет | Planner-executor, не P2P | +| Claude Octopus | 2,069 | Consensus gate 75% | 8 providers | Нет | Plugin, не orchestrator | +| mcp_agent_mail | 1,842 | MCP + SQLite inbox | Any MCP client | Нет | Протокол, не UI | +| claude_code_bridge | 1,855 | Real-time collab | Claude, Codex, Gemini | Нет | Terminal split-pane | +| Overstory | 1,123 | SQLite mail (WAL) | 11 runtimes | Нет | Closest to real P2P | +| agtx | 693 | Session switching | Claude, Codex, Gemini, OpenCode, Cursor | Kanban-like | Autonomous, но молодой | +| AI Maestro | 556 | AMP protocol | Claude, Codex, any | Kanban! | Multi-machine, но TypeScript mesh | +| parallel-code | 407 | Нет (изоляция) | Claude, Codex, Gemini | Diff viewer | Параллельное, не collaborative | +| CAO (AWS) | 344 | SQLite inbox + MCP | Q CLI, Claude, Codex | Нет | AWS-backed, но ранняя стадия | +| MCO | 249 | Fan-out, не P2P | 5 CLIs | Нет | Dispatch layer, не messaging | +| hcom | 164 | File-based hooks | Claude, Codex, Gemini, OpenCode | Нет | Lightweight, hooks-only | +| MetaSwarm | 148 | Skills-based | Claude, Gemini, Codex | Нет | Self-improving framework | +| CAS | 69 | Через MCP server | Claude Code only | Нет | Claude-only, раннее | +| kodo | 46 | Verification cycle | Claude, Codex, Gemini | Нет | SWE-bench verified | + +--- + +## 1. CAS (Coding Agent System) + +- **Repo:** https://github.com/codingagentsystem/cas +- **Stars:** 69 +- **Language:** Rust +- **License:** MIT +- **Created:** 2026-01-05 + +### Что это +Supervisor + Workers модель для Claude Code. Factory mode оркестрирует несколько Claude Code инстансов в параллельных git worktree. MCP server дает агентам persistent memory, task tracking, rules, skills через SQLite + FTS. + +### Inter-Agent Communication +- Нет прямого inter-agent messaging между агентами +- Communication идет через supervisor (hub-and-spoke) +- Workers не общаются друг с другом напрямую +- Coordinator раздает задачи, workers возвращают результаты + +### Multi-Provider Support +- **ТОЛЬКО Claude Code** — нет поддержки Codex, Gemini, Goose и др. + +### Вердикт +Не подходит как фундамент. Claude-only, маленькое коммьюнити (69 stars), нет inter-agent messaging, нет multi-provider. Persistent memory через MCP server — интересная идея, но не уникальная. + +--- + +## 2. AWS CLI Agent Orchestrator (CAO) + +- **Repo:** https://github.com/awslabs/cli-agent-orchestrator +- **Stars:** 344 +- **Language:** Python +- **License:** Apache 2.0 (AWS) +- **Created:** 2025-07-29 + +### Что это +Иерархическая система оркестрации CLI AI агентов от AWS Labs. Три паттерна: Handoff (синхронный transfer), Assign (async spawn), Send Message (прямая коммуникация). + +### Inter-Agent Communication +- **Send Message** — прямые сообщения между существующими агентами +- **SQLite inbox system** — асинхронная доставка сообщений с FIFO ordering +- **File-watching** — определяет когда terminal idle и доставляет pending messages +- **MCP tools** — `handoff`, `assign`, `send_message` для координации +- **REST API** — cao-server на `localhost:9889` + +### Multi-Provider Support +- Amazon Q CLI, Claude Code, Codex CLI (через провайдер с API key) +- Каждый агент в изолированной tmux сессии + +### Что хорошо +- AWS-backed = стабильная поддержка +- Реальный inter-agent messaging через SQLite inbox +- Profile-based agent isolation +- Cron-like scheduled runs + +### Что плохо +- 344 stars — ранняя стадия +- Зависимость на tmux +- Python-based (не наш стек) +- Нет UI/dashboard + +### Вердикт +Наиболее продуманный подход к inter-agent messaging через SQLite inbox. Но ранняя стадия, нет UI, Python-only. Send Message паттерн — это то, что нам нужно, но реализация привязана к tmux sessions. + +--- + +## 3. Overstory + +- **Repo:** https://github.com/jayminwest/overstory +- **Stars:** 1,123 +- **Language:** TypeScript (Bun) +- **License:** MIT +- **Created:** 2026-02-12 + +### Что это +Превращает coding session в multi-agent team. Workers в git worktree через tmux. SQLite mail system для координации. FIFO merge queue с 4-tier conflict resolution. + +### Inter-Agent Communication +- **SQLite mail system** (WAL mode, ~1-5ms/query) — ключевая фича +- **8 typed protocol messages:** `worker_done`, `merge_ready`, `merged`, `merge_failed`, `escalation`, `health_check`, `dispatch`, `assign` +- **Type-safe API:** `sendProtocol()` и `parsePayload()` +- **Broadcast:** группы `@all`, `@builders` и др. +- **`overstory mail`** CLI: send/check/list/read/reply + +### Multi-Provider Support +- **11 runtime adapters:** Claude Code, Pi, Gemini CLI, Aider, Goose, Amp и др. +- Pluggable `AgentRuntime` interface + +### Что хорошо +- Самый развитый SQLite mail system среди всех инструментов +- Type-safe protocol messages — близко к нашему подходу с inbox files +- 11 runtime adapters — реальная мультипровайдерность +- TypeScript/Bun — совместимый стек + +### Что плохо +- Зависимость на tmux + Bun (не Node/Electron) +- "Compounding error rates, cost amplification, debugging complexity" — сами предупреждают +- Нет UI — всё CLI +- 1,123 stars за 1.5 месяца — быстрый рост, но незрелый + +### Вердикт +Ближайший по архитектуре к нашему подходу (SQLite mail ~ наш inbox system). Протокольные сообщения с типами, broadcast — всё это у нас уже есть. Мог бы быть полезен как reference для protocol design, но не как фундамент. + +--- + +## 4. Composio Agent Orchestrator + +- **Repo:** https://github.com/ComposioHQ/agent-orchestrator +- **Stars:** 5,390 +- **Language:** TypeScript +- **License:** MIT +- **Created:** 2026-02-13 + +### Что это +Planner-Executor модель для fleet of parallel coding agents. Orchestrator — сам AI agent который читает codebase, decompose features, мониторит progress. Plugin system с 8 swappable slots. + +### Inter-Agent Communication +- **НЕ peer-to-peer messaging** — orchestrator agent роутит feedback +- CI failures → injection back в agent session +- Review comments → routing в правильный agent с контекстом +- Self-improvement loop: logs → retrospectives → adjustments + +### Multi-Provider Support +- Claude Code, Codex, Aider +- Runtime-agnostic: tmux, Docker +- Tracker-agnostic: GitHub, Linear + +### Что хорошо +- 5,390 stars — самый популярный в категории +- TypeScript — наш стек +- Self-improvement system — уникальная фича +- Plugin architecture — гибко + +### Что плохо +- Нет P2P inter-agent messaging — всё через orchestrator +- Agent A не может напрямую послать сообщение Agent B +- Orchestrator = single point of failure +- 1.5 месяца от creation — очень молодой + +### Вердикт +Самый popular, но inter-agent communication = feedback routing через orchestrator, а не direct messaging. Это принципиально другой паттерн, чем наш. Полезен как reference для planner-executor, но не для P2P communication. + +--- + +## 5. hcom (Hook-Comms) + +- **Repo:** https://github.com/aannoo/hcom +- **Stars:** 164 +- **Language:** Rust +- **Created:** 2025-07-21 + +### Что это +Lightweight CLI для inter-agent messaging через hooks. Agents могут message, watch, spawn друг друга across terminals. + +### Inter-Agent Communication +- **`send`** — отправка сообщений между agents +- **`listen`** — блокирующее ожидание с фильтрами (agent, type, status, sender, intent) +- **`events`** — event stream с подписками +- **`bundle`** — structured context packages для handoffs +- **`transcript`** — чтение conversation другого агента +- **TUI dashboard** для мониторинга + +### Multi-Provider Support +- Claude Code, Gemini CLI, Codex, OpenCode +- Hooks integration для Gemini CLI + +### Что хорошо +- Минимальный, специализированный tool для inter-agent messaging +- Работает с любым CLI agent через hooks +- `listen` с фильтрами — мощный примитив + +### Что плохо +- 164 stars — маленькое коммьюнити +- Rust — другой стек +- Нет task management, нет orchestration — только messaging +- Зависимость на hooks mechanism + +### Вердикт +Интересный lightweight подход к messaging, но это only messaging layer без orchestration. Можно изучить как reference для protocol design, но не как фундамент. + +--- + +## 6. AI Maestro + +- **Repo:** https://github.com/23blocks-OS/ai-maestro +- **Stars:** 556 +- **Language:** TypeScript +- **License:** MIT +- **Created:** 2025-10-10 + +### Что это +Dashboard для управления агентами across multiple machines. Agent Messaging Protocol (AMP). Skills system. Code Graph. Memory. + +### Inter-Agent Communication +- **Agent Messaging Protocol (AMP)** — email-like communication + - Priority levels, message types, cryptographic signatures, push notifications + - Отдельный open-source протокол: https://github.com/agentmessaging/protocol +- **Peer mesh network** — multi-machine без central server +- **External gateways:** Slack, Discord, Email, WhatsApp + +### Multi-Provider Support +- Claude Code, Aider, Cursor, Copilot, OpenCode, Codex CLI, Gemini CLI +- 30+ compatible agents через Skills + +### Kanban Board +- **ДА!** Полный Kanban с drag-and-drop, dependencies, 5 status columns +- Teams + War Rooms + +### Что хорошо +- **Kanban board** — единственный конкурент с Kanban! +- AMP protocol — formalized inter-agent messaging +- Multi-machine support — уникально +- TypeScript — наш стек +- External messaging gateways + +### Что плохо +- 556 stars — умеренная популярность +- AMP protocol ещё развивается +- tmux dependency +- "80+ agents across multiple computers" — выглядит как over-engineering + +### Вердикт +**Самый близкий конкурент** по feature set: Kanban + inter-agent messaging + multi-provider + TypeScript. AMP protocol — интересный formalized подход. Стоит внимательно изучить. Однако peer mesh network и multi-machine — это другой масштаб, чем наш local-first подход. + +--- + +## 7. ORCH + +- **Website:** https://www.orch.one/ +- **Stars:** N/A (repo не найден / приватный на момент исследования) +- **License:** MIT + +### Что это +CLI runtime для управления Claude Code, Codex, Cursor как typed agent teams. State machine, event bus, TUI. + +### Inter-Agent Communication +- **Typed event bus** — 31 event type, agents emit events, orchestrator reacts +- **Inter-agent messaging** — direct messages, broadcasts, injected в prompts +- **Agent Teams** — group agents under lead, broadcast context +- **State machine:** todo -> in_progress -> review -> done + +### Multi-Provider Support +- 5 adapters: Claude, OpenCode (Gemini, DeepSeek via OpenRouter), Codex, Cursor, Shell + +### Что хорошо +- Event bus architecture — decoupled communication +- State machine — production-quality +- 5 adapters из коробки +- Headless daemon mode (`orch serve`) + +### Что плохо +- GitHub repo не найден или приватный — нельзя оценить реальный код +- Event bus = centralized, не P2P +- Нет UI кроме TUI + +### Вердикт +Архитектурно интересный (event bus + state machine), но невозможно оценить зрелость кода без доступа к repo. Event bus — это скорее pub/sub, чем direct messaging. + +--- + +## 8. Ruflo + +- **Repo:** https://github.com/ruvnet/ruflo +- **Stars:** 25,709 +- **Language:** TypeScript +- **License:** MIT +- **Created:** 2025-06-02 + +### Что это +"The leading agent orchestration platform for Claude." Multi-agent swarms, autonomous workflows, RAG integration. Ранее Claude-Flow. + +### Inter-Agent Communication +- SQLite для memory persistence +- JSON-based coordination protocols для inter-agent messaging +- Compaction lifecycle → archive context to SQLite + +### Multi-Provider Support +- Claude Code + Codex integration + +### Что хорошо +- 25K stars — самый популярный в нише +- Comprehensive feature set + +### Что плохо +- 25K stars за < 10 месяцев — подозрительно (возможен бот-boost) +- "v3 introduces self-learning neural capabilities" — marketing buzzwords +- Сравнения с конкурентами в README — red flag +- Claude-centric, minimal real multi-provider + +### Вердикт +Hype-driven проект с подозрительно высокими stars. Inter-agent communication через SQLite + JSON — базовый уровень. Не стоит использовать как фундамент из-за quality concerns. + +--- + +## 9. MCO (Multi-CLI Orchestrator) + +- **Repo:** https://github.com/mco-org/mco +- **Stars:** 249 +- **Language:** Python +- **Created:** 2026-02-26 + +### Что это +Neutral dispatch layer. Отправляет prompts на несколько CLI agents параллельно, агрегирует результаты. + +### Inter-Agent Communication +- **НЕТ real inter-agent messaging** +- Fan-out same prompt → collect results → aggregate +- Structured code review с findings schema + +### Multi-Provider Support +- Claude Code, Codex CLI, Gemini CLI, OpenCode, Qwen Code + +### Вердикт +Dispatch/aggregation, не collaboration. Agent A не знает о Agent B. Полезен для multi-perspective review, но это не inter-agent communication. + +--- + +## 10. mcp_agent_mail + +- **Repo:** https://github.com/Dicklesworthstone/mcp_agent_mail +- **Stars:** 1,842 +- **Language:** Python +- **Created:** 2025-10-23 + +### Что это +Mail-like coordination layer для coding agents. FastMCP server + Git + SQLite. + +### Inter-Agent Communication +- **Inbox/outbox** per agent +- **Searchable message history** +- **File lease system** — voluntary file reservation +- **Memorable identities** для agents +- HTTP-only FastMCP server + +### Multi-Provider Support +- Any MCP-compatible client + +### Что хорошо +- 1,842 stars — солидное коммьюнити +- Clean abstraction: mail metaphor для agent communication +- File leases — unique feature для conflict prevention + +### Что плохо +- Python + FastMCP — другой стек +- Только communication layer, не orchestrator +- Нет task management, нет UI + +### Вердикт +Лучший standalone inter-agent communication protocol. File leases — интересная идея для нас. Но это protocol library, не ready-to-use tool. + +--- + +## 11. agtx + +- **Repo:** https://github.com/fynnfluegge/agtx +- **Stars:** 693 +- **Language:** Rust +- **Created:** 2026-02-08 + +### Что это +Multi-session AI coding terminal manager. Autonomous orchestration с spec-driven workflow. + +### Inter-Agent Communication +- Session switching с context awareness +- Gemini -> research | Claude -> implement | Codex -> review +- Kanban board в TUI + +### Multi-Provider Support +- Claude, Codex, Gemini, OpenCode, Cursor + +### Вердикт +Autonomous orchestration с role-based agent dispatch. Kanban-like TUI. Но Rust стек и нет rich inter-agent messaging. + +--- + +## 12. claude_code_bridge (ccb) + +- **Repo:** https://github.com/bfly123/claude_code_bridge +- **Stars:** 1,855 +- **Language:** Python +- **Created:** 2025-10-25 + +### Что это +Real-time multi-AI collaboration. Split-pane terminal. Persistent context. + +### Inter-Agent Communication +- Real-time collaboration между Claude, Codex, Gemini +- Persistent context sharing +- WYSIWYG split-pane terminal + +### Вердикт +Terminal-based collaboration, не programmatic API. Интересен как UX reference, но не как foundation. + +--- + +## 13. Claude Octopus + +- **Repo:** https://github.com/nyldn/claude-octopus +- **Stars:** 2,069 +- **Language:** Shell +- **Created:** 2026-01-15 + +### Что это +Multi-LLM orchestration plugin для Claude Code. 8 providers, consensus gates. + +### Inter-Agent Communication +- 75% consensus gate — providers должны согласиться +- Parallel (research), sequential (problem scoping), adversarial (review) modes + +### Multi-Provider Support +- Codex, Gemini, Claude, Perplexity, OpenRouter, Copilot, Qwen, Ollama + +### Вердикт +Plugin для Claude Code, не standalone orchestrator. Consensus mechanism — интересно, но это не direct messaging. + +--- + +## Сравнительная таблица: типы Inter-Agent Communication + +| Pattern | Tools | Описание | +|---------|-------|----------| +| **SQLite Inbox/Mail** | CAO, Overstory, mcp_agent_mail | Асинхронная доставка через SQLite, FIFO, typed messages | +| **Event Bus** | ORCH | Typed events, pub/sub, decoupled | +| **AMP Protocol** | AI Maestro | Email-like, priorities, crypto signatures, mesh network | +| **Hooks/File-based** | hcom | File watches + hooks для inter-terminal messaging | +| **Orchestrator Routing** | Composio AO | Central agent роутит feedback, не P2P | +| **Fan-out/Aggregate** | MCO, Claude Octopus | Dispatch same task, collect results — не communication | +| **Session Switching** | agtx, ccb | Context handoff между sessions — implicit communication | + +--- + +## Ключевые выводы + +### 1. Kanban есть ТОЛЬКО у AI Maestro +Из всех исследованных инструментов, только AI Maestro (556 stars) имеет полноценный Kanban board с drag-and-drop. Это подтверждает нашу уникальность. Также agtx имеет kanban-like TUI, но без GUI. + +### 2. Реальный P2P inter-agent messaging — редкость +Большинство инструментов используют hub-and-spoke (orchestrator в центре). Реальный P2P: +- **Overstory** — SQLite mail с typed protocol +- **CAO** — SQLite inbox + Send Message +- **AI Maestro** — AMP protocol + mesh +- **hcom** — hooks-based messaging +- **mcp_agent_mail** — MCP inbox/outbox + +### 3. Ни один инструмент не является зрелым фундаментом +- Все проекты < 6 месяцев (кроме Ruflo и CAO) +- API быстро меняются +- Большинство зависят на tmux +- Нет production-grade error handling + +### 4. Наш подход (Claude Code Agent Teams + Electron UI) остается уникальным +- **Inbox-based messaging** через файлы — мы уже реализовали +- **Kanban board** — мы единственные с полноценным GUI +- **Electron app** — никто больше не делает desktop app для agent orchestration (кроме parallel-code) +- **Team lifecycle management** — наш уровень detail (config.json, session management, DM) не имеет аналогов + +### 5. Что стоит изучить/заимствовать + +| Идея | Источник | Применимость для нас | +|------|----------|---------------------| +| SQLite mail protocol messages (8 types) | Overstory | Можно формализовать наши inbox message types | +| File leases для conflict prevention | mcp_agent_mail | Полезно для multi-agent file editing | +| AMP protocol (priorities, signatures) | AI Maestro | Можно добавить priorities в наш inbox | +| Event bus architecture | ORCH | Для decoupled communication в Electron | +| Self-improvement loop | Composio AO | Agent learning from past sessions | +| Consensus gates | Claude Octopus | Multi-provider code review | +| Pluggable AgentRuntime interface | Overstory | Для будущей multi-provider поддержки | + +--- + +## Рекомендация + +**НЕ использовать ни один из этих инструментов как фундамент.** Причины: + +1. **Наш стек уникален** (Electron + React + TypeScript + Zustand) — ни один tool не совместим +2. **Наша архитектура inbox messaging уже работает** и протестирована +3. **Kanban board** — наше ключевое преимущество, которого нет у конкурентов +4. **Зрелость кода** у всех инструментов низкая (< 6 месяцев) +5. **Dependency risk** — tmux, Bun, Python, Rust — чужой стек + +**Что имеет смысл:** +- Изучить **Overstory** как reference для typed protocol messages +- Изучить **mcp_agent_mail** для file lease механизма +- Изучить **AI Maestro** как ближайшего конкурента (Kanban + AMP) +- Следить за **CAO (AWS)** — AWS backing значит долгосрочную поддержку +- Рассмотреть **AgentRuntime interface** из Overstory для будущей multi-provider поддержки + +--- + +## Источники + +- [CAS - codingagentsystem/cas](https://github.com/codingagentsystem/cas) +- [CAS Website](https://cas.dev/) +- [AWS CLI Agent Orchestrator](https://github.com/awslabs/cli-agent-orchestrator) +- [AWS Blog - Introducing CAO](https://aws.amazon.com/blogs/opensource/introducing-cli-agent-orchestrator-transforming-developer-cli-tools-into-a-multi-agent-powerhouse/) +- [CAO Message Queueing - DeepWiki](https://deepwiki.com/awslabs/cli-agent-orchestrator/3.4-message-queueing-and-inbox-system) +- [Overstory](https://github.com/jayminwest/overstory) +- [Composio Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) +- [hcom](https://github.com/aannoo/hcom) +- [AI Maestro](https://github.com/23blocks-OS/ai-maestro) +- [AMP Protocol](https://github.com/agentmessaging/protocol) +- [ORCH](https://www.orch.one/) +- [MCO](https://github.com/mco-org/mco) +- [Ruflo](https://github.com/ruvnet/ruflo) +- [mcp_agent_mail](https://github.com/Dicklesworthstone/mcp_agent_mail) +- [agtx](https://github.com/fynnfluegge/agtx) +- [claude_code_bridge](https://github.com/bfly123/claude_code_bridge) +- [Claude Octopus](https://github.com/nyldn/claude-octopus) +- [parallel-code](https://github.com/johannesjo/parallel-code) +- [MetaSwarm](https://github.com/dsifry/metaswarm) +- [kodo](https://github.com/ikamensh/kodo) +- [Awesome Agent Orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) +- [Zed Editor - External Agents / ACP](https://zed.dev/docs/ai/external-agents) diff --git a/docs/research/opencode-deep-dive.md b/docs/research/opencode-deep-dive.md new file mode 100644 index 00000000..4bdeb4c4 --- /dev/null +++ b/docs/research/opencode-deep-dive.md @@ -0,0 +1,480 @@ +# OpenCode Deep Dive — Comprehensive Analysis (March 2026) + +## Executive Summary + +OpenCode — open-source AI coding agent от Anomaly (ex-SST), ~126K GitHub stars, 800+ контрибьюторов, 5M MAU. Написан на TypeScript (Bun) + Go (TUI), MIT license. Поддерживает 75+ LLM-провайдеров через Models.dev. Архитектура client/server с persistent sessions. Agent Teams — community-implemented (не core), file-based JSONL inbox, peer-to-peer messaging, multi-provider teams (Claude+Codex+Gemini доказано). Главный конкурент Claude Code в terminal-agent пространстве. + +**Claim verification:** +- "95K+ stars" — **занижено**, на март 2026 ~126-129K stars +- "75+ providers" — **подтверждено**, через Models.dev + AI SDK +- "Multi-agent team support" — **частично**: agent teams реализованы community (opencode-ensemble plugin + PR #12730-12732), НЕ core feature, но доказали работу Claude+Codex+Gemini вместе + +--- + +## 1. What IS OpenCode? + +### Основные факты + +| Параметр | Значение | +|----------|----------| +| **Название** | OpenCode | +| **Организация** | Anomaly (ex-SST / Serverless Stack) | +| **GitHub** | [anomalyco/opencode](https://github.com/anomalyco/opencode) | +| **Сайт** | [opencode.ai](https://opencode.ai/) | +| **Stars** | ~126-129K (март 2026) | +| **Contributors** | 800+ | +| **Commits** | 10,000+ | +| **MAU** | 5M+ developers | +| **License** | MIT | +| **Языки** | TypeScript (Bun) — backend, Go — TUI, Zig — OpenTUI core | +| **Дата запуска** | 19 июня 2025 | +| **Версии** | Terminal CLI, Desktop App (beta), IDE extensions | + +### Кто создал + +**Founders:** +1. **Jay V (CEO)** — задает стратегию, enterprise sales. Университет Waterloo. +2. **Frank Wang (CTO)** — техническая архитектура. Model-agnostic дизайн с нуля. Университет Waterloo. +3. **Dax Raad** — public face, подкасты, Twitter. Ex-Amazon, ex-Ironbay. Присоединился к SST в 2021. +4. **Adam Elmore** — AWS Hero, indie hacker, AWS FM podcast host. + +**Происхождение:** Jay и Frank создали Anomaly, затем Serverless Stack (SST) — прошли Y Combinator, привлекли инвестиции от основателей PayPal, LinkedIn, Yelp, YouTube. SST набрал 25K stars, стал прибыльным в 2025. Во время SST команда строила terminal-first UIs и даже запустила Terminal — подписку на кофе через терминал ($100K продаж в первый год). + +### Скандальная история: Fork и Split (2025) + +- Оригинальный OpenCode создал **Kujtim Hoxha** на Go с Bubble Tea TUI +- **Charm** (компания, создатель Bubble Tea) приобрела проект, наняла Kujtim +- Dax Raad и Adam Doty (из SST) были major contributors, им не понравился ход +- Обвинения: Charm переписал git history, удалил контрибуции, забанил критиков +- **Результат:** Charm переименовал свою версию в **Crush**, а Dax/Adam сохранили бренд OpenCode под SST (anomalyco) +- Fork полностью переписан с Go на **TypeScript + Bun** для использования Vercel AI SDK + +### Скандал с Anthropic (январь 2026) + +- Ранние версии OpenCode подделывали HTTP-заголовок `claude-code-20250219`, выдавая себя за Claude Code +- 9 января 2026 Anthropic заблокировал сторонние tools от использования Claude OAuth +- 19 февраля 2026 Anthropic обновил Terms of Service, запретив OAuth токены Free/Pro/Max в third-party tools +- OpenCode удалил весь Claude OAuth код в тот же день +- Запустили **OpenCode Zen** (pay-as-you-go gateway) и **OpenCode Black** ($200/мес, enterprise) +- **18,000 новых stars за 2 недели** — controversy привлекла внимание + +--- + +## 2. Поддержка 75+ провайдеров + +**Подтверждено.** OpenCode использует [Models.dev](https://models.dev) + Vercel AI SDK для поддержки 75+ LLM-провайдеров. + +### Ключевые провайдеры + +| Провайдер | Детали | +|-----------|--------| +| OpenAI (GPT, Codex) | API key | +| Anthropic (Claude) | API key (после блокировки OAuth) | +| Google Gemini | API key + Vertex AI | +| AWS Bedrock | IAM credentials | +| Groq | API key | +| Azure OpenAI | Enterprise endpoint | +| OpenRouter | Pre-loaded models | +| Ollama (local) | `opencode --model ollama/qwen2.5-coder:32b` | +| GitHub Copilot | Copilot subscription (Pro+ для некоторых моделей) | +| ChatGPT Plus/Pro | OAuth login | +| Cloudflare AI Gateway | Unified billing, no per-provider keys | +| SAP AI Core | 40+ models, enterprise platform | +| GitLab | Agent Platform (18.8+) | +| Deepseek | API key | +| Local models | Any OpenAI-compatible endpoint | + +### Как это работает + +``` +User → OpenCode → AI SDK → Models.dev → Provider API → LLM Response +``` + +- Models.dev — реестр моделей с метаданными +- AI SDK (от Vercel) — универсальный SDK для вызова разных провайдеров +- `/connect` команда — добавление credentials +- `/models` команда — список доступных моделей +- Config: можно назначить разные модели для разных agent-ролей (plan vs build) + +### Монетизация через провайдеров + +| Tier | Цена | Описание | +|------|-------|----------| +| Free | $0 | BYO API key или local models (Ollama) | +| OpenCode Zen | Pay-per-token | Curated gateway, pass-through pricing | +| OpenCode Black | $200/мес | Enterprise, multi-provider (sold out) | + +--- + +## 3. Agent Teams: Multi-Agent Support + +### Статус: Community-Implemented, NOT Core Feature + +Важное уточнение: Agent Teams в OpenCode — это **community contribution**, а не встроенная core-фича (в отличие от Claude Code). + +- **GitHub Issue [#12661](https://github.com/anomalyco/opencode/issues/12661)** — Feature request для native agent teams +- **PRs #12730-12732** (dev branch) — community implementation (core, tools & routes, TUI) +- **[opencode-ensemble](https://github.com/hueyexe/opencode-ensemble)** — SDK plugin для agent teams +- **[opencode-workspace](https://github.com/kdcokenny/opencode-workspace)** — multi-agent orchestration harness + +### Архитектура Agent Teams (community implementation) + +#### Messaging: Two-Layer System + +``` +Layer 1: Inbox (Source of Truth) + team_inbox///.jsonl + Каждая строка: { id, from, text, timestamp, read } + +Layer 2: Session Injection (Delivery) + Message → injected as synthetic user message → LLM видит и обрабатывает +``` + +**Ключевые отличия от Claude Code:** + +| Аспект | Claude Code | OpenCode | +|--------|-------------|----------| +| Storage | JSON array (O(N) writes) | JSONL append-only (O(1)) | +| Messaging | Polling JSON files | Event-driven auto-wake | +| Communication | Leader-centric routing | Full mesh peer-to-peer | +| Multi-model | Single provider only | Multiple providers per team | +| Process model | 3 backends (in-process, tmux, iTerm2) | Single process | +| State tracking | Implicit | Two-level state machines | + +#### State Machines (Dual) + +**Member Status (5 states):** `ready` → `busy` → `shutdown_requested` → `shutdown` (terminal), `error` +- Guards: `guard: true` (prevents race conditions), `force: true` (crash recovery) + +**Execution Status (10 states):** Fine-grained prompt loop position tracking + +#### Peer-to-Peer Messaging + +Любой teammate может отправить сообщение любому другому по имени — не только через lead. Lead фокусируется на orchestration, а не routing. + +#### Sub-Agent Isolation + +Team tools (`team_create`, `team_spawn`, `team_message`) запрещены для sub-agents через deny rules + tool visibility hiding. Sub-agents — одноразовые workers, их output не должен попадать в coordination channel. + +### Доказано: Claude + Codex + Gemini в одной команде + +**Тест 1: Architecture Drama (3 провайдера)** +- GPT-5.3 Codex + Gemini 2.5 Pro + Claude Sonnet 4 +- Координация через один message bus +- Claiming tasks из shared list +- "Arguing about architecture" через peer-to-peer messaging + +**Тест 2: Super Bowl Prediction (4 Claude Opus)** +- Stats analyst + Betting analyst + Matchup analyst + Injury scout +- Full-mesh topology +- Atomic task claiming под concurrent access + +**Тест 3: NFL Research (2 Gemini)** +- Обнаружена проблема: Gemini генерировал ~50 одинаковых "task complete" сообщений в цикле + +### Ограничения + +- Agent teams пока на dev branch, не в stable release +- Нет multi-caller support в core — субагент не знает, кто с ним говорит (кроме Parent) +- Gemini имеет проблемы с message loop +- Recovery при crash: нет auto-restart, user должен re-engage teammates + +--- + +## 4. Architecture Deep Dive + +### Двухъязычная система + +``` +┌──────────────────────────────────────────────┐ +│ User runs `opencode` │ +│ (single Bun-compiled binary) │ +└──────────────────┬───────────────────────────┘ + │ + ┌────────▼─────────┐ + │ Bun Process │ + │ (TypeScript) │──── HTTP Server (API + SSE events) + │ - LLM calls │ ▲ + │ - Tool exec │ │ OpenAPI SDK + │ - Sessions │ │ (auto-generated by Stainless) + │ - LSP client │ ┌────┴──────┐ + │ - Plugin system │ │ Go TUI │ + │ - MCP client │ │ (Client) │ + └──────────────────┘ └────────────┘ + │ + (Migrating to OpenTUI: + Zig core + React/Solid/Vue) +``` + +### Backend (TypeScript + Bun) + +- **Runtime:** Bun (fast JavaScript runtime) +- **Build:** `bun build .. --compile` — single executable +- **HTTP Server:** API + SSE events для real-time updates +- **Storage:** SQLite для persistent data +- **LLM Communication:** Через Vercel AI SDK +- **Tool Execution:** LLM решает когда вызвать tool, SDK вызывает `execute` функцию +- **LSP Integration:** Отправляет `textDocument/didChange`, получает diagnostics, кормит LLM +- **40+ event types:** Через GlobalBus, доставка через SSE + +### Frontend (Go TUI → OpenTUI) + +- **Текущий:** Go с Bubble Tea framework +- **Мигрирует на:** [OpenTUI](https://github.com/anomalyco/opentui) — Zig core + TypeScript bindings +- **OpenTUI:** React/SolidJS/Vue reconcilers, Bun exclusive (Node/Deno в процессе) +- **Persistent sessions:** Сервер в background, TUI реконнектится после disconnect/sleep + +### Client-Server Protocol + +- **OpenAPI spec** → auto-generated SDK через Stainless +- **3 official SDKs:** TypeScript, Go, Python +- **SSE** для real-time events (40+ event types) +- **Zero dependencies** в SDK + +### Desktop App + +- Beta на macOS, Windows, Linux +- Также есть community [OpenGUI](https://dev.to/akemmanuel/i-built-a-native-desktop-gui-for-opencode-in-4-days-with-ai-p44) — Electron + React + +### IDE Extensions + +VS Code, Cursor, Zed, Windsurf, VSCodium + GitHub и GitLab integrations. + +--- + +## 5. Built-in Tools + +| Tool | Описание | +|------|----------| +| Shell | Выполнение bash команд | +| Edit | Exact string replacement в файлах | +| Write | Создание/перезапись файлов | +| Read | Чтение файлов | +| Grep | Regex поиск по codebase | +| LSP | Code intelligence: definitions, references, hover, call hierarchy | + +### Agents + +| Agent | Доступ | Назначение | +|-------|--------|------------| +| **Build** (default) | Full access | Development work | +| **Plan** | Read-only | Analysis, planning | +| **Review** | Read-only + docs | Code review | +| **Debug** | Bash + Read | Investigation | +| **Docs** | File ops, no shell | Documentation | +| **@general** | Subagent | Complex search/multistep | + +--- + +## 6. MCP Support + +**Полная поддержка MCP как client.** Feature request для MCP server mode ([#3306](https://github.com/sst/opencode/issues/3306)). + +### Типы MCP серверов + +1. **Local MCP Servers** — stdio-based communication, запускаются как local processes +2. **Remote MCP Servers** — HTTP + OAuth 2.0 (Dynamic Client Registration RFC 7591) + +### Конфигурация + +```json +// opencode.json +{ + "mcp": { + "sentry": { + "command": "npx", + "args": ["@sentry/mcp-server"], + "env": { "SENTRY_AUTH_TOKEN": "{env:SENTRY_TOKEN}" } + } + } +} +``` + +- Поддержка `{env:VAR}` и `{file:path}` для секретов +- `enabled: false` для временного отключения +- Auto-OAuth flow для remote servers +- Tools автоматически доступны LLM наряду с built-in tools + +### Предупреждение о Context + +MCP tools добавляют контекст. GitHub MCP server, например, может быстро превысить context limit. Рекомендуется осторожность при выборе MCP серверов. + +--- + +## 7. Plugin System & Extensibility + +### Plugin Sources + +1. **Directory plugins:** `.opencode/plugins/` (project) или `~/.config/opencode/plugins/` (global) +2. **NPM packages:** в opencode.json, auto-install через Bun + +### Hook Types + +| Hook | Описание | +|------|----------| +| `tool.execute.before/after` | Перехват tool calls | +| `session.created/updated` | Session lifecycle | +| `message.*` | Message events | +| `event` | System events (`session.idle`, `session.created`, etc.) | +| `experimental.session.compacting` | Inject context before compaction | +| `chat.message` | Modify messages before LLM | + +### Custom Tools + +Plugin tools можно определить — они доступны LLM наряду с built-in tools. Если имя совпадает с built-in, plugin tool имеет приоритет. + +### Notable Community Plugins + +- **EnvSitter** — блокирует чтение `.env*` файлов +- **Agent Ensemble** — agent teams orchestration +- **Persistent Memory** — self-editable memory blocks (как Letta) +- **Annotation UI** — перехватывает plan mode, открывает browser UI +- **Worktree Isolation** — git worktree per agent + +--- + +## 8. What Can We Learn From It? + +### Архитектурные идеи для Claude Agent Teams UI + +1. **Client/Server разделение** — persistent sessions, reconnect после disconnect. Наш Electron-подход можно дополнить server mode для remote access. + +2. **JSONL append-only inbox** — O(1) writes vs O(N) JSON array. **Мы уже используем JSONL для session files**, но team inbox в Claude Code — JSON array. Можно предложить Anthropic JSONL формат. + +3. **Event-driven vs Polling** — OpenCode использует SSE + event bus вместо file polling. Мы используем file watching с debounce (100ms). Event-driven подход быстрее и чище. + +4. **Peer-to-Peer messaging** — в Claude Code все идет через lead. OpenCode показывает, что full-mesh topology работает. **Мы уже отключили relay для teammate DMs** (см. CLAUDE.md), что близко к peer-to-peer. + +5. **Two-level state machines** — member status (coarse) + execution status (fine). Может улучшить наш UI для отображения состояния agents. + +6. **Plugin system** — hooks для tool.execute, session events, compaction. Потенциал для нашего MCP integration. + +7. **Multi-provider teams** — самая уникальная фича. Claude Code не может этого. Для нашего UI это не актуально (мы визуализируем Claude Code teams), но показывает направление рынка. + +8. **Auto-wake** — когда teammate отправляет сообщение idle agent'у, он автоматически "просыпается". В Claude Code нужен manual re-engage. + +--- + +## 9. Competitor or Integration Partner? + +### Как конкурент нашему продукту + +| Аспект | OpenCode | Claude Agent Teams UI (мы) | +|--------|----------|---------------------------| +| **Что это** | Coding agent | UI для управления agent teams | +| **Kanban** | Нет (только community: opencode-kanban, VibeKanban) | Встроенный kanban board | +| **Code Review** | Нет diff view в TUI | Diff view per task | +| **Team Management** | CLI-based, нет visual management | Visual kanban + real-time status | +| **Notifications** | Нет | Встроенные уведомления | +| **Session Analysis** | Базовый | Deep analysis (bash, reasoning, subagents) | +| **Context Monitoring** | Нет | Token usage по категориям | +| **Direct Messaging** | Через CLI | Visual DM interface | + +**Вывод: OpenCode — НЕ прямой конкурент.** Они coding agent, мы — UI для управления agent teams. OpenCode больше конкурирует с Claude Code CLI, а не с нашим UI. + +### Как потенциальный integration partner + +OpenCode имеет **полноценный SDK** (TypeScript, Go, Python) и **SSE events**. Теоретически мы могли бы: + +1. **Добавить OpenCode backend** — управлять OpenCode sessions через их SDK вместо/параллельно Claude Code +2. **Визуализировать OpenCode teams** — их agent teams используют JSONL inbox, мы могли бы парсить +3. **Multi-agent kanban** — один kanban для Claude Code + OpenCode agents +4. **Cross-provider orchestration** — использовать наш UI для управления mixed teams (Claude через Claude Code, GPT/Gemini через OpenCode) + +**Риски интеграции:** +- OpenCode agent teams — community feature, не stable API +- Совершенно другая архитектура (HTTP SDK vs CLI process management) +- Потребуется значительная работа по адаптации + +--- + +## 10. Unique Features vs Claude Code + +| Feature | OpenCode | Claude Code | +|---------|----------|-------------| +| **Model freedom** | 75+ providers, local models | Only Anthropic | +| **Open source** | MIT license, full source | Closed source | +| **Desktop app** | Beta (macOS/Win/Linux) | Нет | +| **IDE extensions** | VS Code, Cursor, Zed, Windsurf | Нет (только CLI) | +| **Plugin system** | Hooks, custom tools, npm plugins | Hooks (bash-based) | +| **Persistent sessions** | Client/server, reconnect | Нет | +| **Agent types** | Build/Plan/Review/Debug/Docs + custom | One agent + subagents | +| **SDK** | TypeScript/Go/Python, OpenAPI spec | Нет public SDK | +| **LSP integration** | Built-in, feeds diagnostics to LLM | Нет | +| **Agent Teams** | Community (multi-provider!) | Native (single provider) | +| **Context compaction** | Supports plugin hook | Automatic | +| **Pricing** | Free + BYO API key | $20/mo Claude Pro minimum | +| **Accuracy** | Varies by model | SWE-bench Pro 57.5% | +| **Adoption** | 5M MAU, 126K stars | 4% of GitHub commits, 135K/day | + +### Что уникально у OpenCode + +1. **Model agnosticism** — designed from day one, не afterthought +2. **Client/server architecture** — sessions persist, remote control possible +3. **Multi-provider agent teams** — Claude+Codex+Gemini в одной команде +4. **Plugin ecosystem** — rich hook system, npm packages, custom tools +5. **3 official SDKs** — TypeScript, Go, Python +6. **OpenTUI** — собственный TUI framework на Zig + +### Что уникально у Claude Code (и у нас) + +1. **Native agent teams** — core feature, не community plugin +2. **SWE-bench accuracy** — лучшие результаты на бенчмарках +3. **4% GitHub commits** — доминирует в реальном использовании +4. **Stream-json protocol** — надежный IPC для agent coordination +5. **Kanban board** (наш UI) — НИКТО не имеет визуального kanban для agent teams + +--- + +## Summary & Key Takeaways + +### Факты (verified) + +- OpenCode — реальный и крупный проект: ~126-129K stars, 800+ contributors, 5M MAU +- 75+ providers — подтверждено через Models.dev + AI SDK +- MIT license — подтверждено +- Agent teams с multi-provider — доказано (community implementation) +- TypeScript (Bun) + Go + Zig architecture — подтверждено +- MCP client support — полноценный +- Desktop app + IDE extensions — beta, но работает +- Plugin system — rich, с hooks и custom tools + +### Риски и concerns + +- Agent teams — community feature, не stable, на dev branch +- Скандал с Anthropic OAuth — показывает этические вопросы +- Fork controversy — community split может повлиять на долгосрочную стабильность +- Gemini message loop bug — multi-provider teams нестабильны +- OpenCode Black ($200/мес) sold out — бизнес-модель не ясна + +### Relevance для нашего продукта + +- **Прямая конкуренция: НЕТ** — мы UI для team management, они coding agent +- **Косвенная конкуренция: ДА** — community tools (opencode-kanban, VibeKanban) пытаются решить ту же проблему +- **Потенциал интеграции: СРЕДНИЙ** — SDK доступен, но архитектура сильно отличается +- **Наше преимущество сохраняется:** Kanban board для agent teams нет НИ У КОГО, включая OpenCode + +--- + +## Sources + +- [anomalyco/opencode (GitHub)](https://github.com/anomalyco/opencode) +- [opencode.ai](https://opencode.ai/) +- [OpenCode Docs](https://opencode.ai/docs/) +- [Building Agent Teams in OpenCode (DEV Community)](https://dev.to/uenyioha/porting-claude-codes-agent-teams-to-opencode-4hol) +- [How OpenCode went from zero to titan (Dev Genius)](https://blog.devgenius.io/how-opencode-went-from-zero-to-titan-in-eight-months-dcdcd8ff5572) +- [OpenCode background story (TFN)](https://techfundingnews.com/opencode-the-background-story-on-the-most-popular-open-source-coding-agent-in-the-world/) +- [How Coding Agents Actually Work: Inside OpenCode](https://cefboud.com/posts/coding-agents-internals-opencode-deepdive/) +- [OpenCode vs Claude Code (MorphLLM)](https://www.morphllm.com/comparisons/opencode-vs-claude-code) +- [OpenCode vs Claude Code (DataCamp)](https://www.datacamp.com/blog/opencode-vs-claude-code) +- [OpenCode vs Anthropic Legal Controversy](https://www.shareuhack.com/en/posts/opencode-anthropic-legal-controversy-2026) +- [OpenCode MCP Servers docs](https://opencode.ai/docs/mcp-servers/) +- [OpenCode Plugins docs](https://opencode.ai/docs/plugins/) +- [OpenCode Agents docs](https://opencode.ai/docs/agents/) +- [OpenCode Models docs](https://opencode.ai/docs/models/) +- [OpenCode Providers docs](https://opencode.ai/docs/providers/) +- [OpenTUI (GitHub)](https://github.com/anomalyco/opentui) +- [opencode-ensemble (GitHub)](https://github.com/hueyexe/opencode-ensemble) +- [opencode-kanban (GitHub)](https://github.com/qrafty-ai/opencode-kanban) +- [Vibe Kanban](https://vibekanban.com/) +- [awesome-opencode (GitHub)](https://github.com/awesome-opencode/awesome-opencode) diff --git a/docs/research/orchestrator-as-foundation.md b/docs/research/orchestrator-as-foundation.md new file mode 100644 index 00000000..db3bc2e1 --- /dev/null +++ b/docs/research/orchestrator-as-foundation.md @@ -0,0 +1,284 @@ +# Оценка: внешний оркестратор как фундамент вместо собственного agent management + +**Дата**: 2026-03-25 +**Вопрос**: стоит ли взять готовый multi-agent оркестратор и посадить наш Electron UI сверху, вместо того чтобы развивать собственный TeamProvisioningService? + +--- + +## 1. Что мы бы заменяли (наш текущий стек) + +### Собственная инфраструктура + +| Компонент | Файлы | LOC | Что делает | +|-----------|-------|-----|------------| +| `TeamProvisioningService.ts` | 1 | ~8000 | Полный lifecycle команды: создание, запуск, stream-json протокол, preflight, stdin relay, tool approval, stall detection, cross-team messaging | +| `agent-teams-controller/` | ~20 модулей | ~4050 | Kanban store, task management, review workflow, cross-team protocol, runtime helpers, message store, process store | +| Остальные team сервисы | 38 файлов | ~13200 | TeamConfigReader, TeamInboxReader/Writer, TeamTaskReader/Writer, TeamKanbanManager, TeamMcpConfigBuilder, CascadeGuard, CrossTeamService, ReviewApplier, MemberStatsComputer, TeamBackupService и др. | +| `childProcess.ts` | 1 | ~220 | spawnCli/execCli с Windows fallback, process tree kill | +| MCP server tools | 8 файлов | ~500 | taskTools, kanbanTools, reviewTools, messageTools, processTools, runtimeTools, crossTeamTools | +| **ИТОГО** | ~68 файлов | **~26000 LOC** | | + +### Ключевые точки spawn (spawnCli вызовы) + +- `TeamProvisioningService.ts` — 4 точки: create team, launch team, launch member, DM relay +- `CliInstallerService.ts` — install CLI +- `ScheduledTaskExecutor.ts` — scheduled tasks +- `McpHealthDiagnosticsService.ts`, `PluginInstallService.ts`, `McpInstallService.ts` — execCli для MCP/plugin операций + +### Что уникально в нашей реализации + +1. **stream-json протокол** — двусторонний, lead читает stdin, teammates читают inbox +2. **Tool approval system** — перехват tool_use запросов, auto-approve по правилам, UI промпт +3. **Cross-team communication** — structured TaskRef, inbox files, cross-team MCP tools +4. **Kanban + code review** — 5-column board, diff view, approve/request_changes workflow +5. **MCP config builder** — передача `--mcp-config` с наследованием для teammates +6. **SIGKILL-only kill** — предотвращение cleanup CLI, который удаляет team файлы +7. **Context monitoring** — token usage tracking по категориям + +--- + +## 2. Оценка кандидатов + +### 2.1 MCO (mco-org/mco) + +**GitHub**: https://github.com/mco-org/mco +**Stars**: ~249 | **Лицензия**: MIT | **Язык**: TypeScript (CLI) +**npm**: `@tt-a1i/mco` + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **НЕТ** — только CLI. Нет programmatic API для import. | +| Inter-agent communication? | Частично — агенты диспатчат задачи через MCO CLI, но нет inbox/messaging системы | +| MCP поддержка? | Да — может работать как MCP server | +| Что бы мы СОХРАНИЛИ? | Всё UI, kanban, review, context tracking | +| Что бы мы ЗАМЕНИЛИ? | Только dispatch логику (4 spawnCli точки), и то частично | +| Effort интеграции | **Высокий** — MCO не даёт API, пришлось бы обёртывать CLI вызовы | +| Риск зависимости | **Средний** — 249 stars, 1 основной автор | + +**Вердикт**: MCO решает другую задачу (dispatch к разным CLI), а не управление командой. У нас уже есть более продвинутая система. +- Надёжность решения: **3/10** +- Уверенность в оценке: **8/10** + +--- + +### 2.2 Overstory (jayminwest/overstory) + +**GitHub**: https://github.com/jayminwest/overstory +**Stars**: ~1100 | **Лицензия**: MIT | **Язык**: TypeScript (Bun-native) +**npm**: `@os-eco/overstory-cli` + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | Частично — есть pluggable AgentRuntime интерфейс, но **ТРЕБУЕТ BUN** (не Node.js/Electron) | +| Inter-agent communication? | **Да** — SQLite mail system, 8 типов сообщений, WAL mode, broadcast | +| MCP поддержка? | Упоминается, но без деталей | +| Зависимости | **Bun v1.0+, tmux, git** — все три обязательны | +| Что бы мы СОХРАНИЛИ? | UI, kanban, review, context tracking, MCP server | +| Что бы мы ЗАМЕНИЛИ? | TeamProvisioningService, inbox system, process management | +| Effort интеграции | **КРИТИЧЕСКИЙ** — Bun runtime несовместим с Electron (Node.js). Потребуется форк или полный переписывание на Node.js | +| Риск зависимости | **Высокий** — 1 автор, Bun lock-in, tmux dependency | + +**Вердикт**: Архитектурно интересен (SQLite mail, pluggable runtimes, watchdog), но **Bun dependency — dealbreaker** для Electron-приложения. tmux dependency тоже проблема (нет на Windows). +- Надёжность решения: **2/10** +- Уверенность в оценке: **9/10** + +--- + +### 2.3 ComposioHQ/agent-orchestrator + +**GitHub**: https://github.com/ComposioHQ/agent-orchestrator +**Stars**: ~5400 | **Лицензия**: MIT | **Язык**: TypeScript (pnpm monorepo) +**npm**: `@composio/ao` (CLI) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **Условно** — monorepo с packages, но core не опубликован как отдельный npm пакет `@composio/ao-core`. Нет документации по programmatic API. | +| Inter-agent communication? | **Нет прямой** — агенты изолированы в worktrees, координация через dashboard/server | +| MCP поддержка? | Не упоминается | +| Зависимости | tmux, Next.js dashboard (порт 3000) | +| Plugin architecture | **Сильная** — 8 swappable slots (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Lifecycle) | +| Что бы мы СОХРАНИЛИ? | Kanban UI, review, context tracking, cross-team messaging, MCP tools | +| Что бы мы ЗАМЕНИЛИ? | Process spawning, workspace isolation | +| Effort интеграции | **Высокий** — нет published API, потребуется fork monorepo, вырезание Next.js dashboard, адаптация под Electron IPC | +| Риск зависимости | **Средний** — 5.4K stars, Composio (коммерческая компания) за спиной, но agent-orchestrator =/= их core business | + +**Вердикт**: Самый продвинутый из кандидатов. Plugin architecture — то что нужно. НО: нет published programmatic API, нет inter-agent messaging (мы это уже имеем), требует tmux, и dashboard на Next.js конфликтует с нашим Electron. Интеграция = фактически форк. +- Надёжность решения: **4/10** +- Уверенность в оценке: **8/10** + +--- + +### 2.4 MetaSwarm (dsifry/metaswarm) + +**GitHub**: https://github.com/dsifry/metaswarm +**Stars**: ~148 | **Лицензия**: MIT | **Язык**: TypeScript/JS (skills + commands) +**npm**: `metaswarm` (npx installer) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **НЕТ** — это framework из skills/commands/hooks, инжектируемый в CLAUDE.md. Не importable. | +| Inter-agent communication? | Через Claude Code Team Mode (нативный) | +| MCP поддержка? | Нет собственной — использует нативный Claude Code | +| Что бы мы ЗАМЕНИЛИ? | Ничего — это workflow methodology, не runtime | +| Effort интеграции | **Неприменимо** — это не backend, это набор CLAUDE.md инструкций и скриптов | +| Риск зависимости | **Высокий** — 148 stars, 2 контрибьютора, последний коммит feb 2026 | + +**Вердикт**: MetaSwarm — это не оркестратор в техническом смысле. Это structured workflow (skills, personas, phases), который инжектируется в prompt. Не подходит как backend. +- Надёжность решения: **1/10** +- Уверенность в оценке: **9/10** + +--- + +### 2.5 ORCH (oxgeneral/ORCH) + +**GitHub**: https://github.com/oxgeneral/ORCH (404 на момент проверки, возможно приватный) +**Website**: https://www.orch.one +**Stars**: Неизвестно | **Лицензия**: MIT | **Язык**: TypeScript +**npm**: `@oxgeneral/orch` (GitHub Packages registry) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **НЕТ** — CLI-only (`npm i -g @oxgeneral/orch`). Нет programmatic API. | +| Inter-agent communication? | **Да** — direct messaging + broadcast + shared key-value store | +| MCP поддержка? | Не упоминается | +| State machine | task states: todo → in_progress → review → done | +| Зависимости | git worktrees, TUI (терминал) | +| Что бы мы ЗАМЕНИЛИ? | Process management, state machine, messaging | +| Effort интеграции | **Высокий** — CLI-only, GitHub Packages registry (не стандартный npm), GitHub 404 | +| Риск зависимости | **КРИТИЧЕСКИЙ** — GitHub repo недоступен (404), 1 автор | + +**Вердикт**: Архитектурно интересен (DDD, state machine, 987 тестов), но **GitHub repo 404** — красный флаг. CLI-only без programmatic API. Нельзя рассматривать как зависимость. +- Надёжность решения: **1/10** +- Уверенность в оценке: **7/10** (7 потому что repo недоступен, не можем полноценно проверить) + +--- + +### 2.6 conductor-oss (charannyk06/conductor-oss) + +**GitHub**: https://github.com/charannyk06/conductor-oss +**Stars**: ~14 | **Лицензия**: MIT | **Язык**: Rust (backend) + TypeScript (Next.js frontend) +**Adapters**: 10 (Claude Code, Codex, Gemini, Qwen, Amp, Cursor CLI, OpenCode, Droid, Copilot, CCR) + +| Критерий | Оценка | +|----------|--------| +| Используется как библиотека? | **Частично** — Rust binary + HTTP API (port 4747). Можно использовать API. | +| Inter-agent communication? | Через orchestrator server | +| MCP поддержка? | **Да** — `mcp-server` команда, stdio transport | +| Dashboard | Next.js (порт 3000) — конфликт с нашим Electron | +| Что бы мы ЗАМЕНИЛИ? | Agent spawning, workspace isolation, adapter layer | +| Что бы мы СОХРАНИЛИ? | Всё UI, kanban, review, messaging, context tracking | +| Effort интеграции | **Средний-Высокий** — HTTP API есть, но Rust binary нужно распространять с Electron, Next.js dashboard лишний | +| Риск зависимости | **КРИТИЧЕСКИЙ** — 14 stars, 1 автор, Rust dependency в TypeScript/Electron проекте | + +**Вердикт**: HTTP API делает интеграцию возможной, 10 адаптеров впечатляют. НО: 14 stars, Rust sidecar binary для Electron — серьёзная сложность в packaging и distribution. Проект слишком молодой. +- Надёжность решения: **2/10** +- Уверенность в оценке: **8/10** + +--- + +## 3. Сводная таблица + +| Оркестратор | Stars | Library? | Inter-agent msg | MCP | Electron-совместим | Effort | Риск | +|-------------|-------|----------|-----------------|-----|-------------------|--------|------| +| **MCO** | 249 | CLI only | Partial | Yes | Partial | High | Medium | +| **Overstory** | 1.1K | Bun only | SQLite mail | Partial | **NO (Bun)** | Critical | High | +| **Composio AO** | 5.4K | No published API | No direct | No | Partial (tmux) | High | Medium | +| **MetaSwarm** | 148 | No (skills) | Native CC | No | N/A | N/A | High | +| **ORCH** | ? | CLI only | Yes | No | No (TUI) | High | **Critical** | +| **conductor-oss** | 14 | HTTP API | Via server | Yes | Partial (Rust) | Medium-High | **Critical** | + +--- + +## 4. Что конкретно нам дал бы внешний оркестратор + +### Потенциальная ценность +1. **Multi-runtime support** — запуск не только Claude Code, но и Codex, Gemini, Aider +2. **Git worktree isolation** — у нас нет, но и не нужен (Claude Code сам управляет файлами) +3. **Adapters pattern** — абстракция спавна разных CLI + +### Что мы УЖЕ имеем и ни один оркестратор не даёт +1. **stream-json bidirectional protocol** — уникальная интеграция с Claude Code Agent Teams +2. **Tool approval UI** — перехват и approve/reject tool calls в реальном времени +3. **Cross-team structured messaging** — TaskRef, zero-width metadata encoding +4. **Kanban с code review** — diff view, approve/request_changes per task +5. **Context monitoring** — 6-category token tracking +6. **MCP server with 7 tool groups** — kanban, tasks, review, messages, processes, runtime, cross-team +7. **Post-compact context recovery** — восстановление инструкций после compaction +8. **SIGKILL team kill protocol** — предотвращение file cleanup +9. **Cascading guard** — предотвращение cascade team deletion + +--- + +## 5. Ключевой вопрос: стоит ли зависеть от внешнего оркестратора? + +### Аргументы ЗА интеграцию +- Multi-runtime support (Codex, Gemini, Aider) без написания адаптеров +- Потенциально меньше кода для поддержки +- Community contributions и bug fixes + +### Аргументы ПРОТИВ (перевешивают) + +1. **Ни один оркестратор не имеет programmatic library API для embedding в Electron** + - Все либо CLI-only, либо CLI + собственный dashboard + - Интеграция = обёртка над CLI вызовами или fork — то есть по сути мы сами пишем адаптер + +2. **Наша интеграция глубже любого оркестратора** + - stream-json протокол, tool approval, cross-team refs — этого нет НИ У КОГО + - Мы бы потеряли эти фичи при переходе на внешний backend + +3. **Несовместимость со стеком** + - Bun (Overstory) vs Node.js/Electron + - Rust sidecar (conductor-oss) — packaging nightmare + - tmux (Composio, Overstory) — нет на Windows + - Next.js dashboards — дублирование с нашим Electron UI + +4. **Стоимость интеграции >= стоимость написания адаптера** + - Даже для лучшего кандидата (Composio AO) нужен fork monorepo + выпиливание Next.js + адаптация под IPC + - Это ~2-4 недели работы с непредсказуемым результатом + - Наш собственный adapter layer для нового CLI = ~200-500 LOC (1-2 дня) + +5. **Риск зависимости** + - Большинство проектов < 6 месяцев, 1-2 автора + - Composio AO (5.4K stars) — самый живой, но agent-orchestrator != core business Composio + - Если проект умирает — мы на форке без community + +--- + +## 6. Рекомендация + +### Вердикт: **НЕ ИСПОЛЬЗОВАТЬ внешний оркестратор как фундамент** + +Стоимость интеграции выше, чем написание собственного тонкого adapter layer. + +### Что стоит ЗАИМСТВОВАТЬ (паттерны, не код) + +| Паттерн | Источник | Применение у нас | +|---------|----------|-----------------| +| Plugin architecture (8 slots) | Composio AO | Вынести agent adapter в интерфейс `AgentRuntime` для будущей multi-runtime поддержки | +| SQLite mail system | Overstory | Рассмотреть для замены JSON inbox files (производительность) | +| State machine для tasks | ORCH | У нас уже есть kanban states, но можно формализовать transitions | +| AgentRuntime interface | Overstory | `{ spawn, configure, detectReadiness, parseTranscript }` — хороший контракт | +| Tiered watchdog | Overstory | Stall detection → AI triage → monitor agent | + +### Рекомендуемый план + +1. **Сейчас**: оставить текущую архитектуру, она работает и покрывает наш use case +2. **Если нужен multi-runtime**: написать `AgentRuntime` интерфейс (~200 LOC) + адаптер для каждого CLI (~300-500 LOC) +3. **Если нужна масштабируемость messaging**: рассмотреть миграцию с JSON inbox → SQLite WAL +4. **Мониторить** Composio AO — если опубликуют `@composio/ao-core` как npm library с programmatic API, пересмотреть решение + +- **Надёжность рекомендации**: 8/10 +- **Уверенность**: 9/10 + +--- + +## Источники + +- [MCO (mco-org/mco)](https://github.com/mco-org/mco) — 249 stars, CLI-only orchestrator +- [Overstory (jayminwest/overstory)](https://github.com/jayminwest/overstory) — 1.1K stars, Bun + SQLite + tmux +- [Composio Agent Orchestrator](https://github.com/ComposioHQ/agent-orchestrator) — 5.4K stars, plugin architecture +- [MetaSwarm (dsifry/metaswarm)](https://github.com/dsifry/metaswarm) — 148 stars, workflow framework +- [ORCH (orch.one)](https://www.orch.one/) — CLI runtime, GitHub repo 404 +- [conductor-oss (markdown-native)](https://github.com/charannyk06/conductor-oss) — 14 stars, Rust + 10 adapters +- [Awesome Agent Orchestrators](https://github.com/andyrewlee/awesome-agent-orchestrators) — каталог 80+ инструментов +- [Composio Architecture Design](https://github.com/ComposioHQ/agent-orchestrator/blob/main/artifacts/architecture-design.md) diff --git a/docs/research/sdk-vs-cli-comparison.md b/docs/research/sdk-vs-cli-comparison.md new file mode 100644 index 00000000..5a0e8020 --- /dev/null +++ b/docs/research/sdk-vs-cli-comparison.md @@ -0,0 +1,376 @@ +# SDK vs CLI Direct Spawn: Honest Comparison + +**Date:** 2026-03-25 +**Status:** Research complete +**Verdict:** SDKs are NOT limiting — they ARE the CLI with a nicer API, plus extras. But there are real tradeoffs. + +--- + +## TL;DR + +All three SDKs (Claude Agent SDK, Codex SDK, Gemini CLI SDK) **spawn the CLI as a child process** under the hood and communicate via stdin/stdout JSON protocol. The SDK IS the CLI — it just wraps `child_process.spawn()` with a typed API. There is **no functional limitation** vs direct spawn, because the SDK literally does the same thing. However, there is a **real performance overhead** (~12s per `query()` call for Claude) and some **CLI-only features** (Agent Teams for Claude) that require workarounds. + +--- + +## 1. Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) + +### Architecture: How It Works Under the Hood + +The SDK **bundles `cli.js`** directly inside the npm package. When you call `query()`, it spawns a Node.js process running this bundled CLI with `--input-format stream-json --output-format stream-json --verbose`. Communication is via NDJSON over stdin/stdout. + +> "The SDK code actually bundles a cli.js file directly — which contains the entire Claude Code CLI." +> — [Claude Agent SDK Pitfalls](https://liruifengv.com/posts/claude-agent-sdk-pitfalls-en/) + +**Key insight:** The `spawnClaudeCodeProcess` option lets you provide a completely custom spawn function. Node's `ChildProcess` already satisfies the `SpawnedProcess` interface. This means you can override HOW the CLI is spawned — Docker, VM, remote, whatever. + +### Complete Options Reference (from [official docs](https://platform.claude.com/docs/en/agent-sdk/typescript)) + +| Option | Type | Description | +|--------|------|-------------| +| `model` | `string` | Claude model to use | +| `cwd` | `string` | Working directory | +| `env` | `Record` | Environment variables | +| `systemPrompt` | `string \| preset` | Custom or `claude_code` preset | +| `allowedTools` | `string[]` | Auto-approve tools | +| `disallowedTools` | `string[]` | Deny tools (checked first, overrides everything) | +| `mcpServers` | `Record` | MCP server configs (stdio, SSE, HTTP, in-process SDK) | +| `strictMcpConfig` | `boolean` | Only use MCP servers from this config | +| `settingSources` | `SettingSource[]` | `["user", "project", "local"]` to match CLI behavior | +| `permissionMode` | `PermissionMode` | `default`, `acceptEdits`, `bypassPermissions`, `plan`, `dontAsk` | +| `canUseTool` | `Function` | Custom permission callback | +| `agents` | `Record` | Programmatic subagents | +| `hooks` | `Partial>` | Programmatic hook callbacks | +| `plugins` | `SdkPluginConfig[]` | Load custom plugins | +| `maxTurns` | `number` | Max agentic turns | +| `maxBudgetUsd` | `number` | Budget cap | +| `effort` | `'low'\|'medium'\|'high'\|'max'` | Thinking depth | +| `thinking` | `ThinkingConfig` | Adaptive thinking config | +| `betas` | `SdkBeta[]` | Beta features (e.g., `context-1m-2025-08-07`) | +| `includePartialMessages` | `boolean` | Stream partial responses | +| `outputFormat` | `{ type: 'json_schema', schema }` | Structured output | +| `spawnClaudeCodeProcess` | `Function` | Custom spawn function | +| `pathToClaudeCodeExecutable` | `string` | Custom CLI path | +| `executable` | `'bun'\|'deno'\|'node'` | JS runtime | +| `executableArgs` | `string[]` | Runtime args | +| **`extraArgs`** | **`Record`** | **ANY arbitrary CLI flags** | +| `debug` | `boolean` | Debug mode | +| `debugFile` | `string` | Debug log file | +| `sandbox` | `SandboxSettings` | Sandbox config | +| `persistSession` | `boolean` | Disable session persistence | +| `resume` | `string` | Resume session by ID | +| `forkSession` | `boolean` | Fork on resume | +| `enableFileCheckpointing` | `boolean` | File change tracking | +| `fallbackModel` | `string` | Fallback model | +| `promptSuggestions` | `boolean` | Emit prompt suggestions | +| `stderr` | `Function` | Stderr callback | + +### Feature Comparison: SDK vs CLI Direct + +| Feature | CLI Direct | SDK | Notes | +|---------|-----------|-----|-------| +| MCP config | `--mcp-config '{...}'` | `mcpServers: {...}` | SDK has typed config + in-process MCP servers (SDK advantage) | +| Strict MCP | `--strict-mcp-config` | `strictMcpConfig: true` | Equivalent | +| Disallowed tools | `--disallowedTools X,Y` | `disallowedTools: ['X','Y']` | Equivalent. Known bug: both ignore MCP tools in `-p` mode ([#12863](https://github.com/anthropics/claude-code/issues/12863)) | +| Allowed tools | `--allowedTools X,Y` | `allowedTools: ['X','Y']` | Equivalent | +| stream-json I/O | `--input-format stream-json --output-format stream-json` | Automatic (SDK default) | SDK uses this internally, no config needed | +| Permission mode | `--permission-mode X` | `permissionMode: 'X'` | Equivalent | +| Custom flags | Any `--flag value` | `extraArgs: { flag: 'value' }` | **SDK supports arbitrary flags via `extraArgs`** | +| CLAUDE.md | Auto-loaded | `settingSources: ['project']` | Opt-in in SDK, auto in CLI | +| Custom spawn | Manual `child_process.spawn()` | `spawnClaudeCodeProcess: (opts) => spawn(...)` | SDK provides typed interface | +| In-process MCP | Not possible | `createSdkMcpServer()` | **SDK-only advantage** — no subprocess overhead | +| Custom tools | Via MCP only | In-process functions | **SDK-only advantage** | +| Programmatic hooks | Via config files | Callback functions | **SDK-only advantage** | +| Programmatic subagents | Via config files | `agents: {...}` inline | **SDK-only advantage** | +| Agent Teams | Full support | **CLI-only feature** | Not configurable via SDK options. Must use CLI | +| Auto memory | Full support | **Never loaded by SDK** | CLI-only feature | +| Skills | Full support | Via `settingSources` + `allowedTools: ['Skill']` | Equivalent when configured | +| Session resume | `claude --resume ID` | `resume: 'sessionId'` | Equivalent | +| Streaming | Via flags | `includePartialMessages: true` | SDK provides typed events | +| Structured output | Not available | `outputFormat: { type: 'json_schema', ... }` | **SDK-only advantage** | +| File checkpointing | Not available | `enableFileCheckpointing: true` | **SDK-only advantage** | +| V2 Session API | Not available | `unstable_v2_*` | **SDK-only**, unstable | + +### Performance: ~12s Overhead Per `query()` Call + +**This is real and documented.** Each `query()` call spawns a new CLI process, which takes ~12s to initialize. + +> "The Claude Agent SDK `query()` has ~12s overhead per call — no hot process reuse" +> — [GitHub Issue #34](https://github.com/anthropics/claude-agent-sdk-typescript/issues/34) + +For comparison, direct Anthropic Messages API: 1-3s. Previous SDK versions: ~40s (improved 70%). + +**But for our use case (long-running agent sessions), this doesn't matter.** We spawn teams that run for minutes/hours. 12s startup is amortized. If you need sub-second responses, use the Anthropic API directly — not the SDK and not CLI direct. + +**Direct CLI spawn has the SAME overhead** — the 12s is the CLI initialization time, not SDK overhead. SDK adds negligible wrapper cost on top. + +### SDK-Only Features (Not Available in CLI Direct) + +1. **In-process MCP servers** — `createSdkMcpServer()`, no subprocess management +2. **Custom tools as functions** — No separate MCP server needed +3. **Programmatic hooks** — TypeScript/Python callbacks, not shell scripts +4. **Structured output** — JSON schema for typed responses +5. **File checkpointing** — Rewind file changes to any point +6. **Typed message stream** — `SDKMessage` union type with discriminators +7. **Dynamic MCP management** — `setMcpServers()`, `toggleMcpServer()`, `reconnectMcpServer()` +8. **Prompt suggestions** — AI-generated next prompt +9. **Permission callbacks** — `canUseTool()` with structured decisions + +### CLI-Only Features (Not Available in SDK) + +1. **Agent Teams** — Multiple coordinated sessions (our core feature!) +2. **Auto memory** — `~/.claude/projects/*/memory/` persistence +3. **Interactive TUI** — Terminal UI + +### Critical Finding for Our Project + +**Agent Teams are CLI-only.** The official docs explicitly state: +> "Agent teams are a CLI feature where one session acts as the team lead, coordinating work across independent teammates." +> — [Claude Code Features in SDK](https://platform.claude.com/docs/en/agent-sdk/claude-code-features) + +This means for our team management feature, we **MUST** use CLI direct (which we already do). The SDK cannot replace our current architecture for teams. + +However, for solo agents or subagent workflows, the SDK provides a better API. + +--- + +## 2. Codex SDK (`@openai/codex-sdk`) + +### Architecture + +> "The TypeScript SDK wraps the Codex CLI from `@openai/codex`. It spawns the CLI and exchanges JSONL events over stdin/stdout." +> — [Codex SDK docs](https://developers.openai.com/codex/sdk) + +Same pattern as Claude: SDK spawns CLI subprocess. + +### API Surface + +```typescript +const codex = new Codex({ + env?: Record, // Environment variables + baseUrl?: string, // API base URL (→ --config openai_base_url=...) + config?: Record // Arbitrary config (→ --config key=value) +}); + +const thread = codex.startThread({ + workingDirectory?: string, + skipGitRepoCheck?: boolean +}); + +// Buffered +const result = await thread.run(prompt, { outputSchema?: JSONSchema }); + +// Streaming +for await (const event of thread.runStreamed(prompt)) { ... } + +// Resume +const thread = codex.resumeThread(threadId); +``` + +### Feature Comparison: SDK vs CLI Direct + +| Feature | CLI Direct | SDK | Notes | +|---------|-----------|-----|-------| +| MCP config | `config.toml` / `codex mcp` | Via `config` option passthrough | CLI manages MCP directly | +| Custom flags | Any flag | `config: { key: value }` → `--config key=value` | Limited to config passthrough | +| Model selection | `/model` command | Not directly exposed | Must use config | +| Approval modes | `--full-auto`, etc. | Not directly exposed | Must use config or env | +| Structured output | Not in interactive mode | `outputSchema` (Zod → JSON Schema) | **SDK advantage** | +| Thread persistence | `codex resume` | `resumeThread(threadId)` | Equivalent | +| Streaming | JSONL stdout | `runStreamed()` async generator | SDK provides typed events | +| Multimodal input | Screenshots, sketches | `{ type: 'local_image', path }` | Equivalent | +| Performance | Baseline (CLI init) | Same + minimal SDK overhead | No significant difference | + +### Native SDK Alternative: `@codex-native/sdk` + +There's a Rust-based alternative via napi-rs that **does NOT spawn child processes**: + +> "The Native SDK provides Rust-powered bindings via napi-rs, giving you direct access to Codex functionality without spawning child processes." +> — [@codex-native/sdk npm](https://www.npmjs.com/package/@codex-native/sdk) + +Full API compatibility with the TypeScript SDK, but with native performance. However, only 33 weekly downloads — practically nobody uses it. + +### Known Issues + +- **Windows spawn EPERM** — CLI fails on Windows ([#7810](https://github.com/openai/codex/issues/7810)) +- **Zombie MCP processes** — 1,300+ zombies, 37GB memory leak ([#12491](https://github.com/openai/codex/issues/12491)) +- **Subagents experimental** — Gated behind `features.multi_agent` flag + +--- + +## 3. Gemini CLI SDK (`@google/gemini-cli-sdk`) + +### Architecture + +Monorepo with three packages: +- `@google/gemini-cli` — Bundled single executable (CLI) +- `@google/gemini-cli-core` — Core logic, API orchestration, tool execution +- `@google/gemini-cli-sdk` — Programmatic API layer over core + +**Key difference from Claude/Codex:** The Gemini SDK **does NOT spawn CLI as subprocess**. It uses `@google/gemini-cli-core` directly as a library. This is architecturally different — the SDK calls core functions in-process. + +### API Surface + +```typescript +// Agent-based API +const agent = new GeminiCliAgent(definition: LocalAgentDefinition); +// Includes: model config, tools, system instructions + +// Session management +const session = new GeminiCliSession(context: AgentLoopContext); + +// Activity monitoring +agent.onActivity((activity) => { ... }); +``` + +### Feature Comparison: SDK vs CLI Direct + +| Feature | CLI Direct | SDK | Notes | +|---------|-----------|-----|-------| +| MCP support | `config.toml` | Via core ToolRegistry | Same underlying system | +| Custom tools | Via MCP servers | Via ToolRegistry + custom definitions | SDK has more direct access | +| Model routing | Auto fallback | Via ModelConfig | Same capability | +| Hooks | Shell scripts | Programmatic callbacks | SDK advantage | +| Sandboxing | Built-in | Via SandboxManager | Same capability | +| Output format | `--output-format json/stream-json` | Typed events via callbacks | SDK provides typed events | +| Extensions | Plugin architecture | Same plugin system | Equivalent | +| Agent Skills | Custom skills | Custom skills | Equivalent | +| Performance | Baseline | **No subprocess — in-process** | **SDK is faster** | +| Abort support | Ctrl+C | Limited — aborted requests continue ([known issue](https://github.com/google-gemini/gemini-cli/issues/15539)) | CLI wins here | +| Checkpointing | Automatic snapshots | Via SDK session | Equivalent | + +### Maturity + +The SDK was introduced in v0.30.0. GitHub issue [#15539](https://github.com/google-gemini/gemini-cli/issues/15539) requesting a formal SDK is now **CLOSED as completed**. The core API surface is still evolving — `@google/gemini-cli-core` includes "robust compatibility measures" suggesting instability. + +--- + +## 4. Cross-SDK Comparison Matrix + +| Dimension | Claude Agent SDK | Codex SDK | Gemini CLI SDK | +|-----------|-----------------|-----------|----------------| +| **Architecture** | Spawns CLI subprocess | Spawns CLI subprocess | In-process (uses core directly) | +| **Startup overhead** | ~12s per query() | Unknown (similar pattern) | Minimal (no subprocess) | +| **CLI flag passthrough** | `extraArgs` for ANY flag | `config` for config flags | N/A (not subprocess-based) | +| **MCP support** | Full (stdio, SSE, HTTP, in-process) | Full (stdio, HTTP) | Full (via ToolRegistry) | +| **In-process tools** | `createSdkMcpServer()` | Custom tool registration | ToolRegistry | +| **Structured output** | JSON Schema | JSON Schema (Zod) | Zod schemas | +| **Agent teams** | CLI-only | N/A | N/A | +| **Subagents** | Programmatic + filesystem | Experimental | Via LocalAgentExecutor | +| **Streaming** | AsyncGenerator | AsyncGenerator events | onActivity callback | +| **Custom spawn** | `spawnClaudeCodeProcess` | Not exposed | N/A (no subprocess) | +| **Session resume** | Full (resume, fork) | Full (resumeThread) | Via GeminiCliSession | +| **Hooks** | Programmatic callbacks | Not documented | Programmatic callbacks | +| **License** | Proprietary | Open source (Apache 2.0) | Open source (Apache 2.0) | +| **npm weekly DL** | High (official Anthropic) | High (official OpenAI) | Medium (newer) | +| **Maturity** | Production (v0.2.81) | Production | Early (v0.30.0+) | + +--- + +## 5. Key Questions Answered + +### Q1: Does Claude Agent SDK support ALL CLI flags? + +**YES.** Via `extraArgs: Record` you can pass ANY arbitrary flag. Plus most important flags have dedicated typed options (`mcpServers`, `disallowedTools`, `allowedTools`, `permissionMode`, etc.). + +### Q2: Does Codex SDK expose all CLI capabilities? + +**Partially.** The `config` option can pass arbitrary config values, but not all CLI flags are exposed as typed options. The API surface is minimal compared to Claude's SDK. + +### Q3: Does Gemini CLI SDK expose all CLI capabilities? + +**Mostly.** Since it uses `@google/gemini-cli-core` directly (not subprocess), it has access to all internal APIs. But the public SDK surface is still maturing. + +### Q4: Is there a performance overhead? + +**Claude/Codex: YES — ~12s startup per query() call.** This is the CLI initialization time, not SDK overhead. Direct spawn has the same cost. + +**Gemini: NO additional overhead** — in-process architecture, no subprocess. + +### Q5: Can we pass arbitrary flags through the SDK? + +- **Claude:** YES, via `extraArgs` +- **Codex:** Partially, via `config` (maps to `--config key=value`) +- **Gemini:** N/A (not subprocess-based) + +### Q6: Does the SDK actually spawn the CLI? + +- **Claude:** YES — spawns bundled `cli.js` via `child_process` +- **Codex:** YES — spawns CLI and exchanges JSONL over stdin/stdout +- **Gemini:** NO — uses core library in-process + +### Q7: What happens when a new CLI flag is added? + +- **Claude:** `extraArgs` passes ANY flag through immediately. No SDK update needed. +- **Codex:** May need SDK update for new flags not covered by `config` +- **Gemini:** Core library update needed, but it's the same package ecosystem + +### Q8: Can we use SDK and direct CLI interchangeably? + +**YES.** They are not mutually exclusive. Use SDK for simple flows, CLI direct for advanced (Agent Teams, etc.). Both produce the same session files, use the same auth, same MCP servers. + +--- + +## 6. Verdict for Our Project (Claude Agent Teams UI) + +### What We Need + +1. **Agent Teams** (lead + teammates, stream-json, inbox messaging) — **CLI-only** +2. **MCP config passthrough** (`--mcp-config`, `--strict-mcp-config`) — **Both work** +3. **Disallowed tools** (`--disallowedTools`) — **Both work** +4. **stream-json stdin/stdout** — **SDK uses this internally, CLI direct also works** +5. **Custom spawn control** (Electron, process management) — **Both work** +6. **Long-running sessions** (teams run for hours) — **Both work, 12s overhead irrelevant** + +### Recommendation + +| Use Case | Approach | Confidence | +|----------|----------|------------| +| Agent Teams (lead + teammates) | **CLI direct spawn** (current) | 10/10 — SDK cannot do this | +| Solo agent mode | **Either works**, SDK is nicer | 9/10 | +| Future multi-provider support | **CLI direct for each** | 8/10 — more flexible | +| Subagent orchestration | **SDK preferred** (typed subagents) | 8/10 | + +### Final Assessment + +**The concern about SDKs being "less flexible" is UNFOUNDED for most use cases.** The SDKs provide typed access to everything the CLI does, plus extras (in-process MCP, programmatic hooks, structured output). The `extraArgs` option in Claude SDK means you're never blocked by missing typed options. + +**The concern about SDKs being "slower" is VALID but IRRELEVANT for long-running agents.** The ~12s startup overhead is the CLI itself, not the SDK wrapper. Direct spawn has the same cost. + +**The ONE real limitation: Agent Teams are CLI-only.** Since our core feature IS Agent Teams, we MUST use CLI direct for team management. This is not a limitation of "SDKs in general" — it's a specific architectural decision by Anthropic. + +### Hybrid Approach (Best of Both Worlds) + +``` +Agent Teams → CLI direct spawn (mandatory) +Solo agents → SDK query() (nicer API, typed, in-process MCP) +Subagents → SDK agents option (programmatic, isolated) +Multi-provider → CLI direct for each provider +``` + +Reliability: 9/10 +Confidence: 9/10 + +--- + +## Sources + +- [Claude Agent SDK TypeScript Reference](https://platform.claude.com/docs/en/agent-sdk/typescript) +- [Claude Code Features in SDK](https://platform.claude.com/docs/en/agent-sdk/claude-code-features) +- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/agent-sdk/overview) +- [Claude Agent SDK MCP](https://platform.claude.com/docs/en/agent-sdk/mcp) +- [Claude Agent SDK Performance Issue #34](https://github.com/anthropics/claude-agent-sdk-typescript/issues/34) +- [Claude Agent SDK Custom Spawn #103](https://github.com/anthropics/claude-agent-sdk-typescript/issues/103) +- [Claude Agent SDK Pitfalls](https://liruifengv.com/posts/claude-agent-sdk-pitfalls-en/) +- [Claude Agent SDK vs CLI System Prompts](https://github.com/shanraisshan/claude-code-best-practice/blob/main/reports/claude-agent-sdk-vs-cli-system-prompts.md) +- [Claude Code vs Claude Agent SDK (Medium)](https://drlee.io/claude-code-vs-claude-agent-sdk-whats-the-difference-177971c442a9) +- [--disallowedTools MCP bug #12863](https://github.com/anthropics/claude-code/issues/12863) +- [Codex SDK Documentation](https://developers.openai.com/codex/sdk) +- [Codex CLI Documentation](https://developers.openai.com/codex/cli) +- [Codex MCP Support](https://developers.openai.com/codex/mcp) +- [@codex-native/sdk npm](https://www.npmjs.com/package/@codex-native/sdk) +- [Codex Zombie Process Bug #12491](https://github.com/openai/codex/issues/12491) +- [Gemini CLI SDK DeepWiki](https://deepwiki.com/google-gemini/gemini-cli/5.9-sdk-and-programmatic-api) +- [Gemini CLI Formal SDK Request #15539](https://github.com/google-gemini/gemini-cli/issues/15539) +- [Gemini CLI npm Package](https://geminicli.com/docs/npm/) +- [Making Claude Agents Run Faster](https://medium.com/@bayllama/making-your-agents-built-using-claude-agent-sdk-run-faster-2f2526a5cb42) +- [Building Agents with Claude Agent SDK (Anthropic)](https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk) From 507bf798eb284f0bfb9c396a043f51745fbf16dc Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 17:52:39 +0200 Subject: [PATCH 025/113] improvement(task-change): improve task change presence tracking and related IPC handlers - Added support for tracking task change presence with new IPC channels: TEAM_GET_TASK_CHANGE_PRESENCE and TEAM_SET_CHANGE_PRESENCE_TRACKING. - Introduced JsonTaskChangePresenceRepository and TeamLogSourceTracker to manage task change presence data. - Enhanced ChangeExtractorService to utilize task change presence services for improved task change detection. - Updated TeamDataService to integrate task change presence tracking and resolve task change presence states. - Modified UI components to reflect task change presence status in Kanban and task detail views. This feature aims to provide real-time insights into task changes, enhancing user experience and task management capabilities. --- electron.vite.config.ts | 3 +- src/main/index.ts | 11 +- src/main/ipc/teams.ts | 38 + .../services/team/ChangeExtractorService.ts | 932 +++--------------- src/main/services/team/TaskChangeComputer.ts | 706 +++++++++++++ .../services/team/TaskChangeWorkerClient.ts | 267 +++++ src/main/services/team/TeamDataService.ts | 167 +++- .../services/team/TeamLogSourceTracker.ts | 361 +++++++ .../services/team/TeamMemberLogsFinder.ts | 26 + .../cache/JsonTaskChangePresenceRepository.ts | 140 +++ .../cache/TaskChangePresenceRepository.ts | 23 + .../cache/taskChangePresenceCacheSchema.ts | 107 ++ .../cache/taskChangePresenceCacheTypes.ts | 22 + src/main/services/team/index.ts | 1 + .../services/team/taskChangePresenceUtils.ts | 152 +++ .../services/team/taskChangeWorkerTypes.ts | 49 + src/main/utils/pathDecoder.ts | 4 + src/main/workers/task-change-worker.ts | 40 + src/preload/constants/ipcChannels.ts | 6 + src/preload/index.ts | 12 + src/renderer/api/httpClient.ts | 8 + .../components/layout/TeamTabSectionNav.tsx | 8 +- .../team/dialogs/TaskDetailDialog.tsx | 54 +- .../components/team/kanban/KanbanBoard.tsx | 36 +- .../components/team/kanban/KanbanTaskCard.tsx | 522 +++++----- src/renderer/store/index.ts | 154 +++ .../store/slices/changeReviewSlice.ts | 101 +- src/renderer/store/slices/teamSlice.ts | 155 ++- src/shared/types/api.ts | 3 + src/shared/types/team.ts | 5 + test/main/ipc/teams.test.ts | 32 + .../team/ChangeExtractorService.test.ts | 404 +++++++- .../team/TaskChangeWorkerClient.test.ts | 255 +++++ .../services/team/TeamDataService.test.ts | 283 ++++++ test/renderer/store/changeReviewSlice.test.ts | 238 +++++ .../renderer/store/teamChangeThrottle.test.ts | 143 +++ test/renderer/store/teamSlice.test.ts | 30 +- 37 files changed, 4362 insertions(+), 1136 deletions(-) create mode 100644 src/main/services/team/TaskChangeComputer.ts create mode 100644 src/main/services/team/TaskChangeWorkerClient.ts create mode 100644 src/main/services/team/TeamLogSourceTracker.ts create mode 100644 src/main/services/team/cache/JsonTaskChangePresenceRepository.ts create mode 100644 src/main/services/team/cache/TaskChangePresenceRepository.ts create mode 100644 src/main/services/team/cache/taskChangePresenceCacheSchema.ts create mode 100644 src/main/services/team/cache/taskChangePresenceCacheTypes.ts create mode 100644 src/main/services/team/taskChangePresenceUtils.ts create mode 100644 src/main/services/team/taskChangeWorkerTypes.ts create mode 100644 src/main/workers/task-change-worker.ts create mode 100644 test/main/services/team/TaskChangeWorkerClient.test.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f9121fca..2eb209d8 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -76,7 +76,8 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/main/index.ts'), - 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts') + 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts'), + 'task-change-worker': resolve(__dirname, 'src/main/workers/task-change-worker.ts') }, output: { // CJS format so bundled deps can use __dirname/require. diff --git a/src/main/index.ts b/src/main/index.ts index b6916518..e8378d75 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; +import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { CONTEXT_CHANGED, @@ -104,6 +105,7 @@ import { TaskBoundaryParser, TeamDataService, TeamMemberLogsFinder, + TeamLogSourceTracker, TeamProvisioningService, UpdaterService, } from './services'; @@ -780,9 +782,13 @@ function initializeServices(): void { teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request)); const teamMemberLogsFinder = new TeamMemberLogsFinder(); + const taskChangePresenceRepository = new JsonTaskChangePresenceRepository(); + const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder); const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder); const taskBoundaryParser = new TaskBoundaryParser(); const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser); + teamDataService.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); + changeExtractor.setTaskChangePresenceServices(taskChangePresenceRepository, teamLogSourceTracker); const gitDiffFallback = new GitDiffFallback(); const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const reviewApplier = new ReviewApplierService(); @@ -839,6 +845,7 @@ function initializeServices(): void { httpServer?.broadcast('team-change', event); }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); + teamLogSourceTracker.setEmitter(teamChangeEmitter); // Allow SchedulerService to push schedule events to renderer schedulerService.setChangeEmitter((event) => { @@ -1321,7 +1328,9 @@ function createWindow(): void { markRendererUnavailable(mainWindow); const activeContext = contextRegistry.getActive(); activeContext?.stopFileWatcher(); - scheduleRendererRecovery(mainWindow); + if (mainWindow) { + scheduleRendererRecovery(mainWindow); + } }); // Set main window reference for notification manager and updater diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 1b12e3b9..c3814203 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -19,6 +19,7 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -46,6 +47,7 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -306,6 +308,8 @@ export function initializeTeamHandlers( export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LIST, handleListTeams); ipcMain.handle(TEAM_GET_DATA, handleGetData); + ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence); + ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); ipcMain.handle(TEAM_CREATE, handleCreateTeam); @@ -368,6 +372,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LIST); ipcMain.removeHandler(TEAM_GET_DATA); + ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE); + ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); ipcMain.removeHandler(TEAM_CREATE); @@ -613,6 +619,38 @@ async function handleGetData( return { success: true, data: { ...data, isAlive, messages: merged } }; } +async function handleGetTaskChangePresence( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise>> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + + return wrapTeamHandler('getTaskChangePresence', () => + getTeamDataService().getTaskChangePresence(validated.value!) + ); +} + +async function handleSetChangePresenceTracking( + _event: IpcMainInvokeEvent, + teamName: unknown, + enabled: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + if (typeof enabled !== 'boolean') { + return { success: false, error: 'enabled must be a boolean' }; + } + + return wrapTeamHandler('setChangePresenceTracking', async () => { + getTeamDataService().setTaskChangePresenceTracking(validated.value!, enabled); + }); +} + async function handleDeleteTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index fe63d20d..116918a7 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -6,27 +6,29 @@ import { type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; import { createHash } from 'crypto'; -import { createReadStream } from 'fs'; import { readFile, stat } from 'fs/promises'; import * as path from 'path'; -import * as readline from 'readline'; +import { TaskChangeComputer } from './TaskChangeComputer'; +import { TaskChangeWorkerClient, getTaskChangeWorkerClient } from './TaskChangeWorkerClient'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; import { TeamConfigReader } from './TeamConfigReader'; -import { countLineChanges } from './UnifiedLineCounter'; +import { + buildTaskChangePresenceDescriptor, + computeTaskChangePresenceProjectFingerprint, + normalizeTaskChangePresenceFilePath, +} from './taskChangePresenceUtils'; +import { + type ResolvedTaskChangeComputeInput, + type TaskChangeEffectiveOptions, + type TaskChangeTaskMeta, +} from './taskChangeWorkerTypes'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; -import type { - AgentChangeSet, - ChangeStats, - FileChangeSummary, - FileEditEvent, - FileEditTimeline, - SnippetDiff, - TaskChangeScope, - TaskChangeSetV2, -} from '@shared/types'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; +import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); @@ -42,13 +44,6 @@ interface TaskChangeSummaryCacheEntry { expiresAt: number; } -interface ParsedSnippetsCacheEntry { - data: SnippetDiff[]; - mtime: number; - expiresAt: number; -} - -/** Ссылка на JSONL файл с привязкой к memberName */ interface LogFileRef { filePath: string; memberName: string; @@ -60,22 +55,34 @@ export class ChangeExtractorService { private taskChangeSummaryInFlight = new Map>(); private taskChangeSummaryVersionByTask = new Map(); private taskChangeSummaryValidationInFlight = new Set(); - private parsedSnippetsCache = new Map(); private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk private readonly taskChangeSummaryCacheTtl = 60 * 1000; private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000; private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000; private readonly maxTaskChangeSummaryCacheEntries = 200; - private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets private readonly isPersistedTaskChangeCacheEnabled = process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0'; + private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; + private teamLogSourceTracker: TeamLogSourceTracker | null = null; + private readonly taskChangeComputer: TaskChangeComputer; constructor( private readonly logsFinder: TeamMemberLogsFinder, - private readonly boundaryParser: TaskBoundaryParser, + boundaryParser: TaskBoundaryParser, private readonly configReader: TeamConfigReader = new TeamConfigReader(), - private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository() - ) {} + private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository(), + private readonly taskChangeWorkerClient: TaskChangeWorkerClient = getTaskChangeWorkerClient() + ) { + this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser); + } + + setTaskChangePresenceServices( + repository: TaskChangePresenceRepository, + tracker: TeamLogSourceTracker + ): void { + this.taskChangePresenceRepository = repository; + this.teamLogSourceTracker = tracker; + } /** Получить все изменения агента */ async getAgentChanges(teamName: string, memberName: string): Promise { @@ -85,37 +92,12 @@ export class ChangeExtractorService { return cached.data; } - const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); const projectPath = await this.resolveProjectPath(teamName); - - // Собираем все snippets из всех JSONL файлов параллельно - const parseResults = await this.parseJSONLFilesWithConcurrency(paths); - let latestMtime = 0; - const merged: SnippetDiff[] = []; - for (const r of parseResults) { - merged.push(...r.snippets); - if (r.mtime > latestMtime) latestMtime = r.mtime; - } - const allSnippets = this.sortSnippetsChronologically(merged); - - const files = this.aggregateByFile(allSnippets, projectPath); - - let totalLinesAdded = 0; - let totalLinesRemoved = 0; - for (const file of files) { - totalLinesAdded += file.linesAdded; - totalLinesRemoved += file.linesRemoved; - } - - const result: AgentChangeSet = { + const { result, latestMtime } = await this.taskChangeComputer.computeAgentChanges( teamName, memberName, - files, - totalLinesAdded, - totalLinesRemoved, - totalFiles: files.length, - computedAt: new Date().toISOString(), - }; + projectPath + ); this.cache.set(cacheKey, { data: result, @@ -140,14 +122,16 @@ export class ChangeExtractorService { forceFresh?: boolean; } ): Promise { + const initialVersion = this.getTaskChangeSummaryVersion(teamName, taskId); const includeDetails = options?.summaryOnly !== true; const taskMeta = await this.readTaskMeta(teamName, taskId); - const effectiveOptions = { + const effectiveOptions: TaskChangeEffectiveOptions = { owner: options?.owner ?? taskMeta?.owner, status: options?.status ?? taskMeta?.status, intervals: options?.intervals ?? taskMeta?.intervals, since: options?.since, }; + const projectPath = await this.resolveProjectPath(teamName); const effectiveStateBucket = taskMeta ? getTaskChangeStateBucket({ status: effectiveOptions.status, @@ -162,14 +146,27 @@ export class ChangeExtractorService { const summaryCacheableState = isTaskChangeSummaryCacheable(effectiveStateBucket); const shouldUseSummaryCache = !includeDetails && summaryCacheableState; + let version = initialVersion; if (!summaryCacheableState || options?.forceFresh === true) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true, }); + version = this.getTaskChangeSummaryVersion(teamName, taskId); } + const resolvedInput: ResolvedTaskChangeComputeInput = { + teamName, + taskId, + taskMeta, + effectiveOptions, + projectPath, + includeDetails, + }; + if (!shouldUseSummaryCache) { - return this.computeTaskChanges(teamName, taskId, effectiveOptions, includeDetails); + const result = await this.computeTaskChangesPreferred(resolvedInput); + await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); + return result; } const cacheKey = this.buildTaskChangeSummaryCacheKey( @@ -178,11 +175,17 @@ export class ChangeExtractorService { effectiveOptions, effectiveStateBucket ); - const version = this.getTaskChangeSummaryVersion(teamName, taskId); if (options?.forceFresh !== true) { const cached = this.taskChangeSummaryCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + cached.data + ); return cached.data; } this.taskChangeSummaryCache.delete(cacheKey); @@ -201,11 +204,18 @@ export class ChangeExtractorService { ); if (persisted) { this.setTaskChangeSummaryCache(cacheKey, persisted); + await this.recordTaskChangePresence( + teamName, + taskId, + taskMeta, + effectiveOptions, + persisted + ); return persisted; } } - const promise = this.computeTaskChanges(teamName, taskId, effectiveOptions, false) + const promise = this.computeTaskChangesPreferred({ ...resolvedInput, includeDetails: false }) .then(async (result) => { if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) { return result; @@ -220,6 +230,7 @@ export class ChangeExtractorService { result, version ); + await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result); return result; }) .finally(() => { @@ -256,101 +267,41 @@ export class ChangeExtractorService { ); } - private async computeTaskChanges( - teamName: string, - taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, - includeDetails: boolean + private async computeTaskChangesPreferred( + input: ResolvedTaskChangeComputeInput ): Promise { - const taskMeta = await this.readTaskMeta(teamName, taskId); - const logRefs = await this.logsFinder.findLogFileRefsForTask( - teamName, - taskId, - effectiveOptions - ); - if (logRefs.length === 0) { - return this.emptyTaskChangeSet(teamName, taskId); + if (!this.taskChangeWorkerClient.isAvailable()) { + return this.taskChangeComputer.computeTaskChanges(input); } - const projectPath = await this.resolveProjectPath(teamName); - - // Парсим boundaries для каждого лог-файла и ищем scope данной задачи - const allScopes: TaskChangeScope[] = []; - for (const ref of logRefs) { - const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath); - const scope = boundaries.scopes.find((s) => s.taskId === taskId); - if (scope) { - allScopes.push({ ...scope, memberName: ref.memberName }); + try { + const result = await this.taskChangeWorkerClient.computeTaskChanges(input); + if (this.isValidWorkerTaskChangeResult(result, input)) { + return result; } + logger.warn( + `Task change worker returned malformed result for ${input.teamName}/${input.taskId}; falling back inline.` + ); + } catch (error) { + logger.warn( + `Task change worker failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` + ); } - // Если scope не найден — try deterministic interval scoping, else fallback to whole file - if (allScopes.length === 0) { - const intervals = effectiveOptions.intervals; - if (Array.isArray(intervals) && intervals.length > 0) { - const { files, toolUseIds, startTimestamp, endTimestamp } = - await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails); + return this.taskChangeComputer.computeTaskChanges(input); + } - return { - teamName, - taskId, - files, - totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0), - totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0), - totalFiles: files.length, - confidence: 'medium', - computedAt: new Date().toISOString(), - scope: { - taskId, - memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '', - startLine: 0, - endLine: 0, - startTimestamp, - endTimestamp, - toolUseIds, - filePaths: files.map((f) => f.filePath), - confidence: { - tier: 2, - label: 'medium', - reason: 'Scoped by persisted task workIntervals (timestamp-based)', - }, - }, - warnings: - files.length === 0 - ? ['No file edits found within persisted workIntervals.'] - : ['Task boundaries missing — scoped by workIntervals timestamps.'], - }; - } - - return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails); - } - - const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds)); - const files = await this.extractFilteredChanges( - logRefs, - allowedToolUseIds, - projectPath, - includeDetails + private isValidWorkerTaskChangeResult( + result: TaskChangeSetV2, + input: ResolvedTaskChangeComputeInput + ): boolean { + return ( + !!result && + typeof result === 'object' && + result.teamName === input.teamName && + result.taskId === input.taskId && + Array.isArray(result.files) ); - - const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier)); - return { - teamName, - taskId, - files, - totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), - totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), - totalFiles: files.length, - confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', - computedAt: new Date().toISOString(), - scope: allScopes[0], - warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [], - }; } /** Получить краткую статистику */ @@ -366,17 +317,7 @@ export class ChangeExtractorService { // ---- Private methods ---- /** Read task metadata (owner, status) from the task JSON file */ - private async readTaskMeta( - teamName: string, - taskId: string - ): Promise<{ - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; - historyEvents?: unknown[]; - kanbanColumn?: 'review' | 'approved'; - } | null> { + private async readTaskMeta(teamName: string, taskId: string): Promise { try { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const raw = await readFile(taskPath, 'utf8'); @@ -460,606 +401,21 @@ export class ChangeExtractorService { } } - private async extractIntervalScopedChanges( - logRefs: LogFileRef[], - intervals: { startedAt: string; completedAt?: string }[], - projectPath?: string, - includeDetails = true - ): Promise<{ - files: FileChangeSummary[]; - toolUseIds: string[]; - startTimestamp: string; - endTimestamp: string; - }> { - const normalized: { - startMs: number; - endMs: number | null; - startedAt: string; - completedAt?: string; - }[] = []; - - for (const i of intervals) { - const startMs = Date.parse(i.startedAt); - if (!Number.isFinite(startMs)) continue; - const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; - const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; - normalized.push({ startMs, endMs, startedAt: i.startedAt, completedAt: i.completedAt }); - } - - normalized.sort((a, b) => a.startMs - b.startMs); - const startTimestamp = normalized[0]?.startedAt ?? ''; - - const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>((acc, it) => { - if (it.endMs == null || typeof it.completedAt !== 'string') return acc; - if (!acc || it.endMs > acc.endMs) return { endMs: it.endMs, endTimestamp: it.completedAt }; - return acc; - }, null); - const endTimestamp = maxEnd?.endTimestamp ?? ''; - - const inAnyInterval = (ts: string): boolean => { - const ms = Date.parse(ts); - if (!Number.isFinite(ms)) return false; - for (const it of normalized) { - if (ms < it.startMs) continue; - if (it.endMs == null) return true; - if (ms <= it.endMs) return true; - } - return false; - }; - - const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); - const allowedSnippets: SnippetDiff[] = []; - const toolUseIdsSet = new Set(); - - for (const { snippets } of allParsed) { - for (const s of snippets) { - if (s.isError) continue; - if (!inAnyInterval(s.timestamp)) continue; - allowedSnippets.push(s); - if (s.toolUseId) toolUseIdsSet.add(s.toolUseId); - } - } - - const files = this.aggregateByFile( - this.sortSnippetsChronologically(allowedSnippets), - projectPath, - includeDetails - ); - return { - files, - toolUseIds: [...toolUseIdsSet], - startTimestamp, - endTimestamp, - }; - } - - /** - * Compute a context hash from old/newString for reliable hunk↔snippet matching. - * Uses first+last 3 lines of both strings as a fingerprint. - */ - private computeContextHash(oldString: string, newString: string): string { - const take3 = (s: string): string => { - const lines = s.split('\n'); - const head = lines.slice(0, 3).join('\n'); - const tail = lines.length > 3 ? lines.slice(-3).join('\n') : ''; - return `${head}|${tail}`; - }; - const raw = `${take3(oldString)}::${take3(newString)}`; - // Simple hash: DJB2 variant (fast, no crypto needed) - let hash = 5381; - for (let i = 0; i < raw.length; i++) { - hash = ((hash << 5) + hash + raw.charCodeAt(i)) | 0; - } - return (hash >>> 0).toString(36); - } - - /** Deterministic sort: timestamp → filePath → toolUseId → originalIndex */ - private sortSnippetsChronologically(snippets: SnippetDiff[]): SnippetDiff[] { - return snippets - .map((snippet, originalIndex) => ({ snippet, originalIndex })) - .sort((a, b) => { - const aMs = Date.parse(a.snippet.timestamp); - const bMs = Date.parse(b.snippet.timestamp); - const safeA = Number.isFinite(aMs) ? aMs : Number.MAX_SAFE_INTEGER; - const safeB = Number.isFinite(bMs) ? bMs : Number.MAX_SAFE_INTEGER; - if (safeA !== safeB) return safeA - safeB; - if (a.snippet.filePath !== b.snippet.filePath) - return a.snippet.filePath.localeCompare(b.snippet.filePath); - if (a.snippet.toolUseId !== b.snippet.toolUseId) - return a.snippet.toolUseId.localeCompare(b.snippet.toolUseId); - return a.originalIndex - b.originalIndex; - }) - .map(({ snippet }) => snippet); - } - - /** Parse multiple JSONL files with bounded concurrency (worker-pool) */ - private static readonly JSONL_PARSE_CONCURRENCY = 6; - - private async parseJSONLFilesWithConcurrency( - paths: string[] - ): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> { - if (paths.length === 0) return []; - - const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length); - let nextIndex = 0; - - const worker = async (): Promise => { - while (true) { - const currentIndex = nextIndex++; - if (currentIndex >= paths.length) return; - results[currentIndex] = await this.parseJSONLFile(paths[currentIndex]); - } - }; - - await Promise.all( - Array.from( - { length: Math.min(ChangeExtractorService.JSONL_PARSE_CONCURRENCY, paths.length) }, - () => worker() - ) - ); - - return results; - } - - /** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */ - private async parseJSONLFile( - filePath: string - ): Promise<{ snippets: SnippetDiff[]; mtime: number }> { - let fileMtime = 0; - try { - const fileStat = await stat(filePath); - fileMtime = fileStat.mtimeMs; - const cached = this.parsedSnippetsCache.get(filePath); - if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) { - return { snippets: cached.data, mtime: fileMtime }; - } - } catch (err) { - logger.debug(`Не удалось stat файла ${filePath}: ${String(err)}`); - return { snippets: [], mtime: 0 }; - } - - // Сначала считываем все записи в память для двух проходов - const entries: Record[] = []; - - try { - const stream = createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - entries.push(JSON.parse(trimmed) as Record); - } catch { - // Пропускаем невалидный JSON - } - } - - rl.close(); - stream.destroy(); - } catch (err) { - logger.debug(`Не удалось прочитать файл ${filePath}: ${String(err)}`); - return { snippets: [], mtime: 0 }; - } - - // Проход 1: собираем tool_use_id с ошибками - const erroredIds = this.collectErroredToolUseIds(entries); - - // Проход 2: извлекаем snippets из tool_use блоков - const snippets: SnippetDiff[] = []; - // Множество уже встречавшихся файлов (для определения write-new vs write-update) - const seenFiles = new Set(); - - for (const entry of entries) { - const role = this.extractRole(entry); - if (role !== 'assistant') continue; - - const content = this.extractContent(entry); - if (!content) continue; - - const timestamp = - typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString(); - - for (const block of content) { - if ( - !block || - typeof block !== 'object' || - (block as Record).type !== 'tool_use' - ) { - continue; - } - - const toolBlock = block as Record; - const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : ''; - // Убираем proxy_ префикс - const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName; - const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : ''; - const input = toolBlock.input as Record | undefined; - if (!input) continue; - - const isError = erroredIds.has(toolUseId); - - if (toolName === 'Edit') { - const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; - const oldString = typeof input.old_string === 'string' ? input.old_string : ''; - const newString = typeof input.new_string === 'string' ? input.new_string : ''; - const replaceAll = input.replace_all === true; - - if (targetPath) { - seenFiles.add(this.normalizeFilePathKey(targetPath)); - snippets.push({ - toolUseId, - filePath: targetPath, - toolName: 'Edit', - type: 'edit', - oldString, - newString, - replaceAll, - timestamp, - isError, - contextHash: this.computeContextHash(oldString, newString), - }); - } - } else if (toolName === 'Write') { - const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; - const writeContent = typeof input.content === 'string' ? input.content : ''; - - if (targetPath) { - const normalizedTargetPath = this.normalizeFilePathKey(targetPath); - const isNew = !seenFiles.has(normalizedTargetPath); - seenFiles.add(normalizedTargetPath); - snippets.push({ - toolUseId, - filePath: targetPath, - toolName: 'Write', - type: isNew ? 'write-new' : 'write-update', - oldString: '', - newString: writeContent, - replaceAll: false, - timestamp, - isError, - contextHash: this.computeContextHash('', writeContent), - }); - } - } else if (toolName === 'MultiEdit') { - const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; - const edits = Array.isArray(input.edits) ? input.edits : []; - - if (targetPath) { - seenFiles.add(this.normalizeFilePathKey(targetPath)); - for (const edit of edits) { - if (!edit || typeof edit !== 'object') continue; - const editObj = edit as Record; - const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : ''; - const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; - snippets.push({ - toolUseId, - filePath: targetPath, - toolName: 'MultiEdit', - type: 'multi-edit', - oldString, - newString, - replaceAll: false, - timestamp, - isError, - contextHash: this.computeContextHash(oldString, newString), - }); - } - } - } - // Остальные инструменты (NotebookEdit и пр.) пропускаем - } - } - - this.parsedSnippetsCache.set(filePath, { - data: snippets, - mtime: fileMtime, - expiresAt: Date.now() + this.parsedSnippetsCacheTtl, - }); - - return { snippets, mtime: fileMtime }; - } - - /** Извлечь content array из JSONL entry (оба формата: subagent и main) */ - private extractContent(entry: Record): unknown[] | null { - const message = entry.message as Record | undefined; - if (message && Array.isArray(message.content)) return message.content as unknown[]; - if (Array.isArray(entry.content)) return entry.content as unknown[]; - return null; - } - - /** Извлечь роль из JSONL entry */ - private extractRole(entry: Record): string | null { - if (typeof entry.role === 'string') return entry.role; - const message = entry.message as Record | undefined; - if (message && typeof message.role === 'string') return message.role; - return null; - } - - /** Собрать errored tool_use_ids из tool_result блоков */ - private collectErroredToolUseIds(entries: Record[]): Set { - const erroredIds = new Set(); - - for (const entry of entries) { - // tool_result может находиться в entry.content (когда это массив) - if (Array.isArray(entry.content)) { - for (const block of entry.content) { - if (this.isErroredToolResult(block)) { - const toolUseId = (block as Record).tool_use_id; - if (typeof toolUseId === 'string') { - erroredIds.add(toolUseId); - } - } - } - } - - // Также проверяем entry.message.content - const message = entry.message as Record | undefined; - if (message && Array.isArray(message.content)) { - for (const block of message.content) { - if (this.isErroredToolResult(block)) { - const toolUseId = (block as Record).tool_use_id; - if (typeof toolUseId === 'string') { - erroredIds.add(toolUseId); - } - } - } - } - } - - return erroredIds; - } - - /** Проверить, является ли блок tool_result с ошибкой */ - private isErroredToolResult(block: unknown): boolean { - if (!block || typeof block !== 'object') return false; - const obj = block as Record; - return obj.type === 'tool_result' && obj.is_error === true; - } - - /** Агрегировать snippets в FileChangeSummary[] */ - private aggregateByFile( - snippets: SnippetDiff[], - projectPath?: string, - includeDetails = true - ): FileChangeSummary[] { - const fileMap = new Map< - string, - { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } - >(); - - for (const snippet of snippets) { - // Пропускаем snippets с ошибками при агрегации - if (snippet.isError) continue; - - const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath); - const existing = fileMap.get(normalizedFilePath); - if (existing) { - existing.snippets.push(snippet); - if (snippet.type === 'write-new') existing.isNewFile = true; - } else { - fileMap.set(normalizedFilePath, { - filePath: snippet.filePath, - snippets: [snippet], - isNewFile: snippet.type === 'write-new', - }); - } - } - - return [...fileMap.values()].map((data) => { - const fp = data.filePath; - let totalAdded = 0; - let totalRemoved = 0; - for (const s of data.snippets) { - if (s.isError) continue; - const { added, removed } = countLineChanges(s.oldString, s.newString); - totalAdded += added; - totalRemoved += removed; - } - // Normalize separators for cross-platform path stripping - const normalizedFp = fp.replace(/\\/g, '/'); - const normalizedProject = projectPath?.replace(/\\/g, '/'); - const relative = normalizedProject - ? normalizedFp.startsWith(normalizedProject + '/') - ? normalizedFp.slice(normalizedProject.length + 1) - : normalizedFp.startsWith(normalizedProject) - ? normalizedFp.slice(normalizedProject.length) - : normalizedFp.split('/').slice(-3).join('/') - : normalizedFp.split('/').slice(-3).join('/'); - return { - filePath: fp, - relativePath: relative, - snippets: includeDetails ? data.snippets : [], - linesAdded: totalAdded, - linesRemoved: totalRemoved, - isNewFile: data.isNewFile, - timeline: includeDetails ? this.buildTimeline(fp, data.snippets) : undefined, - }; - }); - } - - /** Build edit timeline from snippets */ - private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { - const events: FileEditEvent[] = snippets - .filter((s) => !s.isError) - .map((s, idx) => { - const { added, removed } = countLineChanges(s.oldString, s.newString); - return { - toolUseId: s.toolUseId, - toolName: s.toolName as FileEditEvent['toolName'], - timestamp: s.timestamp, - summary: this.generateEditSummary(s), - linesAdded: added, - linesRemoved: removed, - snippetIndex: idx, - }; - }); - - const timestamps = events.map((e) => new Date(e.timestamp).getTime()).filter((t) => !isNaN(t)); - const durationMs = - timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0; - - return { filePath, events, durationMs }; - } - - private generateEditSummary(snippet: SnippetDiff): string { - switch (snippet.type) { - case 'write-new': - return 'Created new file'; - case 'write-update': - return 'Wrote full file content'; - case 'multi-edit': { - const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); - const total = added + removed; - return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`; - } - case 'edit': { - const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); - if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`; - if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; - return `Changed ${removed} → ${added} lines`; - } - default: - return 'File modified'; - } - } - - /** Проверить, содержит ли путь к файлу один из sessionId */ - private pathMatchesAnySession(filePath: string, sessionIds: Set): boolean { - for (const sessionId of sessionIds) { - if (filePath.includes(sessionId)) return true; - } - return false; - } - - /** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */ - private async extractFilteredChanges( - logRefs: LogFileRef[], - allowedToolUseIds: Set, - projectPath?: string, - includeDetails = true - ): Promise { - const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); - const allSnippets: SnippetDiff[] = []; - for (const { snippets } of allParsed) { - if (allowedToolUseIds.size > 0) { - for (const s of snippets) { - if (allowedToolUseIds.has(s.toolUseId)) { - allSnippets.push(s); - } - } - } else { - allSnippets.push(...snippets); - } - } - return this.aggregateByFile( - this.sortSnippetsChronologically(allSnippets), - projectPath, - includeDetails - ); - } - - /** Извлечь все изменения из одного файла */ - private async extractAllChanges( - filePath: string, - _memberName: string, - projectPath?: string, - includeDetails = true - ): Promise { - const { snippets } = await this.parseJSONLFile(filePath); - return this.aggregateByFile(snippets, projectPath, includeDetails); - } - - /** Fallback: вернуть все изменения из лог-файлов как Tier 4 */ - private async fallbackSingleTaskScope( - teamName: string, - taskId: string, - logRefs: LogFileRef[], - projectPath?: string, - includeDetails = true - ): Promise { - const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); - const allSnippets = this.sortSnippetsChronologically(allParsed.flatMap((r) => r.snippets)); - const allFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails); - - const fallbackScope: TaskChangeScope = { - taskId, - memberName: logRefs[0]?.memberName ?? 'unknown', - startLine: 1, - endLine: 0, - startTimestamp: '', - endTimestamp: '', - toolUseIds: [], - filePaths: allFiles.map((f) => f.filePath), - confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, - }; - - return { - teamName, - taskId, - files: allFiles, - totalLinesAdded: allFiles.reduce((sum, f) => sum + f.linesAdded, 0), - totalLinesRemoved: allFiles.reduce((sum, f) => sum + f.linesRemoved, 0), - totalFiles: allFiles.length, - confidence: 'fallback', - computedAt: new Date().toISOString(), - scope: fallbackScope, - warnings: ['No task boundaries found — showing all changes from related sessions.'], - }; - } - - /** Пустой TaskChangeSetV2 */ - private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 { - return { - teamName, - taskId, - files: [], - totalLinesAdded: 0, - totalLinesRemoved: 0, - totalFiles: 0, - confidence: 'fallback', - computedAt: new Date().toISOString(), - scope: { - taskId, - memberName: '', - startLine: 0, - endLine: 0, - startTimestamp: '', - endTimestamp: '', - toolUseIds: [], - filePaths: [], - confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, - }, - warnings: ['No log files found for this task.'], - }; - } - private buildTaskChangeSummaryCacheKey( teamName: string, taskId: string, - options: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + options: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket ): string { return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`; } private normalizeFilePathKey(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); + return normalizeTaskChangePresenceFilePath(filePath); } private buildTaskSignature( - options: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + options: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket ): string { const owner = typeof options.owner === 'string' ? options.owner.trim() : ''; @@ -1131,19 +487,9 @@ export class ChangeExtractorService { private async readPersistedTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket, - taskMeta: { - status?: string; - reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; - historyEvents?: unknown[]; - kanbanColumn?: 'review' | 'approved'; - } | null + taskMeta: TaskChangeTaskMeta | null ): Promise { if (!this.isPersistedTaskChangeCacheEnabled) { return null; @@ -1197,12 +543,7 @@ export class ChangeExtractorService { private schedulePersistedTaskChangeSummaryValidation( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string ): void { @@ -1237,12 +578,7 @@ export class ChangeExtractorService { private async validatePersistedTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, expectedBucket: TaskChangeStateBucket, expectedSourceFingerprint: string, version: number @@ -1282,12 +618,7 @@ export class ChangeExtractorService { private async persistTaskChangeSummary( teamName: string, taskId: string, - effectiveOptions: { - owner?: string; - status?: string; - intervals?: { startedAt: string; completedAt?: string }[]; - since?: string; - }, + effectiveOptions: TaskChangeEffectiveOptions, stateBucket: TaskChangeStateBucket, result: TaskChangeSetV2, generation: number @@ -1365,7 +696,58 @@ export class ChangeExtractorService { private async computeProjectFingerprint(teamName: string): Promise { const projectPath = await this.resolveProjectPath(teamName); - if (!projectPath) return null; - return createHash('sha256').update(this.normalizeFilePathKey(projectPath)).digest('hex'); + return computeTaskChangePresenceProjectFingerprint(projectPath); + } + + private async recordTaskChangePresence( + teamName: string, + taskId: string, + taskMeta: TaskChangeTaskMeta | null, + effectiveOptions: TaskChangeEffectiveOptions, + result: TaskChangeSetV2 + ): Promise { + if (!this.taskChangePresenceRepository || !this.teamLogSourceTracker || !taskMeta) { + return; + } + + const snapshot = await this.teamLogSourceTracker.ensureTracking(teamName); + if (!snapshot.projectFingerprint || !snapshot.logSourceGeneration) { + return; + } + + if ( + result.files.length === 0 && + result.confidence !== 'high' && + result.confidence !== 'medium' + ) { + return; + } + + const descriptor = buildTaskChangePresenceDescriptor({ + owner: effectiveOptions.owner ?? taskMeta.owner, + status: effectiveOptions.status ?? taskMeta.status, + intervals: effectiveOptions.intervals ?? taskMeta.intervals, + since: effectiveOptions.since, + reviewState: taskMeta.reviewState, + historyEvents: taskMeta.historyEvents, + kanbanColumn: taskMeta.kanbanColumn, + }); + + const now = new Date().toISOString(); + await this.taskChangePresenceRepository.upsertEntry( + teamName, + { + projectFingerprint: snapshot.projectFingerprint, + logSourceGeneration: snapshot.logSourceGeneration, + writtenAt: now, + }, + { + taskId, + taskSignature: descriptor.taskSignature, + presence: result.files.length > 0 ? 'has_changes' : 'no_changes', + writtenAt: now, + logSourceGeneration: snapshot.logSourceGeneration, + } + ); } } diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts new file mode 100644 index 00000000..893a9ee2 --- /dev/null +++ b/src/main/services/team/TaskChangeComputer.ts @@ -0,0 +1,706 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import * as readline from 'readline'; + +import { countLineChanges } from './UnifiedLineCounter'; +import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils'; + +import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { + AgentChangeSet, + FileChangeSummary, + FileEditEvent, + FileEditTimeline, + SnippetDiff, + TaskChangeScope, + TaskChangeSetV2, +} from '@shared/types'; +import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes'; + +const logger = createLogger('Service:TaskChangeComputer'); + +interface ParsedSnippetsCacheEntry { + data: SnippetDiff[]; + mtime: number; + expiresAt: number; +} + +interface LogFileRef { + filePath: string; + memberName: string; +} + +export class TaskChangeComputer { + private parsedSnippetsCache = new Map(); + private readonly parsedSnippetsCacheTtl = 20 * 1000; + private static readonly JSONL_PARSE_CONCURRENCY = 6; + + constructor( + private readonly logsFinder: TeamMemberLogsFinder, + private readonly boundaryParser: TaskBoundaryParser + ) {} + + async computeAgentChanges( + teamName: string, + memberName: string, + projectPath?: string + ): Promise<{ result: AgentChangeSet; latestMtime: number }> { + const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); + const parseResults = await this.parseJSONLFilesWithConcurrency(paths); + let latestMtime = 0; + const merged: SnippetDiff[] = []; + + for (const result of parseResults) { + merged.push(...result.snippets); + if (result.mtime > latestMtime) { + latestMtime = result.mtime; + } + } + + const files = this.aggregateByFile(this.sortSnippetsChronologically(merged), projectPath); + const taskChangeResult = { + teamName, + memberName, + files, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: files.length, + computedAt: new Date().toISOString(), + } satisfies AgentChangeSet; + + return { result: taskChangeResult, latestMtime }; + } + + async computeTaskChanges(input: ResolvedTaskChangeComputeInput): Promise { + const { teamName, taskId, taskMeta, effectiveOptions, projectPath, includeDetails } = input; + const logRefs = await this.logsFinder.findLogFileRefsForTask( + teamName, + taskId, + effectiveOptions + ); + if (logRefs.length === 0) { + return this.emptyTaskChangeSet(teamName, taskId); + } + + const allScopes: TaskChangeScope[] = []; + for (const ref of logRefs) { + const boundaries = await this.boundaryParser.parseBoundaries(ref.filePath); + const scope = boundaries.scopes.find((candidate) => candidate.taskId === taskId); + if (scope) { + allScopes.push({ ...scope, memberName: ref.memberName }); + } + } + + if (allScopes.length === 0) { + const intervals = effectiveOptions.intervals; + if (Array.isArray(intervals) && intervals.length > 0) { + const { files, toolUseIds, startTimestamp, endTimestamp } = + await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails); + + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: files.length, + confidence: 'medium', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '', + startLine: 0, + endLine: 0, + startTimestamp, + endTimestamp, + toolUseIds, + filePaths: files.map((file) => file.filePath), + confidence: { + tier: 2, + label: 'medium', + reason: 'Scoped by persisted task workIntervals (timestamp-based)', + }, + }, + warnings: + files.length === 0 + ? ['No file edits found within persisted workIntervals.'] + : ['Task boundaries missing — scoped by workIntervals timestamps.'], + }; + } + + return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails); + } + + const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds)); + const files = await this.extractFilteredChanges( + logRefs, + allowedToolUseIds, + projectPath, + includeDetails + ); + + const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier)); + return { + teamName, + taskId, + files, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: files.length, + confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', + computedAt: new Date().toISOString(), + scope: allScopes[0], + warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [], + }; + } + + private async extractIntervalScopedChanges( + logRefs: LogFileRef[], + intervals: { startedAt: string; completedAt?: string }[], + projectPath?: string, + includeDetails = true + ): Promise<{ + files: FileChangeSummary[]; + toolUseIds: string[]; + startTimestamp: string; + endTimestamp: string; + }> { + const normalized: { + startMs: number; + endMs: number | null; + startedAt: string; + completedAt?: string; + }[] = []; + + for (const interval of intervals) { + const startMs = Date.parse(interval.startedAt); + if (!Number.isFinite(startMs)) continue; + const endMsRaw = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; + normalized.push({ + startMs, + endMs, + startedAt: interval.startedAt, + completedAt: interval.completedAt, + }); + } + + normalized.sort((a, b) => a.startMs - b.startMs); + const startTimestamp = normalized[0]?.startedAt ?? ''; + const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>( + (acc, item) => { + if (item.endMs == null || typeof item.completedAt !== 'string') return acc; + if (!acc || item.endMs > acc.endMs) { + return { endMs: item.endMs, endTimestamp: item.completedAt }; + } + return acc; + }, + null + ); + const endTimestamp = maxEnd?.endTimestamp ?? ''; + + const inAnyInterval = (timestamp: string): boolean => { + const ms = Date.parse(timestamp); + if (!Number.isFinite(ms)) return false; + for (const interval of normalized) { + if (ms < interval.startMs) continue; + if (interval.endMs == null) return true; + if (ms <= interval.endMs) return true; + } + return false; + }; + + const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); + const allowedSnippets: SnippetDiff[] = []; + const toolUseIdsSet = new Set(); + + for (const { snippets } of allParsed) { + for (const snippet of snippets) { + if (snippet.isError) continue; + if (!inAnyInterval(snippet.timestamp)) continue; + allowedSnippets.push(snippet); + if (snippet.toolUseId) { + toolUseIdsSet.add(snippet.toolUseId); + } + } + } + + return { + files: this.aggregateByFile( + this.sortSnippetsChronologically(allowedSnippets), + projectPath, + includeDetails + ), + toolUseIds: [...toolUseIdsSet], + startTimestamp, + endTimestamp, + }; + } + + private async extractFilteredChanges( + logRefs: LogFileRef[], + allowedToolUseIds: Set, + projectPath?: string, + includeDetails = true + ): Promise { + const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); + const allSnippets: SnippetDiff[] = []; + + for (const { snippets } of allParsed) { + if (allowedToolUseIds.size > 0) { + for (const snippet of snippets) { + if (allowedToolUseIds.has(snippet.toolUseId)) { + allSnippets.push(snippet); + } + } + } else { + allSnippets.push(...snippets); + } + } + + return this.aggregateByFile( + this.sortSnippetsChronologically(allSnippets), + projectPath, + includeDetails + ); + } + + private async fallbackSingleTaskScope( + teamName: string, + taskId: string, + logRefs: LogFileRef[], + projectPath?: string, + includeDetails = true + ): Promise { + const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath)); + const allSnippets = this.sortSnippetsChronologically( + allParsed.flatMap((result) => result.snippets) + ); + const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails); + + return { + teamName, + taskId, + files: aggregatedFiles, + totalLinesAdded: aggregatedFiles.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: aggregatedFiles.reduce((sum, file) => sum + file.linesRemoved, 0), + totalFiles: aggregatedFiles.length, + confidence: 'fallback', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: logRefs[0]?.memberName ?? 'unknown', + startLine: 1, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: aggregatedFiles.map((file) => file.filePath), + confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, + }, + warnings: ['No task boundaries found — showing all changes from related sessions.'], + }; + } + + private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 { + return { + teamName, + taskId, + files: [], + totalLinesAdded: 0, + totalLinesRemoved: 0, + totalFiles: 0, + confidence: 'fallback', + computedAt: new Date().toISOString(), + scope: { + taskId, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: ['No log files found for this task.'], + }; + } + + private async parseJSONLFilesWithConcurrency( + paths: string[] + ): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> { + if (paths.length === 0) return []; + + const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length); + let nextIndex = 0; + + const worker = async (): Promise => { + while (true) { + const currentIndex = nextIndex++; + if (currentIndex >= paths.length) return; + results[currentIndex] = await this.parseJSONLFile(paths[currentIndex]); + } + }; + + await Promise.all( + Array.from( + { length: Math.min(TaskChangeComputer.JSONL_PARSE_CONCURRENCY, paths.length) }, + () => worker() + ) + ); + + return results; + } + + private async parseJSONLFile( + filePath: string + ): Promise<{ snippets: SnippetDiff[]; mtime: number }> { + let fileMtime = 0; + try { + const fileStat = await stat(filePath); + fileMtime = fileStat.mtimeMs; + const cached = this.parsedSnippetsCache.get(filePath); + if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) { + return { snippets: cached.data, mtime: fileMtime }; + } + } catch (error) { + logger.debug(`Не удалось stat файла ${filePath}: ${String(error)}`); + return { snippets: [], mtime: 0 }; + } + + const entries: Record[] = []; + + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + entries.push(JSON.parse(trimmed) as Record); + } catch { + // Ignore invalid JSON lines. + } + } + + rl.close(); + stream.destroy(); + } catch (error) { + logger.debug(`Не удалось прочитать файл ${filePath}: ${String(error)}`); + return { snippets: [], mtime: 0 }; + } + + const erroredIds = this.collectErroredToolUseIds(entries); + const snippets: SnippetDiff[] = []; + const seenFiles = new Set(); + + for (const entry of entries) { + const role = this.extractRole(entry); + if (role !== 'assistant') continue; + + const content = this.extractContent(entry); + if (!content) continue; + + const timestamp = + typeof entry.timestamp === 'string' ? entry.timestamp : new Date().toISOString(); + + for (const block of content) { + if ( + !block || + typeof block !== 'object' || + (block as Record).type !== 'tool_use' + ) { + continue; + } + + const toolBlock = block as Record; + const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : ''; + const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName; + const toolUseId = typeof toolBlock.id === 'string' ? toolBlock.id : ''; + const input = toolBlock.input as Record | undefined; + if (!input) continue; + + const isError = erroredIds.has(toolUseId); + + if (toolName === 'Edit') { + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; + const oldString = typeof input.old_string === 'string' ? input.old_string : ''; + const newString = typeof input.new_string === 'string' ? input.new_string : ''; + const replaceAll = input.replace_all === true; + + if (targetPath) { + seenFiles.add(this.normalizeFilePathKey(targetPath)); + snippets.push({ + toolUseId, + filePath: targetPath, + toolName: 'Edit', + type: 'edit', + oldString, + newString, + replaceAll, + timestamp, + isError, + contextHash: this.computeContextHash(oldString, newString), + }); + } + } else if (toolName === 'Write') { + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; + const writeContent = typeof input.content === 'string' ? input.content : ''; + + if (targetPath) { + const normalizedTargetPath = this.normalizeFilePathKey(targetPath); + const isNew = !seenFiles.has(normalizedTargetPath); + seenFiles.add(normalizedTargetPath); + snippets.push({ + toolUseId, + filePath: targetPath, + toolName: 'Write', + type: isNew ? 'write-new' : 'write-update', + oldString: '', + newString: writeContent, + replaceAll: false, + timestamp, + isError, + contextHash: this.computeContextHash('', writeContent), + }); + } + } else if (toolName === 'MultiEdit') { + const targetPath = typeof input.file_path === 'string' ? input.file_path : ''; + const edits = Array.isArray(input.edits) ? input.edits : []; + + if (targetPath) { + seenFiles.add(this.normalizeFilePathKey(targetPath)); + for (const edit of edits) { + if (!edit || typeof edit !== 'object') continue; + const editObj = edit as Record; + const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : ''; + const newString = typeof editObj.new_string === 'string' ? editObj.new_string : ''; + snippets.push({ + toolUseId, + filePath: targetPath, + toolName: 'MultiEdit', + type: 'multi-edit', + oldString, + newString, + replaceAll: false, + timestamp, + isError, + contextHash: this.computeContextHash(oldString, newString), + }); + } + } + } + } + } + + this.parsedSnippetsCache.set(filePath, { + data: snippets, + mtime: fileMtime, + expiresAt: Date.now() + this.parsedSnippetsCacheTtl, + }); + + return { snippets, mtime: fileMtime }; + } + + private extractContent(entry: Record): unknown[] | null { + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) return message.content as unknown[]; + if (Array.isArray(entry.content)) return entry.content as unknown[]; + return null; + } + + private extractRole(entry: Record): string | null { + if (typeof entry.role === 'string') return entry.role; + const message = entry.message as Record | undefined; + if (message && typeof message.role === 'string') return message.role; + return null; + } + + private collectErroredToolUseIds(entries: Record[]): Set { + const erroredIds = new Set(); + + for (const entry of entries) { + if (Array.isArray(entry.content)) { + for (const block of entry.content) { + if (this.isErroredToolResult(block)) { + const toolUseId = (block as Record).tool_use_id; + if (typeof toolUseId === 'string') { + erroredIds.add(toolUseId); + } + } + } + } + + const message = entry.message as Record | undefined; + if (message && Array.isArray(message.content)) { + for (const block of message.content) { + if (this.isErroredToolResult(block)) { + const toolUseId = (block as Record).tool_use_id; + if (typeof toolUseId === 'string') { + erroredIds.add(toolUseId); + } + } + } + } + } + + return erroredIds; + } + + private isErroredToolResult(block: unknown): boolean { + if (!block || typeof block !== 'object') return false; + const obj = block as Record; + return obj.type === 'tool_result' && obj.is_error === true; + } + + private aggregateByFile( + snippets: SnippetDiff[], + projectPath?: string, + includeDetails = true + ): FileChangeSummary[] { + const fileMap = new Map< + string, + { filePath: string; snippets: SnippetDiff[]; isNewFile: boolean } + >(); + + for (const snippet of snippets) { + if (snippet.isError) continue; + + const normalizedFilePath = this.normalizeFilePathKey(snippet.filePath); + const existing = fileMap.get(normalizedFilePath); + if (existing) { + existing.snippets.push(snippet); + if (snippet.type === 'write-new') existing.isNewFile = true; + } else { + fileMap.set(normalizedFilePath, { + filePath: snippet.filePath, + snippets: [snippet], + isNewFile: snippet.type === 'write-new', + }); + } + } + + return [...fileMap.values()].map((data) => { + let totalAdded = 0; + let totalRemoved = 0; + for (const snippet of data.snippets) { + if (snippet.isError) continue; + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + totalAdded += added; + totalRemoved += removed; + } + + const normalizedFilePath = data.filePath.replace(/\\/g, '/'); + const normalizedProjectPath = projectPath?.replace(/\\/g, '/'); + const relativePath = normalizedProjectPath + ? normalizedFilePath.startsWith(normalizedProjectPath + '/') + ? normalizedFilePath.slice(normalizedProjectPath.length + 1) + : normalizedFilePath.startsWith(normalizedProjectPath) + ? normalizedFilePath.slice(normalizedProjectPath.length) + : normalizedFilePath.split('/').slice(-3).join('/') + : normalizedFilePath.split('/').slice(-3).join('/'); + + return { + filePath: data.filePath, + relativePath, + snippets: includeDetails ? data.snippets : [], + linesAdded: totalAdded, + linesRemoved: totalRemoved, + isNewFile: data.isNewFile, + timeline: includeDetails ? this.buildTimeline(data.filePath, data.snippets) : undefined, + }; + }); + } + + private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline { + const events: FileEditEvent[] = snippets + .filter((snippet) => !snippet.isError) + .map((snippet, index) => { + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + return { + toolUseId: snippet.toolUseId, + toolName: snippet.toolName as FileEditEvent['toolName'], + timestamp: snippet.timestamp, + summary: this.generateEditSummary(snippet), + linesAdded: added, + linesRemoved: removed, + snippetIndex: index, + }; + }); + + const timestamps = events + .map((event) => new Date(event.timestamp).getTime()) + .filter((timestamp) => !Number.isNaN(timestamp)); + const durationMs = + timestamps.length >= 2 ? Math.max(...timestamps) - Math.min(...timestamps) : 0; + + return { filePath, events, durationMs }; + } + + private generateEditSummary(snippet: SnippetDiff): string { + switch (snippet.type) { + case 'write-new': + return 'Created new file'; + case 'write-update': + return 'Wrote full file content'; + case 'multi-edit': { + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + const total = added + removed; + return `Multi-edit (${total} line${total !== 1 ? 's' : ''})`; + } + case 'edit': { + const { added, removed } = countLineChanges(snippet.oldString, snippet.newString); + if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`; + if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`; + return `Changed ${removed} → ${added} lines`; + } + default: + return 'File modified'; + } + } + + private computeContextHash(oldString: string, newString: string): string { + const take3 = (value: string): string => { + const lines = value.split('\n'); + const head = lines.slice(0, 3).join('\n'); + const tail = lines.length > 3 ? lines.slice(-3).join('\n') : ''; + return `${head}|${tail}`; + }; + + const raw = `${take3(oldString)}::${take3(newString)}`; + let hash = 5381; + for (let index = 0; index < raw.length; index++) { + hash = ((hash << 5) + hash + raw.charCodeAt(index)) | 0; + } + return (hash >>> 0).toString(36); + } + + private sortSnippetsChronologically(snippets: SnippetDiff[]): SnippetDiff[] { + return snippets + .map((snippet, originalIndex) => ({ snippet, originalIndex })) + .sort((a, b) => { + const aMs = Date.parse(a.snippet.timestamp); + const bMs = Date.parse(b.snippet.timestamp); + const safeA = Number.isFinite(aMs) ? aMs : Number.MAX_SAFE_INTEGER; + const safeB = Number.isFinite(bMs) ? bMs : Number.MAX_SAFE_INTEGER; + if (safeA !== safeB) return safeA - safeB; + if (a.snippet.filePath !== b.snippet.filePath) { + return a.snippet.filePath.localeCompare(b.snippet.filePath); + } + if (a.snippet.toolUseId !== b.snippet.toolUseId) { + return a.snippet.toolUseId.localeCompare(b.snippet.toolUseId); + } + return a.originalIndex - b.originalIndex; + }) + .map(({ snippet }) => snippet); + } + + private normalizeFilePathKey(filePath: string): string { + return normalizeTaskChangePresenceFilePath(filePath); + } +} diff --git a/src/main/services/team/TaskChangeWorkerClient.ts b/src/main/services/team/TaskChangeWorkerClient.ts new file mode 100644 index 00000000..bee769c5 --- /dev/null +++ b/src/main/services/team/TaskChangeWorkerClient.ts @@ -0,0 +1,267 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { createLogger } from '@shared/utils/logger'; + +import type { + ResolvedTaskChangeComputeInput, + TaskChangeWorkerRequest, + TaskChangeWorkerResponse, +} from './taskChangeWorkerTypes'; +import type { TaskChangeSetV2 } from '@shared/types'; + +const logger = createLogger('Service:TaskChangeWorkerClient'); +const DEFAULT_WORKER_CALL_TIMEOUT_MS = 30_000; + +interface WorkerLike { + on(event: 'message', listener: (msg: TaskChangeWorkerResponse) => void): this; + on(event: 'error', listener: (error: Error) => void): this; + on(event: 'exit', listener: (code: number) => void): this; + postMessage(message: TaskChangeWorkerRequest): void; + terminate(): Promise; +} + +interface QueueEntry { + id: string; + request: TaskChangeWorkerRequest; + resolve: (value: TaskChangeSetV2) => void; + reject: (error: Error) => void; +} + +function makeId(): string { + return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; +} + +function resolveWorkerPath(): string | null { + const baseDir = + typeof __dirname === 'string' && __dirname.length > 0 + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + + const candidates = [ + path.join(baseDir, 'task-change-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'task-change-worker.js'), + ]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // ignore + } + } + + return null; +} + +export class TaskChangeWorkerClient { + private worker: WorkerLike | null = null; + private terminatingWorker: WorkerLike | null = null; + private readonly workerPath: string | null; + private readonly workerFactory: (workerPath: string) => WorkerLike; + private readonly timeoutMs: number; + private readonly enabled: boolean; + private warnedUnavailable = false; + private activeRequestId: string | null = null; + private activeTimeout: ReturnType | null = null; + private terminatingForTimeoutRequestId: string | null = null; + private pending = new Map(); + private queue: QueueEntry[] = []; + + constructor(options?: { + workerPath?: string | null; + workerFactory?: (workerPath: string) => WorkerLike; + timeoutMs?: number; + enabled?: boolean; + }) { + this.workerPath = + options && 'workerPath' in options ? (options.workerPath ?? null) : resolveWorkerPath(); + this.workerFactory = options?.workerFactory ?? ((workerPath) => new Worker(workerPath)); + this.timeoutMs = options?.timeoutMs ?? DEFAULT_WORKER_CALL_TIMEOUT_MS; + this.enabled = options?.enabled ?? process.env.CLAUDE_TEAM_ENABLE_TASK_CHANGE_WORKER !== '0'; + } + + isAvailable(): boolean { + if (!this.enabled) { + return false; + } + + if (!this.workerPath && !this.warnedUnavailable) { + this.warnedUnavailable = true; + logger.warn('task-change-worker not found; falling back to main-thread extraction.'); + } + + return this.workerPath !== null; + } + + async computeTaskChanges(payload: ResolvedTaskChangeComputeInput): Promise { + if (!this.isAvailable()) { + throw new Error('Task change worker is not available in this environment'); + } + + const id = makeId(); + const entry: QueueEntry = { + id, + request: { id, op: 'computeTaskChanges', payload }, + resolve: () => undefined, + reject: () => undefined, + }; + + return new Promise((resolve, reject) => { + entry.resolve = resolve; + entry.reject = reject; + this.pending.set(id, entry); + this.queue.push(entry); + this.processQueue(); + }); + } + + private ensureWorker(): WorkerLike { + if (!this.workerPath) { + throw new Error('Task change worker is not available in this environment'); + } + if (this.worker) { + return this.worker; + } + + const worker = this.workerFactory(this.workerPath); + worker.on('message', (msg) => this.handleMessage(msg)); + worker.on('error', (error) => this.handleWorkerFailure(worker, error)); + worker.on('exit', (code) => this.handleWorkerExit(worker, code)); + this.worker = worker; + return worker; + } + + private processQueue(): void { + if (this.activeRequestId || this.queue.length === 0) { + return; + } + + const entry = this.queue.shift(); + if (!entry) { + return; + } + + const worker = this.ensureWorker(); + this.activeRequestId = entry.id; + this.activeTimeout = setTimeout(() => { + const activeId = this.activeRequestId; + if (!activeId) { + return; + } + + this.clearActiveState(); + this.terminatingForTimeoutRequestId = activeId; + const pending = this.pending.get(activeId); + if (pending) { + this.pending.delete(activeId); + pending.reject( + new Error(`Worker call timeout after ${this.timeoutMs}ms (computeTaskChanges)`) + ); + } + + try { + const workerToTerminate = this.worker; + this.terminatingWorker = workerToTerminate; + workerToTerminate?.terminate().catch(() => undefined); + } catch { + // ignore + } finally { + this.worker = null; + } + + this.processQueue(); + }, this.timeoutMs); + + try { + worker.postMessage(entry.request); + } catch (error) { + this.clearActiveState(); + this.pending.delete(entry.id); + entry.reject(error instanceof Error ? error : new Error(String(error))); + this.processQueue(); + } + } + + private handleMessage(message: TaskChangeWorkerResponse): void { + const entry = this.pending.get(message.id); + if (!entry) { + return; + } + + this.pending.delete(message.id); + if (this.activeRequestId === message.id) { + this.clearActiveState(); + } + + if (message.ok) { + entry.resolve(message.result); + } else { + entry.reject(new Error(message.error)); + } + + this.processQueue(); + } + + private handleWorkerFailure(worker: WorkerLike, error: Error): void { + logger.error('Task change worker error', error); + if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) { + this.terminatingForTimeoutRequestId = null; + this.terminatingWorker = null; + return; + } + + this.rejectAllPending(error); + this.clearActiveState(); + if (this.worker === worker) { + this.worker = null; + } + } + + private handleWorkerExit(worker: WorkerLike, code: number): void { + if (this.terminatingForTimeoutRequestId && this.terminatingWorker === worker) { + this.terminatingForTimeoutRequestId = null; + this.terminatingWorker = null; + return; + } + + if (code !== 0) { + logger.warn(`Task change worker exited with code ${code}`); + } + this.rejectAllPending(new Error(`Worker exited with code ${code}`)); + this.clearActiveState(); + if (this.worker === worker) { + this.worker = null; + } + } + + private rejectAllPending(error: Error): void { + for (const entry of this.pending.values()) { + entry.reject(error); + } + this.pending.clear(); + this.queue = []; + } + + private clearActiveState(): void { + this.activeRequestId = null; + if (this.activeTimeout) { + clearTimeout(this.activeTimeout); + this.activeTimeout = null; + } + } +} + +let singleton: TaskChangeWorkerClient | null = null; + +export function getTaskChangeWorkerClient(): TaskChangeWorkerClient { + if (!singleton) { + singleton = new TaskChangeWorkerClient(); + } + return singleton; +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 4d454aec..a79ef673 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -40,6 +40,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; +import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import type { AddMemberRequest, @@ -63,11 +64,15 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, + TaskChangePresenceState, TeamTaskWithKanban, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; import type { AgentTeamsController } from 'agent-teams-controller'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; +import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; const { createController } = agentTeamsControllerModule; @@ -91,6 +96,11 @@ interface EligibleTaskCommentNotification { summary: string; } +interface TaskChangeLogSourceSnapshot { + projectFingerprint: string | null; + logSourceGeneration: string | null; +} + export class TeamDataService { private processHealthTimer: ReturnType | null = null; private processHealthTeams = new Set(); @@ -98,6 +108,8 @@ export class TeamDataService { private notifiedTaskStarts = new Set(); private taskCommentNotificationInitialization: Promise | null = null; private taskCommentNotificationInFlight = new Set(); + private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; + private teamLogSourceTracker: TeamLogSourceTracker | null = null; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -168,6 +180,120 @@ export class TeamDataService { return null; } + setTaskChangePresenceServices( + repository: TaskChangePresenceRepository, + tracker: TeamLogSourceTracker + ): void { + this.taskChangePresenceRepository = repository; + this.teamLogSourceTracker = tracker; + } + + setTaskChangePresenceTracking(teamName: string, enabled: boolean): void { + if (!this.teamLogSourceTracker) { + return; + } + + if (enabled) { + void this.teamLogSourceTracker + .ensureTracking(teamName) + .catch((error) => + logger.debug(`Failed to start change-presence tracking for ${teamName}: ${String(error)}`) + ); + return; + } + + void this.teamLogSourceTracker + .stopTracking(teamName) + .catch((error) => + logger.debug(`Failed to stop change-presence tracking for ${teamName}: ${String(error)}`) + ); + } + + private resolveTaskChangePresenceMap( + tasks: readonly TeamTaskWithKanban[], + changePresenceEnabled: boolean, + presenceIndex: PersistedTaskChangePresenceIndex | null, + logSourceSnapshot: TaskChangeLogSourceSnapshot | null + ): Record { + const result: Record = {}; + if ( + !changePresenceEnabled || + !presenceIndex || + !logSourceSnapshot?.projectFingerprint || + !logSourceSnapshot.logSourceGeneration || + presenceIndex.projectFingerprint !== logSourceSnapshot.projectFingerprint || + presenceIndex.logSourceGeneration !== logSourceSnapshot.logSourceGeneration + ) { + for (const task of tasks) { + result[task.id] = 'unknown'; + } + return result; + } + + for (const task of tasks) { + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + reviewState: task.reviewState, + historyEvents: task.historyEvents, + kanbanColumn: task.kanbanColumn, + }); + const presenceEntry = presenceIndex.entries[task.id]; + result[task.id] = + presenceEntry && + presenceEntry.taskSignature === descriptor.taskSignature && + presenceEntry.logSourceGeneration === logSourceSnapshot.logSourceGeneration + ? presenceEntry.presence + : 'unknown'; + } + + return result; + } + + async getTaskChangePresence(teamName: string): Promise> { + const config = await this.configReader.getConfig(teamName); + if (!config) { + throw new Error(`Team not found: ${teamName}`); + } + + const changePresenceEnabled = + this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null; + const logSourceSnapshot: TaskChangeLogSourceSnapshot | null = + changePresenceEnabled && + typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown }) + .getSnapshot === 'function' + ? (( + this.teamLogSourceTracker as { + getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null; + } + ).getSnapshot(teamName) ?? null) + : null; + + const [tasks, kanbanState, presenceIndex] = await Promise.all([ + this.taskReader.getTasks(teamName).catch(() => [] as TeamTask[]), + this.kanbanManager + .getState(teamName) + .catch(() => ({ teamName, reviewers: [], tasks: {} }) as KanbanState), + changePresenceEnabled && + logSourceSnapshot?.projectFingerprint && + logSourceSnapshot.logSourceGeneration + ? this.taskChangePresenceRepository!.load(teamName) + : Promise.resolve(null), + ]); + + const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) => + this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) + ); + + return this.resolveTaskChangePresenceMap( + tasksWithKanbanBase, + changePresenceEnabled, + presenceIndex, + logSourceSnapshot + ); + } + async listTeams(): Promise { return this.configReader.listTeams(); } @@ -333,6 +459,24 @@ export class TeamDataService { mark('config'); const warnings: string[] = []; + const changePresenceEnabled = + this.taskChangePresenceRepository !== null && this.teamLogSourceTracker !== null; + const logSourceSnapshot: TaskChangeLogSourceSnapshot | null = + changePresenceEnabled && + typeof (this.teamLogSourceTracker as { getSnapshot?: (teamName: string) => unknown }) + .getSnapshot === 'function' + ? (( + this.teamLogSourceTracker as { + getSnapshot: (teamName: string) => TaskChangeLogSourceSnapshot | null; + } + ).getSnapshot(teamName) ?? null) + : null; + const presenceIndexPromise = + changePresenceEnabled && + logSourceSnapshot?.projectFingerprint && + logSourceSnapshot.logSourceGeneration + ? this.taskChangePresenceRepository!.load(teamName) + : Promise.resolve(null); let tasks: TeamTask[] = []; try { @@ -473,10 +617,25 @@ export class TeamDataService { mark('kanbanGc'); - const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => + const tasksWithKanbanBase: TeamTaskWithKanban[] = tasks.map((task) => this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) ); + const presenceIndex = await presenceIndexPromise; + + const taskChangePresenceById = this.resolveTaskChangePresenceMap( + tasksWithKanbanBase, + changePresenceEnabled, + presenceIndex, + logSourceSnapshot + ); + const tasksWithKanban: TeamTaskWithKanban[] = changePresenceEnabled + ? tasksWithKanbanBase.map((task) => ({ + ...task, + changePresence: taskChangePresenceById[task.id] ?? 'unknown', + })) + : tasksWithKanbanBase; + const members = this.memberResolver.resolveMembers( config, metaMembers, @@ -492,10 +651,6 @@ export class TeamDataService { mark('syncComments'); - const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => - this.attachKanbanCompatibility(task, kanbanState.tasks[task.id]) - ); - let processes: TeamProcess[] = []; try { processes = await this.readProcesses(teamName); @@ -530,7 +685,7 @@ export class TeamDataService { return { teamName, config, - tasks: tasksToReturn, + tasks: tasksWithKanban, members, messages, kanbanState, diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts new file mode 100644 index 00000000..863a8b81 --- /dev/null +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -0,0 +1,361 @@ +import { createLogger } from '@shared/utils/logger'; +import { watch } from 'chokidar'; +import { createHash } from 'crypto'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { + computeTaskChangePresenceProjectFingerprint, + normalizeTaskChangePresenceFilePath, +} from './taskChangePresenceUtils'; + +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { TeamChangeEvent } from '@shared/types'; +import type { FSWatcher } from 'chokidar'; + +const logger = createLogger('Service:TeamLogSourceTracker'); + +interface TeamLogSourceSnapshot { + projectFingerprint: string | null; + logSourceGeneration: string | null; +} + +interface TrackingState { + watcher: FSWatcher | null; + projectDir: string | null; + refreshTimer: ReturnType | null; + initializePromise: Promise | null; + initializeVersion: number | null; + recomputePromise: Promise | null; + recomputeVersion: number | null; + snapshot: TeamLogSourceSnapshot; + desiredTracking: boolean; + lifecycleVersion: number; +} + +export class TeamLogSourceTracker { + private readonly stateByTeam = new Map(); + private emitter: ((event: TeamChangeEvent) => void) | null = null; + + constructor(private readonly logsFinder: TeamMemberLogsFinder) {} + + setEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void { + this.emitter = emitter; + } + + getSnapshot(teamName: string): TeamLogSourceSnapshot | null { + const state = this.stateByTeam.get(teamName); + return state ? { ...state.snapshot } : null; + } + + async ensureTracking(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (!state.desiredTracking) { + state.desiredTracking = true; + state.lifecycleVersion += 1; + } + + if ( + state.initializePromise && + state.initializeVersion === state.lifecycleVersion && + state.desiredTracking + ) { + return state.initializePromise; + } + + const initializeVersion = state.lifecycleVersion; + const initializePromise = this.initializeTeam(teamName, initializeVersion) + .catch((error) => { + logger.debug(`Failed to initialize log-source tracker for ${teamName}: ${String(error)}`); + return { projectFingerprint: null, logSourceGeneration: null }; + }) + .finally(() => { + const current = this.stateByTeam.get(teamName); + if (current?.initializePromise === initializePromise) { + current.initializePromise = null; + current.initializeVersion = null; + } + }); + + state.initializePromise = initializePromise; + state.initializeVersion = initializeVersion; + return initializePromise; + } + + async dispose(): Promise { + await Promise.all([...this.stateByTeam.keys()].map((teamName) => this.stopTracking(teamName))); + } + + private getOrCreateState(teamName: string): TrackingState { + const existing = this.stateByTeam.get(teamName); + if (existing) { + return existing; + } + + const created: TrackingState = { + watcher: null, + projectDir: null, + refreshTimer: null, + initializePromise: null, + initializeVersion: null, + recomputePromise: null, + recomputeVersion: null, + snapshot: { projectFingerprint: null, logSourceGeneration: null }, + desiredTracking: false, + lifecycleVersion: 0, + }; + this.stateByTeam.set(teamName, created); + return created; + } + + async stopTracking(teamName: string): Promise { + const state = this.stateByTeam.get(teamName); + if (!state) { + return; + } + + if (state.desiredTracking) { + state.desiredTracking = false; + state.lifecycleVersion += 1; + } + + if (state.refreshTimer) { + clearTimeout(state.refreshTimer); + state.refreshTimer = null; + } + + if (state.watcher) { + await state.watcher.close().catch(() => undefined); + state.watcher = null; + } + + state.projectDir = null; + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + } + + private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { + const state = this.stateByTeam.get(teamName); + return !!state && state.desiredTracking && state.lifecycleVersion === expectedVersion; + } + + private async initializeTeam( + teamName: string, + expectedVersion: number + ): Promise { + const state = this.getOrCreateState(teamName); + const previousGeneration = state.snapshot.logSourceGeneration; + const context = await this.logsFinder.getLogSourceWatchContext(teamName, { + forceRefresh: true, + }); + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + if (!context) { + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + await this.rebuildWatcher(teamName, null, expectedVersion); + return state.snapshot; + } + + const snapshot = await this.computeSnapshot(context); + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + state.snapshot = snapshot; + await this.rebuildWatcher(teamName, context.projectDir, expectedVersion); + if ( + this.isTrackingCurrent(teamName, expectedVersion) && + state.snapshot.logSourceGeneration && + previousGeneration !== state.snapshot.logSourceGeneration + ) { + this.emitter?.({ + type: 'log-source-change', + teamName, + }); + } + return snapshot; + } + + private async rebuildWatcher( + teamName: string, + projectDir: string | null, + expectedVersion: number + ): Promise { + const state = this.stateByTeam.get(teamName); + if (!state || !state.desiredTracking || state.lifecycleVersion !== expectedVersion) { + return; + } + if (state.projectDir === projectDir && state.watcher) { + return; + } + + if (state.watcher) { + await state.watcher.close().catch(() => undefined); + state.watcher = null; + } + + state.projectDir = projectDir; + if (!projectDir) { + return; + } + + if (!this.isTrackingCurrent(teamName, expectedVersion)) { + state.projectDir = null; + return; + } + + state.watcher = watch(projectDir, { + ignoreInitial: true, + ignorePermissionErrors: true, + followSymlinks: false, + depth: 3, + awaitWriteFinish: { + stabilityThreshold: 250, + pollInterval: 50, + }, + }); + + const scheduleRecompute = (): void => { + const current = this.stateByTeam.get(teamName); + if (!current || !current.desiredTracking) { + return; + } + if (current.refreshTimer) { + clearTimeout(current.refreshTimer); + } + current.refreshTimer = setTimeout(() => { + current.refreshTimer = null; + void this.recompute(teamName); + }, 300); + }; + + state.watcher.on('add', scheduleRecompute); + state.watcher.on('change', scheduleRecompute); + state.watcher.on('unlink', scheduleRecompute); + state.watcher.on('addDir', scheduleRecompute); + state.watcher.on('unlinkDir', scheduleRecompute); + state.watcher.on('error', (error) => { + logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`); + }); + } + + private async recompute(teamName: string): Promise { + const state = this.getOrCreateState(teamName); + if (!state.desiredTracking) { + return state.snapshot; + } + if ( + state.recomputePromise && + state.recomputeVersion === state.lifecycleVersion && + state.desiredTracking + ) { + return state.recomputePromise; + } + + const recomputeVersion = state.lifecycleVersion; + const recomputePromise = (async () => { + const previousGeneration = state.snapshot.logSourceGeneration; + const context = await this.logsFinder.getLogSourceWatchContext(teamName, { + forceRefresh: true, + }); + if (!this.isTrackingCurrent(teamName, recomputeVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + + if (!context) { + state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; + await this.rebuildWatcher(teamName, null, recomputeVersion); + } else { + state.snapshot = await this.computeSnapshot(context); + if (!this.isTrackingCurrent(teamName, recomputeVersion)) { + return this.getOrCreateState(teamName).snapshot; + } + await this.rebuildWatcher(teamName, context.projectDir, recomputeVersion); + } + + if ( + this.isTrackingCurrent(teamName, recomputeVersion) && + previousGeneration && + state.snapshot.logSourceGeneration && + previousGeneration !== state.snapshot.logSourceGeneration + ) { + this.emitter?.({ + type: 'log-source-change', + teamName, + }); + } + + return state.snapshot; + })().finally(() => { + const current = this.stateByTeam.get(teamName); + if (current?.recomputePromise === recomputePromise) { + current.recomputePromise = null; + current.recomputeVersion = null; + } + }); + + state.recomputePromise = recomputePromise; + state.recomputeVersion = recomputeVersion; + return recomputePromise; + } + + private async computeSnapshot(context: { + projectDir: string; + projectPath?: string; + leadSessionId?: string; + sessionIds: string[]; + }): Promise { + const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath); + const parts: string[] = []; + + if (context.leadSessionId) { + const leadLogPath = path.join(context.projectDir, `${context.leadSessionId}.jsonl`); + parts.push(await this.describePath('lead', leadLogPath)); + } + + for (const sessionId of [...context.sessionIds].sort((a, b) => a.localeCompare(b))) { + const sessionDir = path.join(context.projectDir, sessionId); + const subagentsDir = path.join(sessionDir, 'subagents'); + parts.push(await this.describePath('session', sessionDir)); + parts.push(await this.describePath('subagents', subagentsDir)); + + let entries: string[] = []; + try { + entries = await fs.readdir(subagentsDir); + } catch { + entries = []; + } + + for (const fileName of entries + .filter( + (entry) => + entry.startsWith('agent-') && + entry.endsWith('.jsonl') && + !entry.startsWith('agent-acompact') + ) + .sort((a, b) => a.localeCompare(b))) { + parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName))); + } + } + + const sourceMaterial = + parts.length > 0 + ? parts.join('|') + : `empty:${normalizeTaskChangePresenceFilePath(context.projectDir)}`; + + return { + projectFingerprint, + logSourceGeneration: createHash('sha256').update(sourceMaterial).digest('hex'), + }; + } + + private async describePath(kind: string, targetPath: string): Promise { + const normalizedPath = normalizeTaskChangePresenceFilePath(targetPath); + try { + const stats = await fs.stat(targetPath); + const type = stats.isDirectory() ? 'dir' : 'file'; + return `${kind}:${type}:${normalizedPath}:${stats.size}:${stats.mtimeMs}`; + } catch { + return `${kind}:missing:${normalizedPath}`; + } + } +} diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 6bdfa8d5..ea15c5fb 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -173,6 +173,32 @@ export class TeamMemberLogsFinder { ); } + async getLogSourceWatchContext( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise<{ + projectDir: string; + projectPath?: string; + leadSessionId?: string; + sessionIds: string[]; + } | null> { + if (options?.forceRefresh) { + this.discoveryCache.delete(teamName); + } + + const discovery = await this.discoverProjectSessions(teamName); + if (!discovery) { + return null; + } + + return { + projectDir: discovery.projectDir, + projectPath: discovery.config.projectPath, + leadSessionId: discovery.config.leadSessionId, + sessionIds: [...discovery.sessionIds], + }; + } + /** * Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.). * When the task is in_progress and has an owner, also includes that owner's session logs so diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts new file mode 100644 index 00000000..1d1df59b --- /dev/null +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -0,0 +1,140 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { getTaskChangePresenceBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + normalizePersistedTaskChangePresenceIndex, + toPersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheSchema'; +import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes'; + +import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; +import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes'; + +const logger = createLogger('Service:JsonTaskChangePresenceRepository'); + +const READ_TIMEOUT_MS = 5_000; + +function encodeFileSegment(value: string): string { + return encodeURIComponent(value); +} + +export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepository { + private readonly writeChains = new Map>(); + + private get basePath(): string { + return getTaskChangePresenceBasePath(); + } + + private filePath(teamName: string): string { + return path.join(this.basePath, `${encodeFileSegment(teamName)}.json`); + } + + private async readIndex(teamName: string): Promise { + const filePath = this.filePath(teamName); + let content: string; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS); + try { + content = await fs.promises.readFile(filePath, { + encoding: 'utf8', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.warn(`Failed to read task-change presence index ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + logger.warn(`Corrupted task-change presence index ${filePath}: ${String(error)}`); + await fs.promises.unlink(filePath).catch(() => undefined); + return null; + } + + const normalized = normalizePersistedTaskChangePresenceIndex(parsed); + if (!normalized) { + await fs.promises.unlink(filePath).catch(() => undefined); + return null; + } + + return normalized; + } + + async load(teamName: string): Promise { + return this.readIndex(teamName); + } + + async upsertEntry( + teamName: string, + metadata: { + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + }, + entry: { + taskId: string; + taskSignature: string; + presence: 'has_changes' | 'no_changes'; + writtenAt: string; + logSourceGeneration: string; + } + ): Promise { + const write = async (): Promise => { + const current = + (await this.readIndex(teamName)) ?? + ({ + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName, + projectFingerprint: metadata.projectFingerprint, + logSourceGeneration: metadata.logSourceGeneration, + writtenAt: metadata.writtenAt, + entries: {}, + } satisfies PersistedTaskChangePresenceIndex); + + const next = toPersistedTaskChangePresenceIndex({ + ...current, + projectFingerprint: metadata.projectFingerprint, + logSourceGeneration: metadata.logSourceGeneration, + writtenAt: metadata.writtenAt, + entries: { + ...current.entries, + [entry.taskId]: { + taskId: entry.taskId, + taskSignature: entry.taskSignature, + presence: entry.presence, + writtenAt: entry.writtenAt, + logSourceGeneration: entry.logSourceGeneration, + }, + }, + }); + + await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2)); + }; + + const previous = this.writeChains.get(teamName) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(write) + .finally(() => { + if (this.writeChains.get(teamName) === next) { + this.writeChains.delete(teamName); + } + }); + + this.writeChains.set(teamName, next); + await next; + } +} diff --git a/src/main/services/team/cache/TaskChangePresenceRepository.ts b/src/main/services/team/cache/TaskChangePresenceRepository.ts new file mode 100644 index 00000000..e07910fa --- /dev/null +++ b/src/main/services/team/cache/TaskChangePresenceRepository.ts @@ -0,0 +1,23 @@ +import type { + PersistedTaskChangePresence, + PersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheTypes'; + +export interface TaskChangePresenceRepository { + load(teamName: string): Promise; + upsertEntry( + teamName: string, + metadata: { + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + }, + entry: { + taskId: string; + taskSignature: string; + presence: PersistedTaskChangePresence; + writtenAt: string; + logSourceGeneration: string; + } + ): Promise; +} diff --git a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts new file mode 100644 index 00000000..16c5f78b --- /dev/null +++ b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts @@ -0,0 +1,107 @@ +import { + TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + type PersistedTaskChangePresence, + type PersistedTaskChangePresenceEntry, + type PersistedTaskChangePresenceIndex, +} from './taskChangePresenceCacheTypes'; + +function isIsoString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value)); +} + +function normalizePresence(value: unknown): PersistedTaskChangePresence | null { + return value === 'has_changes' || value === 'no_changes' ? value : null; +} + +function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + const normalizedPresence = normalizePresence(raw.presence); + if ( + typeof raw.taskSignature !== 'string' || + !normalizedPresence || + !isIsoString(raw.writtenAt) || + typeof raw.logSourceGeneration !== 'string' || + raw.logSourceGeneration.length === 0 + ) { + return null; + } + + return { + taskId, + taskSignature: raw.taskSignature, + presence: normalizedPresence, + writtenAt: raw.writtenAt, + logSourceGeneration: raw.logSourceGeneration, + }; +} + +export function normalizePersistedTaskChangePresenceIndex( + value: unknown +): PersistedTaskChangePresenceIndex | null { + if (!value || typeof value !== 'object') { + return null; + } + + const raw = value as Record; + if ( + raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION || + typeof raw.teamName !== 'string' || + typeof raw.projectFingerprint !== 'string' || + raw.projectFingerprint.length === 0 || + typeof raw.logSourceGeneration !== 'string' || + raw.logSourceGeneration.length === 0 || + !isIsoString(raw.writtenAt) || + !raw.entries || + typeof raw.entries !== 'object' + ) { + return null; + } + + const normalizedEntries: Record = {}; + for (const [taskId, entryValue] of Object.entries(raw.entries as Record)) { + if (typeof taskId !== 'string' || taskId.length === 0) { + continue; + } + const normalized = normalizeEntry(taskId, entryValue); + if (normalized) { + normalizedEntries[taskId] = normalized; + } + } + + return { + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: raw.teamName, + projectFingerprint: raw.projectFingerprint, + logSourceGeneration: raw.logSourceGeneration, + writtenAt: raw.writtenAt, + entries: normalizedEntries, + }; +} + +export function toPersistedTaskChangePresenceIndex( + value: PersistedTaskChangePresenceIndex +): PersistedTaskChangePresenceIndex { + return { + version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, + teamName: value.teamName, + projectFingerprint: value.projectFingerprint, + logSourceGeneration: value.logSourceGeneration, + writtenAt: value.writtenAt, + entries: Object.fromEntries( + Object.entries(value.entries).map(([taskId, entry]) => [ + taskId, + { + taskId, + taskSignature: entry.taskSignature, + presence: entry.presence, + writtenAt: entry.writtenAt, + logSourceGeneration: entry.logSourceGeneration, + }, + ]) + ), + }; +} diff --git a/src/main/services/team/cache/taskChangePresenceCacheTypes.ts b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts new file mode 100644 index 00000000..f06f853f --- /dev/null +++ b/src/main/services/team/cache/taskChangePresenceCacheTypes.ts @@ -0,0 +1,22 @@ +import type { TaskChangePresenceState } from '@shared/types/team'; + +export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1; + +export type PersistedTaskChangePresence = Exclude; + +export interface PersistedTaskChangePresenceEntry { + taskId: string; + taskSignature: string; + presence: PersistedTaskChangePresence; + writtenAt: string; + logSourceGeneration: string; +} + +export interface PersistedTaskChangePresenceIndex { + version: typeof TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION; + teamName: string; + projectFingerprint: string; + logSourceGeneration: string; + writtenAt: string; + entries: Record; +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 7cfb94e8..e2df6084 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -17,6 +17,7 @@ export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxWriter } from './TeamInboxWriter'; export { TeamKanbanManager } from './TeamKanbanManager'; export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +export { TeamLogSourceTracker } from './TeamLogSourceTracker'; export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMembersMetaStore } from './TeamMembersMetaStore'; export { TeamProvisioningService } from './TeamProvisioningService'; diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts new file mode 100644 index 00000000..58cc6c90 --- /dev/null +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -0,0 +1,152 @@ +import { + getTaskChangeStateBucket, + type TaskChangeStateBucket, +} from '@shared/utils/taskChangeState'; +import { createHash } from 'crypto'; + +export interface TaskChangePresenceInterval { + startedAt: string; + completedAt?: string; +} + +export interface TaskChangePresenceDescriptorInput { + owner?: string; + status?: string; + intervals?: TaskChangePresenceInterval[]; + since?: string; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; +} + +export interface TaskChangePresenceDescriptor { + stateBucket: TaskChangeStateBucket; + taskSignature: string; + effectiveOptions: { + owner?: string; + status?: string; + intervals?: TaskChangePresenceInterval[]; + since?: string; + }; +} + +function deriveIntervalsFromHistory( + historyEvents?: unknown[] +): TaskChangePresenceInterval[] | undefined { + if (!Array.isArray(historyEvents) || historyEvents.length === 0) { + return undefined; + } + + const transitions = historyEvents + .map((event) => + event && typeof event === 'object' ? (event as Record) : null + ) + .filter((event): event is Record => event !== null) + .filter((event) => event.type === 'status_changed') + .map((event) => ({ + to: typeof event.to === 'string' ? event.to : null, + timestamp: typeof event.timestamp === 'string' ? event.timestamp : null, + })) + .filter( + (transition): transition is { to: string; timestamp: string } => + transition.to !== null && transition.timestamp !== null + ) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + + if (transitions.length === 0) { + return undefined; + } + + const derived: TaskChangePresenceInterval[] = []; + let currentStart: string | null = null; + + for (const transition of transitions) { + if (transition.to === 'in_progress') { + if (!currentStart) { + currentStart = transition.timestamp; + } + continue; + } + + if (currentStart) { + derived.push({ startedAt: currentStart, completedAt: transition.timestamp }); + currentStart = null; + } + } + + if (currentStart) { + derived.push({ startedAt: currentStart }); + } + + return derived.length > 0 ? derived : undefined; +} + +export function normalizeTaskChangePresenceFilePath(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); +} + +export function computeTaskChangePresenceProjectFingerprint( + projectPath?: string | null +): string | null { + const normalizedProjectPath = typeof projectPath === 'string' ? projectPath.trim() : ''; + if (!normalizedProjectPath) { + return null; + } + + return createHash('sha256') + .update(normalizeTaskChangePresenceFilePath(normalizedProjectPath)) + .digest('hex'); +} + +export function buildTaskChangePresenceDescriptor( + input: TaskChangePresenceDescriptorInput +): TaskChangePresenceDescriptor { + const effectiveIntervals = + Array.isArray(input.intervals) && input.intervals.length > 0 + ? input.intervals.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt ?? '', + })) + : (deriveIntervalsFromHistory(input.historyEvents)?.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt ?? '', + })) ?? []); + + const stateBucket = getTaskChangeStateBucket({ + status: input.status, + reviewState: input.reviewState, + historyEvents: input.historyEvents, + kanbanColumn: input.kanbanColumn, + }); + + const effectiveOptions = { + owner: typeof input.owner === 'string' ? input.owner.trim() : '', + status: typeof input.status === 'string' ? input.status.trim() : '', + intervals: effectiveIntervals, + since: typeof input.since === 'string' ? input.since : '', + }; + + return { + stateBucket, + taskSignature: JSON.stringify({ + owner: effectiveOptions.owner, + status: effectiveOptions.status, + since: effectiveOptions.since, + stateBucket, + intervals: effectiveIntervals, + }), + effectiveOptions: { + owner: effectiveOptions.owner || undefined, + status: effectiveOptions.status || undefined, + intervals: + effectiveIntervals.length > 0 + ? effectiveIntervals.map((interval) => ({ + startedAt: interval.startedAt, + completedAt: interval.completedAt || undefined, + })) + : undefined, + since: effectiveOptions.since || undefined, + }, + }; +} diff --git a/src/main/services/team/taskChangeWorkerTypes.ts b/src/main/services/team/taskChangeWorkerTypes.ts new file mode 100644 index 00000000..5bc0f105 --- /dev/null +++ b/src/main/services/team/taskChangeWorkerTypes.ts @@ -0,0 +1,49 @@ +import type { TaskChangeSetV2 } from '@shared/types'; + +export interface TaskChangeTaskMeta { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; +} + +export interface TaskChangeEffectiveOptions { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; +} + +export interface ResolvedTaskChangeComputeInput { + teamName: string; + taskId: string; + taskMeta: TaskChangeTaskMeta | null; + effectiveOptions: TaskChangeEffectiveOptions; + projectPath?: string; + includeDetails: boolean; +} + +export interface ComputeTaskChangesRequest { + id: string; + op: 'computeTaskChanges'; + payload: ResolvedTaskChangeComputeInput; +} + +export interface ComputeTaskChangesSuccessResponse { + id: string; + ok: true; + result: TaskChangeSetV2; +} + +export interface ComputeTaskChangesErrorResponse { + id: string; + ok: false; + error: string; +} + +export type TaskChangeWorkerRequest = ComputeTaskChangesRequest; +export type TaskChangeWorkerResponse = + | ComputeTaskChangesSuccessResponse + | ComputeTaskChangesErrorResponse; diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 4590181c..f302ff1a 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -393,6 +393,10 @@ export function getTaskChangeSummariesBasePath(): string { return path.join(getClaudeBasePath(), 'task-change-summaries'); } +export function getTaskChangePresenceBasePath(): string { + return path.join(getClaudeBasePath(), 'task-change-presence'); +} + /** * Get the backups directory path for the app's own storage. */ diff --git a/src/main/workers/task-change-worker.ts b/src/main/workers/task-change-worker.ts new file mode 100644 index 00000000..06f92042 --- /dev/null +++ b/src/main/workers/task-change-worker.ts @@ -0,0 +1,40 @@ +import { parentPort } from 'node:worker_threads'; + +import { TaskBoundaryParser } from '@main/services/team/TaskBoundaryParser'; +import { TaskChangeComputer } from '@main/services/team/TaskChangeComputer'; +import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; + +import type { + TaskChangeWorkerRequest, + TaskChangeWorkerResponse, +} from '@main/services/team/taskChangeWorkerTypes'; + +const logsFinder = new TeamMemberLogsFinder(); +const boundaryParser = new TaskBoundaryParser(); +const computer = new TaskChangeComputer(logsFinder, boundaryParser); + +function postMessage(message: TaskChangeWorkerResponse): void { + parentPort?.postMessage(message); +} + +parentPort?.on('message', async (message: TaskChangeWorkerRequest) => { + if (!message || message.op !== 'computeTaskChanges') { + postMessage({ + id: message?.id ?? 'unknown', + ok: false, + error: `Unsupported task change worker op: ${String(message?.op)}`, + }); + return; + } + + try { + const result = await computer.computeTaskChanges(message.payload); + postMessage({ id: message.id, ok: true, result }); + } catch (error) { + postMessage({ + id: message.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 7ced81a6..30ac6dee 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -210,6 +210,12 @@ export const TEAM_LIST = 'team:list'; /** Get detailed team data */ export const TEAM_GET_DATA = 'team:getData'; +/** Get lightweight task change presence map for the currently viewed team */ +export const TEAM_GET_TASK_CHANGE_PRESENCE = 'team:getTaskChangePresence'; + +/** Enable or disable task change presence tracking for a visible team tab */ +export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking'; + /** Get buffered Claude CLI logs (paged, newest-first) */ export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs'; diff --git a/src/preload/index.ts b/src/preload/index.ts index e9f97771..df252ede 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,6 +121,7 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -148,6 +149,7 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_TASK_CLARIFICATION, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -260,6 +262,7 @@ import type { SshConnectionStatus, SshLastConnection, TaskAttachmentMeta, + TaskChangePresenceState, TaskChangeSetV2, TaskComment, TeamChangeEvent, @@ -800,6 +803,15 @@ const electronAPI: ElectronAPI = { getData: async (teamName: string) => { return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, + getTaskChangePresence: async (teamName: string) => { + return invokeIpcWithResult>( + TEAM_GET_TASK_CHANGE_PRESENCE, + teamName + ); + }, + setChangePresenceTracking: async (teamName: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled); + }, getClaudeLogs: async (teamName: string, query?: TeamClaudeLogsQuery) => { return invokeIpcWithResult(TEAM_GET_CLAUDE_LOGS, teamName, query); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 2160f340..20a6568a 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -668,6 +668,14 @@ export class HttpAPIClient implements ElectronAPI { getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, + getTaskChangePresence: async (): Promise< + Record + > => { + return {}; + }, + setChangePresenceTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, getClaudeLogs: async ( _teamName: string, _query?: TeamClaudeLogsQuery diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx index c43125ff..e3466adf 100644 --- a/src/renderer/components/layout/TeamTabSectionNav.tsx +++ b/src/renderer/components/layout/TeamTabSectionNav.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { useStore } from '@renderer/store'; import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; @@ -22,11 +23,16 @@ export const TeamTabSectionNav = ({ teamName, onActivate, }: TeamTabSectionNavProps): React.JSX.Element => { + const messagesPanelMode = useStore((s) => s.messagesPanelMode); const [open, setOpen] = useState(false); const [hoveredId, setHoveredId] = useState(null); const buttonRef = useRef(null); const menuRef = useRef(null); const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); + const visibleSections = SECTIONS.filter( + (section) => + messagesPanelMode !== 'sidebar' || (section.id !== 'messages' && section.id !== 'claude-logs') + ); const handleNavigate = useCallback( (sectionId: string) => { @@ -99,7 +105,7 @@ export const TeamTabSectionNav = ({ if (e.key === 'Escape') setOpen(false); }} > - {SECTIONS.map((section) => { + {visibleSections.map((section) => { const SectionIcon = section.icon; return ( @@ -215,246 +216,201 @@ const TaskActionIconButton = ({ ); -export const KanbanTaskCard = ({ - task, - teamName, - columnId, - kanbanTaskState, - hasReviewers, - compact, - taskMap, - members, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onDeleteTask, -}: KanbanTaskCardProps): React.JSX.Element => { - const colorMap = useMemo(() => buildMemberColorMap(members), [members]); - const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); - const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; - const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; - const hasBlockedBy = blockedByIds.length > 0; - const hasBlocks = blocksIds.length > 0; - - // Lazy-check if task has file changes - const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); - const canDisplay = useMemo( - () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, - [taskChangeRequestOptions, onViewChanges] - ); - const cacheKey = useMemo( - () => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions), - [teamName, task.id, taskChangeRequestOptions] - ); - const taskHasChanges = useStore((s) => s.taskHasChanges[cacheKey]); - const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); - - useEffect(() => { - if (canDisplay && taskHasChanges === undefined) { - void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions); - } - }, [ - canDisplay, - task.id, +export const KanbanTaskCard = memo( + function KanbanTaskCard({ + task, teamName, - taskHasChanges, - checkTaskHasChanges, - taskChangeRequestOptions, - ]); + columnId, + kanbanTaskState, + hasReviewers, + compact, + taskMap, + memberColorMap, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onDeleteTask, + }: KanbanTaskCardProps): React.JSX.Element { + const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); + const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? []; + const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? []; + const hasBlockedBy = blockedByIds.length > 0; + const hasBlocks = blocksIds.length > 0; - const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; - const metaActions = ( - <> - {canDisplay && taskHasChanges === true ? ( - } - variant="ghost" - className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" - onClick={(e) => { - e.stopPropagation(); - onViewChanges!(task.id); - }} - /> - ) : null} - - {onDeleteTask ? ( - } - variant="ghost" - className="text-red-400 hover:bg-red-500/10 hover:text-red-300" - onClick={(e) => { - e.stopPropagation(); - onDeleteTask(task.id); - }} - /> - ) : null} - - ); + const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); + const canDisplay = useMemo( + () => canDisplayTaskChangesForOptions(taskChangeRequestOptions) && !!onViewChanges, + [taskChangeRequestOptions, onViewChanges] + ); - return ( -
onTaskClick?.(task)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onTaskClick?.(task); - } - }} - > - - {formatTaskDisplayLabel(task)} - - {task.owner ? ( - - + const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; + const metaActions = ( + <> + {canDisplay && task.changePresence === 'has_changes' ? ( + } + variant="ghost" + className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300" + onClick={(e) => { + e.stopPropagation(); + onViewChanges!(task.id); + }} + /> + ) : canDisplay && task.changePresence === 'no_changes' ? ( + + No changes + + ) : null} + + {onDeleteTask ? ( + } + variant="ghost" + className="text-red-400 hover:bg-red-500/10 hover:text-red-300" + onClick={(e) => { + e.stopPropagation(); + onDeleteTask(task.id); + }} + /> + ) : null} + + ); + + return ( +
onTaskClick?.(task)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onTaskClick?.(task); + } + }} + > + + {formatTaskDisplayLabel(task)} - ) : null} -
- {!compact && } - {task.needsClarification ? ( - - - {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} + {task.owner ? ( + + ) : null} - {task.reviewState === 'needsFix' ? ( - - {REVIEW_STATE_DISPLAY.needsFix.label} - +
+ {!compact && } + {task.needsClarification ? ( + + + {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'} + + ) : null} + {task.reviewState === 'needsFix' ? ( + + {REVIEW_STATE_DISPLAY.needsFix.label} + + ) : null} + {compact && } +
+ + {hasBlockedBy ? ( +
+ + + Blocked by + + {blockedByIds.map((id) => ( + + ))} +
) : null} - {compact && } -
- {hasBlockedBy ? ( -
- - - Blocked by - - {blockedByIds.map((id) => ( - - ))} -
- ) : null} - - {hasBlocks ? ( -
- - - Blocks - - {blocksIds.map((id) => ( - - ))} -
- ) : null} - -
-
- {columnId === 'todo' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onStartTask(task.id); - }} + {hasBlocks ? ( +
+ + + Blocks + + {blocksIds.map((id) => ( + - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - ) : null} + ))} +
+ ) : null} - {columnId === 'in_progress' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onCompleteTask(task.id); - }} - /> - - - ) : null} +
+
+ {columnId === 'todo' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onStartTask(task.id); + }} + /> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(task.id); + }} + /> + + ) : null} - {columnId === 'done' ? ( - <> - } - className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" - onClick={(e) => { - e.stopPropagation(); - onApprove(task.id); - }} - /> - } - className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" - onClick={(e) => { - e.stopPropagation(); - onRequestReview(task.id); - }} - /> - - ) : null} + {columnId === 'in_progress' ? ( + <> + } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onCompleteTask(task.id); + }} + /> + + + ) : null} - {columnId === 'review' ? ( -
- {isReviewManual ? ( -
- Manual review -
- ) : null} -
+ {columnId === 'done' ? ( + <> } @@ -465,34 +421,84 @@ export const KanbanTaskCard = ({ }} /> } - variant="destructive" - className="bg-red-500/90 text-white hover:bg-red-500" + label="Request review" + icon={} + className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300" onClick={(e) => { e.stopPropagation(); - onRequestChanges(task.id); + onRequestReview(task.id); }} /> + + ) : null} + + {columnId === 'review' ? ( +
+ {isReviewManual ? ( +
+ Manual review +
+ ) : null} +
+ } + className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" + onClick={(e) => { + e.stopPropagation(); + onApprove(task.id); + }} + /> + } + variant="destructive" + className="bg-red-500/90 text-white hover:bg-red-500" + onClick={(e) => { + e.stopPropagation(); + onRequestChanges(task.id); + }} + /> +
-
- ) : null} + ) : null} - {columnId === 'approved' ? ( - } - className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" - onClick={(e) => { - e.stopPropagation(); - onMoveBackToDone(task.id); - }} - /> - ) : null} + {columnId === 'approved' ? ( + } + className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300" + onClick={(e) => { + e.stopPropagation(); + onMoveBackToDone(task.id); + }} + /> + ) : null} +
+ +
{metaActions}
- -
{metaActions}
-
- ); -}; + ); + }, + (prev, next) => + prev.task === next.task && + prev.teamName === next.teamName && + prev.columnId === next.columnId && + prev.kanbanTaskState === next.kanbanTaskState && + prev.hasReviewers === next.hasReviewers && + prev.compact === next.compact && + prev.taskMap === next.taskMap && + prev.memberColorMap === next.memberColorMap && + prev.onRequestReview === next.onRequestReview && + prev.onApprove === next.onApprove && + prev.onRequestChanges === next.onRequestChanges && + prev.onMoveBackToDone === next.onMoveBackToDone && + prev.onStartTask === next.onStartTask && + prev.onCompleteTask === next.onCompleteTask && + prev.onCancelTask === next.onCancelTask && + prev.onScrollToTask === next.onScrollToTask && + prev.onTaskClick === next.onTaskClick && + prev.onViewChanges === next.onViewChanges && + prev.onDeleteTask === next.onDeleteTask +); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 28c9506e..418a64e8 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -5,6 +5,11 @@ import { api } from '@renderer/api'; import { syncRendererTelemetry } from '@renderer/sentry'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; +import { + buildTaskChangePresenceKey, + buildTaskChangeRequestOptions, + canDisplayTaskChangesForOptions, +} from '@renderer/utils/taskChangeRequest'; import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; @@ -41,6 +46,9 @@ import type { UpdaterStatus, } from '@shared/types'; +const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false; +const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000; + // ============================================================================= // Store Creation // ============================================================================= @@ -135,15 +143,25 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); }); + const inProgressChangePresencePollTimer = setInterval(() => { + void pollVisibleTeamInProgressChangePresence(); + }, IN_PROGRESS_CHANGE_PRESENCE_POLL_MS); + cleanupFns.push(() => { + clearInterval(inProgressChangePresencePollTimer); + }); const pendingSessionRefreshTimers = new Map>(); const pendingProjectRefreshTimers = new Map>(); let teamRefreshTimers = new Map>(); + let teamPresenceRefreshTimers = new Map>(); + let inProgressChangePresencePollInFlight = false; + const inProgressChangePresenceCursorByTeam = new Map(); let teamListRefreshTimer: ReturnType | null = null; let globalTasksRefreshTimer: ReturnType | null = null; const SESSION_REFRESH_DEBOUNCE_MS = 150; const PROJECT_REFRESH_DEBOUNCE_MS = 300; const TEAM_REFRESH_THROTTLE_MS = 800; + const TEAM_PRESENCE_REFRESH_THROTTLE_MS = 400; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; const getBaseProjectId = (projectId: string | null | undefined): string | null => { @@ -152,6 +170,69 @@ export function initializeNotificationListeners(): () => void { return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId; }; + const pollVisibleTeamInProgressChangePresence = async (): Promise => { + if (inProgressChangePresencePollInFlight) { + return; + } + + const state = useStore.getState(); + const selectedTeamName = state.selectedTeamName; + const selectedTeamData = state.selectedTeamData; + if ( + !selectedTeamName || + !selectedTeamData || + selectedTeamData.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return; + } + + const candidateTasks = selectedTeamData.tasks.filter((task) => { + if (task.status !== 'in_progress') { + return false; + } + return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task)); + }); + if (candidateTasks.length === 0) { + inProgressChangePresenceCursorByTeam.delete(selectedTeamName); + return; + } + + inProgressChangePresencePollInFlight = true; + try { + const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0; + const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); + const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; + const nextTask = sourceTasks[cursor % sourceTasks.length]; + + inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length); + + const current = useStore.getState(); + if ( + current.selectedTeamName !== selectedTeamName || + !current.selectedTeamData || + current.selectedTeamData.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return; + } + + const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id); + if (!currentTask || currentTask.status !== 'in_progress') { + return; + } + + const requestOptions = buildTaskChangeRequestOptions(currentTask); + const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions); + current.invalidateTaskChangePresence([cacheKey]); + await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions); + } catch { + // Best-effort polling for in-progress tasks only. + } finally { + inProgressChangePresencePollInFlight = false; + } + }; + const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { const key = `${projectId}/${sessionId}`; // Throttle (not trailing debounce): keep at most one pending refresh per session. @@ -257,6 +338,61 @@ export function initializeNotificationListeners(): () => void { }); }; + const getTrackedChangePresenceTeams = (): Set => { + const { selectedTeamName, selectedTeamData } = useStore.getState(); + if ( + !selectedTeamName || + !selectedTeamData || + selectedTeamData.teamName !== selectedTeamName || + !isTeamVisibleInAnyPane(selectedTeamName) + ) { + return new Set(); + } + return new Set([selectedTeamName]); + }; + + if (ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING && api.teams?.setChangePresenceTracking) { + let trackedTeamNames = new Set(); + const syncVisibleTeamTracking = (): void => { + const nextTrackedTeamNames = getTrackedChangePresenceTeams(); + + for (const teamName of nextTrackedTeamNames) { + if (!trackedTeamNames.has(teamName)) { + void api.teams.setChangePresenceTracking(teamName, true).catch(() => undefined); + } + } + + for (const teamName of trackedTeamNames) { + if (!nextTrackedTeamNames.has(teamName)) { + void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined); + } + } + + trackedTeamNames = nextTrackedTeamNames; + }; + + syncVisibleTeamTracking(); + + const unsubscribeVisibleTeamTracking = useStore.subscribe((state, prevState) => { + if ( + state.paneLayout === prevState.paneLayout && + state.selectedTeamName === prevState.selectedTeamName && + state.selectedTeamData === prevState.selectedTeamData + ) { + return; + } + syncVisibleTeamTracking(); + }); + + cleanupFns.push(() => { + unsubscribeVisibleTeamTracking(); + for (const teamName of trackedTeamNames) { + void api.teams.setChangePresenceTracking(teamName, false).catch(() => undefined); + } + trackedTeamNames.clear(); + }); + } + // Listen for task-list file changes to refresh currently viewed session metadata if (api.onTodoChange) { const cleanup = api.onTodoChange((event) => { @@ -474,6 +610,22 @@ export function initializeNotificationListeners(): () => void { return; } + if (event.type === 'log-source-change') { + if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { + return; + } + if (teamPresenceRefreshTimers.has(event.teamName)) { + return; + } + const timer = setTimeout(() => { + teamPresenceRefreshTimers.delete(event.teamName); + const current = useStore.getState(); + void current.refreshSelectedTeamChangePresence(event.teamName); + }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); + teamPresenceRefreshTimers.set(event.teamName, timer); + return; + } + // Throttled refresh of summary list (keeps TeamListView current without flooding). if (!teamListRefreshTimer) { teamListRefreshTimer = setTimeout(() => { @@ -513,6 +665,8 @@ export function initializeNotificationListeners(): () => void { cleanup(); for (const t of teamRefreshTimers.values()) clearTimeout(t); teamRefreshTimers = new Map(); + for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t); + teamPresenceRefreshTimers = new Map(); if (teamListRefreshTimer) { clearTimeout(teamListRefreshTimer); teamListRefreshTimer = null; diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index aceba6dd..98044fb2 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -16,6 +16,7 @@ const taskChangesPresenceRevalidationInFlight = new Set(); /** Negative results cached with timestamp — recheck after 30s */ const taskChangesNegativeCache = new Map(); const NEGATIVE_CACHE_TTL = 30_000; +const TASK_CHANGE_WARM_CONCURRENCY = 4; const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now(); let latestTaskChangesRequestToken = 0; @@ -77,6 +78,16 @@ function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean { return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME; } +function resolveTaskChangePresenceFromResult( + data: Pick +): 'has_changes' | 'no_changes' | null { + if (data.files.length > 0) { + return 'has_changes'; + } + + return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; +} + export interface ChangeReviewSlice { // Phase 1 state activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null; @@ -503,10 +514,14 @@ export const createChangeReviewSlice: StateCreator 0 }, }); + if (nextPresence) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence); + } if (data.files.length > 0) { taskChangesNegativeCache.delete(cacheKey); } else { @@ -1310,12 +1325,20 @@ export const createChangeReviewSlice: StateCreator { + const selectedTask = + get().selectedTeamName === teamName + ? get().selectedTeamData?.tasks.find((task) => task.id === taskId) + : undefined; const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); const summaryCacheable = isTaskSummaryCacheableForOptions(options); - if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return; + if (summaryCacheable && get().taskHasChanges[cacheKey] === true) { + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); + return; + } if (taskChangesCheckInFlight.has(cacheKey)) return; const negativeTs = taskChangesNegativeCache.get(cacheKey); - if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return; + const hasUnknownPresence = selectedTask?.changePresence === 'unknown'; + if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL && !hasUnknownPresence) return; taskChangesCheckInFlight.add(cacheKey); try { @@ -1323,11 +1346,13 @@ export const createChangeReviewSlice: StateCreator 0) { set((s) => ({ taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, })); taskChangesNegativeCache.delete(cacheKey); + get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes'); if (wasRestoredBeforeCurrentSession(data)) { void revalidateTaskChangePresence(teamName, taskId, options); } @@ -1336,6 +1361,11 @@ export const createChangeReviewSlice: StateCreator { - if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) - return; + const entries = [...uniqueRequests.entries()]; + const runWarmRequest = async ( + cacheKey: string, + request: { teamName: string; taskId: string; options: TaskChangeRequestOptions } + ): Promise => { + if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) { + return; + } - taskChangesCheckInFlight.add(cacheKey); - try { - const data = await api.review.getTaskChanges(request.teamName, request.taskId, { - ...request.options, - summaryOnly: true, - }); - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, - })); - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - if (wasRestoredBeforeCurrentSession(data)) { - void revalidateTaskChangePresence( - request.teamName, - request.taskId, - request.options - ); - } - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); + taskChangesCheckInFlight.add(cacheKey); + try { + const data = await api.review.getTaskChanges(request.teamName, request.taskId, { + ...request.options, + summaryOnly: true, + }); + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, + })); + if (data.files.length > 0) { + taskChangesNegativeCache.delete(cacheKey); + if (wasRestoredBeforeCurrentSession(data)) { + void revalidateTaskChangePresence(request.teamName, request.taskId, request.options); } - } catch { - // Best-effort warm path. - } finally { - taskChangesCheckInFlight.delete(cacheKey); + } else { + taskChangesNegativeCache.set(cacheKey, Date.now()); } - }) - ); + } catch { + // Best-effort warm path. + } finally { + taskChangesCheckInFlight.delete(cacheKey); + } + }; + + for (let index = 0; index < entries.length; index += TASK_CHANGE_WARM_CONCURRENCY) { + await Promise.all( + entries + .slice(index, index + TASK_CHANGE_WARM_CONCURRENCY) + .map(([cacheKey, request]) => runWarmRequest(cacheKey, request)) + ); + } }, invalidateTaskChangePresence: (cacheKeys) => { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 7d84b85c..05d0c52c 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -3,7 +3,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, - isTaskSummaryCacheableForOptions, + canDisplayTaskChangesForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; @@ -57,6 +57,43 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } +async function refreshTaskChangePresenceForUpdatedTask( + getState: () => AppState, + teamName: string, + taskId: string +): Promise { + const state = getState(); + if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + return; + } + + const task = state.selectedTeamData.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + return; + } + + const options = buildTaskChangeRequestOptions(task); + if (!canDisplayTaskChangesForOptions(options)) { + return; + } + + if ( + typeof state.invalidateTaskChangePresence !== 'function' || + typeof state.checkTaskHasChanges !== 'function' + ) { + return; + } + + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); + state.invalidateTaskChangePresence([cacheKey]); + + try { + await state.checkTaskHasChanges(teamName, taskId, options); + } catch { + // Best-effort refresh after explicit task transition. + } +} + async function pollProvisioningStatus( getState: () => TeamSlice, runId: string, @@ -105,6 +142,7 @@ import type { SendMessageRequest, SendMessageResult, TaskComment, + TaskChangePresenceState, TeamCreateRequest, TeamData, TeamLaunchRequest, @@ -445,19 +483,6 @@ function collectTaskChangeInvalidationState( }; } -function buildTaskChangeWarmRequests( - teamName: string, - tasks: TeamData['tasks'] -): { teamName: string; taskId: string; options: TaskChangeRequestOptions }[] { - return tasks.flatMap((task) => { - const options = buildTaskChangeRequestOptions(task); - if (!isTaskSummaryCacheableForOptions(options)) { - return []; - } - return [{ teamName, taskId: task.id, options }]; - }); -} - function mapSendMessageError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; @@ -556,6 +581,12 @@ export interface TeamSlice { openTeamsTab: () => void; openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void; clearKanbanFilter: () => void; + setSelectedTeamTaskChangePresence: ( + teamName: string, + taskId: string, + presence: TaskChangePresenceState + ) => void; + refreshSelectedTeamChangePresence: (teamName: string) => Promise; selectTeam: ( teamName: string, opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } @@ -1099,6 +1130,89 @@ export const createTeamSlice: StateCreator = (set, set({ kanbanFilterQuery: null }); }, + setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => { + set((state) => { + let selectedChanged = false; + const nextSelectedTeamData = + state.selectedTeamName === teamName && state.selectedTeamData + ? { + ...state.selectedTeamData, + tasks: state.selectedTeamData.tasks.map((task) => { + if (task.id !== taskId || task.changePresence === presence) { + return task; + } + selectedChanged = true; + return { ...task, changePresence: presence }; + }), + } + : state.selectedTeamData; + + let globalChanged = false; + const nextGlobalTasks = state.globalTasks.map((task) => { + if (task.teamName !== teamName || task.id !== taskId || task.changePresence === presence) { + return task; + } + globalChanged = true; + return { ...task, changePresence: presence }; + }); + + if (!selectedChanged && !globalChanged) { + return {}; + } + + return { + ...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}), + ...(globalChanged ? { globalTasks: nextGlobalTasks } : {}), + }; + }); + }, + + refreshSelectedTeamChangePresence: async (teamName: string) => { + const selected = get().selectedTeamData; + if (get().selectedTeamName !== teamName || !selected) { + return; + } + + try { + const presenceByTaskId = await unwrapIpc('team:getTaskChangePresence', () => + api.teams.getTaskChangePresence(teamName) + ); + + if (get().selectedTeamName !== teamName || !get().selectedTeamData) { + return; + } + + set((state) => { + if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + return {}; + } + + let changed = false; + const nextTasks = state.selectedTeamData.tasks.map((task) => { + const nextPresence = presenceByTaskId[task.id] ?? 'unknown'; + if (task.changePresence === nextPresence) { + return task; + } + changed = true; + return { ...task, changePresence: nextPresence }; + }); + + if (!changed) { + return {}; + } + + return { + selectedTeamData: { + ...state.selectedTeamData, + tasks: nextTasks, + }, + }; + }); + } catch { + // best-effort lightweight refresh; keep current UI state on failure + } + }, + selectTeam: async (teamName: string, opts) => { const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. @@ -1170,11 +1284,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); - if (warmRequests.length > 0) { - void get().warmTaskChangeSummaries(warmRequests); - } - // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; const allTabs = get().getAllPaneTabs(); @@ -1295,10 +1404,6 @@ export const createTeamSlice: StateCreator = (set, if (invalidationState.taskIds.length > 0) { await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); } - const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); - if (warmRequests.length > 0) { - void get().warmTaskChangeSummaries(warmRequests); - } } catch (error) { if (get().selectedTeamName !== teamName) { return; @@ -1424,6 +1529,7 @@ export const createTeamSlice: StateCreator = (set, set({ reviewActionError: null }); await unwrapIpc('team:requestReview', () => api.teams.requestReview(teamName, taskId)); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); } catch (error) { set({ reviewActionError: mapReviewError(error), @@ -1441,6 +1547,7 @@ export const createTeamSlice: StateCreator = (set, startTask: async (teamName: string, taskId: string) => { const result = await unwrapIpc('team:startTask', () => api.teams.startTask(teamName, taskId)); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); return result; }, @@ -1449,6 +1556,7 @@ export const createTeamSlice: StateCreator = (set, api.teams.startTaskByUser(teamName, taskId) ); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); return result; }, @@ -1457,6 +1565,7 @@ export const createTeamSlice: StateCreator = (set, api.teams.updateTaskStatus(teamName, taskId, status) ); await get().refreshTeamData(teamName); + void refreshTaskChangePresenceForUpdatedTask(get, teamName, taskId); }, updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 5bae8b21..88aa7202 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -73,6 +73,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TaskChangePresenceState, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -416,6 +417,8 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; getData: (teamName: string) => Promise; + getTaskChangePresence: (teamName: string) => Promise>; + setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise; deleteTeam: (teamName: string) => Promise; restoreTeam: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f87cba50..74afbd92 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -218,11 +218,15 @@ export interface TeamTask { } /** Task enriched for UI/DTO use (overlay from kanban-state.json). */ +export type TaskChangePresenceState = 'has_changes' | 'no_changes' | 'unknown'; + export interface TeamTaskWithKanban extends TeamTask { /** Set when task is in team kanban (review or approved column). */ kanbanColumn?: 'review' | 'approved'; /** Reviewer assigned in kanban state, when applicable. */ reviewer?: string | null; + /** Cheap persisted change-presence state for kanban rendering. */ + changePresence?: TaskChangePresenceState; } /** Metadata for an attachment associated with a task or comment. */ @@ -502,6 +506,7 @@ export interface TeamChangeEvent { type: | 'config' | 'inbox' + | 'log-source-change' | 'task' | 'lead-activity' | 'lead-context' diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 78905fc0..1dba68ff 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -43,6 +43,7 @@ import { TEAM_PROVISIONING_STATUS, TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, + TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_GET_ALL_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -56,6 +57,7 @@ import { TEAM_ADD_TASK_COMMENT, TEAM_GET_ATTACHMENTS, TEAM_GET_DELETED_TASKS, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_PROJECT_BRANCH, TEAM_KILL_PROCESS, TEAM_LEAD_ACTIVITY, @@ -105,7 +107,9 @@ describe('ipc teams handlers', () => { kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], })), + getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })), reconcileTeamArtifacts: vi.fn(async () => undefined), + setTaskChangePresenceTracking: vi.fn(() => undefined), deleteTeam: vi.fn(async () => undefined), getLeadMemberName: vi.fn(async () => 'team-lead'), getTeamDisplayName: vi.fn(async () => 'My Team'), @@ -175,6 +179,8 @@ describe('ipc teams handlers', () => { it('registers all expected handlers', () => { expect(handlers.has(TEAM_LIST)).toBe(true); expect(handlers.has(TEAM_GET_DATA)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true); + expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true); expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true); expect(handlers.has(TEAM_PREPARE_PROVISIONING)).toBe(true); expect(handlers.has(TEAM_CREATE)).toBe(true); @@ -224,6 +230,32 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(true); }); + it('updates change presence tracking for a team', async () => { + const handler = handlers.get(TEAM_SET_CHANGE_PRESENCE_TRACKING); + expect(handler).toBeDefined(); + + const result = (await handler!({} as never, 'my-team', true)) as { + success: boolean; + data?: void; + }; + + expect(result.success).toBe(true); + expect(service.setTaskChangePresenceTracking).toHaveBeenCalledWith('my-team', true); + }); + + it('returns lightweight task change presence for a team', async () => { + const handler = handlers.get(TEAM_GET_TASK_CHANGE_PRESENCE); + expect(handler).toBeDefined(); + + const result = (await handler!({} as never, 'my-team')) as { + success: boolean; + data?: Record; + }; + + expect(result).toEqual({ success: true, data: { 'task-1': 'has_changes' } }); + expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team'); + }); + it('returns success false on invalid sendMessage args', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index fd312b2a..6dfe9f72 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs/promises'; import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService'; +import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; const TEAM_NAME = 'team-a'; @@ -70,31 +71,134 @@ function persistedEntryPath(baseDir: string): string { return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeTaskChangeResult( + taskId = TASK_ID, + overrides: Partial<{ + teamName: string; + taskId: string; + filePath: string; + confidence: 'high' | 'medium' | 'low' | 'fallback'; + content: string; + warning: string; + }> = {} +) { + const teamName = overrides.teamName ?? TEAM_NAME; + const targetTaskId = overrides.taskId ?? taskId; + const filePath = overrides.filePath ?? '/repo/src/file.ts'; + const content = overrides.content ?? 'export const value = 1;\n'; + const confidence = overrides.confidence ?? 'high'; + const confidenceTierByLabel = { + high: 1, + medium: 2, + low: 3, + fallback: 4, + } as const; + const files = + content.length > 0 + ? [ + { + filePath, + relativePath: 'src/file.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ] + : []; + + return { + teamName, + taskId: targetTaskId, + files, + totalFiles: files.length, + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), + confidence, + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: targetTaskId, + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: files.map((file) => file.filePath), + confidence: { + tier: confidenceTierByLabel[confidence], + label: confidence, + reason: 'test fixture', + }, + }, + warnings: overrides.warning ? [overrides.warning] : [], + }; +} + function createService(params: { logPaths: string[]; projectPath?: string; findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise; + taskChangePresenceRepository?: { upsertEntry: ReturnType }; + teamLogSourceTracker?: { + ensureTracking: ReturnType< + typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>> + >; + }; + taskChangeWorkerClient?: { + isAvailable: ReturnType boolean>>; + computeTaskChanges: ReturnType Promise>>; + }; }) { const findLogFileRefsForTask = params.findLogFileRefsForTask ?? vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' }))); + const taskChangeWorkerClient = + params.taskChangeWorkerClient ?? + ({ + isAvailable: vi.fn(() => false), + computeTaskChanges: vi.fn(async () => { + throw new Error('worker disabled in test'); + }), + } as const); + const service = new ChangeExtractorService( + { + findLogFileRefsForTask, + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => ({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + })), + } as any, + { getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any, + undefined, + taskChangeWorkerClient as any + ); + + if (params.taskChangePresenceRepository && params.teamLogSourceTracker) { + service.setTaskChangePresenceServices( + params.taskChangePresenceRepository as any, + params.teamLogSourceTracker as any + ); + } + return { findLogFileRefsForTask, - service: new ChangeExtractorService( - { - findLogFileRefsForTask, - findMemberLogPaths: vi.fn(async () => []), - } as any, - { - parseBoundaries: vi.fn(async () => ({ - boundaries: [], - scopes: [], - isSingleTaskSession: true, - detectedMechanism: 'none' as const, - })), - } as any, - { getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any - ), + service, }; } @@ -337,7 +441,14 @@ describe('ChangeExtractorService', () => { detectedMechanism: 'none' as const, })), } as any, - { getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any + { getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any, + undefined, + { + isAvailable: vi.fn(() => false), + computeTaskChanges: vi.fn(async () => { + throw new Error('worker disabled in test'); + }), + } as any ); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); @@ -373,4 +484,265 @@ describe('ChangeExtractorService', () => { expect(result.files[0]?.relativePath).toBe('src/same.ts'); expect(result.totalLinesAdded).toBe(2); }); + + it('prefers worker task-change results when the worker is available', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const workerResult = makeTaskChangeResult(); + const computeTaskChanges = vi.fn(async () => workerResult); + const { service, findLogFileRefsForTask } = createService({ + logPaths: [], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => true), + computeTaskChanges, + }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + }); + + expect(result).toEqual(workerResult); + expect(computeTaskChanges).toHaveBeenCalledTimes(1); + expect(findLogFileRefsForTask).not.toHaveBeenCalled(); + }); + + it('falls back inline when task-change worker is unavailable', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const computeTaskChanges = vi.fn(); + const { service, findLogFileRefsForTask } = createService({ + logPaths: [logPath], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => false), + computeTaskChanges, + }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + }); + + expect(result.files).toHaveLength(1); + expect(findLogFileRefsForTask).toHaveBeenCalled(); + expect(computeTaskChanges).not.toHaveBeenCalled(); + }); + + it('falls back inline when task-change worker throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const computeTaskChanges = vi.fn(async () => { + throw new Error('worker failed'); + }); + const { service, findLogFileRefsForTask } = createService({ + logPaths: [logPath], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => true), + computeTaskChanges, + }, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + }); + + expect(result.files).toHaveLength(1); + expect(computeTaskChanges).toHaveBeenCalledTimes(1); + expect(findLogFileRefsForTask).toHaveBeenCalled(); + }); + + it('keeps summary cache in main and skips worker on repeat terminal summary requests', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const computeTaskChanges = vi.fn(async () => makeTaskChangeResult()); + const { service } = createService({ + logPaths: [logPath], + taskChangeWorkerClient: { + isAvailable: vi.fn(() => true), + computeTaskChanges, + }, + }); + + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(computeTaskChanges).toHaveBeenCalledTimes(1); + }); + + it('restores persisted summaries without invoking worker compute', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const firstWorker = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult()), + }; + await createService({ + logPaths: [logPath], + taskChangeWorkerClient: firstWorker, + }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + const secondWorker = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: 'stale\n' })), + }; + const restored = await createService({ + logPaths: [logPath], + taskChangeWorkerClient: secondWorker, + }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(restored.files).toHaveLength(1); + expect(secondWorker.computeTaskChanges).not.toHaveBeenCalled(); + }); + + it('does not let stale worker results populate summary cache after invalidation', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const first = deferred>(); + const worker = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi + .fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(async () => + makeTaskChangeResult(TASK_ID, { filePath: '/repo/src/newer.ts' }) + ), + }; + const { service } = createService({ + logPaths: [], + taskChangeWorkerClient: worker, + }); + + const stalePromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await service.invalidateTaskChangeSummaries(TEAM_NAME, [TASK_ID], { deletePersisted: true }); + const freshPromise = service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + first.resolve(makeTaskChangeResult()); + const stale = await stalePromise; + const fresh = await freshPromise; + + expect(stale.files[0]?.filePath).toBe('/repo/src/file.ts'); + expect(fresh.files[0]?.filePath).toBe('/repo/src/newer.ts'); + expect(worker.computeTaskChanges).toHaveBeenCalledTimes(2); + }); + + it('writes has_changes presence entries after successful task diff computation', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-presence.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry( + 'tool-1', + '/repo/src/file.ts', + 'export const value = 1;\n', + '2026-03-01T10:00:00.000Z' + ), + ]); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })); + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult()), + }; + const { service } = createService({ + logPaths: [logPath], + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + taskChangeWorkerClient: workerClient, + }); + + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(upsertEntry).toHaveBeenCalledWith( + TEAM_NAME, + expect.objectContaining({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + }), + expect.objectContaining({ + taskId: TASK_ID, + presence: 'has_changes', + taskSignature: buildTaskChangePresenceDescriptor({ + owner: 'alice', + status: 'completed', + intervals: [ + { + startedAt: '2026-03-01T10:00:00.000Z', + completedAt: '2026-03-01T10:10:00.000Z', + }, + ], + reviewState: 'none', + historyEvents: [], + }).taskSignature, + }) + ); + }); + + it('does not write no_changes presence entries for uncertain empty task diff results', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const upsertEntry = vi.fn(async () => undefined); + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })); + const workerClient = { + isAvailable: vi.fn(() => true), + computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })), + }; + const { service } = createService({ + logPaths: [], + taskChangePresenceRepository: { upsertEntry }, + teamLogSourceTracker: { ensureTracking }, + taskChangeWorkerClient: workerClient, + }); + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(result.files).toHaveLength(0); + expect(result.confidence === 'high' || result.confidence === 'medium').toBe(false); + expect(upsertEntry).not.toHaveBeenCalled(); + }); }); diff --git a/test/main/services/team/TaskChangeWorkerClient.test.ts b/test/main/services/team/TaskChangeWorkerClient.test.ts new file mode 100644 index 00000000..fc695cb7 --- /dev/null +++ b/test/main/services/team/TaskChangeWorkerClient.test.ts @@ -0,0 +1,255 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TaskChangeWorkerClient } from '../../../../src/main/services/team/TaskChangeWorkerClient'; + +import type { TaskChangeSetV2 } from '../../../../src/shared/types'; +import type { TaskChangeWorkerRequest, TaskChangeWorkerResponse } from '../../../../src/main/services/team/taskChangeWorkerTypes'; + +class FakeWorker { + readonly posted: TaskChangeWorkerRequest[] = []; + readonly terminate = vi.fn(async () => 0); + private readonly listeners: { + message: Array<(message: TaskChangeWorkerResponse) => void>; + error: Array<(error: Error) => void>; + exit: Array<(code: number) => void>; + } = { + message: [], + error: [], + exit: [], + }; + + on(event: 'message' | 'error' | 'exit', listener: ((value: any) => void) & ((value: any) => void)) { + if (event === 'message') this.listeners.message.push(listener as (message: TaskChangeWorkerResponse) => void); + if (event === 'error') this.listeners.error.push(listener as (error: Error) => void); + if (event === 'exit') this.listeners.exit.push(listener as (code: number) => void); + return this; + } + + postMessage(message: TaskChangeWorkerRequest): void { + this.posted.push(message); + } + + emitMessage(message: TaskChangeWorkerResponse): void { + for (const listener of this.listeners.message) { + listener(message); + } + } + + emitError(error: Error): void { + for (const listener of this.listeners.error) { + listener(error); + } + } + + emitExit(code: number): void { + for (const listener of this.listeners.exit) { + listener(code); + } + } +} + +function makePayload(taskId = 'task-1') { + return { + teamName: 'team-a', + taskId, + taskMeta: { + owner: 'alice', + status: 'completed', + intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], + reviewState: 'none' as const, + historyEvents: [], + }, + effectiveOptions: { + owner: 'alice', + status: 'completed', + intervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], + }, + projectPath: '/repo', + includeDetails: false, + }; +} + +function makeResult(taskId = 'task-1', filePath = '/repo/src/file.ts'): TaskChangeSetV2 { + return { + teamName: 'team-a', + taskId, + files: [ + { + filePath, + relativePath: 'src/file.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ], + totalFiles: 1, + totalLinesAdded: 1, + totalLinesRemoved: 0, + confidence: 'high' as const, + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId, + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [filePath], + confidence: { tier: 1, label: 'high', reason: 'test fixture' }, + }, + warnings: [], + }; +} + +describe('TaskChangeWorkerClient', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves successful worker responses', async () => { + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const promise = client.computeTaskChanges(makePayload()); + const request = workers[0]!.posted[0]!; + workers[0]!.emitMessage({ id: request.id, ok: true, result: makeResult() }); + + await expect(promise).resolves.toEqual(makeResult()); + }); + + it('times out the active request, terminates the worker, and recreates it on the next call', async () => { + vi.useFakeTimers(); + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + timeoutMs: 25, + enabled: true, + }); + + const firstPromise = client.computeTaskChanges(makePayload('task-timeout')); + const firstExpectation = expect(firstPromise).rejects.toThrow('Worker call timeout'); + await vi.advanceTimersByTimeAsync(25); + await firstExpectation; + expect(workers[0]!.terminate).toHaveBeenCalledTimes(1); + + const secondPromise = client.computeTaskChanges(makePayload('task-next')); + const request = workers[1]!.posted[0]!; + workers[1]!.emitMessage({ + id: request.id, + ok: true, + result: makeResult('task-next', '/repo/src/next.ts'), + }); + + await expect(secondPromise).resolves.toEqual(makeResult('task-next', '/repo/src/next.ts')); + expect(workers).toHaveLength(2); + }); + + it('rejects all pending requests on worker error and clears queued work', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const first = client.computeTaskChanges(makePayload('task-1')); + const second = client.computeTaskChanges(makePayload('task-2')); + workers[0]!.emitError(new Error('boom')); + + await expect(first).rejects.toThrow('boom'); + await expect(second).rejects.toThrow('boom'); + + const third = client.computeTaskChanges(makePayload('task-3')); + const request = workers[1]!.posted[0]!; + workers[1]!.emitMessage({ id: request.id, ok: true, result: makeResult('task-3') }); + await expect(third).resolves.toEqual(makeResult('task-3')); + }); + + it('rejects all pending requests on worker exit', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const first = client.computeTaskChanges(makePayload('task-1')); + const second = client.computeTaskChanges(makePayload('task-2')); + workers[0]!.emitExit(9); + + await expect(first).rejects.toThrow('Worker exited with code 9'); + await expect(second).rejects.toThrow('Worker exited with code 9'); + }); + + it('executes queued requests sequentially in FIFO order', async () => { + const workers: FakeWorker[] = []; + const client = new TaskChangeWorkerClient({ + workerPath: '/tmp/task-change-worker.cjs', + workerFactory: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker as any; + }, + enabled: true, + }); + + const first = client.computeTaskChanges(makePayload('task-1')); + const second = client.computeTaskChanges(makePayload('task-2')); + + expect(workers[0]!.posted).toHaveLength(1); + expect(workers[0]!.posted[0]!.payload.taskId).toBe('task-1'); + + workers[0]!.emitMessage({ + id: workers[0]!.posted[0]!.id, + ok: true, + result: makeResult('task-1'), + }); + + expect(workers[0]!.posted).toHaveLength(2); + expect(workers[0]!.posted[1]!.payload.taskId).toBe('task-2'); + + workers[0]!.emitMessage({ + id: workers[0]!.posted[1]!.id, + ok: true, + result: makeResult('task-2'), + }); + + await expect(first).resolves.toEqual(makeResult('task-1')); + await expect(second).resolves.toEqual(makeResult('task-2')); + }); + + it('reports unavailable when the worker file is missing', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = new TaskChangeWorkerClient({ + workerPath: null, + enabled: true, + }); + + expect(client.isAvailable()).toBe(false); + }); +}); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index cc1e45ed..42565b41 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; import type { TeamTask } from '../../../../src/shared/types/team'; @@ -145,6 +146,51 @@ describe('TeamDataService', () => { expect(reconcileArtifacts).toHaveBeenCalledWith({ reason: 'file-watch' }); }); + it('starts and stops task change presence tracking outside getTeamData', async () => { + const ensureTracking = vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'generation-1', + })); + const stopTracking = vi.fn(async () => undefined); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [] })), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + garbageCollect: vi.fn(async () => undefined), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => null), + save: vi.fn(async () => undefined), + deleteTasks: vi.fn(async () => undefined), + } as never, + { + ensureTracking, + stopTracking, + } as never + ); + + service.setTaskChangePresenceTracking('my-team', true); + service.setTaskChangePresenceTracking('my-team', false); + await Promise.resolve(); + + expect(ensureTracking).toHaveBeenCalledWith('my-team'); + expect(stopTracking).toHaveBeenCalledWith('my-team'); + }); + it('surfaces controller reconcile failures', async () => { const reconcileArtifacts = vi.fn(() => { throw new Error('reconcile failed'); @@ -1662,4 +1708,241 @@ describe('TeamDataService', () => { else process.env[TASK_COMMENT_FORWARDING_ENV] = previous; } }); + + it('returns unknown changePresence when no cached presence entry exists', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + const load = vi.fn(async () => null); + + service.setTaskChangePresenceServices( + { + load, + upsertEntry: vi.fn(async () => undefined), + } as never, + { + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTeamData('my-team'); + + expect(data.tasks[0]?.changePresence).toBe('unknown'); + expect(load).not.toHaveBeenCalled(); + }); + + it('returns cached changePresence only when signature and generation still match', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + historyEvents: task.historyEvents, + reviewState: 'none', + }); + + const createServiceWithPresence = ( + load: ReturnType, + trackerSnapshot: { projectFingerprint: string; logSourceGeneration: string } | null + ) => { + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load, + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => trackerSnapshot), + ensureTracking: vi.fn(async () => trackerSnapshot), + } as never + ); + + return service; + }; + + const matched = await createServiceWithPresence( + vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + { + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + } + ).getTeamData('my-team'); + expect(matched.tasks[0]?.changePresence).toBe('has_changes'); + + const mismatched = await createServiceWithPresence( + vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'stale-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'stale-generation', + }, + }, + })), + { + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + } + ).getTeamData('my-team'); + expect(mismatched.tasks[0]?.changePresence).toBe('unknown'); + }); + + it('returns lightweight task change presence without loading full team data', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + }; + const descriptor = buildTaskChangePresenceDescriptor({ + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + historyEvents: task.historyEvents, + reviewState: 'none', + }); + const getMessages = vi.fn(async () => []); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages, + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: descriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTaskChangePresence('my-team'); + + expect(data).toEqual({ 'task-1': 'has_changes' }); + expect(getMessages).not.toHaveBeenCalled(); + }); }); diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 234cd038..8ef2d282 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -37,6 +37,7 @@ vi.mock('@renderer/api', () => ({ function createSliceStore() { return create()((set, get, store) => ({ ...createChangeReviewSlice(set as never, get as never, store as never), + setSelectedTeamTaskChangePresence: vi.fn(), })); } @@ -203,6 +204,164 @@ describe('changeReviewSlice task changes', () => { expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); }); + it('updates selected team task changePresence after a positive summary check', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue(makeTaskChangeSet('presence-hit')); + + await store.getState().checkTaskHasChanges('team-a', 'presence-hit', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'presence-hit', + 'has_changes' + ); + }); + + it('updates selected team task changePresence to no_changes only for confirmed empty summaries', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-empty', + confidence: 'high', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-empty', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 1, label: 'high', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-empty', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'presence-empty', + 'no_changes' + ); + }); + + it('keeps changePresence unknown for fallback empty summaries', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-unknown', + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-unknown', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-unknown', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith( + 'team-a', + 'presence-unknown', + 'no_changes' + ); + }); + + it('downgrades stale known presence to unknown for fallback empty summaries', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'team-a', + selectedTeamData: { + tasks: [{ id: 'presence-stale', changePresence: 'has_changes' }], + }, + }); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-stale', + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-stale', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-stale', OPTIONS_A); + + expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'presence-stale', + 'unknown' + ); + }); + + it('bypasses stale negative cache when selected team task presence is unknown', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: 'presence-bypass', + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: 'presence-bypass', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'test fixture' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A); + store.setState({ + selectedTeamName: 'team-a', + selectedTeamData: { + tasks: [{ id: 'presence-bypass', changePresence: 'unknown' }], + }, + }); + await store.getState().checkTaskHasChanges('team-a', 'presence-bypass', OPTIONS_A); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); + }); + it('ignores stale fetchTaskChanges responses when a newer task request wins', async () => { const store = createSliceStore(); const first = deferred(); @@ -399,6 +558,85 @@ describe('changeReviewSlice task changes', () => { ).toBe(true); }); + it('warms task summaries with bounded concurrency', async () => { + const store = createSliceStore(); + const pending = Array.from({ length: 6 }, () => deferred()); + let callIndex = 0; + hoisted.getTaskChanges.mockImplementation(() => pending[callIndex++].promise); + + const requests = Array.from({ length: 6 }, (_, index) => ({ + teamName: 'team-a', + taskId: `task-${index}`, + options: { + owner: 'alice', + status: 'completed', + intervals: [{ startedAt: `2026-03-01T1${index}:00:00.000Z` }], + since: `2026-03-01T0${index}:58:00.000Z`, + stateBucket: 'completed' as const, + }, + })); + + const warmPromise = store.getState().warmTaskChangeSummaries(requests); + await flushAsyncWork(); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(4); + + for (let index = 0; index < 4; index++) { + pending[index].resolve({ + teamName: 'team-a', + taskId: `task-${index}`, + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + confidence: 'fallback', + computedAt: '2026-12-01T12:00:00.000Z', + scope: { + taskId: `task-${index}`, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: [], + }); + } + await flushAsyncWork(); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(6); + + for (let index = 4; index < 6; index++) { + pending[index].resolve({ + teamName: 'team-a', + taskId: `task-${index}`, + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + confidence: 'fallback', + computedAt: '2026-12-01T12:00:00.000Z', + scope: { + taskId: `task-${index}`, + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: [], + }); + } + + await warmPromise; + }); + it('clears optimistic terminal presence after background forceFresh revalidation', async () => { const store = createSliceStore(); const teamName = 'team-revalidate'; diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index c48bfd34..1a882134 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -31,6 +31,7 @@ vi.mock('@renderer/api', () => ({ })), }, teams: { + setChangePresenceTracking: vi.fn(async () => undefined), onTeamChange: vi.fn( (cb: (event: unknown, data: { teamName: string }) => void): (() => void) => { hoisted.onTeamChangeCb = cb; @@ -58,6 +59,7 @@ vi.mock('@renderer/api', () => ({ })); import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; +import { api } from '@renderer/api'; describe('team change throttling', () => { let cleanup: (() => void) | null = null; @@ -66,10 +68,14 @@ describe('team change throttling', () => { vi.useFakeTimers(); const fetchTeams = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); + const refreshSelectedTeamChangePresence = vi.fn(async () => undefined); useStore.setState({ fetchTeams, refreshTeamData, + refreshSelectedTeamChangePresence, + selectedTeamName: null, + selectedTeamData: null, paneLayout: { focusedPaneId: 'p1', panes: [ @@ -165,6 +171,99 @@ describe('team change throttling', () => { expect(fetchAllTasksSpy).not.toHaveBeenCalled(); }); + it('log-source-change refreshes only task change presence', async () => { + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + } as never); + + const state = useStore.getState(); + const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + const refreshSelectedTeamChangePresenceSpy = vi.spyOn( + state, + 'refreshSelectedTeamChangePresence' + ); + + hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(399); + expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1); + expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(fetchTeamsSpy).not.toHaveBeenCalled(); + }); + + it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => { + const invalidateTaskChangePresence = vi.fn(); + const checkTaskHasChanges = vi.fn(async () => undefined); + + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [ + { + id: 'task-1', + owner: 'alice', + status: 'in_progress', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], + historyEvents: [], + reviewState: 'none', + changePresence: 'unknown', + }, + { + id: 'task-2', + owner: 'alice', + status: 'in_progress', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], + historyEvents: [], + reviewState: 'none', + changePresence: 'unknown', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + invalidateTaskChangePresence, + checkTaskHasChanges, + } as never); + + await vi.advanceTimersByTimeAsync(10_000); + expect(checkTaskHasChanges).toHaveBeenNthCalledWith( + 1, + 'my-team', + 'task-1', + expect.objectContaining({ status: 'in_progress', owner: 'alice' }) + ); + + await vi.advanceTimersByTimeAsync(10_000); + expect(checkTaskHasChanges).toHaveBeenNthCalledWith( + 2, + 'my-team', + 'task-2', + expect.objectContaining({ status: 'in_progress', owner: 'alice' }) + ); + }); + it('per-team throttling: busy team does not block another visible team', async () => { // Add a second visible team tab useStore.setState({ @@ -204,4 +303,48 @@ describe('team change throttling', () => { expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team'); }); + + it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => { + const setChangePresenceTrackingSpy = vi.mocked(api.teams.setChangePresenceTracking); + setChangePresenceTrackingSpy.mockClear(); + + expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); + + useStore.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team', members: [], projectPath: '/repo' }, + tasks: [], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + } as never); + + await Promise.resolve(); + + expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); + + useStore.setState({ + selectedTeamName: 'other-team', + selectedTeamData: null, + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 't2', type: 'team', teamName: 'other-team', label: 'other-team' }], + activeTabId: 't2', + }, + ], + }, + } as never); + + await Promise.resolve(); + + expect(setChangePresenceTrackingSpy).not.toHaveBeenCalled(); + }); }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 98a48107..1764c682 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -144,6 +144,30 @@ describe('teamSlice actions', () => { ); }); + it('does not warm task-change summaries on team open', async () => { + const store = createSliceStore(); + hoisted.getData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [ + { + id: 'completed-1', + owner: 'alice', + status: 'completed', + createdAt: '2026-03-20T08:00:00.000Z', + updatedAt: '2026-03-20T12:00:00.000Z', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }); + + await store.getState().selectTeam('my-team'); + + expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); + }); + describe('refreshTeamData provisioning safety', () => { it('does not set fatal error on TEAM_PROVISIONING', async () => { const store = createSliceStore(); @@ -265,7 +289,7 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamError).toBe('Team not found'); }); - it('invalidates changed task summaries and warms only cacheable terminal tasks', async () => { + it('invalidates changed task summaries without warming task availability on refresh', async () => { const store = createSliceStore(); const invalidateTaskChangePresence = vi.fn(); const warmTaskChangeSummaries = vi.fn(async () => undefined); @@ -367,9 +391,7 @@ describe('teamSlice actions', () => { expect(hoisted.invalidateTaskChangeSummaries).toHaveBeenCalledWith('my-team', ['task-1']); expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1); - expect(warmTaskChangeSummaries).toHaveBeenCalledWith([ - expect.objectContaining({ teamName: 'my-team', taskId: 'task-2' }), - ]); + expect(warmTaskChangeSummaries).not.toHaveBeenCalled(); }); }); From 924bcd8b99b15b0f578a4a96c9bc1c77713faa8e Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 17:55:59 +0200 Subject: [PATCH 026/113] fix(team): render teammate permission requests in ToolApprovalSheet instead of raw JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When auto-approve is disabled, teammate tool requests arrived as permission_request JSON via SendMessage and rendered as "Raw JSON" with no way to approve/deny (#29). - Intercept permission_request in lead inbox relay, convert to ToolApprovalRequest and show in existing ToolApprovalSheet - Respond via teammate inbox (permission_response) + control_response via stdin as fallback - Show teammate name in approval header (e.g. "bob — Bash") - Compact noise label in Messages panel for permission_request/response - Proper file locking, race condition protection, idempotency checks --- .../services/team/TeamProvisioningService.ts | 211 +++++++++++++++++- .../components/team/ToolApprovalSheet.tsx | 1 + .../components/team/activity/ActivityItem.tsx | 13 ++ src/renderer/utils/agentMessageFormatting.ts | 2 + src/shared/utils/inboxNoise.ts | 41 ++++ 5 files changed, 265 insertions(+), 3 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f5ef1501..dfcee64f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -32,7 +32,11 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; -import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { + isInboxNoiseMessage, + parsePermissionRequest, + type ParsedPermissionRequest, +} from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -4027,12 +4031,35 @@ export class TeamProvisioningService { ); const deferredIds = new Set(deferredByAge.map((m) => m.messageId)); + // Category 4: teammate permission requests — intercept and convert to tool approvals. + // Don't relay these to the lead agent (it can't handle them). + const permissionRequestMsgs = unread.filter( + (m) => + !permanentlyIgnoredIds.has(m.messageId) && + !nativeMatchedMessageIds.has(m.messageId) && + !deferredIds.has(m.messageId) && + parsePermissionRequest(m.text) !== null + ); + const permissionRequestIds = new Set(permissionRequestMsgs.map((m) => m.messageId)); + if (permissionRequestMsgs.length > 0) { + for (const msg of permissionRequestMsgs) { + const perm = parsePermissionRequest(msg.text)!; + this.handleTeammatePermissionRequest(run, perm, msg.timestamp); + } + try { + await this.markInboxMessagesRead(teamName, leadName, permissionRequestMsgs); + } catch { + // best-effort + } + } + // Actionable: everything not in any category. const actionableUnread = unread.filter( (m) => !permanentlyIgnoredIds.has(m.messageId) && !nativeMatchedMessageIds.has(m.messageId) && - !deferredIds.has(m.messageId) + !deferredIds.has(m.messageId) && + !permissionRequestIds.has(m.messageId) ); // Layer 3: schedule retry timers. @@ -5536,6 +5563,52 @@ export class TeamProvisioningService { this.maybeShowToolApprovalOsNotification(run, approval); } + /** + * Handles a teammate permission_request received via inbox message. + * Converts it to a ToolApprovalRequest and feeds it into the existing approval flow. + */ + private handleTeammatePermissionRequest( + run: ProvisioningRun, + perm: ParsedPermissionRequest, + messageTimestamp: string + ): void { + // Skip if already tracked (idempotency — relay can be called multiple times) + if (run.pendingApprovals.has(perm.requestId)) return; + + const approval: ToolApprovalRequest = { + requestId: perm.requestId, + runId: run.runId, + teamName: run.teamName, + source: perm.agentId, + toolName: perm.toolName, + toolInput: perm.input, + receivedAt: messageTimestamp || new Date().toISOString(), + teamColor: run.request.color, + teamDisplayName: run.request.displayName, + }; + + const autoResult = shouldAutoAllow(this.toolApprovalSettings, perm.toolName, perm.input); + if (autoResult.autoAllow) { + logger.info( + `[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})` + ); + void this.respondToTeammatePermission(run, perm.agentId, perm.requestId, true); + this.emitToolApprovalEvent({ + autoResolved: true, + requestId: perm.requestId, + runId: run.runId, + teamName: run.teamName, + reason: 'auto_allow_category', + } as ToolApprovalAutoResolved); + return; + } + + run.pendingApprovals.set(perm.requestId, approval); + this.emitToolApprovalEvent(approval); + this.startApprovalTimeout(run, perm.requestId); + this.maybeShowToolApprovalOsNotification(run, approval); + } + /** * Shows a native OS notification for a pending tool approval when the app * is not in focus. On macOS, adds Allow/Deny action buttons that respond @@ -5704,6 +5777,31 @@ export class TeamProvisioningService { const allow = currentAction === 'allow'; logger.info(`[${run.teamName}] Timeout ${allow ? 'allowing' : 'denying'} ${requestId}`); + const approval = run.pendingApprovals.get(requestId); + if (approval && approval.source !== 'lead') { + // Teammate request — respond via inbox + control_response fallback. + // Defer cleanup until the async write completes to avoid silent data loss. + this.respondToTeammatePermission( + run, + approval.source, + requestId, + allow, + allow ? undefined : 'Timed out — auto-denied by settings' + ).finally(() => { + run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); + this.dismissApprovalNotification(requestId); + this.emitToolApprovalEvent({ + autoResolved: true, + requestId, + runId: run.runId, + teamName: run.teamName, + reason: allow ? 'timeout_allow' : 'timeout_deny', + } as ToolApprovalAutoResolved); + }); + return; + } + if (allow) { this.autoAllowControlRequest(run, requestId); } else { @@ -5768,7 +5866,12 @@ export class TeamProvisioningService { ); if (result.autoAllow) { this.clearApprovalTimeout(requestId); - this.autoAllowControlRequest(run, requestId); + if (!this.tryClaimResponse(requestId)) continue; + if (approval.source !== 'lead') { + void this.respondToTeammatePermission(run, approval.source, requestId, true); + } else { + this.autoAllowControlRequest(run, requestId); + } this.dismissApprovalNotification(requestId); toRemove.push(requestId); this.emitToolApprovalEvent({ @@ -5794,6 +5897,7 @@ export class TeamProvisioningService { } for (const requestId of toRemove) { run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); } } } @@ -5834,6 +5938,20 @@ export class TeamProvisioningService { return; } + const approval = run.pendingApprovals.get(requestId)!; + + // Teammate permission requests use a different response path (inbox, not stdin) + if (approval.source !== 'lead') { + try { + await this.respondToTeammatePermission(run, approval.source, requestId, allow, message); + } finally { + run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); + this.dismissApprovalNotification(requestId); + } + return; + } + if (!run.child?.stdin?.writable) { throw new Error(`Team "${teamName}" process stdin is not writable`); } @@ -5889,6 +6007,93 @@ export class TeamProvisioningService { } } + /** + * Respond to a teammate's permission_request by writing to the teammate's inbox + * AND attempting a control_response via stdin (belt-and-suspenders). + */ + private async respondToTeammatePermission( + run: ProvisioningRun, + agentId: string, + requestId: string, + allow: boolean, + message?: string + ): Promise { + const teamsBase = getTeamsBasePath(); + const inboxPath = path.join(teamsBase, run.teamName, 'inboxes', `${agentId}.json`); + + // 1. Write permission_response to teammate's inbox (with proper file locking) + const responseMsg = { + from: 'user', + text: JSON.stringify({ + type: 'permission_response', + request_id: requestId, + approved: allow, + ...(message ? { message } : {}), + }), + timestamp: new Date().toISOString(), + read: false, + }; + + try { + await withFileLock(inboxPath, async () => { + await withInboxLock(inboxPath, async () => { + let existing: unknown[] = []; + try { + const raw = await tryReadRegularFileUtf8(inboxPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_INBOX_MAX_BYTES, + }); + if (raw) { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) existing = parsed; + } + } catch { + // File may not exist yet — start with empty array + } + existing.push(responseMsg); + await atomicWriteAsync(inboxPath, JSON.stringify(existing, null, 2)); + }); + }); + logger.info( + `[${run.teamName}] Wrote permission_response to ${agentId} inbox: ${allow ? 'allow' : 'deny'}` + ); + } catch (error) { + logger.error( + `[${run.teamName}] Failed to write permission_response to ${agentId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + // 2. Also try control_response via stdin (in case lead runtime can forward it) + if (run.child?.stdin?.writable) { + const controlResponse = allow + ? { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow' }, + }, + } + : { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'deny', message: message ?? 'User denied' }, + }, + }; + run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { + if (err) { + logger.warn( + `[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` + ); + } + }); + } + } + /** * Called when the first stream-json turn completes successfully. * Verifies provisioning files exist and marks as ready. diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 7e0292f3..01ae8ab1 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -202,6 +202,7 @@ export const ToolApprovalSheet: React.FC = () => {
{getToolIcon(current.toolName)} + {current.source !== 'lead' ? `${current.source} — ` : ''} {current.toolName}
diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2dfc1606..a456b6cc 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -222,6 +222,19 @@ function getNoiseLabel(parsed: StructuredMessage): string | null { : 'Completed a task'; } + if (type === 'permission_request') { + const toolName = getStringField(parsed, 'tool_name'); + const description = getStringField(parsed, 'description'); + const label = toolName ? `Permission: ${toolName}` : 'Permission request'; + return description ? `${label} — ${description}` : label; + } + + if (type === 'permission_response') { + if (parsed.approved === true) return 'Permission granted'; + if (parsed.approved === false) return 'Permission denied'; + return 'Permission response'; + } + return null; } diff --git a/src/renderer/utils/agentMessageFormatting.ts b/src/renderer/utils/agentMessageFormatting.ts index f194bc96..354f1513 100644 --- a/src/renderer/utils/agentMessageFormatting.ts +++ b/src/renderer/utils/agentMessageFormatting.ts @@ -73,6 +73,8 @@ const TYPE_LABELS: Record = { shutdown_response: 'Shutdown response', message: 'Message', broadcast: 'Broadcast', + permission_request: 'Permission request', + permission_response: 'Permission response', }; export function parseStructuredAgentMessage(content: string): StructuredAgentMessage | null { diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index d390076c..5a85497c 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -37,6 +37,47 @@ export function isInboxNoiseMessage(text: string): boolean { return !!type && INBOX_NOISE_SET.has(type); } +// --------------------------------------------------------------------------- +// Teammate permission request parsing +// --------------------------------------------------------------------------- + +/** Parsed teammate permission request from inbox message. */ +export interface ParsedPermissionRequest { + requestId: string; + agentId: string; + toolName: string; + toolUseId: string; + description: string; + input: Record; +} + +/** + * Parses a `permission_request` JSON message from a teammate's inbox entry. + * Returns null if the text is not a valid permission_request. + */ +export function parsePermissionRequest(text: string): ParsedPermissionRequest | null { + const parsed = parseInboxJson(text); + if (!parsed || parsed.type !== 'permission_request') return null; + + const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null; + const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null; + const toolName = typeof parsed.tool_name === 'string' ? parsed.tool_name : null; + + if (!requestId || !agentId || !toolName) return null; + + return { + requestId, + agentId, + toolName, + toolUseId: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', + description: typeof parsed.description === 'string' ? parsed.description : '', + input: + parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) + ? (parsed.input as Record) + : {}, + }; +} + // --------------------------------------------------------------------------- // Teammate-message XML block detection & stripping // --------------------------------------------------------------------------- From 8ea470b1ae3a4423ee8e5894584849487b5cb99a Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 18:12:58 +0200 Subject: [PATCH 027/113] fix(team): intercept permission_request regardless of native delivery status When Claude Code runtime natively delivers permission_request to the lead via stdout, the message was excluded from interception by the nativeMatchedMessageIds filter. This caused the ToolApprovalSheet to not appear for teammate requests. Remove nativeMatchedMessageIds check from the permission_request filter so interception works even for natively delivered messages. --- src/main/services/team/TeamProvisioningService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index dfcee64f..c6f4f80b 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4033,10 +4033,12 @@ export class TeamProvisioningService { // Category 4: teammate permission requests — intercept and convert to tool approvals. // Don't relay these to the lead agent (it can't handle them). + // NOTE: We intentionally do NOT exclude nativeMatchedMessageIds here — even if + // Claude Code runtime natively delivered the message to the lead, we still need + // to intercept permission_request and show the ToolApprovalSheet for the user. const permissionRequestMsgs = unread.filter( (m) => !permanentlyIgnoredIds.has(m.messageId) && - !nativeMatchedMessageIds.has(m.messageId) && !deferredIds.has(m.messageId) && parsePermissionRequest(m.text) !== null ); From ed1726c29819503dfcd6b3f33afe786ceb2112e8 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 18:44:58 +0200 Subject: [PATCH 028/113] fix(team): intercept permission_request in native stdout delivery path relayLeadInboxMessages only runs after provisioningComplete, but teammate permission_request messages arrive during bootstrap (before provisioning finishes). Claude Code delivers them natively via stdout type:"user" messages, bypassing the relay entirely. Add permission_request interception in handleNativeTeammateUserMessage which processes stdout user messages at all times, including during provisioning. This ensures ToolApprovalSheet appears immediately when teammates need tool permission. --- src/main/services/team/TeamProvisioningService.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c6f4f80b..eeb92511 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1749,6 +1749,17 @@ export class TeamProvisioningService { const blocks = parseAllTeammateMessages(rawText); if (blocks.length === 0) return; + // Intercept teammate permission_request messages delivered natively via stdout. + // This runs even during provisioning (unlike relayLeadInboxMessages which waits + // for provisioningComplete). The lead already received the message — we can't + // prevent that — but we create a ToolApprovalRequest so the user sees the dialog. + for (const block of blocks) { + const perm = parsePermissionRequest(block.content); + if (perm) { + this.handleTeammatePermissionRequest(run, perm, new Date().toISOString()); + } + } + const crossTeamBlocks = blocks.flatMap((block) => { const origin = parseCrossTeamPrefix(block.content); const sourceTeam = origin?.from.includes('.') ? origin.from.split('.', 1)[0] : null; From bdd0dc6f39980ea18f3667016d4bc68f3b5b686c Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 20:09:01 +0200 Subject: [PATCH 029/113] fix(team): add permission_request interception in stdout handler + diagnostic logging - Parse raw user text for permission_request in handleStreamJsonMessage (covers case where permission_request arrives without wrapper) - Add [PERM-TRACE] logger.warn diagnostics to trace the exact flow: where permission_request is detected, whether it reaches handleTeammatePermissionRequest, and whether relay or stdout interception triggers - These logs will help diagnose why ToolApprovalSheet may not appear --- .../services/team/TeamProvisioningService.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index eeb92511..98b24a80 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4055,6 +4055,9 @@ export class TeamProvisioningService { ); const permissionRequestIds = new Set(permissionRequestMsgs.map((m) => m.messageId)); if (permissionRequestMsgs.length > 0) { + logger.warn( + `[${run.teamName}] [PERM-TRACE] relay intercepted ${permissionRequestMsgs.length} permission_request(s) from inbox` + ); for (const msg of permissionRequestMsgs) { const perm = parsePermissionRequest(msg.text)!; this.handleTeammatePermissionRequest(run, perm, msg.timestamp); @@ -4907,6 +4910,24 @@ export class TeamProvisioningService { // {"type":"assistant","content":[{"type":"text","text":"..."},...]} // {"type":"result","subtype":"success",...} if (msg.type === 'user') { + // Check for permission_request in raw user message text BEFORE teammate-message parsing. + // The permission_request may arrive as plain JSON without wrapper, + // and handleNativeTeammateUserMessage only processes blocks. + const rawUserText = this.extractStreamUserText(msg); + if (rawUserText) { + const perm = parsePermissionRequest(rawUserText); + if (perm) { + logger.warn( + `[${run.teamName}] [PERM-TRACE] Intercepted permission_request from stdout user message: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}` + ); + this.handleTeammatePermissionRequest(run, perm, new Date().toISOString()); + } else if (rawUserText.includes('permission_request')) { + // Log near-miss: text contains "permission_request" but wasn't parsed + logger.warn( + `[${run.teamName}] [PERM-TRACE] stdout user message contains "permission_request" but parsePermissionRequest returned null. Text preview: ${rawUserText.slice(0, 300)}` + ); + } + } this.handleNativeTeammateUserMessage(run, msg); return; } @@ -5586,7 +5607,16 @@ export class TeamProvisioningService { messageTimestamp: string ): void { // Skip if already tracked (idempotency — relay can be called multiple times) - if (run.pendingApprovals.has(perm.requestId)) return; + if (run.pendingApprovals.has(perm.requestId)) { + logger.warn( + `[${run.teamName}] [PERM-TRACE] Duplicate permission_request skipped: ${perm.requestId}` + ); + return; + } + + logger.warn( + `[${run.teamName}] [PERM-TRACE] handleTeammatePermissionRequest: agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}` + ); const approval: ToolApprovalRequest = { requestId: perm.requestId, From c0c20d07f88d696c737f0520a1eff181ef2dbecd Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 20:16:03 +0200 Subject: [PATCH 030/113] fix(team): add backdrop overlay to ToolApprovalSheet and move Settings inline - Add semi-transparent backdrop (bg-black/40) behind the approval popup to draw attention and dim surrounding UI - Refactor ToolApprovalSettingsPanel to render toggle button inline (fragment-based) so it can sit in the actions row next to pending count - Settings button now appears right-aligned at the same level as Allow/Deny buttons; expanded panel renders below --- .../components/team/ToolApprovalSheet.tsx | 281 +++++++++--------- .../dialogs/ToolApprovalSettingsPanel.tsx | 10 +- 2 files changed, 148 insertions(+), 143 deletions(-) diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 01ae8ab1..b4850dbe 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -186,156 +186,161 @@ export const ToolApprovalSheet: React.FC = () => { const displayName = current.teamDisplayName ?? teamSummary?.displayName ?? current.teamName; return ( -
- {/* Header */} + <> + {/* Backdrop overlay */} +
+
-
- {getToolIcon(current.toolName)} - - {current.source !== 'lead' ? `${current.source} — ` : ''} - {current.toolName} - -
-
- {selectedTeamName !== current.teamName && ( - - {displayName} - - )} - -
-
- - {/* Tool input preview (syntax-highlighted) */} - - - {/* Diff preview (Write/Edit/NotebookEdit only) */} - - - {/* Error feedback */} - {error && ( + {/* Header */}
- - {error} +
+ {getToolIcon(current.toolName)} + + {current.source !== 'lead' ? `${current.source} — ` : ''} + {current.toolName} + +
+
+ {selectedTeamName !== current.teamName && ( + + {displayName} + + )} + +
- )} - {/* Actions */} -
-
- - - -
- - -
- {pendingApprovals.length > 1 && ( - - {pendingApprovals.length - 1} pending - + + {error} +
)} + + {/* Actions */} +
+
+ + + +
+ + +
+
+ {pendingApprovals.length > 1 && ( + + {pendingApprovals.length - 1} pending + + )} + +
+
+ + {/* Timeout progress bar */} +
- - {/* Settings panel (full-width, outside flex row) */} - - - {/* Timeout progress bar */} - -
+ ); }; diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx index 963209aa..9f02438f 100644 --- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx +++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx @@ -20,8 +20,8 @@ export const ToolApprovalSettingsPanel: React.FC = () => { const updateSettings = useStore((s) => s.updateToolApprovalSettings); return ( -
- {/* Toggle button */} + <> + {/* Toggle button — rendered inline in parent layout */} - {/* Collapsible panel */} + {/* Collapsible panel — full-width, below toggle */} {expanded && (
{
)} -
+ ); }; From d0b9c4f529fdfca3b45448bd5a7890d1718a550c Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 22:04:44 +0200 Subject: [PATCH 031/113] fix(team): settings panel opens below buttons instead of beside them Split ToolApprovalSettingsPanel into ToolApprovalSettingsToggle (inline button) and ToolApprovalSettingsContent (full-width expandable panel). Toggle sits in the actions row, content renders below it inside the popup. --- .../components/team/ToolApprovalSheet.tsx | 14 +- .../dialogs/ToolApprovalSettingsPanel.tsx | 276 +++++++++--------- 2 files changed, 150 insertions(+), 140 deletions(-) diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index b4850dbe..91a4b640 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -7,7 +7,10 @@ import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, Search, Terminal } from 'lucide-react'; -import { ToolApprovalSettingsPanel } from './dialogs/ToolApprovalSettingsPanel'; +import { + ToolApprovalSettingsContent, + ToolApprovalSettingsToggle, +} from './dialogs/ToolApprovalSettingsPanel'; import { FileIcon } from './editor/FileIcon'; import { ToolApprovalDiffPreview } from './ToolApprovalDiffPreview'; @@ -124,6 +127,7 @@ export const ToolApprovalSheet: React.FC = () => { const [disabled, setDisabled] = useState(false); const [error, setError] = useState(null); const [diffExpanded, setDiffExpanded] = useState(false); + const [settingsExpanded, setSettingsExpanded] = useState(false); // Clear error when current approval changes useEffect(() => { @@ -333,10 +337,16 @@ export const ToolApprovalSheet: React.FC = () => { {pendingApprovals.length - 1} pending )} - + setSettingsExpanded((v) => !v)} + />
+ {/* Settings expanded content — below actions row */} + + {/* Timeout progress bar */}
diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx index 9f02438f..802cd6cc 100644 --- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx +++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx @@ -13,153 +13,153 @@ import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; import type { ToolApprovalTimeoutAction } from '@shared/types'; -export const ToolApprovalSettingsPanel: React.FC = () => { - const [expanded, setExpanded] = useState(false); +export const ToolApprovalSettingsToggle: React.FC<{ expanded: boolean; onToggle: () => void }> = ({ + expanded, + onToggle, +}) => ( + +); + +export const ToolApprovalSettingsContent: React.FC<{ expanded: boolean }> = ({ expanded }) => { const [localSeconds, setLocalSeconds] = useState(''); const settings = useStore((s) => s.toolApprovalSettings); const updateSettings = useStore((s) => s.updateToolApprovalSettings); + if (!expanded) return null; + return ( - <> - {/* Toggle button — rendered inline in parent layout */} - + + void updateSettings({ autoAllowFileEdits: checked === true }) + } + /> + Auto-allow file edits (Edit, Write, NotebookEdit) + - {/* Collapsible panel — full-width, below toggle */} - {expanded && ( -
+ + void updateSettings({ autoAllowSafeBash: checked === true }) + } + /> + Auto-allow safe commands (git, pnpm, npm, ls...) + + + {/* Separator */} +
+ + {/* Timeout section */} +
+ On timeout: + + + {settings.timeoutAction !== 'wait' && ( + <> + after + setLocalSeconds(e.target.value)} + onBlur={() => { + const val = parseInt(localSeconds, 10); + if (!isNaN(val) && val >= 5 && val <= 300) { + void updateSettings({ timeoutSeconds: val }); + } + setLocalSeconds(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }} + className="w-14 rounded border px-1.5 py-0.5 text-center text-xs" + style={{ + backgroundColor: 'var(--color-surface-raised)', + borderColor: 'var(--color-border)', + color: 'var(--color-text)', + }} /> - Auto-allow all tools - - - {/* Separator */} -
- - {/* Auto-allow file edits */} - - - {/* Auto-allow safe bash */} - - - {/* Separator */} -
- - {/* Timeout section */} -
- On timeout: - - - {settings.timeoutAction !== 'wait' && ( - <> - after - setLocalSeconds(e.target.value)} - onBlur={() => { - const val = parseInt(localSeconds, 10); - if (!isNaN(val) && val >= 5 && val <= 300) { - void updateSettings({ timeoutSeconds: val }); - } - setLocalSeconds(''); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - } - }} - className="w-14 rounded border px-1.5 py-0.5 text-center text-xs" - style={{ - backgroundColor: 'var(--color-surface-raised)', - borderColor: 'var(--color-border)', - color: 'var(--color-text)', - }} - /> - sec - - )} -
-
- )} - + sec + + )} +
+
); }; From dd42cf0069e026dae7bcbbdc212adbc5ad872a81 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 23:35:52 +0200 Subject: [PATCH 032/113] fix(team): scan inbox for permission_request during provisioning relayLeadInboxMessages only processes unread messages after provisioningComplete, but CLI marks permission_request messages as read after native delivery -- before our relay runs. Move permission_request inbox scan BEFORE provisioningComplete check. Scan ALL messages (including read=true), track processed IDs via processedPermissionRequestIds Set on ProvisioningRun to prevent re-emitting. Also look up both alive and provisioning runs so the scan works during team bootstrap. --- README.md | 4 + src/main/ipc/teams.ts | 59 ++-- src/main/services/team/TeamDataService.ts | 106 ++++++- src/main/services/team/TeamInboxReader.ts | 31 ++ src/main/services/team/TeamInboxWriter.ts | 3 + .../services/team/TeamProvisioningService.ts | 53 +++- .../services/team/TeamSentMessagesStore.ts | 31 ++ .../team/leadSessionMessageExtractor.ts | 198 +++++++++++++ .../components/team/activity/ActivityItem.tsx | 269 +++++++++++++++--- .../team/activity/LeadThoughtsGroup.tsx | 1 + .../team/messages/MessageComposer.tsx | 39 ++- .../components/ui/MentionSuggestionList.tsx | 27 +- .../components/ui/MentionableTextarea.tsx | 99 ++++++- .../ui/SlashCommandInteractionLayer.tsx | 88 ++++++ src/renderer/hooks/useMentionDetection.ts | 20 +- src/renderer/types/mention.ts | 8 +- src/renderer/utils/mentionSuggestions.ts | 11 +- src/renderer/utils/messageRenderEquality.ts | 28 +- src/shared/types/team.ts | 23 ++ src/shared/utils/contentSanitizer.ts | 21 +- src/shared/utils/slashCommands.ts | 123 ++++++++ test/main/ipc/teams.test.ts | 95 +++++++ .../services/team/TeamDataService.test.ts | 113 ++++++++ .../team/TeamSentMessagesStore.test.ts | 91 ++++++ .../team/leadSessionMessageExtractor.test.ts | 148 ++++++++++ .../team/activity/ActivityItem.test.ts | 114 +++++++- .../team/activity/LeadThoughtsGroup.test.ts | 102 ++----- test/shared/utils/slashCommands.test.ts | 56 ++++ 28 files changed, 1783 insertions(+), 178 deletions(-) create mode 100644 src/main/services/team/leadSessionMessageExtractor.ts create mode 100644 src/renderer/components/ui/SlashCommandInteractionLayer.tsx create mode 100644 src/shared/utils/slashCommands.ts create mode 100644 test/main/services/team/TeamSentMessagesStore.test.ts create mode 100644 test/main/services/team/leadSessionMessageExtractor.test.ts create mode 100644 test/shared/utils/slashCommands.test.ts diff --git a/README.md b/README.md index 3e1e5fbd..daee46d2 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,10 @@ pnpm dist # macOS + Windows + Linux - [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them) - [ ] Curate what context each agent sees (files, docs, MCP servers, skills) - [ ] Slash commands +- [ ] Outgoing message queue — queue user messages while the lead (or agent) is busy; clear agent-busy status in the UI; flush to stdin or relay from inbox when idle (durable queue on disk for the lead inbox path) +- [ ] `createTasksBatch` — IPC/service API to create many team tasks in one call (playbooks, markdown checklist import, scripts); complements single `createTask` +- [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow +- [ ] Custom kanban columns --- diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c3814203..c38aa3e1 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -78,6 +78,10 @@ import { } from '@shared/utils/cliArgsParser'; import { createLogger } from '@shared/utils/logger'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; +import { + buildStandaloneSlashCommandMeta, + parseStandaloneSlashCommand, +} from '@shared/utils/slashCommands'; import crypto from 'crypto'; import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; @@ -1412,25 +1416,38 @@ async function handleSendMessage( const preGeneratedMessageId = crypto.randomUUID(); // Separate try blocks: stdin delivery vs persistence // If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate) - // Wrap with instructions so lead responds with visible text (not just agent-only blocks) - const wrappedText = [ - `You received a direct message from the user.`, - `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, - AGENT_BLOCK_OPEN, - `MessageId: ${preGeneratedMessageId}`, - `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, - AGENT_BLOCK_CLOSE, - ``, - `Message from user:`, - buildMessageDeliveryText(payload.text!, { - actionMode, - isLeadRecipient: true, - }), - ].join('\n'); + const standaloneSlashCommand = !validatedAttachments?.length + ? parseStandaloneSlashCommand(payload.text!) + : null; + const slashCommandMeta = standaloneSlashCommand + ? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw) + : null; + const rawSlashCommandText = standaloneSlashCommand?.raw; + const stdinTextForLead = rawSlashCommandText + ? rawSlashCommandText + : [ + `You received a direct message from the user.`, + `IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`, + AGENT_BLOCK_OPEN, + `MessageId: ${preGeneratedMessageId}`, + `When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`, + AGENT_BLOCK_CLOSE, + ``, + `Message from user:`, + buildMessageDeliveryText(payload.text!, { + actionMode, + isLeadRecipient: true, + }), + ].join('\n'); + const persistTextForLead = rawSlashCommandText ?? payload.text!; let stdinSent = false; try { - await provisioning.sendMessageToTeam(tn, wrappedText, validatedAttachments); + await provisioning.sendMessageToTeam( + tn, + stdinTextForLead, + rawSlashCommandText ? undefined : validatedAttachments + ); stdinSent = true; } catch (stdinError: unknown) { // Stdin failed (process died between check and write) @@ -1477,7 +1494,7 @@ async function handleSendMessage( result = await getTeamDataService().sendDirectToLead( tn, resolvedLeadName, - payload.text!, + persistTextForLead, payload.summary, attachmentMeta, validatedTaskRefs.value, @@ -1493,7 +1510,7 @@ async function handleSendMessage( provisioning.pushLiveLeadProcessMessage(tn, { from: 'user', to: resolvedLeadName, - text: payload.text!, + text: persistTextForLead, timestamp: new Date().toISOString(), read: true, summary: payload.summary, @@ -1501,6 +1518,12 @@ async function handleSendMessage( source: 'user_sent', attachments: attachmentMeta, taskRefs: validatedTaskRefs.value, + ...(slashCommandMeta + ? { + messageKind: 'slash_command' as const, + slashCommand: slashCommandMeta, + } + : {}), }); return result; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a79ef673..9db2a91c 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -18,6 +18,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; +import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; @@ -40,6 +41,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; +import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import type { @@ -251,6 +253,47 @@ export class TeamDataService { return result; } + private isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean { + if (typeof message.to === 'string' && message.to.trim().length > 0) return false; + if (message.from === 'system') return false; + return message.source === 'lead_session' || message.source === 'lead_process'; + } + + private annotateSlashCommandResponses(messages: InboxMessage[]): void { + let pendingSlash = null as InboxMessage['slashCommand'] | null; + + for (const message of messages) { + const slashCommand = + message.source === 'user_sent' + ? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text)) + : null; + + if (slashCommand) { + pendingSlash = slashCommand; + continue; + } + + if (!pendingSlash) { + continue; + } + + if (message.messageKind === 'slash_command_result') { + continue; + } + + if (this.isLeadThoughtCandidateForSlashResult(message)) { + message.messageKind = 'slash_command_result'; + message.commandOutput = { + stream: 'stdout', + commandLabel: pendingSlash.command, + }; + continue; + } + + pendingSlash = null; + } + } + async getTaskChangePresence(teamName: string): Promise> { const config = await this.configReader.getConfig(teamName); if (!config) { @@ -593,6 +636,9 @@ export class TeamDataService { } } + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + this.annotateSlashCommandResponses(messages); + messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); let metaMembers: TeamConfig['members'] = []; @@ -1342,6 +1388,15 @@ export class TeamDataService { // non-critical } } + const slashCommandMeta = + enrichedRequest.slashCommand ?? buildStandaloneSlashCommandMeta(enrichedRequest.text); + if (slashCommandMeta) { + enrichedRequest = { + ...enrichedRequest, + messageKind: 'slash_command', + slashCommand: slashCommandMeta, + }; + } return this.getController(teamName).messages.sendMessage({ member: enrichedRequest.member, from: enrichedRequest.from, @@ -1354,6 +1409,9 @@ export class TeamDataService { replyToConversationId: enrichedRequest.replyToConversationId, toolSummary: enrichedRequest.toolSummary, toolCalls: enrichedRequest.toolCalls, + messageKind: enrichedRequest.messageKind, + slashCommand: enrichedRequest.slashCommand, + commandOutput: enrichedRequest.commandOutput, taskRefs: enrichedRequest.taskRefs, summary: enrichedRequest.summary, source: enrichedRequest.source, @@ -1782,6 +1840,7 @@ export class TeamDataService { // non-critical — proceed without sessionId } + const slashCommandMeta = buildStandaloneSlashCommandMeta(text); const msg = this.getController(teamName).messages.appendSentMessage({ from: 'user', to: leadName, @@ -1791,6 +1850,12 @@ export class TeamDataService { source: 'user_sent', attachments: attachments?.length ? attachments : undefined, leadSessionId, + ...(slashCommandMeta + ? { + messageKind: 'slash_command', + slashCommand: slashCommandMeta, + } + : {}), ...(messageId ? { messageId } : {}), }) as InboxMessage; return { @@ -1961,7 +2026,7 @@ export class TeamDataService { return sessionIds; } - private async extractLeadSessionTextsFromJsonl( + private async extractLeadAssistantTextsFromJsonl( jsonlPath: string, leadName: string, leadSessionId: string, @@ -1969,10 +2034,8 @@ export class TeamDataService { ): Promise { if (maxTexts <= 0) return []; - // Optimization: read from the end of the JSONL file (we only need the last N texts). - // The full file can be huge; scanning from the start causes long stalls on Windows. - const MAX_SCAN_BYTES = 8 * 1024 * 1024; // 8MB tail cap - const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB + const MAX_SCAN_BYTES = 8 * 1024 * 1024; + const INITIAL_SCAN_BYTES = 256 * 1024; const textsReversed: InboxMessage[] = []; const seenMessageIds = new Set(); @@ -1989,7 +2052,6 @@ export class TeamDataService { const chunk = buffer.toString('utf8'); const lines = chunk.split(/\r?\n/); - // If we started mid-file, the first line may be partial — drop it. const fromIndex = start > 0 ? 1 : 0; for (let i = lines.length - 1; i >= fromIndex; i--) { @@ -2022,8 +2084,6 @@ export class TeamDataService { const combined = stripAgentBlocks(textParts.join('\n')).trim(); if (combined.length < MIN_TEXT_LENGTH) continue; - // 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 toolCallsList: ToolCallMeta[] = []; const lookaheadLimit = Math.min(i + 200, lines.length); for (let j = i + 1; j < lookaheadLimit; j++) { @@ -2035,12 +2095,12 @@ export class TeamDataService { } catch { continue; } - if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines + if (tMsg.type !== 'assistant') continue; 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 + if (tBlocks.some((b) => b.type === 'text')) break; for (const b of tBlocks) { if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') { const input = (b.input ?? {}) as Record; @@ -2062,7 +2122,6 @@ export class TeamDataService { ? `lead-thought-msg-${assistantMessageId}` : null; - // Fallback messageId: timestamp + text prefix (survives tail-scan range changes) const textPrefix = combined .slice(0, 50) .replace(/[^\p{L}\p{N}]/gu, '') @@ -2095,10 +2154,29 @@ export class TeamDataService { await handle.close(); } - // Convert back to chronological order (old behavior) and keep the last N texts. textsReversed.reverse(); - const texts = textsReversed; - return texts.length > maxTexts ? texts.slice(-maxTexts) : texts; + return textsReversed.length > maxTexts ? textsReversed.slice(-maxTexts) : textsReversed; + } + + private async extractLeadSessionTextsFromJsonl( + jsonlPath: string, + leadName: string, + leadSessionId: string, + maxTexts: number + ): Promise { + const [assistantTexts, commandResults] = await Promise.all([ + this.extractLeadAssistantTextsFromJsonl(jsonlPath, leadName, leadSessionId, maxTexts), + extractLeadSessionMessagesFromJsonl({ + jsonlPath, + leadName, + leadSessionId, + maxMessages: maxTexts, + }), + ]); + + const combined = [...assistantTexts, ...commandResults]; + combined.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + return combined.length > maxTexts ? combined.slice(-maxTexts) : combined; } private async extractLeadSessionTexts(config: TeamConfig): Promise { diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 11e5f1f9..fb191740 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -131,6 +131,37 @@ export class TeamInboxReader { preview: typeof tc.preview === 'string' ? tc.preview : undefined, })) : undefined, + messageKind: + row.messageKind === 'slash_command' || row.messageKind === 'slash_command_result' + ? row.messageKind + : row.messageKind === 'default' + ? 'default' + : undefined, + slashCommand: + row.slashCommand && + typeof row.slashCommand === 'object' && + typeof row.slashCommand.name === 'string' && + typeof row.slashCommand.command === 'string' + ? { + name: row.slashCommand.name, + command: row.slashCommand.command as `/${string}`, + args: typeof row.slashCommand.args === 'string' ? row.slashCommand.args : undefined, + knownDescription: + typeof row.slashCommand.knownDescription === 'string' + ? row.slashCommand.knownDescription + : undefined, + } + : undefined, + commandOutput: + row.commandOutput && + typeof row.commandOutput === 'object' && + (row.commandOutput.stream === 'stdout' || row.commandOutput.stream === 'stderr') && + typeof row.commandOutput.commandLabel === 'string' + ? { + stream: row.commandOutput.stream, + commandLabel: row.commandOutput.commandLabel, + } + : undefined, }); } diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 006ef14d..272f4d45 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -41,6 +41,9 @@ export class TeamInboxWriter { }), ...(request.toolSummary && { toolSummary: request.toolSummary }), ...(request.toolCalls && { toolCalls: request.toolCalls }), + ...(request.messageKind && { messageKind: request.messageKind }), + ...(request.slashCommand && { slashCommand: request.slashCommand }), + ...(request.commandOutput && { commandOutput: request.commandOutput }), }; await withFileLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 98b24a80..d35dd6ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -319,6 +319,8 @@ interface ProvisioningRun { } | null; /** Pending tool approval requests awaiting user response (control_request protocol). */ pendingApprovals: Map; + /** Teammate permission_request IDs already intercepted (prevents re-processing read messages). */ + processedPermissionRequestIds: Set; /** * Post-compact context reinjection lifecycle. * - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject. @@ -1837,6 +1839,9 @@ export class TeamProvisioningService { color: message.color, toolSummary: message.toolSummary, toolCalls: message.toolCalls, + messageKind: message.messageKind, + slashCommand: message.slashCommand, + commandOutput: message.commandOutput, }); } catch (error) { logger.warn(`[${teamName}] sent-message persist failed: ${String(error)}`); @@ -1865,6 +1870,9 @@ export class TeamProvisioningService { color: message.color, toolSummary: message.toolSummary, toolCalls: message.toolCalls, + messageKind: message.messageKind, + slashCommand: message.slashCommand, + commandOutput: message.commandOutput, }); } catch (error) { logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`); @@ -2956,6 +2964,7 @@ export class TeamProvisioningService { authRetryInProgress: false, spawnContext: null, pendingApprovals: new Map(), + processedPermissionRequestIds: new Set(), pendingPostCompactReminder: false, postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, @@ -3388,6 +3397,7 @@ export class TeamProvisioningService { authRetryInProgress: false, spawnContext: null, pendingApprovals: new Map(), + processedPermissionRequestIds: new Set(), pendingPostCompactReminder: false, postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, @@ -3904,24 +3914,55 @@ export class TeamProvisioningService { } const work = (async (): Promise => { - const runId = this.getAliveRunId(teamName); + const runId = this.getAliveRunId(teamName) ?? this.getProvisioningRunId(teamName); if (!runId) return 0; const run = this.runs.get(runId); if (!run?.child || run.processKilled || run.cancelRequested) return 0; - if (!run.provisioningComplete) return 0; - - const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set(); + // Permission request scan runs even during provisioning — teammates may need + // tool approval before the lead's first turn completes. CLI marks inbox messages + // as read after native delivery, so we must scan ALL messages (including read). let config: Awaited> | null = null; try { config = await this.configReader.getConfig(teamName); } catch { - return 0; + // config not ready yet during early provisioning — skip scan + } + if (config) { + const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; + try { + const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); + for (const msg of leadInboxMessages) { + if (typeof msg.text !== 'string') continue; + const perm = parsePermissionRequest(msg.text); + if (!perm) continue; + if (run.processedPermissionRequestIds.has(perm.requestId)) continue; + run.processedPermissionRequestIds.add(perm.requestId); + logger.warn( + `[${run.teamName}] [PERM-TRACE] Intercepted permission_request from inbox scan (read=${String(msg.read)}): agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}` + ); + this.handleTeammatePermissionRequest(run, perm, msg.timestamp); + } + } catch { + // best-effort — inbox may not exist yet + } + } + + if (!run.provisioningComplete) return 0; + + const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set(); + + // Re-read config if needed (already fetched above but guard provisioningComplete path) + if (!config) { + try { + config = await this.configReader.getConfig(teamName); + } catch { + return 0; + } } if (!config) return 0; const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; - let leadInboxMessages: Awaited> = []; try { leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index a33ca76f..b7352446 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -98,6 +98,37 @@ export class TeamSentMessagesStore { preview: typeof tc.preview === 'string' ? tc.preview : undefined, })) : undefined, + messageKind: + row.messageKind === 'slash_command' || row.messageKind === 'slash_command_result' + ? row.messageKind + : row.messageKind === 'default' + ? 'default' + : undefined, + slashCommand: + row.slashCommand && + typeof row.slashCommand === 'object' && + typeof row.slashCommand.name === 'string' && + typeof row.slashCommand.command === 'string' + ? { + name: row.slashCommand.name, + command: row.slashCommand.command as `/${string}`, + args: typeof row.slashCommand.args === 'string' ? row.slashCommand.args : undefined, + knownDescription: + typeof row.slashCommand.knownDescription === 'string' + ? row.slashCommand.knownDescription + : undefined, + } + : undefined, + commandOutput: + row.commandOutput && + typeof row.commandOutput === 'object' && + (row.commandOutput.stream === 'stdout' || row.commandOutput.stream === 'stderr') && + typeof row.commandOutput.commandLabel === 'string' + ? { + stream: row.commandOutput.stream, + commandLabel: row.commandOutput.commandLabel, + } + : undefined, }); } diff --git a/src/main/services/team/leadSessionMessageExtractor.ts b/src/main/services/team/leadSessionMessageExtractor.ts new file mode 100644 index 00000000..6fd9c23f --- /dev/null +++ b/src/main/services/team/leadSessionMessageExtractor.ts @@ -0,0 +1,198 @@ +import { isParsedSystemChunkMessage, isParsedUserChunkMessage, isTextContent } from '@main/types'; +import { parseJsonlLine } from '@main/utils/jsonl'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; + +import { extractCommandOutputInfo, extractSlashInfo } from '@shared/utils/contentSanitizer'; +import { buildSlashCommandMeta } from '@shared/utils/slashCommands'; + +import type { ParsedMessage } from '@main/types'; +import type { CommandOutputMeta, InboxMessage, SlashCommandMeta } from '@shared/types'; + +const MAX_SCAN_BYTES = 8 * 1024 * 1024; +const INITIAL_SCAN_BYTES = 256 * 1024; + +interface LeadSessionMessageExtractorOptions { + jsonlPath: string; + leadName: string; + leadSessionId: string; + maxMessages: number; +} + +function getMessageText(message: ParsedMessage): string { + if (typeof message.content === 'string') { + return message.content.trim(); + } + + if (!Array.isArray(message.content)) { + return ''; + } + + return message.content + .filter(isTextContent) + .map((block) => block.text) + .join('\n') + .trim(); +} + +function buildScanKey(message: ParsedMessage, rawLine: string): string { + if (typeof message.uuid === 'string' && message.uuid.trim()) { + return message.uuid.trim(); + } + + return `${message.timestamp.toISOString()}\0${rawLine}`; +} + +function summarizeCommandOutput(output: string): string { + const firstLine = output + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + if (!firstLine) return ''; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; +} + +function buildSlashMetaFromParsedMessage(message: ParsedMessage): SlashCommandMeta | null { + const slash = extractSlashInfo(getMessageText(message)); + if (!slash) return null; + return buildSlashCommandMeta(slash.name, slash.args, `/${slash.name}`); +} + +function buildCommandOutputMeta( + pendingSlash: SlashCommandMeta | null, + stream: CommandOutputMeta['stream'] +): CommandOutputMeta { + return { + stream, + commandLabel: pendingSlash?.command ?? '/command', + }; +} + +function buildResultMessageId(message: ParsedMessage, output: string): string { + const uuid = typeof message.uuid === 'string' ? message.uuid.trim() : ''; + if (uuid) { + return `lead-command-result-${uuid}`; + } + + return `lead-command-result-${createHash('sha256').update(`${message.timestamp.toISOString()}\n${output}`).digest('hex').slice(0, 16)}`; +} + +function canMergeCommandOutput( + previousMessage: InboxMessage | undefined, + commandOutput: CommandOutputMeta, + previousWasCommandOutput: boolean +): previousMessage is InboxMessage & { commandOutput: CommandOutputMeta } { + if (!previousWasCommandOutput || !previousMessage?.commandOutput) { + return false; + } + + return ( + previousMessage.messageKind === 'slash_command_result' && + previousMessage.commandOutput.stream === commandOutput.stream && + previousMessage.commandOutput.commandLabel === commandOutput.commandLabel + ); +} + +export async function extractLeadSessionMessagesFromJsonl({ + jsonlPath, + leadName, + leadSessionId, + maxMessages, +}: LeadSessionMessageExtractorOptions): Promise { + if (maxMessages <= 0) return []; + + const parsedMessagesReversed: ParsedMessage[] = []; + const seenScanKeys = new Set(); + const handle = await fs.promises.open(jsonlPath, 'r'); + + try { + const stat = await handle.stat(); + const fileSize = stat.size; + + let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); + while (scanBytes <= MAX_SCAN_BYTES) { + const start = Math.max(0, fileSize - scanBytes); + const buffer = Buffer.alloc(scanBytes); + await handle.read(buffer, 0, scanBytes, start); + const chunk = buffer.toString('utf8'); + + const lines = chunk.split(/\r?\n/); + const fromIndex = start > 0 ? 1 : 0; + + for (let i = lines.length - 1; i >= fromIndex; i--) { + const trimmed = lines[i]?.trim(); + if (!trimmed) continue; + + let parsed: ParsedMessage | null = null; + try { + parsed = parseJsonlLine(trimmed); + } catch { + parsed = null; + } + if (!parsed || parsed.isSidechain) continue; + + const scanKey = buildScanKey(parsed, trimmed); + if (seenScanKeys.has(scanKey)) continue; + seenScanKeys.add(scanKey); + parsedMessagesReversed.push(parsed); + } + + if (scanBytes === fileSize) break; + scanBytes = Math.min(fileSize, scanBytes * 2); + } + } finally { + await handle.close(); + } + + const parsedMessages = parsedMessagesReversed.reverse(); + const extractedMessages: InboxMessage[] = []; + let pendingSlash: SlashCommandMeta | null = null; + let previousWasCommandOutput = false; + + for (const message of parsedMessages) { + if (isParsedUserChunkMessage(message)) { + pendingSlash = buildSlashMetaFromParsedMessage(message); + previousWasCommandOutput = false; + continue; + } + + if (!isParsedSystemChunkMessage(message)) { + previousWasCommandOutput = false; + continue; + } + + const outputInfo = extractCommandOutputInfo(getMessageText(message)); + if (!outputInfo?.output) { + previousWasCommandOutput = false; + continue; + } + + const commandOutput = buildCommandOutputMeta(pendingSlash, outputInfo.stream); + const previousMessage = extractedMessages[extractedMessages.length - 1]; + if (canMergeCommandOutput(previousMessage, commandOutput, previousWasCommandOutput)) { + previousMessage.text = `${previousMessage.text}\n${outputInfo.output}`; + previousMessage.summary = summarizeCommandOutput(previousMessage.text) || undefined; + previousWasCommandOutput = true; + continue; + } + + extractedMessages.push({ + from: leadName, + text: outputInfo.output, + timestamp: message.timestamp.toISOString(), + read: true, + source: 'lead_session', + leadSessionId, + messageId: buildResultMessageId(message, outputInfo.output), + messageKind: 'slash_command_result', + commandOutput, + summary: summarizeCommandOutput(outputInfo.output) || undefined, + }); + previousWasCommandOutput = true; + } + + return extractedMessages.length > maxMessages + ? extractedMessages.slice(-maxMessages) + : extractedMessages; +} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index a456b6cc..f9bf3dd6 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -39,8 +39,21 @@ import { } from '@shared/constants/crossTeam'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; +import { + buildStandaloneSlashCommandMeta, + getKnownSlashCommand, + parseStandaloneSlashCommand, +} from '@shared/utils/slashCommands'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; -import { AlertTriangle, ChevronRight, ListPlus, Maximize2, RefreshCw, Reply } from 'lucide-react'; +import { + AlertTriangle, + ChevronRight, + Command, + ListPlus, + Maximize2, + RefreshCw, + Reply, +} from 'lucide-react'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; @@ -70,6 +83,16 @@ function parseCrossTeamPseudoRecipient(value: string | undefined): string | null return teamName.length > 0 ? teamName : null; } +function getCommandOutputSummary(text: string): string { + const firstLine = text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + + if (!firstLine) return ''; + return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine; +} + export function isQualifiedExternalRecipient( value: string | undefined, teamName: string, @@ -475,6 +498,8 @@ export const ActivityItem = memo( message.from === 'user' || message.from === 'system' || crossTeamOrigin?.memberName === 'user'; + const isUserSent = message.source === 'user_sent' || isCrossTeamSent; + const isSystemMessage = message.from === 'system'; // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -489,6 +514,28 @@ export const ActivityItem = memo( // Normalize literal \n from historical CLI-produced text to real newlines return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); }, [structured, message.text, isCrossTeamAny]); + const standaloneSlashCommand = useMemo( + () => (strippedText ? parseStandaloneSlashCommand(strippedText) : null), + [strippedText] + ); + const slashCommandMeta = useMemo( + () => + message.slashCommand ?? + (standaloneSlashCommand + ? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw) + : null), + [message.slashCommand, standaloneSlashCommand] + ); + const knownSlashCommand = useMemo( + () => (slashCommandMeta?.name ? (getKnownSlashCommand(slashCommandMeta.name) ?? null) : null), + [slashCommandMeta] + ); + const isSlashCommandResult = + message.messageKind === 'slash_command_result' && !!message.commandOutput; + const isSlashCommandMessage = + !isSlashCommandResult && + (message.messageKind === 'slash_command' || (isUserSent && standaloneSlashCommand !== null)); + const isCommandOutputError = isSlashCommandResult && message.commandOutput?.stream === 'stderr'; // Parse reply BEFORE linkification — linkifyAllMentionsInMarkdown transforms @name // into markdown links which breaks the reply regex matcher @@ -515,6 +562,16 @@ export const ActivityItem = memo( }, [isCrossTeamAny, strippedText]); const rawSummary = useMemo(() => { + if (isSlashCommandResult && message.commandOutput) { + return message.summary || getCommandOutputSummary(message.text); + } + if (isSlashCommandMessage && slashCommandMeta) { + if (slashCommandMeta.args) { + const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim(); + return `${slashCommandMeta.command} ${oneLine}`; + } + return slashCommandMeta.command; + } if (crossTeamPreview) return crossTeamPreview; const s = message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; @@ -524,7 +581,17 @@ export const ActivityItem = memo( if (!plain) return ''; const oneLine = plain.replace(/\n+/g, ' '); return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; - }, [crossTeamPreview, message.summary, structured, message.text]); + }, [ + crossTeamPreview, + isSlashCommandMessage, + isSlashCommandResult, + message.commandOutput, + message.summary, + message.text, + slashCommandMeta, + standaloneSlashCommand, + structured, + ]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); // Noise messages: minimal inline row @@ -557,8 +624,6 @@ export const ActivityItem = memo( const isHeaderClickable = isManaged && canToggleCollapse; const showChevron = isHeaderClickable && !compactHeader; - const isUserSent = message.source === 'user_sent' || isCrossTeamSent; - const isSystemMessage = message.from === 'system'; const handleHeaderToggle = useCallback(() => { if (isHeaderClickable && collapseToggleKey) { onToggleCollapse?.(collapseToggleKey); @@ -569,33 +634,45 @@ export const ActivityItem = memo(
{/* Header — div with role=button (cannot use
+ ) : isSlashCommandResult && message.commandOutput ? ( +
+
+ + + {message.commandOutput.commandLabel} + + + {message.commandOutput.stream} + +
+ +
+
+ +
+                    {message.text}
+                  
+
+
+ ) : isSlashCommandMessage && slashCommandMeta ? ( +
+
+ + + {slashCommandMeta.command} + +
+ {(slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? ( +

+ {slashCommandMeta.knownDescription ?? knownSlashCommand?.description} +

+ ) : null} + {slashCommandMeta.args ? ( +
+ {slashCommandMeta.args} +
+ ) : null} +
) : parsedReply ? ( 0) return false; // Compaction boundary events are system messages, not lead thoughts if (isCompactionMessage(msg)) return false; + if (msg.messageKind === 'slash_command_result') return false; // Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden if (isThoughtProtocolNoise(msg.text)) return false; if (msg.source === 'lead_session') return true; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 30902e9d..b21e5985 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -25,6 +25,7 @@ import { } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands'; import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; @@ -198,8 +199,21 @@ export const MessageComposer = ({ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); + const slashCommandSuggestions = useMemo( + () => + KNOWN_SLASH_COMMANDS.map((command) => ({ + id: `command:${command.name}`, + name: command.name, + command: command.command, + description: command.description, + subtitle: command.description, + type: 'command', + })), + [] + ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); + const standaloneSlashCommand = useMemo(() => parseStandaloneSlashCommand(trimmed), [trimmed]); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; @@ -265,6 +279,17 @@ export const MessageComposer = ({ : 'Team must be online to attach files' : undefined; const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; + const slashCommandRestrictionReason = standaloneSlashCommand + ? draft.attachments.length > 0 + ? 'Slash commands require a live team lead and cannot be sent with attachments' + : isCrossTeam + ? 'Slash commands can only be run on the current team lead' + : !isLeadRecipient + ? 'Slash commands can only be sent to the team lead' + : !isTeamAlive + ? 'Slash commands require the team lead to be online' + : null + : null; const canSend = recipient.length > 0 && trimmed.length > 0 && @@ -272,6 +297,7 @@ export const MessageComposer = ({ !sending && !isProvisioning && !attachmentsBlocked && + !slashCommandRestrictionReason && (!isCrossTeam || onCrossTeamSend !== undefined); // Track whether we initiated a send — clear draft only on confirmed success @@ -870,6 +896,7 @@ export const MessageComposer = ({ suggestions={mentionSuggestions} teamSuggestions={teamMentionSuggestions} taskSuggestions={taskSuggestions} + commandSuggestions={slashCommandSuggestions} chips={draft.chips} onChipRemove={draft.removeChip} projectPath={projectPath} @@ -877,6 +904,7 @@ export const MessageComposer = ({ onModEnter={handleSend} onShiftTab={handleCycleActionMode} dismissMentionsRef={dismissMentionsRef} + extraTips={['Tip: You can use "/" to run any Claude commands.']} minRows={2} maxRows={6} maxLength={MAX_TEXT_LENGTH} @@ -918,7 +946,9 @@ export const MessageComposer = ({ - {isProvisioning && !sending ? ( + {slashCommandRestrictionReason ? ( + {slashCommandRestrictionReason} + ) : isProvisioning && !sending ? ( Sending unavailable while team is launching @@ -928,7 +958,12 @@ export const MessageComposer = ({ } footerRight={
- {sendError ? ( + {slashCommandRestrictionReason ? ( + + + {slashCommandRestrictionReason} + + ) : sendError ? ( {sendError} diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index cd9d3fca..141c9460 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -5,7 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { getTeamColorSet, getThemedText } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { Folder, Hash, Loader2, UsersRound } from 'lucide-react'; +import { Command, Folder, Hash, Loader2, UsersRound } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -83,10 +83,11 @@ export const MentionSuggestionList = ({ } // Categorize suggestions (folders are grouped with files) - type Section = 'member' | 'team' | 'task' | 'file'; + type Section = 'member' | 'team' | 'task' | 'file' | 'command'; const getSuggestionSection = (s: MentionSuggestion): Section => { if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'task') return 'task'; + if (s.type === 'command') return 'command'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -96,6 +97,7 @@ export const MentionSuggestionList = ({ team: 'Teams', task: 'Tasks', file: 'Files', + command: 'Commands', }; // Determine which sections are present @@ -114,6 +116,7 @@ export const MentionSuggestionList = ({ const isFileOrFolder = isFile || isFolder; const isTeam = section === 'team'; const isTask = section === 'task'; + const isCommand = section === 'command'; const taskTeamColorSet = isTask && s.color ? getTeamColorSet(s.color) @@ -160,6 +163,8 @@ export const MentionSuggestionList = ({ ) : isTask ? ( + ) : isCommand ? ( + ) : isTeam ? ( - + {!isTask && !isFileOrFolder && s.subtitle ? ( {s.subtitle} @@ -208,6 +218,11 @@ export const MentionSuggestionList = ({ {isTask && s.subtitle ? (
{s.subtitle}
) : null} + {isCommand && s.description ? ( +
+ {s.description} +
+ ) : null}
{isTeam && s.isOnline !== undefined ? ( void; /** Called when Shift+Tab is pressed. */ onShiftTab?: () => void; /** Ref that receives the dismiss callback to close mention dropdown from outside */ dismissMentionsRef?: React.MutableRefObject<(() => void) | null>; + /** Additional rotating tips to append after the defaults */ + extraTips?: string[]; } export const MentionableTextarea = React.forwardRef( @@ -347,9 +375,11 @@ export const MentionableTextarea = React.forwardRef 0 ? ['@', '#', '/'] : enableTaskSearch ? ['@', '#'] : ['@'], isTriggerEnabled: (triggerChar) => { if (triggerChar === '#') return enableTaskSearch; + if (triggerChar === '/') return commandSuggestions.length > 0; return suggestions.length > 0 || enableFiles || teamSuggestions.length > 0; }, + isTriggerMatchValid: (trigger, text) => { + if (trigger.triggerChar !== '/') return true; + return text.slice(0, trigger.triggerIndex).trim().length === 0; + }, }); // Expose dismiss to parent via ref for external close (e.g. Send button click) @@ -413,7 +449,7 @@ export const MentionableTextarea = React.forwardRef { if (!isOpen || !isAtTrigger) return []; @@ -434,6 +470,12 @@ export const MentionableTextarea = React.forwardRef doesSuggestionMatchQuery(task, query)); }, [taskSuggestions, activeTriggerChar, isOpen, query]); + const filteredCommandSuggestions = React.useMemo(() => { + if (commandSuggestions.length === 0 || !isOpen || activeTriggerChar !== '/') return []; + if (!query) return commandSuggestions; + return commandSuggestions.filter((command) => doesSuggestionMatchQuery(command, query)); + }, [commandSuggestions, activeTriggerChar, isOpen, query]); + // Merged suggestion list: members → online teams → offline teams → files const atSuggestions = React.useMemo(() => { const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline); @@ -444,7 +486,11 @@ export const MentionableTextarea = React.forwardRef { if (!isOpen) return; @@ -607,6 +653,7 @@ export const MentionableTextarea = React.forwardRef 0 || teamSuggestions.length > 0 || taskSuggestions.length > 0 || @@ -617,6 +664,11 @@ export const MentionableTextarea = React.forwardRef (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions), [suggestions, teamSuggestions] ); + const slashCommand = React.useMemo(() => parseStandaloneSlashCommand(value), [value]); + const knownSlashCommand = React.useMemo( + () => (slashCommand ? getKnownSlashCommand(slashCommand.name) : null), + [slashCommand] + ); const segments = React.useMemo( () => @@ -962,8 +1014,9 @@ export const MentionableTextarea = React.forwardRef 0 || enableFiles || teamSuggestions.length > 0 || enableTaskSearch); + (suggestions.length > 0 || + enableFiles || + teamSuggestions.length > 0 || + enableTaskSearch || + commandSuggestions.length > 0); const showFooter = showHintRow || footerRight; return ( @@ -1012,6 +1069,26 @@ export const MentionableTextarea = React.forwardRef; } + if (seg.type === 'slash_command') { + return ( + + {seg.value} + + ); + } if (seg.type === 'task') { return ( @@ -1110,6 +1187,16 @@ export const MentionableTextarea = React.forwardRef ) : null} + {slashCommand ? ( + + ) : null} + ; + scrollTop: number; +} + +export const SlashCommandInteractionLayer = ({ + command, + definition, + value, + textareaRef, + scrollTop, +}: SlashCommandInteractionLayerProps): React.JSX.Element | null => { + const [position, setPosition] = React.useState<{ + top: number; + left: number; + width: number; + height: number; + } | null>(null); + + React.useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const [match] = calculateInlineMatchPositions(textarea, value, [ + { + item: command, + start: command.startIndex, + end: command.endIndex, + token: command.raw, + }, + ]); + + if (!match) { + setPosition(null); + return; + } + + setPosition({ + top: match.top, + left: match.left, + width: match.width, + height: match.height, + }); + }, [command, textareaRef, value]); + + if (!definition || !position) return null; + + return ( +
+
+ + +
+ + +
+
{definition.command}
+
+ {definition.description} +
+
+
+ +
+
+ ); +}; diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index a273bc69..b15bcbcb 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -15,6 +15,8 @@ interface UseMentionDetectionOptions { triggerChars?: string[]; /** Enable or disable individual triggers dynamically. */ isTriggerEnabled?: (triggerChar: string) => boolean; + /** Additional validation for trigger matches before opening the dropdown. */ + isTriggerMatchValid?: (trigger: MentionTrigger, text: string) => boolean; } export interface DropdownPosition { @@ -176,6 +178,7 @@ export function useMentionDetection({ textareaRef, triggerChars = ['@'], isTriggerEnabled, + isTriggerMatchValid, }: UseMentionDetectionOptions): UseMentionDetectionResult { const [isOpen, setIsOpen] = useState(false); const [activeTriggerChar, setActiveTriggerChar] = useState(null); @@ -253,7 +256,8 @@ export function useMentionDetection({ (cursorPos: number) => { const trigger = findMentionTrigger(value, cursorPos, triggerChars); const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; - if (trigger && isEnabled) { + const isValid = trigger ? (isTriggerMatchValid?.(trigger, value) ?? true) : false; + if (trigger && isEnabled && isValid) { const sameQuery = triggerIndexRef.current === trigger.triggerIndex && activeTriggerCharRef.current === trigger.triggerChar && @@ -274,7 +278,7 @@ export function useMentionDetection({ dismiss(); } }, - [value, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] + [value, triggerChars, isTriggerEnabled, isTriggerMatchValid, dismiss, computeDropdownPosition] ); const handleChange = useCallback( @@ -286,7 +290,8 @@ export function useMentionDetection({ const cursorPos = e.target.selectionStart; const trigger = findMentionTrigger(newValue, cursorPos, triggerChars); const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false; - if (trigger && isEnabled) { + const isValid = trigger ? (isTriggerMatchValid?.(trigger, newValue) ?? true) : false; + if (trigger && isEnabled && isValid) { triggerIndexRef.current = trigger.triggerIndex; activeTriggerCharRef.current = trigger.triggerChar; queryRef.current = trigger.query; @@ -300,7 +305,14 @@ export function useMentionDetection({ dismiss(); } }, - [onValueChange, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition] + [ + onValueChange, + triggerChars, + isTriggerEnabled, + isTriggerMatchValid, + dismiss, + computeDropdownPosition, + ] ); const handleSelect = useCallback( diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index fd4bdca0..a708abc8 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -5,10 +5,12 @@ export interface MentionSuggestion { name: string; /** Role displayed in suggestion list */ subtitle?: string; + /** Optional description for command and rich suggestion tooltips */ + description?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', 'file', 'folder', or 'task' */ - type?: 'member' | 'team' | 'file' | 'folder' | 'task'; + /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */ + type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; /** Absolute file/folder path (file/folder suggestions only) */ @@ -19,6 +21,8 @@ export interface MentionSuggestion { insertText?: string; /** Optional extra searchable text (subject, team name, path, etc.) */ searchText?: string; + /** Optional slash command string including leading slash (command suggestions only) */ + command?: `/${string}`; /** Canonical task id (task suggestions only) */ taskId?: string; /** Owning team name (task suggestions only) */ diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts index c8c37d4f..a83e1bed 100644 --- a/src/renderer/utils/mentionSuggestions.ts +++ b/src/renderer/utils/mentionSuggestions.ts @@ -1,10 +1,15 @@ import type { MentionSuggestion } from '@renderer/types/mention'; -export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' { - return suggestion.type === 'task' ? '#' : '@'; +export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' { + if (suggestion.type === 'task') return '#'; + if (suggestion.type === 'command') return '/'; + return '@'; } export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { + if (suggestion.type === 'command') { + return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name; + } return suggestion.insertText ?? suggestion.name; } @@ -15,10 +20,12 @@ export function doesSuggestionMatchQuery(suggestion: MentionSuggestion, query: s const haystacks = [ suggestion.name, suggestion.subtitle, + suggestion.description, suggestion.relativePath, suggestion.searchText, suggestion.teamDisplayName, suggestion.teamName, + suggestion.command, ] .filter(Boolean) .map((value) => value!.toLowerCase()); diff --git a/src/renderer/utils/messageRenderEquality.ts b/src/renderer/utils/messageRenderEquality.ts index f9da51ad..ef25acb2 100644 --- a/src/renderer/utils/messageRenderEquality.ts +++ b/src/renderer/utils/messageRenderEquality.ts @@ -89,6 +89,29 @@ export function areToolCallsEqual( return true; } +export function areSlashCommandsEqual( + prev?: InboxMessage['slashCommand'], + next?: InboxMessage['slashCommand'] +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + return ( + prev.name === next.name && + prev.command === next.command && + prev.args === next.args && + prev.knownDescription === next.knownDescription + ); +} + +export function areCommandOutputsEqual( + prev?: InboxMessage['commandOutput'], + next?: InboxMessage['commandOutput'] +): boolean { + if (prev === next) return true; + if (!prev || !next) return !prev && !next; + return prev.stream === next.stream && prev.commandLabel === next.commandLabel; +} + export function areInboxMessagesEquivalentForRender( prev: InboxMessage, next: InboxMessage @@ -107,10 +130,13 @@ export function areInboxMessagesEquivalentForRender( if (prev.source !== next.source) return false; if (prev.leadSessionId !== next.leadSessionId) return false; if (prev.toolSummary !== next.toolSummary) return false; + if (prev.messageKind !== next.messageKind) return false; return ( areTaskRefsEqual(prev.taskRefs, next.taskRefs) && - areAttachmentsEqual(prev.attachments, next.attachments) + areAttachmentsEqual(prev.attachments, next.attachments) && + areSlashCommandsEqual(prev.slashCommand, next.slashCommand) && + areCommandOutputsEqual(prev.commandOutput, next.commandOutput) ); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 74afbd92..1d4fa2d7 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -166,6 +166,20 @@ export interface SourceMessageSnapshot { }[]; } +export type InboxMessageKind = 'default' | 'slash_command' | 'slash_command_result'; + +export interface SlashCommandMeta { + name: string; + command: `/${string}`; + args?: string; + knownDescription?: string; +} + +export interface CommandOutputMeta { + stream: 'stdout' | 'stderr'; + commandLabel: string; +} + // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. // Adding a field here without mapping it there will cause a compile error. export interface TeamTask { @@ -323,6 +337,12 @@ export interface InboxMessage { toolSummary?: string; /** Structured tool call details for tooltip display. */ toolCalls?: ToolCallMeta[]; + /** Renderer-friendly semantic kind. Defaults to "default" when absent. */ + messageKind?: InboxMessageKind; + /** Structured slash-command metadata for sent command rows. */ + slashCommand?: SlashCommandMeta; + /** Structured command-output metadata for session-derived result rows. */ + commandOutput?: CommandOutputMeta; } export type AgentActionMode = 'do' | 'ask' | 'delegate'; @@ -348,6 +368,9 @@ export interface SendMessageRequest { replyToConversationId?: string; toolSummary?: string; toolCalls?: ToolCallMeta[]; + messageKind?: InboxMessageKind; + slashCommand?: SlashCommandMeta; + commandOutput?: CommandOutputMeta; } export interface SendMessageResult { diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts index e85c145d..06430637 100644 --- a/src/shared/utils/contentSanitizer.ts +++ b/src/shared/utils/contentSanitizer.ts @@ -21,18 +21,29 @@ const NOISE_TAG_PATTERNS = [ /[\s\S]*?<\/system-reminder>/gi, ]; +export interface CommandOutputInfo { + stream: 'stdout' | 'stderr'; + output: string; +} + /** * Extract content from tags. * Returns the command output without the wrapper tags. */ -function extractCommandOutput(content: string): string | null { +export function extractCommandOutputInfo(content: string): CommandOutputInfo | null { const match = /([\s\S]*?)<\/local-command-stdout>/i.exec(content); const matchStderr = /([\s\S]*?)<\/local-command-stderr>/i.exec(content); if (match) { - return match[1].trim(); + return { + stream: 'stdout', + output: match[1].trim(), + }; } if (matchStderr) { - return matchStderr[1].trim(); + return { + stream: 'stderr', + output: matchStderr[1].trim(), + }; } return null; } @@ -84,9 +95,9 @@ export function isCommandOutputContent(content: string): boolean { export function sanitizeDisplayContent(content: string): string { // If it's a command output message, extract the output content if (isCommandOutputContent(content)) { - const commandOutput = extractCommandOutput(content); + const commandOutput = extractCommandOutputInfo(content); if (commandOutput) { - return commandOutput; + return commandOutput.output; } } diff --git a/src/shared/utils/slashCommands.ts b/src/shared/utils/slashCommands.ts new file mode 100644 index 00000000..35e4f19b --- /dev/null +++ b/src/shared/utils/slashCommands.ts @@ -0,0 +1,123 @@ +import type { SlashCommandMeta } from '@shared/types/team'; + +export interface KnownSlashCommandDefinition { + name: string; + command: `/${string}`; + description: string; +} + +export interface ParsedStandaloneSlashCommand { + name: string; + command: `/${string}`; + args?: string; + raw: string; + startIndex: number; + endIndex: number; +} + +const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i; + +export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [ + { + name: 'compact', + command: '/compact', + description: 'Compact conversation with optional focus instructions.', + }, + { + name: 'clear', + command: '/clear', + description: 'Clear conversation history and free up context.', + }, + { + name: 'reset', + command: '/reset', + description: 'Alias of /clear. Clear conversation history and free up context.', + }, + { + name: 'new', + command: '/new', + description: 'Alias of /clear. Start a fresh conversation.', + }, + { + name: 'plan', + command: '/plan', + description: 'Enter plan mode with an optional task description.', + }, + { + name: 'model', + command: '/model', + description: 'Select or change the Claude model.', + }, + { + name: 'effort', + command: '/effort', + description: 'Set reasoning effort for the current session.', + }, + { + name: 'fast', + command: '/fast', + description: 'Toggle fast mode on or off.', + }, + { + name: 'cost', + command: '/cost', + description: 'Show token usage statistics.', + }, + { + name: 'usage', + command: '/usage', + description: 'Show plan usage limits and rate-limit status.', + }, +] as const; + +const KNOWN_SLASH_COMMANDS_BY_NAME = new Map( + KNOWN_SLASH_COMMANDS.map((command) => [command.name.toLowerCase(), command] as const) +); + +export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition | null { + return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null; +} + +export function buildSlashCommandMeta( + name: string, + args?: string, + command?: `/${string}` +): SlashCommandMeta { + const normalizedName = name.trim().toLowerCase(); + const normalizedCommand = command ?? `/${normalizedName}`; + const known = getKnownSlashCommand(normalizedName); + return { + name: normalizedName, + command: normalizedCommand, + ...(args ? { args } : {}), + ...(known ? { knownDescription: known.description } : {}), + }; +} + +export function buildStandaloneSlashCommandMeta(text: string): SlashCommandMeta | null { + const parsed = parseStandaloneSlashCommand(text); + if (!parsed) return null; + return buildSlashCommandMeta(parsed.name, parsed.args, parsed.command); +} + +export function parseStandaloneSlashCommand(text: string): ParsedStandaloneSlashCommand | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + const match = STANDALONE_SLASH_COMMAND_PATTERN.exec(trimmed); + if (!match) return null; + + const name = match[1].toLowerCase(); + const args = match[2]?.trim(); + const startIndex = text.indexOf(trimmed); + const endIndex = startIndex + trimmed.length; + + return { + name, + command: `/${name}`, + args: args || undefined, + raw: trimmed, + startIndex, + endIndex, + }; +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 1dba68ff..025593a4 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -293,6 +293,101 @@ describe('ipc teams handlers', () => { ); }); + it('sends standalone slash commands to lead stdin without the UI routing wrapper', async () => { + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const result = (await sendHandler!({} as never, 'my-team', { + member: 'team-lead', + text: ' /COMPACT keep kanban ', + })) as { success: boolean }; + + expect(result.success).toBe(true); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( + 'my-team', + '/COMPACT keep kanban', + undefined + ); + const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock + .calls as unknown[][]; + expect(String(compactCall[0]?.[1] ?? '')).not.toContain('You received a direct message from the user'); + expect(service.sendDirectToLead).toHaveBeenCalledWith( + 'my-team', + 'team-lead', + '/COMPACT keep kanban', + undefined, + undefined, + undefined, + expect.any(String) + ); + }); + + it('routes unknown standalone slash commands through the same raw stdin path', async () => { + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + + const result = (await sendHandler!({} as never, 'my-team', { + member: 'team-lead', + text: ' /foo bar ', + })) as { success: boolean }; + + expect(result.success).toBe(true); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( + 'my-team', + '/foo bar', + undefined + ); + const unknownSlashCall = vi.mocked(provisioningService.sendMessageToTeam).mock + .calls as unknown[][]; + expect(String(unknownSlashCall[0]?.[1] ?? '')).not.toContain( + 'You received a direct message from the user' + ); + expect(service.sendDirectToLead).toHaveBeenCalledWith( + 'my-team', + 'team-lead', + '/foo bar', + undefined, + undefined, + undefined, + expect.any(String) + ); + }); + + it('does not route slash commands through raw stdin when attachments are present', async () => { + const sendHandler = handlers.get(TEAM_SEND_MESSAGE); + expect(sendHandler).toBeDefined(); + vi.stubEnv('HOME', os.tmpdir()); + try { + const result = (await sendHandler!({} as never, 'my-team', { + member: 'team-lead', + text: '/compact keep kanban', + attachments: [ + { + id: 'att-1', + filename: 'note.txt', + mimeType: 'text/plain', + size: 4, + data: Buffer.from('test').toString('base64'), + }, + ], + })) as { success: boolean }; + + expect(result.success).toBe(true); + expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith( + 'my-team', + expect.stringContaining('You received a direct message from the user'), + expect.arrayContaining([ + expect.objectContaining({ + id: 'att-1', + filename: 'note.txt', + }), + ]) + ); + } finally { + vi.unstubAllEnvs(); + } + }); + it('rejects delegate mode when recipient is not the team lead', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 42565b41..6595d69f 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1945,4 +1945,117 @@ describe('TeamDataService', () => { expect(data).toEqual({ 'task-1': 'has_changes' }); expect(getMessages).not.toHaveBeenCalled(); }); + + it('persists standalone slash metadata when sending directly to the live lead', async () => { + const appendSentMessage = vi.fn((payload) => payload); + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + leadSessionId: 'lead-1', + })), + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + () => + ({ + messages: { + appendSentMessage, + }, + }) as never + ); + + const result = await service.sendDirectToLead( + 'my-team', + 'team-lead', + '/compact keep only kanban context' + ); + + expect(result.deliveredViaStdin).toBe(true); + expect(appendSentMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: '/compact keep only kanban context', + messageKind: 'slash_command', + slashCommand: expect.objectContaining({ + name: 'compact', + command: '/compact', + args: 'keep only kanban context', + }), + }) + ); + }); + + it('annotates immediate lead replies after slash commands as command results', async () => { + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ + name: 'My team', + members: [{ name: 'team-lead', role: 'Lead' }], + leadSessionId: 'lead-1', + })), + } as never, + { + getTasks: vi.fn(async () => []), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => [ + { + from: 'team-lead', + text: 'Total cost: $1.05', + timestamp: '2026-03-27T22:17:01.000Z', + read: true, + source: 'lead_process', + leadSessionId: 'lead-1', + messageId: 'lead-thought-1', + }, + ]), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never, + {} as never, + {} as never, + { + readMessages: vi.fn(async () => [ + { + from: 'user', + to: 'team-lead', + text: '/cost', + timestamp: '2026-03-27T22:17:00.000Z', + read: true, + source: 'user_sent', + leadSessionId: 'lead-1', + messageId: 'user-cost-1', + }, + ]), + } as never + ); + + const data = await service.getTeamData('my-team'); + const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1'); + + expect(costResult).toMatchObject({ + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/cost', + }, + }); + }); }); diff --git a/test/main/services/team/TeamSentMessagesStore.test.ts b/test/main/services/team/TeamSentMessagesStore.test.ts new file mode 100644 index 00000000..b88175eb --- /dev/null +++ b/test/main/services/team/TeamSentMessagesStore.test.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TeamSentMessagesStore } from '../../../../src/main/services/team/TeamSentMessagesStore'; + +const tempDirs: string[] = []; + +vi.mock('@main/utils/pathDecoder', () => ({ + getTeamsBasePath: () => tempDirs[tempDirs.length - 1], +})); + +describe('TeamSentMessagesStore', () => { + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map(async (dir) => { + await fs.rm(dir, { recursive: true, force: true }); + }) + ); + }); + + it('preserves slash-command metadata when reading sent messages', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'team-sent-store-')); + tempDirs.push(root); + + const teamDir = path.join(root, 'my-team'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'sentMessages.json'), + JSON.stringify( + [ + { + from: 'user', + to: 'team-lead', + text: '/model sonnet', + timestamp: '2026-03-27T12:00:00.000Z', + read: true, + messageId: 'msg-1', + source: 'user_sent', + messageKind: 'slash_command', + slashCommand: { + name: 'model', + command: '/model', + args: 'sonnet', + knownDescription: 'Select or change the Claude model.', + }, + }, + { + from: 'team-lead', + text: 'Model set to sonnet', + timestamp: '2026-03-27T12:00:01.000Z', + read: true, + messageId: 'msg-2', + source: 'lead_session', + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/model', + }, + }, + ], + null, + 2 + ), + 'utf8' + ); + + const store = new TeamSentMessagesStore(); + const messages = await store.readMessages('my-team'); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + messageKind: 'slash_command', + slashCommand: { + name: 'model', + command: '/model', + args: 'sonnet', + knownDescription: 'Select or change the Claude model.', + }, + }); + expect(messages[1]).toMatchObject({ + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/model', + }, + }); + }); +}); diff --git a/test/main/services/team/leadSessionMessageExtractor.test.ts b/test/main/services/team/leadSessionMessageExtractor.test.ts new file mode 100644 index 00000000..016e0d49 --- /dev/null +++ b/test/main/services/team/leadSessionMessageExtractor.test.ts @@ -0,0 +1,148 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { extractLeadSessionMessagesFromJsonl } from '../../../../src/main/services/team/leadSessionMessageExtractor'; + +function createUserEntry( + uuid: string, + timestamp: string, + content: string +): Record { + return { + uuid, + parentUuid: null, + type: 'user', + timestamp, + isSidechain: false, + userType: 'external', + cwd: '/repo', + sessionId: 'lead-1', + version: '1.0.0', + gitBranch: 'main', + message: { + role: 'user', + content, + }, + }; +} + +function createAssistantEntry( + uuid: string, + timestamp: string, + text: string +): Record { + return { + uuid, + parentUuid: null, + type: 'assistant', + timestamp, + isSidechain: false, + userType: 'external', + cwd: '/repo', + sessionId: 'lead-1', + version: '1.0.0', + gitBranch: 'main', + requestId: `req-${uuid}`, + message: { + role: 'assistant', + model: 'claude-sonnet', + id: `msg-${uuid}`, + type: 'message', + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + }, + content: [{ type: 'text', text }], + }, + }; +} + +describe('extractLeadSessionMessagesFromJsonl', () => { + const tempPaths: string[] = []; + + afterEach(async () => { + await Promise.all( + tempPaths.splice(0).map(async (tempPath) => { + await fs.rm(tempPath, { recursive: true, force: true }); + }) + ); + }); + + it('extracts and merges command outputs without duplicating command rows', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lead-session-extractor-')); + tempPaths.push(dir); + const jsonlPath = path.join(dir, 'lead-1.jsonl'); + + const lines = [ + createUserEntry( + 'user-slash-1', + '2026-03-27T12:00:00.000Z', + '/modelmodelsonnet' + ), + createUserEntry( + 'stdout-1', + '2026-03-27T12:00:01.000Z', + 'Model set to sonnet' + ), + createUserEntry( + 'stdout-2', + '2026-03-27T12:00:02.000Z', + 'Context usage reset' + ), + createAssistantEntry('assistant-1', '2026-03-27T12:00:03.000Z', 'Regular assistant text'), + createUserEntry( + 'stderr-1', + '2026-03-27T12:00:04.000Z', + 'Warning: using cached model alias' + ), + createUserEntry('user-plain-1', '2026-03-27T12:00:05.000Z', 'hello'), + createUserEntry( + 'stdout-3', + '2026-03-27T12:00:06.000Z', + 'Detached output' + ), + ].map((entry) => JSON.stringify(entry)); + + await fs.writeFile(jsonlPath, `${lines.join('\n')}\n`, 'utf8'); + + const messages = await extractLeadSessionMessagesFromJsonl({ + jsonlPath, + leadName: 'team-lead', + leadSessionId: 'lead-1', + maxMessages: 20, + }); + + expect(messages).toHaveLength(3); + expect(messages[0]).toMatchObject({ + from: 'team-lead', + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/model', + }, + text: 'Model set to sonnet\nContext usage reset', + summary: 'Model set to sonnet', + }); + expect(messages[1]).toMatchObject({ + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stderr', + commandLabel: '/model', + }, + text: 'Warning: using cached model alias', + }); + expect(messages[2]).toMatchObject({ + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/command', + }, + text: 'Detached output', + }); + }); +}); diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 8140a67e..7c96dc1a 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -1,11 +1,123 @@ -import { describe, expect, it } from 'vitest'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ theme: 'dark', resolvedTheme: 'dark', isDark: true, isLight: false }), +})); +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content), +})); +vi.mock('@renderer/components/common/CopyButton', () => ({ + CopyButton: () => null, +})); +vi.mock('@renderer/components/team/attachments/AttachmentDisplay', () => ({ + AttachmentDisplay: () => null, +})); +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); +vi.mock('@renderer/components/team/TaskTooltip', () => ({ + TaskTooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), +})); +vi.mock('@renderer/components/ui/ExpandableContent', () => ({ + ExpandableContent: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})); +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); +vi.mock('@renderer/components/team/activity/ReplyQuoteBlock', () => ({ + ReplyQuoteBlock: () => null, +})); import { + ActivityItem, getCrossTeamSentMemberName, getCrossTeamSentTarget, getSystemMessageLabel, isQualifiedExternalRecipient, } from '@renderer/components/team/activity/ActivityItem'; +import type { InboxMessage } from '@shared/types'; + +describe('ActivityItem slash command rendering', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('renders standalone sent slash commands with command-specific styling content', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const message: InboxMessage = { + from: 'user', + text: '/compact keep kanban aligned', + timestamp: new Date('2026-03-27T12:00:00.000Z').toISOString(), + read: true, + source: 'user_sent', + }; + + await act(async () => { + root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('command'); + expect(host.textContent).toContain('/compact'); + expect(host.textContent).toContain('Compact conversation with optional focus instructions.'); + expect(host.textContent).toContain('keep kanban aligned'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('renders slash command results as a distinct command output row', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const message: InboxMessage = { + from: 'team-lead', + text: 'Model set to sonnet\nContext usage reset', + timestamp: new Date('2026-03-27T12:01:00.000Z').toISOString(), + read: true, + source: 'lead_session', + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/model', + }, + summary: 'Model set to sonnet', + }; + + await act(async () => { + root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('result'); + expect(host.textContent).toContain('stdout'); + expect(host.textContent).toContain('/model'); + expect(host.textContent).toContain('Model set to sonnet'); + expect(host.textContent).toContain('Context usage reset'); + expect(host.textContent).not.toContain('team-lead'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); describe('ActivityItem legacy system message fallback', () => { it('recognizes historical assignment and review message wording', () => { diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index 526938cd..13cdf52f 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -1,83 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { isLeadThought } from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup'; +import { + groupTimelineItems, + isLeadThought, +} from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup'; + +import type { InboxMessage } from '../../../../../src/shared/types'; describe('LeadThoughtsGroup', () => { - it('does not classify outbound runtime messages with recipients as lead thoughts', () => { - expect( - isLeadThought({ - from: 'team-lead', - to: 'alice', - text: 'Please check task #abcd1234', - timestamp: '2026-03-08T00:00:00.000Z', - read: true, - source: 'lead_process', - }) - ).toBe(false); - }); + it('does not classify slash command results as lead thoughts', () => { + const resultMessage: InboxMessage = { + from: 'team-lead', + text: 'Total cost: $1.05', + timestamp: '2026-03-27T22:06:00.000Z', + read: true, + source: 'lead_session', + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/cost', + }, + }; - it('filters out idle_notification JSON noise from lead thoughts', () => { - expect( - isLeadThought({ - from: 'team-lead', - text: '{"type":"idle_notification","message":"alice is idle"}', - timestamp: '2026-03-08T00:00:00.000Z', - read: true, - source: 'lead_session', - }) - ).toBe(false); - }); - - it('filters out shutdown_request JSON noise from lead thoughts', () => { - expect( - isLeadThought({ - from: 'team-lead', - text: '{"type":"shutdown_request","reason":"Task complete"}', - timestamp: '2026-03-08T00:00:00.000Z', - read: true, - source: 'lead_process', - }) - ).toBe(false); - }); - - it('filters out pure XML blocks from lead thoughts', () => { - expect( - isLeadThought({ - from: 'team-lead', - text: 'Task completed', - timestamp: '2026-03-08T00:00:00.000Z', - read: true, - source: 'lead_session', - }) - ).toBe(false); - }); - - it('filters out multiple blocks with whitespace', () => { - const text = [ - 'Hello', - '', - 'OK', - ].join('\n'); - expect( - isLeadThought({ - from: 'team-lead', - text, - timestamp: '2026-03-08T00:00:00.000Z', - read: true, - source: 'lead_process', - }) - ).toBe(false); - }); - - it('keeps normal lead thoughts with real content', () => { - expect( - isLeadThought({ - from: 'team-lead', - text: 'Reviewing the implementation plan for the new feature.', - timestamp: '2026-03-08T00:00:00.000Z', - read: true, - source: 'lead_session', - }) - ).toBe(true); + expect(isLeadThought(resultMessage)).toBe(false); + expect(groupTimelineItems([resultMessage])).toEqual([ + { + type: 'message', + message: resultMessage, + }, + ]); }); }); diff --git a/test/shared/utils/slashCommands.test.ts b/test/shared/utils/slashCommands.test.ts new file mode 100644 index 00000000..29a48857 --- /dev/null +++ b/test/shared/utils/slashCommands.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { + getKnownSlashCommand, + KNOWN_SLASH_COMMANDS, + parseStandaloneSlashCommand, +} from '@shared/utils/slashCommands'; + +describe('slashCommands', () => { + it('exposes exactly the curated known commands', () => { + expect(KNOWN_SLASH_COMMANDS.map((command) => command.command)).toEqual([ + '/compact', + '/clear', + '/reset', + '/new', + '/plan', + '/model', + '/effort', + '/fast', + '/cost', + '/usage', + ]); + }); + + it('parses known standalone slash commands', () => { + expect(parseStandaloneSlashCommand(' /compact keep kanban ')).toEqual({ + name: 'compact', + command: '/compact', + args: 'keep kanban', + raw: '/compact keep kanban', + startIndex: 2, + endIndex: 22, + }); + }); + + it('parses unknown standalone slash commands', () => { + expect(parseStandaloneSlashCommand('/foo bar')).toEqual({ + name: 'foo', + command: '/foo', + args: 'bar', + raw: '/foo bar', + startIndex: 0, + endIndex: 8, + }); + }); + + it('rejects slash-like text that is not a standalone command', () => { + expect(parseStandaloneSlashCommand('please run /compact now')).toBeNull(); + expect(parseStandaloneSlashCommand('/')).toBeNull(); + }); + + it('returns metadata for known commands only', () => { + expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model'); + expect(getKnownSlashCommand('foo')).toBeNull(); + }); +}); From 11bb49c53e076f1717dc1f47d8463e955e006589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Sat, 28 Mar 2026 12:03:42 +0200 Subject: [PATCH 033/113] feat(graph): force-directed agent graph visualization with kanban-zone task layout Force-directed graph visualization for agent teams. Package: @claude-teams/agent-graph (isolated workspace package) - Space theme: bloom, particles, hex grid, depth stars - Members as hexagonal nodes with breathing glow - Tasks as pill cards in kanban columns (todo/wip/done/review/approved) per owner - Message particles along edges (real-time only) - Deterministic layout, Figma-style pan, scroll/pinch zoom - Clean Architecture: ports/adapters/strategies, ES #private classes Integration: features/agent-graph/ (adapter + overlay + tab) - Full-screen overlay (Cmd+Shift+G) + Pin as Tab - Graph button in Team section header - Frustum culling, zero per-frame allocations, adaptive fps - Performance overlay via ?perf query param Also: CI runs on all PR branches, features/CLAUDE.md architecture guide --- .github/workflows/ci.yml | 12 +- CLAUDE.md | 4 + package.json | 1 + packages/agent-graph/package.json | 24 + .../src/canvas/background-layer.ts | 158 ++++++ .../agent-graph/src/canvas/bloom-renderer.ts | 70 +++ .../agent-graph/src/canvas/draw-agents.ts | 280 ++++++++++ packages/agent-graph/src/canvas/draw-edges.ts | 210 ++++++++ .../agent-graph/src/canvas/draw-effects.ts | 175 +++++++ packages/agent-graph/src/canvas/draw-misc.ts | 65 +++ .../agent-graph/src/canvas/draw-particles.ts | 154 ++++++ .../agent-graph/src/canvas/draw-processes.ts | 65 +++ packages/agent-graph/src/canvas/draw-tasks.ts | 178 +++++++ .../agent-graph/src/canvas/hit-detection.ts | 66 +++ packages/agent-graph/src/canvas/index.ts | 11 + .../agent-graph/src/canvas/render-cache.ts | 140 +++++ .../src/constants/canvas-constants.ts | 247 +++++++++ packages/agent-graph/src/constants/colors.ts | 167 ++++++ packages/agent-graph/src/constants/index.ts | 27 + .../agent-graph/src/hooks/useGraphCamera.ts | 178 +++++++ .../src/hooks/useGraphInteraction.ts | 89 ++++ .../src/hooks/useGraphSimulation.ts | 285 ++++++++++ packages/agent-graph/src/index.ts | 28 + .../agent-graph/src/layout/kanbanLayout.ts | 131 +++++ .../agent-graph/src/ports/GraphConfigPort.ts | 55 ++ .../agent-graph/src/ports/GraphDataPort.ts | 20 + .../agent-graph/src/ports/GraphEventPort.ts | 22 + packages/agent-graph/src/ports/index.ts | 13 + packages/agent-graph/src/ports/types.ts | 117 +++++ packages/agent-graph/src/strategies/index.ts | 27 + .../src/strategies/memberStrategy.ts | 72 +++ .../src/strategies/processStrategy.ts | 39 ++ .../src/strategies/taskStrategy.ts | 38 ++ packages/agent-graph/src/strategies/types.ts | 48 ++ packages/agent-graph/src/ui/GraphCanvas.tsx | 295 +++++++++++ packages/agent-graph/src/ui/GraphControls.tsx | 113 ++++ packages/agent-graph/src/ui/GraphOverlay.tsx | 165 ++++++ packages/agent-graph/src/ui/GraphView.tsx | 342 ++++++++++++ packages/agent-graph/tsconfig.json | 17 + pnpm-lock.yaml | 45 ++ pnpm-workspace.yaml | 1 + src/main/index.ts | 4 +- src/main/ipc/teams.ts | 2 +- .../infrastructure/PtyTerminalService.ts | 3 +- .../services/infrastructure/UpdaterService.ts | 3 +- .../services/team/ChangeExtractorService.ts | 11 +- src/main/services/team/TaskChangeComputer.ts | 4 +- src/main/services/team/TeamDataService.ts | 13 +- .../services/team/TeamLogSourceTracker.ts | 2 +- .../cache/JsonTaskChangePresenceRepository.ts | 2 +- .../cache/taskChangePresenceCacheSchema.ts | 2 +- src/main/services/team/index.ts | 2 +- src/main/workers/task-change-worker.ts | 2 +- src/preload/index.ts | 2 +- .../components/layout/PaneContent.tsx | 6 + .../components/layout/SortableTab.tsx | 2 + .../components/team/TeamDetailView.tsx | 78 ++- .../team/dialogs/TaskDetailDialog.tsx | 2 +- .../components/team/kanban/KanbanBoard.tsx | 3 +- src/renderer/features/CLAUDE.md | 494 ++++++++++++++++++ .../agent-graph/adapters/TeamGraphAdapter.ts | 399 ++++++++++++++ .../adapters/useTeamGraphAdapter.ts | 30 ++ src/renderer/features/agent-graph/index.ts | 9 + .../agent-graph/ui/TeamGraphOverlay.tsx | 57 ++ .../features/agent-graph/ui/TeamGraphTab.tsx | 31 ++ src/renderer/hooks/useKeyboardShortcuts.ts | 12 + src/renderer/store/index.ts | 11 +- src/renderer/store/slices/teamSlice.ts | 2 +- src/renderer/types/tabs.ts | 3 +- src/shared/types/api.ts | 2 +- .../CliInstallerService.test.ts | 8 +- 71 files changed, 5332 insertions(+), 63 deletions(-) create mode 100644 packages/agent-graph/package.json create mode 100644 packages/agent-graph/src/canvas/background-layer.ts create mode 100644 packages/agent-graph/src/canvas/bloom-renderer.ts create mode 100644 packages/agent-graph/src/canvas/draw-agents.ts create mode 100644 packages/agent-graph/src/canvas/draw-edges.ts create mode 100644 packages/agent-graph/src/canvas/draw-effects.ts create mode 100644 packages/agent-graph/src/canvas/draw-misc.ts create mode 100644 packages/agent-graph/src/canvas/draw-particles.ts create mode 100644 packages/agent-graph/src/canvas/draw-processes.ts create mode 100644 packages/agent-graph/src/canvas/draw-tasks.ts create mode 100644 packages/agent-graph/src/canvas/hit-detection.ts create mode 100644 packages/agent-graph/src/canvas/index.ts create mode 100644 packages/agent-graph/src/canvas/render-cache.ts create mode 100644 packages/agent-graph/src/constants/canvas-constants.ts create mode 100644 packages/agent-graph/src/constants/colors.ts create mode 100644 packages/agent-graph/src/constants/index.ts create mode 100644 packages/agent-graph/src/hooks/useGraphCamera.ts create mode 100644 packages/agent-graph/src/hooks/useGraphInteraction.ts create mode 100644 packages/agent-graph/src/hooks/useGraphSimulation.ts create mode 100644 packages/agent-graph/src/index.ts create mode 100644 packages/agent-graph/src/layout/kanbanLayout.ts create mode 100644 packages/agent-graph/src/ports/GraphConfigPort.ts create mode 100644 packages/agent-graph/src/ports/GraphDataPort.ts create mode 100644 packages/agent-graph/src/ports/GraphEventPort.ts create mode 100644 packages/agent-graph/src/ports/index.ts create mode 100644 packages/agent-graph/src/ports/types.ts create mode 100644 packages/agent-graph/src/strategies/index.ts create mode 100644 packages/agent-graph/src/strategies/memberStrategy.ts create mode 100644 packages/agent-graph/src/strategies/processStrategy.ts create mode 100644 packages/agent-graph/src/strategies/taskStrategy.ts create mode 100644 packages/agent-graph/src/strategies/types.ts create mode 100644 packages/agent-graph/src/ui/GraphCanvas.tsx create mode 100644 packages/agent-graph/src/ui/GraphControls.tsx create mode 100644 packages/agent-graph/src/ui/GraphOverlay.tsx create mode 100644 packages/agent-graph/src/ui/GraphView.tsx create mode 100644 packages/agent-graph/tsconfig.json create mode 100644 src/renderer/features/CLAUDE.md create mode 100644 src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts create mode 100644 src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts create mode 100644 src/renderer/features/agent-graph/index.ts create mode 100644 src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx create mode 100644 src/renderer/features/agent-graph/ui/TeamGraphTab.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba3cdbf..ae068fd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,12 @@ name: CI on: push: - branches: [main] + branches: [main, dev] paths: - 'src/**' - 'agent-teams-controller/**' - 'mcp-server/**' + - 'packages/**' - 'test/**' - '.github/workflows/**' - 'pnpm-workspace.yaml' @@ -18,11 +19,11 @@ on: - 'tailwind.config.*' - 'eslint.config.*' pull_request: - branches: [main] paths: - 'src/**' - 'agent-teams-controller/**' - 'mcp-server/**' + - 'packages/**' - 'test/**' - '.github/workflows/**' - 'pnpm-workspace.yaml' @@ -58,9 +59,10 @@ jobs: uses: actions/cache@v4 with: path: .eslintcache - key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*') }} - restore-keys: | - eslint-${{ runner.os }}- + key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*', 'src/**/*.ts', 'src/**/*.tsx') }} + + - name: Auto-fix import sort (Node version parity) + run: npx eslint src/ --fix --no-cache || true - name: Validate workspace truth gate run: pnpm check diff --git a/CLAUDE.md b/CLAUDE.md index adfa3ce2..858c0dcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,10 @@ Use path aliases for imports: - `@shared/*` → `src/shared/*` - `@preload/*` → `src/preload/*` +## Features Architecture +**All new features MUST be created in `src/renderer/features//`.** +See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns. + ## Data Sources ~/.claude/projects/{encoded-path}/*.jsonl - Session files ~/.claude/todos/{sessionId}.json - Todo data diff --git a/package.json b/package.json index 3ecf56ef..4e9f89b5 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "agent-teams-controller": "workspace:*", + "@claude-teams/agent-graph": "workspace:*", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/agent-graph/package.json b/packages/agent-graph/package.json new file mode 100644 index 00000000..2d4eb5c6 --- /dev/null +++ b/packages/agent-graph/package.json @@ -0,0 +1,24 @@ +{ + "name": "@claude-teams/agent-graph", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "dependencies": { + "d3-force": "^3.0.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10" + } +} diff --git a/packages/agent-graph/src/canvas/background-layer.ts b/packages/agent-graph/src/canvas/background-layer.ts new file mode 100644 index 00000000..ea181392 --- /dev/null +++ b/packages/agent-graph/src/canvas/background-layer.ts @@ -0,0 +1,158 @@ +/** + * Background rendering: depth star field + hex grid. + * Adapted from agent-flow's background-layer.ts (Apache 2.0). + */ + +import { COLORS, alphaHex } from '../constants/colors'; +import { BACKGROUND } from '../constants/canvas-constants'; + +// ─── Depth Particle (star) ────────────────────────────────────────────────── + +export interface DepthParticle { + x: number; + y: number; + size: number; + brightness: number; + speed: number; + depth: number; +} + +export function createDepthParticles(w: number, h: number): DepthParticle[] { + const particles: DepthParticle[] = []; + for (let i = 0; i < BACKGROUND.starCount; i++) { + particles.push({ + x: Math.random() * w, + y: Math.random() * h, + size: 0.3 + Math.random() * 1.2, + brightness: 0.15 + Math.random() * 0.4, + speed: 0.05 + Math.random() * 0.15, + depth: Math.random(), + }); + } + return particles; +} + +export function updateDepthParticles( + particles: DepthParticle[], + w: number, + h: number, + dt: number, +): void { + for (const p of particles) { + p.y += p.speed * dt * 20; + if (p.y > h + 5) { + p.y = -5; + p.x = Math.random() * w; + } + } +} + +// ─── Background Drawing ───────────────────────────────────────────────────── + +/** + * Draw the space background: void fill + depth stars + optional hex grid. + */ +export function drawBackground( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + particles: DepthParticle[], + camera: { x: number; y: number; zoom: number }, + time: number, + options?: { showHexGrid?: boolean; showStarField?: boolean }, +): void { + const showStars = options?.showStarField ?? true; + const showHex = options?.showHexGrid ?? true; + + // Deep void background + ctx.fillStyle = COLORS.void; + ctx.fillRect(0, 0, w, h); + + // Depth star field + if (showStars) { + for (const p of particles) { + const parallax = 1 - p.depth * 0.3; + const sx = p.x + camera.x * parallax * 0.02; + const sy = p.y + camera.y * parallax * 0.02; + const twinkle = 0.7 + 0.3 * Math.sin(time * 2 + p.x * 0.01); + const alpha = p.brightness * twinkle; + + ctx.fillStyle = COLORS.holoBright + alphaHex(alpha); + ctx.beginPath(); + ctx.arc( + ((sx % w) + w) % w, + ((sy % h) + h) % h, + p.size, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + } + + // Hex grid + if (showHex) { + drawHexGrid(ctx, w, h, camera, time); + } +} + +// ─── Hex Grid ─────────────────────────────────────────────────────────────── + +// Pre-computed hex vertex offsets +const HEX_OFFSETS: [number, number][] = []; +for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6; + HEX_OFFSETS.push([Math.cos(angle), Math.sin(angle)]); +} + +function drawHexGrid( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + camera: { x: number; y: number; zoom: number }, + time: number, +): void { + const size = BACKGROUND.hexSize; + const pulse = BACKGROUND.hexAlpha * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed)); + + // Visible region in world space (expanded a bit for edge cells) + const worldX0 = -camera.x / camera.zoom - size * 2; + const worldY0 = -camera.y / camera.zoom - size * 2; + const worldX1 = (w - camera.x) / camera.zoom + size * 2; + const worldY1 = (h - camera.y) / camera.zoom + size * 2; + + const rowH = size * 1.5; + const colW = size * Math.sqrt(3); + + const rowStart = Math.floor(worldY0 / rowH); + const rowEnd = Math.ceil(worldY1 / rowH); + const colStart = Math.floor(worldX0 / colW); + const colEnd = Math.ceil(worldX1 / colW); + + ctx.save(); + ctx.translate(camera.x, camera.y); + ctx.scale(camera.zoom, camera.zoom); + + ctx.strokeStyle = COLORS.hexGrid + alphaHex(pulse); + ctx.lineWidth = 0.5 / camera.zoom; + + ctx.beginPath(); + for (let row = rowStart; row <= rowEnd; row++) { + for (let col = colStart; col <= colEnd; col++) { + const cx = col * colW + (row % 2 === 0 ? 0 : colW / 2); + const cy = row * rowH; + + for (let i = 0; i < 6; i++) { + const [ox, oy] = HEX_OFFSETS[i]; + const px = cx + ox * size; + const py = cy + oy * size; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + } + } + ctx.stroke(); + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/bloom-renderer.ts b/packages/agent-graph/src/canvas/bloom-renderer.ts new file mode 100644 index 00000000..efbe85a2 --- /dev/null +++ b/packages/agent-graph/src/canvas/bloom-renderer.ts @@ -0,0 +1,70 @@ +/** + * Post-processing bloom effect. + * Adapted from agent-flow's bloom-renderer.ts (Apache 2.0). + * Zero imports — pure Canvas 2D. + */ + +export class BloomRenderer { + #bloomCanvas: HTMLCanvasElement; + #bloomCtx: CanvasRenderingContext2D; + #tempCanvas: HTMLCanvasElement; + #tempCtx: CanvasRenderingContext2D; + #intensity: number; + #w = 0; + #h = 0; + + constructor(intensity = 0.6) { + this.#intensity = intensity; + this.#bloomCanvas = document.createElement('canvas'); + this.#bloomCtx = this.#bloomCanvas.getContext('2d')!; + this.#tempCanvas = document.createElement('canvas'); + this.#tempCtx = this.#tempCanvas.getContext('2d')!; + } + + resize(w: number, h: number): void { + const hw = Math.ceil(w / 2); + const hh = Math.ceil(h / 2); + if (this.#w === hw && this.#h === hh) return; + this.#w = hw; + this.#h = hh; + this.#bloomCanvas.width = hw; + this.#bloomCanvas.height = hh; + this.#tempCanvas.width = hw; + this.#tempCanvas.height = hh; + } + + setIntensity(v: number): void { + this.#intensity = Math.max(0, Math.min(1, v)); + } + + apply(source: HTMLCanvasElement, targetCtx: CanvasRenderingContext2D): void { + if (this.#intensity <= 0 || this.#w === 0) return; + + this.#bloomCtx.clearRect(0, 0, this.#w, this.#h); + this.#bloomCtx.drawImage(source, 0, 0, this.#w, this.#h); + + const radii = [8, 6, 4]; + for (const r of radii) { + this.#tempCtx.clearRect(0, 0, this.#w, this.#h); + this.#tempCtx.filter = `blur(${r}px)`; + this.#tempCtx.drawImage(this.#bloomCanvas, 0, 0); + this.#tempCtx.filter = 'none'; + + this.#bloomCtx.clearRect(0, 0, this.#w, this.#h); + this.#bloomCtx.drawImage(this.#tempCanvas, 0, 0); + } + + const prevOp = targetCtx.globalCompositeOperation; + const prevAlpha = targetCtx.globalAlpha; + targetCtx.globalCompositeOperation = 'lighter'; + targetCtx.globalAlpha = this.#intensity; + targetCtx.drawImage(this.#bloomCanvas, 0, 0, source.width, source.height); + targetCtx.globalCompositeOperation = prevOp; + targetCtx.globalAlpha = prevAlpha; + } + + [Symbol.dispose](): void { + this.#w = 0; + this.#h = 0; + } +} diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts new file mode 100644 index 00000000..5c97774b --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -0,0 +1,280 @@ +/** + * Agent (member/lead) node drawing with holographic effects. + * Adapted from agent-flow's draw-agents.ts (Apache 2.0). + * Uses our GraphNode port type instead of agent-flow's Agent type. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS, getStateColor, alphaHex } from '../constants/colors'; +import { NODE, AGENT_DRAW, CONTEXT_RING, ANIM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; +import { drawHexagon } from './draw-misc'; +import { getAgentGlowSprite, ensureHex, hexWithAlpha } from './render-cache'; + +/** + * Draw all member/lead nodes on the canvas. + */ +export function drawAgents( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'member' && node.kind !== 'lead') continue; + const opacity = getNodeOpacity(node); + if (opacity < MIN_VISIBLE_OPACITY) continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = node.kind === 'lead' ? NODE.radiusLead : NODE.radiusMember; + const color = node.color ?? getStateColor(node.state); + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = opacity; + + // Depth shadow + drawDepthShadow(ctx, x, y, r); + + // Outer glow + drawGlow(ctx, x, y, r, color); + + // Hexagonal body with interior fill + drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered); + + // Breathing animation + drawBreathing(ctx, x, y, r, node.state, time); + + // Name label + drawLabel(ctx, x, y, r, node.label, color); + + // Role subtitle + if (node.role) { + drawSublabel(ctx, x, y, r, node.role); + } + + // Context ring for lead + if (node.kind === 'lead' && node.contextUsage != null) { + drawContextRing(ctx, x, y, r, node.contextUsage, time); + } + + // Selection ring + if (isSelected) { + drawSelectionRing(ctx, x, y, r, color); + } + + ctx.restore(); + } +} + +// ─── Private Helpers ──────────────────────────────────────────────────────── + +function getNodeOpacity(node: GraphNode): number { + if (node.state === 'terminated' || node.state === 'complete') return 0.3; + if (node.spawnStatus === 'spawning') return 0.6; + if (node.spawnStatus === 'waiting') return 0.4; + if (node.spawnStatus === 'offline') return 0; + return 1; +} + +function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { + ctx.save(); + ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + ctx.shadowBlur = AGENT_DRAW.shadowBlur; + ctx.shadowOffsetX = AGENT_DRAW.shadowOffsetX; + ctx.shadowOffsetY = AGENT_DRAW.shadowOffsetY; + drawHexagon(ctx, x, y, r); + ctx.fillStyle = 'rgba(0, 0, 0, 0.01)'; + ctx.fill(); + ctx.restore(); +} + +function drawGlow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string): void { + const outerR = r + AGENT_DRAW.glowPadding; + const sprite = getAgentGlowSprite(color, r * 0.5, outerR); + ctx.drawImage(sprite, x - outerR, y - outerR); +} + +function drawHexBody( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, + state: string, + time: number, + isSelected: boolean, + isHovered: boolean, +): void { + // Interior fill + drawHexagon(ctx, x, y, r); + ctx.fillStyle = isSelected + ? 'rgba(100, 200, 255, 0.15)' + : COLORS.nodeInterior; + ctx.fill(); + + // Scanline effect + const scanSpeed = state === 'active' || state === 'thinking' + ? ANIM.scanline.active + : ANIM.scanline.normal; + const scanY = ((time * scanSpeed) % (r * 2)) - r; + ctx.save(); + drawHexagon(ctx, x, y, r); + ctx.clip(); + const grad = ctx.createLinearGradient( + x, + y + scanY - AGENT_DRAW.scanlineHalfH, + x, + y + scanY + AGENT_DRAW.scanlineHalfH, + ); + grad.addColorStop(0, hexWithAlpha(color, 0)); + grad.addColorStop(0.5, hexWithAlpha(color, 0.13)); + grad.addColorStop(1, hexWithAlpha(color, 0)); + ctx.fillStyle = grad; + ctx.fillRect(x - r, y + scanY - AGENT_DRAW.scanlineHalfH, r * 2, AGENT_DRAW.scanlineHalfH * 2); + ctx.restore(); + + // Border + drawHexagon(ctx, x, y, r); + ctx.strokeStyle = hexWithAlpha(color, isHovered ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); +} + +function drawBreathing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + state: string, + time: number, +): void { + const isActive = state === 'active' || state === 'thinking' || state === 'tool_calling'; + const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed; + const amp = isActive ? ANIM.breathe.activeAmp : ANIM.breathe.idleAmp; + const breathe = 1 + amp * Math.sin(time * speed); + + if (isActive) { + // Orbiting particles for active agents + const orbitR = r + AGENT_DRAW.orbitParticleOffset; + const count = 4; + for (let i = 0; i < count; i++) { + const angle = time * ANIM.orbitSpeed + (Math.PI * 2 * i) / count; + const px = x + orbitR * breathe * Math.cos(angle); + const py = y + orbitR * breathe * Math.sin(angle); + ctx.fillStyle = COLORS.holoBright + '80'; + ctx.beginPath(); + ctx.arc(px, py, AGENT_DRAW.orbitParticleSize, 0, Math.PI * 2); + ctx.fill(); + } + } else { + // Subtle pulsing glow ring for idle agents + const pulseAlpha = 0.04 + 0.04 * Math.sin(time * speed); + ctx.beginPath(); + ctx.arc(x, y, r + 2, 0, Math.PI * 2); + ctx.strokeStyle = COLORS.holoBase + alphaHex(pulseAlpha); + ctx.lineWidth = 1; + ctx.stroke(); + } +} + +function drawLabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + label: string, + color: string, +): void { + ctx.font = `bold 10px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = color; + ctx.fillText(label, x, y + r + AGENT_DRAW.labelYOffset); +} + +function drawSublabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + sublabel: string, +): void { + ctx.font = '7px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = COLORS.textDim; + ctx.fillText(sublabel, x, y + r + AGENT_DRAW.labelYOffset + 13); +} + +/** + * Draw context usage ring around lead node. + */ +export function drawContextRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + usage: number, + time: number, +): void { + const ringR = r + CONTEXT_RING.ringOffset; + const startAngle = -Math.PI / 2; + const endAngle = startAngle + Math.PI * 2 * Math.min(1, usage); + + // Background ring + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = COLORS.holoBright + '15'; + ctx.lineWidth = CONTEXT_RING.ringWidth; + ctx.stroke(); + + // Usage arc + let ringColor: string = COLORS.complete; + if (usage > CONTEXT_RING.criticalThreshold) { + ringColor = COLORS.error; + } else if (usage > CONTEXT_RING.warningThreshold) { + ringColor = COLORS.waiting; + } + + // Pulsing glow for high usage + if (usage > CONTEXT_RING.warningThreshold) { + const pulse = 0.5 + 0.5 * Math.sin(time * 3); + ctx.beginPath(); + ctx.arc(x, y, ringR, startAngle, endAngle); + ctx.strokeStyle = ringColor + alphaHex(0.3 * pulse); + ctx.lineWidth = CONTEXT_RING.ringWidth + CONTEXT_RING.glowPadding; + ctx.stroke(); + } + + ctx.beginPath(); + ctx.arc(x, y, ringR, startAngle, endAngle); + ctx.strokeStyle = ringColor; + ctx.lineWidth = CONTEXT_RING.ringWidth; + ctx.stroke(); + + // Percentage label + if (usage > CONTEXT_RING.percentLabelThreshold) { + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = ringColor; + ctx.fillText(`${Math.round(usage * 100)}%`, x, y - r - CONTEXT_RING.percentYOffset); + } +} + +function drawSelectionRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, +): void { + drawHexagon(ctx, x, y, r + 4); + ctx.strokeStyle = hexWithAlpha(color, 0.67); + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.setLineDash([]); +} diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts new file mode 100644 index 00000000..7b764797 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -0,0 +1,210 @@ +/** + * Edge drawing with tapered bezier curves and gradients. + * Adapted from agent-flow's draw-edges.ts (Apache 2.0). + */ + +import type { GraphNode, GraphEdge, GraphEdgeType } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; + +// ─── Edge Type → Color/Width Mapping ──────────────────────────────────────── + +const EDGE_STYLES: Record = { + 'parent-child': { color: COLORS.edgeParentChild, ...BEAM.parentChild }, + ownership: { color: COLORS.edgeOwnership, ...BEAM.ownership }, + blocking: { color: COLORS.edgeBlocking, ...BEAM.blocking, dash: [8, 4] }, + related: { color: COLORS.edgeRelated, ...BEAM.related, dash: [4, 4] }, + message: { color: COLORS.edgeMessage, ...BEAM.message }, +}; + +// ─── Bezier Utilities ─────────────────────────────────────────────────────── + +export interface ControlPoints { + cp1x: number; + cp1y: number; + cp2x: number; + cp2y: number; +} + +export function computeControlPoints( + x1: number, + y1: number, + x2: number, + y2: number, +): ControlPoints { + const dx = x2 - x1; + const dy = y2 - y1; + const nx = -dy * BEAM.curvature; + const ny = dx * BEAM.curvature; + return { + cp1x: x1 + dx * BEAM.cp1 + nx, + cp1y: y1 + dy * BEAM.cp1 + ny, + cp2x: x1 + dx * BEAM.cp2 + nx, + cp2y: y1 + dy * BEAM.cp2 + ny, + }; +} + +/** + * Evaluate a cubic bezier at parameter t. + */ +export function bezierPoint( + x1: number, + y1: number, + cp: ControlPoints, + x2: number, + y2: number, + t: number, +): { x: number; y: number } { + const u = 1 - t; + const uu = u * u; + const uuu = uu * u; + const tt = t * t; + const ttt = tt * t; + return { + x: uuu * x1 + 3 * uu * t * cp.cp1x + 3 * u * tt * cp.cp2x + ttt * x2, + y: uuu * y1 + 3 * uu * t * cp.cp1y + 3 * u * tt * cp.cp2y + ttt * y2, + }; +} + +// ─── Draw All Edges ───────────────────────────────────────────────────────── + +export function drawEdges( + ctx: CanvasRenderingContext2D, + edges: GraphEdge[], + nodeMap: Map, + _time: number, + hasActiveParticles: Set, +): void { + for (const edge of edges) { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; + const isActive = hasActiveParticles.has(edge.id); + const alpha = isActive ? BEAM.activeAlpha : BEAM.idleAlpha; + + if (alpha < MIN_VISIBLE_OPACITY) continue; + + const cp = computeControlPoints(source.x, source.y, target.x, target.y); + + ctx.save(); + ctx.globalAlpha = alpha; + + // Draw tapered bezier + drawTaperedBezier( + ctx, + source.x, + source.y, + cp, + target.x, + target.y, + style.startW, + style.endW, + edge.color ?? style.color, + style.dash, + ); + + // Arrow for blocking edges + if (edge.type === 'blocking') { + drawArrowHead(ctx, cp, target.x, target.y, style.color, alpha); + } + + ctx.restore(); + } +} + +// ─── Tapered Bezier ───────────────────────────────────────────────────────── + +function drawTaperedBezier( + ctx: CanvasRenderingContext2D, + x1: number, + y1: number, + cp: ControlPoints, + x2: number, + y2: number, + startW: number, + endW: number, + color: string, + dash?: number[], +): void { + if (dash) { + // Dashed edges use stroke, not fill polygon + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, x2, y2); + ctx.strokeStyle = color; + ctx.lineWidth = (startW + endW) / 2; + ctx.setLineDash(dash); + ctx.stroke(); + ctx.setLineDash([]); + return; + } + + // Build polygon outline for tapered width + const segments = BEAM.segments; + const leftPoints: { x: number; y: number }[] = []; + const rightPoints: { x: number; y: number }[] = []; + + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const pos = bezierPoint(x1, y1, cp, x2, y2, t); + const w = startW + (endW - startW) * t; + + // Normal perpendicular + const dt = 0.01; + const tNext = Math.min(1, t + dt); + const posNext = bezierPoint(x1, y1, cp, x2, y2, tNext); + const dx = posNext.x - pos.x; + const dy = posNext.y - pos.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = -dy / len; + const ny = dx / len; + + leftPoints.push({ x: pos.x + nx * w * 0.5, y: pos.y + ny * w * 0.5 }); + rightPoints.push({ x: pos.x - nx * w * 0.5, y: pos.y - ny * w * 0.5 }); + } + + ctx.beginPath(); + ctx.moveTo(leftPoints[0].x, leftPoints[0].y); + for (let i = 1; i < leftPoints.length; i++) { + ctx.lineTo(leftPoints[i].x, leftPoints[i].y); + } + for (let i = rightPoints.length - 1; i >= 0; i--) { + ctx.lineTo(rightPoints[i].x, rightPoints[i].y); + } + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); +} + +// ─── Arrow Head ───────────────────────────────────────────────────────────── + +function drawArrowHead( + ctx: CanvasRenderingContext2D, + cp: ControlPoints, + x2: number, + y2: number, + color: string, + alpha: number, +): void { + // Compute direction at t=1 + const dx = x2 - cp.cp2x; + const dy = y2 - cp.cp2y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const ux = dx / len; + const uy = dy / len; + const arrowSize = 8; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - ux * arrowSize - uy * arrowSize * 0.5, y2 - uy * arrowSize + ux * arrowSize * 0.5); + ctx.lineTo(x2 - ux * arrowSize + uy * arrowSize * 0.5, y2 - uy * arrowSize - ux * arrowSize * 0.5); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/draw-effects.ts b/packages/agent-graph/src/canvas/draw-effects.ts new file mode 100644 index 00000000..b2e4e68e --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-effects.ts @@ -0,0 +1,175 @@ +/** + * Visual effects: spawn animation, completion shatter, spawn ring. + * Adapted from agent-flow's draw-effects.ts (Apache 2.0). + */ + +import { alphaHex } from '../constants/colors'; +import { SPAWN_FX, COMPLETE_FX } from '../constants/canvas-constants'; +import { drawHexagon } from './draw-misc'; +import { hexWithAlpha } from './render-cache'; + +// ─── Effect Type ──────────────────────────────────────────────────────────── + +export interface VisualEffect { + type: 'spawn' | 'complete' | 'shatter'; + x: number; + y: number; + color: string; + age: number; + duration: number; + particles?: ShatterParticle[]; +} + +interface ShatterParticle { + angle: number; + speed: number; + size: number; +} + +/** + * Create a spawn effect at position. + */ +export function createSpawnEffect(x: number, y: number, color: string): VisualEffect { + return { type: 'spawn', x, y, color, age: 0, duration: 0.8 }; +} + +/** + * Create a completion shatter effect at position. + */ +export function createCompleteEffect(x: number, y: number, color: string): VisualEffect { + const particles: ShatterParticle[] = []; + for (let i = 0; i < 12; i++) { + particles.push({ + angle: (Math.PI * 2 * i) / 12 + (Math.random() - 0.5) * 0.3, + speed: 30 + Math.random() * 60, + size: 1 + Math.random() * 2, + }); + } + return { type: 'shatter', x, y, color, age: 0, duration: 0.8, particles }; +} + +// ─── Draw Effects ─────────────────────────────────────────────────────────── + +export function drawEffects( + ctx: CanvasRenderingContext2D, + effects: VisualEffect[], +): void { + for (const fx of effects) { + const progress = fx.age / fx.duration; + if (progress >= 1) continue; + + switch (fx.type) { + case 'spawn': + drawSpawnEffect(ctx, fx, progress); + break; + case 'complete': + drawCompleteEffect(ctx, fx, progress); + break; + case 'shatter': + drawShatterEffect(ctx, fx, progress); + break; + } + } +} + +// ─── Spawn: expanding hex ring + white flash ──────────────────────────────── + +function drawSpawnEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + const alpha = SPAWN_FX.maxAlpha * (1 - progress); + const ringR = SPAWN_FX.ringStart + SPAWN_FX.ringExpand * progress; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Expanding hex ring + drawHexagon(ctx, fx.x, fx.y, ringR); + ctx.strokeStyle = fx.color; + ctx.lineWidth = 2 * (1 - progress); + ctx.stroke(); + + // Flash + if (progress < SPAWN_FX.flashThreshold) { + const flashProgress = progress / SPAWN_FX.flashThreshold; + const flashR = SPAWN_FX.flashBaseRadius * (1 - flashProgress) + SPAWN_FX.flashMinRadius; + ctx.fillStyle = '#ffffff' + alphaHex(SPAWN_FX.flashAlpha * (1 - flashProgress)); + ctx.beginPath(); + ctx.arc(fx.x, fx.y, flashR, 0, Math.PI * 2); + ctx.fill(); + } + + // Scatter particles + for (let i = 0; i < SPAWN_FX.particleCount; i++) { + const angle = (Math.PI * 2 * i) / SPAWN_FX.particleCount; + const dist = ringR * 0.8 * progress; + const px = fx.x + Math.cos(angle) * dist; + const py = fx.y + Math.sin(angle) * dist; + ctx.fillStyle = fx.color + alphaHex(alpha * 0.6); + ctx.beginPath(); + ctx.arc(px, py, SPAWN_FX.particleSize * (1 - progress), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} + +// ─── Complete: white flash + expanding ring ───────────────────────────────── + +function drawCompleteEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + const alpha = COMPLETE_FX.maxAlpha * (1 - progress); + const ringR = COMPLETE_FX.ringStart + COMPLETE_FX.ringExpand * progress; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Expanding ring + ctx.beginPath(); + ctx.arc(fx.x, fx.y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = fx.color; + ctx.lineWidth = COMPLETE_FX.lineWidthMax * (1 - progress); + ctx.stroke(); + + // Flash + if (progress < COMPLETE_FX.flashThreshold) { + const flashAlpha = COMPLETE_FX.flashAlpha * (1 - progress / COMPLETE_FX.flashThreshold); + ctx.fillStyle = '#ffffff' + alphaHex(flashAlpha); + ctx.beginPath(); + ctx.arc(fx.x, fx.y, COMPLETE_FX.flashRadius * (1 - progress), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} + +// ─── Shatter: particles scatter outward ───────────────────────────────────── + +function drawShatterEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + if (!fx.particles) return; + + const alpha = 1 - progress; + ctx.save(); + ctx.globalAlpha = alpha; + + for (const p of fx.particles) { + const dist = p.speed * progress; + const px = fx.x + Math.cos(p.angle) * dist; + const py = fx.y + Math.sin(p.angle) * dist; + const size = p.size * (1 - progress * 0.5); + + // Glow + const grad = ctx.createRadialGradient(px, py, 0, px, py, size * 3); + grad.addColorStop(0, fx.color + alphaHex(alpha * 0.4)); + grad.addColorStop(1, hexWithAlpha(fx.color, 0)); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(px, py, size * 3, 0, Math.PI * 2); + ctx.fill(); + + // Core + ctx.fillStyle = fx.color; + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/draw-misc.ts b/packages/agent-graph/src/canvas/draw-misc.ts new file mode 100644 index 00000000..1a60831e --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-misc.ts @@ -0,0 +1,65 @@ +/** + * Utility drawing functions. + * Adapted from agent-flow's draw-misc.ts (Apache 2.0). + */ + +import { measureTextCached } from './render-cache'; + +/** + * Truncate text to fit within maxWidth, appending "..." if needed. + * Uses binary search for efficiency. + */ +export function truncateText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, + font: string, +): string { + if (measureTextCached(ctx, font, text) <= maxWidth) return text; + + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (measureTextCached(ctx, font, text.slice(0, mid) + '...') <= maxWidth) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo > 0 ? text.slice(0, lo) + '...' : '...'; +} + +// Pre-computed hex vertex unit offsets (avoids cos/sin per call) +const HEX_COS: number[] = []; +const HEX_SIN: number[] = []; +for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6; + HEX_COS.push(Math.cos(angle)); + HEX_SIN.push(Math.sin(angle)); +} + +/** + * Draw a regular hexagon path centered at (x, y) with given radius. + */ +export function drawHexagon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, +): void { + ctx.beginPath(); + ctx.moveTo(x + radius * HEX_COS[0], y + radius * HEX_SIN[0]); + ctx.lineTo(x + radius * HEX_COS[1], y + radius * HEX_SIN[1]); + ctx.lineTo(x + radius * HEX_COS[2], y + radius * HEX_SIN[2]); + ctx.lineTo(x + radius * HEX_COS[3], y + radius * HEX_SIN[3]); + ctx.lineTo(x + radius * HEX_COS[4], y + radius * HEX_SIN[4]); + ctx.lineTo(x + radius * HEX_COS[5], y + radius * HEX_SIN[5]); + ctx.closePath(); +} + +/** + * SVG path data for the Claude spark logo (256×256 viewbox). + */ +export const CLAUDE_SPARK_D = + 'M128,8C60.6,8,8,60.6,8,128s52.6,120,120,120s120-52.6,120-120S195.4,8,128,8z M161.6,169.6 c-4.8,8-16,10.8-24,6l-9.6-5.6l-9.6,5.6c-8,4.8-19.2,1.6-24-6c-4.8-8-1.6-19.2,6-24l9.6-5.6v-11.2l-9.6-5.6 c-8-4.8-10.8-16-6-24c4.8-8,16-10.8,24-6l9.6,5.6l9.6-5.6c8-4.8,19.2-1.6,24,6c4.8,8,1.6,19.2-6,24l-9.6,5.6v11.2l9.6,5.6 C163.2,150.4,166.4,161.6,161.6,169.6z'; diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts new file mode 100644 index 00000000..863aa250 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -0,0 +1,154 @@ +/** + * Particle animation along edges. + * Adapted from agent-flow's draw-particles.ts (Apache 2.0). + */ + +import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { PARTICLE_DRAW, BEAM } from '../constants/canvas-constants'; +import { bezierPoint, computeControlPoints, type ControlPoints } from './draw-edges'; +import { getGlowSprite, hexWithAlpha } from './render-cache'; + +/** + * Build a lookup from edge.id → edge for fast particle→edge resolution. + */ +export function buildEdgeMap(edges: GraphEdge[]): Map { + const map = new Map(); + for (const e of edges) map.set(e.id, e); + return map; +} + +/** + * Draw all active particles along their edges. + */ +export function drawParticles( + ctx: CanvasRenderingContext2D, + particles: GraphParticle[], + edgeMap: Map, + nodeMap: Map, + time: number, +): void { + for (const p of particles) { + const edge = edgeMap.get(p.edgeId); + if (!edge) continue; + + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const cp = computeControlPoints(source.x, source.y, target.x, target.y); + const color = p.color || COLORS.message; + const baseSize = (p.size ?? 1) * 3; + // Differentiate visual by particle kind + const size = p.kind === 'spawn' ? baseSize * 1.5 + : p.kind === 'review_request' || p.kind === 'review_response' ? baseSize * 1.2 + : baseSize; + + // Wobble offset for organic look + const phaseOffset = p.id.charCodeAt(Math.min(5, p.id.length - 1)) * 0.1; + const wobbleAmp = BEAM.wobble.amp; + + drawParticleTrail(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time); + drawParticleCore(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time); + + // Label + if (p.label && p.progress > PARTICLE_DRAW.labelMinT && p.progress < PARTICLE_DRAW.labelMaxT) { + const pos = getWobbledPosition(source, target, cp, p.progress, wobbleAmp, phaseOffset, time); + ctx.font = `${PARTICLE_DRAW.labelFontSize}px monospace`; + ctx.textAlign = 'center'; + ctx.fillStyle = hexWithAlpha(color, 0.56); + ctx.fillText(p.label, pos.x, pos.y + PARTICLE_DRAW.labelYOffset); + } + } +} + +// ─── Private Helpers ──────────────────────────────────────────────────────── + +function getWobbledPosition( + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + t: number, + wobbleAmp: number, + phaseOffset: number, + time: number, +): { x: number; y: number } { + const pos = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, t); + + // Perpendicular wobble + const dt = 0.01; + const tNext = Math.min(1, t + dt); + const posNext = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, tNext); + const dx = posNext.x - pos.x; + const dy = posNext.y - pos.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = -dy / len; + const ny = dx / len; + + const wobble = Math.sin(t * BEAM.wobble.freq + time * BEAM.wobble.timeFreq + phaseOffset) * wobbleAmp; + return { + x: pos.x + nx * wobble, + y: pos.y + ny * wobble, + }; +} + +function drawParticleTrail( + ctx: CanvasRenderingContext2D, + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + progress: number, + color: string, + size: number, + wobbleAmp: number, + phaseOffset: number, + time: number, +): void { + const trailSegments = 6; + const trailStep = BEAM.wobble.trailOffset / trailSegments; + + for (let i = trailSegments; i >= 1; i--) { + const t = Math.max(0, progress - trailStep * i); + const pos = getWobbledPosition(source, target, cp, t, wobbleAmp, phaseOffset, time); + const alpha = (1 - i / trailSegments) * 0.3; + const trailSize = size * (1 - i / trailSegments) * 0.5; + + ctx.fillStyle = hexWithAlpha(color, alpha); + ctx.beginPath(); + ctx.arc(pos.x, pos.y, trailSize, 0, Math.PI * 2); + ctx.fill(); + } +} + +function drawParticleCore( + ctx: CanvasRenderingContext2D, + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + progress: number, + color: string, + size: number, + wobbleAmp: number, + phaseOffset: number, + time: number, +): void { + const pos = getWobbledPosition(source, target, cp, progress, wobbleAmp, phaseOffset, time); + + // Glow sprite + const glowR = PARTICLE_DRAW.glowRadius; + const sprite = getGlowSprite(color, glowR, 0.4, 0); + ctx.drawImage(sprite, pos.x - glowR, pos.y - glowR); + + // Core dot + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, size, 0, Math.PI * 2); + ctx.fill(); + + // Highlight + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, size * PARTICLE_DRAW.coreHighlightScale, 0, Math.PI * 2); + ctx.fill(); +} diff --git a/packages/agent-graph/src/canvas/draw-processes.ts b/packages/agent-graph/src/canvas/draw-processes.ts new file mode 100644 index 00000000..25485126 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-processes.ts @@ -0,0 +1,65 @@ +/** + * Process node rendering — small circles for running processes. + * NEW — not from agent-flow. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { NODE } from '../constants/canvas-constants'; +import { hexWithAlpha, getGlowSprite } from './render-cache'; + +/** + * Draw all process nodes as small circles. + */ +export function drawProcesses( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'process') continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusProcess; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = 0.8; + + // Glow — use cached sprite instead of createRadialGradient per frame + const procColor = node.color ?? COLORS.tool_calling; + const glowSprite = getGlowSprite(procColor, r * 2, 0.19, 0); + ctx.drawImage(glowSprite, x - r * 2, y - r * 2); + + // Body + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = isSelected ? COLORS.cardBgSelected : COLORS.cardBg; + ctx.fill(); + ctx.strokeStyle = hexWithAlpha(procColor, 0.38); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); + + // Spinning ring for active processes + const spinAngle = time * 2; + ctx.beginPath(); + ctx.arc(x, y, r + 3, spinAngle, spinAngle + Math.PI * 0.8); + ctx.strokeStyle = hexWithAlpha(procColor, 0.38); + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Label + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = COLORS.textDim; + const label = node.label.length > 12 ? node.label.slice(0, 12) + '...' : node.label; + ctx.fillText(label, x, y + r + 4); + + ctx.restore(); + } +} diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts new file mode 100644 index 00000000..d7f7a00e --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -0,0 +1,178 @@ +/** + * Task pill-shaped node rendering. + * NEW — not from agent-flow. Custom renderer for our task nodes. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors'; +import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants'; +import { truncateText } from './draw-misc'; +import { hexWithAlpha } from './render-cache'; + +/** + * Draw all task nodes as pill-shaped cards. + */ +export function drawTasks( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'task') continue; + + const opacity = getTaskOpacity(node); + if (opacity < MIN_VISIBLE_OPACITY) continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = opacity; + + drawTaskPill(ctx, x, y, node, time, isSelected, isHovered); + + ctx.restore(); + } +} + +// ─── Private ──────────────────────────────────────────────────────────────── + +function getTaskOpacity(node: GraphNode): number { + if (node.taskStatus === 'deleted') return 0; + if (node.reviewState === 'approved') return 0.65; + if (node.taskStatus === 'completed') return 0.45; + return 1; +} + +function drawTaskPill( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + node: GraphNode, + time: number, + isSelected: boolean, + isHovered: boolean, +): void { + const w = TASK_PILL.width; + const h = TASK_PILL.height; + const r = TASK_PILL.borderRadius; + const halfW = w / 2; + const halfH = h / 2; + + const statusColor = getTaskStatusColor(node.taskStatus); + const reviewColor = getReviewStateColor(node.reviewState); + + // Pulse only for active work — completed + approved = static + const needsAttention = + (node.taskStatus === 'in_progress' && node.reviewState !== 'approved') || + node.reviewState === 'review' || + node.reviewState === 'needsFix' || + (node.needsClarification != null); + const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved'; + const breathe = needsAttention && !isFinished + ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) + : 1; + const scale = breathe; + + ctx.save(); + ctx.translate(x, y); + ctx.scale(scale, scale); + + // Shadow — stronger for attention tasks + ctx.shadowColor = hexWithAlpha(statusColor, 0.25); + ctx.shadowBlur = needsAttention ? 12 : 4; + + // Background fill + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, w, h, r); + ctx.fillStyle = isSelected + ? COLORS.cardBgSelected + : isHovered + ? 'rgba(15, 20, 40, 0.7)' + : COLORS.cardBg; + ctx.fill(); + ctx.shadowBlur = 0; + + // Border + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, w, h, r); + ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); + + // Review state overlay border — pulsing for review/needsFix, STATIC for approved + if (reviewColor !== 'transparent') { + ctx.beginPath(); + ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1); + const reviewAlpha = node.reviewState === 'approved' + ? 0.6 // static — no pulse + : 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix + ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha); + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + // Clarification warning indicator + if (node.needsClarification) { + const pulseAlpha = 0.4 + 0.4 * Math.sin(time * 4); + ctx.beginPath(); + ctx.roundRect(-halfW - 2, -halfH - 2, w + 4, h + 4, r + 2); + ctx.strokeStyle = hexWithAlpha(COLORS.error, pulseAlpha); + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Status dot + ctx.fillStyle = statusColor; + ctx.beginPath(); + ctx.arc( + -halfW + TASK_PILL.statusDotX, + 0, + TASK_PILL.statusDotRadius, + 0, + Math.PI * 2, + ); + ctx.fill(); + + // Display ID + const displayId = node.displayId ?? node.label; + ctx.font = `bold ${TASK_PILL.idFontSize}px monospace`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = isFinished ? COLORS.textDim : COLORS.textPrimary; + ctx.fillText(displayId, -halfW + TASK_PILL.textOffsetX, -4); + + // Subject text + if (node.sublabel) { + ctx.font = `${TASK_PILL.subjectFontSize}px sans-serif`; + ctx.fillStyle = isFinished ? COLORS.textMuted : COLORS.textDim; + const maxW = w - TASK_PILL.textOffsetX - 8; + const subject = truncateText(ctx, node.sublabel, maxW, ctx.font); + ctx.fillText(subject, -halfW + TASK_PILL.textOffsetX, 8); + } + + // Approved badge: checkmark at right side + if (node.reviewState === 'approved') { + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = COLORS.reviewApproved; + ctx.fillText('\u2713', halfW - 8, 0); // ✓ + } + + // Completed: subtle strikethrough line + if (node.taskStatus === 'completed' && node.reviewState !== 'approved') { + ctx.beginPath(); + ctx.moveTo(-halfW + TASK_PILL.textOffsetX, 0); + ctx.lineTo(halfW - 10, 0); + ctx.strokeStyle = COLORS.textMuted; + ctx.lineWidth = 0.5; + ctx.stroke(); + } + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts new file mode 100644 index 00000000..3895b9fd --- /dev/null +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -0,0 +1,66 @@ +/** + * Hit detection — determine what the user clicked/hovered in world space. + * Adapted from agent-flow's hit-detection.ts (Apache 2.0). + */ + +import type { GraphNode } from '../ports/types'; +import { NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; + +/** + * Find the node at the given world-space coordinates. + * Returns node ID or null. + * Priority: lead > member > task > process. + */ +export function findNodeAt( + worldX: number, + worldY: number, + nodes: GraphNode[], +): string | null { + // Check in reverse priority order, return last match (highest priority wins) + let hit: string | null = null; + + for (const node of nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + + switch (node.kind) { + case 'lead': + case 'member': { + const r = (node.kind === 'lead' ? NODE.radiusLead : NODE.radiusMember) + HIT_DETECTION.agentPadding; + const dx = worldX - x; + const dy = worldY - y; + if (dx * dx + dy * dy <= r * r) { + hit = node.id; + // Lead has highest priority, return immediately + if (node.kind === 'lead') return hit; + } + break; + } + case 'task': { + const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; + const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; + if ( + worldX >= x - halfW && + worldX <= x + halfW && + worldY >= y - halfH && + worldY <= y + halfH + ) { + hit = node.id; + } + break; + } + case 'process': { + const r = NODE.radiusProcess + HIT_DETECTION.agentPadding; + const dx = worldX - x; + const dy = worldY - y; + if (dx * dx + dy * dy <= r * r) { + // Only override if no member/lead already hit + if (!hit) hit = node.id; + } + break; + } + } + } + + return hit; +} diff --git a/packages/agent-graph/src/canvas/index.ts b/packages/agent-graph/src/canvas/index.ts new file mode 100644 index 00000000..82d8def4 --- /dev/null +++ b/packages/agent-graph/src/canvas/index.ts @@ -0,0 +1,11 @@ +export { drawAgents, drawContextRing } from './draw-agents'; +export { drawEdges, bezierPoint, computeControlPoints, type ControlPoints } from './draw-edges'; +export { drawParticles, buildEdgeMap } from './draw-particles'; +export { drawEffects, createSpawnEffect, createCompleteEffect, type VisualEffect } from './draw-effects'; +export { drawTasks } from './draw-tasks'; +export { drawProcesses } from './draw-processes'; +export { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from './background-layer'; +export { BloomRenderer } from './bloom-renderer'; +export { findNodeAt } from './hit-detection'; +export { truncateText, drawHexagon, CLAUDE_SPARK_D } from './draw-misc'; +export { getGlowSprite, getAgentGlowSprite, measureTextCached } from './render-cache'; diff --git a/packages/agent-graph/src/canvas/render-cache.ts b/packages/agent-graph/src/canvas/render-cache.ts new file mode 100644 index 00000000..9f163e92 --- /dev/null +++ b/packages/agent-graph/src/canvas/render-cache.ts @@ -0,0 +1,140 @@ +/** + * Pre-rendered sprite cache for Canvas 2D glow effects. + * Adapted from agent-flow (Apache 2.0). + */ + +const glowCache = new Map(); +const textCache = new Map(); +const TEXT_CACHE_LIMIT = 2000; + +// ─── Color resolution: named colors → hex ─────────────────────────────────── + +let _resolverCtx: CanvasRenderingContext2D | null = null; +const _hexCache = new Map(); + +/** + * Ensure a color string is in #rrggbb hex format. + * Resolves CSS named colors (purple → #800080), shorthand (#abc → #aabbcc). + */ +function ensureHex(color: string): string { + if (color.startsWith('#') && color.length === 7) return color; + + let hex = _hexCache.get(color); + if (hex) return hex; + + if (color.startsWith('#') && color.length === 4) { + hex = `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`; + } else { + // Resolve named/rgb/hsl colors via canvas + _resolverCtx ??= document.createElement('canvas').getContext('2d')!; + _resolverCtx.fillStyle = '#000000'; + _resolverCtx.fillStyle = color; + hex = _resolverCtx.fillStyle; // always returns #rrggbb + } + + _hexCache.set(color, hex); + return hex; +} + +/** Build a hex color with alpha: "#rrggbbaa" — cached for repeated calls */ +const _hexAlphaCache = new Map(); +function hexWithAlpha(color: string, alpha: number): string { + // Quantize alpha to 1/255 steps for cache hit rate + const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255); + const key = `${color}|${a}`; + let result = _hexAlphaCache.get(key); + if (result) return result; + result = ensureHex(color) + ALPHA_LUT[a]; + _hexAlphaCache.set(key, result); + if (_hexAlphaCache.size > 500) _hexAlphaCache.clear(); // prevent unbounded growth + return result; +} + +// Import-time LUT for alpha hex +const ALPHA_LUT: string[] = []; +for (let i = 0; i < 256; i++) ALPHA_LUT.push(i.toString(16).padStart(2, '0')); + +// ─── Glow Sprite Cache ────────────────────────────────────────────────────── + +/** + * Get or create a pre-rendered radial gradient glow sprite. + */ +export function getGlowSprite( + color: string, + radius: number, + innerAlpha: number, + outerAlpha: number, +): HTMLCanvasElement { + const hex = ensureHex(color); + const key = `${hex}|${radius}|${innerAlpha}|${outerAlpha}`; + let canvas = glowCache.get(key); + if (canvas) return canvas; + + const size = Math.ceil(radius * 2); + canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + const cx = size / 2; + + const grad = ctx.createRadialGradient(cx, cx, 0, cx, cx, radius); + grad.addColorStop(0, hexWithAlpha(hex, innerAlpha)); + grad.addColorStop(1, hexWithAlpha(hex, outerAlpha)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, size, size); + + glowCache.set(key, canvas); + return canvas; +} + +/** + * Get or create a pre-rendered agent glow sprite (inner + outer radius). + */ +export function getAgentGlowSprite( + color: string, + innerRadius: number, + outerRadius: number, +): HTMLCanvasElement { + const hex = ensureHex(color); + const key = `agent|${hex}|${innerRadius}|${outerRadius}`; + let canvas = glowCache.get(key); + if (canvas) return canvas; + + const size = Math.ceil(outerRadius * 2); + canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + const cx = size / 2; + + const grad = ctx.createRadialGradient(cx, cx, innerRadius, cx, cx, outerRadius); + grad.addColorStop(0, hexWithAlpha(hex, 0.25)); + grad.addColorStop(0.5, hexWithAlpha(hex, 0.08)); + grad.addColorStop(1, hexWithAlpha(hex, 0)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, size, size); + + glowCache.set(key, canvas); + return canvas; +} + +/** + * Cached text width measurement. + */ +export function measureTextCached(ctx: CanvasRenderingContext2D, font: string, text: string): number { + const key = `${font}|${text}`; + let w = textCache.get(key); + if (w !== undefined) return w; + + if (textCache.size > TEXT_CACHE_LIMIT) textCache.clear(); + + const prevFont = ctx.font; + ctx.font = font; + w = ctx.measureText(text).width; + ctx.font = prevFont; + textCache.set(key, w); + return w; +} + +/** Exported for use by draw functions that need hex+alpha colors */ +export { ensureHex, hexWithAlpha }; diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts new file mode 100644 index 00000000..259ce4f8 --- /dev/null +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -0,0 +1,247 @@ +/** + * Canvas rendering constants for the agent graph visualization. + * Adapted from agent-flow's canvas-constants.ts (Apache 2.0). + * Stripped of unused features (tool cards, discoveries, cost overlays, bubbles). + */ + +// ─── Visibility threshold ─────────────────────────────────────────────────── + +export const MIN_VISIBLE_OPACITY = 0.05; + +// ─── Animation speed multipliers (× deltaTime) ───────────────────────────── + +export const ANIM_SPEED = { + agentFadeIn: 3, + agentScaleIn: 4, + agentFadeOut: 0.4, + agentScaleOut: 0.05, + edgeFadeIn: 4, + particleSpeed: 1.2, + maxDeltaTime: 0.1, + defaultDeltaTime: 0.016, + /** Task pill fade in/out */ + taskFadeIn: 3, + taskFadeOut: 0.6, +} as const; + +// ─── Camera / interaction ─────────────────────────────────────────────────── + +export const CAMERA = { + zoomStepDown: 0.92, + zoomStepUp: 1.08, + minZoom: 0.15, + maxZoom: 5, + velocityScale: 0.016, +} as const; + +// ─── Force simulation ─────────────────────────────────────────────────────── + +export const FORCE = { + chargeStrength: -800, + centerStrength: 0.03, + collideRadius: 100, + linkDistance: { + 'parent-child': 500, + ownership: 150, + blocking: 200, + related: 200, + message: 300, + }, + linkStrength: 0.4, + alphaDecay: 0.02, + velocityDecay: 0.4, +} as const; + +// ─── Node sizes ───────────────────────────────────────────────────────────── + +export const NODE = { + /** Lead agent radius */ + radiusLead: 32, + /** Team member radius */ + radiusMember: 24, + /** Process node radius */ + radiusProcess: 14, +} as const; + +// ─── Task pill dimensions ─────────────────────────────────────────────────── + +export const TASK_PILL = { + width: 120, + height: 36, + borderRadius: 6, + statusDotRadius: 4, + statusDotX: 12, + /** Font size for display ID */ + idFontSize: 9, + /** Font size for subject text */ + subjectFontSize: 7, + /** Max chars for subject before truncation */ + subjectMaxChars: 18, + /** X offset for text content */ + textOffsetX: 20, +} as const; + +// ─── Agent drawing constants ──────────────────────────────────────────────── + +export const AGENT_DRAW = { + glowPadding: 20, + outerRingOffset: 3, + shadowBlur: 15, + shadowOffsetX: 3, + shadowOffsetY: 5, + labelYOffset: 8, + labelWidthMultiplier: 3, + scanlineHalfH: 4, + waitingDashSpeed: 25, + orbitParticleOffset: 12, + orbitParticleSize: 1.5, + rippleInnerOffset: 5, + rippleMaxExpand: 45, + rippleMaxAlpha: 0.4, + waitingOrbitOffset: 14, + waitingOrbitParticleSize: 2, + waitingOrbitSpeed: 0.8, + waitingBreatheSpeed: 1.2, + waitingBreatheAmp: 0.08, + sparkScale: 0.45, + sparkViewBox: 256, + subIconScale: 0.45, +} as const; + +// ─── Context ring (lead node only) ───────────────────────────────────────── + +export const CONTEXT_RING = { + ringOffset: 8, + ringWidth: 4, + warningThreshold: 0.8, + criticalThreshold: 0.9, + percentLabelThreshold: 0.7, + glowPadding: 4, + glowLineWidth: 2, + glowBlur: 12, + percentYOffset: 10, +} as const; + +// ─── Edge/beam drawing ────────────────────────────────────────────────────── + +export const BEAM = { + curvature: 0.15, + cp1: 0.33, + cp2: 0.66, + segments: 16, + parentChild: { startW: 3, endW: 1 }, + ownership: { startW: 2, endW: 0.8 }, + blocking: { startW: 2, endW: 1.5 }, + related: { startW: 1, endW: 0.5 }, + message: { startW: 1.5, endW: 0.5 }, + glowExtra: { startW: 3, endW: 1, alpha: 0.08 }, + idleAlpha: 0.08, + activeAlpha: 0.3, + wobble: { amp: 3, freq: 10, timeFreq: 3, trailOffset: 0.15 }, +} as const; + +// ─── Animation constants ──────────────────────────────────────────────────── + +export const ANIM = { + inertiaDecay: 0.94, + inertiaThreshold: 0.5, + dragLerp: 0.25, + autoFitLerp: 0.06, + dragThresholdPx: 5, + viewportPadding: 120, + breathe: { + activeSpeed: 2, + activeAmp: 0.03, + idleSpeed: 0.7, + idleAmp: 0.015, + }, + scanline: { active: 40, normal: 15 }, + orbitSpeed: 1.5, + pulseSpeed: 4, +} as const; + +// ─── Visual effects ───────────────────────────────────────────────────────── + +export const FX = { + spawnDuration: 0.8, + completeDuration: 1.0, + shatterDuration: 0.8, + shatterCount: 12, + shatterSpeed: { min: 30, range: 60 }, + shatterSize: { min: 1, range: 2 }, + trailSegments: 8, +} as const; + +export const SPAWN_FX = { + ringStart: 10, + ringExpand: 60, + maxAlpha: 0.7, + flashThreshold: 0.3, + flashAlpha: 0.6, + flashBaseRadius: 20, + flashMinRadius: 5, + particleCount: 8, + particleSize: 1.5, +} as const; + +export const COMPLETE_FX = { + ringStart: 20, + ringExpand: 80, + maxAlpha: 0.6, + flashThreshold: 0.2, + flashAlpha: 0.8, + flashRadius: 30, + lineWidthMax: 3, + glowInner: 5, + glowOuter: 10, +} as const; + +// ─── Particle drawing ─────────────────────────────────────────────────────── + +export const PARTICLE_DRAW = { + glowRadius: 15, + coreHighlightScale: 0.4, + labelMinT: 0.2, + labelMaxT: 0.8, + labelFontSize: 8, + labelYOffset: -12, + /** Seconds a particle lives before fading */ + lifetime: 2.0, +} as const; + +// ─── Hit detection ────────────────────────────────────────────────────────── + +export const HIT_DETECTION = { + /** Extra padding around nodes for easier clicking */ + agentPadding: 8, + /** Task pill hit area padding */ + taskPadding: 4, +} as const; + +// ─── Background ───────────────────────────────────────────────────────────── + +export const BACKGROUND = { + /** Number of depth particles (stars) */ + starCount: 80, + /** Hex grid cell size */ + hexSize: 30, + /** Hex grid max alpha */ + hexAlpha: 0.08, + /** Hex grid pulse speed */ + hexPulseSpeed: 0.3, +} as const; + +// ─── Kanban zone layout ───────────────────────────────────────────────────── + +export const KANBAN_ZONE = { + /** Column width: pill (120) + gap (20) */ + columnWidth: 140, + /** Row height: pill (36) + gap (10) */ + rowHeight: 46, + /** Zone starts this far below member node center */ + offsetY: 60, + /** Column order: todo → wip → done → review → approved */ + columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, + /** Max tasks shown per column (overflow hidden) */ + maxVisibleRows: 6, +} as const; diff --git a/packages/agent-graph/src/constants/colors.ts b/packages/agent-graph/src/constants/colors.ts new file mode 100644 index 00000000..8474d1e6 --- /dev/null +++ b/packages/agent-graph/src/constants/colors.ts @@ -0,0 +1,167 @@ +/** + * Color palette for the space-themed graph visualization. + * Adapted from agent-flow's colors.ts (Apache 2.0). + * Uses our GraphNodeState instead of agent-flow's AgentState. + */ + +import type { GraphNodeState } from '../ports/types'; + +// ─── Holographic Color Palette ────────────────────────────────────────────── + +export const COLORS = { + // Background + void: '#050510', + hexGrid: '#0d0d1f', + + // Primary hologram + holoBase: '#66ccff', + holoBright: '#aaeeff', + holoHot: '#ffffff', + + // Node states + idle: '#66ccff', + active: '#66ccff', + thinking: '#66ccff', + tool_calling: '#ffbb44', + complete: '#66ffaa', + error: '#ff5566', + waiting: '#ffaa33', + terminated: '#888899', + + // Edge/Particle colors + dispatch: '#cc88ff', + return: '#66ffaa', + tool: '#ffbb44', + message: '#66ccff', + + // Task status colors + taskPending: '#6b7280', + taskInProgress: '#3b82f6', + taskCompleted: '#22c55e', + taskDeleted: '#ef4444', + + // Review state colors + reviewNone: 'transparent', + reviewPending: '#f59e0b', + reviewNeedsFix: '#ef4444', + reviewApproved: '#22c55e', + + // Edge type colors + edgeParentChild: '#66ccff', + edgeOwnership: '#66ccff', + edgeBlocking: '#ff5566', + edgeRelated: '#888899', + edgeMessage: '#cc88ff', + + // Particle kind colors + particleMessage: '#66ccff', + particleTaskAssign: '#ffbb44', + particleReviewRequest: '#f59e0b', + particleReviewResponse: '#22c55e', + particleSpawn: '#cc88ff', + + // UI Chrome + nodeInterior: 'rgba(10, 15, 40, 0.5)', + textPrimary: '#aaeeff', + textDim: '#66ccff90', + textMuted: '#66ccff50', + + // Glass card (for popovers) + glassBg: 'rgba(10, 15, 30, 0.7)', + glassBorder: 'rgba(100, 200, 255, 0.15)', + glassHighlight: 'rgba(100, 200, 255, 0.08)', + + // Holo background/border opacities + holoBg05: 'rgba(100, 200, 255, 0.05)', + holoBg10: 'rgba(100, 200, 255, 0.1)', + holoBorder10: 'rgba(100, 200, 255, 0.1)', + holoBorder12: 'rgba(100, 200, 255, 0.12)', + + // Card backgrounds + cardBg: 'rgba(10, 15, 30, 0.6)', + cardBgSelected: 'rgba(100, 200, 255, 0.15)', + + // Controls + controlBg: 'rgba(8, 12, 24, 0.85)', + controlBorder: 'rgba(100, 200, 255, 0.1)', + controlActive: 'rgba(100, 200, 255, 0.15)', + controlInactive: 'rgba(100, 200, 255, 0.05)', +} as const; + +// ─── State Color Resolver ─────────────────────────────────────────────────── + +export function getStateColor(state: GraphNodeState): string { + switch (state) { + case 'idle': + return COLORS.idle; + case 'active': + return COLORS.active; + case 'thinking': + return COLORS.thinking; + case 'tool_calling': + return COLORS.tool_calling; + case 'complete': + return COLORS.complete; + case 'error': + return COLORS.error; + case 'waiting': + return COLORS.waiting; + case 'terminated': + return COLORS.terminated; + } +} + +// ─── Task Status Color Resolver ───────────────────────────────────────────── + +export function getTaskStatusColor( + status: 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined, +): string { + switch (status) { + case 'pending': + return COLORS.taskPending; + case 'in_progress': + return COLORS.taskInProgress; + case 'completed': + return COLORS.taskCompleted; + case 'deleted': + return COLORS.taskDeleted; + default: + return COLORS.taskPending; + } +} + +// ─── Review State Color Resolver ──────────────────────────────────────────── + +export function getReviewStateColor( + state: 'none' | 'review' | 'needsFix' | 'approved' | undefined, +): string { + switch (state) { + case 'review': + return COLORS.reviewPending; + case 'needsFix': + return COLORS.reviewNeedsFix; + case 'approved': + return COLORS.reviewApproved; + default: + return COLORS.reviewNone; + } +} + +// ─── Hex Color Alpha Utility ──────────────────────────────────────────────── + +// Pre-built LUT: index 0-255 → '00'-'ff' (avoids toString+padStart per call) +const ALPHA_HEX_LUT: string[] = []; +for (let i = 0; i < 256; i++) ALPHA_HEX_LUT.push(i.toString(16).padStart(2, '0')); + +/** Convert 0..1 alpha to 2-digit hex suffix (via LUT) */ +export function alphaHex(alpha: number): string { + return ALPHA_HEX_LUT[Math.round(Math.max(0, Math.min(1, alpha)) * 255)]; +} + +/** Safely combine a partial rgba base (e.g. "rgba(100, 200, 255,") with an alpha value */ +export function withAlpha(rgbaBase: string, alpha: number): string { + // Handles both "rgba(r,g,b," and "rgba(r, g, b," formats + const trimmed = rgbaBase.trimEnd(); + const separator = trimmed.endsWith(',') ? ' ' : ', '; + return `${trimmed}${separator}${alpha})`; +} diff --git a/packages/agent-graph/src/constants/index.ts b/packages/agent-graph/src/constants/index.ts new file mode 100644 index 00000000..9488d7c5 --- /dev/null +++ b/packages/agent-graph/src/constants/index.ts @@ -0,0 +1,27 @@ +export { + MIN_VISIBLE_OPACITY, + ANIM_SPEED, + CAMERA, + FORCE, + NODE, + TASK_PILL, + AGENT_DRAW, + CONTEXT_RING, + BEAM, + ANIM, + FX, + SPAWN_FX, + COMPLETE_FX, + PARTICLE_DRAW, + HIT_DETECTION, + BACKGROUND, +} from './canvas-constants'; + +export { + COLORS, + getStateColor, + getTaskStatusColor, + getReviewStateColor, + alphaHex, + withAlpha, +} from './colors'; diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts new file mode 100644 index 00000000..75b1ae89 --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -0,0 +1,178 @@ +/** + * Camera hook — pan, zoom, auto-fit. + * Adapted from agent-flow's use-canvas-camera.ts (Apache 2.0). + * All state in refs — no React re-renders. + */ + +import { useRef, useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; + +export interface CameraTransform { + x: number; + y: number; + zoom: number; +} + +export interface UseGraphCameraResult { + transformRef: React.MutableRefObject; + screenToWorld: (sx: number, sy: number) => { x: number; y: number }; + worldToScreen: (wx: number, wy: number) => { x: number; y: number }; + handleWheel: (e: WheelEvent) => void; + handlePanStart: (sx: number, sy: number) => void; + handlePanMove: (sx: number, sy: number) => void; + handlePanEnd: () => void; + zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number) => void; + zoomIn: () => void; + zoomOut: () => void; + updateInertia: () => void; +} + +export function useGraphCamera(): UseGraphCameraResult { + const transformRef = useRef({ x: 0, y: 0, zoom: 1 }) as React.MutableRefObject; + const panStartRef = useRef<{ x: number; y: number; camX: number; camY: number } | null>(null); + const velocityRef = useRef({ vx: 0, vy: 0 }); + + const screenToWorld = useCallback((sx: number, sy: number) => { + const t = transformRef.current; + return { + x: (sx - t.x) / t.zoom, + y: (sy - t.y) / t.zoom, + }; + }, []); + + const worldToScreen = useCallback((wx: number, wy: number) => { + const t = transformRef.current; + return { + x: wx * t.zoom + t.x, + y: wy * t.zoom + t.y, + }; + }, []); + + const handleWheel = useCallback((e: WheelEvent) => { + const t = transformRef.current; + + // Trackpad pinch (ctrlKey=true) sends small deltaY values — use them directly. + // Mouse wheel sends larger discrete deltaY — normalize to smaller steps. + let zoomDelta: number; + if (e.ctrlKey) { + // Pinch-to-zoom: deltaY is typically -2..+2, dampen it + zoomDelta = -e.deltaY * 0.008; + } else { + // Mouse wheel: deltaY is typically ±100-150, use discrete steps + zoomDelta = e.deltaY < 0 ? 0.08 : -0.08; + } + + const newZoom = Math.max(CAMERA.minZoom, Math.min(CAMERA.maxZoom, t.zoom * (1 + zoomDelta))); + + // Zoom toward cursor position + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect?.(); + const cx = rect ? e.clientX - rect.left : e.offsetX; + const cy = rect ? e.clientY - rect.top : e.offsetY; + + t.x = cx - (cx - t.x) * (newZoom / t.zoom); + t.y = cy - (cy - t.y) * (newZoom / t.zoom); + t.zoom = newZoom; + }, []); + + const lastPanPos = useRef({ x: 0, y: 0 }); + + const handlePanStart = useCallback((sx: number, sy: number) => { + const t = transformRef.current; + panStartRef.current = { x: sx, y: sy, camX: t.x, camY: t.y }; + lastPanPos.current = { x: sx, y: sy }; + velocityRef.current = { vx: 0, vy: 0 }; + }, []); + + const handlePanMove = useCallback((sx: number, sy: number) => { + const start = panStartRef.current; + if (!start) return; + const t = transformRef.current; + const dx = sx - start.x; + const dy = sy - start.y; + t.x = start.camX + dx; + t.y = start.camY + dy; + // Per-frame delta for inertia (not total drag distance) + const frameDx = sx - lastPanPos.current.x; + const frameDy = sy - lastPanPos.current.y; + lastPanPos.current = { x: sx, y: sy }; + velocityRef.current = { vx: frameDx * CAMERA.velocityScale, vy: frameDy * CAMERA.velocityScale }; + }, []); + + const handlePanEnd = useCallback(() => { + panStartRef.current = null; + }, []); + + const updateInertia = useCallback(() => { + const v = velocityRef.current; + if (Math.abs(v.vx) < ANIM.inertiaThreshold && Math.abs(v.vy) < ANIM.inertiaThreshold) { + v.vx = 0; + v.vy = 0; + return; + } + const t = transformRef.current; + t.x += v.vx; + t.y += v.vy; + v.vx *= ANIM.inertiaDecay; + v.vy *= ANIM.inertiaDecay; + }, []); + + const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number) => { + if (nodes.length === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of nodes) { + const x = n.x ?? 0; + const y = n.y ?? 0; + const pad = n.kind === 'task' + ? TASK_PILL.width / 2 + : n.kind === 'lead' + ? NODE.radiusLead + : NODE.radiusMember; + minX = Math.min(minX, x - pad); + minY = Math.min(minY, y - pad); + maxX = Math.max(maxX, x + pad); + maxY = Math.max(maxY, y + pad); + } + + const padding = ANIM.viewportPadding; + const contentW = maxX - minX + padding * 2; + const contentH = maxY - minY + padding * 2; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const zoom = Math.max( + CAMERA.minZoom, + Math.min(CAMERA.maxZoom, Math.min(canvasW / contentW, canvasH / contentH)), + ); + + const t = transformRef.current; + t.zoom = zoom; + t.x = canvasW / 2 - centerX * zoom; + t.y = canvasH / 2 - centerY * zoom; + }, []); + + const zoomIn = useCallback(() => { + const t = transformRef.current; + t.zoom = Math.min(CAMERA.maxZoom, t.zoom * 1.2); + }, []); + + const zoomOut = useCallback(() => { + const t = transformRef.current; + t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); + }, []); + + return { + transformRef, + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + }; +} diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts new file mode 100644 index 00000000..2c76c940 --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -0,0 +1,89 @@ +/** + * Interaction hook — click, hover, drag on canvas. + * Delegates hit testing to strategy pattern. + */ + +import { useRef, useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import { ANIM } from '../constants/canvas-constants'; +import { findNodeAt } from '../canvas/hit-detection'; + +export interface UseGraphInteractionResult { + hoveredNodeId: React.RefObject; + dragNodeId: React.RefObject; + isDragging: React.RefObject; + handleMouseDown: (wx: number, wy: number, nodes: GraphNode[]) => void; + handleMouseMove: (wx: number, wy: number, nodes: GraphNode[]) => void; + handleMouseUp: () => string | null; + handleDoubleClick: (wx: number, wy: number, nodes: GraphNode[]) => string | null; +} + +export function useGraphInteraction( + onDragNode?: (nodeId: string, x: number, y: number) => void, +): UseGraphInteractionResult { + const hoveredNodeId = useRef(null); + const dragNodeId = useRef(null); + const isDragging = useRef(false); + const mouseDownPos = useRef<{ x: number; y: number } | null>(null); + const clickedNodeId = useRef(null); + + const handleMouseDown = useCallback((wx: number, wy: number, nodes: GraphNode[]) => { + mouseDownPos.current = { x: wx, y: wy }; + const hit = findNodeAt(wx, wy, nodes); + clickedNodeId.current = hit; + + if (hit) { + dragNodeId.current = hit; + } + }, []); + + const handleMouseMove = useCallback((wx: number, wy: number, nodes: GraphNode[]) => { + // Check drag threshold + if (mouseDownPos.current && dragNodeId.current) { + const dx = wx - mouseDownPos.current.x; + const dy = wy - mouseDownPos.current.y; + if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) { + isDragging.current = true; + } + } + + // Drag node + if (isDragging.current && dragNodeId.current) { + onDragNode?.(dragNodeId.current, wx, wy); + return; + } + + // Hover detection + hoveredNodeId.current = findNodeAt(wx, wy, nodes); + }, [onDragNode]); + + const handleMouseUp = useCallback((): string | null => { + const wasDragging = isDragging.current; + const nodeId = clickedNodeId.current; + + isDragging.current = false; + dragNodeId.current = null; + mouseDownPos.current = null; + clickedNodeId.current = null; + + // If not dragging, this was a click + if (!wasDragging && nodeId) { + return nodeId; + } + return null; + }, []); + + const handleDoubleClick = useCallback((wx: number, wy: number, nodes: GraphNode[]): string | null => { + return findNodeAt(wx, wy, nodes); + }, []); + + return { + hoveredNodeId, + dragNodeId, + isDragging, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleDoubleClick, + }; +} diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts new file mode 100644 index 00000000..c7c2e3c2 --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -0,0 +1,285 @@ +/** + * Graph simulation hook using d3-force for MEMBER/LEAD nodes only. + * Task nodes are positioned by KanbanLayoutEngine (deterministic grid). + * + * CRITICAL: Animation state in useRef, NOT useState — no React re-renders at 60fps. + * This hook does NOT run its own RAF loop — the parent (GraphView) calls tick(). + */ + +import { useRef, useEffect, useCallback } from 'react'; +import { + forceSimulation, + forceCenter, + forceManyBody, + forceCollide, + forceLink, + type Simulation, + type SimulationNodeDatum, + type SimulationLinkDatum, +} from 'd3-force'; +import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types'; +import { FORCE, ANIM_SPEED } from '../constants/canvas-constants'; +import { getNodeStrategy } from '../strategies'; +import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; +import { getStateColor } from '../constants/colors'; +import { KanbanLayoutEngine } from '../layout/kanbanLayout'; + +// ─── Force Node/Link types (properly typed, no loose `string`) ────────────── + +interface ForceNode extends SimulationNodeDatum { + id: string; + kind: GraphNodeKind; +} + +interface ForceLink extends SimulationLinkDatum { + id: string; + edgeType: string; +} + +// ─── Simulation State (in ref, not useState) ──────────────────────────────── + +export interface SimulationState { + nodes: GraphNode[]; + edges: GraphEdge[]; + particles: GraphParticle[]; + effects: VisualEffect[]; + time: number; +} + +export interface UseGraphSimulationResult { + stateRef: React.MutableRefObject; + updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void; + /** Tick one simulation frame — called from parent's RAF loop */ + tick: (dt: number) => void; +} + +// ─── Deterministic hash for stable initial positions ───────────────────────── + +/** Returns a value in [-0.5, 0.5] deterministically from string + seed */ +function deterministicPosition(id: string, seed: number): number { + let hash = seed * 2654435761; + for (let i = 0; i < id.length; i++) { + hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; + } + return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5; +} + +// ─── Hook ─────────────────────────────────────────────────────────────────── + +export function useGraphSimulation(): UseGraphSimulationResult { + const stateRef = useRef({ + nodes: [], + edges: [], + particles: [], + effects: [], + time: 0, + }); + + const simRef = useRef | null>(null); + + // Initialize d3-force simulation + const initSimulation = useCallback(() => { + if (simRef.current) simRef.current.stop(); + + const sim = forceSimulation([]) + .force('center', forceCenter(0, 0).strength(FORCE.centerStrength)) + .force('charge', forceManyBody().strength((d) => { + return getNodeStrategy(d.kind).getChargeStrength(); + })) + .force('collide', forceCollide().radius((d) => { + return getNodeStrategy(d.kind).getCollisionRadius(); + })) + .force('link', forceLink([]).id((d) => d.id).distance((d) => { + return FORCE.linkDistance[d.edgeType as keyof typeof FORCE.linkDistance] ?? 200; + }).strength(FORCE.linkStrength)) + .alphaDecay(FORCE.alphaDecay) + .velocityDecay(FORCE.velocityDecay) + .stop(); // We tick manually + + simRef.current = sim; + return sim; + }, []); + + // Track node set identity to avoid re-running simulation when data reference changes but content is same + const lastNodeIdsHash = useRef(''); + + // Sync graph data to d3-force — ONLY when node set actually changes + const syncSimulation = useCallback((nodes: GraphNode[], edges: GraphEdge[]) => { + // Hash includes IDs + mutable fields (status, owner, review) to detect real changes + const hash = nodes.map((n) => `${n.id}:${n.state}:${n.ownerId ?? ''}:${n.taskStatus ?? ''}:${n.reviewState ?? ''}`).sort().join(','); + if (hash === lastNodeIdsHash.current) return; // same nodes — skip re-simulation + lastNodeIdsHash.current = hash; + + let sim = simRef.current; + if (!sim) sim = initSimulation(); + + // Tasks excluded from d3-force — positioned by KanbanLayoutEngine + const forceNodes: ForceNode[] = nodes + .filter((n) => n.kind !== 'task') + .map((n) => ({ + id: n.id, + kind: n.kind, + // Deterministic initial positions from node ID hash — same layout every time + x: n.x ?? deterministicPosition(n.id, 0) * 500, + y: n.y ?? deterministicPosition(n.id, 1) * 500, + vx: n.vx ?? 0, + vy: n.vy ?? 0, + fx: n.fx, + fy: n.fy, + })); + + // Links only between non-task nodes (parent-child: lead↔member) + const forceNodeIds = new Set(forceNodes.map((n) => n.id)); + const forceLinks: ForceLink[] = edges + .filter((e) => forceNodeIds.has(e.source) && forceNodeIds.has(e.target)) + .map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + edgeType: e.type, + })); + + sim.nodes(forceNodes); + (sim.force('link') as ReturnType)?.links(forceLinks); + sim.alpha(1); + + // Run simulation to near-completion so nodes are settled on first render + for (let i = 0; i < 120; i++) sim.tick(); + sim.alpha(0); // fully settled — no more movement until new data + + // Copy settled positions BACK to GraphNode objects + const simNodeMap = new Map(); + for (const sn of sim.nodes()) simNodeMap.set(sn.id, sn); + for (const node of nodes) { + const sn = simNodeMap.get(node.id); + if (sn) { + node.x = sn.x; + node.y = sn.y; + node.vx = sn.vx; + node.vy = sn.vy; + } + } + + // Position tasks in kanban zones relative to their owners + KanbanLayoutEngine.layout(nodes); + }, [initSimulation]); + + // Track previous node IDs and states for effect spawning + const prevNodeIdsRef = useRef(new Set()); + const prevNodeStatesRef = useRef(new Map()); + + // Update data from adapter + const updateData = useCallback((nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => { + const state = stateRef.current; + const prevIds = prevNodeIdsRef.current; + const prevStates = prevNodeStatesRef.current; + + // Preserve positions from previous frame + const prevPositions = new Map(); + for (const n of state.nodes) { + if (n.x != null && n.y != null) { + prevPositions.set(n.id, { x: n.x, y: n.y, vx: n.vx ?? 0, vy: n.vy ?? 0 }); + } + } + + for (const n of nodes) { + const prev = prevPositions.get(n.id); + if (prev && n.x == null) { + n.x = prev.x; + n.y = prev.y; + n.vx = prev.vx; + n.vy = prev.vy; + } + } + + // Detect state transitions → spawn visual effects + for (const node of nodes) { + // New node appeared → spawn effect + if (!prevIds.has(node.id) && node.x != null && node.y != null) { + state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + } + + // Task completed → shatter effect + const prevState = prevStates.get(node.id); + if (prevState && prevState !== 'complete' && node.state === 'complete' && node.x != null && node.y != null) { + state.effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + } + } + + // Update tracking refs + prevNodeIdsRef.current = new Set(nodes.map((n) => n.id)); + prevNodeStatesRef.current = new Map(nodes.map((n) => [n.id, n.state])); + + state.nodes = nodes; + state.edges = edges; + state.particles = particles; + + syncSimulation(nodes, edges); + }, [syncSimulation]); + + // Tick one frame (called by parent's RAF loop) + const tick = useCallback((dt: number) => { + tickFrame(stateRef.current, simRef.current, dt); + }, []); + + // Cleanup + useEffect(() => { + return () => { + simRef.current?.stop(); + }; + }, []); + + return { stateRef, updateData, tick }; +} + +// ─── Frame Tick (pure function) ───────────────────────────────────────────── + +function tickFrame( + state: SimulationState, + sim: Simulation | null, + dt: number, +): void { + state.time += dt; + + // Tick d3-force (only when simulation is still active) + if (sim && sim.alpha() > 0.001) { + sim.tick(1); + + const simNodes = sim.nodes(); + const simNodeMap = new Map(); + for (const sn of simNodes) simNodeMap.set(sn.id, sn); + + for (const node of state.nodes) { + const sn = simNodeMap.get(node.id); + if (sn) { + node.x = sn.x; + node.y = sn.y; + node.vx = sn.vx; + node.vy = sn.vy; + } + } + } + + // Re-layout tasks in kanban zones ONLY when members moved (alpha > 0 or drag) + if (!sim || sim.alpha() > 0.001 || state.particles.length > 0) { + KanbanLayoutEngine.layout(state.nodes); + } + + // Update particle progress — in-place removal (no new array allocation) + let pw = 0; + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + p.progress += dt * ANIM_SPEED.particleSpeed * 0.5; + if (p.progress < 1) state.particles[pw++] = p; + } + state.particles.length = pw; + + // Update effects — in-place removal + let ew = 0; + for (let i = 0; i < state.effects.length; i++) { + const fx = state.effects[i]; + fx.age += dt; + if (fx.age < fx.duration) state.effects[ew++] = fx; + } + state.effects.length = ew; +} diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts new file mode 100644 index 00000000..26a1f01c --- /dev/null +++ b/packages/agent-graph/src/index.ts @@ -0,0 +1,28 @@ +/** + * @claude-teams/agent-graph + * + * Force-directed graph visualization for agent teams. + * Isolated package — depends only on React (peer) and d3-force. + * Uses Port/Adapter pattern: host project provides data through port interfaces. + */ + +// ─── Components ────────────────────────────────────────────────────────────── +export { GraphView } from './ui/GraphView'; +export type { GraphViewProps } from './ui/GraphView'; + +// ─── Port Interfaces (for adapters in host project) ───────────────────────── +export type { GraphDataPort } from './ports/GraphDataPort'; +export type { GraphEventPort } from './ports/GraphEventPort'; +export type { GraphConfigPort } from './ports/GraphConfigPort'; + +// ─── Port Types ────────────────────────────────────────────────────────────── +export type { + GraphNode, + GraphEdge, + GraphParticle, + GraphNodeKind, + GraphNodeState, + GraphEdgeType, + GraphParticleKind, + GraphDomainRef, +} from './ports/types'; diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts new file mode 100644 index 00000000..6528c542 --- /dev/null +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -0,0 +1,131 @@ +/** + * KanbanLayoutEngine — positions task nodes in kanban columns relative to their owner. + * + * Each member/lead gets a zone below them with 4 columns: todo → wip → review → done. + * Tasks are pinned (fx/fy) — no d3-force drift. Deterministic layout. + * + * Class with ES #private methods, single source of truth from KANBAN_ZONE constants. + */ + +import type { GraphNode } from '../ports/types'; +import { KANBAN_ZONE } from '../constants/canvas-constants'; + +export class KanbanLayoutEngine { + // Reusable collections (cleared each call, never GC'd) + static readonly #nodeMap = new Map(); + static readonly #tasksByOwner = new Map(); + static readonly #unassigned: GraphNode[] = []; + static readonly #colTasks = new Map(); + + /** + * Position all task nodes in kanban columns relative to their owner. + * Call AFTER d3-force settles member positions, BEFORE drawing. + */ + static layout(nodes: GraphNode[]): void { + const nodeMap = this.#nodeMap; + nodeMap.clear(); + for (const n of nodes) nodeMap.set(n.id, n); + + // Group tasks by owner — reuse maps + const tasksByOwner = this.#tasksByOwner; + tasksByOwner.clear(); + const unassigned = this.#unassigned; + unassigned.length = 0; + + for (const n of nodes) { + if (n.kind !== 'task') continue; + if (n.ownerId) { + let group = tasksByOwner.get(n.ownerId); + if (!group) { + group = []; + tasksByOwner.set(n.ownerId, group); + } + group.push(n); + } else { + unassigned.push(n); + } + } + + // Layout each owner's tasks in kanban columns + for (const [ownerId, tasks] of tasksByOwner) { + const owner = nodeMap.get(ownerId); + if (!owner || owner.x == null || owner.y == null) continue; + KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y); + } + + // Unassigned tasks: separate zone + KanbanLayoutEngine.#layoutUnassigned(unassigned); + } + + // ─── Private ────────────────────────────────────────────────────────────── + + static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number): void { + const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE; + const totalWidth = columns.length * columnWidth; + const baseX = ownerX - totalWidth / 2; + const baseY = ownerY + offsetY; + + // Classify each task into a column — reuse shared Map + const colTasks = KanbanLayoutEngine.#colTasks; + colTasks.clear(); + for (const col of columns) colTasks.set(col, []); + + for (const task of tasks) { + const col = KanbanLayoutEngine.#resolveColumn(task); + colTasks.get(col)?.push(task); + } + + // Position each task in its column + row + for (const [colIdx, colName] of columns.entries()) { + const colNodes = colTasks.get(colName) ?? []; + for (const [rowIdx, task] of colNodes.entries()) { + if (rowIdx >= maxVisibleRows) { + // Hide overflow tasks off-screen + task.x = -99999; + task.y = -99999; + task.fx = task.x; + task.fy = task.y; + continue; + } + task.x = baseX + colIdx * columnWidth; + task.y = baseY + rowIdx * rowHeight; + task.fx = task.x; + task.fy = task.y; + task.vx = 0; + task.vy = 0; + } + } + } + + /** + * Determine which kanban column a task belongs to. + * Columns: todo → wip → done → review → approved + * approved is separate from review — approved goes after review. + */ + static #resolveColumn(task: GraphNode): string { + // Approved = separate column (after review) + if (task.reviewState === 'approved') return 'approved'; + // Active review/needsFix = review column (next to done) + if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; + switch (task.taskStatus) { + case 'in_progress': + return 'wip'; + case 'completed': + return 'done'; + default: + return 'todo'; + } + } + + static #layoutUnassigned(tasks: GraphNode[]): void { + const { columnWidth, rowHeight } = KANBAN_ZONE; + for (const [idx, task] of tasks.entries()) { + task.x = -400 + (idx % 3) * columnWidth; + task.y = 400 + Math.floor(idx / 3) * rowHeight; + task.fx = task.x; + task.fy = task.y; + task.vx = 0; + task.vy = 0; + } + } +} diff --git a/packages/agent-graph/src/ports/GraphConfigPort.ts b/packages/agent-graph/src/ports/GraphConfigPort.ts new file mode 100644 index 00000000..6065bfe2 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphConfigPort.ts @@ -0,0 +1,55 @@ +import type { GraphNodeState } from './types'; + +/** + * Configuration port — visual theme, filters, animation settings. + * All fields optional — package provides sensible defaults. + */ +export interface GraphConfigPort { + // ─── Theme ───────────────────────────────────────────────────────────── + /** Background color (default: space dark #0a0f1a) */ + backgroundColor?: string; + /** Whether to show hex grid on background */ + showHexGrid?: boolean; + /** Whether to show depth star field */ + showStarField?: boolean; + /** Bloom post-processing intensity (0 = off, 1 = default) */ + bloomIntensity?: number; + + // ─── Node Colors (overrides per state) ───────────────────────────────── + nodeStateColors?: Partial>; + /** Task status colors */ + taskStatusColors?: { + pending?: string; + in_progress?: string; + completed?: string; + deleted?: string; + }; + /** Review state colors */ + reviewStateColors?: { + review?: string; + needsFix?: string; + approved?: string; + }; + + // ─── Filters (show/hide node kinds) ──────────────────────────────────── + showTasks?: boolean; + showProcesses?: boolean; + showCompletedTasks?: boolean; + showEdgeLabels?: boolean; + + // ─── Animation ───────────────────────────────────────────────────────── + /** Animation enabled (default: true) */ + animationEnabled?: boolean; + /** Particle speed multiplier (default: 1) */ + particleSpeed?: number; + /** Breathing animation speed (default: 1) */ + breathingSpeed?: number; + + // ─── Force Layout ────────────────────────────────────────────────────── + /** Charge strength (repulsion, default: -800) */ + chargeStrength?: number; + /** Center attraction strength (default: 0.03) */ + centerStrength?: number; + /** Task orbit radius around owner (default: 150) */ + taskOrbitRadius?: number; +} diff --git a/packages/agent-graph/src/ports/GraphDataPort.ts b/packages/agent-graph/src/ports/GraphDataPort.ts new file mode 100644 index 00000000..ec4c5ce0 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphDataPort.ts @@ -0,0 +1,20 @@ +import type { GraphNode, GraphEdge, GraphParticle } from './types'; + +/** + * Data provider port — supplies graph state to the visualization. + * Host project implements this via an adapter (e.g., useTeamGraphAdapter). + */ +export interface GraphDataPort { + /** All nodes to render (members, tasks, processes, lead) */ + nodes: GraphNode[]; + /** All edges (ownership, blocking, related, message, parent-child) */ + edges: GraphEdge[]; + /** Active particles (messages in flight, spawn effects) */ + particles: GraphParticle[]; + /** Team name for display */ + teamName: string; + /** Team brand color */ + teamColor?: string; + /** Whether the team lead process is alive */ + isAlive?: boolean; +} diff --git a/packages/agent-graph/src/ports/GraphEventPort.ts b/packages/agent-graph/src/ports/GraphEventPort.ts new file mode 100644 index 00000000..8ed94ca6 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphEventPort.ts @@ -0,0 +1,22 @@ +import type { GraphDomainRef, GraphEdge } from './types'; + +/** + * Event callback port — graph fires these when user interacts with nodes/edges. + * Host project provides handlers to navigate to domain-specific views. + */ +export interface GraphEventPort { + /** Single click on a node — show popover with details */ + onNodeClick?: (ref: GraphDomainRef) => void; + /** Double click on a node — open full detail dialog */ + onNodeDoubleClick?: (ref: GraphDomainRef) => void; + /** Click on an edge */ + onEdgeClick?: (edge: GraphEdge) => void; + /** Click on empty canvas background */ + onBackgroundClick?: () => void; + /** "Send Message" action from node popover */ + onSendMessage?: (memberName: string, teamName: string) => void; + /** "Open Task Detail" action from task popover */ + onOpenTaskDetail?: (taskId: string, teamName: string) => void; + /** "Open Member Profile" action from member popover */ + onOpenMemberProfile?: (memberName: string, teamName: string) => void; +} diff --git a/packages/agent-graph/src/ports/index.ts b/packages/agent-graph/src/ports/index.ts new file mode 100644 index 00000000..532bc497 --- /dev/null +++ b/packages/agent-graph/src/ports/index.ts @@ -0,0 +1,13 @@ +export type { GraphDataPort } from './GraphDataPort'; +export type { GraphEventPort } from './GraphEventPort'; +export type { GraphConfigPort } from './GraphConfigPort'; +export type { + GraphNode, + GraphEdge, + GraphParticle, + GraphNodeKind, + GraphNodeState, + GraphEdgeType, + GraphParticleKind, + GraphDomainRef, +} from './types'; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts new file mode 100644 index 00000000..2ff862c8 --- /dev/null +++ b/packages/agent-graph/src/ports/types.ts @@ -0,0 +1,117 @@ +/** + * Core types for graph visualization. + * Framework-agnostic — no dependencies on TeamData, Zustand, Electron, or agent-flow internals. + */ + +// ─── Node Kinds ────────────────────────────────────────────────────────────── + +export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process'; + +export type GraphNodeState = + | 'idle' + | 'active' + | 'thinking' + | 'tool_calling' + | 'waiting' + | 'complete' + | 'error' + | 'terminated'; + +// ─── Edge & Particle Types ─────────────────────────────────────────────────── + +export type GraphEdgeType = 'parent-child' | 'ownership' | 'blocking' | 'related' | 'message'; + +export type GraphParticleKind = + | 'message' + | 'task_assign' + | 'review_request' + | 'review_response' + | 'spawn'; + +// ─── Graph Node ────────────────────────────────────────────────────────────── + +export interface GraphNode { + /** Unique node identifier (e.g., "member:alice", "task:abc123") */ + id: string; + kind: GraphNodeKind; + label: string; + state: GraphNodeState; + + /** Node color override (e.g., member.color hex value) */ + color?: string; + + // ─── Member/Lead-specific ────────────────────────────────────────────── + /** Agent role description */ + role?: string; + /** Spawn lifecycle status */ + spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; + /** Context window usage ratio (0..1), available for lead only */ + contextUsage?: number; + + // ─── Task-specific ───────────────────────────────────────────────────── + /** Short display ID (e.g., "#3") */ + displayId?: string; + /** Task subject / description */ + sublabel?: string; + /** Owner member node ID — tasks orbit around this node */ + ownerId?: string | null; + /** Task status for pill coloring */ + taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted'; + /** Review state overlay */ + reviewState?: 'none' | 'review' | 'needsFix' | 'approved'; + /** Requires clarification indicator */ + needsClarification?: 'lead' | 'user' | null; + + // ─── Process-specific ────────────────────────────────────────────────── + /** Clickable URL for process */ + processUrl?: string; + + // ─── Force simulation (managed by the package internally) ────────────── + x?: number; + y?: number; + vx?: number; + vy?: number; + /** Pinned position (user-dragged) */ + fx?: number | null; + fy?: number | null; + + // ─── Domain reference (opaque, for navigation back to host app) ──────── + domainRef: GraphDomainRef; +} + +// ─── Graph Edge ────────────────────────────────────────────────────────────── + +export interface GraphEdge { + id: string; + source: string; + target: string; + type: GraphEdgeType; + /** Label shown on edge (e.g., message summary) */ + label?: string; + /** Edge color override */ + color?: string; +} + +// ─── Graph Particle ────────────────────────────────────────────────────────── + +export interface GraphParticle { + id: string; + /** Edge ID this particle travels along */ + edgeId: string; + /** Progress along edge (0..1) */ + progress: number; + kind: GraphParticleKind; + color: string; + /** Size multiplier (1 = default) */ + size?: number; + /** Short label near particle */ + label?: string; +} + +// ─── Domain Reference (opaque back-pointer) ────────────────────────────────── + +export type GraphDomainRef = + | { kind: 'lead'; teamName: string } + | { kind: 'member'; teamName: string; memberName: string } + | { kind: 'task'; teamName: string; taskId: string } + | { kind: 'process'; teamName: string; processId: string }; diff --git a/packages/agent-graph/src/strategies/index.ts b/packages/agent-graph/src/strategies/index.ts new file mode 100644 index 00000000..8f0fc9f0 --- /dev/null +++ b/packages/agent-graph/src/strategies/index.ts @@ -0,0 +1,27 @@ +/** + * Strategy registry — maps GraphNodeKind to its render strategy. + * Open-Closed: add new node kinds by adding new strategies to the registry. + */ + +import type { GraphNodeKind } from '../ports/types'; +import type { NodeRenderStrategy } from './types'; +import { LeadStrategy, MemberStrategy } from './memberStrategy'; +import { TaskStrategy } from './taskStrategy'; +import { ProcessStrategy } from './processStrategy'; + +const STRATEGIES: Record = { + lead: new LeadStrategy(), + member: new MemberStrategy(), + task: new TaskStrategy(), + process: new ProcessStrategy(), +}; + +export function getNodeStrategy(kind: GraphNodeKind): NodeRenderStrategy { + return STRATEGIES[kind]; +} + +export function getAllStrategies(): NodeRenderStrategy[] { + return Object.values(STRATEGIES); +} + +export type { NodeRenderStrategy, NodeRenderState } from './types'; diff --git a/packages/agent-graph/src/strategies/memberStrategy.ts b/packages/agent-graph/src/strategies/memberStrategy.ts new file mode 100644 index 00000000..789b47a1 --- /dev/null +++ b/packages/agent-graph/src/strategies/memberStrategy.ts @@ -0,0 +1,72 @@ +/** + * Render strategy for member and lead nodes. + * Uses the holographic hexagonal rendering from draw-agents.ts. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawAgents } from '../canvas/draw-agents'; +import { NODE, HIT_DETECTION } from '../constants/canvas-constants'; + +export class MemberStrategy implements NodeRenderStrategy { + readonly kind = 'member' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + // drawAgents handles both member and lead — we delegate to it + drawAgents( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusMember + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusMember + 20; + } + + getChargeStrength(): number { + return -600; + } +} + +export class LeadStrategy implements NodeRenderStrategy { + readonly kind = 'lead' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawAgents( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusLead + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusLead + 30; + } + + getChargeStrength(): number { + return -1200; + } +} diff --git a/packages/agent-graph/src/strategies/processStrategy.ts b/packages/agent-graph/src/strategies/processStrategy.ts new file mode 100644 index 00000000..f96b78f1 --- /dev/null +++ b/packages/agent-graph/src/strategies/processStrategy.ts @@ -0,0 +1,39 @@ +/** + * Render strategy for process nodes. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawProcesses } from '../canvas/draw-processes'; +import { NODE, HIT_DETECTION } from '../constants/canvas-constants'; + +export class ProcessStrategy implements NodeRenderStrategy { + readonly kind = 'process' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawProcesses( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusProcess + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusProcess + 10; + } + + getChargeStrength(): number { + return -200; + } +} diff --git a/packages/agent-graph/src/strategies/taskStrategy.ts b/packages/agent-graph/src/strategies/taskStrategy.ts new file mode 100644 index 00000000..96a9a92b --- /dev/null +++ b/packages/agent-graph/src/strategies/taskStrategy.ts @@ -0,0 +1,38 @@ +/** + * Render strategy for task pill nodes. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawTasks } from '../canvas/draw-tasks'; +import { TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; + +export class TaskStrategy implements NodeRenderStrategy { + readonly kind = 'task' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawTasks( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; + const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; + return wx >= x - halfW && wx <= x + halfW && wy >= y - halfH && wy <= y + halfH; + } + + getCollisionRadius(): number { + return Math.max(TASK_PILL.width, TASK_PILL.height) / 2 + 10; + } + + getChargeStrength(): number { + return -300; + } +} diff --git a/packages/agent-graph/src/strategies/types.ts b/packages/agent-graph/src/strategies/types.ts new file mode 100644 index 00000000..b683d09a --- /dev/null +++ b/packages/agent-graph/src/strategies/types.ts @@ -0,0 +1,48 @@ +/** + * Strategy interfaces for per-kind node rendering, hit testing, and layout. + * Open-Closed principle: new node kinds add new strategies, no changes to GraphCanvas. + */ + +import type { GraphNode, GraphNodeKind } from '../ports/types'; + +/** + * Rendering state passed to strategy draw methods (animation context). + */ +export interface NodeRenderState { + isSelected: boolean; + isHovered: boolean; + time: number; + cameraZoom: number; +} + +/** + * Strategy for rendering a specific node kind. + * Liskov: all strategies are interchangeable via the registry. + */ +export interface NodeRenderStrategy { + readonly kind: GraphNodeKind; + + /** + * Draw the node on the canvas. + */ + draw( + ctx: CanvasRenderingContext2D, + node: GraphNode, + state: NodeRenderState, + ): void; + + /** + * Test whether the world-space point (wx, wy) is inside this node. + */ + hitTest(node: GraphNode, wx: number, wy: number): boolean; + + /** + * Get collision radius for d3-force collide simulation. + */ + getCollisionRadius(): number; + + /** + * Get charge strength for d3-force many-body simulation. + */ + getChargeStrength(): number; +} diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx new file mode 100644 index 00000000..2b6fa632 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -0,0 +1,295 @@ +/** + * GraphCanvas — Canvas 2D rendering component with imperative RAF draw loop. + * + * ARCHITECTURE: The canvas draws imperatively via drawRef, NOT via React re-renders. + * GraphView calls `drawRef.current()` from the unified RAF loop. + * React only manages: mount/unmount, resize, mouse events. + */ + +import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react'; +import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; +import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer'; +import { drawEdges } from '../canvas/draw-edges'; +import { drawParticles } from '../canvas/draw-particles'; +import { drawAgents } from '../canvas/draw-agents'; +import { drawTasks } from '../canvas/draw-tasks'; +import { drawProcesses } from '../canvas/draw-processes'; +import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; +import { BloomRenderer } from '../canvas/bloom-renderer'; +import type { CameraTransform } from '../hooks/useGraphCamera'; + +// ─── Draw State (passed by ref, not by props — no React re-renders) ───────── + +export interface GraphDrawState { + nodes: GraphNode[]; + edges: GraphEdge[]; + particles: GraphParticle[]; + effects: VisualEffect[]; + time: number; + camera: CameraTransform; + selectedNodeId: string | null; + hoveredNodeId: string | null; +} + +export interface GraphCanvasHandle { + /** Call this from RAF to draw one frame */ + draw: (state: GraphDrawState) => void; + /** Get the canvas element for coordinate transforms */ + getCanvas: () => HTMLCanvasElement | null; +} + +export interface GraphCanvasProps { + showHexGrid?: boolean; + showStarField?: boolean; + bloomIntensity?: number; + onWheel?: (e: WheelEvent) => void; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseMove?: (e: React.MouseEvent) => void; + onMouseUp?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; + className?: string; +} + +export const GraphCanvas = forwardRef(function GraphCanvas( + { + showHexGrid = true, + showStarField = true, + bloomIntensity = 0.6, + onWheel, + onMouseDown, + onMouseMove, + onMouseUp, + onDoubleClick, + onContextMenu, + className, + }, + ref, +) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const bloomRef = useRef(new BloomRenderer(bloomIntensity)); + const starsRef = useRef([]); + const sizeRef = useRef({ w: 0, h: 0 }); + + // Performance tracking + const perfRef = useRef({ frames: 0, fps: 0, frameTimeMs: 0, lastFpsUpdate: 0, frameTimes: [] as number[] }); + // Rate-limited error logging (prevent console flood at 60fps) + const lastDrawErrorRef = useRef(0); + + // Update bloom intensity without recreating + useEffect(() => { + bloomRef.current.setIntensity(bloomIntensity); + }, [bloomIntensity]); + + // Handle resize + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + const dpr = window.devicePixelRatio || 1; + const canvas = canvasRef.current; + if (!canvas) continue; + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + sizeRef.current = { w: width, h: height }; + bloomRef.current.resize(width * dpr, height * dpr); + starsRef.current = createDepthParticles(width, height); + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + + // Persistent per-frame collections (reused, never GC'd) + const nodeMapCache = useRef(new Map()); + const edgeMapCache = useRef(new Map()); + const visibleNodesCache = useRef([]); + const visibleEdgesCache = useRef([]); + const visibleNodeIdsCache = useRef(new Set()); + const activeParticleEdgesCache = useRef(new Set()); + + // Imperative draw function — called from RAF, NOT from React render + useImperativeHandle(ref, () => ({ + draw: (state: GraphDrawState) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const frameStart = performance.now(); + + const dpr = window.devicePixelRatio || 1; + const { w, h } = sizeRef.current; + if (w === 0 || h === 0) return; + + try { + + const cam = state.camera; + const zoom = cam.zoom; + + // ─── Frustum culling: compute visible world-space bounds ────────── + const viewLeft = -cam.x / zoom; + const viewTop = -cam.y / zoom; + const viewRight = (w - cam.x) / zoom; + const viewBottom = (h - cam.y) / zoom; + const pad = 200; // overdraw padding for glow/labels + + // ─── Reuse cached maps (avoid per-frame allocation) ─────────────── + const nodeMap = nodeMapCache.current; + nodeMap.clear(); + for (const n of state.nodes) nodeMap.set(n.id, n); + + const edgeMap = edgeMapCache.current; + edgeMap.clear(); + for (const e of state.edges) edgeMap.set(e.id, e); + + // ─── Filter visible nodes (frustum cull) — reuse array ──────────── + const visibleNodes = visibleNodesCache.current; + visibleNodes.length = 0; + for (const n of state.nodes) { + const x = n.x ?? 0; + const y = n.y ?? 0; + if (x > viewLeft - pad && x < viewRight + pad && + y > viewTop - pad && y < viewBottom + pad) { + visibleNodes.push(n); + } + } + + // ─── Active particle edges — reuse Set ─────────────────────────── + const activeParticleEdges = activeParticleEdgesCache.current; + activeParticleEdges.clear(); + for (const p of state.particles) activeParticleEdges.add(p.edgeId); + + // ─── Draw ───────────────────────────────────────────────────────── + ctx.save(); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + // 1. Background (screen space) + updateDepthParticles(starsRef.current, w, h, state.time > 0 ? 0.016 : 0); + drawBackground(ctx, w, h, starsRef.current, cam, state.time, { + showHexGrid, + showStarField, + }); + + // 2. World-space content + ctx.save(); + ctx.translate(cam.x, cam.y); + ctx.scale(zoom, zoom); + + // 2a. Edges (only those connecting visible nodes) — reuse collections + const visibleNodeIds = visibleNodeIdsCache.current; + visibleNodeIds.clear(); + for (const n of visibleNodes) visibleNodeIds.add(n.id); + + const visibleEdges = visibleEdgesCache.current; + visibleEdges.length = 0; + for (const e of state.edges) { + if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) { + visibleEdges.push(e); + } + } + drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges); + + // 2b. Particles (cap at 50 for performance) + const cappedParticles = state.particles.length > 50 + ? state.particles.slice(-50) + : state.particles; + drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time); + + // 2c. Visible nodes only (back to front: process → task → member/lead) + drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + + // 2d. Effects + drawEffects(ctx, state.effects); + + ctx.restore(); // world space + ctx.restore(); // DPR scale + + // 3. Bloom post-processing — skip when scene is fully idle (saves 3 blur passes) + const hasActivity = state.particles.length > 0 || state.effects.length > 0; + if (bloomIntensity > 0 && hasActivity) { + bloomRef.current.apply(canvas, ctx); + } + + // 4. Performance overlay (enabled via ?perf in URL) + const perf = perfRef.current; + const frameMs = performance.now() - frameStart; + perf.frameTimes.push(frameMs); + perf.frames++; + if (perf.frameTimes.length > 120) perf.frameTimes.shift(); + + const now = performance.now(); + if (now - perf.lastFpsUpdate > 1000) { + perf.fps = perf.frames; + perf.frames = 0; + perf.lastFpsUpdate = now; + const sorted = [...perf.frameTimes].sort((a, b) => a - b); + perf.frameTimeMs = sorted[Math.floor(sorted.length * 0.95)] ?? 0; + } + + if (typeof window !== 'undefined' && window.location?.search?.includes('perf')) { + ctx.save(); + ctx.scale(dpr, dpr); + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(w - 130, 4, 126, 48); + ctx.font = '10px monospace'; + ctx.fillStyle = perf.fps >= 50 ? '#66ffaa' : perf.fps >= 30 ? '#ffbb44' : '#ff5566'; + ctx.textAlign = 'right'; + ctx.fillText(`${perf.fps} fps`, w - 10, 18); + ctx.fillStyle = '#aaeeff'; + ctx.fillText(`p95: ${perf.frameTimeMs.toFixed(1)}ms`, w - 10, 32); + ctx.fillText(`${state.nodes.length} nodes ${state.edges.length} edges`, w - 10, 46); + ctx.restore(); + } + + } catch (err) { + // Rate-limited error logging — max once per 5 seconds + const now = performance.now(); + if (now - lastDrawErrorRef.current > 5000) { + lastDrawErrorRef.current = now; + console.error('[AgentGraph] Draw error:', err); + } + } + }, + getCanvas: () => canvasRef.current, + }), [showHexGrid, showStarField, bloomIntensity]); + + // Wheel handler (passive: false required for preventDefault) + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !onWheel) return; + const handler = (e: WheelEvent) => { + e.preventDefault(); + onWheel(e); + }; + canvas.addEventListener('wheel', handler, { passive: false }); + return () => canvas.removeEventListener('wheel', handler); + }, [onWheel]); + + return ( +
+ +
+ ); +}); diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx new file mode 100644 index 00000000..d3537463 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -0,0 +1,113 @@ +/** + * GraphControls — floating toolbar over the canvas. + * Zoom, fit, filter toggles, pause, pin-as-tab, close. + */ + +import { useCallback } from 'react'; + +export interface GraphFilterState { + showTasks: boolean; + showProcesses: boolean; + showEdges: boolean; + paused: boolean; +} + +export interface GraphControlsProps { + filters: GraphFilterState; + onFiltersChange: (filters: GraphFilterState) => void; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomToFit: () => void; + onRequestClose?: () => void; + onRequestPinAsTab?: () => void; + teamName: string; + isAlive?: boolean; +} + +export function GraphControls({ + filters, + onFiltersChange, + onZoomIn, + onZoomOut, + onZoomToFit, + onRequestClose, + onRequestPinAsTab, + teamName, + isAlive, +}: GraphControlsProps): React.JSX.Element { + const toggle = useCallback( + (key: keyof GraphFilterState) => { + onFiltersChange({ ...filters, [key]: !filters[key] }); + }, + [filters, onFiltersChange], + ); + + return ( +
+ {/* Left: title + status */} +
+
+ {isAlive && ( +
+ )} + + {teamName} + +
+
+ + {/* Center: filters */} +
+ toggle('showTasks')} label="Tasks" /> + toggle('showProcesses')} label="Proc" /> + toggle('showEdges')} label="Edges" /> +
+ toggle('paused')} label={filters.paused ? '▶' : '⏸'} /> +
+ + {/* Right: zoom + actions */} +
+ + + + {onRequestPinAsTab && ( + <> +
+ + + )} + {onRequestClose && ( + + )} +
+
+ ); +} + +// ─── Toolbar Button ───────────────────────────────────────────────────────── + +function ToolbarButton({ + active, + onClick, + label, +}: { + active?: boolean; + onClick?: () => void; + label: string; +}): React.JSX.Element { + return ( + + ); +} diff --git a/packages/agent-graph/src/ui/GraphOverlay.tsx b/packages/agent-graph/src/ui/GraphOverlay.tsx new file mode 100644 index 00000000..bf4154f3 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphOverlay.tsx @@ -0,0 +1,165 @@ +/** + * GraphOverlay — HTML popovers positioned over Canvas nodes. + * Uses camera worldToScreen transform for positioning. + */ + +import { useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import type { GraphEventPort } from '../ports/GraphEventPort'; +import { getStateColor, getTaskStatusColor } from '../constants/colors'; + +export interface GraphOverlayProps { + selectedNode: GraphNode | null; + worldToScreen: (wx: number, wy: number) => { x: number; y: number }; + events?: GraphEventPort; + onDeselect: () => void; +} + +export function GraphOverlay({ + selectedNode, + worldToScreen, + events, + onDeselect, +}: GraphOverlayProps): React.JSX.Element | null { + if (!selectedNode) return null; + + const screenPos = worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + + return ( +
+ +
+ ); +} + +// ─── Node Popover ─────────────────────────────────────────────────────────── + +function NodePopover({ + node, + events, + onClose, +}: { + node: GraphNode; + events?: GraphEventPort; + onClose: () => void; +}): React.JSX.Element { + const handleAction = useCallback( + (action: string) => { + const ref = node.domainRef; + switch (action) { + case 'sendMessage': + if (ref.kind === 'member' || ref.kind === 'lead') { + events?.onSendMessage?.(ref.kind === 'member' ? ref.memberName : 'team-lead', ref.teamName); + } + break; + case 'openDetail': + if (ref.kind === 'task') events?.onOpenTaskDetail?.(ref.taskId, ref.teamName); + else if (ref.kind === 'member') events?.onOpenMemberProfile?.(ref.memberName, ref.teamName); + break; + case 'openUrl': + if (node.processUrl) window.open(node.processUrl, '_blank'); + break; + } + onClose(); + }, + [node, events, onClose], + ); + + const color = node.kind === 'task' + ? getTaskStatusColor(node.taskStatus) + : getStateColor(node.state); + + return ( +
+ {/* Header */} +
+
+ + {node.label} + +
+ + {/* Info */} + {node.sublabel && ( +
+ {node.sublabel} +
+ )} + {node.role && ( +
+ {node.role} +
+ )} + + {/* Status badges */} +
+ + {node.reviewState && node.reviewState !== 'none' && ( + + )} +
+ + {/* Actions */} +
+ {(node.kind === 'member' || node.kind === 'lead') && ( + handleAction('sendMessage')} /> + )} + {(node.kind === 'task' || node.kind === 'member') && ( + handleAction('openDetail')} /> + )} + {node.kind === 'process' && node.processUrl && ( + handleAction('openUrl')} /> + )} +
+
+ ); +} + +// ─── UI Primitives ────────────────────────────────────────────────────────── + +function StatusBadge({ label, color }: { label: string; color: string }): React.JSX.Element { + return ( + + {label} + + ); +} + +function ActionButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element { + return ( + + ); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx new file mode 100644 index 00000000..89c70d4c --- /dev/null +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -0,0 +1,342 @@ +/** + * GraphView — main orchestrator with UNIFIED RAF loop. + * + * ARCHITECTURE: One RAF loop that: + * 1. Ticks d3-force simulation (updates node positions in refs) + * 2. Updates particles and effects (in refs) + * 3. Calls canvasRef.draw() imperatively (no React re-renders) + * + * React useState ONLY for: selectedNodeId, filters (user-facing UI state). + * ALL animation state (positions, particles, effects, time) lives in refs. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { GraphDataPort } from '../ports/GraphDataPort'; +import type { GraphEventPort } from '../ports/GraphEventPort'; +import type { GraphConfigPort } from '../ports/GraphConfigPort'; +import type { GraphNode } from '../ports/types'; +import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; +import { GraphControls, type GraphFilterState } from './GraphControls'; +import { GraphOverlay } from './GraphOverlay'; +import { useGraphSimulation } from '../hooks/useGraphSimulation'; +import { useGraphCamera } from '../hooks/useGraphCamera'; +import { useGraphInteraction } from '../hooks/useGraphInteraction'; +import { findNodeAt } from '../canvas/hit-detection'; +import { ANIM_SPEED } from '../constants/canvas-constants'; + +export interface GraphViewProps { + data: GraphDataPort; + events?: GraphEventPort; + config?: Partial; + className?: string; + onRequestClose?: () => void; + onRequestPinAsTab?: () => void; +} + +export function GraphView({ + data, + events, + config, + className, + onRequestClose, + onRequestPinAsTab, +}: GraphViewProps): React.JSX.Element { + // ─── React state (user-facing only) ───────────────────────────────────── + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [filters, setFilters] = useState({ + showTasks: config?.showTasks ?? true, + showProcesses: config?.showProcesses ?? true, + showEdges: true, + paused: !(config?.animationEnabled ?? true), + }); + + // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change + const selectedNodeIdRef = useRef(null); + selectedNodeIdRef.current = selectedNodeId; + + const containerRef = useRef(null); + const canvasHandle = useRef(null); + const rafRef = useRef(0); + const lastTimeRef = useRef(0); + const runningRef = useRef(false); + + // ─── Hooks ────────────────────────────────────────────────────────────── + const simulation = useGraphSimulation(); + const camera = useGraphCamera(); + + // Stable refs for RAF loop (avoid recreating animate on hook identity change) + const simulationRef = useRef(simulation); + simulationRef.current = simulation; + const cameraRef = useRef(camera); + cameraRef.current = camera; + + const interaction = useGraphInteraction( + useCallback((nodeId: string, x: number, y: number) => { + const state = simulation.stateRef.current; + const node = state.nodes.find((n) => n.id === nodeId); + if (node) { + node.fx = x; + node.fy = y; + node.x = x; + node.y = y; + } + }, [simulation.stateRef]), + ); + + // ─── Sync data from adapter → simulation ──────────────────────────────── + useEffect(() => { + const filteredNodes = data.nodes.filter((n) => { + if (n.kind === 'task' && !filters.showTasks) return false; + if (n.kind === 'process' && !filters.showProcesses) return false; + return true; + }); + const filteredEdges = filters.showEdges + ? data.edges + : data.edges.filter((e) => e.type === 'parent-child'); + simulation.updateData(filteredNodes, filteredEdges, data.particles); + }, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]); + + // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── + const idleFrameSkip = useRef(0); + + const animate = useCallback(() => { + if (!runningRef.current) return; + + const now = performance.now() / 1000; + const dt = Math.min( + lastTimeRef.current > 0 ? now - lastTimeRef.current : ANIM_SPEED.defaultDeltaTime, + ANIM_SPEED.maxDeltaTime, + ); + lastTimeRef.current = now; + + // 1. Tick simulation + simulationRef.current.tick(dt); + + // 2. Update camera inertia + cameraRef.current.updateInertia(); + + // 3. Adaptive frame rate: skip every other frame when idle (no particles, no effects, sim settled) + const state = simulationRef.current.stateRef.current; + const isIdle = state.particles.length === 0 && state.effects.length === 0; + if (isIdle) { + idleFrameSkip.current++; + if (idleFrameSkip.current % 2 !== 0) { + rafRef.current = requestAnimationFrame(animate); + return; // skip draw, halve fps when idle + } + } else { + idleFrameSkip.current = 0; + } + + // 4. Draw canvas imperatively (NO React re-render) + canvasHandle.current?.draw({ + nodes: state.nodes, + edges: state.edges, + particles: state.particles, + effects: state.effects, + time: state.time, + camera: cameraRef.current.transformRef.current, + selectedNodeId: selectedNodeIdRef.current, + hoveredNodeId: interaction.hoveredNodeId.current, + }); + + rafRef.current = requestAnimationFrame(animate); + // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs + }, []); + + // Start/stop RAF + useEffect(() => { + if (!filters.paused) { + runningRef.current = true; + lastTimeRef.current = 0; + rafRef.current = requestAnimationFrame(animate); + } else { + runningRef.current = false; + cancelAnimationFrame(rafRef.current); + } + return () => { + runningRef.current = false; + cancelAnimationFrame(rafRef.current); + }; + }, [filters.paused, animate]); + + // ─── Auto-fit: center graph immediately when data arrives ────────────── + const hasAutoFit = useRef(false); + useEffect(() => { + if (data.nodes.length > 0 && !hasAutoFit.current) { + hasAutoFit.current = true; + // Immediate fit (simulation already settled from 120 pre-ticks) + const el = containerRef.current; + if (el) { + camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + } + // Second fit after mount stabilizes (ResizeObserver may fire late) + const timer = setTimeout(() => { + if (el) { + camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + } + }, 300); + return () => clearTimeout(timer); + } + }, [data.nodes.length, camera, simulation.stateRef]); + + // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ + const isPanningRef = useRef(false); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; // only left click + + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + + // Check if we hit a node + interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes); + + if (interaction.dragNodeId.current) { + // Hit a node → will drag it + isPanningRef.current = false; + } else { + // Hit empty space → pan + isPanningRef.current = true; + camera.handlePanStart(e.clientX, e.clientY); + } + }, [camera, interaction, simulation.stateRef]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + // Dragging with left button held + if (e.buttons & 1) { + if (isPanningRef.current) { + camera.handlePanMove(e.clientX, e.clientY); + return; + } + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes); + return; + } + + // No button held — hover detection + cursor update + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); + canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab'; + }, [camera, interaction, simulation.stateRef]); + + const handleMouseUp = useCallback(() => { + if (isPanningRef.current) { + camera.handlePanEnd(); + isPanningRef.current = false; + setSelectedNodeId(null); // hide popover after pan + return; + } + + const clickedId = interaction.handleMouseUp(); + if (clickedId) { + setSelectedNodeId(clickedId); + const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); + if (node) events?.onNodeClick?.(node.domainRef); + } else { + setSelectedNodeId(null); // click on empty space — hide popover + if (!interaction.isDragging.current) { + events?.onBackgroundClick?.(); + } + } + }, [interaction, simulation.stateRef, events, camera]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes); + if (nodeId) { + const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); + if (node) { + // Unpin if pinned (toggle) + if (node.fx != null) { + node.fx = null; + node.fy = null; + } + events?.onNodeDoubleClick?.(node.domainRef); + } + } + }, [camera, interaction, simulation.stateRef, events]); + + // ─── Keyboard ─────────────────────────────────────────────────────────── + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Don't capture from inputs + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + + if (e.key === 'Escape') { + if (selectedNodeId) { + setSelectedNodeId(null); + } else { + onRequestClose?.(); + } + } + if (e.key === 'f' || e.key === 'F') { + const el = containerRef.current; + if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + } + if (e.key === ' ') { + e.preventDefault(); + setFilters((f) => ({ ...f, paused: !f.paused })); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [selectedNodeId, onRequestClose, camera, simulation.stateRef]); + + // ─── Selected node for overlay ────────────────────────────────────────── + const selectedNode: GraphNode | null = + selectedNodeId + ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null + : null; + + // ─── Render ───────────────────────────────────────────────────────────── + return ( +
+ + + { + const el = containerRef.current; + if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + }} + onRequestClose={onRequestClose} + onRequestPinAsTab={onRequestPinAsTab} + teamName={data.teamName} + isAlive={data.isAlive} + /> + + setSelectedNodeId(null)} + /> +
+ ); +} diff --git a/packages/agent-graph/tsconfig.json b/packages/agent-graph/tsconfig.json new file mode 100644 index 00000000..4196ea0e --- /dev/null +++ b/packages/agent-graph/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 977139de..f81f1a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@claude-teams/agent-graph': + specifier: workspace:* + version: link:packages/agent-graph '@codemirror/autocomplete': specifier: ^6.20.0 version: 6.20.0 @@ -515,6 +518,22 @@ importers: specifier: ^3.1.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.0.2)(sass@1.98.0)(terser@5.46.0) + packages/agent-graph: + dependencies: + d3-force: + specifier: ^3.0.0 + version: 3.0.0 + react: + specifier: ^18.0.0 + version: 18.3.1 + react-dom: + specifier: ^18.0.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 + packages: 7zip-bin@5.2.0: @@ -9045,6 +9064,11 @@ packages: rc9@3.0.0: resolution: {integrity: sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -9111,6 +9135,10 @@ packages: '@types/react': optional: true + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -9377,6 +9405,9 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -21042,6 +21073,12 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -21121,6 +21158,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + react@19.2.4: {} read-binary-file-arch@1.0.6: @@ -21496,6 +21537,10 @@ snapshots: sax@1.6.0: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scheduler@0.27.0: {} scslre@0.3.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 73c2b7d9..e8e77a86 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,6 @@ packages: - agent-teams-controller - mcp-server - landing + - packages/agent-graph ignoredBuiltDependencies: - esbuild diff --git a/src/main/index.ts b/src/main/index.ts index e8378d75..524ae9ce 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import './sentry'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { SchedulerService } from '@main/services/schedule/SchedulerService'; +import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import { CrossTeamService } from '@main/services/team/CrossTeamService'; import { FileContentResolver } from '@main/services/team/FileContentResolver'; @@ -30,7 +31,6 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; -import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { CONTEXT_CHANGED, @@ -104,8 +104,8 @@ import { SshConnectionManager, TaskBoundaryParser, TeamDataService, - TeamMemberLogsFinder, TeamLogSourceTracker, + TeamMemberLogsFinder, TeamProvisioningService, UpdaterService, } from './services'; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c38aa3e1..cabe55b4 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -19,7 +19,6 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, - TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -27,6 +26,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index 89008836..ba23ebb5 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -9,13 +9,12 @@ import crypto from 'node:crypto'; import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; import type { PtySpawnOptions } from '@shared/types/terminal'; -import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; - import type { BrowserWindow } from 'electron'; const logger = createLogger('PtyTerminalService'); diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 170b0ca5..826b873e 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -17,9 +17,10 @@ import electronUpdater from 'electron-updater'; const { autoUpdater } = electronUpdater; +import { net } from 'electron'; + import type { UpdaterStatus } from '@shared/types'; import type { BrowserWindow } from 'electron'; -import { net } from 'electron'; const logger = createLogger('UpdaterService'); diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 116918a7..6a29c300 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -9,25 +9,26 @@ import { createHash } from 'crypto'; import { readFile, stat } from 'fs/promises'; import * as path from 'path'; -import { TaskChangeComputer } from './TaskChangeComputer'; -import { TaskChangeWorkerClient, getTaskChangeWorkerClient } from './TaskChangeWorkerClient'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; -import { TeamConfigReader } from './TeamConfigReader'; +import { TaskChangeComputer } from './TaskChangeComputer'; import { buildTaskChangePresenceDescriptor, computeTaskChangePresenceProjectFingerprint, normalizeTaskChangePresenceFilePath, } from './taskChangePresenceUtils'; +import { getTaskChangeWorkerClient } from './TaskChangeWorkerClient'; import { type ResolvedTaskChangeComputeInput, type TaskChangeEffectiveOptions, type TaskChangeTaskMeta, } from './taskChangeWorkerTypes'; +import { TeamConfigReader } from './TeamConfigReader'; -import type { TaskBoundaryParser } from './TaskBoundaryParser'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; -import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts index 893a9ee2..4d8cd308 100644 --- a/src/main/services/team/TaskChangeComputer.ts +++ b/src/main/services/team/TaskChangeComputer.ts @@ -3,10 +3,11 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import * as readline from 'readline'; -import { countLineChanges } from './UnifiedLineCounter'; import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils'; +import { countLineChanges } from './UnifiedLineCounter'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { AgentChangeSet, @@ -17,7 +18,6 @@ import type { TaskChangeScope, TaskChangeSetV2, } from '@shared/types'; -import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes'; const logger = createLogger('Service:TaskChangeComputer'); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 9db2a91c..f70070bf 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -30,6 +30,7 @@ import * as path from 'path'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; import { atomicWriteAsync } from './atomicWrite'; +import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; @@ -42,8 +43,10 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; -import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; +import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { AddMemberRequest, AttachmentMeta, @@ -56,6 +59,7 @@ import type { SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TaskRef, TeamConfig, @@ -66,15 +70,11 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, - TaskChangePresenceState, TeamTaskWithKanban, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; import type { AgentTeamsController } from 'agent-teams-controller'; -import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; -import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; -import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; const { createController } = agentTeamsControllerModule; @@ -243,8 +243,7 @@ export class TeamDataService { }); const presenceEntry = presenceIndex.entries[task.id]; result[task.id] = - presenceEntry && - presenceEntry.taskSignature === descriptor.taskSignature && + presenceEntry?.taskSignature === descriptor.taskSignature && presenceEntry.logSourceGeneration === logSourceSnapshot.logSourceGeneration ? presenceEntry.presence : 'unknown'; diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 863a8b81..64ad4880 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -216,7 +216,7 @@ export class TeamLogSourceTracker { const scheduleRecompute = (): void => { const current = this.stateByTeam.get(teamName); - if (!current || !current.desiredTracking) { + if (!current?.desiredTracking) { return; } if (current.refreshTimer) { diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts index 1d1df59b..0e573e5e 100644 --- a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -10,8 +10,8 @@ import { } from './taskChangePresenceCacheSchema'; import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes'; -import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes'; +import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; const logger = createLogger('Service:JsonTaskChangePresenceRepository'); diff --git a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts index 16c5f78b..b65af3e8 100644 --- a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts +++ b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts @@ -1,8 +1,8 @@ import { - TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, type PersistedTaskChangePresence, type PersistedTaskChangePresenceEntry, type PersistedTaskChangePresenceIndex, + TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, } from './taskChangePresenceCacheTypes'; function isIsoString(value: unknown): value is string { diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index e2df6084..75a6e638 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -16,8 +16,8 @@ export { TeamDataService } from './TeamDataService'; export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxWriter } from './TeamInboxWriter'; export { TeamKanbanManager } from './TeamKanbanManager'; -export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; export { TeamLogSourceTracker } from './TeamLogSourceTracker'; +export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMembersMetaStore } from './TeamMembersMetaStore'; export { TeamProvisioningService } from './TeamProvisioningService'; diff --git a/src/main/workers/task-change-worker.ts b/src/main/workers/task-change-worker.ts index 06f92042..77a6b119 100644 --- a/src/main/workers/task-change-worker.ts +++ b/src/main/workers/task-change-worker.ts @@ -18,7 +18,7 @@ function postMessage(message: TaskChangeWorkerResponse): void { } parentPort?.on('message', async (message: TaskChangeWorkerRequest) => { - if (!message || message.op !== 'computeTaskChanges') { + if (message?.op !== 'computeTaskChanges') { postMessage({ id: message?.id ?? 'unknown', ok: false, diff --git a/src/preload/index.ts b/src/preload/index.ts index df252ede..13b70812 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,7 +121,6 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, - TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -129,6 +128,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 39931ad9..1219722e 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -4,6 +4,7 @@ */ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; +import { TeamGraphTab } from '@renderer/features/agent-graph/ui/TeamGraphTab'; import { DashboardView } from '../dashboard/DashboardView'; import { ExtensionStoreView } from '../extensions/ExtensionStoreView'; @@ -65,6 +66,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { )} {tab.type === 'schedules' && } + {tab.type === 'graph' && ( + + + + )}
); })} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 6c22d0e4..0f34ad2a 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -17,6 +17,7 @@ import { Calendar, FileText, LayoutDashboard, + Network, Pin, Puzzle, Search, @@ -52,6 +53,7 @@ const TAB_ICONS = { report: Activity, extensions: Puzzle, schedules: Calendar, + graph: Network, } as const; export const SortableTab = ({ diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 875a1fa8..b1226889 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -42,6 +42,7 @@ import { FolderOpen, GitBranch, History, + Network, Pencil, Play, Plus, @@ -71,6 +72,11 @@ import type { AddMemberEntry } from './dialogs/AddMemberDialog'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); +const TeamGraphOverlay = lazy(() => + import('@renderer/features/agent-graph/ui/TeamGraphOverlay').then((m) => ({ + default: m.TeamGraphOverlay, + })) +); import { MemberList } from './members/MemberList'; import { MessagesPanel } from './messages/MessagesPanel'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; @@ -192,20 +198,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [editDialogOpen, setEditDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); - // Set inert on background content when editor overlay is open (a11y focus trap) + // Set inert on background content when editor/graph overlay is open (a11y focus trap) useEffect(() => { const el = contentRef.current; if (!el) return; - if (editorOpen) { + if (editorOpen || graphOpen) { el.setAttribute('inert', ''); } else { el.removeAttribute('inert'); } - }, [editorOpen]); + }, [editorOpen, graphOpen]); + + // Listen for Cmd+Shift+G keyboard shortcut + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + setGraphOpen((prev) => !prev); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [teamName]); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -1406,18 +1425,32 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} defaultOpen action={ - +
+ + +
} > )} + + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + /> + + )} ); }; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 108377d8..7831af44 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -90,8 +90,8 @@ import type { FileChangeSummary, KanbanTaskState, ResolvedTeamMember, - TaskChangeSetV2, TaskAttachmentMeta, + TaskChangeSetV2, TeamTaskWithKanban, } from '@shared/types'; diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index d4bb55d3..6c5e28ef 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -317,8 +317,7 @@ export const KanbanBoard = ({ ); const previous = stableTaskMapRef.current; if ( - previous && - previous.signatures.length === signatures.length && + previous?.signatures.length === signatures.length && previous.signatures.every((signature, index) => signature === signatures[index]) ) { return previous.map; diff --git a/src/renderer/features/CLAUDE.md b/src/renderer/features/CLAUDE.md new file mode 100644 index 00000000..50d59279 --- /dev/null +++ b/src/renderer/features/CLAUDE.md @@ -0,0 +1,494 @@ +# Features Directory — Architecture Guide + +All new renderer features live here. Each feature is a self-contained module following **Clean Architecture**, **SOLID**, and **class-based** patterns. + +--- + +## Quick Start + +```bash +mkdir -p src/renderer/features//{ports,adapters,domain,ui,hooks,__tests__} +``` + +--- + +## Directory Structure + +### Full Feature + +``` +src/renderer/features// + ├── ports/ # Interfaces (contracts) — NO implementations + │ ├── DataPort.ts # What data the feature needs (input) + │ ├── EventPort.ts # Callbacks the feature fires (output) + │ ├── ConfigPort.ts# Configuration / theme overrides + │ └── types.ts # Domain value types for this feature + │ + ├── adapters/ # Bridge between project infrastructure and feature + │ └── Adapter.ts # Zustand store → DataPort (ONLY place that imports store) + │ + ├── domain/ # Business logic — pure TS, no React, no UI + │ ├── models/ # Domain entities and value objects (classes) + │ └── services/ # Domain services and use cases (classes) + │ + ├── ui/ # React components — presentation only + │ ├── View.tsx # Main component (orchestrator, entry point) + │ ├── Overlay.tsx # Full-screen overlay variant (if applicable) + │ └── Tab.tsx # Tab wrapper variant (if applicable) + │ + ├── hooks/ # React hooks — thin bridges to domain classes + │ └── use.ts # Instantiates domain services, subscribes to store + │ + ├── __tests__/ # Tests colocated with feature + │ ├── adapters.test.ts # Adapter mapping correctness + │ ├── domain.test.ts # Domain logic unit tests + │ └── ports.test.ts # Port type validation + │ + └── index.ts # Public API barrel — exports ONLY from ui/ and ports/ +``` + +### Minimal Feature (no domain layer) + +Small features that don't need business logic: + +``` +src/renderer/features// + ├── Adapter.ts # Zustand → feature data + ├── View.tsx # Main component + └── index.ts # Public API +``` + +### When to Extract a Workspace Package + +Some features benefit from a separate `packages//` workspace package: + +| Keep in `features/` | Extract to `packages/` | +|---------------------|----------------------| +| Tightly coupled to our UI | Reusable in other projects | +| Uses our Zustand store | Framework-agnostic (only React peer dep) | +| Small (<500 LOC) | Large (>1000 LOC of core logic) | +| No external deps | Has its own dependencies (d3-force, etc.) | + +Example: `agent-graph` has BOTH: +- `packages/agent-graph/` — Canvas rendering, d3-force simulation (reusable, no project coupling) +- `features/agent-graph/` — Adapter + overlay + tab (thin integration, imports from store) + +--- + +## Real-World Example: agent-graph + +``` +features/agent-graph/ ← Integration layer (3 files) + ├── useTeamGraphAdapter.ts ← Adapter: TeamData → GraphDataPort + ├── TeamGraphOverlay.tsx ← UI: full-screen overlay + └── TeamGraphTab.tsx ← UI: tab wrapper + +packages/agent-graph/ ← Isolated package (34 files) + ├── src/ports/ ← GraphDataPort, GraphEventPort, types + ├── src/canvas/ ← Canvas 2D renderers + ├── src/strategies/ ← Strategy pattern per node kind + ├── src/hooks/ ← Simulation, camera, interaction + └── src/components/ ← GraphView, GraphCanvas, Controls +``` + +The adapter (`useTeamGraphAdapter.ts`) is the **only file** that imports from `@renderer/store`. Everything else depends only on port interfaces. + +--- + +## SOLID Principles + +### S — Single Responsibility + +Each layer has exactly one reason to change: + +| Layer | Changes when... | Does NOT change when... | +|-------|----------------|------------------------| +| `ports/` | Feature contract changes | Store structure changes | +| `adapters/` | Store data model changes | Canvas rendering changes | +| `domain/` | Business rules change | React version updates | +| `ui/` | UX/layout changes | Data mapping changes | + +### O — Open-Closed + +Extend via new classes, never modify existing ones: + +```typescript +// ✅ New node kind = new class, zero changes to existing code +class ReviewNodeRenderer implements NodeRenderer { ... } + +// Register it — the registry and canvas loop don't change +NodeRendererRegistry.register(new ReviewNodeRenderer()); +``` + +### L — Liskov Substitution + +Any implementation of a port can replace another without breaking the feature: + +```typescript +// Both adapters satisfy GraphDataPort — feature works with either +class LiveTeamAdapter implements GraphDataPort { ... } // Real-time Zustand data +class MockTeamAdapter implements GraphDataPort { ... } // Static test data +class ReplayTeamAdapter implements GraphDataPort { ... } // Recorded session playback + +// Feature doesn't know or care which one it gets +const view = ; +``` + +### I — Interface Segregation + +Split ports by consumer. Each consumer depends only on what it needs: + +```typescript +// ✅ Three small ports +interface GraphDataPort { nodes: GraphNode[]; edges: GraphEdge[]; } +interface GraphEventPort { onNodeClick?(ref: DomainRef): void; } +interface GraphConfigPort { bloomIntensity?: number; showTasks?: boolean; } + +// ❌ One massive interface — forces every consumer to know about everything +interface GraphPort { + nodes: GraphNode[]; edges: GraphEdge[]; + onNodeClick?(ref: DomainRef): void; + bloomIntensity?: number; showTasks?: boolean; +} +``` + +### D — Dependency Inversion + +High-level modules (feature UI) depend on abstractions (ports), not on low-level modules (Zustand store). + +``` +UI → depends on → Port interface ← implemented by ← Adapter → depends on → Store + +Feature code never touches the store. The adapter translates in both directions. +``` + +--- + +## Class-Based Patterns + +Prefer **classes** over functions for domain logic, services, adapters, and stateful code. Use the **latest ECMAScript class features** (ES2024+). + +### Modern Class Syntax + +```typescript +class TeamGraphAdapter implements GraphDataPort { + // ─── ES private fields (NOT TypeScript `private`) ───────────── + readonly #store: StoreApi; + #cachedNodes: GraphNode[] = []; + #lastTeamName = ''; + + // ─── Static factory (prefer for complex initialization) ─────── + static create(store: StoreApi): TeamGraphAdapter { + return new TeamGraphAdapter(store); + } + + // ─── Constructor with DI ────────────────────────────────────── + constructor(store: StoreApi) { + this.#store = store; + } + + // ─── Accessors (get/set) ────────────────────────────────────── + get nodes(): readonly GraphNode[] { + return this.#cachedNodes; + } + + // ─── Public method (port contract) ──────────────────────────── + adapt(teamData: TeamData): GraphDataPort { + if (teamData.teamName === this.#lastTeamName) return this; + this.#lastTeamName = teamData.teamName; + this.#cachedNodes = this.#buildNodes(teamData); + return this; + } + + // ─── ES private method ──────────────────────────────────────── + #buildNodes(data: TeamData): GraphNode[] { + return data.members.map(m => ({ id: m.name, kind: 'member', ... })); + } + + // ─── Disposable (cleanup) ───────────────────────────────────── + [Symbol.dispose](): void { + this.#cachedNodes = []; + } +} +``` + +### Key Rules + +| Rule | Do | Don't | +|------|-----|-------| +| Private fields | `#field` (ES private) | `private field` (TS keyword) | +| Private methods | `#method()` | `private method()` | +| Readonly fields | `readonly #field` | Mutable when immutability intended | +| Static factory | `static create()` | Complex constructor logic | +| Disposal | `[Symbol.dispose]()` or `dispose()` | Forgetting cleanup | +| Type narrowing | `instanceof` checks | `as` casts | + +### When to Use Classes vs Functions + +| Use Case | Pattern | Why | +|----------|---------|-----| +| Domain models with state | **Class** | Encapsulation, lifecycle | +| Adapters (data mapping) | **Class** with caching | State for memoization | +| Services (business logic) | **Class** with DI | Testable, injectable | +| Canvas renderers | **Class** implementing strategy | Polymorphism | +| React components | **Function component** | React requires it | +| React hooks | **Function** | React requires it | +| Pure stateless utilities | **Function** | Simpler, no overhead | +| Constants | `as const` object | Immutable | + +### Dependency Injection + +Always inject dependencies through the constructor: + +```typescript +class FeatureService { + readonly #data: FeatureDataPort; + readonly #events: FeatureEventPort; + + constructor(data: FeatureDataPort, events: FeatureEventPort) { + this.#data = data; + this.#events = events; + } + + execute(): void { + const result = this.#data.getNodes(); + this.#events.onResult?.(result); + } +} + +// Wiring in a hook: +function useFeature(): FeatureService { + const adapter = useMemo(() => FeatureAdapter.create(store), [store]); + return useMemo(() => new FeatureService(adapter, eventHandler), [adapter]); +} +``` + +### Strategy Pattern + +```typescript +interface NodeRenderer { + readonly kind: string; + draw(ctx: CanvasRenderingContext2D, node: Node): void; + hitTest(node: Node, x: number, y: number): boolean; +} + +class MemberNodeRenderer implements NodeRenderer { + readonly kind = 'member'; + draw(ctx: CanvasRenderingContext2D, node: Node): void { /* ... */ } + hitTest(node: Node, x: number, y: number): boolean { /* ... */ } +} + +class NodeRendererRegistry { + readonly #renderers = new Map(); + + register(renderer: NodeRenderer): this { + this.#renderers.set(renderer.kind, renderer); + return this; + } + + get(kind: string): NodeRenderer | undefined { + return this.#renderers.get(kind); + } +} + +// Usage: +const registry = new NodeRendererRegistry() + .register(new MemberNodeRenderer()) + .register(new TaskNodeRenderer()); +``` + +--- + +## Error Handling + +```typescript +// Domain errors — typed, not string messages +class FeatureError extends Error { + constructor( + readonly code: 'INVALID_DATA' | 'RENDER_FAILED' | 'ADAPTER_ERROR', + message: string, + readonly cause?: unknown, + ) { + super(message); + this.name = 'FeatureError'; + } +} + +// In adapters — catch and wrap external errors +class FeatureAdapter { + adapt(data: unknown): FeatureDataPort { + try { + return this.#transform(data); + } catch (err) { + throw new FeatureError('ADAPTER_ERROR', 'Failed to adapt data', err); + } + } +} + +// In UI — catch at boundary, show fallback +function FeatureView({ data }: Props) { + // React error boundary or try/catch in event handlers + // Never let feature errors crash the host app +} +``` + +--- + +## Inter-Feature Communication + +Features MUST NOT import from each other directly. If two features need to share data: + +``` +Feature A → emits event → Host app (TeamDetailView) → passes data → Feature B +``` + +Pattern: use `CustomEvent` on `window` (same as keyboard shortcuts): + +```typescript +// Feature A fires: +window.dispatchEvent(new CustomEvent('feature-a:data-ready', { detail: { ... } })); + +// Host app listens and passes to Feature B via props/ports +``` + +--- + +## Testing + +Tests live in `__tests__/` inside the feature directory. + +```typescript +// __tests__/adapters.test.ts — test data mapping +describe('FeatureAdapter', () => { + it('maps TeamData members to GraphNodes', () => { + const adapter = new FeatureAdapter(); + const result = adapter.adapt(mockTeamData); + expect(result.nodes).toHaveLength(3); + expect(result.nodes[0].kind).toBe('lead'); + }); +}); + +// __tests__/domain.test.ts — test business logic +describe('SimulationService', () => { + it('applies orbit force to task nodes', () => { + const service = new SimulationService(mockConfig); + service.tick(0.016); + expect(service.nodes[0].x).toBeDefined(); + }); +}); +``` + +Run: `pnpm test -- --testPathPattern=features/` + +--- + +## Integration with Main App + +Features connect through minimal **registration points** in shared files: + +### Tab Registration (3 files) + +```typescript +// 1. src/renderer/types/tabs.ts — add to union +type: '...' | ''; + +// 2. src/renderer/components/layout/PaneContent.tsx — add route +{tab.type === '' && ( + + + +)} + +// 3. src/renderer/components/layout/SortableTab.tsx — add icon +: SomeIcon, +``` + +### Overlay Registration (1 file) + +```typescript +// In host component (e.g., TeamDetailView.tsx): +const FeatureOverlay = lazy(() => + import('@renderer/features//ui/FeatureOverlay') + .then(m => ({ default: m.FeatureOverlay })) +); +``` + +### Keyboard Shortcut (1 file) + +```typescript +// In useKeyboardShortcuts.ts: +if (key === '' && event.shiftKey && !event.altKey) { + window.dispatchEvent(new CustomEvent('toggle-', { detail })); +} +``` + +--- + +## Naming Conventions + +| Entity | Convention | Example | +|--------|-----------|---------| +| Feature directory | `kebab-case` | `agent-graph/` | +| Port interfaces | `PascalCase` + `Port` suffix | `GraphDataPort` | +| Domain classes | `PascalCase` | `SimulationService` | +| Adapter classes | `PascalCase` + `Adapter` suffix | `TeamGraphAdapter` | +| UI components | `PascalCase` | `GraphView`, `GraphOverlay` | +| Hooks | `camelCase` + `use` prefix | `useTeamGraphAdapter` | +| Test files | `.test.ts` | `adapters.test.ts` | +| Type files | `camelCase` or `types.ts` | `types.ts` | +| Barrel | `index.ts` | `index.ts` | + +--- + +## Existing Features + +| Feature | Path | Companion Package | Description | +|---------|------|-------------------|-------------| +| `agent-graph` | `features/agent-graph/` | `packages/agent-graph/` | Force-directed graph visualization | + +--- + +## Anti-Patterns + +```typescript +// ❌ Feature imports from another feature +import { X } from '@renderer/features/other-feature/X'; + +// ❌ UI component imports store directly (only adapters may) +import { useStore } from '@renderer/store'; + +// ❌ Feature imports from @renderer/components/* +import { KanbanBoard } from '@renderer/components/team/kanban/KanbanBoard'; + +// ❌ TypeScript `private` instead of ES #private +class Bad { private field = 1; } // Use: #field = 1; + +// ❌ Mutable global state +let globalCache = {}; + +// ❌ `any` or `as any` +const data = response as any; + +// ❌ God-class with mixed responsibilities +class FeatureManager { + fetchData() { ... } + renderUI() { ... } + handleClick() { ... } + saveToStorage() { ... } +} +``` + +--- + +## Checklist for New Feature PR + +- [ ] Feature lives in `src/renderer/features//` +- [ ] Port interfaces defined (`DataPort`, `EventPort` at minimum) +- [ ] Adapter is the ONLY file importing from `@renderer/store` +- [ ] No cross-feature imports +- [ ] Classes use ES `#private` fields, not TypeScript `private` +- [ ] `index.ts` exports only public API (ui components + port types) +- [ ] Integration points documented (which shared files were modified) +- [ ] Tests in `__tests__/` for adapter and domain logic +- [ ] Typecheck passes: `pnpm typecheck` +- [ ] Build passes: `pnpm build` diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts new file mode 100644 index 00000000..1d2291ad --- /dev/null +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -0,0 +1,399 @@ +/** + * TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort. + * + * This is the ONLY file in this feature that imports from @renderer/store. + * If the project data model changes, ONLY this class needs updating. + * + * Class-based with ES #private fields, caching, and DI-ready constructor. + */ + +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + GraphDataPort, + GraphEdge, + GraphNode, + GraphNodeState, + GraphParticle, +} from '@claude-teams/agent-graph'; +import type { InboxMessage, MemberSpawnStatusEntry, TeamData } from '@shared/types/team'; +import type { LeadContextUsage } from '@shared/types/team'; + +export class TeamGraphAdapter { + // ─── ES #private fields ────────────────────────────────────────────────── + #lastTeamName = ''; + #lastDataHash = ''; + #cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult(''); + readonly #seenRelated = new Set(); + readonly #seenMessageIds = new Set(); + #initialMessagesSeen = false; + + // ─── Static factory ────────────────────────────────────────────────────── + static create(): TeamGraphAdapter { + return new TeamGraphAdapter(); + } + + static #emptyResult(teamName: string): GraphDataPort { + return { nodes: [], edges: [], particles: [], teamName, isAlive: false }; + } + + // ─── Public API ────────────────────────────────────────────────────────── + + /** + * Adapt team data into a GraphDataPort snapshot. + * Returns cached result if inputs haven't changed (referential check). + */ + adapt( + teamData: TeamData | null, + teamName: string, + spawnStatuses?: Record, + leadContext?: LeadContextUsage + ): GraphDataPort { + if (teamData?.teamName !== teamName) { + return TeamGraphAdapter.#emptyResult(teamName); + } + + // Simple hash for change detection (avoids full deep equality) + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}`; + if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { + return this.#cachedResult; + } + + // Reset particle tracking when team changes + if (teamName !== this.#lastTeamName) { + this.#seenMessageIds.clear(); + this.#initialMessagesSeen = false; + } + + this.#lastTeamName = teamName; + this.#lastDataHash = hash; + this.#seenRelated.clear(); + + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const particles: GraphParticle[] = []; + + const leadId = `lead:${teamName}`; + + this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext); + this.#buildMemberNodes(nodes, edges, leadId, teamData, teamName, spawnStatuses); + this.#buildTaskNodes(nodes, edges, teamData, teamName); + this.#buildProcessNodes(nodes, edges, teamData, teamName); + this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges); + + this.#cachedResult = { + nodes, + edges, + particles, + teamName, + teamColor: teamData.config.color ?? undefined, + isAlive: teamData.isAlive, + }; + + return this.#cachedResult; + } + + // ─── Disposal ──────────────────────────────────────────────────────────── + + [Symbol.dispose](): void { + this.#cachedResult = TeamGraphAdapter.#emptyResult(''); + this.#seenRelated.clear(); + this.#seenMessageIds.clear(); + this.#initialMessagesSeen = false; + this.#lastDataHash = ''; + } + + // ─── Private: node builders ────────────────────────────────────────────── + + #buildLeadNode( + nodes: GraphNode[], + leadId: string, + data: TeamData, + teamName: string, + leadContext?: LeadContextUsage + ): void { + const percent = leadContext?.percent; + nodes.push({ + id: leadId, + kind: 'lead', + label: data.config.name || teamName, + state: data.isAlive ? 'active' : 'idle', + color: data.config.color ?? undefined, + contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, + domainRef: { kind: 'lead', teamName }, + }); + } + + #buildMemberNodes( + nodes: GraphNode[], + edges: GraphEdge[], + leadId: string, + data: TeamData, + teamName: string, + spawnStatuses?: Record + ): void { + for (const member of data.members) { + if (member.removedAt) continue; + if (isLeadMember(member)) continue; + + const memberId = `member:${teamName}:${member.name}`; + const spawn = spawnStatuses?.[member.name]; + + nodes.push({ + id: memberId, + kind: 'member', + label: member.name, + state: TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), + color: member.color ?? undefined, + role: member.role ?? undefined, + spawnStatus: spawn?.status, + domainRef: { kind: 'member', teamName, memberName: member.name }, + }); + + edges.push({ + id: `edge:parent:${leadId}:${memberId}`, + source: leadId, + target: memberId, + type: 'parent-child', + }); + } + } + + #buildTaskNodes(nodes: GraphNode[], edges: GraphEdge[], data: TeamData, teamName: string): void { + for (const task of data.tasks) { + if (task.status === 'deleted') continue; + const taskId = `task:${teamName}:${task.id}`; + const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + + nodes.push({ + id: taskId, + kind: 'task', + label: task.displayId ?? `#${task.id.slice(0, 6)}`, + sublabel: task.subject, + state: TeamGraphAdapter.#mapTaskStatus(task.status), + taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status), + reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState), + displayId: task.displayId ?? undefined, + ownerId: ownerMemberId, + needsClarification: task.needsClarification ?? null, + domainRef: { kind: 'task', teamName, taskId: task.id }, + }); + + if (ownerMemberId) { + edges.push({ + id: `edge:own:${ownerMemberId}:${taskId}`, + source: ownerMemberId, + target: taskId, + type: 'ownership', + }); + } + + const seenBlockEdges = new Set(); + for (const blockedById of task.blockedBy ?? []) { + const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`; + if (seenBlockEdges.has(edgeId)) continue; + seenBlockEdges.add(edgeId); + edges.push({ + id: edgeId, + source: `task:${teamName}:${blockedById}`, + target: taskId, + type: 'blocking', + }); + } + + for (const blocksId of task.blocks ?? []) { + const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`; + if (seenBlockEdges.has(edgeId)) continue; + seenBlockEdges.add(edgeId); + edges.push({ + id: edgeId, + source: taskId, + target: `task:${teamName}:${blocksId}`, + type: 'blocking', + }); + } + + for (const relatedId of task.related ?? []) { + const key = [task.id, relatedId].sort().join(':'); + if (this.#seenRelated.has(key)) continue; + this.#seenRelated.add(key); + edges.push({ + id: `edge:rel:${key}`, + source: taskId, + target: `task:${teamName}:${relatedId}`, + type: 'related', + }); + } + } + } + + #buildProcessNodes( + nodes: GraphNode[], + edges: GraphEdge[], + data: TeamData, + teamName: string + ): void { + for (const proc of data.processes) { + if (proc.stoppedAt) continue; + const procId = `process:${teamName}:${proc.id}`; + const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null; + + nodes.push({ + id: procId, + kind: 'process', + label: proc.label, + state: 'active', + processUrl: proc.url ?? undefined, + domainRef: { kind: 'process', teamName, processId: proc.id }, + }); + + if (ownerId) { + edges.push({ + id: `edge:proc:${ownerId}:${procId}`, + source: ownerId, + target: procId, + type: 'ownership', + }); + } + } + } + + #buildMessageParticles( + particles: GraphParticle[], + messages: readonly InboxMessage[], + teamName: string, + leadId: string, + edges: GraphEdge[] + ): void { + const recent = messages.slice(-20); + + // First call: record all existing message IDs without creating particles. + // This prevents old messages from spawning particles when the graph opens. + if (!this.#initialMessagesSeen) { + this.#initialMessagesSeen = true; + for (const msg of recent) { + const msgKey = msg.messageId ?? msg.timestamp; + this.#seenMessageIds.add(msgKey); + } + return; + } + + // Subsequent calls: only create particles for messages not yet seen. + for (const msg of recent) { + const msgKey = msg.messageId ?? msg.timestamp; + if (this.#seenMessageIds.has(msgKey)) continue; + this.#seenMessageIds.add(msgKey); + + const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, edges); + if (!edgeId) continue; + + const ts = typeof msg.timestamp === 'string' ? new Date(msg.timestamp).getTime() : 0; + particles.push({ + id: `particle:msg:${msgKey}`, + edgeId, + progress: (ts % 800) / 1000, + kind: 'message', + color: msg.color ?? '#66ccff', + label: msg.summary ?? undefined, + }); + } + } + + // ─── Static mappers ────────────────────────────────────────────────────── + + static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState { + if (spawnStatus === 'spawning') return 'thinking'; + if (spawnStatus === 'error') return 'error'; + if (spawnStatus === 'waiting') return 'waiting'; + switch (status) { + case 'active': + return 'active'; + case 'idle': + return 'idle'; + case 'terminated': + return 'terminated'; + default: + return 'idle'; + } + } + + static #mapTaskStatus(status: string): GraphNodeState { + switch (status) { + case 'pending': + return 'waiting'; + case 'in_progress': + return 'active'; + case 'completed': + return 'complete'; + default: + return 'idle'; + } + } + + static #mapTaskStatusLiteral( + status: string + ): 'pending' | 'in_progress' | 'completed' | 'deleted' { + switch (status) { + case 'pending': + return 'pending'; + case 'in_progress': + return 'in_progress'; + case 'completed': + return 'completed'; + case 'deleted': + return 'deleted'; + default: + return 'pending'; + } + } + + static #mapReviewState(state: string | undefined): 'none' | 'review' | 'needsFix' | 'approved' { + switch (state) { + case 'review': + return 'review'; + case 'needsFix': + return 'needsFix'; + case 'approved': + return 'approved'; + default: + return 'none'; + } + } + + static #resolveMessageEdge( + msg: InboxMessage, + teamName: string, + leadId: string, + edges: GraphEdge[] + ): string | null { + const { from, to } = msg; + + if (from && to) { + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId); + const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId); + return ( + edges.find((e) => e.source === fromId && e.target === toId)?.id ?? + edges.find((e) => e.source === toId && e.target === fromId)?.id ?? + null + ); + } + + if (from && !to) { + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId); + return ( + edges.find( + (e) => + (e.source === leadId && e.target === fromId) || + (e.source === fromId && e.target === leadId) + )?.id ?? null + ); + } + + return null; + } + + static #resolveParticipantId(name: string, teamName: string, leadId: string): string { + if (name === 'user' || name === 'team-lead') return leadId; + return `member:${teamName}:${name}`; + } +} diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts new file mode 100644 index 00000000..ec11d302 --- /dev/null +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -0,0 +1,30 @@ +/** + * React hook bridge for TeamGraphAdapter class. + * Thin wrapper — instantiates the class adapter and calls adapt() with store data. + */ + +import { useMemo, useRef } from 'react'; + +import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; + +import { TeamGraphAdapter } from './TeamGraphAdapter'; + +import type { GraphDataPort } from '@claude-teams/agent-graph'; + +export function useTeamGraphAdapter(teamName: string): GraphDataPort { + const adapterRef = useRef(TeamGraphAdapter.create()); + + const { teamData, spawnStatuses, leadContext } = useStore( + useShallow((s) => ({ + teamData: s.selectedTeamData, + spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, + leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, + })) + ); + + return useMemo( + () => adapterRef.current.adapt(teamData, teamName, spawnStatuses, leadContext), + [teamData, teamName, spawnStatuses, leadContext] + ); +} diff --git a/src/renderer/features/agent-graph/index.ts b/src/renderer/features/agent-graph/index.ts new file mode 100644 index 00000000..1c15bd65 --- /dev/null +++ b/src/renderer/features/agent-graph/index.ts @@ -0,0 +1,9 @@ +/** + * agent-graph feature — public API. + * Only exports UI components and adapter types. + */ + +export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; +export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay'; +export { TeamGraphOverlay } from './ui/TeamGraphOverlay'; +export { TeamGraphTab } from './ui/TeamGraphTab'; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx new file mode 100644 index 00000000..5e7f7aca --- /dev/null +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -0,0 +1,57 @@ +/** + * TeamGraphOverlay — full-screen overlay showing the agent graph. + * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). + */ + +import { useCallback } from 'react'; + +import { GraphView } from '@claude-teams/agent-graph'; + +import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + +import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; + +export interface TeamGraphOverlayProps { + teamName: string; + onClose: () => void; + onPinAsTab?: () => void; +} + +export const TeamGraphOverlay = ({ + teamName, + onClose, + onPinAsTab, +}: TeamGraphOverlayProps): React.JSX.Element => { + const graphData = useTeamGraphAdapter(teamName); + + const events: GraphEventPort = { + onNodeClick: useCallback((_ref: GraphDomainRef) => { + // Popover shown by GraphView internally + }, []), + onNodeDoubleClick: useCallback((ref: GraphDomainRef) => { + // TODO: open TaskDetailDialog or MemberDetailDialog based on ref.kind + console.log('Double-click:', ref); + }, []), + onSendMessage: useCallback((_memberName: string, _teamName: string) => { + // TODO: open SendMessageDialog + }, []), + onOpenTaskDetail: useCallback((_taskId: string, _teamName: string) => { + // TODO: open TaskDetailDialog + }, []), + onBackgroundClick: useCallback(() => { + // Deselect handled by GraphView + }, []), + }; + + return ( +
+ +
+ ); +}; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx new file mode 100644 index 00000000..3238ea49 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -0,0 +1,31 @@ +/** + * TeamGraphTab — wraps GraphView for use as a dedicated tab. + */ + +import { useCallback } from 'react'; + +import { GraphView } from '@claude-teams/agent-graph'; + +import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + +import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; + +export interface TeamGraphTabProps { + teamName: string; +} + +export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element => { + const graphData = useTeamGraphAdapter(teamName); + + const events: GraphEventPort = { + onNodeDoubleClick: useCallback((ref: GraphDomainRef) => { + console.log('Double-click in tab:', ref); + }, []), + }; + + return ( +
+ +
+ ); +}; diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index ae763dd1..007a01aa 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -174,6 +174,18 @@ export function useKeyboardShortcuts(): void { return; } + // Cmd+Shift+G: Toggle team graph overlay + if (key === 'g' && event.shiftKey && !event.altKey) { + event.preventDefault(); + const activeTab = openTabs.find((t) => t.id === activeTabId); + if (activeTab?.type === 'team' && activeTab.teamName) { + window.dispatchEvent( + new CustomEvent('toggle-team-graph', { detail: { teamName: activeTab.teamName } }) + ); + } + return; + } + // Cmd+W: Close selected tabs (if multi-selected) or active tab if (key === 'w' && !event.altKey) { event.preventDefault(); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 418a64e8..3e7e388c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -180,8 +180,7 @@ export function initializeNotificationListeners(): () => void { const selectedTeamData = state.selectedTeamData; if ( !selectedTeamName || - !selectedTeamData || - selectedTeamData.teamName !== selectedTeamName || + selectedTeamData?.teamName !== selectedTeamName || !isTeamVisibleInAnyPane(selectedTeamName) ) { return; @@ -210,15 +209,14 @@ export function initializeNotificationListeners(): () => void { const current = useStore.getState(); if ( current.selectedTeamName !== selectedTeamName || - !current.selectedTeamData || - current.selectedTeamData.teamName !== selectedTeamName || + current.selectedTeamData?.teamName !== selectedTeamName || !isTeamVisibleInAnyPane(selectedTeamName) ) { return; } const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id); - if (!currentTask || currentTask.status !== 'in_progress') { + if (currentTask?.status !== 'in_progress') { return; } @@ -342,8 +340,7 @@ export function initializeNotificationListeners(): () => void { const { selectedTeamName, selectedTeamData } = useStore.getState(); if ( !selectedTeamName || - !selectedTeamData || - selectedTeamData.teamName !== selectedTeamName || + selectedTeamData?.teamName !== selectedTeamName || !isTeamVisibleInAnyPane(selectedTeamName) ) { return new Set(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 05d0c52c..2a452f6e 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -141,8 +141,8 @@ import type { MemberSpawnStatusEntry, SendMessageRequest, SendMessageResult, - TaskComment, TaskChangePresenceState, + TaskComment, TeamCreateRequest, TeamData, TeamLaunchRequest, diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 65ffe18c..f8ebc7a2 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -85,7 +85,8 @@ export interface Tab { | 'team' | 'report' | 'extensions' - | 'schedules'; + | 'schedules' + | 'graph'; /** Session ID (required when type === 'session') */ sessionId?: string; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 88aa7202..e0b9c277 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -55,6 +55,7 @@ import type { SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TeamChangeEvent, TeamClaudeLogsQuery, @@ -73,7 +74,6 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, - TaskChangePresenceState, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index d367a286..30a4bb7e 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -150,7 +150,7 @@ describe('CliInstallerService', () => { const mockWindow = { isDestroyed: () => false, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => false }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); @@ -177,7 +177,7 @@ describe('CliInstallerService', () => { const mockWindow = { isDestroyed: () => false, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => false }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); @@ -206,7 +206,7 @@ describe('CliInstallerService', () => { it('accepts a BrowserWindow instance', () => { const mockWindow = { isDestroyed: () => false, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => false }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); expect(true).toBe(true); @@ -425,7 +425,7 @@ describe('CliInstallerService', () => { const mockWindow = { isDestroyed: () => true, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => true }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); From 17e9be99dda3bf4280f323d557422ec8b160c747 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:23:28 +0200 Subject: [PATCH 034/113] fix(ui): change auto-approve banner from warning to info style --- .../team/dialogs/SkipPermissionsCheckbox.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx index d71e2974..40c8bdf2 100644 --- a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx +++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Label } from '@renderer/components/ui/label'; -import { AlertTriangle, Info } from 'lucide-react'; +import { Info } from 'lucide-react'; interface SkipPermissionsCheckboxProps { id: string; @@ -33,13 +33,13 @@ export const SkipPermissionsCheckbox: React.FC = (
- +

Unleash Claude's full power — no interruptions asking for permission. Autonomous mode — all tools execute without confirmation. Be cautious with untrusted code. From 6866f003dce7ddcec660336beb2fe06602a0f4b9 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:27:37 +0200 Subject: [PATCH 035/113] feat(graph): spawn/edge/task/comment animations 1. Member spawn animation: rotating dashed ring for 'spawning' status, pulsing hex outline for 'waiting' status 2. Edge flash: active edges pulse brighter (0.1-0.5 alpha) with glow shadow when particles travel along them 3. Smooth task positioning: tasks LERP to target position (0.15 factor) instead of teleporting when kanban column changes 4. Comment flight particles: when a member adds a comment to a task, a purple particle with speech bubble flies from member to task node --- .../agent-graph/src/canvas/draw-agents.ts | 31 +++++++++- packages/agent-graph/src/canvas/draw-edges.ts | 11 +++- .../agent-graph/src/layout/kanbanLayout.ts | 13 ++-- .../agent-graph/adapters/TeamGraphAdapter.ts | 61 ++++++++++++++++++- 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 5c97774b..6a4d4ed8 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -44,8 +44,8 @@ export function drawAgents( // Hexagonal body with interior fill drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered); - // Breathing animation - drawBreathing(ctx, x, y, r, node.state, time); + // Breathing animation + spawn/waiting effects + drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); // Name label drawLabel(ctx, x, y, r, node.label, color); @@ -150,7 +150,34 @@ function drawBreathing( r: number, state: string, time: number, + spawnStatus?: GraphNode['spawnStatus'], ): void { + // Spawning: rotating dashed ring (loading spinner) + if (spawnStatus === 'spawning') { + const ringR = r + AGENT_DRAW.orbitParticleOffset; + const rotation = time * ANIM.orbitSpeed * 2; + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.4); + ctx.strokeStyle = COLORS.holoBase + alphaHex(0.5); + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + return; + } + + // Waiting: pulsing hex outline (breathing border) + if (spawnStatus === 'waiting') { + const pulse = 0.12 + 0.12 * Math.sin(time * AGENT_DRAW.waitingBreatheSpeed); + drawHexagon(ctx, x, y, r + AGENT_DRAW.outerRingOffset); + ctx.strokeStyle = COLORS.holoBase + alphaHex(pulse); + ctx.lineWidth = 1.5; + ctx.stroke(); + return; + } + const isActive = state === 'active' || state === 'thinking' || state === 'tool_calling'; const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed; const amp = isActive ? ANIM.breathe.activeAmp : ANIM.breathe.idleAmp; diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts index 7b764797..9982168b 100644 --- a/packages/agent-graph/src/canvas/draw-edges.ts +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -83,7 +83,10 @@ export function drawEdges( const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; const isActive = hasActiveParticles.has(edge.id); - const alpha = isActive ? BEAM.activeAlpha : BEAM.idleAlpha; + // Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave + const alpha = isActive + ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) + : BEAM.idleAlpha; if (alpha < MIN_VISIBLE_OPACITY) continue; @@ -92,6 +95,12 @@ export function drawEdges( ctx.save(); ctx.globalAlpha = alpha; + // Subtle glow pass when edge has active particles + if (isActive) { + ctx.shadowColor = edge.color ?? style.color; + ctx.shadowBlur = 12; + } + // Draw tapered bezier drawTaperedBezier( ctx, diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 6528c542..b49b8b85 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -87,8 +87,11 @@ export class KanbanLayoutEngine { task.fy = task.y; continue; } - task.x = baseX + colIdx * columnWidth; - task.y = baseY + rowIdx * rowHeight; + const targetX = baseX + colIdx * columnWidth; + const targetY = baseY + rowIdx * rowHeight; + // Smooth slide: LERP toward target; instant on first appearance + task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; task.fy = task.y; task.vx = 0; @@ -120,8 +123,10 @@ export class KanbanLayoutEngine { static #layoutUnassigned(tasks: GraphNode[]): void { const { columnWidth, rowHeight } = KANBAN_ZONE; for (const [idx, task] of tasks.entries()) { - task.x = -400 + (idx % 3) * columnWidth; - task.y = 400 + Math.floor(idx / 3) * rowHeight; + const targetX = -400 + (idx % 3) * columnWidth; + const targetY = 400 + Math.floor(idx / 3) * rowHeight; + task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; task.fy = task.y; task.vx = 0; diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 1d2291ad..52eaebc6 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -27,6 +27,8 @@ export class TeamGraphAdapter { readonly #seenRelated = new Set(); readonly #seenMessageIds = new Set(); #initialMessagesSeen = false; + readonly #seenCommentCounts = new Map(); + #initialCommentsSeen = false; // ─── Static factory ────────────────────────────────────────────────────── static create(): TeamGraphAdapter { @@ -54,7 +56,8 @@ export class TeamGraphAdapter { } // Simple hash for change detection (avoids full deep equality) - const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}`; + const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0); + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}`; if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { return this.#cachedResult; } @@ -63,6 +66,8 @@ export class TeamGraphAdapter { if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#seenCommentCounts.clear(); + this.#initialCommentsSeen = false; } this.#lastTeamName = teamName; @@ -80,6 +85,7 @@ export class TeamGraphAdapter { this.#buildTaskNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName); this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges); + this.#buildCommentParticles(particles, teamData, teamName, edges); this.#cachedResult = { nodes, @@ -100,6 +106,8 @@ export class TeamGraphAdapter { this.#seenRelated.clear(); this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#seenCommentCounts.clear(); + this.#initialCommentsSeen = false; this.#lastDataHash = ''; } @@ -299,6 +307,57 @@ export class TeamGraphAdapter { } } + #buildCommentParticles( + particles: GraphParticle[], + data: TeamData, + teamName: string, + edges: GraphEdge[] + ): void { + // First call: record current comment counts without creating particles. + // This prevents pre-existing comments from spawning particles when the graph opens. + if (!this.#initialCommentsSeen) { + this.#initialCommentsSeen = true; + for (const task of data.tasks) { + this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0); + } + return; + } + + // Build a member color lookup for assigning particle colors + const memberColors = new Map(); + for (const member of data.members) { + if (member.color) memberColors.set(member.name, member.color); + } + + for (const task of data.tasks) { + if (task.status === 'deleted') continue; + + const prevCount = this.#seenCommentCounts.get(task.id) ?? 0; + const currentCount = task.comments?.length ?? 0; + + if (currentCount > prevCount && prevCount > 0) { + // New comment(s) detected — create a particle from the author to the task + const newComment = task.comments![currentCount - 1]; + const authorNodeId = `member:${teamName}:${newComment.author}`; + const taskNodeId = `task:${teamName}:${task.id}`; + const authorEdge = edges.find((e) => e.source === authorNodeId && e.target === taskNodeId); + + if (authorEdge) { + particles.push({ + id: `particle:comment:${task.id}:${currentCount}`, + edgeId: authorEdge.id, + progress: 0, + kind: 'message', + color: memberColors.get(newComment.author) ?? '#cc88ff', + label: '\u{1F4AC}', + }); + } + } + + this.#seenCommentCounts.set(task.id, currentCount); + } + } + // ─── Static mappers ────────────────────────────────────────────────────── static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState { From 36336cbd06f67aeb11225a98456379ffcf0f47b0 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:30:38 +0200 Subject: [PATCH 036/113] feat(graph): compact kanban columns + column headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Empty columns no longer reserve space — only non-empty columns render - Active columns are packed tightly next to each other - Each column gets a header label (Todo, In Progress, Done, Review, Approved) with status color and subtle underline - Columns centered under owner node --- packages/agent-graph/src/canvas/draw-tasks.ts | 28 ++++++ .../agent-graph/src/layout/kanbanLayout.ts | 99 ++++++++++++++----- packages/agent-graph/src/ui/GraphCanvas.tsx | 4 +- 3 files changed, 105 insertions(+), 26 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index d7f7a00e..dcddfd04 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -8,6 +8,7 @@ import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/co import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants'; import { truncateText } from './draw-misc'; import { hexWithAlpha } from './render-cache'; +import type { KanbanZoneInfo } from '../layout/kanbanLayout'; /** * Draw all task nodes as pill-shaped cards. @@ -176,3 +177,30 @@ function drawTaskPill( ctx.restore(); } + +/** + * Draw kanban column headers above task columns. + */ +export function drawColumnHeaders( + ctx: CanvasRenderingContext2D, + zones: KanbanZoneInfo[], +): void { + for (const zone of zones) { + for (const header of zone.headers) { + ctx.font = 'bold 8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = hexWithAlpha(header.color, 0.6); + ctx.fillText(header.label, header.x, header.y - 2); + + // Subtle underline + const labelWidth = ctx.measureText(header.label).width; + ctx.beginPath(); + ctx.moveTo(header.x - labelWidth / 2, header.y); + ctx.lineTo(header.x + labelWidth / 2, header.y); + ctx.strokeStyle = hexWithAlpha(header.color, 0.2); + ctx.lineWidth = 0.5; + ctx.stroke(); + } + } +} diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index b49b8b85..e822afcc 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -1,8 +1,8 @@ /** * KanbanLayoutEngine — positions task nodes in kanban columns relative to their owner. * - * Each member/lead gets a zone below them with 4 columns: todo → wip → review → done. - * Tasks are pinned (fx/fy) — no d3-force drift. Deterministic layout. + * Each member/lead gets a zone below them with columns for non-empty statuses only. + * Empty columns are skipped — no wasted space. Each column has a header label. * * Class with ES #private methods, single source of truth from KANBAN_ZONE constants. */ @@ -10,6 +10,31 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE } from '../constants/canvas-constants'; +/** Column header info for rendering */ +export interface KanbanColumnHeader { + label: string; + x: number; + y: number; + color: string; +} + +/** Zone info per owner for rendering headers */ +export interface KanbanZoneInfo { + ownerId: string; + ownerX: number; + ownerY: number; + headers: KanbanColumnHeader[]; +} + +// Column display config +const COLUMN_LABELS: Record = { + todo: { label: 'Todo', color: '#6b7280' }, + wip: { label: 'In Progress', color: '#3b82f6' }, + done: { label: 'Done', color: '#22c55e' }, + review: { label: 'Review', color: '#f59e0b' }, + approved: { label: 'Approved', color: '#22c55e' }, +}; + export class KanbanLayoutEngine { // Reusable collections (cleared each call, never GC'd) static readonly #nodeMap = new Map(); @@ -17,6 +42,9 @@ export class KanbanLayoutEngine { static readonly #unassigned: GraphNode[] = []; static readonly #colTasks = new Map(); + /** Zone info for rendering column headers — updated each layout() call */ + static zones: KanbanZoneInfo[] = []; + /** * Position all task nodes in kanban columns relative to their owner. * Call AFTER d3-force settles member positions, BEFORE drawing. @@ -26,7 +54,6 @@ export class KanbanLayoutEngine { nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); - // Group tasks by owner — reuse maps const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); const unassigned = this.#unassigned; @@ -46,26 +73,27 @@ export class KanbanLayoutEngine { } } - // Layout each owner's tasks in kanban columns + // Reset zones + this.zones = []; + for (const [ownerId, tasks] of tasksByOwner) { const owner = nodeMap.get(ownerId); if (!owner || owner.x == null || owner.y == null) continue; - KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y); + const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y, ownerId); + if (zoneInfo) this.zones.push(zoneInfo); } - // Unassigned tasks: separate zone KanbanLayoutEngine.#layoutUnassigned(unassigned); } // ─── Private ────────────────────────────────────────────────────────────── - static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number): void { + static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null { const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE; - const totalWidth = columns.length * columnWidth; - const baseX = ownerX - totalWidth / 2; + const headerHeight = 20; // space for column header label const baseY = ownerY + offsetY; - // Classify each task into a column — reuse shared Map + // Classify tasks into columns const colTasks = KanbanLayoutEngine.#colTasks; colTasks.clear(); for (const col of columns) colTasks.set(col, []); @@ -75,21 +103,47 @@ export class KanbanLayoutEngine { colTasks.get(col)?.push(task); } - // Position each task in its column + row - for (const [colIdx, colName] of columns.entries()) { - const colNodes = colTasks.get(colName) ?? []; - for (const [rowIdx, task] of colNodes.entries()) { + // Collect only NON-EMPTY columns (skip empty — no wasted space) + const activeColumns: { name: string; tasks: GraphNode[] }[] = []; + for (const colName of columns) { + const nodes = colTasks.get(colName) ?? []; + if (nodes.length > 0) { + activeColumns.push({ name: colName, tasks: nodes }); + } + } + + if (activeColumns.length === 0) return null; + + // Center active columns under owner + const totalWidth = activeColumns.length * columnWidth; + const baseX = ownerX - totalWidth / 2; + + // Build headers + position tasks + const headers: KanbanColumnHeader[] = []; + + for (const [colIdx, col] of activeColumns.entries()) { + const colX = baseX + colIdx * columnWidth; + const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; + + // Column header + headers.push({ + label: config.label, + x: colX + columnWidth / 2, // centered in column + y: baseY, + color: config.color, + }); + + // Position tasks below header + for (const [rowIdx, task] of col.tasks.entries()) { if (rowIdx >= maxVisibleRows) { - // Hide overflow tasks off-screen task.x = -99999; task.y = -99999; task.fx = task.x; task.fy = task.y; continue; } - const targetX = baseX + colIdx * columnWidth; - const targetY = baseY + rowIdx * rowHeight; - // Smooth slide: LERP toward target; instant on first appearance + const targetX = colX; + const targetY = baseY + headerHeight + rowIdx * rowHeight; task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; @@ -98,17 +152,12 @@ export class KanbanLayoutEngine { task.vy = 0; } } + + return { ownerId, ownerX, ownerY, headers }; } - /** - * Determine which kanban column a task belongs to. - * Columns: todo → wip → done → review → approved - * approved is separate from review — approved goes after review. - */ static #resolveColumn(task: GraphNode): string { - // Approved = separate column (after review) if (task.reviewState === 'approved') return 'approved'; - // Active review/needsFix = review column (next to done) if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; switch (task.taskStatus) { case 'in_progress': diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 2b6fa632..8fd1ba85 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -12,10 +12,11 @@ import { drawBackground, createDepthParticles, updateDepthParticles, type DepthP import { drawEdges } from '../canvas/draw-edges'; import { drawParticles } from '../canvas/draw-particles'; import { drawAgents } from '../canvas/draw-agents'; -import { drawTasks } from '../canvas/draw-tasks'; +import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; import { BloomRenderer } from '../canvas/bloom-renderer'; +import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import type { CameraTransform } from '../hooks/useGraphCamera'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── @@ -208,6 +209,7 @@ export const GraphCanvas = forwardRef(funct // 2c. Visible nodes only (back to front: process → task → member/lead) drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawColumnHeaders(ctx, KanbanLayoutEngine.zones); drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); From 899922da9b124a7c85fc1e845888dffe7c6de0f9 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:36:04 +0200 Subject: [PATCH 037/113] fix(graph): always show context % label on lead ring + fix hex alpha --- packages/agent-graph/src/canvas/draw-agents.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 6a4d4ed8..fcfa1234 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -254,7 +254,7 @@ export function drawContextRing( // Background ring ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); - ctx.strokeStyle = COLORS.holoBright + '15'; + ctx.strokeStyle = hexWithAlpha(COLORS.holoBright, 0.08); ctx.lineWidth = CONTEXT_RING.ringWidth; ctx.stroke(); @@ -282,13 +282,11 @@ export function drawContextRing( ctx.lineWidth = CONTEXT_RING.ringWidth; ctx.stroke(); - // Percentage label - if (usage > CONTEXT_RING.percentLabelThreshold) { - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillStyle = ringColor; - ctx.fillText(`${Math.round(usage * 100)}%`, x, y - r - CONTEXT_RING.percentYOffset); - } + // Percentage label — always show for lead + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = ringColor; + ctx.fillText(`${Math.round(usage * 100)}% context`, x, y - r - CONTEXT_RING.percentYOffset); } function drawSelectionRing( From 9fdbdd72d951f14dee82c4bacb9a69753e76fc53 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:39:50 +0200 Subject: [PATCH 038/113] feat(graph): centered column headers + member avatar letters - Column headers centered horizontally over pill area (not column edge) - Member/lead nodes show first letter of name inside hexagon as avatar (bold, sized proportionally to node radius, colored in member color) - Lead gets slightly smaller font (0.6r vs 0.7r) to fit context ring --- .../agent-graph/src/canvas/draw-agents.ts | 22 +++++++++++++++++++ .../agent-graph/src/layout/kanbanLayout.ts | 6 ++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index fcfa1234..bc353adc 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -44,6 +44,9 @@ export function drawAgents( // Hexagonal body with interior fill drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered); + // Avatar: first letter of name centered inside hexagon + drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead'); + // Breathing animation + spawn/waiting effects drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); @@ -207,6 +210,25 @@ function drawBreathing( } } +function drawAvatar( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + name: string, + color: string, + isLead: boolean, +): void { + const letter = name.charAt(0).toUpperCase(); + const fontSize = isLead ? Math.round(r * 0.6) : Math.round(r * 0.7); + + ctx.font = `bold ${fontSize}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(color, 0.9); + ctx.fillText(letter, x, y + 1); +} + function drawLabel( ctx: CanvasRenderingContext2D, x: number, diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index e822afcc..1c5a2623 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -8,7 +8,7 @@ */ import type { GraphNode } from '../ports/types'; -import { KANBAN_ZONE } from '../constants/canvas-constants'; +import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -125,10 +125,10 @@ export class KanbanLayoutEngine { const colX = baseX + colIdx * columnWidth; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; - // Column header + // Column header — centered over pill area headers.push({ label: config.label, - x: colX + columnWidth / 2, // centered in column + x: colX + TASK_PILL.width / 2, // horizontally centered over pills y: baseY, color: config.color, }); From d85058f198a8ca55fef5442dfa586c1f8ffb97ae Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:46:25 +0200 Subject: [PATCH 039/113] feat(graph): robohash avatars inside member hexagons + center column headers - Member/lead nodes show robohash avatar image clipped to circle inside hex - Async image loading with cache (fallback to first letter while loading) - avatarUrl field added to GraphNode type + adapter passes agentAvatarUrl() - Column headers now centered over pill center (was offset by half pill width) --- .../agent-graph/src/canvas/draw-agents.ts | 48 +++++++++++++++++-- .../agent-graph/src/layout/kanbanLayout.ts | 6 +-- packages/agent-graph/src/ports/types.ts | 2 + .../agent-graph/adapters/TeamGraphAdapter.ts | 3 ++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index bc353adc..2d701336 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -44,8 +44,8 @@ export function drawAgents( // Hexagonal body with interior fill drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered); - // Avatar: first letter of name centered inside hexagon - drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead'); + // Avatar: robohash image or fallback letter + drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl); // Breathing animation + spawn/waiting effects drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); @@ -210,6 +210,30 @@ function drawBreathing( } } +// ─── Avatar image cache ───────────────────────────────────────────────────── + +const avatarCache = new Map(); +const avatarLoading = new Set(); + +function getAvatarImage(url: string): HTMLImageElement | null { + const cached = avatarCache.get(url); + if (cached) return cached; + if (avatarLoading.has(url)) return null; + + avatarLoading.add(url); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + avatarCache.set(url, img); + avatarLoading.delete(url); + }; + img.onerror = () => { + avatarLoading.delete(url); + }; + img.src = url; + return null; +} + function drawAvatar( ctx: CanvasRenderingContext2D, x: number, @@ -218,10 +242,28 @@ function drawAvatar( name: string, color: string, isLead: boolean, + avatarUrl?: string, ): void { + const avatarR = r * 0.6; + + // Try to draw avatar image + if (avatarUrl) { + const img = getAvatarImage(avatarUrl); + if (img) { + ctx.save(); + // Clip to circle inside hexagon + ctx.beginPath(); + ctx.arc(x, y, avatarR, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(img, x - avatarR, y - avatarR, avatarR * 2, avatarR * 2); + ctx.restore(); + return; + } + } + + // Fallback: first letter const letter = name.charAt(0).toUpperCase(); const fontSize = isLead ? Math.round(r * 0.6) : Math.round(r * 0.7); - ctx.font = `bold ${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 1c5a2623..5be02b2c 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -8,7 +8,7 @@ */ import type { GraphNode } from '../ports/types'; -import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; +import { KANBAN_ZONE } from '../constants/canvas-constants'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -125,10 +125,10 @@ export class KanbanLayoutEngine { const colX = baseX + colIdx * columnWidth; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; - // Column header — centered over pill area + // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) headers.push({ label: config.label, - x: colX + TASK_PILL.width / 2, // horizontally centered over pills + x: colX, // pill center = task.x = colX y: baseY, color: config.color, }); diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 2ff862c8..4bafef3b 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -43,6 +43,8 @@ export interface GraphNode { // ─── Member/Lead-specific ────────────────────────────────────────────── /** Agent role description */ role?: string; + /** Avatar image URL (e.g., robohash) */ + avatarUrl?: string; /** Spawn lifecycle status */ spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; /** Context window usage ratio (0..1), available for lead only */ diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 52eaebc6..71b3cd40 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -8,6 +8,7 @@ */ import { isLeadMember } from '@shared/utils/leadDetection'; +import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import type { GraphDataPort, @@ -128,6 +129,7 @@ export class TeamGraphAdapter { state: data.isAlive ? 'active' : 'idle', color: data.config.color ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, + avatarUrl: agentAvatarUrl('team-lead', 64), domainRef: { kind: 'lead', teamName }, }); } @@ -155,6 +157,7 @@ export class TeamGraphAdapter { color: member.color ?? undefined, role: member.role ?? undefined, spawnStatus: spawn?.status, + avatarUrl: agentAvatarUrl(member.name, 64), domainRef: { kind: 'member', teamName, memberName: member.name }, }); From 322de540ec7a54fecca4d9a3513eaef6f0ed1227 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:50:10 +0200 Subject: [PATCH 040/113] fix(graph): merge name+role into single line, increase kanban zone offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Name and role combined: "jack · developer" (was two separate lines) - Removes overlap between role text and column headers - Kanban zone offsetY 60→70 for extra clearance - Removed unused drawSublabel function --- .../agent-graph/src/canvas/draw-agents.ts | 29 ++++--------------- .../src/constants/canvas-constants.ts | 2 +- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 2d701336..8239532f 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -50,13 +50,9 @@ export function drawAgents( // Breathing animation + spawn/waiting effects drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); - // Name label - drawLabel(ctx, x, y, r, node.label, color); - - // Role subtitle - if (node.role) { - drawSublabel(ctx, x, y, r, node.role); - } + // Name + role label (single line: "jack · developer") + const labelText = node.role ? `${node.label} · ${node.role}` : node.label; + drawLabel(ctx, x, y, r, labelText, color); // Context ring for lead if (node.kind === 'lead' && node.contextUsage != null) { @@ -279,25 +275,12 @@ function drawLabel( label: string, color: string, ): void { - ctx.font = `bold 10px monospace`; + const labelY = y + r + AGENT_DRAW.labelYOffset; + ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = color; - ctx.fillText(label, x, y + r + AGENT_DRAW.labelYOffset); -} - -function drawSublabel( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - r: number, - sublabel: string, -): void { - ctx.font = '7px sans-serif'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - ctx.fillStyle = COLORS.textDim; - ctx.fillText(sublabel, x, y + r + AGENT_DRAW.labelYOffset + 13); + ctx.fillText(label, x, labelY); } /** diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 259ce4f8..0dc10cab 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -239,7 +239,7 @@ export const KANBAN_ZONE = { /** Row height: pill (36) + gap (10) */ rowHeight: 46, /** Zone starts this far below member node center */ - offsetY: 60, + offsetY: 70, /** Column order: todo → wip → done → review → approved */ columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, /** Max tasks shown per column (overflow hidden) */ From 789b74219e2b8ef3d83eb065164a97b76dc05411 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:52:46 +0200 Subject: [PATCH 041/113] =?UTF-8?q?feat(graph):=20redesign=20toolbar=20?= =?UTF-8?q?=E2=80=94=20icons,=20team=20color,=20no=20system=20button=20ove?= =?UTF-8?q?rlap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toolbar moved to top-10 (below macOS system buttons) - Lucide icons for all buttons (Tasks, Proc, Edges, Play/Pause, Zoom, Pin, Close) - Toggle buttons show active state with highlight background - Team name + alive indicator uses team color from config - Grouped buttons in glass panels with backdrop-blur - Removed raw text-only buttons (was: "−", "⊡", "+", "⊞ Pin", "✕") --- packages/agent-graph/src/ui/GraphControls.tsx | 148 ++++++++++++++---- packages/agent-graph/src/ui/GraphView.tsx | 1 + 2 files changed, 116 insertions(+), 33 deletions(-) diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index d3537463..d593b10d 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -1,9 +1,22 @@ /** * GraphControls — floating toolbar over the canvas. - * Zoom, fit, filter toggles, pause, pin-as-tab, close. + * Positioned below system buttons (top-10) to avoid overlap on macOS. */ import { useCallback } from 'react'; +import { + Columns3, + Eye, + EyeOff, + Maximize2, + Minus, + Pause, + Pin, + Play, + Plus, + Server, + X, +} from 'lucide-react'; export interface GraphFilterState { showTasks: boolean; @@ -21,6 +34,7 @@ export interface GraphControlsProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; teamName: string; + teamColor?: string; isAlive?: boolean; } @@ -33,6 +47,7 @@ export function GraphControls({ onRequestClose, onRequestPinAsTab, teamName, + teamColor, isAlive, }: GraphControlsProps): React.JSX.Element { const toggle = useCallback( @@ -42,72 +57,139 @@ export function GraphControls({ [filters, onFiltersChange], ); + const nameColor = teamColor ?? '#aaeeff'; + return ( -

- {/* Left: title + status */} -
-
+
+ {/* Left: team name + status indicator */} +
+
{isAlive && ( -
+
)} - + {teamName}
- {/* Center: filters */} -
- toggle('showTasks')} label="Tasks" /> - toggle('showProcesses')} label="Proc" /> - toggle('showEdges')} label="Edges" /> -
- toggle('paused')} label={filters.paused ? '▶' : '⏸'} /> + {/* Center: filter toggles */} +
+ toggle('showTasks')} + icon={} + label="Tasks" + /> + toggle('showProcesses')} + icon={} + label="Proc" + /> + toggle('showEdges')} + icon={filters.showEdges ? : } + label="Edges" + /> + + toggle('paused')} + icon={filters.paused ? : } + />
{/* Right: zoom + actions */} -
- - - +
+ } /> + } /> + } /> {onRequestPinAsTab && ( <> -
- + + } + label="Pin" + /> )} {onRequestClose && ( - + } /> )}
); } -// ─── Toolbar Button ───────────────────────────────────────────────────────── +// ─── Primitives ───────────────────────────────────────────────────────────── function ToolbarButton({ - active, onClick, + icon, label, }: { - active?: boolean; onClick?: () => void; + icon: React.ReactNode; + label?: string; +}): React.JSX.Element { + return ( + + ); +} + +function ToolbarToggle({ + active, + onClick, + icon, + label, +}: { + active: boolean; + onClick: () => void; + icon: React.ReactNode; label: string; }): React.JSX.Element { return ( ); } + +function Separator(): React.JSX.Element { + return
; +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 89c70d4c..bfac037f 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -328,6 +328,7 @@ export function GraphView({ onRequestClose={onRequestClose} onRequestPinAsTab={onRequestPinAsTab} teamName={data.teamName} + teamColor={data.teamColor} isAlive={data.isAlive} /> From 98a1155635f92e4ba2f7948ea370d2b7ac1ab3ef Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 12:56:43 +0200 Subject: [PATCH 042/113] feat(graph): wire popover actions to real dialogs (Message, Open task/member) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TeamGraphOverlay accepts onSendMessage, onOpenTaskDetail, onOpenMemberProfile - TeamDetailView passes dialog openers to overlay: - Message → opens SendMessageDialog with recipient pre-filled - Open (task) → opens TaskDetailDialog with task found by ID - Open (member) → opens SendMessageDialog for that member - Double-click → same as Open - Removed console.log stubs --- .github/CONTRIBUTING.md | 42 ++++++++----------- .../components/team/TeamDetailView.tsx | 16 +++++++ .../agent-graph/ui/TeamGraphOverlay.tsx | 41 +++++++++++------- 3 files changed, 58 insertions(+), 41 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cf3195c2..b0153209 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,28 +1,17 @@ # Contributing -Thanks for contributing to Claude Agent Teams UI. +Thanks for contributing to Claude Agent Teams UI! -## Project Philosophy & Scope +## Before You Start -claude-devtools exists to make the invisible parts of Claude Code visible — the token flows, context injections, tool executions, and session dynamics that are otherwise hidden behind the CLI. It is not a general-purpose dashboard or an IDE. +For big features and major changes, please discuss them in our [Discord](https://discord.gg/RgBHMBsn) first so we can figure out the best approach together and avoid conflicts. -Our priorities: - -1. **Parity with Claude Code** — When Claude Code ships new capabilities (agent teams, context tracking, new tool types), we adopt them quickly so users always have full visibility. -2. **Context engineering insight** — Features that help users understand *what* is consuming their context window, *how* tokens flow through a session, and *where* to optimize. If it doesn't help someone make better decisions about their Claude Code usage, it probably doesn't belong here. -3. **Stability over novelty** — A reliable, fast tool for professional workflows. We'd rather do fewer things well than many things poorly. - -**What we generally do not accept:** -- Large custom features that don't directly serve context visibility or Claude Code parity. -- Speculative features that add maintenance burden without solving a concrete problem users face today. -- PRs that significantly expand scope without prior discussion in an Issue. - -If you're considering a non-trivial contribution, **open an Issue first** to check alignment with the current roadmap. This saves everyone time and keeps the project focused. +Small fixes, bug reports, and minor improvements are always welcome -- just open a PR. ## Prerequisites - Node.js 20+ - pnpm 10+ -- macOS or Windows +- macOS, Windows, or Linux ## Setup ```bash @@ -39,12 +28,16 @@ pnpm test pnpm build ``` +Or all at once: +```bash +pnpm check +``` + ## Pull Request Guidelines -- Keep changes focused and small — one purpose per PR. +- Keep changes focused and small -- one purpose per PR. - Add/adjust tests for behavior changes. - Update docs when changing public behavior or setup. - Use clear PR titles and include a short validation checklist. -- **Large changes (new features, new dependencies, large data additions) must have a discussion in an Issue first.** Do not open a large PR without prior agreement on the approach. - Avoid committing large hardcoded data blobs. If data can be fetched at runtime or generated at build time, prefer that approach. ## AI-Assisted Contributions @@ -52,15 +45,14 @@ pnpm build AI coding tools are welcome, but **you are responsible for what you submit**: - **Review before submitting.** Read every line of AI-generated code and understand what it does. Do not submit raw, unreviewed AI output. -- **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools (e.g. `docs/plans/`, `.speckit/`, etc.) do not belong in the repository. -- **Test it yourself.** AI-generated code must be manually verified — run the app, confirm the feature works, check edge cases. +- **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools do not belong in the repository. +- **Test it yourself.** AI-generated code must be manually verified -- run the app, confirm the feature works, check edge cases. - **Keep it intentional.** Every line in your PR should exist for a reason you can explain. If you can't explain why a piece of code is there, remove it. ## What Does NOT Belong in the Repo - Personal planning/workflow artifacts (AI session plans, task lists, etc.) - Large static data that could be fetched at runtime - Generated files that aren't part of the build output -- Experimental features without prior discussion ## Commit Style - Prefer conventional commits (`feat:`, `fix:`, `chore:`, `docs:`). @@ -69,7 +61,7 @@ AI coding tools are welcome, but **you are responsible for what you submit**: ## Reporting Bugs Please include: - OS version -- app version / commit hash -- repro steps -- expected vs actual behavior -- logs/screenshots when possible +- App version / commit hash +- Repro steps +- Expected vs actual behavior +- Logs/screenshots when possible diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index b1226889..4a01b5c0 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -2088,6 +2088,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele .getState() .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); }} + onSendMessage={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + onOpenTaskDetail={(taskId) => { + const task = data.tasks.find((t) => t.id === taskId); + if (task) setSelectedTask(task); + }} + onOpenMemberProfile={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} /> )} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 5e7f7aca..9a7afd7d 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -15,32 +15,41 @@ export interface TeamGraphOverlayProps { teamName: string; onClose: () => void; onPinAsTab?: () => void; + onSendMessage?: (memberName: string) => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: (memberName: string) => void; } export const TeamGraphOverlay = ({ teamName, onClose, onPinAsTab, + onSendMessage, + onOpenTaskDetail, + onOpenMemberProfile, }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const events: GraphEventPort = { - onNodeClick: useCallback((_ref: GraphDomainRef) => { - // Popover shown by GraphView internally - }, []), - onNodeDoubleClick: useCallback((ref: GraphDomainRef) => { - // TODO: open TaskDetailDialog or MemberDetailDialog based on ref.kind - console.log('Double-click:', ref); - }, []), - onSendMessage: useCallback((_memberName: string, _teamName: string) => { - // TODO: open SendMessageDialog - }, []), - onOpenTaskDetail: useCallback((_taskId: string, _teamName: string) => { - // TODO: open TaskDetailDialog - }, []), - onBackgroundClick: useCallback(() => { - // Deselect handled by GraphView - }, []), + onNodeDoubleClick: useCallback( + (ref: GraphDomainRef) => { + if (ref.kind === 'task') onOpenTaskDetail?.(ref.taskId); + else if (ref.kind === 'member') onOpenMemberProfile?.(ref.memberName); + }, + [onOpenTaskDetail, onOpenMemberProfile] + ), + onSendMessage: useCallback( + (memberName: string) => onSendMessage?.(memberName), + [onSendMessage] + ), + onOpenTaskDetail: useCallback( + (taskId: string) => onOpenTaskDetail?.(taskId), + [onOpenTaskDetail] + ), + onOpenMemberProfile: useCallback( + (memberName: string) => onOpenMemberProfile?.(memberName), + [onOpenMemberProfile] + ), }; return ( From b7064759c0aa331c002ee382b996ce6415224cd7 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 13:03:29 +0200 Subject: [PATCH 043/113] =?UTF-8?q?fix(graph):=20toolbar=20position=20?= =?UTF-8?q?=E2=80=94=20top-2=20left-20=20to=20avoid=20macOS=20system=20but?= =?UTF-8?q?tons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CONTRIBUTING.md | 6 +++--- packages/agent-graph/src/ui/GraphControls.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b0153209..360e9185 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thanks for contributing to Claude Agent Teams UI! For big features and major changes, please discuss them in our [Discord](https://discord.gg/RgBHMBsn) first so we can figure out the best approach together and avoid conflicts. -Small fixes, bug reports, and minor improvements are always welcome -- just open a PR. +Small fixes, bug reports, and minor improvements are always welcome - just open a PR. ## Prerequisites - Node.js 20+ @@ -34,7 +34,7 @@ pnpm check ``` ## Pull Request Guidelines -- Keep changes focused and small -- one purpose per PR. +- Keep changes focused and small - one purpose per PR. - Add/adjust tests for behavior changes. - Update docs when changing public behavior or setup. - Use clear PR titles and include a short validation checklist. @@ -46,7 +46,7 @@ AI coding tools are welcome, but **you are responsible for what you submit**: - **Review before submitting.** Read every line of AI-generated code and understand what it does. Do not submit raw, unreviewed AI output. - **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools do not belong in the repository. -- **Test it yourself.** AI-generated code must be manually verified -- run the app, confirm the feature works, check edge cases. +- **Test it yourself.** AI-generated code must be manually verified - run the app, confirm the feature works, check edge cases. - **Keep it intentional.** Every line in your PR should exist for a reason you can explain. If you can't explain why a piece of code is there, remove it. ## What Does NOT Belong in the Repo diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index d593b10d..45bd4361 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -60,7 +60,7 @@ export function GraphControls({ const nameColor = teamColor ?? '#aaeeff'; return ( -
+
{/* Left: team name + status indicator */}
Date: Sat, 28 Mar 2026 14:11:28 +0200 Subject: [PATCH 044/113] =?UTF-8?q?fix(graph):=20toggle=20buttons=20visual?= =?UTF-8?q?ly=20distinct=20=E2=80=94=20active=20gets=20border=20+=20glow,?= =?UTF-8?q?=20inactive=20dimmed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent-graph/src/ui/GraphControls.tsx | 6 +- .../team/dialogs/TaskDetailDialog.tsx | 252 +++++++++++------- .../team/review/FileSectionDiff.tsx | 38 ++- .../team/review/reviewDiffSafety.ts | 59 ++++ src/renderer/store/index.ts | 2 + .../components/reviewDiffSafety.test.ts | 53 ++++ 6 files changed, 309 insertions(+), 101 deletions(-) create mode 100644 src/renderer/components/team/review/reviewDiffSafety.ts create mode 100644 test/renderer/components/reviewDiffSafety.test.ts diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 45bd4361..fb5806eb 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -178,10 +178,10 @@ function ToolbarToggle({ return ( ); From 54c259b0170ec832da2c1ff5988bd0e00e09ef93 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 14:27:55 +0200 Subject: [PATCH 047/113] =?UTF-8?q?refactor(graph):=20extract=20popover=20?= =?UTF-8?q?to=20features=20layer=20=E2=80=94=20reuse=20project=20UI=20(DRY?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: GraphNodePopover.tsx in features/agent-graph/ui/ Uses @renderer/components/ui/badge, button, agentAvatarUrl No code duplication with MemberCard/MemberHoverCard patterns - GraphView: added renderOverlay prop — host app injects its own popover Falls back to built-in GraphOverlay if renderOverlay not provided - TeamGraphTab + TeamGraphOverlay pass renderOverlay with GraphNodePopover Member popover: avatar, status dot, role, context bar, badges, Message/Profile Task popover: displayId, status/review badges, Open button Process popover: label, URL link --- packages/agent-graph/src/ui/GraphView.tsx | 36 ++- .../agent-graph/ui/GraphNodePopover.tsx | 272 ++++++++++++++++++ .../agent-graph/ui/TeamGraphOverlay.tsx | 19 ++ .../features/agent-graph/ui/TeamGraphTab.tsx | 24 +- 4 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 src/renderer/features/agent-graph/ui/GraphNodePopover.tsx diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 4dec015f..24c0f031 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -32,6 +32,12 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + /** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */ + renderOverlay?: (props: { + node: GraphNode; + screenPos: { x: number; y: number }; + onClose: () => void; + }) => React.ReactNode; } export function GraphView({ @@ -42,6 +48,7 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + renderOverlay, }: GraphViewProps): React.JSX.Element { // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -335,12 +342,29 @@ export function GraphView({ isAlive={data.isAlive} /> - setSelectedNodeId(null)} - /> + {selectedNode && renderOverlay ? ( +
+ {renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + })} +
+ ) : ( + setSelectedNodeId(null)} + /> + )}
); } diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx new file mode 100644 index 00000000..67996ce9 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -0,0 +1,272 @@ +/** + * GraphNodePopover — renders popover for graph nodes using project UI components. + * Lives in features/ (not in package) so it CAN import from @renderer/. + * Reuses agentAvatarUrl, status helpers, and UI primitives from the project. + */ + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { MessageSquare, ExternalLink, User } from 'lucide-react'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +interface GraphNodePopoverProps { + node: GraphNode; + onClose: () => void; + onSendMessage?: (memberName: string) => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: (memberName: string) => void; +} + +export function GraphNodePopover({ + node, + onClose, + onSendMessage, + onOpenTaskDetail, + onOpenMemberProfile, +}: GraphNodePopoverProps): React.JSX.Element { + if (node.kind === 'member' || node.kind === 'lead') { + return ( + + ); + } + + if (node.kind === 'task') { + return ; + } + + // Process + return ( +
+
{node.label}
+ {node.processUrl && ( + + Open URL + + )} +
+ ); +} + +// ─── Member Popover ───────────────────────────────────────────────────────── + +function MemberPopoverContent({ + node, + onClose, + onSendMessage, + onOpenProfile, +}: { + node: GraphNode; + onClose: () => void; + onSendMessage?: (name: string) => void; + onOpenProfile?: (name: string) => void; +}): React.JSX.Element { + const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead'; + const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); + const statusLabel = + node.state === 'active' + ? 'Active' + : node.state === 'idle' + ? 'Idle' + : node.state === 'terminated' + ? 'Offline' + : node.state; + + const statusDotColor = + node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling' + ? 'bg-emerald-400' + : node.state === 'idle' + ? 'bg-zinc-400' + : node.state === 'error' + ? 'bg-red-400' + : 'bg-zinc-600'; + + return ( +
+ {/* Header: avatar + name */} +
+
+ {memberName} +
+
+
+
+ {node.label.split(' · ')[0]} +
+ {node.role && ( +
{node.role}
+ )} +
+
+ + {/* Status badges */} +
+ + {statusLabel} + + {node.kind === 'lead' && ( + + Lead + + )} + {node.spawnStatus && node.spawnStatus !== 'online' && ( + + {node.spawnStatus} + + )} +
+ + {/* Context usage for lead */} + {node.kind === 'lead' && node.contextUsage != null && node.contextUsage > 0 && ( +
+
+ Context + {Math.round(node.contextUsage * 100)}% +
+
+
0.9 + ? '#ef4444' + : node.contextUsage > 0.8 + ? '#f59e0b' + : '#22c55e', + }} + /> +
+
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} + +// ─── Task Popover ─────────────────────────────────────────────────────────── + +function TaskPopoverContent({ + node, + onClose, + onOpenDetail, +}: { + node: GraphNode; + onClose: () => void; + onOpenDetail?: (taskId: string) => void; +}): React.JSX.Element { + const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; + + const statusColor = + node.taskStatus === 'in_progress' + ? 'text-blue-400 border-blue-500/30' + : node.taskStatus === 'completed' + ? 'text-emerald-400 border-emerald-500/30' + : 'text-zinc-400 border-zinc-500/30'; + + const reviewColor = + node.reviewState === 'review' + ? 'text-amber-400 border-amber-500/30' + : node.reviewState === 'needsFix' + ? 'text-red-400 border-red-500/30' + : node.reviewState === 'approved' + ? 'text-emerald-400 border-emerald-500/30' + : ''; + + return ( +
+
+ {node.displayId ?? node.label} +
+ {node.sublabel && ( +
+ {node.sublabel} +
+ )} + +
+ + {node.taskStatus ?? 'pending'} + + {node.reviewState && node.reviewState !== 'none' && ( + + {node.reviewState} + + )} + {node.needsClarification && ( + + needs clarification + + )} +
+ +
+ +
+
+ ); +} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 9a7afd7d..ac42d858 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -8,6 +8,7 @@ import { useCallback } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; +import { GraphNodePopover } from './GraphNodePopover'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; @@ -60,6 +61,24 @@ export const TeamGraphOverlay = ({ onRequestClose={onClose} onRequestPinAsTab={onPinAsTab} className="flex-1" + renderOverlay={({ node, onClose: closePopover }) => ( + { + onSendMessage?.(name); + closePopover(); + }} + onOpenTaskDetail={(id) => { + onOpenTaskDetail?.(id); + closePopover(); + }} + onOpenMemberProfile={(name) => { + onOpenMemberProfile?.(name); + closePopover(); + }} + /> + )} />
); diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 041bac55..ba1a0842 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -8,8 +8,9 @@ import { useCallback, useState, lazy, Suspense } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; +import { GraphNodePopover } from './GraphNodePopover'; -import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; +import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph'; const TeamGraphOverlay = lazy(() => import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay })) @@ -72,6 +73,27 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element events={events} className="size-full" onRequestFullscreen={() => setFullscreen(true)} + renderOverlay={({ node, onClose }) => ( + + window.dispatchEvent( + new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } }) + ) + } + onOpenTaskDetail={(id) => + window.dispatchEvent( + new CustomEvent('graph:open-task', { detail: { teamName, taskId: id } }) + ) + } + onOpenMemberProfile={(name) => + window.dispatchEvent( + new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } }) + ) + } + /> + )} /> {fullscreen && ( From 26fe42739b0c388b22c322aed40072ce8d693cc2 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 14:41:24 +0200 Subject: [PATCH 048/113] refactor(graph): fix SOLID/DRY violations from audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRY fixes: - GraphOverlay.tsx: 539 LOC → 85 LOC minimal fallback (was dead code) - COLUMN_LABELS: import from COLORS instead of hardcoded hex duplicates - Alpha hex LUT: consolidated to single source in colors.ts - TeamGraphTab: extract typed dispatchOpenTask/dispatchSendMessage helpers (was 8 repeated CustomEvent dispatches) SOLID fixes: - D-1: lucide-react added to peerDependencies in package.json - S-1: GraphOverlay no longer has 8 responsibilities (now just 1 fallback) Architecture: - renderOverlay prop properly used by both Tab and Overlay - Popover rendering lives in features/ layer only (no duplication with package) --- packages/agent-graph/package.json | 3 +- .../agent-graph/src/canvas/render-cache.ts | 7 +- .../agent-graph/src/layout/kanbanLayout.ts | 13 +- packages/agent-graph/src/ui/GraphOverlay.tsx | 518 ++---------------- .../features/agent-graph/ui/TeamGraphTab.tsx | 73 +-- 5 files changed, 69 insertions(+), 545 deletions(-) diff --git a/packages/agent-graph/package.json b/packages/agent-graph/package.json index 2d4eb5c6..0144aa33 100644 --- a/packages/agent-graph/package.json +++ b/packages/agent-graph/package.json @@ -13,7 +13,8 @@ }, "peerDependencies": { "react": "^18.0.0", - "react-dom": "^18.0.0" + "react-dom": "^18.0.0", + "lucide-react": ">=0.300.0" }, "dependencies": { "d3-force": "^3.0.0" diff --git a/packages/agent-graph/src/canvas/render-cache.ts b/packages/agent-graph/src/canvas/render-cache.ts index 9f163e92..7336d497 100644 --- a/packages/agent-graph/src/canvas/render-cache.ts +++ b/packages/agent-graph/src/canvas/render-cache.ts @@ -44,15 +44,14 @@ function hexWithAlpha(color: string, alpha: number): string { const key = `${color}|${a}`; let result = _hexAlphaCache.get(key); if (result) return result; - result = ensureHex(color) + ALPHA_LUT[a]; + result = ensureHex(color) + alphaHex(a / 255); _hexAlphaCache.set(key, result); if (_hexAlphaCache.size > 500) _hexAlphaCache.clear(); // prevent unbounded growth return result; } -// Import-time LUT for alpha hex -const ALPHA_LUT: string[] = []; -for (let i = 0; i < 256; i++) ALPHA_LUT.push(i.toString(16).padStart(2, '0')); +// Reuse alpha hex LUT from colors.ts (DRY — single source) +import { alphaHex } from '../constants/colors'; // ─── Glow Sprite Cache ────────────────────────────────────────────────────── diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 5be02b2c..d28885d8 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -9,6 +9,7 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE } from '../constants/canvas-constants'; +import { COLORS } from '../constants/colors'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -26,13 +27,13 @@ export interface KanbanZoneInfo { headers: KanbanColumnHeader[]; } -// Column display config +// Column display config — colors from single source of truth (COLORS) const COLUMN_LABELS: Record = { - todo: { label: 'Todo', color: '#6b7280' }, - wip: { label: 'In Progress', color: '#3b82f6' }, - done: { label: 'Done', color: '#22c55e' }, - review: { label: 'Review', color: '#f59e0b' }, - approved: { label: 'Approved', color: '#22c55e' }, + todo: { label: 'Todo', color: COLORS.taskPending }, + wip: { label: 'In Progress', color: COLORS.taskInProgress }, + done: { label: 'Done', color: COLORS.taskCompleted }, + review: { label: 'Review', color: COLORS.reviewPending }, + approved: { label: 'Approved', color: COLORS.reviewApproved }, }; export class KanbanLayoutEngine { diff --git a/packages/agent-graph/src/ui/GraphOverlay.tsx b/packages/agent-graph/src/ui/GraphOverlay.tsx index 7922c619..301e6998 100644 --- a/packages/agent-graph/src/ui/GraphOverlay.tsx +++ b/packages/agent-graph/src/ui/GraphOverlay.tsx @@ -1,15 +1,11 @@ /** - * GraphOverlay — HTML popovers positioned over Canvas nodes. - * Uses camera worldToScreen transform for positioning. - * - * Styled to match the host app's MemberHoverCard / MemberCard look: - * avatar + status dot, name, role, status badges, action buttons. + * GraphOverlay — minimal built-in popover fallback. + * Used ONLY when host app doesn't provide renderOverlay prop. + * For full-featured popovers, use renderOverlay with project UI components. */ -import { useCallback, useEffect, useRef } from 'react'; import type { GraphNode } from '../ports/types'; import type { GraphEventPort } from '../ports/GraphEventPort'; -import { COLORS, getStateColor, getTaskStatusColor } from '../constants/colors'; export interface GraphOverlayProps { selectedNode: GraphNode | null; @@ -37,502 +33,56 @@ export function GraphOverlay({ transform: 'translateY(-50%)', }} > - -
- ); -} - -// ─── SVG Icons (inline — package cannot import lucide-react) ──────────────── - -function IconMessage({ size = 13 }: { size?: number }): React.JSX.Element { - return ( - - - - ); -} - -function IconExternalLink({ size = 12 }: { size?: number }): React.JSX.Element { - return ( - - - - - - ); -} - -function IconGlobe({ size = 12 }: { size?: number }): React.JSX.Element { - return ( - - - - - - ); -} - -function IconClipboard({ size = 12 }: { size?: number }): React.JSX.Element { - return ( - - - - - ); -} - -// ─── State helpers ────────────────────────────────────────────────────────── - -function getPresenceLabel(state: GraphNode['state']): string { - switch (state) { - case 'active': return 'active'; - case 'thinking': return 'thinking'; - case 'tool_calling': return 'tool calling'; - case 'idle': return 'idle'; - case 'waiting': return 'waiting'; - case 'complete': return 'done'; - case 'error': return 'error'; - case 'terminated': return 'offline'; - } -} - -function getStatusDotClass(state: GraphNode['state']): string { - switch (state) { - case 'active': - case 'thinking': - case 'tool_calling': - return 'animate-pulse'; - default: - return ''; - } -} - -/** Capitalise first letter, replace underscores with spaces */ -function formatLabel(s: string): string { - return s.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); -} - -/** Truncate display name: "team-lead" → "Team Lead", "alice" → "Alice" */ -function displayName(name: string): string { - return name - .split(/[-_]/) - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '); -} - -// ─── Node Popover ─────────────────────────────────────────────────────────── - -function NodePopover({ - node, - events, - onClose, -}: { - node: GraphNode; - events?: GraphEventPort; - onClose: () => void; -}): React.JSX.Element { - const popoverRef = useRef(null); - - // Close on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { - onClose(); - } - }; - // Delay to avoid closing immediately from the click that opened it - const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50); - return () => { - clearTimeout(timer); - document.removeEventListener('mousedown', handler); - }; - }, [onClose]); - - const handleAction = useCallback( - (action: string) => { - const ref = node.domainRef; - switch (action) { - case 'sendMessage': - if (ref.kind === 'member' || ref.kind === 'lead') { - events?.onSendMessage?.(ref.kind === 'member' ? ref.memberName : 'team-lead', ref.teamName); - } - break; - case 'openDetail': - if (ref.kind === 'task') events?.onOpenTaskDetail?.(ref.taskId, ref.teamName); - else if (ref.kind === 'member') events?.onOpenMemberProfile?.(ref.memberName, ref.teamName); - else if (ref.kind === 'lead') events?.onOpenMemberProfile?.('team-lead', ref.teamName); - break; - case 'openUrl': - if (node.processUrl) window.open(node.processUrl, '_blank'); - break; - } - onClose(); - }, - [node, events, onClose], - ); - - const isMemberLike = node.kind === 'member' || node.kind === 'lead'; - const color = node.kind === 'task' - ? getTaskStatusColor(node.taskStatus) - : node.color ?? getStateColor(node.state); - const stateColor = getStateColor(node.state); - - if (isMemberLike) { - return ; - } - if (node.kind === 'task') { - return ; - } - return ; -} - -// ─── Member / Lead Popover ────────────────────────────────────────────────── - -import { forwardRef } from 'react'; - -const MemberPopover = forwardRef< - HTMLDivElement, - { node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void } ->(function MemberPopover({ node, color, stateColor, onAction }, ref) { - const presenceText = getPresenceLabel(node.state); - const dotAnim = getStatusDotClass(node.state); - - return ( -
- {/* Colored top accent */} -
- -
- {/* Header: avatar + name + status */} -
- {node.avatarUrl ? ( -
- {node.label} - -
- ) : ( -
-
- {node.label.charAt(0).toUpperCase()} -
- -
- )} - -
-
- - {displayName(node.label)} - -
- {node.role && ( -
- {node.role} -
- )} -
+
+
+ {selectedNode.label}
- - {/* Status badge */} -
- - {node.kind === 'lead' && ( - - )} - {node.spawnStatus && node.spawnStatus !== 'online' && ( - - )} -
- - {/* Context usage bar (lead only) */} - {node.contextUsage != null && node.contextUsage > 0 && ( -
-
- Context - {Math.round(node.contextUsage * 100)}% -
-
-
0.8 ? COLORS.error : color, - }} - /> -
+ {selectedNode.sublabel && ( +
+ {selectedNode.sublabel}
)} - - {/* Sublabel (current activity) */} - {node.sublabel && ( -
- {node.sublabel} + {selectedNode.role && ( +
+ {selectedNode.role}
)} - - {/* Action buttons */} -
- } label="Message" onClick={() => onAction('sendMessage')} color={color} /> - } label="Profile" onClick={() => onAction('openDetail')} color={color} /> -
-
-
- ); -}); - -// ─── Task Popover ─────────────────────────────────────────────────────────── - -const TaskPopover = forwardRef< - HTMLDivElement, - { node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void } ->(function TaskPopover({ node, color, stateColor, onAction }, ref) { - const taskStatusLabel = node.taskStatus ? formatLabel(node.taskStatus) : 'pending'; - - return ( -
- {/* Colored top accent */} -
- -
- {/* Header: display ID + label */} -
- {node.displayId && ( - - {node.displayId} - - )} - - {node.label} - -
- - {/* Subject / description */} - {node.sublabel && ( -
- {node.sublabel} -
- )} - - {/* Status badges */} -
- - {node.state !== 'idle' && ( - - )} - {node.reviewState && node.reviewState !== 'none' && ( - + {(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && ( + { + const ref = selectedNode.domainRef; + if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName); + onDeselect(); + }} /> )} - {node.needsClarification && ( - - )} -
- - {/* Action */} -
- } label="Open task" onClick={() => onAction('openDetail')} color={color} /> +
); -}); - -// ─── Process Popover ──────────────────────────────────────────────────────── - -const ProcessPopover = forwardRef< - HTMLDivElement, - { node: GraphNode; color: string; onAction: (a: string) => void } ->(function ProcessPopover({ node, color, onAction }, ref) { - return ( -
-
- -
-
-
- - {node.label} - -
- - {node.sublabel && ( -
- {node.sublabel} -
- )} - -
- -
- - {node.processUrl && ( -
- } label="Open URL" onClick={() => onAction('openUrl')} color={color} /> -
- )} -
-
- ); -}); - -// ─── UI Primitives ────────────────────────────────────────────────────────── - -function StatusBadge({ label, color }: { label: string; color: string }): React.JSX.Element { - return ( - - {label} - - ); } -function ActionButton({ - icon, - label, - onClick, - color, -}: { - icon: React.ReactNode; - label: string; - onClick: () => void; - color: string; -}): React.JSX.Element { +function FallbackButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element { return ( ); diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index ba1a0842..8f634922 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -24,46 +24,31 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element const graphData = useTeamGraphAdapter(teamName); const [fullscreen, setFullscreen] = useState(false); + // Typed event dispatchers (DRY — used in both events + renderOverlay) + const dispatchOpenTask = useCallback( + (taskId: string) => + window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } })), + [teamName] + ); + const dispatchSendMessage = useCallback( + (memberName: string) => + window.dispatchEvent( + new CustomEvent('graph:send-message', { detail: { teamName, memberName } }) + ), + [teamName] + ); + const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { - // Dispatch to TeamDetailView's dialog system via CustomEvent - if (ref.kind === 'task') { - window.dispatchEvent( - new CustomEvent('graph:open-task', { detail: { teamName, taskId: ref.taskId } }) - ); - } else if (ref.kind === 'member') { - window.dispatchEvent( - new CustomEvent('graph:send-message', { - detail: { teamName, memberName: ref.memberName }, - }) - ); - } + if (ref.kind === 'task') dispatchOpenTask(ref.taskId); + else if (ref.kind === 'member') dispatchSendMessage(ref.memberName); }, - [teamName] - ), - onSendMessage: useCallback( - (memberName: string) => { - window.dispatchEvent( - new CustomEvent('graph:send-message', { detail: { teamName, memberName } }) - ); - }, - [teamName] - ), - onOpenTaskDetail: useCallback( - (taskId: string) => { - window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } })); - }, - [teamName] - ), - onOpenMemberProfile: useCallback( - (memberName: string) => { - window.dispatchEvent( - new CustomEvent('graph:send-message', { detail: { teamName, memberName } }) - ); - }, - [teamName] + [dispatchOpenTask, dispatchSendMessage] ), + onSendMessage: dispatchSendMessage, + onOpenTaskDetail: dispatchOpenTask, + onOpenMemberProfile: dispatchSendMessage, }; return ( @@ -77,21 +62,9 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element - window.dispatchEvent( - new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } }) - ) - } - onOpenTaskDetail={(id) => - window.dispatchEvent( - new CustomEvent('graph:open-task', { detail: { teamName, taskId: id } }) - ) - } - onOpenMemberProfile={(name) => - window.dispatchEvent( - new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } }) - ) - } + onSendMessage={dispatchSendMessage} + onOpenTaskDetail={dispatchOpenTask} + onOpenMemberProfile={dispatchSendMessage} /> )} /> From add9df2006eb1f6006217d6326ed73b00f244611 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 15:08:32 +0200 Subject: [PATCH 049/113] fix(graph): Profile opens member dialog (not message) + Add Task button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile button dispatches 'graph:open-profile' → MemberDetailDialog (was incorrectly mapped to 'graph:send-message') - Double-click on member → opens profile (was: send message) - New "Add Task" button on member popover → CreateTaskDialog with owner pre-filled - New events: graph:open-profile, graph:create-task - TeamDetailView listens for both new events --- .../components/team/TeamDetailView.tsx | 15 +++++++++++++ .../agent-graph/ui/GraphNodePopover.tsx | 20 ++++++++++++++++-- .../features/agent-graph/ui/TeamGraphTab.tsx | 21 +++++++++++++++---- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 878c9565..021305fb 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -246,11 +246,26 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setSendDialogDefaultChip(undefined); setSendDialogOpen(true); }; + const onOpenProfile = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const member = data.members.find((m: { name: string }) => m.name === memberName); + if (member) setSelectedMember(member); + }; + const onCreateTask = (e: Event) => { + const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + openCreateTaskDialog('', '', owner ?? ''); + }; window.addEventListener('graph:open-task', onOpenTask); window.addEventListener('graph:send-message', onSendMsg); + window.addEventListener('graph:open-profile', onOpenProfile); + window.addEventListener('graph:create-task', onCreateTask); return () => { window.removeEventListener('graph:open-task', onOpenTask); window.removeEventListener('graph:send-message', onSendMsg); + window.removeEventListener('graph:open-profile', onOpenProfile); + window.removeEventListener('graph:create-task', onCreateTask); }; }); diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 67996ce9..0b124a95 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -7,7 +7,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; -import { MessageSquare, ExternalLink, User } from 'lucide-react'; +import { MessageSquare, ExternalLink, User, Plus } from 'lucide-react'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -17,6 +17,7 @@ interface GraphNodePopoverProps { onSendMessage?: (memberName: string) => void; onOpenTaskDetail?: (taskId: string) => void; onOpenMemberProfile?: (memberName: string) => void; + onCreateTask?: (owner: string) => void; } export function GraphNodePopover({ @@ -25,6 +26,7 @@ export function GraphNodePopover({ onSendMessage, onOpenTaskDetail, onOpenMemberProfile, + onCreateTask, }: GraphNodePopoverProps): React.JSX.Element { if (node.kind === 'member' || node.kind === 'lead') { return ( @@ -33,6 +35,7 @@ export function GraphNodePopover({ onClose={onClose} onSendMessage={onSendMessage} onOpenProfile={onOpenMemberProfile} + onCreateTask={onCreateTask} /> ); } @@ -66,11 +69,13 @@ function MemberPopoverContent({ onClose, onSendMessage, onOpenProfile, + onCreateTask, }: { node: GraphNode; onClose: () => void; onSendMessage?: (name: string) => void; onOpenProfile?: (name: string) => void; + onCreateTask?: (owner: string) => void; }): React.JSX.Element { const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead'; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); @@ -167,7 +172,7 @@ function MemberPopoverContent({ )} {/* Actions */} -
+
+
); diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 8f634922..951d3079 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -37,18 +37,30 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element ), [teamName] ); + const dispatchOpenProfile = useCallback( + (memberName: string) => + window.dispatchEvent( + new CustomEvent('graph:open-profile', { detail: { teamName, memberName } }) + ), + [teamName] + ); + const dispatchCreateTask = useCallback( + (owner: string) => + window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner } })), + [teamName] + ); const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { if (ref.kind === 'task') dispatchOpenTask(ref.taskId); - else if (ref.kind === 'member') dispatchSendMessage(ref.memberName); + else if (ref.kind === 'member') dispatchOpenProfile(ref.memberName); }, - [dispatchOpenTask, dispatchSendMessage] + [dispatchOpenTask, dispatchOpenProfile] ), onSendMessage: dispatchSendMessage, onOpenTaskDetail: dispatchOpenTask, - onOpenMemberProfile: dispatchSendMessage, + onOpenMemberProfile: dispatchOpenProfile, }; return ( @@ -64,7 +76,8 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element onClose={onClose} onSendMessage={dispatchSendMessage} onOpenTaskDetail={dispatchOpenTask} - onOpenMemberProfile={dispatchSendMessage} + onOpenMemberProfile={dispatchOpenProfile} + onCreateTask={dispatchCreateTask} /> )} /> From ee5b7b5888aef3d9c759e2539c641c8cdfebd91d Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 15:18:23 +0200 Subject: [PATCH 050/113] feat(graph): current task indicator + working spinner on member nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Popover: - Shows "working on [task subject]" with spinning Loader2 when member has currentTaskId (same pattern as CurrentTaskIndicator in MemberCard) - Click task subject → opens TaskDetailDialog Canvas: - Active members with currentTaskId get subtle spinning arc around hexagon - Spinner only shows when state is active/thinking/tool_calling Adapter: - Passes currentTaskId + currentTaskSubject from ResolvedTeamMember - Looks up task subject from data.tasks Port types: - Added currentTaskId, currentTaskSubject to GraphNode --- .../agent-graph/src/canvas/draw-agents.ts | 11 +++++++ packages/agent-graph/src/ports/types.ts | 4 +++ .../agent-graph/adapters/TeamGraphAdapter.ts | 4 +++ .../agent-graph/ui/GraphNodePopover.tsx | 30 ++++++++++++++++++- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 8239532f..22400d0d 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -50,6 +50,17 @@ export function drawAgents( // Breathing animation + spawn/waiting effects drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); + // Working indicator: subtle spinning arc when member has active task + if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) { + const ringR = r + 4; + const rotation = time * 1.5; + ctx.beginPath(); + ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 0.8); + ctx.strokeStyle = hexWithAlpha(color, 0.4); + ctx.lineWidth = 1.5; + ctx.stroke(); + } + // Name + role label (single line: "jack · developer") const labelText = node.role ? `${node.label} · ${node.role}` : node.label; drawLabel(ctx, x, y, r, labelText, color); diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 4bafef3b..3b50c13e 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -49,6 +49,10 @@ export interface GraphNode { spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; /** Context window usage ratio (0..1), available for lead only */ contextUsage?: number; + /** Current task ID this member is working on */ + currentTaskId?: string | null; + /** Current task subject (for display in popover) */ + currentTaskSubject?: string; // ─── Task-specific ───────────────────────────────────────────────────── /** Short display ID (e.g., "#3") */ diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 71b3cd40..4f319801 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -158,6 +158,10 @@ export class TeamGraphAdapter { role: member.role ?? undefined, spawnStatus: spawn?.status, avatarUrl: agentAvatarUrl(member.name, 64), + currentTaskId: member.currentTaskId ?? undefined, + currentTaskSubject: member.currentTaskId + ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject + : undefined, domainRef: { kind: 'member', teamName, memberName: member.name }, }); diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 0b124a95..520ccfd3 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -7,7 +7,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; -import { MessageSquare, ExternalLink, User, Plus } from 'lucide-react'; +import { Loader2, MessageSquare, ExternalLink, User, Plus } from 'lucide-react'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -36,6 +36,7 @@ export function GraphNodePopover({ onSendMessage={onSendMessage} onOpenProfile={onOpenMemberProfile} onCreateTask={onCreateTask} + onOpenTask={onOpenTaskDetail} /> ); } @@ -70,12 +71,14 @@ function MemberPopoverContent({ onSendMessage, onOpenProfile, onCreateTask, + onOpenTask, }: { node: GraphNode; onClose: () => void; onSendMessage?: (name: string) => void; onOpenProfile?: (name: string) => void; onCreateTask?: (owner: string) => void; + onOpenTask?: (taskId: string) => void; }): React.JSX.Element { const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead'; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); @@ -171,6 +174,31 @@ function MemberPopoverContent({
)} + {/* Current task indicator — reuses same pattern as MemberCard */} + {node.currentTaskId && node.currentTaskSubject && ( +
+ + working on + +
+ )} + {/* Actions */}

diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 470108c6..78ab5652 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -1146,8 +1146,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ? 'Warming up CLI environment...' : 'Preparing environment...')} -

- Pre-flight check to catch errors before launch +

+ Pre-flight check to catch errors before launch +

From e2000d09002b9a3eed0ec786154ac7a9a3909d05 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 16:17:39 +0200 Subject: [PATCH 058/113] feat(agent-graph): show pulsing amber ring on agents awaiting approval - Add pendingApproval field to GraphNode type - Pass pendingApprovalAgents Set from store through adapter - Draw pulsing amber ring + subtle glow on agent nodes that have pending tool approval requests - Include approval state in adapter cache hash for reactivity --- .../agent-graph/src/canvas/draw-agents.ts | 21 ++++++++++++++++++ packages/agent-graph/src/ports/types.ts | 2 ++ .../agent-graph/adapters/TeamGraphAdapter.ts | 22 +++++++++++++++---- .../adapters/useTeamGraphAdapter.ts | 22 ++++++++++++++++--- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 0f5a46f0..cbae35bb 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -50,6 +50,27 @@ export function drawAgents( // Breathing animation + spawn/waiting effects drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); + // Pending approval indicator: pulsing amber ring + if (node.pendingApproval) { + const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 3); + const ringR = r + 5; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#f59e0b', pulseAlpha); + ctx.lineWidth = 2; + ctx.stroke(); + + // Subtle amber glow + const glowR = r + 12; + const grad = ctx.createRadialGradient(x, y, r, x, y, glowR); + grad.addColorStop(0, hexWithAlpha('#f59e0b', pulseAlpha * 0.25)); + grad.addColorStop(1, 'transparent'); + ctx.beginPath(); + ctx.arc(x, y, glowR, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + } + // Working indicator: subtle spinning arc when member has active task if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) { const ringR = r + 4; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 3b50c13e..9cbe403a 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -53,6 +53,8 @@ export interface GraphNode { currentTaskId?: string | null; /** Current task subject (for display in popover) */ currentTaskSubject?: string; + /** Agent is awaiting tool approval from the user */ + pendingApproval?: boolean; // ─── Task-specific ───────────────────────────────────────────────────── /** Short display ID (e.g., "#3") */ diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 4f319801..483edf73 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -50,7 +50,8 @@ export class TeamGraphAdapter { teamData: TeamData | null, teamName: string, spawnStatuses?: Record, - leadContext?: LeadContextUsage + leadContext?: LeadContextUsage, + pendingApprovalAgents?: Set ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); @@ -58,7 +59,10 @@ export class TeamGraphAdapter { // Simple hash for change detection (avoids full deep equality) const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0); - const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}`; + const approvalKey = pendingApprovalAgents?.size + ? Array.from(pendingApprovalAgents).sort().join(',') + : ''; + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}`; if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { return this.#cachedResult; } @@ -82,7 +86,15 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext); - this.#buildMemberNodes(nodes, edges, leadId, teamData, teamName, spawnStatuses); + this.#buildMemberNodes( + nodes, + edges, + leadId, + teamData, + teamName, + spawnStatuses, + pendingApprovalAgents + ); this.#buildTaskNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName); this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges); @@ -140,7 +152,8 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, - spawnStatuses?: Record + spawnStatuses?: Record, + pendingApprovalAgents?: Set ): void { for (const member of data.members) { if (member.removedAt) continue; @@ -162,6 +175,7 @@ export class TeamGraphAdapter { currentTaskSubject: member.currentTaskId ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject : undefined, + pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, domainRef: { kind: 'member', teamName, memberName: member.name }, }); diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index ec11d302..55f36f6f 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -15,16 +15,32 @@ import type { GraphDataPort } from '@claude-teams/agent-graph'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); - const { teamData, spawnStatuses, leadContext } = useStore( + const { teamData, spawnStatuses, leadContext, pendingApprovals } = useStore( useShallow((s) => ({ teamData: s.selectedTeamData, spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, + pendingApprovals: s.pendingApprovals, })) ); + const pendingApprovalAgents = useMemo(() => { + const agents = new Set(); + for (const a of pendingApprovals) { + if (a.source !== 'lead') agents.add(a.source); + } + return agents; + }, [pendingApprovals]); + return useMemo( - () => adapterRef.current.adapt(teamData, teamName, spawnStatuses, leadContext), - [teamData, teamName, spawnStatuses, leadContext] + () => + adapterRef.current.adapt( + teamData, + teamName, + spawnStatuses, + leadContext, + pendingApprovalAgents + ), + [teamData, teamName, spawnStatuses, leadContext, pendingApprovalAgents] ); } From 6b09b59d88a67a5593a8fc835b0614a99bc7b2a9 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 16:40:18 +0200 Subject: [PATCH 059/113] feat(team): render AskUserQuestion tool as readable UI in ToolApprovalSheet Instead of showing raw JSON for AskUserQuestion, render each question with header badge, question text, and options list showing labels and descriptions. Supports multiSelect indicator (checkbox vs radio style). Add MessageCircleQuestion icon for the tool. --- .../components/team/ToolApprovalSheet.tsx | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index d6e3fc66..535ba772 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -5,7 +5,7 @@ import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; -import { AlertTriangle, FileText, Search, Terminal } from 'lucide-react'; +import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; import { MemberBadge } from './MemberBadge'; @@ -35,6 +35,8 @@ function getToolIcon(toolName: string): React.JSX.Element { case 'Grep': case 'Glob': return ; + case 'AskUserQuestion': + return ; default: return ; } @@ -378,6 +380,78 @@ const ToolInputPreview = ({ toolInput: Record; projectPath?: string; }): React.JSX.Element => { + // AskUserQuestion: render questions with options as readable UI + if (toolName === 'AskUserQuestion' && Array.isArray(toolInput.questions)) { + const questions = toolInput.questions as { + question?: string; + header?: string; + options?: { label?: string; description?: string }[]; + multiSelect?: boolean; + }[]; + return ( +
+ {questions.map((q, qi) => ( +
+ {q.header && ( + + {q.header} + + )} + {q.question && ( +

+ {q.question} +

+ )} + {Array.isArray(q.options) && ( +
+ {q.options.map((opt, oi) => ( +
+ + {q.multiSelect ? '☐' : '○'} + +
+ + {opt.label} + + {opt.description && ( +

+ {opt.description} +

+ )} +
+
+ ))} +
+ )} +
+ ))} +
+ ); + } + const text = renderToolInput(toolName, toolInput, projectPath); const fileName = getToolInputFileName(toolName, toolInput); const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]); From daef2db07c73aed661152e97aef2f7f096381fbf Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 16:44:26 +0200 Subject: [PATCH 060/113] fix(team): prevent hooks ordering crash in ToolInputPreview Move useMemo/variable declarations BEFORE the AskUserQuestion early return to ensure hooks are called in consistent order regardless of which tool type is being displayed. --- src/renderer/components/team/ToolApprovalSheet.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 535ba772..a5d9c15e 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -380,6 +380,12 @@ const ToolInputPreview = ({ toolInput: Record; projectPath?: string; }): React.JSX.Element => { + const text = renderToolInput(toolName, toolInput, projectPath); + const fileName = getToolInputFileName(toolName, toolInput); + const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]); + const rawFilePath = typeof toolInput.file_path === 'string' ? toolInput.file_path : null; + const isFileTool = FILE_TOOLS.has(toolName) && rawFilePath; + // AskUserQuestion: render questions with options as readable UI if (toolName === 'AskUserQuestion' && Array.isArray(toolInput.questions)) { const questions = toolInput.questions as { @@ -452,12 +458,6 @@ const ToolInputPreview = ({ ); } - const text = renderToolInput(toolName, toolInput, projectPath); - const fileName = getToolInputFileName(toolName, toolInput); - const lines = useMemo(() => highlightLines(text, fileName), [text, fileName]); - const rawFilePath = typeof toolInput.file_path === 'string' ? toolInput.file_path : null; - const isFileTool = FILE_TOOLS.has(toolName) && rawFilePath; - return (
Date: Sat, 28 Mar 2026 16:49:24 +0200 Subject: [PATCH 061/113] feat(team): interactive AskUserQuestion options in ToolApprovalSheet - Options are clickable: single-select (radio) and multi-select (checkbox) - Selected options highlighted with green border and filled indicator - Submit button replaces Allow for AskUserQuestion - disabled until at least one option is selected - Human-readable tool display names (AskUserQuestion -> Question, etc.) - Selection resets when approval changes --- .../components/team/ToolApprovalSheet.tsx | 138 ++++++++++++++---- 1 file changed, 107 insertions(+), 31 deletions(-) diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index a5d9c15e..15705861 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -22,6 +22,30 @@ import type { ToolApprovalRequest } from '@shared/types'; // Tool icon mapping // --------------------------------------------------------------------------- +/** Human-readable tool name for the approval header */ +function getToolDisplayName(toolName: string): string { + switch (toolName) { + case 'AskUserQuestion': + return 'Question'; + case 'Bash': + return 'Terminal'; + case 'Read': + return 'Read File'; + case 'Edit': + return 'Edit File'; + case 'Write': + return 'Write File'; + case 'NotebookEdit': + return 'Edit Notebook'; + case 'Grep': + return 'Search Content'; + case 'Glob': + return 'Find Files'; + default: + return toolName; + } +} + function getToolIcon(toolName: string): React.JSX.Element { const cls = 'size-4 shrink-0'; switch (toolName) { @@ -132,10 +156,12 @@ export const ToolApprovalSheet: React.FC = () => { const [error, setError] = useState(null); const [diffExpanded, setDiffExpanded] = useState(false); const [settingsExpanded, setSettingsExpanded] = useState(false); + const [selectedOptions, setSelectedOptions] = useState>(new Set()); - // Clear error when current approval changes + // Clear error + selection when current approval changes useEffect(() => { setError(null); + setSelectedOptions(new Set()); }, [current?.requestId]); const handleRespond = useCallback( @@ -167,6 +193,21 @@ export const ToolApprovalSheet: React.FC = () => { [current, disabled, respondToToolApproval] ); + const isAskQuestion = current?.toolName === 'AskUserQuestion'; + const hasSelection = selectedOptions.size > 0; + + const handleOptionSelect = useCallback((label: string, multiSelect: boolean) => { + setSelectedOptions((prev) => { + const next = multiSelect ? new Set(prev) : new Set(); + if (next.has(label)) { + next.delete(label); + } else { + next.add(label); + } + return next; + }); + }, []); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { const tag = document.activeElement?.tagName; @@ -222,7 +263,7 @@ export const ToolApprovalSheet: React.FC = () => { )} {getToolIcon(current.toolName)} - {current.toolName} + {getToolDisplayName(current.toolName)}
@@ -247,6 +288,8 @@ export const ToolApprovalSheet: React.FC = () => { toolName={current.toolName} toolInput={current.toolInput} projectPath={selectedTeamData?.config?.projectPath} + selectedOptions={isAskQuestion ? selectedOptions : undefined} + onOptionSelect={isAskQuestion ? handleOptionSelect : undefined} /> {/* Diff preview (Write/Edit/NotebookEdit only) */} @@ -280,19 +323,30 @@ export const ToolApprovalSheet: React.FC = () => {
- ))} + {opt.label} + + {opt.description && ( +

+ {opt.description} +

+ )} +
+ + ); + })}
)}
From 75a36b5cd2d54aa8006b6fa2cc09c1f35dd9a4a3 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 16:54:22 +0200 Subject: [PATCH 062/113] fix(team): deliver AskUserQuestion answers via updatedInput + fix Enter bypass - Enter key now respects isAskQuestion selection requirement - Selected option labels sent as message to respondToToolApproval - control_response includes updatedInput with answers for AskUserQuestion - useCallback/useEffect deps updated for selectedOptions and hasSelection --- .../services/team/TeamProvisioningService.ts | 16 +++++++++++++++- .../components/team/ToolApprovalSheet.tsx | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d35dd6ba..bbd145f7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6042,13 +6042,27 @@ export class TeamProvisioningService { // IMPORTANT: request_id is NESTED inside response, NOT top-level // (asymmetry with control_request — confirmed by Python SDK, Elixir SDK and issue #29991) + const allowResponse: Record = { behavior: 'allow' }; + // For AskUserQuestion: pass user's answer via updatedInput so the CLI + // can deliver it without re-prompting. Format follows --permission-prompt-tool spec. + if (allow && message) { + const pending = run.pendingApprovals.get(requestId); + if (pending?.toolName === 'AskUserQuestion') { + const questions = (pending.toolInput.questions as { question?: string }[]) ?? []; + const answers: Record = {}; + for (const q of questions) { + if (q.question) answers[q.question] = message; + } + allowResponse.updatedInput = { ...pending.toolInput, answers }; + } + } const response = allow ? { type: 'control_response', response: { subtype: 'success', request_id: requestId, - response: { behavior: 'allow' }, + response: allowResponse, }, } : { diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 15705861..2b801363 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -170,6 +170,12 @@ export const ToolApprovalSheet: React.FC = () => { setDisabled(true); setError(null); + // For AskUserQuestion, build answers from selected options + const answersMessage = + allow && current.toolName === 'AskUserQuestion' && selectedOptions.size > 0 + ? Array.from(selectedOptions).join(', ') + : undefined; + // Safety timeout — if IPC hangs (e.g. stdin.write callback never fires), // re-enable the button so the user isn't stuck forever. const safetyTimer = setTimeout(() => { @@ -177,7 +183,13 @@ export const ToolApprovalSheet: React.FC = () => { setError('Response timed out — process may be unresponsive. Try again or stop the team.'); }, RESPOND_TIMEOUT_MS); - respondToToolApproval(current.teamName, current.runId, current.requestId, allow) + respondToToolApproval( + current.teamName, + current.runId, + current.requestId, + allow, + answersMessage + ) .then(() => { clearTimeout(safetyTimer); // Small delay before re-enabling to prevent accidental double-clicks @@ -190,7 +202,7 @@ export const ToolApprovalSheet: React.FC = () => { setDisabled(false); }); }, - [current, disabled, respondToToolApproval] + [current, disabled, respondToToolApproval, selectedOptions] ); const isAskQuestion = current?.toolName === 'AskUserQuestion'; @@ -213,6 +225,7 @@ export const ToolApprovalSheet: React.FC = () => { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (e.key === 'Enter') { + if (isAskQuestion && !hasSelection) return; e.preventDefault(); handleRespond(true); } else if (e.key === 'Escape') { @@ -223,7 +236,7 @@ export const ToolApprovalSheet: React.FC = () => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleRespond]); + }, [handleRespond, isAskQuestion, hasSelection]); // Resolve teammate color for MemberBadge (when source !== 'lead') const sourceColor = useMemo(() => { From 11506c6ea8e4e94ff6def2fe6921f270b1060142 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 17:00:06 +0200 Subject: [PATCH 063/113] fix(team): fix AskUserQuestion edge cases in ToolApprovalSheet - Option keys now include question index prefix ("qi:label") to prevent cross-question collisions when multiple questions share option labels - Single-select clears only options from the same question, not all - answersMessage built as per-question JSON map instead of flat join - Backend parses JSON answers and maps to correct questions - Fallback label "Option N" for options with undefined label --- .../services/team/TeamProvisioningService.ts | 18 ++++++----- .../components/team/ToolApprovalSheet.tsx | 31 ++++++++++++++++--- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index bbd145f7..3d510f15 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6043,17 +6043,21 @@ export class TeamProvisioningService { // IMPORTANT: request_id is NESTED inside response, NOT top-level // (asymmetry with control_request — confirmed by Python SDK, Elixir SDK and issue #29991) const allowResponse: Record = { behavior: 'allow' }; - // For AskUserQuestion: pass user's answer via updatedInput so the CLI - // can deliver it without re-prompting. Format follows --permission-prompt-tool spec. + // For AskUserQuestion: pass user's answers via updatedInput so the CLI + // can deliver them without re-prompting. Format follows --permission-prompt-tool spec. if (allow && message) { const pending = run.pendingApprovals.get(requestId); if (pending?.toolName === 'AskUserQuestion') { - const questions = (pending.toolInput.questions as { question?: string }[]) ?? []; - const answers: Record = {}; - for (const q of questions) { - if (q.question) answers[q.question] = message; + try { + const answers = JSON.parse(message) as Record; + allowResponse.updatedInput = { ...pending.toolInput, answers }; + } catch { + // If message isn't JSON, use as-is for the first question + const questions = (pending.toolInput.questions as { question?: string }[]) ?? []; + const answers: Record = {}; + if (questions[0]?.question) answers[questions[0].question] = message; + allowResponse.updatedInput = { ...pending.toolInput, answers }; } - allowResponse.updatedInput = { ...pending.toolInput, answers }; } } const response = allow diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 2b801363..8f66578c 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -170,10 +170,26 @@ export const ToolApprovalSheet: React.FC = () => { setDisabled(true); setError(null); - // For AskUserQuestion, build answers from selected options + // For AskUserQuestion, build per-question answers from selected options + // Key format in selectedOptions: "qi:label" — parse question index to map correctly const answersMessage = allow && current.toolName === 'AskUserQuestion' && selectedOptions.size > 0 - ? Array.from(selectedOptions).join(', ') + ? (() => { + const questions = Array.isArray(current.toolInput.questions) + ? (current.toolInput.questions as { question?: string }[]) + : []; + const answersByQuestion: Record = {}; + for (const key of selectedOptions) { + const colonIdx = key.indexOf(':'); + if (colonIdx < 0) continue; + const qi = parseInt(key.slice(0, colonIdx), 10); + const label = key.slice(colonIdx + 1); + const questionText = questions[qi]?.question ?? `Question ${qi + 1}`; + const existing = answersByQuestion[questionText]; + answersByQuestion[questionText] = existing ? `${existing}, ${label}` : label; + } + return JSON.stringify(answersByQuestion); + })() : undefined; // Safety timeout — if IPC hangs (e.g. stdin.write callback never fires), @@ -210,7 +226,12 @@ export const ToolApprovalSheet: React.FC = () => { const handleOptionSelect = useCallback((label: string, multiSelect: boolean) => { setSelectedOptions((prev) => { - const next = multiSelect ? new Set(prev) : new Set(); + // For single-select: clear all options from the SAME question (same prefix) + // Key format: "qi:label" where qi is the question index + const prefix = label.split(':')[0] + ':'; + const next = multiSelect + ? new Set(prev) + : new Set(Array.from(prev).filter((k) => !k.startsWith(prefix))); if (next.has(label)) { next.delete(label); } else { @@ -495,7 +516,7 @@ const ToolInputPreview = ({ {Array.isArray(q.options) && (
{q.options.map((opt, oi) => { - const optKey = opt.label ?? `opt-${oi}`; + const optKey = `${qi}:${opt.label ?? `opt-${oi}`}`; const isSelected = selectedOptions?.has(optKey) ?? false; return (
@@ -253,6 +261,7 @@ export const TokenUsageDisplay = ({ phaseNumber, totalPhases, costUsd, + contextWindowSize, }: Readonly): React.JSX.Element => { const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; // Total input tokens only (without output) — used as denominator for visible context % @@ -531,6 +540,7 @@ export const TokenUsageDisplay = ({ )} diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 483edf73..32252e9a 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -17,7 +17,12 @@ import type { GraphNodeState, GraphParticle, } from '@claude-teams/agent-graph'; -import type { InboxMessage, MemberSpawnStatusEntry, TeamData } from '@shared/types/team'; +import type { + ActiveToolCall, + InboxMessage, + MemberSpawnStatusEntry, + TeamData, +} from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; export class TeamGraphAdapter { @@ -51,7 +56,10 @@ export class TeamGraphAdapter { teamName: string, spawnStatuses?: Record, leadContext?: LeadContextUsage, - pendingApprovalAgents?: Set + pendingApprovalAgents?: Set, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); @@ -62,7 +70,44 @@ export class TeamGraphAdapter { const approvalKey = pendingApprovalAgents?.size ? Array.from(pendingApprovalAgents).sort().join(',') : ''; - const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}`; + const activeToolKey = activeTools + ? Object.entries(activeTools) + .flatMap(([memberName, tools]) => + Object.values(tools).map( + (tool) => + `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + ) + .sort() + .join('|') + : ''; + const finishedVisibleKey = finishedVisible + ? Object.entries(finishedVisible) + .flatMap(([memberName, tools]) => + Object.values(tools).map( + (tool) => + `${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + ) + .sort() + .join('|') + : ''; + const historyKey = toolHistory + ? Object.entries(toolHistory) + .map( + ([memberName, tools]) => + `${memberName}:${tools + .slice(0, 3) + .map( + (tool) => + `${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}` + ) + .join(',')}` + ) + .sort() + .join('|') + : ''; + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`; if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { return this.#cachedResult; } @@ -84,8 +129,19 @@ export class TeamGraphAdapter { const particles: GraphParticle[] = []; const leadId = `lead:${teamName}`; + const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); - this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext); + this.#buildLeadNode( + nodes, + leadId, + teamData, + teamName, + leadName, + leadContext, + activeTools, + finishedVisible, + toolHistory + ); this.#buildMemberNodes( nodes, edges, @@ -93,7 +149,10 @@ export class TeamGraphAdapter { teamData, teamName, spawnStatuses, - pendingApprovalAgents + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory ); this.#buildTaskNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName); @@ -126,23 +185,75 @@ export class TeamGraphAdapter { // ─── Private: node builders ────────────────────────────────────────────── + static #getLeadMemberName(data: TeamData, teamName: string): string { + return data.members.find((member) => isLeadMember(member))?.name ?? `${teamName}-lead`; + } + + static #selectVisibleTool( + runningTools?: Record, + finishedTools?: Record + ): ActiveToolCall | undefined { + const newestRunning = Object.values(runningTools ?? {}).sort((a, b) => + b.startedAt.localeCompare(a.startedAt) + )[0]; + if (newestRunning) return newestRunning; + return Object.values(finishedTools ?? {}).sort((a, b) => + (b.finishedAt ?? '').localeCompare(a.finishedAt ?? '') + )[0]; + } + #buildLeadNode( nodes: GraphNode[], leadId: string, data: TeamData, teamName: string, - leadContext?: LeadContextUsage + leadName: string, + leadContext?: LeadContextUsage, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record ): void { const percent = leadContext?.percent; + const activeTool = TeamGraphAdapter.#selectVisibleTool( + activeTools?.[leadName], + finishedVisible?.[leadName] + ); nodes.push({ id: leadId, kind: 'lead', label: data.config.name || teamName, - state: data.isAlive ? 'active' : 'idle', + state: !data.isAlive + ? 'idle' + : Object.keys(activeTools?.[leadName] ?? {}).length > 0 + ? 'tool_calling' + : 'active', color: data.config.color ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, - avatarUrl: agentAvatarUrl('team-lead', 64), - domainRef: { kind: 'lead', teamName }, + avatarUrl: agentAvatarUrl(leadName, 64), + activeTool: activeTool + ? { + name: activeTool.toolName, + preview: activeTool.preview, + state: activeTool.state, + startedAt: activeTool.startedAt, + finishedAt: activeTool.finishedAt, + resultPreview: activeTool.resultPreview, + source: activeTool.source, + } + : undefined, + recentTools: (toolHistory?.[leadName] ?? []) + .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) + .slice(0, 3) + .map((tool) => ({ + name: tool.toolName, + preview: tool.preview, + state: tool.state === 'error' ? 'error' : 'complete', + startedAt: tool.startedAt, + finishedAt: tool.finishedAt!, + resultPreview: tool.resultPreview, + source: tool.source, + })), + domainRef: { kind: 'lead', teamName, memberName: leadName }, }); } @@ -153,7 +264,10 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, spawnStatuses?: Record, - pendingApprovalAgents?: Set + pendingApprovalAgents?: Set, + activeTools?: Record>, + finishedVisible?: Record>, + toolHistory?: Record ): void { for (const member of data.members) { if (member.removedAt) continue; @@ -161,12 +275,19 @@ export class TeamGraphAdapter { const memberId = `member:${teamName}:${member.name}`; const spawn = spawnStatuses?.[member.name]; + const activeTool = TeamGraphAdapter.#selectVisibleTool( + activeTools?.[member.name], + finishedVisible?.[member.name] + ); + const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0; nodes.push({ id: memberId, kind: 'member', label: member.name, - state: TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), + state: hasRunningTool + ? 'tool_calling' + : TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), color: member.color ?? undefined, role: member.role ?? undefined, spawnStatus: spawn?.status, @@ -176,6 +297,29 @@ export class TeamGraphAdapter { ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject : undefined, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, + activeTool: activeTool + ? { + name: activeTool.toolName, + preview: activeTool.preview, + state: activeTool.state, + startedAt: activeTool.startedAt, + finishedAt: activeTool.finishedAt, + resultPreview: activeTool.resultPreview, + source: activeTool.source, + } + : undefined, + recentTools: (toolHistory?.[member.name] ?? []) + .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) + .slice(0, 3) + .map((tool) => ({ + name: tool.toolName, + preview: tool.preview, + state: tool.state === 'error' ? 'error' : 'complete', + startedAt: tool.startedAt, + finishedAt: tool.finishedAt!, + resultPreview: tool.resultPreview, + source: tool.source, + })), domainRef: { kind: 'member', teamName, memberName: member.name }, }); diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index 55f36f6f..6356d0c4 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -15,12 +15,23 @@ import type { GraphDataPort } from '@claude-teams/agent-graph'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); - const { teamData, spawnStatuses, leadContext, pendingApprovals } = useStore( + const { + teamData, + spawnStatuses, + leadContext, + pendingApprovals, + activeTools, + finishedVisible, + toolHistory, + } = useStore( useShallow((s) => ({ teamData: s.selectedTeamData, spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, pendingApprovals: s.pendingApprovals, + activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, + finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined, + toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, })) ); @@ -39,8 +50,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { teamName, spawnStatuses, leadContext, - pendingApprovalAgents + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory ), - [teamData, teamName, spawnStatuses, leadContext, pendingApprovalAgents] + [ + teamData, + teamName, + spawnStatuses, + leadContext, + pendingApprovalAgents, + activeTools, + finishedVisible, + toolHistory, + ] ); } diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 520ccfd3..72d89ab1 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -80,7 +80,10 @@ function MemberPopoverContent({ onCreateTask?: (owner: string) => void; onOpenTask?: (taskId: string) => void; }): React.JSX.Element { - const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead'; + const memberName = + node.domainRef.kind === 'member' || node.domainRef.kind === 'lead' + ? node.domainRef.memberName + : 'team-lead'; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); const statusLabel = node.state === 'active' @@ -89,7 +92,9 @@ function MemberPopoverContent({ ? 'Idle' : node.state === 'terminated' ? 'Offline' - : node.state; + : node.state === 'tool_calling' + ? 'Running tool' + : node.state; const statusDotColor = node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling' @@ -199,6 +204,67 @@ function MemberPopoverContent({
)} + {node.activeTool && ( +
+
+ + + {node.activeTool.state === 'running' + ? 'Running tool' + : node.activeTool.state === 'error' + ? 'Tool failed' + : 'Tool finished'} + +
+
+ {node.activeTool.preview + ? `${node.activeTool.name}: ${node.activeTool.preview}` + : node.activeTool.name} +
+ {node.activeTool.resultPreview && node.activeTool.state !== 'running' && ( +
+ {node.activeTool.resultPreview} +
+ )} +
+ )} + + {node.recentTools && node.recentTools.length > 0 && ( +
+
+ Recent tools +
+
+ {node.recentTools.slice(0, 3).map((tool) => ( +
+
+ {tool.preview ? `${tool.name}: ${tool.preview}` : tool.name} +
+ {tool.resultPreview && ( +
{tool.resultPreview}
+ )} +
+ ))} +
+
+ )} + {/* Actions */}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 021305fb..0a5104c9 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -674,6 +674,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele () => (teamProjectPath ? [teamProjectPath] : []), [teamProjectPath] ); + // Live branch sync now uses main-side background tracking instead of renderer polling. useBranchSync(branchSyncPaths, { live: true }); const leadBranch = useStore((s) => teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 765e8861..39c0508f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -69,6 +69,21 @@ export function isLeadThought(msg: InboxMessage): boolean { return false; } +/** + * Check if a message from lead session/process is protocol noise that should be + * completely excluded from the timeline (not shown as thoughts OR standalone messages). + * + * When `isLeadThought` returns false due to `isThoughtProtocolNoise`, the message + * falls through to become a standalone ActivityItem — but ActivityItem can't parse + * noise JSON wrapped in `` tags. This helper catches those cases + * so `groupTimelineItems` can skip them entirely. + */ +function isLeadSessionNoise(msg: InboxMessage): boolean { + if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false; + if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; + return isThoughtProtocolNoise(msg.text); +} + export type TimelineItem = | { type: 'message'; message: InboxMessage } | { type: 'lead-thoughts'; group: LeadThoughtGroup }; @@ -109,6 +124,12 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { } pendingThoughts.push(msg); } else { + // Skip lead session/process messages that are protocol noise — they should + // not appear in the timeline at all (neither as thoughts nor as standalone messages). + // isLeadThought already rejects these from thoughts, but without this guard + // they fall through as standalone ActivityItem cards that can't parse the noise JSON. + // Check BEFORE flushThoughts() so noise between two thoughts doesn't split the group. + if (isLeadSessionNoise(msg)) continue; flushThoughts(); result.push({ type: 'message', message: msg }); } diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 32252e9a..724893b2 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -67,6 +67,42 @@ export class TeamGraphAdapter { // Simple hash for change detection (avoids full deep equality) const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0); + const memberKey = teamData.members + .map( + (member) => + `${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.removedAt ?? ''}` + ) + .sort() + .join('|'); + const taskKey = teamData.tasks + .map( + (task) => + `${task.id}:${task.status}:${task.owner ?? ''}:${task.reviewState ?? ''}:${task.displayId ?? ''}:${task.subject}:${task.updatedAt ?? ''}` + ) + .sort() + .join('|'); + const processKey = teamData.processes + .map( + (proc) => + `${proc.id}:${proc.label}:${proc.registeredBy ?? ''}:${proc.url ?? ''}:${proc.stoppedAt ?? ''}` + ) + .sort() + .join('|'); + const messageKey = teamData.messages + .slice(0, 25) + .map((msg) => TeamGraphAdapter.#getMessageParticleKey(msg)) + .join('|'); + const commentKey = teamData.tasks + .map((task) => { + const comments = task.comments ?? []; + const tail = comments + .slice(Math.max(0, comments.length - 5)) + .map((comment) => `${comment.id}:${comment.author}:${comment.createdAt}`) + .join(','); + return `${task.id}:${comments.length}:${tail}`; + }) + .sort() + .join('|'); const approvalKey = pendingApprovalAgents?.size ? Array.from(pendingApprovalAgents).sort().join(',') : ''; @@ -107,7 +143,7 @@ export class TeamGraphAdapter { .sort() .join('|') : ''; - const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`; + const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`; if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { return this.#cachedResult; } @@ -156,8 +192,8 @@ export class TeamGraphAdapter { ); this.#buildTaskNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName); - this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges); - this.#buildCommentParticles(particles, teamData, teamName, edges); + this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, leadName, edges); + this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); this.#cachedResult = { nodes, @@ -436,38 +472,38 @@ export class TeamGraphAdapter { messages: readonly InboxMessage[], teamName: string, leadId: string, + leadName: string, edges: GraphEdge[] ): void { - const recent = messages.slice(-20); + const ordered = [...messages].reverse(); // First call: record all existing message IDs without creating particles. // This prevents old messages from spawning particles when the graph opens. if (!this.#initialMessagesSeen) { this.#initialMessagesSeen = true; - for (const msg of recent) { - const msgKey = msg.messageId ?? msg.timestamp; + for (const msg of ordered) { + const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); this.#seenMessageIds.add(msgKey); } return; } // Subsequent calls: only create particles for messages not yet seen. - for (const msg of recent) { - const msgKey = msg.messageId ?? msg.timestamp; + for (const msg of ordered) { + const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg); if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); - const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, edges); + const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); if (!edgeId) continue; - const ts = typeof msg.timestamp === 'string' ? new Date(msg.timestamp).getTime() : 0; particles.push({ - id: `particle:msg:${msgKey}`, + id: `particle:msg:${teamName}:${msgKey}`, edgeId, - progress: (ts % 800) / 1000, - kind: 'message', + progress: 0, + kind: 'inbox_message', color: msg.color ?? '#66ccff', - label: msg.summary ?? undefined, + label: TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox'), }); } } @@ -476,6 +512,8 @@ export class TeamGraphAdapter { particles: GraphParticle[], data: TeamData, teamName: string, + leadId: string, + leadName: string, edges: GraphEdge[] ): void { // First call: record current comment counts without creating particles. @@ -500,22 +538,46 @@ export class TeamGraphAdapter { const prevCount = this.#seenCommentCounts.get(task.id) ?? 0; const currentCount = task.comments?.length ?? 0; - if (currentCount > prevCount && prevCount > 0) { - // New comment(s) detected — create a particle from the author to the task - const newComment = task.comments![currentCount - 1]; - const authorNodeId = `member:${teamName}:${newComment.author}`; - const taskNodeId = `task:${teamName}:${task.id}`; - const authorEdge = edges.find((e) => e.source === authorNodeId && e.target === taskNodeId); + if (currentCount > prevCount) { + for (let index = prevCount; index < currentCount; index += 1) { + const newComment = task.comments?.[index]; + if (!newComment) continue; + const authorNodeId = TeamGraphAdapter.#resolveParticipantId( + newComment.author, + teamName, + leadId, + leadName + ); + const taskNodeId = `task:${teamName}:${task.id}`; + const authorEdge = + edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ?? + edges.find((e) => e.source === taskNodeId && e.target === authorNodeId); - if (authorEdge) { - particles.push({ - id: `particle:comment:${task.id}:${currentCount}`, - edgeId: authorEdge.id, - progress: 0, - kind: 'message', - color: memberColors.get(newComment.author) ?? '#cc88ff', - label: '\u{1F4AC}', - }); + const edgeId = + authorEdge?.id ?? + (() => { + const syntheticEdgeId = `edge:msg:${authorNodeId}:${taskNodeId}`; + if (!edges.some((edge) => edge.id === syntheticEdgeId)) { + edges.push({ + id: syntheticEdgeId, + source: authorNodeId, + target: taskNodeId, + type: 'message', + }); + } + return syntheticEdgeId; + })(); + + if (authorNodeId) { + particles.push({ + id: `particle:comment:${teamName}:${task.id}:${index + 1}`, + edgeId, + progress: 0, + kind: 'task_comment', + color: memberColors.get(newComment.author) ?? '#cc88ff', + label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'), + }); + } } } @@ -588,13 +650,14 @@ export class TeamGraphAdapter { msg: InboxMessage, teamName: string, leadId: string, + leadName: string, edges: GraphEdge[] ): string | null { const { from, to } = msg; if (from && to) { - const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId); - const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId); + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); + const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? @@ -603,7 +666,7 @@ export class TeamGraphAdapter { } if (from && !to) { - const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId); + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName); return ( edges.find( (e) => @@ -616,8 +679,39 @@ export class TeamGraphAdapter { return null; } - static #resolveParticipantId(name: string, teamName: string, leadId: string): string { - if (name === 'user' || name === 'team-lead') return leadId; + static #resolveParticipantId( + name: string, + teamName: string, + leadId: string, + leadName?: string + ): string { + const normalized = name.trim().toLowerCase(); + if (normalized === 'user' || normalized === 'team-lead') return leadId; + if (leadName && normalized === leadName.trim().toLowerCase()) return leadId; return `member:${teamName}:${name}`; } + + static #buildParticleLabel( + text: string | undefined, + kind: 'inbox' | 'comment', + max = 26 + ): string | undefined { + const normalized = text?.replace(/\s+/g, ' ').trim(); + const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}'; + if (!normalized) return prefix; + const clipped = + normalized.length > max + ? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026` + : normalized; + return `${prefix} ${clipped}`; + } + + static #getMessageParticleKey(msg: InboxMessage): string { + if (msg.messageId && msg.messageId.trim().length > 0) { + return msg.messageId; + } + return [msg.timestamp, msg.from ?? '', msg.to ?? '', msg.summary ?? '', msg.text ?? ''].join( + '\u0000' + ); + } } diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 951d3079..4577c7d3 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -18,9 +18,13 @@ const TeamGraphOverlay = lazy(() => export interface TeamGraphTabProps { teamName: string; + isActive?: boolean; } -export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element => { +export const TeamGraphTab = ({ + teamName, + isActive = true, +}: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const [fullscreen, setFullscreen] = useState(false); @@ -69,6 +73,7 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element data={graphData} events={events} className="size-full" + suspendAnimation={!isActive} onRequestFullscreen={() => setFullscreen(true)} renderOverlay={({ node, onClose }) => ( s.branchByPath)`. * - * The module-level polling manager guarantees: - * - A single shared `setInterval` across all live subscribers - * - Deduplication: N components subscribing to the same path = 1 poll - * - Automatic cleanup: timer stops when all subscribers unmount + * The module-level tracking manager guarantees: + * - Deduplication: N components subscribing to the same path = 1 background tracker + * - Automatic cleanup: tracking stops when all subscribers unmount */ import { useEffect, useMemo } from 'react'; +import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { normalizePath } from '@renderer/utils/pathNormalize'; // ============================================================================= -// Constants -// ============================================================================= - -const POLL_INTERVAL_MS = 6_000; - -// ============================================================================= -// Module-level polling manager (singleton, outside React lifecycle) +// Module-level tracking manager (singleton, outside React lifecycle) // ============================================================================= const livePaths = new Map(); -let pollTimer: ReturnType | null = null; - -function startPollingIfNeeded(): void { - if (pollTimer || livePaths.size === 0) return; - pollTimer = setInterval(() => { - const paths = Array.from(livePaths.values()).map((v) => v.actualPath); - void useStore.getState().fetchBranches(paths); - }, POLL_INTERVAL_MS); -} - -function stopPollingIfEmpty(): void { - if (pollTimer && livePaths.size === 0) { - clearInterval(pollTimer); - pollTimer = null; - } -} - function subscribe(normalizedKey: string, actualPath: string): void { const entry = livePaths.get(normalizedKey); if (entry) { entry.refCount++; } else { livePaths.set(normalizedKey, { actualPath, refCount: 1 }); + void api.teams?.setProjectBranchTracking?.(actualPath, true).catch(() => undefined); } - startPollingIfNeeded(); } function unsubscribe(normalizedKey: string): void { @@ -63,8 +40,8 @@ function unsubscribe(normalizedKey: string): void { entry.refCount--; if (entry.refCount <= 0) { livePaths.delete(normalizedKey); + void api.teams?.setProjectBranchTracking?.(entry.actualPath, false).catch(() => undefined); } - stopPollingIfEmpty(); } // ============================================================================= @@ -75,7 +52,7 @@ function unsubscribe(normalizedKey: string): void { * Sync git branch data for the given project paths into the store. * * @param paths - Raw project paths to resolve branches for - * @param options.live - When true, keeps polling every 6s while mounted + * @param options.live - When true, enables main-side branch tracking while mounted */ export function useBranchSync(paths: string[], options?: { live?: boolean }): void { const live = options?.live ?? false; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 72632133..6a32b2d0 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -5,6 +5,7 @@ import { api } from '@renderer/api'; import { syncRendererTelemetry } from '@renderer/sentry'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; +import { normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, @@ -1003,6 +1004,29 @@ export function initializeNotificationListeners(): () => void { } } + if (api.teams?.onProjectBranchChange) { + const cleanup = api.teams.onProjectBranchChange((_event: unknown, event) => { + if (!event?.projectPath) return; + const normalizedPath = normalizePath(event.projectPath); + if (!normalizedPath) return; + useStore.setState((prev) => { + const current = prev.branchByPath[normalizedPath]; + if (current === event.branch) { + return {}; + } + return { + branchByPath: { + ...prev.branchByPath, + [normalizedPath]: event.branch, + }, + }; + }); + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Tool approval events from CLI control_request protocol if (api.teams?.onToolApprovalEvent) { const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => { diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index 4fc1e8c4..4bcf5e1f 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -372,11 +372,11 @@ export const createTabSlice: StateCreator = (set, ge } } - // For team tabs, re-select the team so global selectedTeamData matches this tab. + // For team and graph tabs, re-select the team so global selectedTeamData matches this tab. // Without this, switching between team A and team B tabs leaves stale data // because each TeamDetailView is kept mounted (CSS display-toggle) and its // useEffect(teamName) only fires once on mount. - if (tab.type === 'team' && tab.teamName) { + if ((tab.type === 'team' || tab.type === 'graph') && tab.teamName) { if (state.selectedTeamName !== tab.teamName) { // Different team -- full reload (also auto-selects project via selectTeam) void state.selectTeam(tab.teamName); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 914b776f..6d1fac51 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -930,17 +930,31 @@ export const createTeamSlice: StateCreator = (set, setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }), fetchBranches: async (paths: string[]) => { - const results: Record = {}; - for (const p of paths) { - try { - const branch = await api.teams.getProjectBranch(p); - results[normalizePath(p)] = branch; - } catch { - results[normalizePath(p)] = null; - } - } + const entries = await Promise.all( + paths.map(async (p) => { + try { + const branch = await api.teams.getProjectBranch(p); + return [normalizePath(p), branch] as const; + } catch { + return [normalizePath(p), null] as const; + } + }) + ); + const results: Record = Object.fromEntries(entries); if (Object.keys(results).length > 0) { - set((state) => ({ branchByPath: { ...state.branchByPath, ...results } })); + set((state) => { + let changed = false; + for (const [key, value] of Object.entries(results)) { + if (state.branchByPath[key] !== value) { + changed = true; + break; + } + } + if (!changed) { + return {}; + } + return { branchByPath: { ...state.branchByPath, ...results } }; + }); } }, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 43f94377..b0485fa0 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -51,6 +51,7 @@ import type { MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, + ProjectBranchChangeEvent, ReplaceMembersRequest, SendMessageRequest, SendMessageResult, @@ -489,6 +490,7 @@ export interface TeamsAPI { value: 'lead' | 'user' | null ) => Promise; getProjectBranch: (projectPath: string) => Promise; + setProjectBranchTracking: (projectPath: string, enabled: boolean) => Promise; getAttachments: (teamName: string, messageId: string) => Promise; killProcess: (teamName: string, pid: number) => Promise; getLeadActivity: (teamName: string) => Promise; @@ -530,6 +532,9 @@ export interface TeamsAPI { attachmentId: string, mimeType: string ) => Promise; + onProjectBranchChange: ( + callback: (event: unknown, data: ProjectBranchChangeEvent) => void + ) => () => void; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 59f236f9..eaec6531 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -579,6 +579,11 @@ export interface TeamChangeEvent { detail?: string; } +export interface ProjectBranchChangeEvent { + projectPath: string; + branch: string | null; +} + /** Per-member spawn status entry, exposed to renderer via IPC. */ export interface MemberSpawnStatusEntry { status: MemberSpawnStatus; diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index 5a85497c..dff92e57 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -108,14 +108,23 @@ export function isOnlyTeammateMessageBlocks(text: string): boolean { // Combined protocol noise check for lead thoughts // --------------------------------------------------------------------------- +/** + * Detects `` opening tags (even without closing tag). + * Claude's lead model sometimes echoes raw teammate message XML in assistant + * text output — these are always protocol artifacts, never real user content. + */ +const TEAMMATE_MESSAGE_OPEN_RE = /^\s*
@@ -193,7 +199,7 @@ function ToolbarToggle({ onClick={onClick} className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-[11px] font-mono transition-all cursor-pointer border ${active - ? 'text-[#ffffff] bg-[rgba(100,200,255,0.2)] border-[rgba(100,200,255,0.4)]' + ? 'text-[#aaeeff] bg-[rgba(100,200,255,0.15)] border-[rgba(100,200,255,0.25)]' : 'text-[#66ccff50] bg-transparent border-transparent hover:text-[#66ccff90] hover:bg-[rgba(100,200,255,0.06)]' }`} > From 304a2a7f796789dfe418166c1adf1df7cedb2bf3 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 29 Mar 2026 00:05:29 +0200 Subject: [PATCH 074/113] feat(graph): enhance GraphControls with settings toggle and improved zoom functionality - Added a settings toggle to the GraphControls for better user interaction. - Implemented event listeners to close settings when clicking outside or pressing Escape. - Improved zoom controls to mark user interaction, preventing auto-fit adjustments during user actions. - Refactored GraphOverlay to streamline rendering and position updates for selected nodes. - Updated GraphView to utilize new layout effects for better performance and responsiveness. --- packages/agent-graph/src/ui/GraphControls.tsx | 217 +++++++++++------- packages/agent-graph/src/ui/GraphOverlay.tsx | 67 +++--- packages/agent-graph/src/ui/GraphView.tsx | 183 +++++++++++---- .../components/layout/PaneContent.tsx | 11 +- src/renderer/components/layout/PaneView.tsx | 2 +- .../sidebar/DateGroupedSessions.tsx | 14 +- .../components/sidebar/GlobalTaskList.tsx | 41 +--- .../components/sidebar/SessionItem.tsx | 4 +- .../components/sidebar/TaskFiltersPopover.tsx | 23 ++ .../components/sidebar/taskFiltersState.ts | 2 + .../components/team/ClaudeLogsSection.tsx | 8 +- .../components/team/TeamDetailView.tsx | 110 ++++----- .../team/messages/MessagesPanel.tsx | 84 ++++++- .../team/sidebar/TeamSidebarHost.tsx | 73 ++++++ .../team/sidebar/TeamSidebarPortalManager.ts | 173 ++++++++++++++ .../team/sidebar/TeamSidebarPortalSource.tsx | 66 ++++++ .../team/sidebar/TeamSidebarRail.tsx | 37 +++ .../team/sidebar/teamSidebarUiState.ts | 122 ++++++++++ .../team/useClaudeLogsController.ts | 48 ++-- .../agent-graph/ui/TeamGraphOverlay.tsx | 6 +- .../features/agent-graph/ui/TeamGraphTab.tsx | 45 ++-- .../sidebar/TeamSidebarPortalManager.test.ts | 101 ++++++++ 22 files changed, 1105 insertions(+), 332 deletions(-) create mode 100644 src/renderer/components/team/sidebar/TeamSidebarHost.tsx create mode 100644 src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts create mode 100644 src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx create mode 100644 src/renderer/components/team/sidebar/TeamSidebarRail.tsx create mode 100644 src/renderer/components/team/sidebar/teamSidebarUiState.ts create mode 100644 test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 54e9c835..b60e7e54 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -3,20 +3,21 @@ * Positioned below system buttons (top-10) to avoid overlap on macOS. */ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Columns3, Expand, + Settings2, Eye, EyeOff, Maximize2, - Minus, Pause, Pin, Play, - Plus, Server, X, + ZoomIn, + ZoomOut, } from 'lucide-react'; export interface GraphFilterState { @@ -53,6 +54,8 @@ export function GraphControls({ teamColor, isAlive, }: GraphControlsProps): React.JSX.Element { + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const settingsRef = useRef(null); const toggle = useCallback( (key: keyof GraphFilterState) => { onFiltersChange({ ...filters, [key]: !filters[key] }); @@ -60,103 +63,142 @@ export function GraphControls({ [filters, onFiltersChange], ); + useEffect(() => { + if (!isSettingsOpen) return; + + const handlePointerDown = (event: MouseEvent): void => { + const target = event.target as Node | null; + if (!target) return; + if (settingsRef.current?.contains(target)) return; + setIsSettingsOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setIsSettingsOpen(false); + } + }; + + window.addEventListener('mousedown', handlePointerDown); + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('mousedown', handlePointerDown); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isSettingsOpen]); + const nameColor = teamColor ?? '#aaeeff'; return ( -
- {/* Left: team name + status indicator */} -
+ <> +
{isAlive && ( -
+
)} - + {teamName} - - beta -
- {/* Center: filter toggles */} -
- toggle('showTasks')} - icon={} - label="Tasks" - /> - toggle('showProcesses')} - icon={} - label="Proc" - /> - toggle('showEdges')} - icon={filters.showEdges ? : } - label="Edges" - /> - - toggle('paused')} - icon={filters.paused ? : } - /> -
- - {/* Right: zoom + actions */} -
- } /> - } /> - } /> - {onRequestPinAsTab && ( - <> - +
+
+
} - label="Pin" + onClick={() => setIsSettingsOpen((value) => !value)} + icon={} + label="View" + active={isSettingsOpen} /> - - )} - {onRequestFullscreen && ( - <> - +
+ + {isSettingsOpen && ( +
+ toggle('showTasks')} + icon={} + label="Tasks" + block + /> + toggle('showProcesses')} + icon={} + label="Processes" + block + /> + toggle('showEdges')} + icon={filters.showEdges ? : } + label="Edges" + block + /> +
+ toggle('paused')} + icon={filters.paused ? : } + label={filters.paused ? 'Resume' : 'Pause'} + block + /> +
+ )} +
+ +
+ {onRequestPinAsTab && } />} + {onRequestFullscreen && ( } label="Fullscreen" /> - - )} - {onRequestClose && ( - } /> - )} + )} + {onRequestClose && } />} +
-
+ +
+
+ } /> + } label="Fit" /> + } /> +
+
+ ); } @@ -166,16 +208,21 @@ function ToolbarButton({ onClick, icon, label, + active = false, }: { onClick?: () => void; icon: React.ReactNode; label?: string; + active?: boolean; }): React.JSX.Element { return ( ); } - -function Separator(): React.JSX.Element { - return
; -} diff --git a/packages/agent-graph/src/ui/GraphOverlay.tsx b/packages/agent-graph/src/ui/GraphOverlay.tsx index 301e6998..0cee384c 100644 --- a/packages/agent-graph/src/ui/GraphOverlay.tsx +++ b/packages/agent-graph/src/ui/GraphOverlay.tsx @@ -9,64 +9,51 @@ import type { GraphEventPort } from '../ports/GraphEventPort'; export interface GraphOverlayProps { selectedNode: GraphNode | null; - worldToScreen: (wx: number, wy: number) => { x: number; y: number }; events?: GraphEventPort; onDeselect: () => void; } export function GraphOverlay({ selectedNode, - worldToScreen, events, onDeselect, }: GraphOverlayProps): React.JSX.Element | null { if (!selectedNode) return null; - const screenPos = worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); - return (
-
-
- {selectedNode.label} +
+ {selectedNode.label} +
+ {selectedNode.sublabel && ( +
+ {selectedNode.sublabel}
- {selectedNode.sublabel && ( -
- {selectedNode.sublabel} -
- )} - {selectedNode.role && ( -
- {selectedNode.role} -
- )} -
- {(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && ( - { - const ref = selectedNode.domainRef; - if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName); - onDeselect(); - }} - /> - )} - + )} + {selectedNode.role && ( +
+ {selectedNode.role}
+ )} +
+ {(selectedNode.kind === 'member' || selectedNode.kind === 'lead') && ( + { + const ref = selectedNode.domainRef; + if (ref.kind === 'member') events?.onSendMessage?.(ref.memberName, ref.teamName); + onDeselect(); + }} + /> + )} +
); diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index fe401daa..78bd2200 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -10,7 +10,8 @@ * ALL animation state (positions, particles, effects, time) lives in refs. */ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; @@ -68,9 +69,12 @@ export function GraphView({ const containerRef = useRef(null); const canvasHandle = useRef(null); + const overlayRef = useRef(null); const rafRef = useRef(0); const lastTimeRef = useRef(0); const runningRef = useRef(false); + const hasAutoFit = useRef(false); + const allowAutoFitRef = useRef(true); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -172,25 +176,63 @@ export function GraphView({ }; }, [effectivePaused, animate]); - // ─── Auto-fit: center graph immediately when data arrives ────────────── - const hasAutoFit = useRef(false); + const fitGraphToViewport = useCallback(() => { + const el = containerRef.current; + if (!el || data.nodes.length === 0) return; + camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + }, [camera, data.nodes.length, simulation.stateRef]); + + // ─── Auto-fit: until first user interaction, also react to container resizes ───── useEffect(() => { - if (data.nodes.length > 0 && !hasAutoFit.current) { - hasAutoFit.current = true; - // Immediate fit (simulation already settled from 120 pre-ticks) - const el = containerRef.current; - if (el) { - camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); - } - // Second fit after mount stabilizes (ResizeObserver may fire late) - const timer = setTimeout(() => { - if (el) { - camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); - } - }, 300); - return () => clearTimeout(timer); + if (data.nodes.length === 0) { + hasAutoFit.current = false; + allowAutoFitRef.current = true; + return; } - }, [data.nodes.length, camera, simulation.stateRef]); + + if (!hasAutoFit.current) { + hasAutoFit.current = true; + fitGraphToViewport(); + + const raf1 = requestAnimationFrame(() => { + fitGraphToViewport(); + requestAnimationFrame(() => { + fitGraphToViewport(); + }); + }); + + return () => cancelAnimationFrame(raf1); + } + }, [data.nodes.length, fitGraphToViewport]); + + useEffect(() => { + const el = containerRef.current; + if (!el || data.nodes.length === 0) return; + + let frame = 0; + const observer = new ResizeObserver(() => { + if (!allowAutoFitRef.current) return; + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + fitGraphToViewport(); + }); + }); + + observer.observe(el); + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, [data.nodes.length, fitGraphToViewport]); + + const markUserInteracted = useCallback(() => { + allowAutoFitRef.current = false; + }, []); + + const handleWheel = useCallback((e: WheelEvent) => { + markUserInteracted(); + camera.handleWheel(e); + }, [camera, markUserInteracted]); // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); @@ -208,13 +250,15 @@ export function GraphView({ if (interaction.dragNodeId.current) { // Hit a node → will drag it + markUserInteracted(); isPanningRef.current = false; } else { // Hit empty space → pan + markUserInteracted(); isPanningRef.current = true; camera.handlePanStart(e.clientX, e.clientY); } - }, [camera, interaction, simulation.stateRef]); + }, [camera, interaction, markUserInteracted, simulation.stateRef]); const handleMouseMove = useCallback((e: React.MouseEvent) => { // Dragging with left button held @@ -313,6 +357,58 @@ export function GraphView({ ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null : null; + useLayoutEffect(() => { + if (!selectedNode || !containerRef.current || !overlayRef.current) { + return; + } + + const container = containerRef.current; + const floating = overlayRef.current; + + const reference = { + getBoundingClientRect(): DOMRect { + const containerRect = container.getBoundingClientRect(); + const screenPos = camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + return DOMRect.fromRect({ + x: containerRect.left + screenPos.x, + y: containerRect.top + screenPos.y, + width: 0, + height: 0, + }); + }, + }; + + const updatePosition = async (): Promise => { + const { x, y } = await computePosition(reference, floating, { + strategy: 'fixed', + placement: 'right-start', + middleware: [ + offset(16), + flip({ + boundary: container, + padding: 12, + fallbackPlacements: ['left-start', 'bottom-start', 'top-start'], + }), + shift({ + boundary: container, + padding: 12, + }), + ], + }); + + floating.style.left = `${x}px`; + floating.style.top = `${y}px`; + }; + + const cleanup = autoUpdate(reference, floating, updatePosition, { + animationFrame: true, + }); + + void updatePosition(); + + return cleanup; + }, [camera, selectedNode]); + // ─── Render ───────────────────────────────────────────────────────────── return (
@@ -321,7 +417,7 @@ export function GraphView({ showHexGrid={config?.showHexGrid ?? true} showStarField={config?.showStarField ?? true} bloomIntensity={config?.bloomIntensity ?? 0.6} - onWheel={camera.handleWheel} + onWheel={handleWheel} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} @@ -331,9 +427,16 @@ export function GraphView({ { + markUserInteracted(); + camera.zoomIn(); + }} + onZoomOut={() => { + markUserInteracted(); + camera.zoomOut(); + }} onZoomToFit={() => { + markUserInteracted(); const el = containerRef.current; if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); }} @@ -345,28 +448,22 @@ export function GraphView({ isAlive={data.isAlive} /> - {selectedNode && renderOverlay ? ( -
- {renderOverlay({ - node: selectedNode, - screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), - onClose: () => setSelectedNodeId(null), - })} + {selectedNode && ( +
+ {renderOverlay ? ( + renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + }) + ) : ( + setSelectedNodeId(null)} + /> + )}
- ) : ( - setSelectedNodeId(null)} - /> )}
); diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index fb661611..a7449d29 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -21,9 +21,10 @@ import type { Pane } from '@renderer/types/panes'; interface PaneContentProps { pane: Pane; + isPaneFocused: boolean; } -export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { +export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JSX.Element => { const activeTabId = pane.activeTabId; // Show default dashboard if no tabs are open in this pane @@ -51,7 +52,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'teams' && } {tab.type === 'team' && ( - + )} {tab.type === 'session' && ( @@ -68,7 +69,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'schedules' && } {tab.type === 'graph' && ( - + )}
diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx index 29db5e24..51039bf3 100644 --- a/src/renderer/components/layout/PaneView.tsx +++ b/src/renderer/components/layout/PaneView.tsx @@ -49,7 +49,7 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => { }} onMouseDown={handleMouseDown} > - + {/* Edge split drop zones - visible only during active drag when under MAX_PANES */} diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index dd98b1a3..0cf12c66 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -150,7 +150,7 @@ type VirtualItem = * Mismatch causes items to overlap! */ const HEADER_HEIGHT = 28; -const SESSION_HEIGHT = 58; // Must match h-[58px] in SessionItem.tsx +const SESSION_HEIGHT = 54; // Must match h-[54px] in SessionItem.tsx const LOADER_HEIGHT = 36; const OVERSCAN = 5; @@ -736,10 +736,10 @@ export const DateGroupedSessions = (): React.JSX.Element => { return (
{projectSelector} -
- +
+

{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'} @@ -747,7 +747,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */} setShowCountTooltip(true)} onMouseLeave={() => setShowCountTooltip(false)} @@ -898,7 +898,7 @@ export const DateGroupedSessions = (): React.JSX.Element => { > {item.type === 'pinned-header' ? (
{
) : item.type === 'header' ? (
(null); - const setGroupingMode = (mode: TaskGroupingMode): void => { setGroupingModeState(mode); saveGroupingMode(mode); @@ -326,8 +323,8 @@ export const GlobalTaskList = ({ })); }, [viewMode, repositoryGroups, projects]); - // Resolve local filter to a project path - const selectedProjectPath = localProjectFilter; + // Resolve project filter from filters state + const selectedProjectPath = filters.projectPath; const filtered = useMemo(() => { let result = globalTasks; @@ -355,7 +352,7 @@ export const GlobalTaskList = ({ return result; }, [ globalTasks, - selectedProjectPath, + filters.projectPath, filters.statusIds, filters.teamName, filters.readFilter, @@ -493,27 +490,13 @@ export const GlobalTaskList = ({ open={filtersPopoverOpen} onOpenChange={setFiltersPopoverOpen} teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))} + projectOptions={projectFilterOptions} filters={filters} onFiltersChange={setFilters} onApply={() => {}} />
- {/* Project filter */} -
- setLocalProjectFilter(v)} - placeholder="All Projects" - searchPlaceholder="Search projects..." - emptyMessage="No projects" - className="text-[11px]" - resetLabel="All Projects" - onReset={() => setLocalProjectFilter(null)} - /> -
- {/* Pinned tasks section */} {pinnedTasks.length > 0 && !showArchived && (
@@ -547,14 +530,10 @@ export const GlobalTaskList = ({
)} - {/* Grouping mode — compact segmented toggle */} + {/* Grouping mode — compact text toggle */}
Group by: -
+
{(['none', 'project', 'time'] as const).map((mode) => { const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; return ( @@ -563,10 +542,8 @@ export const GlobalTaskList = ({ type="button" onClick={() => setGroupingMode(mode)} className={cn( - 'rounded px-2 py-0.5 transition-colors', - groupingMode === mode - ? 'bg-surface-raised text-text-secondary shadow-sm ring-1 ring-[var(--color-border)]' - : 'text-text-muted hover:text-text-secondary' + 'rounded px-1.5 py-0.5 transition-colors', + groupingMode === mode ? 'text-text' : 'text-text-muted hover:text-text-secondary' )} > {label} diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 9695f22e..35098c35 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -240,13 +240,13 @@ export const SessionItem = ({ } }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - // Height must match SESSION_HEIGHT (58px) in DateGroupedSessions.tsx for virtual scroll + // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll return ( <>
+ {projectOptions.length > 0 && ( +
+ + Project + + setDraft({ ...draft, projectPath: v || null })} + placeholder="All Projects" + searchPlaceholder="Search projects..." + emptyMessage="No projects" + className="text-[12px]" + resetLabel="All Projects" + onReset={() => setDraft({ ...draft, projectPath: null })} + /> +
+ )} +
Comments diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index c7f0cc59..1cc7a86e 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -25,6 +25,7 @@ export type ReadFilter = 'all' | 'unread' | 'read'; export interface TaskFiltersState { statusIds: Set; teamName: string | null; + projectPath: string | null; /** @deprecated Use readFilter instead */ unreadOnly: boolean; readFilter: ReadFilter; @@ -33,6 +34,7 @@ export interface TaskFiltersState { export const defaultTaskFiltersState = (): TaskFiltersState => ({ statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)), teamName: null, + projectPath: null, unreadOnly: false, readFilter: 'all', }); diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index a6ceb73e..1c8b56c5 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { Brain, Expand, MessageSquare, Terminal, Wrench } from 'lucide-react'; +import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react'; import { ClaudeLogsDialog } from './ClaudeLogsDialog'; import { ClaudeLogsPanel } from './ClaudeLogsPanel'; @@ -96,11 +96,7 @@ export const ClaudeLogsSection = ({ - - - } + icon={null} badge={ctrl.badge} afterBadge={ ctrl.data.total > 0 ? ( diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 0a5104c9..7a015cc1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -84,6 +84,9 @@ import { ScheduleSection } from './schedule/ScheduleSection'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { TeamSidebarHost } from './sidebar/TeamSidebarHost'; +import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource'; +import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -102,6 +105,7 @@ import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { teamName: string; + isPaneFocused?: boolean; } interface CreateTaskDialogState { @@ -178,7 +182,10 @@ function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTask ); } -export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => { +export const TeamDetailView = ({ + teamName, + isPaneFocused = false, +}: TeamDetailViewProps): React.JSX.Element => { const { isLight } = useTheme(); const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); const [selectedTask, setSelectedTask] = useState(null); @@ -1101,6 +1108,27 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : nameColorSet(data.config.name); + const sharedMessagesPanelProps = { + teamName, + onTogglePosition: toggleMessagesPanelMode, + members: activeMembers, + tasks: data.tasks, + messages: data.messages, + isTeamAlive: data.isAlive, + leadActivity: leadActivityByTeam[teamName], + leadContextUpdatedAt, + timeWindow, + teamSessionIds, + currentLeadSessionId: data.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: setSelectedMember, + onTaskClick: setSelectedTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + }; return ( <> @@ -1155,48 +1183,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele )} {/* Messages sidebar (left, after context panel) */} - {messagesPanelMode === 'sidebar' && ( -
+ -
-
- -
-
-
- -
-
- {/* Resize handle */} -
-
- )} + +
} {messagesPanelMode === 'inline' && ( - + )} s.openTeamTab); const composerTextareaRef = useRef(null); + const sidebarScrollRef = useRef(null); const handleExpandContent = useCallback(() => { // no-op: user is reading expanded content, not composing }, []); - const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); - const [messagesFilter, setMessagesFilter] = useState({ - from: new Set(), - to: new Set(), - showNoise: false, - }); - const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); - const [messagesCollapsed, setMessagesCollapsed] = useState(true); - const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false); - const [expandedItemKey, setExpandedItemKey] = useState(null); + const initialSidebarStateRef = useRef(getTeamMessagesSidebarUiState(teamName)); + const [messagesSearchQuery, setMessagesSearchQuery] = useState( + initialSidebarStateRef.current.messagesSearchQuery + ); + const [messagesFilter, setMessagesFilter] = useState( + initialSidebarStateRef.current.messagesFilter + ); + const [messagesFilterOpen, setMessagesFilterOpen] = useState( + initialSidebarStateRef.current.messagesFilterOpen + ); + const [messagesCollapsed, setMessagesCollapsed] = useState( + initialSidebarStateRef.current.messagesCollapsed + ); + const [sidebarSearchVisible, setSidebarSearchVisible] = useState( + initialSidebarStateRef.current.sidebarSearchVisible + ); + const [expandedItemKey, setExpandedItemKey] = useState( + initialSidebarStateRef.current.expandedItemKey + ); + const [sidebarScrollTop, setSidebarScrollTop] = useState( + initialSidebarStateRef.current.sidebarScrollTop + ); + + useEffect(() => { + initialSidebarStateRef.current = getTeamMessagesSidebarUiState(teamName); + setMessagesSearchQuery(initialSidebarStateRef.current.messagesSearchQuery); + setMessagesFilter(initialSidebarStateRef.current.messagesFilter); + setMessagesFilterOpen(initialSidebarStateRef.current.messagesFilterOpen); + setMessagesCollapsed(initialSidebarStateRef.current.messagesCollapsed); + setSidebarSearchVisible(initialSidebarStateRef.current.sidebarSearchVisible); + setExpandedItemKey(initialSidebarStateRef.current.expandedItemKey); + setSidebarScrollTop(initialSidebarStateRef.current.sidebarScrollTop); + }, [teamName]); + + useEffect(() => { + setTeamMessagesSidebarUiState(teamName, { + messagesSearchQuery, + messagesFilter, + messagesFilterOpen, + messagesCollapsed, + sidebarSearchVisible, + expandedItemKey, + sidebarScrollTop, + }); + }, [ + teamName, + messagesSearchQuery, + messagesFilter, + messagesFilterOpen, + messagesCollapsed, + sidebarSearchVisible, + expandedItemKey, + sidebarScrollTop, + ]); + + useLayoutEffect(() => { + if (position !== 'sidebar') return; + const el = sidebarScrollRef.current; + if (!el) return; + el.scrollTop = sidebarScrollTop; + }, [position, sidebarScrollTop]); const filteredMessages = useMemo(() => { return filterTeamMessages(messages, { @@ -479,7 +535,11 @@ export const MessagesPanel = memo(function MessagesPanel({
)} {/* Scrollable content */} -
+
setSidebarScrollTop(e.currentTarget.scrollTop)} + >
(null); + +interface TeamSidebarHostProps { + teamName: string; + surface: TeamSidebarSurface; + isActive: boolean; + isFocused: boolean; + children?: React.ReactNode; +} + +export function useTeamSidebarHostId(): string | null { + return useContext(TeamSidebarHostContext); +} + +export const TeamSidebarHost = ({ + teamName, + surface, + isActive, + isFocused, + children, +}: TeamSidebarHostProps): React.JSX.Element => { + const hostId = useId(); + const [element, setElement] = useState(null); + const { messagesPanelMode, messagesPanelWidth } = useStore((s) => ({ + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + })); + const snapshot = useTeamSidebarPortalSnapshot(); + const isVisible = messagesPanelMode === 'sidebar'; + const isOwner = isVisible && snapshot.activeHostIdByTeam[teamName] === hostId; + + useLayoutEffect(() => { + upsertTeamSidebarHost(hostId, { + teamName, + surface, + element, + isActive, + isFocused, + }); + return () => { + removeTeamSidebarHost(hostId); + }; + }, [element, hostId, isActive, isFocused, surface, teamName]); + + return ( + +
+ {children} +
+
+ ); +}; diff --git a/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts b/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts new file mode 100644 index 00000000..83de088a --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarPortalManager.ts @@ -0,0 +1,173 @@ +import { useSyncExternalStore } from 'react'; + +export type TeamSidebarSurface = 'team' | 'graph-tab' | 'graph-overlay'; + +interface TeamSidebarHostEntry { + id: string; + teamName: string; + surface: TeamSidebarSurface; + element: HTMLElement | null; + isActive: boolean; + isFocused: boolean; + order: number; +} + +interface TeamSidebarSourceEntry { + id: string; + teamName: string; + isActive: boolean; + isFocused: boolean; + order: number; +} + +interface TeamSidebarSnapshot { + version: number; + activeHostIdByTeam: Record; + activeSourceIdByTeam: Record; +} + +const SURFACE_PRIORITY: Record = { + team: 1, + 'graph-tab': 2, + 'graph-overlay': 3, +}; + +const hostById = new Map(); +const sourceById = new Map(); +const listeners = new Set<() => void>(); +let version = 0; +let nextOrder = 1; + +function emit(): void { + version += 1; + for (const listener of listeners) { + listener(); + } +} + +function sortHosts(a: TeamSidebarHostEntry, b: TeamSidebarHostEntry): number { + const focusedDiff = Number(b.isFocused) - Number(a.isFocused); + if (focusedDiff !== 0) return focusedDiff; + const activeDiff = Number(b.isActive) - Number(a.isActive); + if (activeDiff !== 0) return activeDiff; + const priorityDiff = SURFACE_PRIORITY[b.surface] - SURFACE_PRIORITY[a.surface]; + if (priorityDiff !== 0) return priorityDiff; + return b.order - a.order; +} + +function sortSources(a: TeamSidebarSourceEntry, b: TeamSidebarSourceEntry): number { + const focusedDiff = Number(b.isFocused) - Number(a.isFocused); + if (focusedDiff !== 0) return focusedDiff; + const activeDiff = Number(b.isActive) - Number(a.isActive); + if (activeDiff !== 0) return activeDiff; + return b.order - a.order; +} + +function buildSnapshot(): TeamSidebarSnapshot { + const activeHostIdByTeam: Record = {}; + const activeSourceIdByTeam: Record = {}; + + const hostsByTeam = new Map(); + for (const host of hostById.values()) { + if (!host.element) continue; + const list = hostsByTeam.get(host.teamName) ?? []; + list.push(host); + hostsByTeam.set(host.teamName, list); + } + for (const [teamName, hosts] of hostsByTeam.entries()) { + const winner = [...hosts].sort(sortHosts)[0]; + if (winner) activeHostIdByTeam[teamName] = winner.id; + } + + const sourcesByTeam = new Map(); + for (const source of sourceById.values()) { + const list = sourcesByTeam.get(source.teamName) ?? []; + list.push(source); + sourcesByTeam.set(source.teamName, list); + } + for (const [teamName, sources] of sourcesByTeam.entries()) { + const winner = [...sources].sort(sortSources)[0]; + if (winner) activeSourceIdByTeam[teamName] = winner.id; + } + + return { + version, + activeHostIdByTeam, + activeSourceIdByTeam, + }; +} + +let cachedSnapshot = buildSnapshot(); + +function refreshSnapshot(): void { + cachedSnapshot = buildSnapshot(); + emit(); +} + +export function upsertTeamSidebarHost( + id: string, + entry: Omit +): void { + const existing = hostById.get(id); + hostById.set(id, { + id, + order: existing?.order ?? nextOrder++, + ...entry, + }); + refreshSnapshot(); +} + +export function removeTeamSidebarHost(id: string): void { + if (!hostById.delete(id)) return; + refreshSnapshot(); +} + +export function upsertTeamSidebarSource( + id: string, + entry: Omit +): void { + const existing = sourceById.get(id); + sourceById.set(id, { + id, + order: existing?.order ?? nextOrder++, + ...entry, + }); + refreshSnapshot(); +} + +export function removeTeamSidebarSource(id: string): void { + if (!sourceById.delete(id)) return; + refreshSnapshot(); +} + +export function getTeamSidebarHostElement(hostId: string): HTMLElement | null { + return hostById.get(hostId)?.element ?? null; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): TeamSidebarSnapshot { + return cachedSnapshot; +} + +export function useTeamSidebarPortalSnapshot(): TeamSidebarSnapshot { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function getTeamSidebarPortalSnapshotForTests(): TeamSidebarSnapshot { + return cachedSnapshot; +} + +export function resetTeamSidebarPortalManagerForTests(): void { + hostById.clear(); + sourceById.clear(); + listeners.clear(); + version = 0; + nextOrder = 1; + cachedSnapshot = buildSnapshot(); +} diff --git a/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx new file mode 100644 index 00000000..c72eab36 --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx @@ -0,0 +1,66 @@ +import { useId, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import { useStore } from '@renderer/store'; + +import { + getTeamSidebarHostElement, + removeTeamSidebarSource, + upsertTeamSidebarSource, + useTeamSidebarPortalSnapshot, +} from './TeamSidebarPortalManager'; +import { useTeamSidebarHostId } from './TeamSidebarHost'; + +interface TeamSidebarPortalSourceProps { + teamName: string; + isActive: boolean; + isFocused: boolean; + children: React.ReactNode; +} + +export const TeamSidebarPortalSource = ({ + teamName, + isActive, + isFocused, + children, +}: TeamSidebarPortalSourceProps): React.JSX.Element | null => { + const sourceId = useId(); + const hostId = useTeamSidebarHostId(); + const messagesPanelMode = useStore((s) => s.messagesPanelMode); + const snapshot = useTeamSidebarPortalSnapshot(); + + useLayoutEffect(() => { + upsertTeamSidebarSource(sourceId, { + teamName, + isActive, + isFocused, + }); + return () => { + removeTeamSidebarSource(sourceId); + }; + }, [isActive, isFocused, sourceId, teamName]); + + if (!hostId || messagesPanelMode !== 'sidebar') { + return null; + } + + if (snapshot.activeSourceIdByTeam[teamName] !== sourceId) { + return null; + } + + const activeHostId = snapshot.activeHostIdByTeam[teamName]; + if (!activeHostId) { + return null; + } + + if (activeHostId === hostId) { + return <>{children}; + } + + const target = getTeamSidebarHostElement(activeHostId); + if (!target) { + return null; + } + + return createPortal(children, target); +}; diff --git a/src/renderer/components/team/sidebar/TeamSidebarRail.tsx b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx new file mode 100644 index 00000000..73331d28 --- /dev/null +++ b/src/renderer/components/team/sidebar/TeamSidebarRail.tsx @@ -0,0 +1,37 @@ +import { ClaudeLogsSection } from '../ClaudeLogsSection'; +import { MessagesPanel } from '../messages/MessagesPanel'; + +import type { MouseEventHandler } from 'react'; +import type { ComponentProps } from 'react'; + +type SharedMessagesPanelProps = Omit, 'position'>; + +interface TeamSidebarRailProps { + teamName: string; + messagesPanelProps: SharedMessagesPanelProps; + isResizing: boolean; + onResizeMouseDown: MouseEventHandler; +} + +export const TeamSidebarRail = ({ + teamName, + messagesPanelProps, + isResizing, + onResizeMouseDown, +}: TeamSidebarRailProps): React.JSX.Element => { + return ( +
+
+ +
+
+
+ +
+
+
+ ); +}; diff --git a/src/renderer/components/team/sidebar/teamSidebarUiState.ts b/src/renderer/components/team/sidebar/teamSidebarUiState.ts new file mode 100644 index 00000000..ae7acd82 --- /dev/null +++ b/src/renderer/components/team/sidebar/teamSidebarUiState.ts @@ -0,0 +1,122 @@ +import { DEFAULT_CLAUDE_LOGS_FILTER } from '../ClaudeLogsFilterPopover'; + +import type { ClaudeLogsFilterState } from '../ClaudeLogsFilterPopover'; +import type { ClaudeLogsViewerState } from '../CliLogsRichView'; +import type { MessagesFilterState } from '../messages/MessagesFilterPopover'; + +export interface TeamMessagesSidebarUiState { + messagesSearchQuery: string; + messagesFilter: MessagesFilterState; + messagesFilterOpen: boolean; + messagesCollapsed: boolean; + sidebarSearchVisible: boolean; + expandedItemKey: string | null; + sidebarScrollTop: number; +} + +export interface TeamClaudeLogsSidebarUiState { + searchQuery: string; + filter: ClaudeLogsFilterState; + filterOpen: boolean; + viewerState: ClaudeLogsViewerState; +} + +const messagesStateByTeam = new Map(); +const claudeLogsStateByTeam = new Map(); + +function cloneMessagesFilter(filter: MessagesFilterState): MessagesFilterState { + return { + from: new Set(filter.from), + to: new Set(filter.to), + showNoise: filter.showNoise, + }; +} + +function cloneClaudeLogsFilter(filter: ClaudeLogsFilterState): ClaudeLogsFilterState { + return { + streams: new Set(filter.streams), + kinds: new Set(filter.kinds), + }; +} + +function cloneViewerState(viewerState: ClaudeLogsViewerState): ClaudeLogsViewerState { + return { + collapsedGroupIds: new Set(viewerState.collapsedGroupIds), + expandedItemIds: new Set(viewerState.expandedItemIds), + expandedSubagentIds: new Set(viewerState.expandedSubagentIds), + viewport: { ...viewerState.viewport }, + }; +} + +export function createDefaultMessagesSidebarUiState(): TeamMessagesSidebarUiState { + return { + messagesSearchQuery: '', + messagesFilter: { + from: new Set(), + to: new Set(), + showNoise: false, + }, + messagesFilterOpen: false, + messagesCollapsed: true, + sidebarSearchVisible: false, + expandedItemKey: null, + sidebarScrollTop: 0, + }; +} + +export function createDefaultClaudeLogsSidebarUiState(): TeamClaudeLogsSidebarUiState { + return { + searchQuery: '', + filter: { + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + }, + filterOpen: false, + viewerState: { + collapsedGroupIds: new Set(), + expandedItemIds: new Set(), + expandedSubagentIds: new Set(), + viewport: { mode: 'edge', edge: 'newest' }, + }, + }; +} + +export function getTeamMessagesSidebarUiState(teamName: string): TeamMessagesSidebarUiState { + const state = messagesStateByTeam.get(teamName) ?? createDefaultMessagesSidebarUiState(); + return { + ...state, + messagesFilter: cloneMessagesFilter(state.messagesFilter), + }; +} + +export function setTeamMessagesSidebarUiState( + teamName: string, + state: TeamMessagesSidebarUiState +): void { + messagesStateByTeam.set(teamName, { + ...state, + messagesFilter: cloneMessagesFilter(state.messagesFilter), + }); +} + +export function getTeamClaudeLogsSidebarUiState(teamName: string): TeamClaudeLogsSidebarUiState { + const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState(); + return { + searchQuery: state.searchQuery, + filter: cloneClaudeLogsFilter(state.filter), + filterOpen: state.filterOpen, + viewerState: cloneViewerState(state.viewerState), + }; +} + +export function setTeamClaudeLogsSidebarUiState( + teamName: string, + state: TeamClaudeLogsSidebarUiState +): void { + claudeLogsStateByTeam.set(teamName, { + searchQuery: state.searchQuery, + filter: cloneClaudeLogsFilter(state.filter), + filterOpen: state.filterOpen, + viewerState: cloneViewerState(state.viewerState), + }); +} diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index 6f190a88..bee14201 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -14,6 +14,11 @@ import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; +import { + createDefaultClaudeLogsSidebarUiState, + getTeamClaudeLogsSidebarUiState, + setTeamClaudeLogsSidebarUiState, +} from './sidebar/teamSidebarUiState'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsViewerState } from './CliLogsRichView'; @@ -364,12 +369,7 @@ function filterStreamJsonText( // ============================================================================= function createDefaultViewerState(): ClaudeLogsViewerState { - return { - collapsedGroupIds: new Set(), - expandedItemIds: new Set(), - expandedSubagentIds: new Set(), - viewport: { mode: 'edge', edge: 'newest' }, - }; + return createDefaultClaudeLogsSidebarUiState().viewerState; } // ============================================================================= @@ -389,15 +389,17 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController const [error, setError] = useState(null); // ── Search & filter state ───────────────────────────────────────────── - const [searchQuery, setSearchQuery] = useState(''); - const [filter, setFilter] = useState(() => ({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - })); - const [filterOpen, setFilterOpen] = useState(false); + const initialSidebarStateRef = useRef(getTeamClaudeLogsSidebarUiState(teamName)); + const [searchQuery, setSearchQuery] = useState(initialSidebarStateRef.current.searchQuery); + const [filter, setFilter] = useState( + initialSidebarStateRef.current.filter + ); + const [filterOpen, setFilterOpen] = useState(initialSidebarStateRef.current.filterOpen); // ── Viewer state (expansion + viewport) ─────────────────────────────── - const [viewerState, setViewerState] = useState(createDefaultViewerState); + const [viewerState, setViewerState] = useState( + initialSidebarStateRef.current.viewerState + ); const onViewerStateChange = useCallback((state: ClaudeLogsViewerState) => { setViewerState(state); @@ -415,6 +417,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController // ── Reset on team change ────────────────────────────────────────────── useEffect(() => { + initialSidebarStateRef.current = getTeamClaudeLogsSidebarUiState(teamName); setLoadedCount(PAGE_SIZE); setData({ lines: [], total: 0, hasMore: false }); setPending(null); @@ -422,14 +425,21 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController latestRef.current = null; atTopRef.current = true; setError(null); - setSearchQuery(''); - setFilter({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - }); - setViewerState(createDefaultViewerState()); + setSearchQuery(initialSidebarStateRef.current.searchQuery); + setFilter(initialSidebarStateRef.current.filter); + setFilterOpen(initialSidebarStateRef.current.filterOpen); + setViewerState(initialSidebarStateRef.current.viewerState); }, [teamName]); + useEffect(() => { + setTeamClaudeLogsSidebarUiState(teamName, { + searchQuery, + filter, + filterOpen, + viewerState, + }); + }, [teamName, searchQuery, filter, filterOpen, viewerState]); + // ── Sync refs ───────────────────────────────────────────────────────── useEffect(() => { committedRef.current = data; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index ac42d858..91219682 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -6,6 +6,7 @@ import { useCallback } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; +import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; import { GraphNodePopover } from './GraphNodePopover'; @@ -54,13 +55,14 @@ export const TeamGraphOverlay = ({ }; return ( -
+
+ ( export interface TeamGraphTabProps { teamName: string; isActive?: boolean; + isPaneFocused?: boolean; } export const TeamGraphTab = ({ teamName, isActive = true, + isPaneFocused = false, }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const [fullscreen, setFullscreen] = useState(false); @@ -68,24 +71,32 @@ export const TeamGraphTab = ({ }; return ( -
- setFullscreen(true)} - renderOverlay={({ node, onClose }) => ( - - )} +
+ +
+ setFullscreen(true)} + renderOverlay={({ node, onClose }) => ( + + )} + /> +
{fullscreen && ( setFullscreen(false)} /> diff --git a/test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts b/test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts new file mode 100644 index 00000000..738e6b84 --- /dev/null +++ b/test/renderer/components/team/sidebar/TeamSidebarPortalManager.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + getTeamSidebarPortalSnapshotForTests, + resetTeamSidebarPortalManagerForTests, + upsertTeamSidebarHost, + upsertTeamSidebarSource, +} from '@renderer/components/team/sidebar/TeamSidebarPortalManager'; + +afterEach(() => { + resetTeamSidebarPortalManagerForTests(); +}); + +describe('TeamSidebarPortalManager', () => { + it('prefers overlay host over graph tab and team hosts for the same team', () => { + upsertTeamSidebarHost('team-host', { + teamName: 'alpha', + surface: 'team', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + upsertTeamSidebarHost('graph-host', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: true, + isFocused: false, + }); + upsertTeamSidebarHost('overlay-host', { + teamName: 'alpha', + surface: 'graph-overlay', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeHostIdByTeam.alpha).toBe('overlay-host'); + }); + + it('prefers the active team host over an inactive graph host', () => { + upsertTeamSidebarHost('team-host', { + teamName: 'alpha', + surface: 'team', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + upsertTeamSidebarHost('graph-host', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: false, + isFocused: false, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeHostIdByTeam.alpha).toBe('team-host'); + }); + + it('prefers focused graph host over unfocused graph host of the same priority', () => { + upsertTeamSidebarHost('graph-a', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: true, + isFocused: false, + }); + upsertTeamSidebarHost('graph-b', { + teamName: 'alpha', + surface: 'graph-tab', + element: document.createElement('div'), + isActive: true, + isFocused: true, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeHostIdByTeam.alpha).toBe('graph-b'); + }); + + it('prefers focused active source over stale mounted source for the same team', () => { + upsertTeamSidebarSource('source-a', { + teamName: 'alpha', + isActive: true, + isFocused: false, + }); + upsertTeamSidebarSource('source-b', { + teamName: 'alpha', + isActive: true, + isFocused: true, + }); + + const snapshot = getTeamSidebarPortalSnapshotForTests(); + + expect(snapshot.activeSourceIdByTeam.alpha).toBe('source-b'); + }); +}); From 46355d87dfefbf2883d0ef07e66ce03464d09887 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 29 Mar 2026 01:16:04 +0200 Subject: [PATCH 075/113] refactor(session): improve session label formatting and enhance session item display - Replaced direct access to session.firstMessage with formatSessionLabel for consistent label formatting across components. - Updated SessionItem, TeamSessionsSection, and KanbanFilterPopover to utilize the new formatting function. - Enhanced display logic in SessionItem to differentiate between regular and team sessions, improving user experience. - Added new icons for team sessions and adjusted metadata display for better clarity. --- .../components/sidebar/SessionItem.tsx | 126 ++++++++++++------ .../components/team/TeamSessionsSection.tsx | 5 +- .../team/dialogs/GlobalTaskDetailDialog.tsx | 30 +++-- .../team/dialogs/TaskDetailDialog.tsx | 2 +- .../dialogs/globalTaskDetailDialogLoading.ts | 34 +++++ .../team/kanban/KanbanFilterPopover.tsx | 3 +- src/renderer/hooks/useViewportCommentRead.ts | 54 +++++++- src/renderer/utils/sessionTitleParser.ts | 68 ++++++++++ .../dialogs/GlobalTaskDetailDialog.test.ts | 69 ++++++++++ .../hooks/useViewportCommentRead.test.ts | 77 +++++++++++ .../renderer/utils/sessionTitleParser.test.ts | 124 +++++++++++++++++ 11 files changed, 533 insertions(+), 59 deletions(-) create mode 100644 src/renderer/components/team/dialogs/globalTaskDetailDialogLoading.ts create mode 100644 src/renderer/utils/sessionTitleParser.ts create mode 100644 test/renderer/components/team/dialogs/GlobalTaskDetailDialog.test.ts create mode 100644 test/renderer/hooks/useViewportCommentRead.test.ts create mode 100644 test/renderer/utils/sessionTitleParser.test.ts diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 35098c35..b05565b2 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -8,9 +8,10 @@ import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; +import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatDistanceToNowStrict } from 'date-fns'; -import { EyeOff, MessageSquare, Pin } from 'lucide-react'; +import { EyeOff, MessageSquare, Pin, Play, RotateCw, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { OngoingIndicator } from '../common/OngoingIndicator'; @@ -178,7 +179,7 @@ export const SessionItem = ({ type: 'session', sessionId: session.id, projectId: activeProjectId, - label: session.firstMessage?.slice(0, 50) ?? 'Session', + label: formatSessionLabel(session.firstMessage), }, forceNewTab ? { forceNewTab } : { replaceActiveTab: true } ); @@ -191,7 +192,7 @@ export const SessionItem = ({ setContextMenu({ x: e.clientX, y: e.clientY }); }, []); - const sessionLabel = session.firstMessage?.slice(0, 50) ?? 'Session'; + const sessionLabel = formatSessionLabel(session.firstMessage); const handleOpenInCurrentPane = useCallback(() => { if (!activeProjectId) return; @@ -253,49 +254,86 @@ export const SessionItem = ({ ...(isHidden ? { opacity: 0.5 } : {}), }} > - {/* First line: title + ongoing indicator + pin/hidden icons */} -
- {multiSelectActive && ( - onToggleSelect?.()} - onClick={(e) => e.stopPropagation()} - className="size-3.5 shrink-0 accent-blue-500" - /> - )} - {session.isOngoing && } - {isPinned && } - {isHidden && } - - {session.firstMessage ?? 'Untitled'} - -
- - {/* Second line: message count + time + context consumption */} -
- - - {session.messageCount} - - · - {formatShortTime(new Date(session.createdAt))} - {session.contextConsumption != null && session.contextConsumption > 0 && ( + {(() => { + const parsed = parseSessionTitle(session.firstMessage); + const isTeam = parsed.kind !== 'regular'; + return ( <> - · - + {/* First line: title + ongoing indicator + pin/hidden icons */} +
+ {multiSelectActive && ( + onToggleSelect?.()} + onClick={(e) => e.stopPropagation()} + className="size-3.5 shrink-0 accent-blue-500" + /> + )} + {session.isOngoing && } + {isPinned && } + {isHidden && } + {isTeam ? ( + + + {parsed.displayText} + + ) : ( + + {parsed.displayText} + + )} +
+ + {/* Second line: metadata */} +
+ {isTeam && parsed.projectName && ( + <> + {parsed.projectName} + · + + )} + {isTeam && ( + <> + + {parsed.kind === 'team-resume' ? ( + + ) : ( + + )} + {parsed.kind === 'team-resume' ? 'resume' : 'new'} + + · + + )} + + + {session.messageCount} + + · + {formatShortTime(new Date(session.createdAt))} + {session.contextConsumption != null && session.contextConsumption > 0 && ( + <> + · + + + )} +
- )} -
+ ); + })()} {contextMenu && diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx index 13929447..7e19cb64 100644 --- a/src/renderer/components/team/TeamSessionsSection.tsx +++ b/src/renderer/components/team/TeamSessionsSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; +import { formatSessionLabel } from '@renderer/utils/sessionTitleParser'; import { formatDistanceToNowStrict } from 'date-fns'; import { AlertCircle, @@ -69,7 +70,7 @@ export const TeamSessionsSection = ({ type: 'session', sessionId: session.id, projectId, - label: session.firstMessage?.slice(0, 50) ?? 'Session', + label: formatSessionLabel(session.firstMessage), }, { forceNewTab: true } ); @@ -173,7 +174,7 @@ const SessionRow = ({ onToggleFilter, }: SessionRowProps): React.JSX.Element => { const timeAgo = formatShortTime(new Date(session.createdAt)); - const label = session.firstMessage ?? 'Untitled session'; + const label = formatSessionLabel(session.firstMessage); return (
{ selectedTeamName, selectedTeamData, selectedTeamLoading, + selectedTeamError, selectTeam, openTeamTab, setPendingReviewRequest, @@ -32,6 +37,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, selectedTeamLoading: s.selectedTeamLoading, + selectedTeamError: s.selectedTeamError, selectTeam: s.selectTeam, openTeamTab: s.openTeamTab, setPendingReviewRequest: s.setPendingReviewRequest, @@ -41,6 +47,11 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { const teamName = globalTaskDetail?.teamName ?? ''; const taskId = globalTaskDetail?.taskId ?? ''; + const hasTargetTeamData = hasSelectedTargetTeamData( + teamName, + selectedTeamName, + selectedTeamData?.teamName + ); // Load full team data in the background to enable "as before" details (logs/changes/members). useEffect(() => { @@ -65,13 +76,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { teamName, ]); - const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData; - // Team data is still loading when: - // - selectTeam() hasn't updated selectedTeamName yet (team switch pending) - // - selectedTeamName matches but IPC fetch is still in flight - const isThisTeamLoading = - selectedTeamName !== teamName || - (selectedTeamName === teamName && selectedTeamLoading && !selectedTeamData); + const isFullTeamLoaded = hasTargetTeamData; const taskMap = useMemo(() => { const map = new Map(); @@ -119,12 +124,21 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { const kanbanTaskState = isFullTeamLoaded ? selectedTeamData?.kanbanState.tasks[taskId] : undefined; + const loading = shouldKeepGlobalTaskDialogLoading({ + teamName, + taskId, + selectedTeamName, + selectedTeamDataPresent: hasTargetTeamData, + selectedTeamLoading, + selectedTeamError, + hasTaskInMap: taskMap.has(taskId), + }); return ( { if (!v && lightboxOpenRef.current) return; - if (!v) onClose(); + if (!v) handleClose(); }} > { const isLead = session.id === leadSessionId; const isSelected = filter.sessionId === session.id; - const label = session.firstMessage?.slice(0, 50) ?? session.id.slice(0, 8); + const label = formatSessionLabel(session.firstMessage) || session.id.slice(0, 8); return (
{/* Settings expanded content — below actions row */} - + {/* Timeout progress bar */} diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx index 802cd6cc..aaebf66b 100644 --- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx +++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { @@ -11,7 +11,7 @@ import { import { useStore } from '@renderer/store'; import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; -import type { ToolApprovalTimeoutAction } from '@shared/types'; +import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/types'; export const ToolApprovalSettingsToggle: React.FC<{ expanded: boolean; onToggle: () => void }> = ({ expanded, @@ -37,10 +37,17 @@ export const ToolApprovalSettingsToggle: React.FC<{ expanded: boolean; onToggle: ); -export const ToolApprovalSettingsContent: React.FC<{ expanded: boolean }> = ({ expanded }) => { +export const ToolApprovalSettingsContent: React.FC<{ + expanded: boolean; + teamName?: string; +}> = ({ expanded, teamName }) => { const [localSeconds, setLocalSeconds] = useState(''); const settings = useStore((s) => s.toolApprovalSettings); - const updateSettings = useStore((s) => s.updateToolApprovalSettings); + const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings); + const updateSettings = useCallback( + (patch: Partial) => rawUpdateSettings(patch, teamName), + [rawUpdateSettings, teamName] + ); if (!expanded) return null; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 631a5a74..f6555d2a 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -696,7 +696,10 @@ export interface TeamSlice { /** Resolved permission approvals: request_id → allowed (true/false). Used for noise row icons. */ resolvedApprovals: Map; toolApprovalSettings: ToolApprovalSettings; - updateToolApprovalSettings: (patch: Partial) => Promise; + updateToolApprovalSettings: ( + patch: Partial, + forTeam?: string + ) => Promise; respondToToolApproval: ( teamName: string, runId: string, @@ -2347,8 +2350,8 @@ export const createTeamSlice: StateCreator = (set, set({ provisioningProgressUnsubscribe: unsubscribe }); }, - updateToolApprovalSettings: async (patch) => { - const teamName = get().selectedTeamName; + updateToolApprovalSettings: async (patch, forTeam) => { + const teamName = forTeam ?? get().selectedTeamName; const current = get().toolApprovalSettings; const merged = { ...current, ...patch }; set({ toolApprovalSettings: merged }); From 098aa234b25c7f61eb77ea61669d7a6c347efb23 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 00:05:22 +0300 Subject: [PATCH 085/113] =?UTF-8?q?fix(graph):=20Fullscreen=20button=20cli?= =?UTF-8?q?ckable=20=E2=80=94=20add=20pointer-events-auto=20to=20action=20?= =?UTF-8?q?bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent-graph/src/ui/GraphControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index b60e7e54..1280f11b 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -167,7 +167,7 @@ export function GraphControls({
Date: Mon, 30 Mar 2026 00:09:48 +0300 Subject: [PATCH 086/113] fix(team): skip stale permission_request messages from previous runs Inbox files persist across team runs. Permission_request messages from a previous run (e.g. when team was launched without auto-approve) were being reprocessed on the next launch, showing false ToolApprovalSheet popups even when the new run has bypassPermissions enabled. Filter by timestamp: skip permission_request messages older than run.startedAt. --- src/main/services/team/TeamProvisioningService.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 32fcc481..7460f160 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4087,10 +4087,20 @@ export class TeamProvisioningService { try { const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); const permMsgsToMarkRead: { messageId: string }[] = []; + const runStartedAtMs = Date.parse(run.startedAt); for (const msg of leadInboxMessages) { if (typeof msg.text !== 'string') continue; const perm = parsePermissionRequest(msg.text); if (!perm) continue; + // Skip permission_requests from previous runs — they're stale + const msgTs = Date.parse(msg.timestamp); + if ( + Number.isFinite(msgTs) && + Number.isFinite(runStartedAtMs) && + msgTs < runStartedAtMs + ) { + continue; + } // Dedup is handled inside handleTeammatePermissionRequest via processedPermissionRequestIds this.handleTeammatePermissionRequest(run, perm, msg.timestamp); // Mark unread permission_request messages as read to prevent stale unread indicators From 7258de90c341ecf519b49a8a70ad8057bca1bf23 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 12:45:03 +0300 Subject: [PATCH 087/113] fix(team): apply permission_suggestions to settings instead of writing to inbox FACT: Claude Code runtime ignores permission_response in teammate inbox. FACT: permission_request contains permission_suggestions from runtime with instructions to add rules to project settings. FACT: destination "localSettings" = {cwd}/.claude/settings.local.json. When user clicks Allow for teammate permission_request: - Parse permission_suggestions from the request - Add tool rules to {cwd}/.claude/settings.local.json - Creates directory/file if missing, merges with existing rules - Teammate retries tool call, finds rule, succeeds Removed: inbox permission_response write (didn't work) Removed: control_response via stdin fallback (didn't work) --- .../services/team/TeamProvisioningService.ts | 220 +++++++++++------- src/shared/types/team.ts | 9 + src/shared/utils/inboxNoise.ts | 16 ++ 3 files changed, 167 insertions(+), 78 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7460f160..c98f1197 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -5848,6 +5848,8 @@ export class TeamProvisioningService { receivedAt: messageTimestamp || new Date().toISOString(), teamColor: run.request.color, teamDisplayName: run.request.displayName, + permissionSuggestions: + perm.permissionSuggestions.length > 0 ? perm.permissionSuggestions : undefined, }; const autoResult = shouldAutoAllow( @@ -5859,7 +5861,14 @@ export class TeamProvisioningService { logger.info( `[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})` ); - void this.respondToTeammatePermission(run, perm.agentId, perm.requestId, true); + void this.respondToTeammatePermission( + run, + perm.agentId, + perm.requestId, + true, + undefined, + perm.permissionSuggestions + ); this.emitToolApprovalEvent({ autoResolved: true, requestId: perm.requestId, @@ -6046,14 +6055,14 @@ export class TeamProvisioningService { const approval = run.pendingApprovals.get(requestId); if (approval && approval.source !== 'lead') { - // Teammate request — respond via inbox + control_response fallback. - // Defer cleanup until the async write completes to avoid silent data loss. + // Teammate request — apply permission_suggestions to project settings. this.respondToTeammatePermission( run, approval.source, requestId, allow, - allow ? undefined : 'Timed out — auto-denied by settings' + allow ? undefined : 'Timed out — auto-denied by settings', + approval.permissionSuggestions ).finally(() => { run.pendingApprovals.delete(requestId); this.inFlightResponses.delete(requestId); @@ -6132,7 +6141,14 @@ export class TeamProvisioningService { this.clearApprovalTimeout(requestId); if (!this.tryClaimResponse(requestId)) continue; if (approval.source !== 'lead') { - void this.respondToTeammatePermission(run, approval.source, requestId, true); + void this.respondToTeammatePermission( + run, + approval.source, + requestId, + true, + undefined, + approval.permissionSuggestions + ); } else { this.autoAllowControlRequest(run, requestId); } @@ -6198,10 +6214,17 @@ export class TeamProvisioningService { const approval = run.pendingApprovals.get(requestId)!; - // Teammate permission requests use a different response path (inbox, not stdin) + // Teammate permission requests: apply permission_suggestions to project settings if (approval.source !== 'lead') { try { - await this.respondToTeammatePermission(run, approval.source, requestId, allow, message); + await this.respondToTeammatePermission( + run, + approval.source, + requestId, + allow, + message, + approval.permissionSuggestions + ); } finally { run.pendingApprovals.delete(requestId); this.inFlightResponses.delete(requestId); @@ -6284,92 +6307,133 @@ export class TeamProvisioningService { } /** - * Respond to a teammate's permission_request by writing to the teammate's inbox - * AND attempting a control_response via stdin (belt-and-suspenders). + * Respond to a teammate's permission_request by applying permission_suggestions. + * + * FACT: Claude Code teammate runtime sends permission_request via SendMessage (inbox protocol). + * FACT: Writing permission_response to teammate inbox does NOT work - runtime ignores it. + * FACT: control_response via stdin does NOT work for teammate requests - request_id doesn't match. + * FACT: permission_suggestions.destination "localSettings" refers to {cwd}/.claude/settings.local.json. + * FACT: Claude Code CLI reads this file via --setting-sources user,project,local. + * + * When allow=true: applies permission_suggestions (adds tool rules to project settings). + * When allow=false: no action needed - tool stays blocked by default. */ private async respondToTeammatePermission( run: ProvisioningRun, agentId: string, requestId: string, allow: boolean, - message?: string + _message?: string, + permissionSuggestions?: import('@shared/utils/inboxNoise').PermissionSuggestion[] ): Promise { - const teamsBase = getTeamsBasePath(); - const inboxPath = path.join(teamsBase, run.teamName, 'inboxes', `${agentId}.json`); + if (!allow) { + logger.info(`[${run.teamName}] Denied teammate ${agentId} permission ${requestId}`); + return; + } - // 1. Write permission_response to teammate's inbox (with proper file locking) - const responseMsg = { - from: 'user', - text: JSON.stringify({ - type: 'permission_response', - request_id: requestId, - approved: allow, - ...(message ? { message } : {}), - }), - timestamp: new Date().toISOString(), - read: false, - }; + // Apply permission_suggestions: add tool rules to project settings file + const suggestions = permissionSuggestions ?? []; + if (suggestions.length === 0) { + logger.warn(`[${run.teamName}] No permission_suggestions for ${requestId} — cannot add rule`); + return; + } + // Resolve project cwd from team config + let projectCwd: string | undefined; try { - await withFileLock(inboxPath, async () => { - await withInboxLock(inboxPath, async () => { - let existing: unknown[] = []; - try { - const raw = await tryReadRegularFileUtf8(inboxPath, { - timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, - maxBytes: TEAM_INBOX_MAX_BYTES, - }); - if (raw) { - const parsed = JSON.parse(raw) as unknown; - if (Array.isArray(parsed)) existing = parsed; - } - } catch { - // File may not exist yet — start with empty array - } - existing.push(responseMsg); - await atomicWriteAsync(inboxPath, JSON.stringify(existing, null, 2)); - }); - }); - logger.info( - `[${run.teamName}] Wrote permission_response to ${agentId} inbox: ${allow ? 'allow' : 'deny'}` - ); - } catch (error) { - logger.error( - `[${run.teamName}] Failed to write permission_response to ${agentId}: ${ - error instanceof Error ? error.message : String(error) - }` - ); + const config = await this.configReader.getConfig(run.teamName); + projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd; + } catch { + // best-effort + } + if (!projectCwd) { + logger.warn(`[${run.teamName}] Cannot resolve project cwd for permission rule — skipping`); + return; } - // 2. Also try control_response via stdin (in case lead runtime can forward it) - if (run.child?.stdin?.writable) { - const controlResponse = allow - ? { - type: 'control_response', - response: { - subtype: 'success', - request_id: requestId, - response: { behavior: 'allow' }, - }, - } - : { - type: 'control_response', - response: { - subtype: 'success', - request_id: requestId, - response: { behavior: 'deny', message: message ?? 'User denied' }, - }, - }; - run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { - if (err) { - logger.warn( - `[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` - ); - } - }); + for (const suggestion of suggestions) { + if (suggestion.type !== 'addRules' || !Array.isArray(suggestion.rules)) continue; + + const toolNames = suggestion.rules + .map((r) => r.toolName) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + if (toolNames.length === 0) continue; + + const behavior = suggestion.behavior ?? 'allow'; + // FACT: observed destinations are "localSettings" (project-level .claude/settings.local.json) + const settingsPath = + suggestion.destination === 'localSettings' + ? path.join(projectCwd, '.claude', 'settings.local.json') + : path.join(projectCwd, '.claude', 'settings.local.json'); // default to local + + try { + await this.addPermissionRulesToSettings(settingsPath, toolNames, behavior); + logger.info( + `[${run.teamName}] Added permission rules for ${agentId}: ${toolNames.join(', ')} → ${behavior} in ${settingsPath}` + ); + } catch (error) { + logger.error( + `[${run.teamName}] Failed to add permission rules: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } } + /** + * Safely add tool names to the permissions.allow (or deny) array in a Claude settings file. + * Creates the file and parent directories if they don't exist. + * Merges with existing entries — never overwrites. + */ + private async addPermissionRulesToSettings( + settingsPath: string, + toolNames: string[], + behavior: string + ): Promise { + const dir = path.dirname(settingsPath); + await fs.promises.mkdir(dir, { recursive: true }); + + // Read existing settings (or start with empty object) + let settings: Record = {}; + try { + const raw = await fs.promises.readFile(settingsPath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + settings = parsed as Record; + } + } catch { + // File doesn't exist or invalid JSON — start fresh + } + + // Ensure permissions object exists + if (!settings.permissions || typeof settings.permissions !== 'object') { + settings.permissions = {}; + } + const perms = settings.permissions as Record; + + // Target array: "allow" or "deny" based on behavior + const key = behavior === 'deny' ? 'deny' : 'allow'; + if (!Array.isArray(perms[key])) { + perms[key] = []; + } + const list = perms[key] as string[]; + + // Add tool names that aren't already in the list + const existing = new Set(list); + let added = 0; + for (const name of toolNames) { + if (!existing.has(name)) { + list.push(name); + added++; + } + } + + if (added === 0) return; // Nothing new to add + + await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + } + /** * Called when the first stream-json turn completes successfully. * Verifies provisioning files exist and marks as ready. diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index fd51c499..b8f4e817 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -888,6 +888,15 @@ export interface ToolApprovalRequest { teamColor?: string; /** Team display name (from config or create request). */ teamDisplayName?: string; + /** Permission suggestions from teammate runtime (only for teammate permission_request). + * FACT: Populated by Claude Code runtime, contains instructions to add permission rules. + */ + permissionSuggestions?: { + type: string; + rules?: { toolName: string }[]; + behavior?: string; + destination?: string; + }[]; } /** Dismissal event — process died, all pending approvals for this team+run should be removed. */ diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index dff92e57..b6784819 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -41,6 +41,14 @@ export function isInboxNoiseMessage(text: string): boolean { // Teammate permission request parsing // --------------------------------------------------------------------------- +/** A single permission suggestion from the teammate runtime. */ +export interface PermissionSuggestion { + type: string; + rules?: { toolName: string }[]; + behavior?: string; + destination?: string; +} + /** Parsed teammate permission request from inbox message. */ export interface ParsedPermissionRequest { requestId: string; @@ -49,6 +57,11 @@ export interface ParsedPermissionRequest { toolUseId: string; description: string; input: Record; + /** Suggestions from teammate runtime on how to resolve the permission. + * FACT: This field is populated by Claude Code runtime, not by the AI agent. + * FACT: Observed format: { type: "addRules", rules: [{toolName}], behavior: "allow", destination: "localSettings" } + */ + permissionSuggestions: PermissionSuggestion[]; } /** @@ -75,6 +88,9 @@ export function parsePermissionRequest(text: string): ParsedPermissionRequest | parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) ? (parsed.input as Record) : {}, + permissionSuggestions: Array.isArray(parsed.permission_suggestions) + ? (parsed.permission_suggestions as PermissionSuggestion[]) + : [], }; } From dcf775d86c4a0a915beeca774ab161714ce90f79 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 13:45:09 +0300 Subject: [PATCH 088/113] fix(team): add updatedInput: {} to all allow control_responses Claude Code CLI Zod schema requires updatedInput to be a record when behavior is 'allow'. Without it, MCP tool approvals fail with 'Tool permission request failed: ZodError: expected record, received undefined'. Add empty updatedInput: {} to all allow responses (autoAllow, timeout-allow, and manual allow). --- src/main/services/team/TeamProvisioningService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c98f1197..75fadb6c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6014,7 +6014,7 @@ export class TeamProvisioningService { response: { subtype: 'success', request_id: requestId, - response: { behavior: 'allow' }, + response: { behavior: 'allow', updatedInput: {} }, }, }; @@ -6239,7 +6239,7 @@ export class TeamProvisioningService { // IMPORTANT: request_id is NESTED inside response, NOT top-level // (asymmetry with control_request — confirmed by Python SDK, Elixir SDK and issue #29991) - const allowResponse: Record = { behavior: 'allow' }; + const allowResponse: Record = { behavior: 'allow', updatedInput: {} }; // For AskUserQuestion: pass user's answers via updatedInput so the CLI // can deliver them without re-prompting. Format follows --permission-prompt-tool spec. if (allow && message) { From f36501bdef8d70ee9d6ca08eb6ee78d5159a1042 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 13:48:56 +0300 Subject: [PATCH 089/113] fix(graph): fullscreen overlay pt-8 to clear macOS title bar --- src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 91219682..cdeb77e2 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -55,7 +55,7 @@ export const TeamGraphOverlay = ({ }; return ( -
+
Date: Mon, 30 Mar 2026 13:50:04 +0300 Subject: [PATCH 090/113] =?UTF-8?q?fix(graph):=20remove=20pt-8=20from=20fu?= =?UTF-8?q?llscreen=20overlay=20=E2=80=94=20no=20padding=20in=20fullscreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index cdeb77e2..91219682 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -55,7 +55,7 @@ export const TeamGraphOverlay = ({ }; return ( -
+
Date: Mon, 30 Mar 2026 15:24:23 +0300 Subject: [PATCH 091/113] fix(updater): prevent installation of non-newer versions and enhance update notifications - Added checks to ensure that only newer versions are installed during the update process. - Updated the notification logic to suppress alerts for non-newer updates. - Introduced a new method to compare version numbers, improving version management in the UpdaterService. - Enhanced the release workflow by removing unnecessary file uploads and adding canonical updater metadata publishing for better asset management. --- .github/workflows/release.yml | 88 ++++++++++++++++++- .../services/infrastructure/UpdaterService.ts | 34 ++++++- .../services/team/TeamProvisioningService.ts | 76 +++++++++++----- src/renderer/store/index.ts | 18 +++- src/renderer/vite-env.d.ts | 2 + src/shared/utils/version.ts | 29 ++++++ ...eamProvisioningServiceLiveMessages.test.ts | 41 ++++++++- .../TeamProvisioningServicePrepare.test.ts | 57 ++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 49 +++++++++++ 9 files changed, 362 insertions(+), 32 deletions(-) create mode 100644 src/shared/utils/version.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5833d37..4766dbae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,7 +143,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${GITHUB_REF#refs/tags/}" - for f in release/*.dmg release/*.zip release/*.blockmap release/*.yml; do + for f in release/*.dmg release/*.zip release/*.blockmap; do [ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber done @@ -213,7 +213,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${GITHUB_REF#refs/tags/}" - for f in release/*.exe release/*.blockmap release/*.yml; do + for f in release/*.exe release/*.blockmap; do [ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber done @@ -285,7 +285,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${GITHUB_REF#refs/tags/}" - for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap release/*.yml; do + for f in release/*.AppImage release/*.deb release/*.rpm release/*.pacman release/*.blockmap; do [ -f "$f" ] && gh release upload "$TAG" "$f" --repo "$GITHUB_REPOSITORY" --clobber done @@ -326,3 +326,85 @@ jobs: gh release upload "v${VERSION}" "$STABLE_NAME" --repo "$REPO" --clobber rm -f "$STABLE_NAME" done + + - name: Publish canonical updater metadata + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + VERSION="${GITHUB_REF#refs/tags/v}" + TAG="v${VERSION}" + REPO="${GITHUB_REPOSITORY}" + RELEASE_DATE="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" + TMP_DIR="$(mktemp -d)" + cd "$TMP_DIR" + + sha512_base64() { + openssl dgst -sha512 -binary "$1" | openssl base64 -A + } + + file_size() { + wc -c < "$1" | tr -d '[:space:]' + } + + download_asset() { + local name="$1" + curl -fSL -o "$name" "https://github.com/${REPO}/releases/download/${TAG}/${name}" + } + + # Canonical Windows feed + download_asset "Claude-Agent-Teams-UI-Setup.exe" + WIN_SHA="$(sha512_base64 Claude-Agent-Teams-UI-Setup.exe)" + WIN_SIZE="$(file_size Claude-Agent-Teams-UI-Setup.exe)" + cat > latest.yml < latest-linux.yml < latest-mac.yml < { export class UpdaterService { private mainWindow: BrowserWindow | null = null; private periodicTimer: ReturnType | null = null; + private downloadedVersion: string | null = null; constructor() { autoUpdater.autoDownload = false; @@ -109,6 +111,17 @@ export class UpdaterService { * isForceRunAfter=true launches the app after install. Other platforms ignore these. */ quitAndInstall(): void { + if (!this.downloadedVersion || !this.isNewerThanCurrent(this.downloadedVersion)) { + logger.warn( + `Refusing to install non-newer update. current=${app.getVersion()} downloaded=${this.downloadedVersion ?? 'unknown'}` + ); + this.sendStatus({ + type: 'error', + error: 'Refused to install a non-newer app version.', + }); + return; + } + autoUpdater.quitAndInstall(true, true); } @@ -137,6 +150,10 @@ export class UpdaterService { safeSendToRenderer(this.mainWindow, 'updater:status', status); } + private isNewerThanCurrent(candidateVersion: string): boolean { + return isVersionOlder(normalizeVersion(app.getVersion()), normalizeVersion(candidateVersion)); + } + /** * Verify that the platform-specific asset exists before notifying the renderer. * If CI hasn't finished uploading the artifact for this OS yet, suppress the @@ -146,6 +163,13 @@ export class UpdaterService { version: string; releaseNotes?: string | unknown; }): Promise { + if (!this.isNewerThanCurrent(info.version)) { + logger.warn( + `Suppressing non-newer update notification. current=${app.getVersion()} candidate=${info.version}` + ); + return; + } + const url = getExpectedAssetUrl(info.version); if (url) { const exists = await assetExists(url); @@ -192,6 +216,14 @@ export class UpdaterService { }); autoUpdater.on('update-downloaded', (info) => { + if (!this.isNewerThanCurrent(info.version)) { + logger.warn( + `Ignoring downloaded non-newer update. current=${app.getVersion()} downloaded=${info.version}` + ); + return; + } + + this.downloadedVersion = info.version; logger.info('Update downloaded:', info.version); this.sendStatus({ type: 'downloaded', diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 75fadb6c..be69f892 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1695,6 +1695,28 @@ export class TeamProvisioningService { return Array.isArray(innerContent) ? (innerContent as Record[]) : []; } + private hasCapturedVisibleMessageToUser(content: Record[]): boolean { + return content.some((part) => { + if (!part || typeof part !== 'object') return false; + if (part.type !== 'tool_use' || typeof part.name !== 'string') return false; + + // Only native SendMessage(to="user") is guaranteed to be materialized as a + // visible outbound message by captureSendMessages(). + // Keep this intentionally narrower than captureSendMessages(): if another tool path + // later starts creating its own user-visible row, expand this helper in lockstep. + if (part.name !== 'SendMessage') return false; + + const input = part.input; + if (!input || typeof input !== 'object') return false; + const inp = input as Record; + const target = ( + typeof inp.recipient === 'string' ? inp.recipient : typeof inp.to === 'string' ? inp.to : '' + ).trim(); + + return target.toLowerCase() === 'user'; + }); + } + private async matchCrossTeamLeadInboxMessages( teamName: string, leadName: string, @@ -5142,14 +5164,7 @@ export class TeamProvisioningService { if (msg.type === 'assistant') { const content = this.extractStreamContentBlocks(msg); - const hasCapturedSendMessage = content.some((part) => { - if (!part || typeof part !== 'object') return false; - if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false; - const input = part.input; - if (!input || typeof input !== 'object') return false; - const recipient = (input as Record).recipient; - return typeof recipient === 'string' && recipient.trim().length > 0; - }); + const hasCapturedVisibleMessageToUser = this.hasCapturedVisibleMessageToUser(content); const textParts = content .filter((part) => part.type === 'text' && typeof part.text === 'string') @@ -5170,26 +5185,27 @@ export class TeamProvisioningService { run.provisioningOutputParts.push(text); } - if (run.leadRelayCapture) { + // Once relay capture is settled, later assistant chunks belong to the normal live + // message flow. Keeping them in the capture branch would drop them on the floor + // until relayLeadInboxMessages() finally clears run.leadRelayCapture. + if (run.leadRelayCapture && !run.leadRelayCapture.settled) { const capture = run.leadRelayCapture; - if (!capture.settled) { - capture.textParts.push(text); - if (capture.idleHandle) { - clearTimeout(capture.idleHandle); - } - capture.idleHandle = setTimeout(() => { - const combined = capture.textParts.join('\n').trim(); - capture.resolveOnce(combined); - }, capture.idleMs); + capture.textParts.push(text); + if (capture.idleHandle) { + clearTimeout(capture.idleHandle); } + capture.idleHandle = setTimeout(() => { + const combined = capture.textParts.join('\n').trim(); + capture.resolveOnce(combined); + }, capture.idleMs); } else if (run.provisioningComplete) { // Push each assistant text block as a separate live message (per-message pattern). - // When the same assistant message includes SendMessage(...), skip text — + // When the same assistant message includes a user-visible message send, skip text — // captureSendMessages() handles the visible outbound message separately. if ( !run.silentUserDmForward && !run.suppressPostCompactReminderOutput && - !hasCapturedSendMessage + !hasCapturedVisibleMessageToUser ) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { @@ -5203,7 +5219,7 @@ export class TeamProvisioningService { } else { // Pre-ready: keep showing provisioning narration in the banner, but also mirror it // into the live cache so Messages/Activity can show the earliest assistant output. - if (!run.silentUserDmForward && !hasCapturedSendMessage) { + if (!run.silentUserDmForward && !hasCapturedVisibleMessageToUser) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { this.pushLiveLeadTextMessage( @@ -6530,6 +6546,15 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); + // Force a post-ready detail refresh so Messages reload persisted lead_session + // texts from JSONL even if the last visible assistant output only reached disk. + this.teamChangeEmitter?.({ + type: 'lead-message', + teamName: run.teamName, + runId: run.runId, + detail: 'lead-session-sync', + }); + // Fire "Team Launched" notification void this.fireTeamLaunchedNotification(run); @@ -6629,6 +6654,15 @@ export class TeamProvisioningService { this.aliveRunByTeam.set(run.teamName, run.runId); logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`); + // Force a post-ready detail refresh so Messages reload persisted lead_session + // texts from JSONL even if the last visible assistant output only reached disk. + this.teamChangeEmitter?.({ + type: 'lead-message', + teamName: run.teamName, + runId: run.runId, + detail: 'lead-session-sync', + }); + // Fire "Team Launched" notification void this.fireTeamLaunchedNotification(run); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 52602af4..399ad1b1 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -6,6 +6,7 @@ import { api } from '@renderer/api'; import { syncRendererTelemetry } from '@renderer/sentry'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; import { normalizePath } from '@renderer/utils/pathNormalize'; +import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, @@ -53,6 +54,8 @@ const ENABLE_AUTO_TEAM_CHANGE_PRESENCE_TRACKING = false; const IN_PROGRESS_CHANGE_PRESENCE_POLL_MS = 10_000; const FINISHED_TOOL_DISPLAY_MS = 1_500; const MAX_TOOL_HISTORY_PER_MEMBER = 6; +const CURRENT_APP_VERSION = + typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0'; // ============================================================================= // Store Creation @@ -1199,12 +1202,16 @@ export function initializeNotificationListeners(): () => void { if (currentStatus === 'downloading' || currentStatus === 'downloaded') { break; } + const nextVersion = s.version ? normalizeVersion(s.version) : null; + if (!nextVersion || !isVersionOlder(CURRENT_APP_VERSION, nextVersion)) { + break; + } const dismissed = useStore.getState().dismissedUpdateVersion; useStore.setState({ updateStatus: 'available', - availableVersion: s.version ?? null, + availableVersion: nextVersion, releaseNotes: s.releaseNotes ?? null, - showUpdateDialog: (s.version ?? null) !== dismissed, + showUpdateDialog: nextVersion !== dismissed, }); break; } @@ -1223,10 +1230,15 @@ export function initializeNotificationListeners(): () => void { }); break; case 'downloaded': + if (s.version && !isVersionOlder(CURRENT_APP_VERSION, normalizeVersion(s.version))) { + break; + } useStore.setState({ updateStatus: 'downloaded', downloadProgress: 100, - availableVersion: s.version ?? useStore.getState().availableVersion, + availableVersion: s.version + ? normalizeVersion(s.version) + : useStore.getState().availableVersion, }); break; case 'error': { diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts index 827db198..132b9c26 100644 --- a/src/renderer/vite-env.d.ts +++ b/src/renderer/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __APP_VERSION__: string; + declare module '*.png' { const src: string; // eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports diff --git a/src/shared/utils/version.ts b/src/shared/utils/version.ts new file mode 100644 index 00000000..f4fa2ca1 --- /dev/null +++ b/src/shared/utils/version.ts @@ -0,0 +1,29 @@ +/** + * Extract semver-like version from strings such as "v1.2.3" or "1.2.3 (beta)". + */ +export function normalizeVersion(raw: string): string { + const match = /\d{1,10}\.\d{1,10}\.\d{1,10}/.exec(raw); + return match ? match[0] : raw.trim(); +} + +/** + * Numeric semver comparison. + * Returns -1 if a < b, 0 if equal, 1 if a > b. + */ +export function compareVersions(a: string, b: string): number { + const aParts = normalizeVersion(a).split('.').map(Number); + const bParts = normalizeVersion(b).split('.').map(Number); + + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const left = aParts[i] ?? 0; + const right = bParts[i] ?? 0; + if (left < right) return -1; + if (left > right) return 1; + } + + return 0; +} + +export function isVersionOlder(installed: string, latest: string): boolean { + return compareVersions(installed, latest) < 0; +} diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index 8a5f930e..7833cf25 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -332,7 +332,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); }); - it('captures SendMessage(to:team-lead) without rendering duplicate assistant thought text', () => { + it('keeps assistant thought text when SendMessage targets a teammate', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); @@ -355,15 +355,48 @@ describe('TeamProvisioningService pre-ready live messages', () => { }); const live = service.getLiveLeadProcessMessages('my-team'); - expect(live).toHaveLength(1); - expect(live[0].to).toBe('team-lead'); - expect(live[0].text).toBe('Need clarification on #abcd1234'); + expect(live).toHaveLength(2); + expect(live[0].to).toBeUndefined(); + expect(live[0].text).toBe('Forwarding the clarification request now.'); expect(live[0].source).toBe('lead_process'); + expect(live[1].to).toBe('team-lead'); + expect(live[1].text).toBe('Need clarification on #abcd1234'); + expect(live[1].source).toBe('lead_process'); // Non-user recipient → delivered to inbox, not sentMessages expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1); expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); }); + it('suppresses duplicate assistant thought text when SendMessage targets user', () => { + const service = new TeamProvisioningService(); + seedConfig('my-team'); + const run = attachRun(service, 'my-team', { provisioningComplete: true }); + + callHandleStreamJsonMessage(service, run, { + type: 'assistant', + content: [ + { type: 'text', text: 'Task completed. Sending the summary now.' }, + { + type: 'tool_use', + name: 'SendMessage', + input: { + type: 'message', + recipient: 'user', + content: 'Task completed successfully.', + summary: 'Done', + }, + }, + ], + }); + + const live = service.getLiveLeadProcessMessages('my-team'); + expect(live).toHaveLength(1); + expect(live[0].to).toBe('user'); + expect(live[0].text).toBe('Task completed successfully.'); + expect(live[0].source).toBe('lead_process'); + expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); + }); + it('post-ready path also uses the unified helper', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index c8bbfa26..dfc8ca1e 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -223,4 +223,61 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }) ); }); + + it('emits a lead-message refresh after provisioning reaches ready', async () => { + const svc = new TeamProvisioningService(); + const emitter = vi.fn(); + svc.setTeamChangeEmitter(emitter); + + const run = { + runId: 'run-3', + teamName: 'team-alpha', + request: { + cwd: tempRoot, + color: 'blue', + members: [{ name: 'dev', role: 'engineer' }], + }, + progress: { + runId: 'run-3', + teamName: 'team-alpha', + state: 'assembling', + message: 'Assembling', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:00.000Z', + }, + provisioningComplete: false, + cancelRequested: false, + processKilled: false, + stdoutBuffer: '', + stdoutLogLineBuf: '', + stdoutParserCarry: '', + stdoutParserCarryIsCompleteJson: false, + stdoutParserCarryLooksLikeClaudeJson: false, + stderrBuffer: '', + stderrLogLineBuf: '', + provisioningOutputParts: [], + onProgress: vi.fn(), + isLaunch: true, + detectedSessionId: null, + timeoutHandle: null, + fsMonitorHandle: null, + claudeLogLines: [], + activeToolCalls: new Map(), + leadActivityState: 'active', + leadContextUsage: null, + }; + + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + + await (svc as any).handleProvisioningTurnComplete(run); + + expect(emitter).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'lead-message', + teamName: 'team-alpha', + runId: 'run-3', + detail: 'lead-session-sync', + }) + ); + }); }); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 73e4318a..df618c77 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -257,6 +257,55 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); }); + it('shows assistant text after relay capture has already settled', () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + attachAliveRun(service, teamName); + + const run = (service as unknown as { runs: Map }).runs.get('run-1') as { + leadRelayCapture: { + leadName: string; + startedAt: string; + textParts: string[]; + settled: boolean; + idleHandle: NodeJS.Timeout | null; + idleMs: number; + resolveOnce: (text: string) => void; + rejectOnce: (error: string) => void; + timeoutHandle: NodeJS.Timeout; + } | null; + }; + + run.leadRelayCapture = { + leadName: 'team-lead', + startedAt: new Date().toISOString(), + textParts: [], + settled: true, + idleHandle: null, + idleMs: 800, + resolveOnce: vi.fn(), + rejectOnce: vi.fn(), + timeoutHandle: setTimeout(() => undefined, 60_000), + }; + + try { + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Late reply after relay completion.' }], + }); + + const live = service.getLiveLeadProcessMessages(teamName); + expect(live).toHaveLength(1); + expect(live[0].to).toBeUndefined(); + expect(live[0].text).toBe('Late reply after relay completion.'); + expect(live[0].source).toBe('lead_process'); + } finally { + clearTimeout(run.leadRelayCapture.timeoutHandle); + run.leadRelayCapture = null; + } + }); + it('adds task-first reply guidance for task comment notifications in lead relay prompts', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; From e741b1f6039b19b14a07ad50b459d455b78c6f09 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 15:40:54 +0300 Subject: [PATCH 092/113] feat(team): default action mode 'delegate' instead of 'do' for teams --- src/renderer/components/team/dialogs/SendMessageDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index cf36a360..f8806e2c 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -77,7 +77,8 @@ interface SendMessageDialogProps { } // Sticky action mode — survives dialog close/reopen (component remount) -let stickyActionMode: ActionMode = 'do'; +// Default: 'delegate' for teams (overridden to 'do' if solo/no teammates) +let stickyActionMode: ActionMode = 'delegate'; export const SendMessageDialog = ({ open, From b04f82512be2245b16cc3a431578e025635db9fb Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 15:42:06 +0300 Subject: [PATCH 093/113] fix(graph): team name left-20 to clear macOS traffic lights in fullscreen --- packages/agent-graph/src/ui/GraphControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 1280f11b..3bf4e39e 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -91,7 +91,7 @@ export function GraphControls({ return ( <> -
+
Date: Mon, 30 Mar 2026 15:44:52 +0300 Subject: [PATCH 094/113] =?UTF-8?q?fix(graph):=20fullscreen=20overlay=20ac?= =?UTF-8?q?tions=20work=20=E2=80=94=20pass=20dispatchers=20to=20TeamGraphO?= =?UTF-8?q?verlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/features/agent-graph/ui/TeamGraphTab.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 22049c64..c0e84c9d 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -99,7 +99,13 @@ export const TeamGraphTab = ({
{fullscreen && ( - setFullscreen(false)} /> + setFullscreen(false)} + onSendMessage={dispatchSendMessage} + onOpenTaskDetail={dispatchOpenTask} + onOpenMemberProfile={dispatchOpenProfile} + /> )}
From 8808a3ab886f086f94a4b53b593149b95e04e4ba Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 15:49:18 +0300 Subject: [PATCH 095/113] =?UTF-8?q?fix(graph):=20disable=20context=20bar?= =?UTF-8?q?=20in=20lead=20popover=20=E2=80=94=20data=20unreliable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-graph/ui/GraphNodePopover.tsx | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 72d89ab1..d8faec13 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -155,29 +155,7 @@ function MemberPopoverContent({ )}
- {/* Context usage for lead */} - {node.kind === 'lead' && node.contextUsage != null && node.contextUsage > 0 && ( -
-
- Context - {Math.round(node.contextUsage * 100)}% -
-
-
0.9 - ? '#ef4444' - : node.contextUsage > 0.8 - ? '#f59e0b' - : '#22c55e', - }} - /> -
-
- )} + {/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */} {/* Current task indicator — reuses same pattern as MemberCard */} {node.currentTaskId && node.currentTaskSubject && ( From d2487e41c9fcfa44efd0fd5c97c91759c9f959ec Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 15:56:49 +0300 Subject: [PATCH 096/113] fix(team): trim verbose MCP description from permission noise rows Remove tool description from permission_request noise label - tool name alone is clear enough. MCP tools have long technical descriptions that flood the Messages panel. --- src/renderer/components/team/activity/ActivityItem.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index ba32f6f1..79b39aef 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -252,9 +252,7 @@ function getNoiseLabel(parsed: StructuredMessage): string | null { if (type === 'permission_request') { const toolName = getStringField(parsed, 'tool_name'); - const description = getStringField(parsed, 'description'); - const label = toolName ? `Permission: ${toolName}` : 'Permission request'; - return description ? `${label} — ${description}` : label; + return toolName ? `Permission: ${toolName}` : 'Permission request'; } if (type === 'permission_response') { From 34f1f0d61267b02045964410f2f6d81b800e6ec1 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 15:59:53 +0300 Subject: [PATCH 097/113] fix(team): proactively add all agent-teams MCP tools on first approval When user approves any mcp__agent-teams__* tool, also add all other agent-teams tools to settings.local.json preemptively. This prevents teammates from getting stuck on subsequent tool calls (task_get, task_start, task_complete, etc.) since each generates a separate permission_request and the teammate blocks until resolved. FACT: Settings file approach only prevents FUTURE blocks, not current ones. Pre-adding all tools on first approval covers the common case. --- .../services/team/TeamProvisioningService.ts | 22 ++++++++++++- .../agent-graph/ui/GraphNodePopover.tsx | 31 +++++++++++-------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index be69f892..565ecdc0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6370,11 +6370,31 @@ export class TeamProvisioningService { for (const suggestion of suggestions) { if (suggestion.type !== 'addRules' || !Array.isArray(suggestion.rules)) continue; - const toolNames = suggestion.rules + let toolNames = suggestion.rules .map((r) => r.toolName) .filter((name): name is string => typeof name === 'string' && name.length > 0); if (toolNames.length === 0) continue; + // When approving ANY mcp__agent-teams__ tool, proactively add ALL agent-teams tools. + // FACT: Teammates need multiple MCP tools (member_briefing, task_get, task_start, etc.) + // FACT: Each tool generates a separate permission_request, but by the time we process it + // the teammate is already stuck waiting. Pre-adding all tools prevents future blocks. + if (toolNames.some((name) => name.startsWith('mcp__agent-teams__'))) { + const agentTeamsTools = [ + 'mcp__agent-teams__member_briefing', + 'mcp__agent-teams__task_briefing', + 'mcp__agent-teams__task_create', + 'mcp__agent-teams__task_get', + 'mcp__agent-teams__task_list', + 'mcp__agent-teams__task_start', + 'mcp__agent-teams__task_complete', + 'mcp__agent-teams__task_set_status', + 'mcp__agent-teams__task_add_comment', + ]; + const merged = new Set([...toolNames, ...agentTeamsTools]); + toolNames = Array.from(merged); + } + const behavior = suggestion.behavior ?? 'allow'; // FACT: observed destinations are "localSettings" (project-level .claude/settings.local.json) const settingsPath = diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index d8faec13..b861d6ea 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -223,22 +223,27 @@ function MemberPopoverContent({ Recent tools
- {node.recentTools.slice(0, 3).map((tool) => ( -
+ {node.recentTools.slice(0, 3).map((tool) => { + const shortName = formatToolName(tool.name); + const shortPreview = formatToolPreview(tool.preview); + return (
- {tool.preview ? `${tool.name}: ${tool.preview}` : tool.name} + + + {shortName} + + {shortPreview && ( + {shortPreview} + )}
- {tool.resultPreview && ( -
{tool.resultPreview}
- )} -
- ))} + ); + })}
)} From 9241970b02ac54d3df08fb9e73b86c23c217f8dd Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 16:00:34 +0300 Subject: [PATCH 098/113] =?UTF-8?q?fix(graph):=20format=20recent=20tools?= =?UTF-8?q?=20=E2=80=94=20clean=20names=20+=20extract=20readable=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tool names: "mcp__agent-teams__task_create" → "Task Create" (strip MCP prefixes, snake_case → Title Case) - Tool preview: raw JSON → extract subject/name/path field (was showing { "id": "19ebbdd5-...", "displayId": ... }) - Compact single-line layout with status dot + name + preview --- .../agent-graph/ui/GraphNodePopover.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index b861d6ea..ea4ee1e8 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -11,6 +11,37 @@ import { Loader2, MessageSquare, ExternalLink, User, Plus } from 'lucide-react'; import type { GraphNode } from '@claude-teams/agent-graph'; +// ─── Tool name/preview formatters ─────────────────────────────────────────── + +/** Clean up tool names: "mcp__agent-teams__task_create" → "Task Create" */ +function formatToolName(raw: string): string { + // Strip MCP prefixes (mcp__serverName__toolName → toolName) + const parts = raw.split('__'); + const name = parts[parts.length - 1] ?? raw; + // snake_case → Title Case + return name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Clean up tool preview: strip raw JSON, extract meaningful part */ +function formatToolPreview(preview: string | undefined): string | undefined { + if (!preview) return undefined; + // If it looks like raw JSON object, try to extract a readable field + if (preview.startsWith('{') || preview.startsWith('[')) { + try { + const obj = JSON.parse(preview.length > 200 ? preview.slice(0, 200) : preview); + // Common readable fields + return ( + obj.subject ?? obj.name ?? obj.label ?? obj.file_path ?? obj.path ?? obj.query ?? undefined + ); + } catch { + // Truncated JSON — extract first quoted value + const match = preview.match(/"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/); + if (match) return match[1]; + } + } + return preview.length > 50 ? preview.slice(0, 50) + '...' : preview; +} + interface GraphNodePopoverProps { node: GraphNode; onClose: () => void; From 1f28ee50215005c59f33891bff00e67827138009 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 16:45:14 +0300 Subject: [PATCH 099/113] fix(ci): stabilize provisioning test and clear lint errors --- src/main/index.ts | 4 ++-- src/main/ipc/handlers.ts | 4 ++-- src/main/ipc/teams.ts | 6 +++--- src/main/services/team/BranchStatusService.ts | 3 +-- .../services/team/TeamProvisioningService.ts | 2 +- src/main/services/team/TeammateToolTracker.ts | 2 +- src/main/services/team/index.ts | 4 ++-- .../team/leadSessionMessageExtractor.ts | 5 ++--- src/preload/index.ts | 6 +++--- .../components/team/TeamDetailView.tsx | 6 +++--- .../components/team/ToolApprovalSheet.tsx | 3 +-- .../team/kanban/KanbanFilterPopover.tsx | 2 +- .../team/review/FileSectionDiff.tsx | 4 ++-- .../team/sidebar/TeamSidebarPortalSource.tsx | 2 +- .../team/useClaudeLogsController.ts | 2 +- .../agent-graph/adapters/TeamGraphAdapter.ts | 2 +- .../agent-graph/ui/GraphNodePopover.tsx | 20 +++++++++---------- .../agent-graph/ui/TeamGraphOverlay.tsx | 1 + .../features/agent-graph/ui/TeamGraphTab.tsx | 3 ++- src/renderer/store/index.ts | 2 +- .../TeamProvisioningServicePrepare.test.ts | 13 ++++++++++++ 21 files changed, 54 insertions(+), 42 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 885dbe4e..29d569db 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -95,11 +95,11 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { + BranchStatusService, CliInstallerService, configManager, LocalFileSystemProvider, MemberStatsComputer, - BranchStatusService, NotificationManager, PtyTerminalService, ServiceContext, @@ -108,8 +108,8 @@ import { TaskBoundaryParser, TeamDataService, TeamLogSourceTracker, - TeamMemberLogsFinder, TeammateToolTracker, + TeamMemberLogsFinder, TeamProvisioningService, UpdaterService, } from './services'; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 35fcaa8b..c959a381 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -88,8 +88,8 @@ import { registerValidationHandlers, removeValidationHandlers } from './validati import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { - ChangeExtractorService, BranchStatusService, + ChangeExtractorService, CliInstallerService, FileContentResolver, GitDiffFallback, @@ -100,8 +100,8 @@ import type { ServiceContextRegistry, SshConnectionManager, TeamDataService, - TeamMemberLogsFinder, TeammateToolTracker, + TeamMemberLogsFinder, TeamProvisioningService, UpdaterService, } from '../services'; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index e16fa94c..4848902b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -49,8 +49,8 @@ import { TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, - TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, @@ -113,8 +113,8 @@ import { import type { BranchStatusService, MemberStatsComputer, - TeammateToolTracker, TeamDataService, + TeammateToolTracker, TeamMemberLogsFinder, TeamProvisioningService, } from '../services'; @@ -2978,7 +2978,7 @@ async function handleToolApprovalSettings( try { getTeamProvisioningService().updateToolApprovalSettings( - teamName as string, + teamName, s as unknown as ToolApprovalSettings ); } catch (err) { diff --git a/src/main/services/team/BranchStatusService.ts b/src/main/services/team/BranchStatusService.ts index 3f7bc1f5..544ec4f2 100644 --- a/src/main/services/team/BranchStatusService.ts +++ b/src/main/services/team/BranchStatusService.ts @@ -1,6 +1,5 @@ -import * as path from 'path'; - import { createLogger } from '@shared/utils/logger'; +import * as path from 'path'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index be69f892..b1b29a90 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -34,8 +34,8 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { isInboxNoiseMessage, - parsePermissionRequest, type ParsedPermissionRequest, + parsePermissionRequest, } from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; diff --git a/src/main/services/team/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts index d66ff457..5a4416b9 100644 --- a/src/main/services/team/TeammateToolTracker.ts +++ b/src/main/services/team/TeammateToolTracker.ts @@ -337,7 +337,7 @@ export class TeammateToolTracker { for (const block of content) { if (!block || typeof block !== 'object') continue; - const typedBlock = block as Record; + const typedBlock = block; if (typedBlock.type === 'tool_use') { const rawId = typeof typedBlock.id === 'string' ? typedBlock.id.trim() : ''; if (!rawId) continue; diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 27a14f0d..ddc17421 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,5 +1,5 @@ -export { CascadeGuard } from './CascadeGuard'; export { BranchStatusService } from './BranchStatusService'; +export { CascadeGuard } from './CascadeGuard'; export { ChangeExtractorService } from './ChangeExtractorService'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; export { CrossTeamOutbox } from './CrossTeamOutbox'; @@ -18,10 +18,10 @@ export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxWriter } from './TeamInboxWriter'; export { TeamKanbanManager } from './TeamKanbanManager'; export { TeamLogSourceTracker } from './TeamLogSourceTracker'; +export { TeammateToolTracker } from './TeammateToolTracker'; export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMembersMetaStore } from './TeamMembersMetaStore'; -export { TeammateToolTracker } from './TeammateToolTracker'; export { TeamProvisioningService } from './TeamProvisioningService'; export { TeamSentMessagesStore } from './TeamSentMessagesStore'; export { TeamTaskReader } from './TeamTaskReader'; diff --git a/src/main/services/team/leadSessionMessageExtractor.ts b/src/main/services/team/leadSessionMessageExtractor.ts index 6fd9c23f..0124f395 100644 --- a/src/main/services/team/leadSessionMessageExtractor.ts +++ b/src/main/services/team/leadSessionMessageExtractor.ts @@ -1,10 +1,9 @@ import { isParsedSystemChunkMessage, isParsedUserChunkMessage, isTextContent } from '@main/types'; import { parseJsonlLine } from '@main/utils/jsonl'; -import { createHash } from 'crypto'; -import * as fs from 'fs'; - import { extractCommandOutputInfo, extractSlashInfo } from '@shared/utils/contentSanitizer'; import { buildSlashCommandMeta } from '@shared/utils/slashCommands'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; import type { ParsedMessage } from '@main/types'; import type { CommandOutputMeta, InboxMessage, SlashCommandMeta } from '@shared/types'; diff --git a/src/preload/index.ts b/src/preload/index.ts index a8542a5d..f5ab238a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -126,7 +126,6 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_PROJECT_BRANCH, - TEAM_PROJECT_BRANCH_CHANGE, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, TEAM_GET_TASK_CHANGE_PRESENCE, @@ -140,6 +139,7 @@ import { TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, TEAM_PROCESS_SEND, + TEAM_PROJECT_BRANCH_CHANGE, TEAM_PROVISIONING_PROGRESS, TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, @@ -152,8 +152,8 @@ import { TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, - TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, @@ -250,6 +250,7 @@ import type { MemberLogSummary, MemberSpawnStatusesSnapshot, NotificationTrigger, + ProjectBranchChangeEvent, RejectResult, ReplaceMembersRequest, Schedule, @@ -268,7 +269,6 @@ import type { TaskChangePresenceState, TaskChangeSetV2, TaskComment, - ProjectBranchChangeEvent, TeamChangeEvent, TeamClaudeLogsQuery, TeamClaudeLogsResponse, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 7a015cc1..bf84bd5a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -81,12 +81,12 @@ import { MemberList } from './members/MemberList'; import { MessagesPanel } from './messages/MessagesPanel'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ScheduleSection } from './schedule/ScheduleSection'; -import { ClaudeLogsSection } from './ClaudeLogsSection'; -import { CollapsibleTeamSection } from './CollapsibleTeamSection'; -import { ProcessesSection } from './ProcessesSection'; import { TeamSidebarHost } from './sidebar/TeamSidebarHost'; import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource'; import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; +import { ClaudeLogsSection } from './ClaudeLogsSection'; +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; +import { ProcessesSection } from './ProcessesSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index ac530c93..bfc7de63 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -7,13 +7,12 @@ import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; -import { MemberBadge } from './MemberBadge'; - import { ToolApprovalSettingsContent, ToolApprovalSettingsToggle, } from './dialogs/ToolApprovalSettingsPanel'; import { FileIcon } from './editor/FileIcon'; +import { MemberBadge } from './MemberBadge'; import { ToolApprovalDiffPreview } from './ToolApprovalDiffPreview'; import type { ToolApprovalRequest } from '@shared/types'; diff --git a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx index 579f6656..12ebdee3 100644 --- a/src/renderer/components/team/kanban/KanbanFilterPopover.tsx +++ b/src/renderer/components/team/kanban/KanbanFilterPopover.tsx @@ -3,9 +3,9 @@ import { useMemo } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; -import { formatSessionLabel } from '@renderer/utils/sessionTitleParser'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { displayMemberName } from '@renderer/utils/memberHelpers'; +import { formatSessionLabel } from '@renderer/utils/sessionTitleParser'; import { Crown, Filter } from 'lucide-react'; import type { Session } from '@renderer/types/data'; diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx index a25a2dcc..596614f7 100644 --- a/src/renderer/components/team/review/FileSectionDiff.tsx +++ b/src/renderer/components/team/review/FileSectionDiff.tsx @@ -191,10 +191,10 @@ export const FileSectionDiff = ({ ); }; -function OversizedDiffNotice({ message }: { message: string }): React.ReactElement { +const OversizedDiffNotice = ({ message }: { message: string }): React.ReactElement => { return (
{message}
); -} +}; diff --git a/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx index c72eab36..a24da0e1 100644 --- a/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx +++ b/src/renderer/components/team/sidebar/TeamSidebarPortalSource.tsx @@ -3,13 +3,13 @@ import { createPortal } from 'react-dom'; import { useStore } from '@renderer/store'; +import { useTeamSidebarHostId } from './TeamSidebarHost'; import { getTeamSidebarHostElement, removeTeamSidebarSource, upsertTeamSidebarSource, useTeamSidebarPortalSnapshot, } from './TeamSidebarPortalManager'; -import { useTeamSidebarHostId } from './TeamSidebarHost'; interface TeamSidebarPortalSourceProps { teamName: string; diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts index bee14201..430ad446 100644 --- a/src/renderer/components/team/useClaudeLogsController.ts +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -13,12 +13,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; -import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; import { createDefaultClaudeLogsSidebarUiState, getTeamClaudeLogsSidebarUiState, setTeamClaudeLogsSidebarUiState, } from './sidebar/teamSidebarUiState'; +import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; import type { ClaudeLogsViewerState } from './CliLogsRichView'; diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 724893b2..fb446754 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -7,8 +7,8 @@ * Class-based with ES #private fields, caching, and DI-ready constructor. */ -import { isLeadMember } from '@shared/utils/leadDetection'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { isLeadMember } from '@shared/utils/leadDetection'; import type { GraphDataPort, diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 72d89ab1..e5a6e2b2 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -7,7 +7,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; -import { Loader2, MessageSquare, ExternalLink, User, Plus } from 'lucide-react'; +import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -20,14 +20,14 @@ interface GraphNodePopoverProps { onCreateTask?: (owner: string) => void; } -export function GraphNodePopover({ +export const GraphNodePopover = ({ node, onClose, onSendMessage, onOpenTaskDetail, onOpenMemberProfile, onCreateTask, -}: GraphNodePopoverProps): React.JSX.Element { +}: GraphNodePopoverProps): React.JSX.Element => { if (node.kind === 'member' || node.kind === 'lead') { return ( ); -} +}; // ─── Member Popover ───────────────────────────────────────────────────────── -function MemberPopoverContent({ +const MemberPopoverContent = ({ node, onClose, onSendMessage, @@ -79,7 +79,7 @@ function MemberPopoverContent({ onOpenProfile?: (name: string) => void; onCreateTask?: (owner: string) => void; onOpenTask?: (taskId: string) => void; -}): React.JSX.Element { +}): React.JSX.Element => { const memberName = node.domainRef.kind === 'member' || node.domainRef.kind === 'lead' ? node.domainRef.memberName @@ -303,11 +303,11 @@ function MemberPopoverContent({
); -} +}; // ─── Task Popover ─────────────────────────────────────────────────────────── -function TaskPopoverContent({ +const TaskPopoverContent = ({ node, onClose, onOpenDetail, @@ -315,7 +315,7 @@ function TaskPopoverContent({ node: GraphNode; onClose: () => void; onOpenDetail?: (taskId: string) => void; -}): React.JSX.Element { +}): React.JSX.Element => { const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; const statusColor = @@ -379,4 +379,4 @@ function TaskPopoverContent({
); -} +}; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 91219682..7f4e6d30 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -9,6 +9,7 @@ import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + import { GraphNodePopover } from './GraphNodePopover'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 22049c64..332cb785 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -3,12 +3,13 @@ * Provides Fullscreen button that opens the overlay. */ -import { useCallback, useState, lazy, Suspense } from 'react'; +import { lazy, Suspense, useCallback, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + import { GraphNodePopover } from './GraphNodePopover'; import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph'; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 399ad1b1..27ef20db 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -6,12 +6,12 @@ import { api } from '@renderer/api'; import { syncRendererTelemetry } from '@renderer/sentry'; import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage'; import { normalizePath } from '@renderer/utils/pathNormalize'; -import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { buildTaskChangePresenceKey, buildTaskChangeRequestOptions, canDisplayTaskChangesForOptions, } from '@renderer/utils/taskChangeRequest'; +import { isVersionOlder, normalizeVersion } from '@shared/utils/version'; import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index dfc8ca1e..41285f18 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -12,6 +12,15 @@ vi.mock('@main/utils/shellEnv', () => ({ resolveInteractiveShellEnv: vi.fn(), })); +const addTeamNotificationMock = vi.fn().mockResolvedValue(null); +vi.mock('@main/services/infrastructure/NotificationManager', () => ({ + NotificationManager: { + getInstance: () => ({ + addTeamNotification: addTeamNotificationMock, + }), + }, +})); + import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; @@ -21,6 +30,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { beforeEach(() => { vi.clearAllMocks(); + addTeamNotificationMock.mockResolvedValue(null); tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-prepare-')); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ @@ -228,6 +238,9 @@ describe('TeamProvisioningService prepare/auth behavior', () => { const svc = new TeamProvisioningService(); const emitter = vi.fn(); svc.setTeamChangeEmitter(emitter); + vi.spyOn(svc as any, 'updateConfigPostLaunch').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'cleanupPrelaunchBackup').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'relayLeadInboxMessages').mockResolvedValue(undefined); const run = { runId: 'run-3', From 822bbac23c4bb3ae96229dc424e1730ac7003087 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 17:58:17 +0300 Subject: [PATCH 100/113] feat(agent-teams): integrate MCP tool catalog and enhance tool registration - Added mcpToolCatalog to the agent-teams-controller, exporting new types and constants for MCP tool groups and names. - Updated tools registration to utilize AGENT_TEAMS_MCP_TOOL_GROUPS for streamlined tool management. - Enhanced tests to validate the new operational permissions and ensure correct tool registration behavior. --- agent-teams-controller/src/index.js | 2 + agent-teams-controller/src/mcpToolCatalog.js | 115 +++++++++++++ mcp-server/src/agent-teams-controller.d.ts | 27 +++ mcp-server/src/tools/index.ts | 29 +++- mcp-server/test/tools.test.ts | 43 +---- .../services/team/TeamMcpConfigBuilder.ts | 73 +------- .../services/team/TeamProvisioningService.ts | 105 ++++++++---- .../team/dialogs/CreateTeamDialog.tsx | 4 +- src/shared/utils/toolSummary.ts | 10 +- src/types/agent-teams-controller.d.ts | 26 +++ .../team/TeamMcpConfigBuilder.test.ts | 20 +-- .../team/TeamProvisioningService.test.ts | 157 ++++++++++++++++++ 12 files changed, 442 insertions(+), 169 deletions(-) create mode 100644 agent-teams-controller/src/mcpToolCatalog.js diff --git a/agent-teams-controller/src/index.js b/agent-teams-controller/src/index.js index 464aade2..50bb54f8 100644 --- a/agent-teams-controller/src/index.js +++ b/agent-teams-controller/src/index.js @@ -1,5 +1,7 @@ const controller = require('./controller.js'); +const mcpToolCatalog = require('./mcpToolCatalog.js'); module.exports = { ...controller, + ...mcpToolCatalog, }; diff --git a/agent-teams-controller/src/mcpToolCatalog.js b/agent-teams-controller/src/mcpToolCatalog.js new file mode 100644 index 00000000..3146bad4 --- /dev/null +++ b/agent-teams-controller/src/mcpToolCatalog.js @@ -0,0 +1,115 @@ +const AGENT_TEAMS_TASK_TOOL_NAMES = [ + 'member_briefing', + 'task_add_comment', + 'task_attach_comment_file', + 'task_attach_file', + 'task_briefing', + 'task_complete', + 'task_create', + 'task_create_from_message', + 'task_get', + 'task_get_comment', + 'task_link', + 'task_list', + 'task_set_clarification', + 'task_set_owner', + 'task_set_status', + 'task_start', + 'task_unlink', +]; + +const AGENT_TEAMS_REVIEW_TOOL_NAMES = [ + 'review_approve', + 'review_request', + 'review_request_changes', + 'review_start', +]; + +const AGENT_TEAMS_MESSAGE_TOOL_NAMES = ['message_send']; + +const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES = [ + 'cross_team_get_outbox', + 'cross_team_list_targets', + 'cross_team_send', +]; + +const AGENT_TEAMS_PROCESS_TOOL_NAMES = [ + 'process_list', + 'process_register', + 'process_stop', + 'process_unregister', +]; + +const AGENT_TEAMS_KANBAN_TOOL_NAMES = [ + 'kanban_add_reviewer', + 'kanban_clear', + 'kanban_get', + 'kanban_list_reviewers', + 'kanban_remove_reviewer', + 'kanban_set_column', +]; + +const AGENT_TEAMS_RUNTIME_TOOL_NAMES = ['team_launch', 'team_stop']; + +const AGENT_TEAMS_MCP_TOOL_GROUPS = [ + { + id: 'task', + teammateOperational: true, + toolNames: AGENT_TEAMS_TASK_TOOL_NAMES, + }, + { + id: 'kanban', + teammateOperational: false, + toolNames: AGENT_TEAMS_KANBAN_TOOL_NAMES, + }, + { + id: 'review', + teammateOperational: true, + toolNames: AGENT_TEAMS_REVIEW_TOOL_NAMES, + }, + { + id: 'message', + teammateOperational: true, + toolNames: AGENT_TEAMS_MESSAGE_TOOL_NAMES, + }, + { + id: 'process', + teammateOperational: true, + toolNames: AGENT_TEAMS_PROCESS_TOOL_NAMES, + }, + { + id: 'runtime', + teammateOperational: false, + toolNames: AGENT_TEAMS_RUNTIME_TOOL_NAMES, + }, + { + id: 'crossTeam', + teammateOperational: true, + toolNames: AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES, + }, +]; + +const AGENT_TEAMS_REGISTERED_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.flatMap((group) => [ + ...group.toolNames, +]); + +const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES = AGENT_TEAMS_MCP_TOOL_GROUPS.filter( + (group) => group.teammateOperational +).flatMap((group) => [...group.toolNames]); + +const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES = + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`); + +module.exports = { + AGENT_TEAMS_TASK_TOOL_NAMES, + AGENT_TEAMS_REVIEW_TOOL_NAMES, + AGENT_TEAMS_MESSAGE_TOOL_NAMES, + AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES, + AGENT_TEAMS_PROCESS_TOOL_NAMES, + AGENT_TEAMS_KANBAN_TOOL_NAMES, + AGENT_TEAMS_RUNTIME_TOOL_NAMES, + AGENT_TEAMS_MCP_TOOL_GROUPS, + AGENT_TEAMS_REGISTERED_TOOL_NAMES, + AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, +}; diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 2e66083b..994ba2d3 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -108,4 +108,31 @@ declare module 'agent-teams-controller' { } export const protocols: ProtocolsApi; + + export type AgentTeamsMcpToolGroupId = + | 'task' + | 'kanban' + | 'review' + | 'message' + | 'process' + | 'runtime' + | 'crossTeam'; + + export interface AgentTeamsMcpToolGroup { + id: AgentTeamsMcpToolGroupId; + teammateOperational: boolean; + toolNames: readonly string[]; + } + + export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; + export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; } diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index ec5b94eb..9d3408a1 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -1,5 +1,7 @@ import type { FastMCP } from 'fastmcp'; +import { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } from 'agent-teams-controller'; + import { registerCrossTeamTools } from './crossTeamTools'; import { registerKanbanTools } from './kanbanTools'; import { registerMessageTools } from './messageTools'; @@ -8,12 +10,25 @@ import { registerReviewTools } from './reviewTools'; import { registerRuntimeTools } from './runtimeTools'; import { registerTaskTools } from './taskTools'; +const REGISTRATION_BY_GROUP = { + task: registerTaskTools, + kanban: registerKanbanTools, + review: registerReviewTools, + message: registerMessageTools, + process: registerProcessTools, + runtime: registerRuntimeTools, + crossTeam: registerCrossTeamTools, +} as const; + +export const AGENT_TEAMS_MCP_REGISTRATION_GROUPS = AGENT_TEAMS_MCP_TOOL_GROUPS.map((group) => ({ + ...group, + register: REGISTRATION_BY_GROUP[group.id as keyof typeof REGISTRATION_BY_GROUP], +})); + +export { AGENT_TEAMS_REGISTERED_TOOL_NAMES }; + export function registerTools(server: FastMCP) { - registerTaskTools(server); - registerKanbanTools(server); - registerReviewTools(server); - registerMessageTools(server); - registerProcessTools(server); - registerRuntimeTools(server); - registerCrossTeamTools(server); + for (const group of AGENT_TEAMS_MCP_REGISTRATION_GROUPS) { + group.register(server); + } } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index ac3f41e5..d014c7c3 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -3,7 +3,7 @@ import http from 'http'; import os from 'os'; import path from 'path'; -import { registerTools } from '../src/tools'; +import { AGENT_TEAMS_REGISTERED_TOOL_NAMES, registerTools } from '../src/tools'; type RegisteredTool = { name: string; @@ -30,45 +30,6 @@ function parseJsonToolResult(result: unknown) { describe('agent-teams-mcp tools', () => { const tools = collectTools(); - const expectedToolNames = [ - 'cross_team_get_outbox', - 'cross_team_list_targets', - 'cross_team_send', - 'kanban_add_reviewer', - 'kanban_clear', - 'kanban_get', - 'kanban_list_reviewers', - 'kanban_remove_reviewer', - 'kanban_set_column', - 'member_briefing', - 'message_send', - 'process_list', - 'process_register', - 'process_stop', - 'process_unregister', - 'review_approve', - 'review_request', - 'review_request_changes', - 'review_start', - 'task_add_comment', - 'task_attach_comment_file', - 'task_attach_file', - 'task_briefing', - 'task_complete', - 'task_create', - 'task_create_from_message', - 'task_get', - 'task_get_comment', - 'task_link', - 'task_list', - 'task_set_clarification', - 'task_set_owner', - 'task_set_status', - 'task_start', - 'task_unlink', - 'team_launch', - 'team_stop', - ] as const; function getTool(name: string) { const tool = tools.get(name); @@ -147,7 +108,7 @@ describe('agent-teams-mcp tools', () => { } it('registers the full expected MCP tool surface', () => { - expect([...tools.keys()].sort()).toEqual([...expectedToolNames]); + expect([...tools.keys()].sort()).toEqual([...AGENT_TEAMS_REGISTERED_TOOL_NAMES].sort()); }); it('accepts explicit conversation threading fields for cross_team_send', () => { diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 6987729d..2b96a824 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -1,4 +1,4 @@ -import { getHomeDir, getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder'; +import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { execFile } from 'child_process'; import { randomUUID } from 'crypto'; @@ -14,8 +14,6 @@ interface McpLaunchSpec { const MCP_SERVER_NAME = 'agent-teams'; const logger = createLogger('Service:TeamMcpConfigBuilder'); -const USER_MCP_CONFIG_NAME = '.claude.json'; - const MCP_CONFIG_PREFIX = 'agent-teams-mcp-'; /** * Stale configs older than this are removed on startup (best-effort). @@ -27,10 +25,6 @@ const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; type McpServerConfig = Record; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value); -} - function isPackagedApp(): boolean { try { const { app } = require('electron') as typeof import('electron'); @@ -250,21 +244,23 @@ export class TeamMcpConfigBuilder { configDir, `${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json` ); - const userServers = await this.readUserMcpServers(); + // Keep the team bootstrap config minimal: recent Claude sidechain runs can + // lose the agent-teams tool surface when we inline large user MCP bundles + // into the generated --mcp-config. User/project/local MCP remain loaded + // through Claude's native settings sources. const generatedServers: Record = { [MCP_SERVER_NAME]: { command: launchSpec.command, args: launchSpec.args, }, }; - const mergedServers = this.mergeServers(userServers, generatedServers); await fs.promises.mkdir(configDir, { recursive: true }); await atomicWriteAsync( configPath, JSON.stringify( { - mcpServers: mergedServers, + mcpServers: generatedServers, }, null, 2 @@ -336,61 +332,4 @@ export class TeamMcpConfigBuilder { } } } - - private async readUserMcpServers(): Promise> { - const configPath = path.join(getHomeDir(), USER_MCP_CONFIG_NAME); - return this.readMcpServersFromFile(configPath, 'user'); - } - - private async readMcpServersFromFile( - filePath: string, - scope: 'user' - ): Promise> { - try { - const raw = await fs.promises.readFile(filePath, 'utf8'); - const parsed = JSON.parse(raw) as Record; - const mcpServers = parsed.mcpServers; - if (!isRecord(mcpServers)) { - return {}; - } - - return Object.fromEntries( - Object.entries(mcpServers).filter(([, config]) => isRecord(config)) - ) as Record; - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { - return {}; - } - - logger.warn( - `Failed to read ${scope} MCP config from ${filePath}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - return {}; - } - } - - private mergeServers( - userServers: Record, - generatedServers: Record - ): Record { - const duplicates = Object.keys(userServers).filter((name) => - Object.hasOwn(generatedServers, name) - ); - - if (duplicates.length > 0) { - logger.info(`Merging MCP configs with overrides for: ${duplicates.join(', ')}`); - } - - // We inline only top-level user MCP into --mcp-config. - // Project/local scopes are still loaded natively by Claude via - // --setting-sources user,project,local, which preserves documented precedence: - // local > project > user. Generated agent-teams must always win on name collision. - return { - ...userServers, - ...generatedServers, - }; - } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 565ecdc0..66765fa7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -34,8 +34,8 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; import { isInboxNoiseMessage, - parsePermissionRequest, type ParsedPermissionRequest, + parsePermissionRequest, } from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; @@ -109,7 +109,8 @@ import type { } from '@shared/types'; const logger = createLogger('Service:TeamProvisioning'); -const { createController, protocols } = agentTeamsControllerModule; +const { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, createController, protocols } = + agentTeamsControllerModule; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; @@ -2641,7 +2642,7 @@ export class TeamProvisioningService { const mins = Math.floor(silenceSec / 60); const secs = silenceSec % 60; - const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`; + const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`; // If retry messages are flowing, they are more informative than our // generic stall text — don't overwrite progress.message / severity. @@ -2652,7 +2653,7 @@ export class TeamProvisioningService { ...run.progress, updatedAt: nowIso(), ...(!retryActive && { - message: `CLI not responding for ${elapsed} — possible rate limit`, + message: this.buildStallProgressMessage(silenceSec, elapsed), messageSeverity: 'warning' as const, }), assistantOutput: run.provisioningOutputParts.join('\n\n'), @@ -2678,15 +2679,15 @@ export class TeamProvisioningService { private buildStallWarningText(silenceSec: number, run: ProvisioningRun): string { const mins = Math.floor(silenceSec / 60); const secs = silenceSec % 60; - const elapsed = mins > 0 ? `${mins}m ${secs > 0 ? `${secs}s` : ''}` : `${secs}s`; + const elapsed = mins > 0 ? (secs > 0 ? `${mins}m ${secs}s` : `${mins}m`) : `${secs}s`; if (silenceSec < 60) { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is running but not producing output yet. ` + - `This may be caused by an API delay (rate limit / model cooldown) — ` + - `the SDK retries automatically.\n\n` + + `The process is running but not producing output yet. Cloud sometimes delays logs, ` + + `and short waits like this are normal. The SDK also retries automatically if the ` + + `request briefly hits rate limiting.\n\n` + `Waiting...` ); } @@ -2695,9 +2696,10 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Waiting for CLI response** (silent for ${elapsed})\n\n` + - `The process is still not responding. Likely delayed due to rate limiting ` + - `(error 429 / model cooldown). The SDK retries the request automatically — ` + - `this usually resolves within 1-3 minutes.\n\n` + + `The process is still waiting on Cloud. Logs can sometimes show up after ` + + `1-1.5 minutes, and that is still okay. The SDK retries automatically if the ` + + `request hits rate limiting (error 429 / model cooldown).\n\n` + + `If there is still no output after 2 minutes, that starts to look unusual.\n\n` + `You can cancel and try again later if the wait continues.` ); } @@ -2708,15 +2710,23 @@ export class TeamProvisioningService { return ( `---\n\n` + `**Extended CLI wait** (silent for ${elapsed})\n\n` + - `Model **${modelName}**${effortLabel} appears to be under heavy load and is not responding. ` + - `Most likely this is a 429 error (rate limit / model cooldown).\n\n` + - `The process has been silent for over ${mins} minutes. Possible causes:\n` + + `Model **${modelName}**${effortLabel} is still waiting on Cloud. Some delay is normal, ` + + `but no logs for ${elapsed} is already unusual.\n\n` + + `Possible causes:\n` + `- Rate limiting / model cooldown (429) — SDK retries automatically\n` + - `- API server overload for this model\n\n` + + `- API server overload for this model\n` + + `- A stalled or delayed Cloud response\n\n` + `Consider canceling and trying with a different model.` ); } + private buildStallProgressMessage(silenceSec: number, elapsed: string): string { + if (silenceSec < 120) { + return `Waiting on Cloud response for ${elapsed} — logs can be delayed, this is still OK`; + } + return `Still waiting on Cloud response for ${elapsed} — this is unusual`; + } + /** * Detects auth failure keywords in stderr/stdout during provisioning. * On first detection: kills process, waits, and respawns automatically. @@ -3216,6 +3226,9 @@ export class TeamProvisioningService { joinedAt: Date.now(), })) ); + if (request.skipPermissions === false) { + await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + } child = spawnCli(claudePath, spawnArgs, { cwd: request.cwd, @@ -3663,6 +3676,9 @@ export class TeamProvisioningService { // Without it, CLI creates a fresh session ID automatically. try { + if (request.skipPermissions === false) { + await this.seedTeammateOperationalPermissionRules(request.teamName, request.cwd); + } child = spawnCli(claudePath, launchArgs, { cwd: request.cwd, env: { ...shellEnv }, @@ -6375,23 +6391,18 @@ export class TeamProvisioningService { .filter((name): name is string => typeof name === 'string' && name.length > 0); if (toolNames.length === 0) continue; - // When approving ANY mcp__agent-teams__ tool, proactively add ALL agent-teams tools. - // FACT: Teammates need multiple MCP tools (member_briefing, task_get, task_start, etc.) - // FACT: Each tool generates a separate permission_request, but by the time we process it - // the teammate is already stuck waiting. Pre-adding all tools prevents future blocks. - if (toolNames.some((name) => name.startsWith('mcp__agent-teams__'))) { - const agentTeamsTools = [ - 'mcp__agent-teams__member_briefing', - 'mcp__agent-teams__task_briefing', - 'mcp__agent-teams__task_create', - 'mcp__agent-teams__task_get', - 'mcp__agent-teams__task_list', - 'mcp__agent-teams__task_start', - 'mcp__agent-teams__task_complete', - 'mcp__agent-teams__task_set_status', - 'mcp__agent-teams__task_add_comment', - ]; - const merged = new Set([...toolNames, ...agentTeamsTools]); + // Expand teammate-safe operational tools only. + // This removes the bootstrap/task workflow race without accidentally granting + // admin/runtime tools like team_stop or kanban_clear. + if ( + toolNames.some((name) => + AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES.includes(name) + ) + ) { + const merged = new Set([ + ...toolNames, + ...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES, + ]); toolNames = Array.from(merged); } @@ -6426,7 +6437,7 @@ export class TeamProvisioningService { settingsPath: string, toolNames: string[], behavior: string - ): Promise { + ): Promise { const dir = path.dirname(settingsPath); await fs.promises.mkdir(dir, { recursive: true }); @@ -6465,9 +6476,33 @@ export class TeamProvisioningService { } } - if (added === 0) return; // Nothing new to add + if (added === 0) return 0; // Nothing new to add - await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await atomicWriteAsync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); + return added; + } + + private async seedTeammateOperationalPermissionRules( + teamName: string, + projectCwd: string + ): Promise { + const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json'); + try { + const added = await this.addPermissionRulesToSettings( + settingsPath, + [...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES], + 'allow' + ); + logger.info( + `[${teamName}] Seeded teammate operational MCP rules in ${settingsPath} (${added} added)` + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to seed teammate operational MCP rules: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } /** diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index e376daf9..1d2d7550 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -112,9 +112,7 @@ const DEFAULT_MEMBERS: { name: string; roleSelection: string; workflow?: string }, { name: 'tom', - roleSelection: 'researcher', - workflow: - 'Research topics, gather information, and analyze relevant sources. Investigate questions, explore options, and provide detailed findings with clear summaries for the team.', + roleSelection: 'developer', }, { name: 'bob', roleSelection: 'developer' }, { name: 'jack', roleSelection: 'developer' }, diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index c9f20810..546fd5ba 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -112,7 +112,15 @@ export function extractToolPreview( 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; + const v = + input.subject ?? + input.name ?? + input.description ?? + input.prompt ?? + input.path ?? + input.file ?? + input.query ?? + input.command; return typeof v === 'string' ? truncateStr(v, 50) : undefined; } } diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 7c173299..4d60e345 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -93,9 +93,35 @@ declare module 'agent-teams-controller' { buildProcessProtocolText(teamName: string): string; } + export type AgentTeamsMcpToolGroupId = + | 'task' + | 'kanban' + | 'review' + | 'message' + | 'process' + | 'runtime' + | 'crossTeam'; + + export interface AgentTeamsMcpToolGroup { + id: AgentTeamsMcpToolGroupId; + teammateOperational: boolean; + toolNames: readonly string[]; + } + export function createController(options: ControllerContextOptions): AgentTeamsController; export const agentBlocks: AgentBlocksApi; export const protocols: ProtocolsApi; + export const AGENT_TEAMS_TASK_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_REVIEW_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MESSAGE_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_CROSS_TEAM_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_PROCESS_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_KANBAN_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_RUNTIME_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_MCP_TOOL_GROUPS: readonly AgentTeamsMcpToolGroup[]; + export const AGENT_TEAMS_REGISTERED_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; + export const AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES: readonly string[]; } diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 6dcb2620..ad9394e5 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -181,7 +181,7 @@ describe('TeamMcpConfigBuilder', () => { ]); }); - it('merges top-level user MCP with generated agent-teams config', async () => { + it('keeps generated team MCP config minimal and does not inline top-level user MCP', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-')); createdDirs.push(homeDir, projectDir); @@ -223,19 +223,9 @@ describe('TeamMcpConfigBuilder', () => { mcpServers: Record; }; - expect(Object.keys(parsed.mcpServers).sort()).toEqual([ - 'agent-teams', - 'duplicateServer', - 'globalOnly', - ]); - expect(parsed.mcpServers.globalOnly).toMatchObject({ - type: 'http', - url: 'https://global.example.com/mcp', - }); - expect(parsed.mcpServers.duplicateServer).toMatchObject({ - type: 'http', - url: 'https://global.example.com/duplicate', - }); + expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); + expect(parsed.mcpServers.globalOnly).toBeUndefined(); + expect(parsed.mcpServers.duplicateServer).toBeUndefined(); }); it('does not inline project MCP config to preserve native Claude precedence', async () => { @@ -270,7 +260,7 @@ describe('TeamMcpConfigBuilder', () => { expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); }); - it('generated agent-teams server overrides same-named user MCP entry', async () => { + it('generated agent-teams server ignores same-named user MCP entry', async () => { const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); createdDirs.push(homeDir); mockHomeDir = homeDir; diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index caddafe8..10bd96dc 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -53,6 +53,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { spawnCli } from '@main/utils/childProcess'; +import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller'; function allowConsoleLogs() { vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -324,4 +325,160 @@ describe('TeamProvisioningService', () => { run.timeoutHandle = null; } }); + + it('pre-seeds teammate operational MCP permissions before createTeam spawn', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockImplementation(() => { + throw new Error('spawn EINVAL'); + }); + + const mcpConfigBuilder = { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'), + removeConfigFile: vi.fn(async () => {}), + }; + const membersMetaStore = { + writeMembers: vi.fn(async () => {}), + }; + const teamMetaStore = { + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + }; + + const svc = new TeamProvisioningService( + undefined, + undefined, + membersMetaStore as any, + undefined, + mcpConfigBuilder as any, + teamMetaStore as any + ); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).pathExists = vi.fn(async () => false); + + await expect( + svc.createTeam( + { + teamName: 'seeded-team', + cwd: tempClaudeRoot, + members: [{ name: 'alice' }], + skipPermissions: false, + }, + () => {} + ) + ).rejects.toThrow('spawn EINVAL'); + + const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + permissions?: { allow?: string[] }; + }; + expect(settings.permissions?.allow).toEqual( + expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES]) + ); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); + }); + + it('expands teammate permission suggestions to the operational tool set only', async () => { + allowConsoleLogs(); + const svc = new TeamProvisioningService( + { + getConfig: vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })), + } as any + ); + + await (svc as any).respondToTeammatePermission( + { teamName: 'ops-team' }, + 'alice', + 'req-1', + true, + undefined, + [ + { + type: 'addRules', + behavior: 'allow', + destination: 'localSettings', + rules: [{ toolName: 'mcp__agent-teams__task_get' }], + }, + ] + ); + + const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + permissions?: { allow?: string[] }; + }; + expect(settings.permissions?.allow).toEqual( + expect.arrayContaining([...AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES]) + ); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); + expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); + }); + + it('does not broaden admin/runtime teammate permission suggestions', async () => { + allowConsoleLogs(); + const svc = new TeamProvisioningService( + { + getConfig: vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })), + } as any + ); + + await (svc as any).respondToTeammatePermission( + { teamName: 'ops-team' }, + 'alice', + 'req-2', + true, + undefined, + [ + { + type: 'addRules', + behavior: 'allow', + destination: 'localSettings', + rules: [{ toolName: 'mcp__agent-teams__team_stop' }], + }, + ] + ); + + const settingsPath = path.join(tempClaudeRoot, '.claude', 'settings.local.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + permissions?: { allow?: string[] }; + }; + expect(settings.permissions?.allow).toEqual(['mcp__agent-teams__team_stop']); + }); + + it('uses a non-alarming cloud delay message before 2 minutes of silence', () => { + const svc = new TeamProvisioningService(); + + expect((svc as any).buildStallProgressMessage(90, '1m 30s')).toBe( + 'Waiting on Cloud response for 1m 30s — logs can be delayed, this is still OK' + ); + + expect( + (svc as any).buildStallWarningText(90, { + request: { model: 'sonnet' }, + }) + ).toContain('Logs can sometimes show up after 1-1.5 minutes, and that is still okay.'); + }); + + it('marks a cloud wait as unusual after 2 minutes of silence', () => { + const svc = new TeamProvisioningService(); + + expect((svc as any).buildStallProgressMessage(120, '2m')).toBe( + 'Still waiting on Cloud response for 2m — this is unusual' + ); + + expect( + (svc as any).buildStallWarningText(120, { + request: { model: 'sonnet' }, + }) + ).toContain('but no logs for 2m is already unusual.'); + }); }); From d0715dfad699147556efbd1a90c04c45dda23340 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 18:59:39 +0300 Subject: [PATCH 101/113] docs(README): add warning about Claude Code issue with agent teams - Included a warning regarding an upstream issue in Claude Code affecting the inheritance of external MCP tools by teammates spawned via Agent/Task. - Noted the impact on bootstrap processes and provided a link to track the issue status. --- README.md | 3 +++ mcp-server/src/tools/index.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d4801cd1..a4699ce6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@

100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.

+ +> [!WARNING] +> There is an upstream Claude Code issue where teammates spawned via Agent/Task may fail to inherit external MCP tools. In Agent Teams this breaks bootstrap because `mcp__agent-teams__member_briefing` is missing from the teammate tool surface, so agents report that `member_briefing` is unavailable and stop before starting work. We reproduced this locally with direct Claude Code binaries `2.1.86` and `2.1.87`, so a reliable version-pin workaround is not confirmed. Track upstream status in [anthropics/claude-code#7296](https://github.com/anthropics/claude-code/issues/7296).
diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index 9d3408a1..e8de3914 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -1,6 +1,9 @@ import type { FastMCP } from 'fastmcp'; -import { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } from 'agent-teams-controller'; +import agentTeamsControllerModule from 'agent-teams-controller'; + +const { AGENT_TEAMS_MCP_TOOL_GROUPS, AGENT_TEAMS_REGISTERED_TOOL_NAMES } = + agentTeamsControllerModule as typeof import('agent-teams-controller'); import { registerCrossTeamTools } from './crossTeamTools'; import { registerKanbanTools } from './kanbanTools'; From f6daced2ce436a91b7e704ddd3629a351f35f0fe Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 19:01:54 +0300 Subject: [PATCH 102/113] docs(README): remove outdated warning about Claude Code issue - Deleted the warning regarding the upstream Claude Code issue affecting agent teams, as it is no longer relevant. --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index a4699ce6..51909678 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,6 @@ 100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.

-> [!WARNING] -> There is an upstream Claude Code issue where teammates spawned via Agent/Task may fail to inherit external MCP tools. In Agent Teams this breaks bootstrap because `mcp__agent-teams__member_briefing` is missing from the teammate tool surface, so agents report that `member_briefing` is unavailable and stop before starting work. We reproduced this locally with direct Claude Code binaries `2.1.86` and `2.1.87`, so a reliable version-pin workaround is not confirmed. Track upstream status in [anthropics/claude-code#7296](https://github.com/anthropics/claude-code/issues/7296).
From 27b1a4fd9af52db5823593732f89c168c232a950 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 19:07:16 +0300 Subject: [PATCH 103/113] fix(graph): filter system messages from particles + fix direction - Skip idle_notification, shutdown, and other JSON system messages (was showing {"type":"idle_notificatio... as particle labels) - Skip system_notification source messages - Skip messages with < 3 chars --- .../agent-graph/adapters/TeamGraphAdapter.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index fb446754..97d0fdcf 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -494,6 +494,9 @@ export class TeamGraphAdapter { if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); + // Skip system/noise messages (idle notifications, JSON blobs) + if (TeamGraphAdapter.#isSystemMessage(msg)) continue; + const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); if (!edgeId) continue; @@ -691,6 +694,18 @@ export class TeamGraphAdapter { return `member:${teamName}:${name}`; } + /** Filter out system/noise messages that shouldn't show as particles */ + static #isSystemMessage(msg: InboxMessage): boolean { + const text = msg.text ?? ''; + // JSON system messages (idle_notification, shutdown, etc.) + if (text.startsWith('{"type":') || text.startsWith('{"type" :')) return true; + // Very short system messages + if (text.length < 3) return true; + // System notification source + if (msg.source === 'system_notification') return true; + return false; + } + static #buildParticleLabel( text: string | undefined, kind: 'inbox' | 'comment', From 942588093bfdd9ca071a9e5912ce1832ddb508fa Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 19:09:49 +0300 Subject: [PATCH 104/113] fix(graph): pause button as standalone icon left of View dropdown --- packages/agent-graph/src/ui/GraphControls.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 3bf4e39e..135d0acf 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -109,6 +109,19 @@ export function GraphControls({
+
+ toggle('paused')} + icon={filters.paused ? : } + /> +
+
-
- toggle('paused')} - icon={filters.paused ? : } - label={filters.paused ? 'Resume' : 'Pause'} - block - />
)}
From 485327d0777a0ffd912310eccb0ae04f46a6f656 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 19:20:53 +0300 Subject: [PATCH 105/113] fix(graph): correct particle direction + remove system message filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Particle direction: - Added `reverse` flag to GraphParticle — when true, particle flies from target → source (reverse of edge direction) - Messages FROM teammate TO lead now fly member→lead (was lead→member) - draw-particles.ts swaps from/to nodes when reverse=true Reverted system message filter: - Removed #isSystemMessage — all messages shown as particles again (user wants to see idle_notification etc.) --- .../agent-graph/src/canvas/draw-particles.ts | 12 ++++++--- packages/agent-graph/src/ports/types.ts | 2 ++ .../agent-graph/adapters/TeamGraphAdapter.ts | 26 ++++++++----------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts index 554c59cc..3118af0e 100644 --- a/packages/agent-graph/src/canvas/draw-particles.ts +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -37,7 +37,11 @@ export function drawParticles( if (!source || !target) continue; if (source.x == null || source.y == null || target.x == null || target.y == null) continue; - const cp = computeControlPoints(source.x, source.y, target.x, target.y); + // Reverse: swap source/target for particles going in opposite direction + const from = p.reverse ? target : source; + const to = p.reverse ? source : target; + + const cp = computeControlPoints(from.x!, from.y!, to.x!, to.y!); const color = p.color || COLORS.message; const baseSize = (p.size ?? 1) * 3; // Differentiate visual by particle kind @@ -50,12 +54,12 @@ export function drawParticles( const phaseOffset = p.id.charCodeAt(Math.min(5, p.id.length - 1)) * 0.1; const wobbleAmp = BEAM.wobble.amp; - drawParticleTrail(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind); - drawParticleCore(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind); + drawParticleTrail(ctx, from, to, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind); + drawParticleCore(ctx, from, to, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind); // Label if (p.label && p.progress > PARTICLE_DRAW.labelMinT && p.progress < PARTICLE_DRAW.labelMaxT) { - const pos = getWobbledPosition(source, target, cp, p.progress, wobbleAmp, phaseOffset, time); + const pos = getWobbledPosition(from, to, cp, p.progress, wobbleAmp, phaseOffset, time); ctx.font = `${PARTICLE_DRAW.labelFontSize}px monospace`; ctx.textAlign = 'center'; ctx.fillStyle = hexWithAlpha(color, 0.56); diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 8517e081..ea7378f7 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -135,6 +135,8 @@ export interface GraphParticle { size?: number; /** Short label near particle */ label?: string; + /** If true, particle travels from target → source (reverse direction) */ + reverse?: boolean; } // ─── Domain Reference (opaque back-pointer) ────────────────────────────────── diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 97d0fdcf..755e50f3 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -494,12 +494,19 @@ export class TeamGraphAdapter { if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); - // Skip system/noise messages (idle notifications, JSON blobs) - if (TeamGraphAdapter.#isSystemMessage(msg)) continue; - const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); if (!edgeId) continue; + // Determine direction: messages FROM a teammate TO lead should reverse + // (edges are always lead→member, but message goes member→lead) + const fromId = TeamGraphAdapter.#resolveParticipantId( + msg.from ?? '', + teamName, + leadId, + leadName + ); + const isFromTeammate = fromId !== leadId; + particles.push({ id: `particle:msg:${teamName}:${msgKey}`, edgeId, @@ -507,6 +514,7 @@ export class TeamGraphAdapter { kind: 'inbox_message', color: msg.color ?? '#66ccff', label: TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox'), + reverse: isFromTeammate, }); } } @@ -694,18 +702,6 @@ export class TeamGraphAdapter { return `member:${teamName}:${name}`; } - /** Filter out system/noise messages that shouldn't show as particles */ - static #isSystemMessage(msg: InboxMessage): boolean { - const text = msg.text ?? ''; - // JSON system messages (idle_notification, shutdown, etc.) - if (text.startsWith('{"type":') || text.startsWith('{"type" :')) return true; - // Very short system messages - if (text.length < 3) return true; - // System notification source - if (msg.source === 'system_notification') return true; - return false; - } - static #buildParticleLabel( text: string | undefined, kind: 'inbox' | 'comment', From 92968b45adde94ba56e36b689643f398454c3200 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 20:02:05 +0300 Subject: [PATCH 106/113] refactor(graph): simplify comment particle rendering with dedicated bubble function - Replaced inline drawing logic for task comments with a new `drawCommentBubble` function to enhance readability and maintainability. - The new function encapsulates the drawing of a speech-bubble icon, including the rounded rectangle body, tail, and inner dots to suggest text. --- .../agent-graph/src/canvas/draw-particles.ts | 56 +++++++++--- .../services/team/ChangeExtractorService.ts | 2 + src/main/services/team/TeamDataService.ts | 1 + .../services/team/taskChangePresenceUtils.ts | 13 ++- .../services/team/taskChangeWorkerTypes.ts | 1 + .../agent-graph/adapters/TeamGraphAdapter.ts | 3 + src/renderer/utils/taskChangeRequest.ts | 25 +----- src/shared/utils/taskChangeSince.ts | 51 +++++++++++ .../services/team/TeamDataService.test.ts | 88 +++++++++++++++++++ 9 files changed, 206 insertions(+), 34 deletions(-) create mode 100644 src/shared/utils/taskChangeSince.ts diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts index 3118af0e..ce222779 100644 --- a/packages/agent-graph/src/canvas/draw-particles.ts +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -156,16 +156,7 @@ function drawParticleCore( ctx.drawImage(sprite, pos.x - glowR, pos.y - glowR); if (kind === 'task_comment') { - ctx.strokeStyle = color; - ctx.lineWidth = 1.8; - ctx.beginPath(); - ctx.arc(pos.x, pos.y, size * 1.1, 0, Math.PI * 2); - ctx.stroke(); - - ctx.fillStyle = '#ffffff'; - ctx.beginPath(); - ctx.arc(pos.x, pos.y, size * 0.35, 0, Math.PI * 2); - ctx.fill(); + drawCommentBubble(ctx, pos.x, pos.y, size, color); return; } @@ -181,3 +172,48 @@ function drawParticleCore( ctx.arc(pos.x, pos.y, size * PARTICLE_DRAW.coreHighlightScale, 0, Math.PI * 2); ctx.fill(); } + +/** + * Draw a speech-bubble icon for comment particles. + * Rounded rect body + small triangular tail at bottom-left. + */ +function drawCommentBubble( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + size: number, + color: string, +): void { + const w = size * 2.4; + const h = size * 1.8; + const r = size * 0.4; // corner radius + const x = cx - w / 2; + const y = cy - h / 2; + + // Bubble body + ctx.fillStyle = color; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + ctx.fill(); + + // Tail (small triangle at bottom-left) + const tailX = x + w * 0.25; + const tailY = y + h; + ctx.beginPath(); + ctx.moveTo(tailX, tailY - 1); + ctx.lineTo(tailX - size * 0.4, tailY + size * 0.5); + ctx.lineTo(tailX + size * 0.4, tailY - 1); + ctx.closePath(); + ctx.fill(); + + // Inner dots (three small dots to suggest text) + ctx.fillStyle = '#ffffff'; + const dotR = size * 0.18; + const dotY = cy - size * 0.05; + const gap = size * 0.5; + for (let i = -1; i <= 1; i++) { + ctx.beginPath(); + ctx.arc(cx + i * gap, dotY, dotR, 0, Math.PI * 2); + ctx.fill(); + } +} diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 6a29c300..c8b5f162 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -371,6 +371,7 @@ export class ChangeExtractorService { return derived.length > 0 ? derived : undefined; })(); return { + createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, status: typeof parsed.status === 'string' ? parsed.status : undefined, intervals: derivedIntervals, @@ -725,6 +726,7 @@ export class ChangeExtractorService { } const descriptor = buildTaskChangePresenceDescriptor({ + createdAt: taskMeta.createdAt, owner: effectiveOptions.owner ?? taskMeta.owner, status: effectiveOptions.status ?? taskMeta.status, intervals: effectiveOptions.intervals ?? taskMeta.intervals, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ab2393b3..92bdbf5e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -234,6 +234,7 @@ export class TeamDataService { for (const task of tasks) { const descriptor = buildTaskChangePresenceDescriptor({ + createdAt: task.createdAt, owner: task.owner, status: task.status, intervals: task.workIntervals, diff --git a/src/main/services/team/taskChangePresenceUtils.ts b/src/main/services/team/taskChangePresenceUtils.ts index 58cc6c90..3ef3b3c8 100644 --- a/src/main/services/team/taskChangePresenceUtils.ts +++ b/src/main/services/team/taskChangePresenceUtils.ts @@ -2,6 +2,7 @@ import { getTaskChangeStateBucket, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; +import { deriveTaskSince } from '@shared/utils/taskChangeSince'; import { createHash } from 'crypto'; export interface TaskChangePresenceInterval { @@ -13,6 +14,7 @@ export interface TaskChangePresenceDescriptorInput { owner?: string; status?: string; intervals?: TaskChangePresenceInterval[]; + createdAt?: string; since?: string; reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; historyEvents?: unknown[]; @@ -102,6 +104,15 @@ export function computeTaskChangePresenceProjectFingerprint( export function buildTaskChangePresenceDescriptor( input: TaskChangePresenceDescriptorInput ): TaskChangePresenceDescriptor { + const effectiveSince = + typeof input.since === 'string' + ? input.since + : deriveTaskSince({ + createdAt: input.createdAt, + workIntervals: input.intervals, + historyEvents: input.historyEvents as { timestamp?: string | null }[] | undefined, + }); + const effectiveIntervals = Array.isArray(input.intervals) && input.intervals.length > 0 ? input.intervals.map((interval) => ({ @@ -124,7 +135,7 @@ export function buildTaskChangePresenceDescriptor( owner: typeof input.owner === 'string' ? input.owner.trim() : '', status: typeof input.status === 'string' ? input.status.trim() : '', intervals: effectiveIntervals, - since: typeof input.since === 'string' ? input.since : '', + since: effectiveSince ?? '', }; return { diff --git a/src/main/services/team/taskChangeWorkerTypes.ts b/src/main/services/team/taskChangeWorkerTypes.ts index 5bc0f105..a1aae4fe 100644 --- a/src/main/services/team/taskChangeWorkerTypes.ts +++ b/src/main/services/team/taskChangeWorkerTypes.ts @@ -1,6 +1,7 @@ import type { TaskChangeSetV2 } from '@shared/types'; export interface TaskChangeTaskMeta { + createdAt?: string; owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 755e50f3..d6a84fd9 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -494,6 +494,9 @@ export class TeamGraphAdapter { if (this.#seenMessageIds.has(msgKey)) continue; this.#seenMessageIds.add(msgKey); + // Skip comment notifications — #buildCommentParticles handles them with real text + if (msg.summary?.startsWith('Comment on ')) continue; + const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges); if (!edgeId) continue; diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index e384deca..64960d9c 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -3,12 +3,11 @@ import { isTaskChangeSummaryCacheable, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; +import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince'; import type { ReviewAPI } from '@shared/types/api'; import type { TeamTaskWithKanban } from '@shared/types/team'; -const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; - export type TaskChangeRequestOptions = NonNullable[2]>; export interface TaskChangeContext { @@ -31,27 +30,7 @@ type TaskChangeTaskLike = Pick< >; export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undefined { - if (!task) return undefined; - - const sources: string[] = []; - if (task.createdAt) sources.push(task.createdAt); - if (Array.isArray(task.workIntervals)) { - for (const interval of task.workIntervals) { - if (interval.startedAt) sources.push(interval.startedAt); - } - } - if (Array.isArray(task.historyEvents)) { - for (const event of task.historyEvents) { - if (event.timestamp) sources.push(event.timestamp); - } - } - if (sources.length === 0) return undefined; - - const [first, ...rest] = sources; - const earliest = rest.reduce((a, b) => (a < b ? a : b), first); - const date = new Date(earliest); - date.setTime(date.getTime() - TASK_SINCE_GRACE_MS); - return date.toISOString(); + return deriveSharedTaskSince(task); } export function buildTaskChangeRequestOptions( diff --git a/src/shared/utils/taskChangeSince.ts b/src/shared/utils/taskChangeSince.ts new file mode 100644 index 00000000..b21b92ac --- /dev/null +++ b/src/shared/utils/taskChangeSince.ts @@ -0,0 +1,51 @@ +const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; + +type TaskChangeIntervalLike = { + startedAt?: string | null; +}; + +type TaskChangeHistoryEventLike = { + timestamp?: string | null; +}; + +export interface TaskChangeSinceLike< + TInterval extends TaskChangeIntervalLike = TaskChangeIntervalLike, + THistoryEvent extends TaskChangeHistoryEventLike = TaskChangeHistoryEventLike, +> { + createdAt?: string | null; + workIntervals?: TInterval[] | null; + historyEvents?: THistoryEvent[] | null; +} + +export function deriveTaskSince< + TInterval extends TaskChangeIntervalLike, + THistoryEvent extends TaskChangeHistoryEventLike, +>(task: TaskChangeSinceLike | null | undefined): string | undefined { + if (!task) return undefined; + + const sources: string[] = []; + if (typeof task.createdAt === 'string' && task.createdAt.length > 0) { + sources.push(task.createdAt); + } + if (Array.isArray(task.workIntervals)) { + for (const interval of task.workIntervals) { + if (typeof interval?.startedAt === 'string' && interval.startedAt.length > 0) { + sources.push(interval.startedAt); + } + } + } + if (Array.isArray(task.historyEvents)) { + for (const event of task.historyEvents) { + if (typeof event?.timestamp === 'string' && event.timestamp.length > 0) { + sources.push(event.timestamp); + } + } + } + if (sources.length === 0) return undefined; + + const [first, ...rest] = sources; + const earliest = rest.reduce((a, b) => (a < b ? a : b), first); + const date = new Date(earliest); + date.setTime(date.getTime() - TASK_SINCE_GRACE_MS); + return date.toISOString(); +} diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 8f2aa970..0e574462 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1867,6 +1867,94 @@ describe('TeamDataService', () => { expect(mismatched.tasks[0]?.changePresence).toBe('unknown'); }); + it('preserves cached changePresence when persisted entry was recorded with derived since', async () => { + const task: TeamTask = { + id: 'task-1', + subject: 'Review API', + status: 'completed', + owner: 'alice', + createdAt: '2026-03-01T10:05:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }], + historyEvents: [ + { + id: 'evt-1', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-03-01T10:00:00.000Z', + }, + ], + }; + + const persistedDescriptor = buildTaskChangePresenceDescriptor({ + createdAt: task.createdAt, + owner: task.owner, + status: task.status, + intervals: task.workIntervals, + since: '2026-03-01T09:58:00.000Z', + historyEvents: task.historyEvents, + reviewState: 'none', + }); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })), + } as never, + { + getTasks: vi.fn(async () => [task]), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + } as never + ); + + service.setTaskChangePresenceServices( + { + load: vi.fn(async () => ({ + version: 1, + teamName: 'my-team', + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + writtenAt: '2026-03-01T12:00:00.000Z', + entries: { + 'task-1': { + taskId: 'task-1', + taskSignature: persistedDescriptor.taskSignature, + presence: 'has_changes', + writtenAt: '2026-03-01T12:00:00.000Z', + logSourceGeneration: 'log-generation', + }, + }, + })), + upsertEntry: vi.fn(async () => undefined), + } as never, + { + getSnapshot: vi.fn(() => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + ensureTracking: vi.fn(async () => ({ + projectFingerprint: 'project-fingerprint', + logSourceGeneration: 'log-generation', + })), + } as never + ); + + const data = await service.getTeamData('my-team'); + + expect(data.tasks[0]?.changePresence).toBe('has_changes'); + }); + it('returns lightweight task change presence without loading full team data', async () => { const task: TeamTask = { id: 'task-1', From 9a1ba763247843b42566bb73480300f4d8e0fb26 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 20:27:03 +0300 Subject: [PATCH 107/113] fix(team): handle setMode permission_suggestions for Write/Edit tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FACT: Write/Edit permission_requests have permission_suggestions with type "setMode" (not "addRules"): { type: "setMode", mode: "acceptEdits" } Our code only handled "addRules", so Write/Edit approvals were no-ops. Translate setMode suggestions to settings rules: - acceptEdits → add Edit, Write, NotebookEdit to allow list - bypassPermissions → add all common tools to allow list --- .../services/team/TeamProvisioningService.ts | 31 +++++++++++++++++++ src/shared/utils/inboxNoise.ts | 2 ++ 2 files changed, 33 insertions(+) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 66765fa7..4ad82395 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6384,6 +6384,37 @@ export class TeamProvisioningService { } for (const suggestion of suggestions) { + // Handle "setMode" suggestions (e.g. Write/Edit tools suggest acceptEdits mode) + // FACT: Write/Edit permission_requests have permission_suggestions: + // { type: "setMode", mode: "acceptEdits", destination: "session" } + // Since we can't change session mode of a subprocess, we translate to addRules. + if (suggestion.type === 'setMode') { + const mode = typeof suggestion.mode === 'string' ? suggestion.mode : ''; + let toolNames: string[] = []; + if (mode === 'acceptEdits') { + toolNames = ['Edit', 'Write', 'NotebookEdit']; + } else if (mode === 'bypassPermissions') { + // Broad approval — add common tools + toolNames = ['Edit', 'Write', 'NotebookEdit', 'Bash', 'Read', 'Grep', 'Glob']; + } + if (toolNames.length > 0) { + const settingsPath = path.join(projectCwd, '.claude', 'settings.local.json'); + try { + await this.addPermissionRulesToSettings(settingsPath, toolNames, 'allow'); + logger.info( + `[${run.teamName}] Applied setMode "${mode}" for ${agentId}: ${toolNames.join(', ')} in ${settingsPath}` + ); + } catch (error) { + logger.error( + `[${run.teamName}] Failed to apply setMode: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + continue; + } + if (suggestion.type !== 'addRules' || !Array.isArray(suggestion.rules)) continue; let toolNames = suggestion.rules diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index b6784819..0886e309 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -47,6 +47,8 @@ export interface PermissionSuggestion { rules?: { toolName: string }[]; behavior?: string; destination?: string; + /** Permission mode name (for type: "setMode"). FACT: observed values: "acceptEdits", "bypassPermissions" */ + mode?: string; } /** Parsed teammate permission request from inbox message. */ From bd242fac5a6583fa5b26c4e0158264ed3733c387 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 30 Mar 2026 21:47:40 +0300 Subject: [PATCH 108/113] fix(team): re-add control_response via stdin for teammate permissions Belt-and-suspenders approach: 1. Settings file: handles all FUTURE calls (teammate finds rule on retry) 2. control_response via stdin: may unblock CURRENT waiting prompt (now includes updatedInput: {} which was the previous ZodError fix) Without #2, approved teammates stay stuck until team restart because the CLI doesn't hot-reload settings.local.json for pending prompts. --- .../agent-graph/src/canvas/draw-agents.ts | 17 ++++++-- .../agent-graph/src/canvas/draw-effects.ts | 9 ++-- packages/agent-graph/src/canvas/draw-tasks.ts | 37 ++++++++++++++--- .../src/hooks/useGraphSimulation.ts | 5 ++- .../agent-graph/src/layout/kanbanLayout.ts | 8 ++++ packages/agent-graph/src/ports/types.ts | 12 ++++++ packages/agent-graph/src/ui/GraphCanvas.tsx | 6 +-- .../services/team/TeamProvisioningService.ts | 22 ++++++++++ .../agent-graph/adapters/TeamGraphAdapter.ts | 31 +++++++++++++- .../agent-graph/ui/GraphNodePopover.tsx | 41 +++++++++++++++++-- 10 files changed, 166 insertions(+), 22 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 1d0cc7c5..e30b5c17 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -52,7 +52,7 @@ export function drawAgents( // Pending approval indicator: pulsing amber ring if (node.pendingApproval) { - const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 3); + const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 7); const ringR = r + 5; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); @@ -367,20 +367,31 @@ function drawBreathing( } } -// ─── Avatar image cache ───────────────────────────────────────────────────── +// ─── Avatar image cache with LRU eviction ─────────────────────────────────── +const AVATAR_CACHE_MAX = 100; const avatarCache = new Map(); const avatarLoading = new Set(); function getAvatarImage(url: string): HTMLImageElement | null { const cached = avatarCache.get(url); - if (cached) return cached; + if (cached) { + // Move to end (most recently used) + avatarCache.delete(url); + avatarCache.set(url, cached); + return cached; + } if (avatarLoading.has(url)) return null; avatarLoading.add(url); const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { + // Evict oldest entry if over limit + if (avatarCache.size >= AVATAR_CACHE_MAX) { + const first = avatarCache.keys().next().value; + if (first != null) avatarCache.delete(first); + } avatarCache.set(url, img); avatarLoading.delete(url); }; diff --git a/packages/agent-graph/src/canvas/draw-effects.ts b/packages/agent-graph/src/canvas/draw-effects.ts index b2e4e68e..4bdcd8e0 100644 --- a/packages/agent-graph/src/canvas/draw-effects.ts +++ b/packages/agent-graph/src/canvas/draw-effects.ts @@ -17,6 +17,8 @@ export interface VisualEffect { color: string; age: number; duration: number; + /** Node radius for scaling the effect */ + nodeRadius?: number; particles?: ShatterParticle[]; } @@ -29,8 +31,8 @@ interface ShatterParticle { /** * Create a spawn effect at position. */ -export function createSpawnEffect(x: number, y: number, color: string): VisualEffect { - return { type: 'spawn', x, y, color, age: 0, duration: 0.8 }; +export function createSpawnEffect(x: number, y: number, color: string, nodeRadius?: number): VisualEffect { + return { type: 'spawn', x, y, color, age: 0, duration: 0.8, nodeRadius }; } /** @@ -76,7 +78,8 @@ export function drawEffects( function drawSpawnEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { const alpha = SPAWN_FX.maxAlpha * (1 - progress); - const ringR = SPAWN_FX.ringStart + SPAWN_FX.ringExpand * progress; + const baseR = fx.nodeRadius ?? SPAWN_FX.ringStart; + const ringR = baseR + SPAWN_FX.ringExpand * progress; ctx.save(); ctx.globalAlpha = alpha; diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index ab1f0614..9d970f9f 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -83,9 +83,11 @@ function drawTaskPill( ctx.translate(x, y); ctx.scale(scale, scale); - // Shadow — stronger for attention tasks - ctx.shadowColor = hexWithAlpha(statusColor, 0.25); - ctx.shadowBlur = needsAttention ? 12 : 4; + // Shadow — stronger for attention tasks, red for blocked + ctx.shadowColor = node.isBlocked + ? hexWithAlpha(COLORS.edgeBlocking, 0.3) + : hexWithAlpha(statusColor, 0.25); + ctx.shadowBlur = needsAttention || node.isBlocked ? 12 : 4; // Background fill ctx.beginPath(); @@ -98,13 +100,26 @@ function drawTaskPill( ctx.fill(); ctx.shadowBlur = 0; - // Border + // Border — red for blocked tasks ctx.beginPath(); ctx.roundRect(-halfW, -halfH, w, h, r); - ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5); - ctx.lineWidth = isSelected ? 2 : 1; + if (node.isBlocked) { + ctx.strokeStyle = hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7); + ctx.lineWidth = isSelected ? 2.5 : 1.8; + } else { + ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + } ctx.stroke(); + // Blocked indicator — red left stripe + if (node.isBlocked) { + ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6); + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]); + ctx.fill(); + } + // Review state overlay border — pulsing for review/needsFix, STATIC for approved if (reviewColor !== 'transparent') { ctx.beginPath(); @@ -203,6 +218,16 @@ export function drawColumnHeaders( ctx.strokeStyle = hexWithAlpha(header.color, 0.2); ctx.lineWidth = 0.5; ctx.stroke(); + + // Overflow badge: "+N more" + if (header.overflowCount > 0) { + const badgeText = `+${header.overflowCount} more`; + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(header.color, 0.45); + ctx.fillText(badgeText, header.x, header.overflowY + 4); + } } } } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index a8494fcb..94ecb8ba 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -18,7 +18,7 @@ import { type SimulationLinkDatum, } from 'd3-force'; import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types'; -import { FORCE, ANIM_SPEED } from '../constants/canvas-constants'; +import { FORCE, ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { getNodeStrategy } from '../strategies'; import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; import { getStateColor } from '../constants/colors'; @@ -200,7 +200,8 @@ export function useGraphSimulation(): UseGraphSimulationResult { // New node appeared → spawn effect (only if truly new, never seen before). // Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown. if (!allKnown.has(node.id) && node.x != null && node.y != null) { - state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + const nodeR = node.kind === 'lead' ? NODE.radiusLead : node.kind === 'member' ? NODE.radiusMember : undefined; + state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state), nodeR)); } // Task completed → shatter effect diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index d28885d8..94fd1daa 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -17,6 +17,10 @@ export interface KanbanColumnHeader { x: number; y: number; color: string; + /** Number of hidden overflow tasks in this column */ + overflowCount: number; + /** Y position for the overflow badge */ + overflowY: number; } /** Zone info per owner for rendering headers */ @@ -125,6 +129,8 @@ export class KanbanLayoutEngine { for (const [colIdx, col] of activeColumns.entries()) { const colX = baseX + colIdx * columnWidth; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; + const overflow = Math.max(0, col.tasks.length - maxVisibleRows); + const visibleCount = Math.min(col.tasks.length, maxVisibleRows); // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) headers.push({ @@ -132,6 +138,8 @@ export class KanbanLayoutEngine { x: colX, // pill center = task.x = colX y: baseY, color: config.color, + overflowCount: overflow, + overflowY: baseY + headerHeight + visibleCount * rowHeight, }); // Position tasks below header diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index ea7378f7..dafceec0 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -90,10 +90,22 @@ export interface GraphNode { reviewState?: 'none' | 'review' | 'needsFix' | 'approved'; /** Requires clarification indicator */ needsClarification?: 'lead' | 'user' | null; + /** Task is blocked by other tasks */ + isBlocked?: boolean; + /** Display IDs of tasks blocking this one */ + blockedByDisplayIds?: string[]; + /** Display IDs of tasks this one blocks */ + blocksDisplayIds?: string[]; // ─── Process-specific ────────────────────────────────────────────────── /** Clickable URL for process */ processUrl?: string; + /** Who registered the process */ + processRegisteredBy?: string; + /** Command used to start the process */ + processCommand?: string; + /** When the process was registered (ISO) */ + processRegisteredAt?: string; // ─── Force simulation (managed by the package internally) ────────────── x?: number; diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index cfd2553a..d832871d 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -201,9 +201,9 @@ export const GraphCanvas = forwardRef(funct } drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges); - // 2b. Particles (cap at 50 for performance) - const cappedParticles = state.particles.length > 50 - ? state.particles.slice(-50) + // 2b. Particles (cap at 100 for performance) + const cappedParticles = state.particles.length > 100 + ? state.particles.slice(-100) : state.particles; drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4ad82395..f86b2ff1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -6457,6 +6457,28 @@ export class TeamProvisioningService { ); } } + + // Also attempt control_response via stdin — the lead runtime MAY forward it + // to the teammate subprocess. This was broken before (missing updatedInput: {}) + // but is now fixed. Belt-and-suspenders: settings handle future calls, + // control_response may unblock the CURRENT waiting prompt. + if (allow && run.child?.stdin?.writable) { + const controlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow', updatedInput: {} }, + }, + }; + run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { + if (err) { + logger.warn( + `[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` + ); + } + }); + } } /** diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index d6a84fd9..e0b4598f 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -279,7 +279,7 @@ export class TeamGraphAdapter { : undefined, recentTools: (toolHistory?.[leadName] ?? []) .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) - .slice(0, 3) + .slice(0, 5) .map((tool) => ({ name: tool.toolName, preview: tool.preview, @@ -346,7 +346,7 @@ export class TeamGraphAdapter { : undefined, recentTools: (toolHistory?.[member.name] ?? []) .filter((tool) => tool.state !== 'running' && !!tool.finishedAt) - .slice(0, 3) + .slice(0, 5) .map((tool) => ({ name: tool.toolName, preview: tool.preview, @@ -369,11 +369,32 @@ export class TeamGraphAdapter { } #buildTaskNodes(nodes: GraphNode[], edges: GraphEdge[], data: TeamData, teamName: string): void { + // Build lookup tables for fast resolution + const completedTaskIds = new Set(); + const taskDisplayIds = new Map(); + for (const t of data.tasks) { + if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id); + taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); + } + for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + // Task is blocked if any blockedBy task is still not completed + const isBlocked = + (task.blockedBy?.length ?? 0) > 0 && + task.blockedBy!.some((id) => !completedTaskIds.has(id)); + + // Resolve display IDs for dependencies + const blockedByDisplayIds = task.blockedBy?.length + ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + : undefined; + const blocksDisplayIds = task.blocks?.length + ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) + : undefined; + nodes.push({ id: taskId, kind: 'task', @@ -385,6 +406,9 @@ export class TeamGraphAdapter { displayId: task.displayId ?? undefined, ownerId: ownerMemberId, needsClarification: task.needsClarification ?? null, + isBlocked, + blockedByDisplayIds, + blocksDisplayIds, domainRef: { kind: 'task', teamName, taskId: task.id }, }); @@ -453,6 +477,9 @@ export class TeamGraphAdapter { label: proc.label, state: 'active', processUrl: proc.url ?? undefined, + processRegisteredBy: proc.registeredBy ?? undefined, + processCommand: proc.command ?? undefined, + processRegisteredAt: proc.registeredAt, domainRef: { kind: 'process', teamName, processId: proc.id }, }); diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index e4c2db11..0dab486b 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -7,7 +7,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; -import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; +import { Ban, ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; import type { GraphNode } from '@claude-teams/agent-graph'; @@ -78,8 +78,23 @@ export const GraphNodePopover = ({ // Process return ( -
+
{node.label}
+ {node.processCommand && ( +
+ $ {node.processCommand} +
+ )} +
+ {node.processRegisteredBy && ( +
+ Started by: {node.processRegisteredBy} +
+ )} + {node.processRegisteredAt && ( +
At: {new Date(node.processRegisteredAt).toLocaleTimeString()}
+ )} +
{node.processUrl && (
- {node.recentTools.slice(0, 3).map((tool) => { + {node.recentTools.slice(0, 5).map((tool) => { const shortName = formatToolName(tool.name); const shortPreview = formatToolPreview(tool.preview); return ( @@ -368,6 +383,14 @@ const TaskPopoverContent = ({ {node.reviewState} )} + {node.isBlocked && ( + + blocked + + )} {node.needsClarification && ( + {/* Task dependencies */} + {node.blockedByDisplayIds && node.blockedByDisplayIds.length > 0 && ( +
+ Blocked by: {node.blockedByDisplayIds.join(', ')} +
+ )} + {node.blocksDisplayIds && node.blocksDisplayIds.length > 0 && ( +
+ Blocks: {node.blocksDisplayIds.join(', ')} +
+ )} +
); }; - -// ─── Task Popover ─────────────────────────────────────────────────────────── - -const TaskPopoverContent = ({ - node, - onClose, - onOpenDetail, -}: { - node: GraphNode; - onClose: () => void; - onOpenDetail?: (taskId: string) => void; -}): React.JSX.Element => { - const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; - - const statusColor = - node.taskStatus === 'in_progress' - ? 'text-blue-400 border-blue-500/30' - : node.taskStatus === 'completed' - ? 'text-emerald-400 border-emerald-500/30' - : 'text-zinc-400 border-zinc-500/30'; - - const reviewColor = - node.reviewState === 'review' - ? 'text-amber-400 border-amber-500/30' - : node.reviewState === 'needsFix' - ? 'text-red-400 border-red-500/30' - : node.reviewState === 'approved' - ? 'text-emerald-400 border-emerald-500/30' - : ''; - - return ( -
-
- {node.displayId ?? node.label} -
- {node.sublabel && ( -
- {node.sublabel} -
- )} - -
- - {node.taskStatus ?? 'pending'} - - {node.reviewState && node.reviewState !== 'none' && ( - - {node.reviewState} - - )} - {node.isBlocked && ( - - blocked - - )} - {node.needsClarification && ( - - needs clarification - - )} -
- - {/* Task dependencies */} - {node.blockedByDisplayIds && node.blockedByDisplayIds.length > 0 && ( -
- Blocked by: {node.blockedByDisplayIds.join(', ')} -
- )} - {node.blocksDisplayIds && node.blocksDisplayIds.length > 0 && ( -
- Blocks: {node.blocksDisplayIds.join(', ')} -
- )} - -
- -
-
- ); -}; diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx new file mode 100644 index 00000000..3a2f7a4b --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx @@ -0,0 +1,150 @@ +/** + * GraphTaskCard — wraps the REAL KanbanTaskCard with graph-specific glow/pulse effects. + * Lives in features/ so it CAN import from @renderer/. + */ + +import { useMemo } from 'react'; + +import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; +import { useStore } from '@renderer/store'; + +import type { GraphNode } from '@claude-teams/agent-graph'; +import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface GraphTaskCardProps { + node: GraphNode; + teamName: string; + onClose: () => void; + onOpenDetail?: (taskId: string) => void; + onStartTask?: (taskId: string) => void; + onCompleteTask?: (taskId: string) => void; + onApproveTask?: (taskId: string) => void; + onRequestReview?: (taskId: string) => void; + onRequestChanges?: (taskId: string) => void; + onCancelTask?: (taskId: string) => void; + onMoveBackToDone?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function resolveColumn(task: TeamTask): KanbanColumnId { + if (task.reviewState === 'approved') return 'approved'; + if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; + if (task.status === 'in_progress') return 'in_progress'; + if (task.status === 'completed') return 'done'; + return 'todo'; +} + +function getGlowStyle(task: TeamTask): React.CSSProperties { + const col = resolveColumn(task); + const blocked = (task.blockedBy?.length ?? 0) > 0; + if (blocked) { + return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }; + } + switch (col) { + case 'in_progress': + return { + boxShadow: '0 0 14px rgba(59, 130, 246, 0.4), inset 0 0 6px rgba(59, 130, 246, 0.08)', + }; + case 'review': + return task.reviewState === 'needsFix' + ? { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' } + : { boxShadow: '0 0 14px rgba(245, 158, 11, 0.4), inset 0 0 6px rgba(245, 158, 11, 0.08)' }; + case 'approved': + return { boxShadow: '0 0 10px rgba(34, 197, 94, 0.3)' }; + default: + return {}; + } +} + +function getPulseClass(task: TeamTask): string { + const col = resolveColumn(task); + if (col === 'in_progress' || col === 'review') return 'animate-pulse'; + return ''; +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +export const GraphTaskCard = ({ + node, + teamName, + onClose, + onOpenDetail, + onStartTask, + onCompleteTask, + onApproveTask, + onRequestReview, + onRequestChanges, + onCancelTask, + onMoveBackToDone, + onDeleteTask, +}: GraphTaskCardProps): React.JSX.Element => { + const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; + + const task = useStore((s) => s.selectedTeamData?.tasks.find((t) => t.id === taskId)); + const tasks = useStore((s) => s.selectedTeamData?.tasks ?? []); + const members = useStore((s) => s.selectedTeamData?.members ?? []); + + const taskMap = useMemo(() => { + const map = new Map(); + for (const t of tasks) map.set(t.id, t); + return map; + }, [tasks]); + + const memberColorMap = useMemo(() => { + const map = new Map(); + for (const m of members) { + if (m.color) map.set(m.name, m.color); + } + return map; + }, [members]); + + if (!task) { + return ( +
+
+ {node.displayId ?? node.label} +
+
+ ); + } + + const columnId = resolveColumn(task); + const taskWithKanban = task as TeamTaskWithKanban; + + const closeAct = (fn?: (id: string) => void) => (taskId: string) => { + fn?.(taskId); + onClose(); + }; + + return ( +
+ { + onOpenDetail?.(taskId); + onClose(); + }} + onStartTask={closeAct(onStartTask)} + onCompleteTask={closeAct(onCompleteTask)} + onApprove={closeAct(onApproveTask)} + onRequestReview={closeAct(onRequestReview)} + onRequestChanges={closeAct(onRequestChanges)} + onCancelTask={closeAct(onCancelTask)} + onMoveBackToDone={closeAct(onMoveBackToDone)} + onDeleteTask={onDeleteTask ? closeAct(onDeleteTask) : undefined} + /> +
+ ); +}; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 7f4e6d30..84fe1f8b 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -3,7 +3,7 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; @@ -33,6 +33,26 @@ export const TeamGraphOverlay = ({ }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); + // Task action dispatchers (same pattern as TeamGraphTab) + const dispatchTaskAction = useCallback( + (action: string) => (taskId: string) => + window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })), + [teamName] + ); + const taskActions = useMemo( + () => ({ + onStartTask: dispatchTaskAction('start-task'), + onCompleteTask: dispatchTaskAction('complete-task'), + onApproveTask: dispatchTaskAction('approve-task'), + onRequestReview: dispatchTaskAction('request-review'), + onRequestChanges: dispatchTaskAction('request-changes'), + onCancelTask: dispatchTaskAction('cancel-task'), + onMoveBackToDone: dispatchTaskAction('move-back-to-done'), + onDeleteTask: dispatchTaskAction('delete-task'), + }), + [dispatchTaskAction] + ); + const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { @@ -67,6 +87,7 @@ export const TeamGraphOverlay = ({ renderOverlay={({ node, onClose: closePopover }) => ( { onSendMessage?.(name); @@ -80,6 +101,7 @@ export const TeamGraphOverlay = ({ onOpenMemberProfile?.(name); closePopover(); }} + {...taskActions} /> )} /> diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 4580c21f..2f3c5370 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -3,7 +3,7 @@ * Provides Fullscreen button that opens the overlay. */ -import { lazy, Suspense, useCallback, useState } from 'react'; +import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; @@ -58,6 +58,36 @@ export const TeamGraphTab = ({ [teamName] ); + // Task action dispatchers + const dispatchTaskAction = useCallback( + (action: string) => (taskId: string) => + window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })), + [teamName] + ); + const dispatchStartTask = useMemo(() => dispatchTaskAction('start-task'), [dispatchTaskAction]); + const dispatchCompleteTask = useMemo( + () => dispatchTaskAction('complete-task'), + [dispatchTaskAction] + ); + const dispatchApproveTask = useMemo( + () => dispatchTaskAction('approve-task'), + [dispatchTaskAction] + ); + const dispatchRequestReview = useMemo( + () => dispatchTaskAction('request-review'), + [dispatchTaskAction] + ); + const dispatchRequestChanges = useMemo( + () => dispatchTaskAction('request-changes'), + [dispatchTaskAction] + ); + const dispatchCancelTask = useMemo(() => dispatchTaskAction('cancel-task'), [dispatchTaskAction]); + const dispatchMoveBackToDone = useMemo( + () => dispatchTaskAction('move-back-to-done'), + [dispatchTaskAction] + ); + const dispatchDeleteTask = useMemo(() => dispatchTaskAction('delete-task'), [dispatchTaskAction]); + const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { @@ -89,11 +119,20 @@ export const TeamGraphTab = ({ renderOverlay={({ node, onClose }) => ( )} /> diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index f6555d2a..c4e1952d 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -484,6 +484,56 @@ function collectTaskChangeInvalidationState( }; } +function preserveKnownTaskChangePresence( + teamName: string, + prevTasks: TeamData['tasks'] | null | undefined, + nextTasks: TeamData['tasks'] +): TeamData['tasks'] { + if (!Array.isArray(prevTasks) || prevTasks.length === 0 || nextTasks.length === 0) { + return nextTasks; + } + + const prevTaskById = new Map(prevTasks.map((task) => [task.id, task])); + let changed = false; + + const mergedTasks = nextTasks.map((task) => { + if (task.changePresence && task.changePresence !== 'unknown') { + return task; + } + + const previousTask = prevTaskById.get(task.id); + if ( + !previousTask || + !previousTask.changePresence || + previousTask.changePresence === 'unknown' + ) { + return task; + } + + const previousKey = buildTaskChangePresenceKey( + teamName, + previousTask.id, + buildTaskChangeRequestOptions(previousTask) + ); + const nextKey = buildTaskChangePresenceKey( + teamName, + task.id, + buildTaskChangeRequestOptions(task) + ); + if (previousKey !== nextKey) { + return task; + } + + changed = true; + return { + ...task, + changePresence: previousTask.changePresence, + }; + }); + + return changed ? mergedTasks : nextTasks; +} + function mapSendMessageError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; @@ -1333,7 +1383,12 @@ export const createTeamSlice: StateCreator = (set, set({ selectedTeamName: teamName, - selectedTeamData: data, + selectedTeamData: previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data, selectedTeamLoading: false, selectedTeamError: null, }); @@ -1454,7 +1509,12 @@ export const createTeamSlice: StateCreator = (set, return; } set({ - selectedTeamData: data, + selectedTeamData: previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data, selectedTeamError: null, }); const invalidationState = previousData diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index b58f5639..e2bea511 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -393,6 +393,62 @@ describe('teamSlice actions', () => { expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1); expect(warmTaskChangeSummaries).not.toHaveBeenCalled(); }); + + it('preserves known task changePresence across refresh when task change signature is unchanged', async () => { + const store = createSliceStore(); + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [ + { + id: 'task-1', + subject: 'Known changes', + status: 'in_progress', + owner: 'alice', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + comments: [], + attachments: [], + changePresence: 'has_changes', + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }, + }); + + hoisted.getData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [ + { + id: 'task-1', + subject: 'Known changes', + status: 'in_progress', + owner: 'alice', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + comments: [], + attachments: [], + changePresence: 'unknown', + }, + ], + members: [], + messages: [{ from: 'team-lead', text: 'Ping', timestamp: '2026-03-01T10:10:00.000Z' }], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }); + + await store.getState().refreshTeamData('my-team'); + + expect(store.getState().selectedTeamData?.tasks[0]?.changePresence).toBe('has_changes'); + }); }); describe('provisioning run scoping', () => { From 66216603763cb2363359d86abb1194e999adcf34 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 31 Mar 2026 01:48:15 +0300 Subject: [PATCH 113/113] feat(graph): add cross-team ghost nodes and task card improvements - Cross-team messages now show ghost nodes (dashed hexagons) for external teams - Ghost nodes have purple color, link icon, and connect to lead via message edge - Particles flow between ghost node and lead with cross-team message labels - Cross-team popover shows external team name - Task click opens full KanbanTaskCard with glow effects and action buttons - All kanban task actions wired through CustomEvent to TeamDetailView --- .../agent-graph/src/canvas/draw-agents.ts | 63 ++++++++++ .../agent-graph/src/canvas/hit-detection.ts | 5 +- .../src/constants/canvas-constants.ts | 2 + packages/agent-graph/src/ports/types.ts | 5 +- packages/agent-graph/src/strategies/index.ts | 1 + packages/agent-graph/src/ui/GraphCanvas.tsx | 3 +- .../components/team/TeamDetailView.tsx | 7 +- .../agent-graph/adapters/TeamGraphAdapter.ts | 114 +++++++++++++++++- .../agent-graph/ui/GraphNodePopover.tsx | 15 +++ 9 files changed, 208 insertions(+), 7 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index e30b5c17..44bdd28d 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -106,6 +106,69 @@ export function drawAgents( } } +/** + * Draw cross-team ghost nodes — semi-transparent dashed hexagons. + */ +export function drawCrossTeamNodes( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'crossteam') continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusCrossTeam; + const color = node.color ?? '#cc88ff'; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = isHovered ? 0.7 : 0.5; + + // Subtle glow + const glowR = r + AGENT_DRAW.glowPadding; + const sprite = getAgentGlowSprite(color, r, glowR); + ctx.drawImage(sprite, x - glowR, y - glowR); + + // Dashed hexagon body + drawHexagon(ctx, x, y, r); + ctx.fillStyle = 'rgba(10, 15, 40, 0.4)'; + ctx.fill(); + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = hexWithAlpha(color, 0.6); + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.setLineDash([]); + + // Link icon (two arrows ↔) in center + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = hexWithAlpha(color, 0.8); + ctx.fillText('\u{2194}', x, y); // ↔ + + // Label below + ctx.globalAlpha = 0.7; + ctx.font = '8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexWithAlpha(color, 0.7); + ctx.fillText(node.label, x, y + r + 6); + + // Selection ring + if (isSelected) { + drawSelectionRing(ctx, x, y, r, color); + } + + ctx.restore(); + } +} + // ─── Private Helpers ──────────────────────────────────────────────────────── function getNodeOpacity(node: GraphNode): number { diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts index 3895b9fd..8e77998e 100644 --- a/packages/agent-graph/src/canvas/hit-detection.ts +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -49,8 +49,9 @@ export function findNodeAt( } break; } - case 'process': { - const r = NODE.radiusProcess + HIT_DETECTION.agentPadding; + case 'process': + case 'crossteam': { + const r = (node.kind === 'crossteam' ? NODE.radiusCrossTeam : NODE.radiusProcess) + HIT_DETECTION.agentPadding; const dx = worldX - x; const dy = worldY - y; if (dx * dx + dy * dy <= r * r) { diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 1228f389..54c7129a 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -61,6 +61,8 @@ export const NODE = { radiusMember: 24, /** Process node radius */ radiusProcess: 14, + /** Cross-team ghost node radius */ + radiusCrossTeam: 20, } as const; // ─── Task pill dimensions ─────────────────────────────────────────────────── diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 67b1c9a3..8bcc43f0 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -5,7 +5,7 @@ // ─── Node Kinds ────────────────────────────────────────────────────────────── -export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process'; +export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process' | 'crossteam'; export type GraphNodeState = | 'idle' @@ -161,4 +161,5 @@ export type GraphDomainRef = | { kind: 'lead'; teamName: string; memberName: string } | { kind: 'member'; teamName: string; memberName: string } | { kind: 'task'; teamName: string; taskId: string } - | { kind: 'process'; teamName: string; processId: string }; + | { kind: 'process'; teamName: string; processId: string } + | { kind: 'crossteam'; teamName: string; externalTeamName: string }; diff --git a/packages/agent-graph/src/strategies/index.ts b/packages/agent-graph/src/strategies/index.ts index 8f0fc9f0..ea7d4e9c 100644 --- a/packages/agent-graph/src/strategies/index.ts +++ b/packages/agent-graph/src/strategies/index.ts @@ -14,6 +14,7 @@ const STRATEGIES: Record = { member: new MemberStrategy(), task: new TaskStrategy(), process: new ProcessStrategy(), + crossteam: new ProcessStrategy(), // Reuse process strategy (similar small node) }; export function getNodeStrategy(kind: GraphNodeKind): NodeRenderStrategy { diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index d832871d..3548c49b 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -11,7 +11,7 @@ import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer'; import { drawEdges } from '../canvas/draw-edges'; import { drawParticles } from '../canvas/draw-particles'; -import { drawAgents } from '../canvas/draw-agents'; +import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; @@ -209,6 +209,7 @@ export const GraphCanvas = forwardRef(funct // 2c. Visible nodes only (back to front: process → task → member/lead) drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawColumnHeaders(ctx, KanbanLayoutEngine.zones); drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 59087c61..da7dec5a 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1584,7 +1584,12 @@ export const TeamDetailView = ({ }); }} > - + + + + NEW + + Graph