From 4869bb35dae5f0d8852cd0e1ad5d73934752d62a Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 11 Apr 2026 21:57:59 +0300 Subject: [PATCH] feat(agent-teams): harden dev bootstrap and task tooling --- mcp-server/src/tools/taskTools.ts | 56 +- mcp-server/test/tools.test.ts | 66 ++- package.json | 1 - scripts/dev-with-runtime.mjs | 519 ++++++++++++++++-- .../infrastructure/CliInstallerService.ts | 41 +- .../services/team/TeamProvisioningService.ts | 41 +- .../team/ProvisioningProgressBlock.tsx | 7 +- .../team/TeamProvisioningBanner.tsx | 3 + src/shared/types/cliInstaller.ts | 2 +- .../CliInstallerService.test.ts | 42 +- .../team/TeamProvisioningServiceRelay.test.ts | 52 +- 11 files changed, 758 insertions(+), 72 deletions(-) diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 3e91d6d3..a25450aa 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -1,4 +1,6 @@ import type { FastMCP } from 'fastmcp'; +import fs from 'node:fs'; +import path from 'node:path'; import { z } from 'zod'; import { agentBlocks, getController } from '../controller'; @@ -56,6 +58,42 @@ function buildCreateTaskPayload(params: { }; } +function resolveConfigPath(teamName: string, claudeDir?: string): string { + const controller = getController(teamName, claudeDir) as { + context?: { paths?: { teamDir?: string } }; + }; + const teamDir = controller.context?.paths?.teamDir; + if (typeof teamDir !== 'string' || teamDir.trim().length === 0) { + throw new Error( + `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.` + ); + } + return path.join(teamDir, 'config.json'); +} + +function assertConfiguredTeam(teamName: string, claudeDir?: string): void { + const configPath = resolveConfigPath(teamName, claudeDir); + let raw = ''; + try { + raw = fs.readFileSync(configPath, 'utf8'); + } catch { + throw new Error( + `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.` + ); + } + + try { + const parsed = JSON.parse(raw) as { name?: unknown }; + if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) { + throw new Error('invalid'); + } + } catch { + throw new Error( + `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.` + ); + } +} + export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_create', @@ -85,6 +123,7 @@ export function registerTaskTools(server: Pick) { prompt, startImmediately, }) => { + assertConfiguredTeam(teamName, claudeDir); const controller = getController(teamName, claudeDir); return await Promise.resolve( jsonTextContent( @@ -143,23 +182,34 @@ export function registerTaskTools(server: Pick) { prompt, startImmediately, }) => { + assertConfiguredTeam(teamName, claudeDir); const controller = getController(teamName, claudeDir); // 1. Lookup message by exact messageId - const { message } = controller.messages.lookupMessage(messageId); + let message: Record; + try { + ({ message } = controller.messages.lookupMessage(messageId)); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Message not found:')) { + throw new Error( + `${error.message}. task_create_from_message only works with the explicit User MessageId shown in the relay prompt for a user_sent message. Do not use teammate inbox ids or guessed ids.` + ); + } + throw error; + } // 2. Reject if message source is not user-originated const source = typeof message.source === 'string' ? message.source : ''; if (!USER_ORIGINATED_SOURCES.has(source)) { throw new Error( - `Message source "${source}" is not user-originated. Only user_sent messages are eligible.` + `Message source "${source}" is not user-originated. task_create_from_message only accepts explicit user_sent messages from the relay prompt. For teammate, system, or cross-team messages, use task_create instead.` ); } // 3. Reject relay copies explicitly if (typeof message.relayOfMessageId === 'string' && message.relayOfMessageId.trim()) { throw new Error( - 'Cannot create task from a relay copy. Use the original message instead.' + 'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.' ); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index d014c7c3..2a27ba3a 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -665,6 +665,13 @@ describe('agent-teams-mcp tools', () => { it('covers review_request_changes and full process lifecycle tools', async () => { const claudeDir = makeClaudeDir(); const teamName = 'beta'; + writeTeamConfig(claudeDir, teamName, { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'reviewer' }, + { name: 'bob', role: 'developer' }, + ], + }); const createdTask = parseJsonToolResult( await getTool('task_create').execute({ @@ -861,6 +868,12 @@ describe('agent-teams-mcp tools', () => { it('task_add_comment succeeds even when owner inbox write fails', async () => { const claudeDir = makeClaudeDir(); const teamName = 'resilience'; + writeTeamConfig(claudeDir, teamName, { + members: [ + { name: 'lead', role: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }); const task = parseJsonToolResult( await getTool('task_create').execute({ @@ -1109,7 +1122,9 @@ describe('agent-teams-mcp tools', () => { messageId: 'nonexistent-msg', subject: 'Should fail', }) - ).rejects.toThrow('Message not found: nonexistent-msg'); + ).rejects.toThrow( + 'Message not found: nonexistent-msg. task_create_from_message only works with the explicit User MessageId' + ); }); it('rejects non-user-originated message sources', async () => { @@ -1136,7 +1151,9 @@ describe('agent-teams-mcp tools', () => { messageId, subject: 'Should fail', }) - ).rejects.toThrow('not user-originated'); + ).rejects.toThrow( + 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' + ); }); it('rejects lead_process and cross_team sources explicitly', async () => { @@ -1170,7 +1187,9 @@ describe('agent-teams-mcp tools', () => { messageId: 'msg-lead-001', subject: 'Should fail', }) - ).rejects.toThrow('not user-originated'); + ).rejects.toThrow( + 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' + ); await expect( getTool('task_create_from_message').execute({ @@ -1179,7 +1198,9 @@ describe('agent-teams-mcp tools', () => { messageId: 'msg-cross-001', subject: 'Should fail', }) - ).rejects.toThrow('not user-originated'); + ).rejects.toThrow( + 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' + ); }); it('rejects messages without an explicit source field (fail closed)', async () => { @@ -1205,7 +1226,9 @@ describe('agent-teams-mcp tools', () => { messageId: 'msg-no-source', subject: 'Should fail', }) - ).rejects.toThrow('not user-originated'); + ).rejects.toThrow( + 'task_create_from_message only accepts explicit user_sent messages from the relay prompt' + ); }); it('rejects relay copies', async () => { @@ -1233,7 +1256,9 @@ describe('agent-teams-mcp tools', () => { messageId, subject: 'Should fail', }) - ).rejects.toThrow('relay copy'); + ).rejects.toThrow( + 'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.' + ); }); it('preserves attachment metadata without blob copying', async () => { @@ -1430,4 +1455,33 @@ describe('agent-teams-mcp tools', () => { ).toBe(true); }); }); + + it('fails closed for task_create when team config does not exist', async () => { + const claudeDir = makeClaudeDir(); + + await expect( + getTool('task_create').execute({ + claudeDir, + teamName: 'team-lead', + subject: 'Phantom task should fail', + }) + ).rejects.toThrow( + 'Unknown team "team-lead". Board tools require an existing configured team with config.json.' + ); + }); + + it('fails closed for task_create_from_message when team config does not exist', async () => { + const claudeDir = makeClaudeDir(); + + await expect( + getTool('task_create_from_message').execute({ + claudeDir, + teamName: 'team-lead', + messageId: 'msg-1', + subject: 'Phantom task should fail', + }) + ).rejects.toThrow( + 'Unknown team "team-lead". Board tools require an existing configured team with config.json.' + ); + }); }); diff --git a/package.json b/package.json index 3ef9eea4..e5defa4b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "main": "dist-electron/main/index.cjs", "scripts": { "dev": "node ./scripts/dev-with-runtime.mjs", - "dev:ui": "electron-vite dev", "dev:kill": "node bin/kill-dev.js", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "build": "electron-vite build", diff --git a/scripts/dev-with-runtime.mjs b/scripts/dev-with-runtime.mjs index 57dc5f08..bf2cf46b 100644 --- a/scripts/dev-with-runtime.mjs +++ b/scripts/dev-with-runtime.mjs @@ -1,10 +1,25 @@ #!/usr/bin/env node import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import process from 'node:process'; import { spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import readline from 'node:readline'; import { fileURLToPath } from 'node:url'; +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const uiRepoRoot = path.resolve(scriptDir, '..'); +const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim() ?? ''; +const explicitRuntimeCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ?? ''; +const runtimeLockPath = path.join(uiRepoRoot, 'runtime.lock.json'); +const defaultRuntimeCacheRoot = path.join(os.homedir(), '.agent-teams', 'runtime-cache'); +const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim() + ? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim()) + : defaultRuntimeCacheRoot; +const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path'); + function runOrExit(cmd, args, options = {}) { const result = spawnSync(cmd, args, { stdio: 'inherit', @@ -21,6 +36,27 @@ function runOrExit(cmd, args, options = {}) { } } +function runAndCapture(cmd, args, options = {}) { + const result = spawnSync(cmd, args, { + encoding: 'utf8', + ...options, + }); + + if (result.error) { + throw new Error(`Failed to run ${cmd}: ${result.error.message}`); + } + + if (result.status !== 0) { + const details = [result.stdout, result.stderr] + .map((value) => value?.trim()) + .filter(Boolean) + .join('\n'); + throw new Error(`Command failed: ${cmd} ${args.join(' ')}${details ? `\n${details}` : ''}`); + } + + return result.stdout?.trim() ?? ''; +} + function readPackageManagerCommand(repoRoot) { const packageJsonPath = path.join(repoRoot, 'package.json'); const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8'); @@ -39,53 +75,442 @@ function readPackageManagerCommand(repoRoot) { return packageManagerName; } -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const uiRepoRoot = path.resolve(scriptDir, '..'); -// Keep the dev runtime target explicit. This workspace can contain multiple -// sibling repos with the same package name, so auto-discovery is ambiguous and -// can silently point the UI at the wrong runtime after branch switches. -const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim(); +function readRuntimeLock() { + return JSON.parse(fs.readFileSync(runtimeLockPath, 'utf8')); +} -if (!runtimeRepoRoot) { - console.error( - 'CLAUDE_DEV_RUNTIME_ROOT is required for pnpm dev. ' + - 'Point it at the runtime repo root you want the UI to use in dev.' - ); +function getPlatformAssetKey() { + const platformKey = `${process.platform}-${process.arch}`; + + switch (platformKey) { + case 'darwin-arm64': + case 'darwin-x64': + case 'linux-x64': + case 'win32-x64': + return platformKey; + default: + throw new Error( + `Dev runtime bootstrap does not support this platform yet: ${process.platform}/${process.arch}` + ); + } +} + +function getReleaseAssetUrl(runtimeLock, asset) { + return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${runtimeLock.sourceRef}/${encodeURIComponent(asset.file)}`; +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes < 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +function truncateMiddle(value, maxLength) { + if (value.length <= maxLength) { + return value; + } + + if (maxLength <= 3) { + return value.slice(0, maxLength); + } + + const visibleChars = maxLength - 3; + const headLength = Math.ceil(visibleChars / 2); + const tailLength = Math.floor(visibleChars / 2); + return `${value.slice(0, headLength)}...${value.slice(value.length - tailLength)}`; +} + +function buildProgressBar(progressRatio, width) { + const safeWidth = Math.max(10, width); + const clampedRatio = Number.isFinite(progressRatio) + ? Math.min(1, Math.max(0, progressRatio)) + : 0; + const filledWidth = Math.round(safeWidth * clampedRatio); + return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`; +} + +function supportsProgressRedraw() { + return Boolean(process.stdout.isTTY && process.env.TERM && process.env.TERM !== 'dumb'); +} + +function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) { + const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100; + const ratio = hasTotal ? writtenBytes / totalBytes : 0; + const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : ''; + const bytesText = hasTotal + ? `${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}` + : `${formatBytes(writtenBytes)}`; + const barWidth = hasTotal ? Math.min(24, Math.max(10, Math.floor(columns * 0.18))) : 0; + const barText = hasTotal ? ` [${buildProgressBar(ratio, barWidth)}]` : ''; + const fixedParts = `${barText} ${bytesText}${percentText}`.trimStart(); + const availableLabelWidth = Math.max(16, columns - fixedParts.length - 1); + const labelText = truncateMiddle(label, availableLabelWidth); + + return `${labelText}${fixedParts ? ` ${fixedParts}` : ''}`; +} + +function formatProgressSummary(writtenBytes, totalBytes, hasTotal) { + if (hasTotal) { + const ratio = writtenBytes / totalBytes; + return `Runtime download ${Math.floor(ratio * 100)}% - ${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`; + } + + return `Runtime download - ${formatBytes(writtenBytes)}`; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function readBinaryVersion(binaryPath) { + return runAndCapture(binaryPath, ['--version']); +} + +function isExecutable(filePath) { + if (!fs.existsSync(filePath)) { + return false; + } + + if (process.platform === 'win32') { + return fs.statSync(filePath).isFile(); + } + + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function isCachedBinaryValid(binaryPath, expectedVersion) { + if (!isExecutable(binaryPath)) { + return false; + } + + try { + return readBinaryVersion(binaryPath).includes(expectedVersion); + } catch { + return false; + } +} + +async function downloadWithProgress(url, destinationPath) { + const response = await fetch(url, { + headers: { + 'user-agent': 'claude-team-dev-runtime-bootstrap', + }, + redirect: 'follow', + }); + + if (!response.ok || !response.body) { + throw new Error(`Failed to download runtime asset: ${response.status} ${response.statusText}`); + } + + const totalBytes = Number.parseInt(response.headers.get('content-length') ?? '', 10); + const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0; + const writer = fs.createWriteStream(destinationPath); + const reader = response.body.getReader(); + let writtenBytes = 0; + let lastPrintedAt = 0; + let lastLoggedPercent = -1; + let lastLoggedBytes = 0; + const label = `Downloading runtime ${path.basename(destinationPath)}`; + const canRedraw = supportsProgressRedraw(); + + if (canRedraw) { + process.stdout.write(formatProgressLine(label, 0, totalBytes, hasTotal)); + } else { + process.stdout.write(`${label}\n`); + } + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (!writer.write(Buffer.from(value))) { + await once(writer, 'drain'); + } + writtenBytes += value.byteLength; + + const now = Date.now(); + if (canRedraw && (now - lastPrintedAt >= 150 || writtenBytes === totalBytes)) { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(formatProgressLine(label, writtenBytes, totalBytes, hasTotal)); + lastPrintedAt = now; + } else if (!canRedraw) { + const nextPercent = hasTotal ? Math.floor((writtenBytes / totalBytes) * 100) : null; + const shouldLogPercent = + nextPercent !== null && (nextPercent === 100 || nextPercent >= lastLoggedPercent + 5); + const shouldLogBytes = + nextPercent === null && writtenBytes >= lastLoggedBytes + 5 * 1024 * 1024; + + if (shouldLogPercent || shouldLogBytes) { + process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`); + if (nextPercent !== null) { + lastLoggedPercent = nextPercent; + } else { + lastLoggedBytes = writtenBytes; + } + } + } + } + } finally { + await new Promise((resolve, reject) => { + writer.end((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + + if (canRedraw) { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`); + } else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) { + process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`); + } +} + +function extractArchive(archivePath, extractDir, archiveKind) { + ensureDir(extractDir); + + if (archiveKind === 'tar.gz') { + runOrExit('tar', ['-xzf', archivePath, '-C', extractDir]); + return; + } + + if (archiveKind === 'zip') { + if (process.platform === 'win32') { + runOrExit('powershell', [ + '-NoProfile', + '-Command', + `Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}' -Force`, + ]); + return; + } + + runOrExit('unzip', ['-oq', archivePath, '-d', extractDir]); + return; + } + + throw new Error(`Unsupported runtime archive kind: ${archiveKind}`); +} + +function findExtractedBinary(extractDir, binaryName) { + const directCandidate = path.join(extractDir, 'runtime', binaryName); + if (fs.existsSync(directCandidate)) { + return directCandidate; + } + + const fallbackCandidate = path.join(extractDir, binaryName); + if (fs.existsSync(fallbackCandidate)) { + return fallbackCandidate; + } + + throw new Error(`Extracted runtime archive does not contain ${binaryName}`); +} + +async function acquireBootstrapLock(lockPath) { + const waitDeadline = Date.now() + 120_000; + let announcedWait = false; + + while (true) { + try { + return await fs.promises.open(lockPath, 'wx'); + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + + if (!announcedWait) { + process.stdout.write('Waiting for another runtime bootstrap to finish...\n'); + announcedWait = true; + } + + if (Date.now() >= waitDeadline) { + throw new Error(`Timed out waiting for runtime bootstrap lock: ${lockPath}`); + } + + await sleep(750); + } + } +} + +async function ensureBootstrappedRuntime() { + const runtimeLock = readRuntimeLock(); + const platformKey = getPlatformAssetKey(); + const asset = runtimeLock.assets[platformKey]; + if (!asset) { + throw new Error(`No runtime asset configured for ${platformKey}`); + } + + const cacheDir = path.join(runtimeCacheRoot, runtimeLock.version, platformKey); + const cachedBinaryPath = path.join(cacheDir, asset.binaryName); + + if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) { + return { + binaryPath: cachedBinaryPath, + versionText: readBinaryVersion(cachedBinaryPath), + sourceLabel: `cached release ${runtimeLock.sourceRef}`, + cacheDir, + downloaded: false, + }; + } + + ensureDir(cacheDir); + const lockHandle = await acquireBootstrapLock(path.join(cacheDir, '.bootstrap.lock')); + + try { + if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) { + return { + binaryPath: cachedBinaryPath, + versionText: readBinaryVersion(cachedBinaryPath), + sourceLabel: `cached release ${runtimeLock.sourceRef}`, + cacheDir, + downloaded: false, + }; + } + + const workDir = path.join(cacheDir, `.bootstrap-${process.pid}-${Date.now()}`); + ensureDir(workDir); + + try { + const archivePath = path.join(workDir, asset.file); + await downloadWithProgress(getReleaseAssetUrl(runtimeLock, asset), archivePath); + + const extractDir = path.join(workDir, 'extracted'); + extractArchive(archivePath, extractDir, asset.archiveKind); + + const extractedBinaryPath = findExtractedBinary(extractDir, asset.binaryName); + const nextBinaryPath = `${cachedBinaryPath}.tmp`; + await fs.promises.copyFile(extractedBinaryPath, nextBinaryPath); + + try { + if (process.platform !== 'win32') { + await fs.promises.chmod(nextBinaryPath, 0o755); + } + + await fs.promises.rm(cachedBinaryPath, { force: true }); + await fs.promises.rename(nextBinaryPath, cachedBinaryPath); + + const versionText = readBinaryVersion(cachedBinaryPath); + if (!versionText.includes(runtimeLock.version)) { + await fs.promises.rm(cachedBinaryPath, { force: true }); + throw new Error( + `Bootstrapped runtime version mismatch. Expected ${runtimeLock.version}, got: ${versionText}` + ); + } + + return { + binaryPath: cachedBinaryPath, + versionText, + sourceLabel: `downloaded release ${runtimeLock.sourceRef}`, + cacheDir, + downloaded: true, + }; + } finally { + await fs.promises.rm(nextBinaryPath, { force: true }); + } + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + } finally { + await lockHandle.close(); + await fs.promises.rm(path.join(cacheDir, '.bootstrap.lock'), { force: true }); + } +} + +function validateRuntimeRepoRoot(repoRoot) { + const runtimePackageJsonPath = path.join(repoRoot, 'package.json'); + if (!fs.existsSync(runtimePackageJsonPath)) { + console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${repoRoot}`); + process.exit(1); + } +} + +async function resolveRuntimeCli() { + if (explicitRuntimeCliPath) { + if (!isExecutable(explicitRuntimeCliPath)) { + throw new Error( + `CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH is not executable: ${explicitRuntimeCliPath}` + ); + } + + return { + binaryPath: explicitRuntimeCliPath, + versionText: readBinaryVersion(explicitRuntimeCliPath), + sourceLabel: `explicit runtime override ${explicitRuntimeCliPath}`, + }; + } + + if (runtimeRepoRoot) { + validateRuntimeRepoRoot(runtimeRepoRoot); + const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot); + + runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot }); + + const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev'); + return { + binaryPath: runtimeCliPath, + versionText: readBinaryVersion(runtimeCliPath), + sourceLabel: `local runtime repo ${runtimeRepoRoot}`, + }; + } + + return ensureBootstrappedRuntime(); +} + +async function main() { + const resolvedRuntime = await resolveRuntimeCli(); + + if (shouldPrintRuntimePath) { + process.stdout.write(`${resolvedRuntime.binaryPath}\n`); + return; + } + + process.stdout.write(`Using runtime from ${resolvedRuntime.sourceLabel}\n`); + if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) { + process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`); + } + process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`); + + const uiEnv = { + ...process.env, + CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath, + }; + delete uiEnv.CLAUDE_CLI_PATH; + + runOrExit('pnpm', ['exec', 'electron-vite', 'dev'], { + cwd: uiRepoRoot, + env: uiEnv, + }); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); process.exit(1); -} - -const runtimePackageJsonPath = path.join(runtimeRepoRoot, 'package.json'); -if (!fs.existsSync(runtimePackageJsonPath)) { - console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${runtimeRepoRoot}`); - process.exit(1); -} - -const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot); - -if (process.argv.includes('--print-runtime-path')) { - process.stdout.write(`${runtimeRepoRoot}\n`); - process.exit(0); -} - -// Respect the runtime repo's own package manager. The UI repo uses pnpm, but -// the runtime may legitimately be a Bun workspace, and forcing pnpm there can -// fail before the build even starts. -runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot }); - -const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev'); -const uiEnv = { - ...process.env, - // Dev-only agent_teams_orchestrator runtime override. Keep it separate from - // the generic CLAUDE_CLI_PATH override so switching the app into Claude CLI - // mode still resolves the real official binary instead of this local - // cli-dev shim. - CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: runtimeCliPath, -}; -// If the parent shell exported a stale generic override, do not let it leak -// into the Electron main process. Claude mode must resolve the real binary. -delete uiEnv.CLAUDE_CLI_PATH; - -runOrExit('pnpm', ['run', 'dev:ui'], { - cwd: uiRepoRoot, - env: uiEnv, }); diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 20d8d114..20df1a1b 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -250,6 +250,10 @@ export function normalizeVersion(raw: string): string { return match ? match[0] : raw.trim(); } +function isSemverVersion(value: string | null | undefined): value is string { + return typeof value === 'string' && /^\d{1,10}\.\d{1,10}\.\d{1,10}$/.test(value); +} + /** * Compare two semver strings numerically. * Returns true if `installed` is strictly older than `latest`. @@ -579,7 +583,7 @@ export class CliInstallerService { private async probeCliVersion( binaryPath: string - ): Promise<{ ok: true; version: string } | { ok: false; error: string }> { + ): Promise<{ ok: true; version: string | null } | { ok: false; error: string }> { try { const { stdout } = await execCli(binaryPath, ['--version'], { timeout: VERSION_TIMEOUT_MS, @@ -589,13 +593,44 @@ export class CliInstallerService { if (!version) { return { ok: false, error: 'CLI returned an empty version string.' }; } - logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`); - return { ok: true, version }; + + if (isSemverVersion(version)) { + logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`); + return { ok: true, version }; + } + + const inferredVersion = await this.inferInstalledCliVersionFromPath(binaryPath); + if (inferredVersion) { + logger.info( + `Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"` + ); + return { ok: true, version: inferredVersion }; + } + + logger.warn( + `Installed CLI returned a non-semver version string: "${stdout.trim()}". ` + + 'Treating the binary as healthy, but omitting version details.' + ); + return { ok: true, version: null }; } catch (err) { return { ok: false, error: getErrorMessage(err) }; } } + private async inferInstalledCliVersionFromPath(binaryPath: string): Promise { + try { + const resolvedPath = await fsp.realpath(binaryPath); + if (!/[\\/]+versions[\\/]+/.test(resolvedPath)) { + return null; + } + + const inferredVersion = normalizeVersion(resolvedPath); + return isSemverVersion(inferredVersion) ? inferredVersion : null; + } catch { + return null; + } + } + private markProvidersUnavailable(result: CliInstallationStatus, message: string): void { if (result.flavor !== 'agent_teams_orchestrator') { return; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 2c6094dc..35b810a8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1574,6 +1574,27 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string ); } +function buildLeadRosterContextBlock( + teamName: string, + leadName: string, + teammates: Array<{ name: string; role?: string }> +): string | null { + if (teammates.length === 0) return null; + + const summary = teammates + .map((member) => (member.role ? `${member.name} (${member.role})` : member.name)) + .join(', '); + + return [ + `Current durable team context:`, + `- Team name: ${teamName}`, + `- You are the live team lead "${leadName}"`, + `- Persistent teammates currently configured: ${summary}`, + `- This team is NOT in solo mode`, + `- If the user asks who is on the team, answer from this durable roster unless newer durable state explicitly says otherwise.`, + ].join('\n'); +} + /** * Builds the durable lead context — constraints, communication protocol, board MCP ops, * and agent block policy — that must survive context compaction. @@ -5860,6 +5881,16 @@ export class TeamProvisioningService { const MAX_RELAY = 10; const batch = actionableUnread.slice(0, MAX_RELAY); + const teammateRoster = (config.members ?? []) + .filter((member) => { + const name = member.name?.trim(); + return name && name !== leadName; + }) + .map((member) => ({ + name: member.name.trim(), + ...(member.role?.trim() ? { role: member.role.trim() } : {}), + })); + const rosterContextBlock = buildLeadRosterContextBlock(teamName, leadName, teammateRoster); run.activeCrossTeamReplyHints = batch.flatMap((m) => { if (m.source !== 'cross_team') return []; const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : ''; @@ -5877,9 +5908,11 @@ export class TeamProvisioningService { `If there is no action to take, produce ZERO text output. Do NOT write "No action needed.", status echoes, or any other no-op summary.`, `For pure system notifications, comment notifications, or routine teammate availability updates that require no reply/comment/action, say nothing.`, `Do NOT respond with only an agent-only block.`, + ...(rosterContextBlock ? [rosterContextBlock] : []), AGENT_BLOCK_OPEN, `Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`, - `When creating a task from a user message that has a MessageId field, prefer task_create_from_message with that exact messageId for reliable provenance. Only use task_create_from_message when you have an explicit MessageId — never guess or fabricate one.`, + `For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`, + `Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`, `If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`, `Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the task comment notification is FYI and no durable update is needed, say nothing.`, `If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`, @@ -5889,6 +5922,10 @@ export class TeamProvisioningService { `Messages:`, ...batch.flatMap((m, idx) => { const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null; + const isTaskCreateFromMessageEligible = m.source === 'user_sent'; + const provenanceLines = isTaskCreateFromMessageEligible + ? [` Eligible for task_create_from_message: yes`, ` User MessageId: ${m.messageId}`] + : [` Eligible for task_create_from_message: no`]; const crossTeamMeta = m.source === 'cross_team' ? { @@ -5910,11 +5947,11 @@ export class TeamProvisioningService { return [ `${idx + 1}) From: ${m.from || 'unknown'}`, ` Timestamp: ${m.timestamp}`, - ` MessageId: ${m.messageId}`, ...(summaryLine ? [` ${summaryLine}`] : []), ...(typeof m.source === 'string' && m.source.trim() ? [` Source: ${m.source.trim()}`] : []), + ...provenanceLines, ...replyInstructions, ` Text:`, ...m.text.split('\n').map((line) => ` ${line}`), diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 85b2f4c0..dbb0d319 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -59,6 +59,8 @@ export interface ProvisioningProgressBlockProps { cliLogsTail?: string; /** Accumulated assistant text output for live preview */ assistantOutput?: string; + /** Visual surface chrome for the outer block */ + surface?: 'raised' | 'flat'; className?: string; } @@ -148,6 +150,7 @@ export const ProvisioningProgressBlock = ({ pid, cliLogsTail, assistantOutput, + surface = 'raised', className, }: ProvisioningProgressBlockProps): React.JSX.Element => { const elapsed = useElapsedTimer(startedAt, loading); @@ -203,7 +206,9 @@ export const ProvisioningProgressBlock = ({ return (
= 0 ? lastActiveStepRef.current : 0} startedAt={progress.startedAt} @@ -174,6 +175,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({ title="Launch details" message={failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null} messageSeverity={readyDetailSeverity} + surface="flat" currentStepIndex={readyStepIndex} startedAt={progress.startedAt} pid={progress.pid} @@ -199,6 +201,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({ title="Launching team" message={progress.message} messageSeverity={progress.messageSeverity} + surface="flat" currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1} loading startedAt={progress.startedAt} diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index 23f6dc93..7eaf19c0 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -112,7 +112,7 @@ export interface CliInstallationStatus { showBinaryPath: boolean; /** Whether the CLI was found and passed the startup health check (`--version`) */ installed: boolean; - /** Installed version string (e.g. "2.1.59"), null if not installed */ + /** Installed version string (e.g. "2.1.59"), null if unavailable or not installed */ installedVersion: string | null; /** Absolute path to the resolved binary candidate, null if not found */ binaryPath: string | null; diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index 30a4bb7e..fecd1d15 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +const { realpathMock } = vi.hoisted(() => ({ + realpathMock: vi.fn(async (value: string) => value), +})); + // Mock dependencies before importing service vi.mock('@main/utils/childProcess', async (importOriginal) => { const actual = await importOriginal(); @@ -23,6 +27,7 @@ vi.mock('fs', async (importOriginal) => { promises: { ...actual.promises, chmod: vi.fn(), + realpath: realpathMock, unlink: vi.fn(), }, }; @@ -57,6 +62,16 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({ }, })); +vi.mock('@main/services/team/cliFlavor', () => ({ + getConfiguredCliFlavor: vi.fn(() => 'claude'), + getCliFlavorUiOptions: vi.fn(() => ({ + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + })), +})); + import { CliInstallerService, isVersionOlder, @@ -79,6 +94,8 @@ describe('CliInstallerService', () => { beforeEach(() => { vi.clearAllMocks(); + realpathMock.mockReset(); + realpathMock.mockImplementation(async (value: string) => value); service = new CliInstallerService(); }); @@ -95,16 +112,15 @@ describe('CliInstallerService', () => { expect(status.updateAvailable).toBe(false); }); - it('returns installed when binary exists', async () => { + it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => { allowConsoleLogs(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); const status = await service.getStatus(); - expect(status.installed).toBe(true); + expect(status.installed).toBe(false); expect(status.binaryPath).toBe('/usr/local/bin/claude'); - // Version will be null because execFile is mocked to no-op - // and latestVersion will be null because fetch is mocked + expect(status.installedVersion).toBeNull(); }); it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => { @@ -142,6 +158,24 @@ describe('CliInstallerService', () => { expect(status.authLoggedIn).toBe(true); expect(status.authMethod).toBe('oauth_token'); }); + + it('falls back to the installed launcher path when --version reports unknown', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/Users/tester/.local/bin/claude'); + vi.spyOn(service as never, 'inferInstalledCliVersionFromPath').mockResolvedValue('2.1.101'); + vi.mocked(execCli) + .mockResolvedValueOnce({ stdout: 'unknown', stderr: '' }) + .mockResolvedValueOnce({ + stdout: '{"loggedIn":true,"authMethod":"oauth_token"}', + stderr: '', + }); + + const status = await service.getStatus(); + + expect(status.installed).toBe(true); + expect(status.installedVersion).toBe('2.1.101'); + expect(status.authLoggedIn).toBe(true); + }); }); describe('install mutex', () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index b02be173..d9453af8 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -854,7 +854,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(writeSpy).toHaveBeenCalledTimes(0); }); - it('includes MessageId in lead inbox relay prompt for provenance', async () => { + it('includes user message provenance in lead inbox relay prompt', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; seedConfig(teamName); @@ -881,7 +881,8 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { await relayPromise; const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); - expect(payload).toContain('MessageId: msg-provenance-001'); + expect(payload).toContain('Eligible for task_create_from_message: yes'); + expect(payload).toContain('User MessageId: msg-provenance-001'); expect(payload).toContain('Build the authentication module'); }); @@ -1183,7 +1184,16 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { it('lead inbox relay prompt mentions task_create_from_message for user messages with messageId', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; - seedConfig(teamName); + hoisted.files.set( + `/mock/teams/${teamName}/config.json`, + JSON.stringify({ + name: 'My Team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', role: 'developer' }, + ], + }) + ); seedLeadInbox(teamName, [ { from: 'user', @@ -1208,7 +1218,41 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('task_create_from_message'); - expect(payload).toContain('MessageId'); + expect(payload).toContain('Current durable team context:'); + expect(payload).toContain(`- Team name: ${teamName}`); + expect(payload).toContain(`teamName MUST be \\"${teamName}\\"`); + expect(payload).toContain('Eligible for task_create_from_message: yes'); + expect(payload).toContain('User MessageId: msg-task-pref-001'); + }); + + it('does not present teammate inbox message ids as task_create_from_message provenance', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'jack', + text: 'Могу начать с проверки массовых удалений в docs-site.', + timestamp: '2026-02-23T16:05:00.000Z', + read: false, + summary: 'Нет назначенных задач для jack', + messageId: 'inbox-jack-001', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'Понял.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + await relayPromise; + + const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); + expect(payload).toContain('Eligible for task_create_from_message: no'); + expect(payload).not.toContain('User MessageId: inbox-jack-001'); }); it('marks pure lead heartbeat idle as read without relaying it', async () => {