feat(agent-teams): harden dev bootstrap and task tooling

This commit is contained in:
777genius 2026-04-11 21:57:59 +03:00
parent 01e9e8350e
commit 4869bb35da
11 changed files with 758 additions and 72 deletions

View file

@ -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<FastMCP, 'addTool'>) {
server.addTool({
name: 'task_create',
@ -85,6 +123,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
prompt,
startImmediately,
}) => {
assertConfiguredTeam(teamName, claudeDir);
const controller = getController(teamName, claudeDir);
return await Promise.resolve(
jsonTextContent(
@ -143,23 +182,34 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
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<string, unknown>;
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.'
);
}

View file

@ -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.'
);
});
});

View file

@ -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",

View file

@ -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,
});

View file

@ -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<string | null> {
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;

View file

@ -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}`),

View file

@ -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 (
<div
className={cn(
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
surface === 'flat'
? 'rounded-none border-0 bg-transparent px-0 py-0'
: 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
isError && 'border-red-500/40 bg-red-500/10',
className
)}

View file

@ -125,6 +125,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
title="Launch failed"
message={progress.error ?? null}
tone="error"
surface="flat"
currentStepIndex={lastActiveStepRef.current}
errorStepIndex={lastActiveStepRef.current >= 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}

View file

@ -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;

View file

@ -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<typeof import('@main/utils/childProcess')>();
@ -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', () => {

View file

@ -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 () => {