agent-ecosystem/src/main/services/team/TeamProvisioningService.ts
iliya 0099be4f23 fix: improve WSL user home path resolution and enhance process renaming on Windows
- Updated fallback home path logic to correctly handle 'root' user case in WSL.
- Refactored process renaming in TeamAgentToolsInstaller to include retry logic on Windows for better error handling during concurrent operations.
- Enhanced user information retrieval in TeamProvisioningService to handle potential errors gracefully.
- Clarified documentation in processKill utility regarding platform-specific behavior.
2026-03-02 23:10:44 +02:00

3514 lines
130 KiB
TypeScript

/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
import {
encodePath,
extractBaseDir,
getAutoDetectedClaudeBasePath,
getClaudeBasePath,
getHomeDir,
getProjectsBasePath,
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import {
AGENT_BLOCK_CLOSE,
AGENT_BLOCK_OPEN,
stripAgentBlocks,
} from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { createLogger } from '@shared/utils/logger';
import { execFile, spawn } from 'child_process';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { promisify } from 'util';
import { atomicWriteAsync } from './atomicWrite';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import type {
InboxMessage,
TeamChangeEvent,
TeamCreateRequest,
TeamCreateResponse,
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
TeamTask,
} from '@shared/types';
const logger = createLogger('Service:TeamProvisioning');
const RUN_TIMEOUT_MS = 300_000;
const VERIFY_TIMEOUT_MS = 15_000;
const VERIFY_POLL_MS = 500;
const STDERR_RING_LIMIT = 64 * 1024;
const STDOUT_RING_LIMIT = 64 * 1024;
const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000;
const CLI_PREPARE_TIMEOUT_MS = 10000;
const PREFLIGHT_TIMEOUT_MS = 30000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const KEYCHAIN_TIMEOUT_MS = 5000;
const FS_MONITOR_POLL_MS = 2000;
const TASK_WAIT_FALLBACK_MS = 15_000;
const execFileAsync = promisify(execFile);
type TeamsBaseLocation = 'configured' | 'default';
type ValidConfigProbeResult =
| { ok: true; location: TeamsBaseLocation; configPath: string }
| { ok: false };
function getTeamsBasePathsToProbe(): { location: TeamsBaseLocation; basePath: string }[] {
const configured = getTeamsBasePath();
const defaultBase = path.join(getAutoDetectedClaudeBasePath(), 'teams');
if (path.resolve(configured) === path.resolve(defaultBase)) {
return [{ location: 'configured', basePath: configured }];
}
return [
{ location: 'configured', basePath: configured },
{ location: 'default', basePath: defaultBase },
];
}
function logsSuggestShutdownOrCleanup(logs: string): boolean {
const text = logs.toLowerCase();
return (
text.includes('shutdown') ||
text.includes('clean up') ||
text.includes('cleanup') ||
text.includes('deactivate') ||
text.includes('deactivated') ||
text.includes('resources') ||
// Russian keywords observed in some CLI outputs / user environments
text.includes('очист') ||
text.includes('очищ') ||
text.includes('заверш') ||
text.includes('деактив')
);
}
interface ProvisioningRun {
runId: string;
teamName: string;
startedAt: string;
progress: TeamProvisioningProgress;
stdoutBuffer: string;
stderrBuffer: string;
processKilled: boolean;
finalizingByTimeout: boolean;
cancelRequested: boolean;
teamsBasePathsToProbe: { location: TeamsBaseLocation; basePath: string }[];
child: ReturnType<typeof spawn> | null;
timeoutHandle: NodeJS.Timeout | null;
fsMonitorHandle: NodeJS.Timeout | null;
onProgress: (progress: TeamProvisioningProgress) => void;
expectedMembers: string[];
request: TeamCreateRequest;
lastLogProgressAt: number;
fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found';
waitingTasksSince: number | null;
provisioningComplete: boolean;
isLaunch: boolean;
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;
/**
* Accumulates assistant text for direct user→lead messages (no relay capture active).
* Flushed to liveLeadProcessMessages on result.success.
*/
directReplyParts: string[];
/** Accumulates assistant text during provisioning phase for live UI preview. */
provisioningOutputParts: string[];
/** Session ID detected from stream-json output (result.session_id or message.session_id). */
detectedSessionId: string | null;
/** Lead process activity: 'active' during turn processing, 'idle' waiting for input, 'offline' after exit. */
leadActivityState: LeadActivityState;
/** Whether an auth failure retry was already attempted for this run. */
authFailureRetried: boolean;
/** Set to true while auth-failure respawn is in progress to prevent duplicate handling. */
authRetryInProgress: boolean;
/** Saved spawn context for auth-failure respawn. */
spawnContext: {
claudePath: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
prompt: string;
} | null;
}
type LeadActivityState = 'active' | 'idle' | 'offline';
type ProvisioningAuthSource =
| 'anthropic_api_key'
| 'anthropic_auth_token'
| 'claude_code_oauth_token_env'
| 'claude_code_oauth_token_credentials'
| 'none';
interface ProvisioningEnvResolution {
env: NodeJS.ProcessEnv;
authSource: ProvisioningAuthSource;
}
function nowIso(): string {
return new Date().toISOString();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
const parsed: NodeJS.ProcessEnv = {};
const lines = content.split('\0');
for (const line of lines) {
if (!line) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex);
const value = line.slice(separatorIndex + 1);
parsed[key] = value;
}
return parsed;
}
async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.ProcessEnv> {
const envDump = await new Promise<string>((resolve, reject) => {
const child = spawn(shellPath, args, {
env: process.env,
stdio: ['ignore', 'pipe', 'ignore'],
});
const chunks: Buffer[] = [];
let settled = false;
let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => {
timeoutHandle = null;
child.kill();
// SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc)
setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
/* already dead */
}
}, 3000);
if (!settled) {
settled = true;
reject(new Error('shell env resolve timeout'));
}
}, SHELL_ENV_TIMEOUT_MS);
child.stdout?.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
child.once('error', (error) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
if (!settled) {
settled = true;
reject(error);
}
});
child.once('close', () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (!settled) {
settled = true;
resolve(Buffer.concat(chunks).toString('utf8'));
}
});
});
return parseNullSeparatedEnv(envDump);
}
async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
if (cachedInteractiveShellEnv) {
return cachedInteractiveShellEnv;
}
if (shellEnvResolvePromise) {
return shellEnvResolvePromise;
}
if (process.platform === 'win32') {
cachedInteractiveShellEnv = {};
return cachedInteractiveShellEnv;
}
shellEnvResolvePromise = (async () => {
const shellPath = process.env.SHELL || '/bin/zsh';
try {
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
cachedInteractiveShellEnv = loginEnv;
return loginEnv;
} catch (loginError) {
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
logger.warn(`Failed to resolve login shell env: ${loginMessage}`);
try {
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
cachedInteractiveShellEnv = interactiveEnv;
return interactiveEnv;
} catch (interactiveError) {
const interactiveMessage =
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
return {};
}
} finally {
shellEnvResolvePromise = null;
}
})();
return shellEnvResolvePromise;
}
async function ensureCwdExists(cwd: string): Promise<void> {
await fs.promises.mkdir(cwd, { recursive: true });
const stat = await fs.promises.stat(cwd);
if (!stat.isDirectory()) {
throw new Error('cwd must be a directory');
}
}
function wrapInAgentBlock(text: string): string {
const trimmed = text.trim();
if (trimmed.length === 0) return '';
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
}
function buildMembersPrompt(members: TeamCreateRequest['members']): string {
return members
.map((member) => {
const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : '';
return `- ${member.name}${rolePart}`;
})
.join('\n');
}
function buildTaskStatusProtocol(teamName: string): string {
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
1. Use this command to mark task started:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start <taskId>
2. Use this command to mark task completed BEFORE sending your final reply:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete <taskId>
3. If you are asked to review and task is accepted, move it to APPROVED (not DONE):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review approve <taskId>
4. If review fails and changes are needed:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes <taskId> --comment "<what to fix>"
5. NEVER skip status updates. A task is NOT done until completed status is written.
6. To reply to a comment on a task:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<your reply>" --from "<your-name>"
7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<summary of your finding or decision>" --from "<your-name>"
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
8. When sending a message about a specific task, include #<taskId> in your SendMessage summary field for traceability.
9. Review workflow clarity (IMPORTANT):
- The work task (e.g. #1) is the thing that must end up APPROVED after review.
- If you are reviewing work for task #X, run review approve/request-changes on #X (the work task).
- Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) — that will put the wrong task into APPROVED.
- Typical flow:
a) Owner finishes work on #X → task complete #X
b) Reviewer accepts → review approve #X
10. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY):
When you are blocked and need information to continue a task, you MUST do BOTH steps below — skipping the Bash command breaks the task board:
a) STEP 1 — FIRST, set the clarification flag via Bash (this updates the task board):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> lead --from "<your-name>"
b) STEP 2 — THEN, send a message to your team lead via SendMessage explaining what you need.
IMPORTANT: Always run the Bash command BEFORE sending the message. The flag is what makes the task board show "needs clarification" — without it, your request is invisible on the board.
c) The flag is auto-cleared when the lead adds a task comment on your task.
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
11. DEPENDENCY AWARENESS:
When your task has blockedBy dependencies, check if they are completed before starting.
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
Failure to follow this protocol means the task board will show incorrect status.`);
}
function buildProcessRegistrationProtocol(teamName: string): string {
return wrapInAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
1. Launch with & to get PID:
pnpm dev &
2. Register immediately (--port and --url are optional, use when the process listens on a port):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process register --pid $! --label "<description>" --from "<your-name>" [--port <PORT> --url "http://localhost:<PORT>"]
3. VERIFY registration succeeded (MANDATORY — never skip this step):
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process list
4. When stopping a process:
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process unregister --pid <PID>
If verification in step 3 fails or the process is missing from the list, re-register it.`);
}
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
return wrapInAgentBlock(
[
`Internal task board tooling (teamctl.js):`,
`- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).`,
``,
`Parallelization guideline (IMPORTANT):`,
`- If a task is genuinely parallelizable, split it into multiple smaller tasks owned by different members.`,
` - Prefer splitting by independent deliverables (e.g. frontend/backend, API/UI, parsing/rendering, tests/docs) rather than arbitrary slices.`,
` - Use --blocked-by only when one piece truly cannot start without another; otherwise link with --related.`,
` - Do NOT split when work is inherently sequential, requires one person to keep consistent context, or the overhead would exceed the benefit.`,
` - When splitting, make each task have a clear completion criterion and a single accountable owner.`,
``,
`Task board operations — use teamctl.js via Bash:`,
`- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"`,
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
`- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "<member>" --notify --from "${leadName}"`,
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
``,
`Dependency guidelines:`,
`- Use --blocked-by when a task cannot start until another is done.`,
`- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`,
`- Use --related to link related work (e.g. frontend + backend) without blocking.`,
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via: review approve/request-changes #X.`,
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with --related (and optionally --blocked-by #X if it truly cannot start yet).`,
` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
``,
`Notification policy:`,
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
``,
`Clarification handling (CRITICAL — MANDATORY for correct task board state):`,
`- When a teammate needs clarification (needsClarification: "lead"), reply via task comment (preferred — auto-clears the flag) or SendMessage.`,
`- If you reply via SendMessage instead of task comment, also clear the flag manually:`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "${leadName}"`,
`- If you cannot answer and the user needs to decide — ESCALATION PROTOCOL:`,
` 1) FIRST, set the flag to "user" via Bash (this updates the task board):`,
` node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> user --from "${leadName}"`,
` 2) THEN, send a message to "user" explaining the question.`,
` 3) THEN, reply to the teammate telling them to wait.`,
` IMPORTANT: Always run the Bash command BEFORE sending messages. Without the flag, the task board won't show that the task is blocked waiting for user input.`,
].join('\n')
);
}
function buildAgentBlockUsagePolicy(): string {
return `Agent-only formatting policy (applies to ALL messages you write):
- Humans can see teammate inbox messages and coordination text in the UI.
- Keep normal reasoning, decisions, and user-facing communication OUTSIDE agent-only blocks.
- Any internal operational instructions about tooling/scripts MUST be hidden inside an agent-only block, including:
- how to use internal scripts (e.g. teamctl.js), exact CLI commands, flags (e.g. --notify)
- review command phrases like "review approve <id>" / "review request-changes <id>"
- internal file paths under ~/.claude/ (tools, teams, tasks, kanban state, etc.)
- meta coordination lines like "All teammates are online and have received their assignments via --notify."
- Use an agent-only fenced block (AGENT_BLOCK_OPEN / AGENT_BLOCK_CLOSE):
- AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}
- AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}
- IMPORTANT: the fence lines must start at the beginning of the line (no indentation).
- Example (copy/paste exactly, no indentation):
${AGENT_BLOCK_OPEN}
(internal instructions: commands, script usage, paths, etc.)
${AGENT_BLOCK_CLOSE}
- Put ONLY the internal instructions inside the agent-only block.
- CRITICAL: Messages to "user" (the human) must NEVER contain agent-only blocks. Write them as plain readable text — the human sees these messages directly in the UI. Agent-only blocks are stripped before display, so a message containing ONLY an agent-only block will appear completely empty.
- CRITICAL: When processing relayed inbox messages, your text output is shown to the user. Do NOT wrap your entire response in an agent-only block. If you need agent-only instructions, put them in a separate block and include a brief human-readable summary outside of it (e.g. "Delegated task to carol." or "Acknowledged, no action needed.").`;
}
function getSystemLocale(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().locale;
} catch {
return process.env.LANG?.split('.')[0]?.replace('_', '-') ?? 'en';
}
}
function getAgentLanguageInstruction(): string {
const config = ConfigManager.getInstance().getConfig();
const langCode = config.general.agentLanguage || 'system';
const systemLocale = getSystemLocale();
const languageName = resolveLanguageName(langCode, systemLocale);
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
}
/** Build a concise task snapshot for a specific member (pending/in_progress tasks only). */
function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string {
const activeTasks = tasks.filter(
(t) =>
t.owner === memberName &&
(t.status === 'pending' || t.status === 'in_progress') &&
!t.id.startsWith('_internal')
);
if (activeTasks.length === 0) return '';
const lines = activeTasks.map((t) => {
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
const deps = t.blockedBy?.length
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
: '';
return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`;
});
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
}
/** Build a full task board snapshot for the lead. */
function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
const active = tasks.filter(
(t) => (t.status === 'pending' || t.status === 'in_progress') && !t.id.startsWith('_internal')
);
if (active.length === 0) return '\nNo pending tasks on the board.\n';
const lines = active.map((t) => {
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
const desc = t.description ? `${t.description.slice(0, 120)}` : '';
const deps = t.blockedBy?.length
? ` [blocked by: ${t.blockedBy.map((id) => '#' + id).join(', ')}]`
: '';
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`;
});
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
}
function buildProvisioningPrompt(request: TeamCreateRequest): string {
const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description';
const members = buildMembersPrompt(request.members);
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const leadName =
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
Goal: Provision a Claude Code agent team with live teammates.
${userPromptBlock}
${languageInstruction}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members.
- Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work.
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
${teamCtlOps}
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Message formatting:
${agentBlockPolicy}
Steps (execute in this exact order):
1) TeamCreate — create team "${request.teamName}":
- description: "${description}"
2) Spawn each member as a live teammate using the Task tool:
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
- prompt:
You are {name}, a {role} on team "${displayName}" (${request.teamName}).
${languageInstruction}
Introduce yourself briefly (name and role) and confirm you are ready.
Then wait for task assignments.
Include the following agent-only instructions verbatim in the prompt:
${taskProtocol}
${processRegistration}
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
- Prefer fewer, broader tasks over many micro-tasks.
- Avoid duplicate notifications for the same assignment.
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
- Review guidance:
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
- Use --related to connect it to #X (non-blocking link).
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
- There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task start / set-status in_progress) when ready.
- Use --related to connect tasks working on the same feature without blocking.
4) After all steps, output a short summary.
Members:
${members}
`;
}
function buildLaunchPrompt(
request: TeamLaunchRequest,
members: TeamCreateRequest['members'],
tasks: TeamTask[]
): string {
const membersBlock = buildMembersPrompt(members);
const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
const projectName = path.basename(request.cwd);
// Build per-member task snapshots to include in each teammate's spawn prompt
const memberTaskBlocks = new Map<string, string>();
for (const m of members) {
const snapshot = buildMemberTaskSnapshot(m.name, tasks);
if (snapshot) memberTaskBlocks.set(m.name, snapshot);
}
// Build the teammate spawn prompt template with member-specific task injection
const memberSpawnInstructions = members
.map((m) => {
const taskBlock = memberTaskBlocks.get(m.name) || '';
const hasTasks = Boolean(taskBlock);
return ` For "${m.name}":
- prompt:
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".
${languageInstruction}
The team has been reconnected after a restart.
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
Your FIRST action: run this command to get your full task briefing with descriptions and comments:
node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task briefing --for "${m.name}"
Then resume in_progress tasks first, then pending tasks.
If you have no tasks, wait for new assignments.`;
})
.join('\n\n');
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
${userPromptBlock}
${languageInstruction}
${taskBoardSnapshot}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members.
- Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work.
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
${teamCtlOps}
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Message formatting:
${agentBlockPolicy}
Steps (execute in this exact order):
1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state.
2) Spawn each existing member as a live teammate using the Task tool:
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
- IMPORTANT: Include each member's pending tasks in their spawn prompt so they resume work immediately.
Include the following agent-only instructions verbatim in each teammate's prompt:
${taskProtocol}
${processRegistration}
Per-member spawn instructions:
${memberSpawnInstructions}
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.
4) After all steps, output a short summary of reconnected members and resumed tasks.
Members:
${membersBlock}
`;
}
function updateProgress(
run: ProvisioningRun,
state: Exclude<TeamProvisioningState, 'idle'>,
message: string,
extras?: Pick<TeamProvisioningProgress, 'pid' | 'error' | 'warnings' | 'cliLogsTail'>
): TeamProvisioningProgress {
const assistantOutput =
run.provisioningOutputParts.length > 0
? run.provisioningOutputParts.join('\n\n')
: run.progress.assistantOutput;
run.progress = {
...run.progress,
state,
message,
updatedAt: nowIso(),
pid: extras?.pid ?? run.progress.pid,
error: extras?.error,
warnings: extras?.warnings,
cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail,
assistantOutput,
};
return run.progress;
}
function buildCombinedLogs(stdoutBuffer: string, stderrBuffer: string): string {
const stdoutTrimmed = stdoutBuffer.trim();
const stderrTrimmed = stderrBuffer.trim();
if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) {
return '';
}
if (stdoutTrimmed.length > 0 && stderrTrimmed.length === 0) {
return stdoutTrimmed;
}
if (stdoutTrimmed.length === 0 && stderrTrimmed.length > 0) {
return stderrTrimmed;
}
return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n');
}
function extractLogsTail(stdoutBuffer: string, stderrBuffer: string): string | undefined {
const trimmed = buildCombinedLogs(stdoutBuffer, stderrBuffer).trim();
if (trimmed.length === 0) {
return undefined;
}
return trimmed.slice(-UI_LOGS_TAIL_LIMIT);
}
function emitLogsProgress(run: ProvisioningRun): void {
const logsTail = extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
const assistantOutput =
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n\n') : undefined;
if (!logsTail && !assistantOutput) {
return;
}
run.progress = {
...run.progress,
updatedAt: nowIso(),
...(logsTail !== undefined && { cliLogsTail: logsTail }),
...(assistantOutput !== undefined && { assistantOutput }),
};
run.onProgress(run.progress);
}
function buildCliExitError(code: number | null, stdoutText: string, stderrText: string): string {
const trimmed = buildCombinedLogs(stdoutText, stderrText).trim();
if (trimmed.length > 0) {
if (trimmed.toLowerCase().includes('please run /login')) {
return 'CLI output indicates that `-p` mode is not authenticated. `claude -p` typically requires `ANTHROPIC_API_KEY` (Agent SDK). `/login` is interactive-only and does not fix `-p`.';
}
return trimmed.slice(-4000);
}
if (code === 1) {
return 'Claude CLI exited with code 1. Typical causes: missing auth/onboarding for CLI, or command requiring interactive TTY. Run `claude` in a normal terminal, complete setup, and retry.';
}
return `Claude CLI exited with code ${code ?? 'unknown'}`;
}
interface CachedProbeResult {
claudePath: string;
env: NodeJS.ProcessEnv;
authSource: ProvisioningAuthSource;
warning?: string;
}
let cachedProbeResult: CachedProbeResult | null = null;
export class TeamProvisioningService {
private readonly runs = new Map<string, ProvisioningRun>();
private readonly activeByTeam = new Map<string, string>();
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
) {}
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
this.teamChangeEmitter = emitter;
}
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
}
getLeadActivityState(teamName: string): 'active' | 'idle' | 'offline' {
const runId = this.activeByTeam.get(teamName);
if (!runId) return 'offline';
const run = this.runs.get(runId);
if (!run || run.processKilled || run.cancelRequested) return 'offline';
return run.leadActivityState;
}
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
if (run.leadActivityState === state) return;
run.leadActivityState = state;
this.teamChangeEmitter?.({
type: 'lead-activity',
teamName: run.teamName,
detail: state,
});
}
async warmup(): Promise<void> {
try {
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) return;
const { env, authSource } = await this.buildProvisioningEnv();
const cwd = process.cwd();
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
cachedProbeResult = { claudePath, env, authSource, warning: probe.warning };
logger.info('CLI warmup completed');
} catch (error) {
logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async prepareForProvisioning(cwd?: string): Promise<TeamProvisioningPrepareResult> {
// Always validate cwd even when cache is available
const targetCwdForValidation = cwd?.trim() || process.cwd();
if (targetCwdForValidation && path.isAbsolute(targetCwdForValidation)) {
await ensureCwdExists(targetCwdForValidation);
}
if (cachedProbeResult) {
const { warning, authSource } = cachedProbeResult;
const warnings: string[] = [];
if (warning) warnings.push(warning);
const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false;
return {
ready: !warning || authSource !== 'none' || !isAuthFailure,
message: 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
const { env: executionEnv, authSource } = await this.buildProvisioningEnv();
const targetCwd = cwd?.trim() || process.cwd();
if (!path.isAbsolute(targetCwd)) {
throw new Error('cwd must be an absolute path');
}
await ensureCwdExists(targetCwd);
const warnings: string[] = [];
if (authSource === 'none') {
// No explicit auth found. Still attempt preflight — the CLI may
// authenticate through a mechanism we don't know about (e.g. a
// managed apiKeyHelper, SSO, or a future auth flow).
warnings.push(
'No explicit auth env var found (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN). ' +
'Attempting preflight check to verify if CLI can authenticate on its own.'
);
}
if (authSource === 'anthropic_auth_token') {
warnings.push(
'Using ANTHROPIC_AUTH_TOKEN (proxy) mapped to ANTHROPIC_API_KEY for `-p` mode.'
);
}
if (authSource === 'claude_code_oauth_token_credentials') {
const source =
process.platform === 'darwin'
? 'macOS Keychain or credentials file'
: `${path.join(getClaudeBasePath(), '.credentials.json')}`;
warnings.push(
`Using OAuth token from ${source}. ` +
'Note: this token may be stale if Claude Code refreshed it in-memory without persisting. ' +
'If auth fails, run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN.'
);
}
const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv);
if (probe.warning) {
const isAuthFailure = this.isAuthFailureWarning(probe.warning);
if (authSource === 'none' && isAuthFailure) {
// No auth source + preflight indicates auth failure — block to avoid a confusing hang later.
return {
ready: false,
message: probe.warning,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
// Preflight warnings (including timeouts) should not block provisioning.
warnings.push(probe.warning);
}
return {
ready: true,
message: 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
private isAuthFailureWarning(text: string): boolean {
const lower = text.toLowerCase();
return (
lower.includes('not authenticated') ||
lower.includes('not logged in') ||
lower.includes('please run /login') ||
lower.includes('missing api key') ||
lower.includes('invalid api key')
);
}
/**
* Detects auth failure keywords in stderr/stdout during provisioning.
* On first detection: kills process, waits, and respawns automatically.
* On second detection (after retry): fails fast with a clear error.
*/
private handleAuthFailureInOutput(run: ProvisioningRun, text: string, source: string): void {
if (run.provisioningComplete || run.processKilled || run.authRetryInProgress) return;
if (!this.isAuthFailureWarning(text)) return;
if (!run.authFailureRetried) {
logger.warn(
`[${run.teamName}] Auth failure detected in ${source} during provisioning — ` +
`will kill process and retry after ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms`
);
run.authRetryInProgress = true;
void this.respawnAfterAuthFailure(run);
} else {
logger.error(`[${run.teamName}] Auth failure detected in ${source} after retry — giving up`);
run.processKilled = true;
killProcessTree(run.child);
const progress = updateProgress(run, 'failed', 'Authentication failed — CLI requires login', {
error:
'Claude CLI is not authenticated. Run `claude` in a terminal to complete login, ' +
'or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN and try again.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
}
}
/**
* Kills the current process, waits for lock release, and respawns with saved context.
* Reattaches all stream listeners and resends the prompt.
*/
private async respawnAfterAuthFailure(run: ProvisioningRun): Promise<void> {
const ctx = run.spawnContext;
if (!ctx) {
logger.error(`[${run.teamName}] Cannot respawn — no spawn context saved`);
run.authRetryInProgress = false;
return;
}
// Tear down current process without full cleanupRun (keep run alive)
if (run.timeoutHandle) {
clearTimeout(run.timeoutHandle);
run.timeoutHandle = null;
}
this.stopFilesystemMonitor(run);
if (run.child) {
run.child.stdout?.removeAllListeners('data');
run.child.stderr?.removeAllListeners('data');
run.child.removeAllListeners('error');
run.child.removeAllListeners('exit');
killProcessTree(run.child);
run.child = null;
}
// Reset buffers for fresh attempt
run.stdoutBuffer = '';
run.stderrBuffer = '';
run.authFailureRetried = true;
updateProgress(run, 'spawning', 'Auth failed — retrying after short delay');
run.onProgress(run.progress);
await sleep(PREFLIGHT_AUTH_RETRY_DELAY_MS);
if (run.cancelRequested) {
run.authRetryInProgress = false;
return;
}
// Respawn
let child: ReturnType<typeof spawn>;
try {
child = spawnCli(ctx.claudePath, ctx.args, {
cwd: ctx.cwd,
env: { ...ctx.env },
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
run.authRetryInProgress = false;
const progress = updateProgress(run, 'failed', 'Failed to respawn Claude CLI', {
error: error instanceof Error ? error.message : String(error),
});
run.onProgress(progress);
this.cleanupRun(run);
return;
}
logger.info(
`[${run.teamName}] Respawned CLI process after auth failure (pid=${child.pid ?? '?'})`
);
run.child = child;
run.authRetryInProgress = false;
updateProgress(run, 'spawning', 'CLI respawned — sending prompt', {
pid: child.pid ?? undefined,
});
run.onProgress(run.progress);
// Resend prompt
if (child.stdin?.writable) {
const message = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: ctx.prompt }],
},
});
child.stdin.write(message + '\n');
}
// Reattach stdout handler
this.attachStdoutHandler(run);
// Reattach stderr handler
this.attachStderrHandler(run);
// Restart filesystem monitor for createTeam (launch skips it)
if (!run.isLaunch) {
this.startFilesystemMonitor(run, run.request);
} else {
updateProgress(run, 'monitoring', 'CLI running — reconnecting with teammates');
run.onProgress(run.progress);
}
// Restart timeout
run.timeoutHandle = setTimeout(() => {
if (!run.processKilled && !run.provisioningComplete) {
run.processKilled = true;
run.finalizingByTimeout = true;
void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
killProcessTree(run.child);
if (readyOnTimeout) return;
const hint = run.isLaunch ? ' (launch)' : '';
const progress = updateProgress(run, 'failed', `Timed out waiting for CLI${hint}`, {
error: `Timed out waiting for CLI${hint}.`,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
})();
}
}, RUN_TIMEOUT_MS);
child.once('error', (error) => {
const hint = run.isLaunch ? ' (launch)' : '';
const progress = updateProgress(run, 'failed', `Failed to start Claude CLI${hint}`, {
error: error.message,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
});
child.once('exit', (code) => {
void this.handleProcessExit(run, code);
});
}
/** Attaches the stdout stream-json parser to the current child process. */
private attachStdoutHandler(run: ProvisioningRun): void {
const child = run.child;
if (!child?.stdout) return;
let stdoutLineBuf = '';
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
run.stdoutBuffer += text;
if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) {
run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT);
}
// Parse stream-json lines (newline-delimited JSON)
stdoutLineBuf += text;
const lines = stdoutLineBuf.split('\n');
stdoutLineBuf = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
this.handleStreamJsonMessage(run, msg);
} catch {
// Not valid JSON — check for auth failure in raw text output
this.handleAuthFailureInOutput(run, trimmed, 'stdout');
}
}
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
/** Attaches the stderr handler with auth failure detection. */
private attachStderrHandler(run: ProvisioningRun): void {
const child = run.child;
if (!child?.stderr) return;
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
run.stderrBuffer += text;
if (run.stderrBuffer.length > STDERR_RING_LIMIT) {
run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT);
}
// Detect auth failure early instead of waiting for 5-minute timeout
this.handleAuthFailureInOutput(run, text, 'stderr');
const currentTs = Date.now();
if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) {
run.lastLogProgressAt = currentTs;
emitLogsProgress(run);
}
});
}
async createTeam(
request: TeamCreateRequest,
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<TeamCreateResponse> {
if (this.activeByTeam.has(request.teamName)) {
throw new Error('Provisioning already running');
}
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
for (const probe of teamsBasePathsToProbe) {
const configPath = path.join(probe.basePath, request.teamName, 'config.json');
if (await this.pathExists(configPath)) {
const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`;
throw new Error(`Team already exists${suffix}`);
}
}
await ensureCwdExists(request.cwd);
const claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
const runId = randomUUID();
const startedAt = nowIso();
const run: ProvisioningRun = {
runId,
teamName: request.teamName,
startedAt,
stdoutBuffer: '',
stderrBuffer: '',
processKilled: false,
finalizingByTimeout: false,
cancelRequested: false,
teamsBasePathsToProbe,
child: null,
timeoutHandle: null,
fsMonitorHandle: null,
onProgress,
expectedMembers: request.members.map((member) => member.name),
request,
lastLogProgressAt: 0,
waitingTasksSince: null,
provisioningComplete: false,
isLaunch: false,
fsPhase: 'waiting_config',
leadRelayCapture: null,
directReplyParts: [],
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
authFailureRetried: false,
authRetryInProgress: false,
spawnContext: null,
progress: {
runId,
teamName: request.teamName,
state: 'validating',
message: 'Validating team provisioning request',
startedAt,
updatedAt: startedAt,
cliLogsTail: undefined,
},
};
this.runs.set(runId, run);
this.activeByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
const prompt = buildProvisioningPrompt(request);
let child: ReturnType<typeof spawn>;
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
if (authSource === 'none') {
logger.warn(
'No explicit auth env var found for `-p` mode. ' +
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
);
}
const spawnArgs = [
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--setting-sources',
'user,project,local',
'--disallowedTools',
'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
...(request.model ? ['--model', request.model] : []),
];
try {
child = spawnCli(claudePath, spawnArgs, {
cwd: request.cwd,
env: { ...shellEnv },
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
throw error;
}
updateProgress(run, 'spawning', 'Starting Claude CLI process', { pid: child.pid ?? undefined });
run.onProgress(run.progress);
run.child = child;
run.spawnContext = {
claudePath,
args: spawnArgs,
cwd: request.cwd,
env: { ...shellEnv },
prompt,
};
// Send provisioning prompt as first stream-json message (SDKUserMessage format)
if (child.stdin?.writable) {
const message = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: prompt }],
},
});
child.stdin.write(message + '\n');
}
this.attachStdoutHandler(run);
this.attachStderrHandler(run);
// Filesystem-based progress monitor: actively polls team files instead
// of relying on stdout (which only arrives at the end in text mode).
// When config + members + tasks are all present, kill the process early
// rather than waiting for it to deadlock on system-reminder shutdown.
this.startFilesystemMonitor(run, request);
run.timeoutHandle = setTimeout(() => {
if (!run.processKilled && !run.provisioningComplete) {
run.processKilled = true;
run.finalizingByTimeout = true;
void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
killProcessTree(run.child);
if (readyOnTimeout) {
return; // cleanupRun already called inside tryCompleteAfterTimeout
}
const progress = updateProgress(run, 'failed', 'Timed out waiting for CLI', {
error:
'Timed out waiting for CLI. Run `claude` once in terminal to complete onboarding and try again.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
})();
}
}, RUN_TIMEOUT_MS);
child.once('error', (error) => {
const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI', {
error: error.message,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
});
child.once('exit', (code) => {
void this.handleProcessExit(run, code);
});
return { runId };
}
async launchTeam(
request: TeamLaunchRequest,
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<TeamLaunchResponse> {
if (this.activeByTeam.has(request.teamName)) {
throw new Error('Team is already running');
}
// Verify config.json exists — team must already be provisioned
const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json');
let configRaw: string;
try {
configRaw = await fs.promises.readFile(configPath, 'utf8');
} catch {
throw new Error(`Team "${request.teamName}" not found — config.json does not exist`);
}
const {
members: expectedMemberSpecs,
source,
warning,
} = await this.resolveLaunchExpectedMembers(request.teamName, configRaw);
const expectedMembers = expectedMemberSpecs.map((m) => m.name);
// Extract leadSessionId for session resume on reconnect.
// If a valid JSONL file exists for the previous session, we can resume it
// so the lead retains full context of prior work.
// When clearContext is true, skip resume entirely to start a fresh session.
let previousSessionId: string | undefined;
if (request.clearContext) {
logger.info(
`[${request.teamName}] clearContext requested — skipping session resume, starting fresh`
);
} else {
try {
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
if (
typeof configParsed.leadSessionId === 'string' &&
configParsed.leadSessionId.trim().length > 0
) {
const candidateId = configParsed.leadSessionId.trim();
const storedProjectPath =
typeof configParsed.projectPath === 'string' &&
configParsed.projectPath.trim().length > 0
? configParsed.projectPath.trim()
: null;
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
// If the project path changed, the old session JSONL won't be found by the CLI
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) {
logger.info(
`[${request.teamName}] Project path changed: ${storedProjectPath}${request.cwd}. ` +
`Skipping session resume — sessions are per-project.`
);
} else {
const resumeProjectPath = storedProjectPath ?? request.cwd;
const projectId = encodePath(resumeProjectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
}
}
}
} catch {
logger.debug(
`[${request.teamName}] Failed to extract leadSessionId from config for resume`
);
}
}
// IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json.
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
// Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path
// even if provisioning is interrupted or the user stops the team early.
// If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath).
await this.updateConfigProjectPath(request.teamName, request.cwd);
let claudePath: string | null;
try {
await ensureCwdExists(request.cwd);
claudePath = await ClaudeBinaryResolver.resolve();
if (!claudePath) {
throw new Error('Claude CLI not found; install it or provide a valid path');
}
} catch (error) {
// Restore pre-launch backup so config.json is not left in normalized (lead-only) state
await this.restorePrelaunchConfig(request.teamName);
throw error;
}
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
const runId = randomUUID();
const startedAt = nowIso();
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
const syntheticRequest: TeamCreateRequest = {
teamName: request.teamName,
members: expectedMemberSpecs,
cwd: request.cwd,
};
const run: ProvisioningRun = {
runId,
teamName: request.teamName,
startedAt,
stdoutBuffer: '',
stderrBuffer: '',
processKilled: false,
finalizingByTimeout: false,
cancelRequested: false,
teamsBasePathsToProbe,
child: null,
timeoutHandle: null,
fsMonitorHandle: null,
onProgress,
expectedMembers,
request: syntheticRequest,
lastLogProgressAt: 0,
waitingTasksSince: null,
provisioningComplete: false,
isLaunch: true,
fsPhase: 'waiting_members',
leadRelayCapture: null,
directReplyParts: [],
provisioningOutputParts: [],
detectedSessionId: null,
leadActivityState: 'active',
authFailureRetried: false,
authRetryInProgress: false,
spawnContext: null,
progress: {
runId,
teamName: request.teamName,
state: 'validating',
message:
source === 'members-meta'
? 'Validating team launch request (members from members.meta.json)'
: source === 'inboxes'
? 'Validating team launch request (members from inboxes)'
: 'Validating team launch request (fallback members from config.json)',
startedAt,
updatedAt: startedAt,
warnings: warning ? [warning] : undefined,
cliLogsTail: undefined,
},
};
this.runs.set(runId, run);
this.activeByTeam.set(request.teamName, runId);
run.onProgress(run.progress);
// Read existing tasks to include in teammate prompts for work resumption
const taskReader = new TeamTaskReader();
let existingTasks: TeamTask[] = [];
try {
existingTasks = await taskReader.getTasks(request.teamName);
} catch (error) {
logger.warn(`[${request.teamName}] Failed to read tasks for launch prompt: ${String(error)}`);
}
const prompt = buildLaunchPrompt(request, expectedMemberSpecs, existingTasks);
let child: ReturnType<typeof spawn>;
const { env: shellEnv, authSource } = await this.buildProvisioningEnv();
if (authSource === 'none') {
logger.warn(
'No explicit auth env var found for `-p` mode (launch). ' +
'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.'
);
}
const launchArgs = [
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
'--setting-sources',
'user,project,local',
'--disallowedTools',
'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
];
if (previousSessionId) {
launchArgs.push('--resume', previousSessionId);
logger.info(
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
);
}
if (request.model) {
launchArgs.push('--model', request.model);
}
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say
// --resume is for existing sessions and may show an interactive picker if not found.
try {
child = spawnCli(claudePath, launchArgs, {
cwd: request.cwd,
env: {
...shellEnv,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (error) {
this.runs.delete(runId);
this.activeByTeam.delete(request.teamName);
await this.restorePrelaunchConfig(request.teamName);
throw error;
}
const resumeHint = previousSessionId ? ' (resuming previous session)' : '';
updateProgress(run, 'spawning', `Starting Claude CLI process for team launch${resumeHint}`, {
pid: child.pid ?? undefined,
});
run.onProgress(run.progress);
run.child = child;
run.spawnContext = {
claudePath,
args: launchArgs,
cwd: request.cwd,
env: { ...shellEnv },
prompt,
};
// Send launch prompt
if (child.stdin?.writable) {
const message = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: prompt }],
},
});
child.stdin.write(message + '\n');
}
this.attachStdoutHandler(run);
this.attachStderrHandler(run);
// For launch, skip the filesystem monitor — files (config, inboxes, tasks)
// already exist from the previous run and would trigger immediate false
// completion on the first poll. Rely on stream-json result.success instead.
updateProgress(run, 'monitoring', 'CLI running — reconnecting with teammates');
run.onProgress(run.progress);
run.timeoutHandle = setTimeout(() => {
if (!run.processKilled && !run.provisioningComplete) {
run.processKilled = true;
run.finalizingByTimeout = true;
void (async () => {
const readyOnTimeout = await this.tryCompleteAfterTimeout(run);
run.child?.stdin?.end();
killProcessTree(run.child);
if (readyOnTimeout) {
return;
}
const progress = updateProgress(run, 'failed', 'Timed out waiting for CLI (launch)', {
error: 'Timed out waiting for CLI during team launch.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
})();
}
}, RUN_TIMEOUT_MS);
child.once('error', (error) => {
const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI (launch)', {
error: error.message,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
});
child.once('exit', (code) => {
void this.handleProcessExit(run, code);
});
return { runId };
}
async getProvisioningStatus(runId: string): Promise<TeamProvisioningProgress> {
const run = this.runs.get(runId);
if (!run) {
throw new Error('Unknown runId');
}
return run.progress;
}
async cancelProvisioning(runId: string): Promise<void> {
const run = this.runs.get(runId);
if (!run) {
throw new Error('Unknown runId');
}
if (!['spawning', 'monitoring', 'verifying'].includes(run.progress.state)) {
throw new Error('Provisioning cannot be cancelled in current state');
}
run.cancelRequested = true;
run.processKilled = true;
run.child?.stdin?.end();
killProcessTree(run.child);
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
run.onProgress(progress);
this.cleanupRun(run);
}
/**
* Send a message to the team's lead process via stream-json stdin.
* The lead will receive it as a new user turn and can delegate to teammates.
*/
async sendMessageToTeam(
teamName: string,
message: string,
attachments?: { data: string; mimeType: string }[]
): Promise<void> {
const runId = this.activeByTeam.get(teamName);
if (!runId) {
throw new Error(`No active process for team "${teamName}"`);
}
const run = this.runs.get(runId);
if (!run?.child?.stdin?.writable) {
throw new Error(`Team "${teamName}" process stdin is not writable`);
}
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (attachments?.length) {
for (const att of attachments) {
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: att.mimeType,
data: att.data,
},
});
}
}
const payload = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: contentBlocks,
},
});
run.child.stdin.write(payload + '\n');
this.setLeadActivity(run, 'active');
}
/**
* Relay unread inbox messages addressed to the team lead into the live lead process.
*
* Why: teammates (and the UI) write to `inboxes/<lead>.json`, but the live lead CLI
* process consumes new turns via stream-json stdin. Without relaying, the lead
* appears unresponsive to direct messages.
*
* Returns the number of messages relayed.
*/
async relayLeadInboxMessages(teamName: string): Promise<number> {
const existing = this.leadInboxRelayInFlight.get(teamName);
if (existing) {
return existing;
}
const work = (async (): Promise<number> => {
const runId = this.activeByTeam.get(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<string>();
const relayedFallback = this.relayedLeadInboxFallbackKeys.get(teamName) ?? new Set<string>();
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null;
try {
config = await this.configReader.getConfig(teamName);
} catch {
return 0;
}
if (!config) return 0;
const leadName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
try {
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
} catch {
return 0;
}
const unread = leadInboxMessages
.filter((m) => {
if (m.read) return false;
if (typeof m.text !== 'string' || m.text.trim().length === 0) return false;
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
return !relayedIds.has(m.messageId);
}
return !relayedFallback.has(`${m.timestamp}\0${m.from}\0${m.text}`);
})
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
if (unread.length === 0) return 0;
const MAX_RELAY = 10;
const batch = unread.slice(0, MAX_RELAY);
const message = [
`You have new inbox messages addressed to you (team lead "${leadName}").`,
`Process them in order (oldest first).`,
`If action is required, delegate via task creation or SendMessage, and keep responses minimal.`,
`IMPORTANT: Your text response here is shown to the user. Always include a brief human-readable summary (e.g. "Delegated to carol." or "No action needed."). Do NOT respond with only an agent-only block.`,
AGENT_BLOCK_OPEN,
`Internal note: for task assignments, prefer teamctl.js task create --notify (avoid sending a separate SendMessage for the same assignment).`,
AGENT_BLOCK_CLOSE,
``,
`Messages:`,
...batch.flatMap((m, idx) => {
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
return [
`${idx + 1}) From: ${m.from || 'unknown'}`,
` Timestamp: ${m.timestamp}`,
...(summaryLine ? [` ${summaryLine}`] : []),
` Text:`,
...m.text.split('\n').map((line) => ` ${line}`),
``,
];
}),
].join('\n');
const captureTimeoutMs = 15_000;
const captureIdleMs = 800;
const capturePromise = new Promise<string>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
reject(new Error('Timed out waiting for lead reply'));
}, captureTimeoutMs);
const capture = {
leadName,
startedAt: nowIso(),
textParts: [] as string[],
settled: false,
idleHandle: null as NodeJS.Timeout | null,
idleMs: captureIdleMs,
timeoutHandle,
resolveOnce: (text: string) => {
if (capture.settled) return;
capture.settled = true;
if (capture.idleHandle) {
clearTimeout(capture.idleHandle);
capture.idleHandle = null;
}
clearTimeout(capture.timeoutHandle);
resolve(text);
},
rejectOnce: (error: string) => {
if (capture.settled) return;
capture.settled = true;
if (capture.idleHandle) {
clearTimeout(capture.idleHandle);
capture.idleHandle = null;
}
clearTimeout(capture.timeoutHandle);
reject(new Error(error));
},
};
run.leadRelayCapture = capture;
// Clear any direct reply parts — relay capture takes priority
run.directReplyParts = [];
});
try {
await this.sendMessageToTeam(teamName, message);
} catch {
if (run.leadRelayCapture) {
clearTimeout(run.leadRelayCapture.timeoutHandle);
run.leadRelayCapture = null;
}
return 0;
}
for (const m of batch) {
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
relayedIds.add(m.messageId);
} else {
relayedFallback.add(`${m.timestamp}\0${m.from}\0${m.text}`);
}
}
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
this.relayedLeadInboxFallbackKeys.set(teamName, this.trimRelayedSet(relayedFallback));
try {
await this.markInboxMessagesRead(teamName, leadName, batch);
} catch {
// Best-effort: relay succeeded; marking read failed.
}
let replyText: string | null = null;
try {
replyText = (await capturePromise).trim() || null;
} catch {
// Best-effort: if we captured some text but never got result.success, keep it.
const partial = run.leadRelayCapture?.textParts?.join('')?.trim();
replyText = partial && partial.length > 0 ? partial : null;
} finally {
if (run.leadRelayCapture) {
if (run.leadRelayCapture.idleHandle) {
clearTimeout(run.leadRelayCapture.idleHandle);
run.leadRelayCapture.idleHandle = null;
}
clearTimeout(run.leadRelayCapture.timeoutHandle);
run.leadRelayCapture = null;
}
}
// Strip agent-only blocks — lead may respond with pure coordination content
// that is not meant for the human user.
const cleanReply = replyText ? stripAgentBlocks(replyText) : null;
if (cleanReply) {
this.pushLiveLeadProcessMessage(teamName, {
from: leadName,
to: 'user',
text: cleanReply,
timestamp: nowIso(),
read: true,
summary: cleanReply.length > 60 ? cleanReply.slice(0, 57) + '...' : cleanReply,
messageId: `lead-process-${runId}-${Date.now()}`,
source: 'lead_process',
});
this.teamChangeEmitter?.({
type: 'inbox',
teamName,
detail: 'lead-process-reply',
});
}
return batch.length;
})();
this.leadInboxRelayInFlight.set(teamName, work);
try {
return await work;
} finally {
if (this.leadInboxRelayInFlight.get(teamName) === work) {
this.leadInboxRelayInFlight.delete(teamName);
}
}
}
/**
* Check if a team has an active provisioning run (started but not yet finished).
*/
hasProvisioningRun(teamName: string): boolean {
return this.activeByTeam.has(teamName);
}
/**
* Check if a team has a live process.
*/
isTeamAlive(teamName: string): boolean {
const runId = this.activeByTeam.get(teamName);
if (!runId) return false;
const run = this.runs.get(runId);
return run?.child != null && !run.processKilled && !run.cancelRequested;
}
/**
* Get list of teams with active processes.
*/
getAliveTeams(): string[] {
return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name));
}
private languageChangeInFlight: Promise<void> = Promise.resolve();
/**
* Notify alive teams when the agent language setting changes.
* Compares each team's stored `config.language` with the new code and sends
* a message to the team lead if they differ.
*
* Serialised: rapid language switches (e.g. ru → en → ru) are queued so that
* only the latest value is applied to each team.
*/
async notifyLanguageChange(newLangCode: string): Promise<void> {
this.languageChangeInFlight = this.languageChangeInFlight.then(() =>
this.doNotifyLanguageChange(newLangCode)
);
return this.languageChangeInFlight;
}
private async doNotifyLanguageChange(newLangCode: string): Promise<void> {
const aliveTeams = this.getAliveTeams();
if (aliveTeams.length === 0) return;
const systemLocale = getSystemLocale();
const newResolved = resolveLanguageName(newLangCode, systemLocale);
for (const teamName of aliveTeams) {
try {
const config = await this.configReader.getConfig(teamName);
if (!config) continue;
const oldCode = config.language || 'system';
if (oldCode === newLangCode) continue;
// Compare resolved names to avoid spurious notifications
// e.g. switching from 'ru' to 'system' when system locale is Russian
const oldResolved = resolveLanguageName(oldCode, systemLocale);
if (oldResolved === newResolved) {
// Effective language unchanged — just update stored code silently
await this.configReader.updateConfig(teamName, { language: newLangCode });
continue;
}
const message =
`The user has changed the preferred communication language from "${oldResolved}" to "${newResolved}". ` +
`Please switch to ${newResolved} for all future responses and broadcast this change to all teammates ` +
`so they also switch to ${newResolved}.`;
await this.sendMessageToTeam(teamName, message);
await this.configReader.updateConfig(teamName, { language: newLangCode });
logger.info(`[${teamName}] Notified about language change: ${oldCode}${newLangCode}`);
} catch (error) {
logger.warn(
`[${teamName}] Failed to notify language change: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
}
private async markInboxMessagesRead(
teamName: string,
member: string,
messages: { messageId?: string; timestamp: string; from: string; text: string }[]
): Promise<void> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
await withInboxLock(inboxPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(inboxPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return;
}
if (!Array.isArray(parsed)) return;
const ids = new Set(messages.map((m) => m.messageId).filter((id): id is string => !!id));
const fallbackKeys = new Set(
messages.filter((m) => !m.messageId).map((m) => `${m.timestamp}\0${m.from}\0${m.text}`)
);
let changed = false;
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const msgId = typeof row.messageId === 'string' ? row.messageId : null;
const timestamp = typeof row.timestamp === 'string' ? row.timestamp : null;
const from = typeof row.from === 'string' ? row.from : null;
const text = typeof row.text === 'string' ? row.text : null;
const matchesId = msgId ? ids.has(msgId) : false;
const matchesFallback =
!msgId && timestamp && from && text
? fallbackKeys.has(`${timestamp}\0${from}\0${text}`)
: false;
if (!matchesId && !matchesFallback) continue;
if (row.read !== true) {
row.read = true;
changed = true;
}
}
if (!changed) return;
await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2));
});
}
private trimRelayedSet(set: Set<string>): Set<string> {
const MAX_IDS = 2000;
if (set.size <= MAX_IDS) return set;
const next = new Set<string>();
const tail = Array.from(set).slice(-MAX_IDS);
for (const id of tail) next.add(id);
return next;
}
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
const MAX = 100;
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
list.push(message);
if (list.length > MAX) {
list.splice(0, list.length - MAX);
}
this.liveLeadProcessMessages.set(teamName, list);
}
/**
* Stop the running process for a team. No-op if team is not running.
*/
stopTeam(teamName: string): void {
const runId = this.activeByTeam.get(teamName);
if (!runId) {
return;
}
const run = this.runs.get(runId);
if (!run) {
this.activeByTeam.delete(teamName);
return;
}
if (run.processKilled || run.cancelRequested) {
return;
}
run.processKilled = true;
run.cancelRequested = true;
run.child?.stdin?.end();
killProcessTree(run.child);
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
run.onProgress(progress);
this.cleanupRun(run);
logger.info(`[${teamName}] Process stopped by user`);
}
/**
* Process a parsed stream-json message from stdout.
* Extracts assistant text for progress reporting and detects turn completion.
*/
private handleStreamJsonMessage(run: ProvisioningRun, msg: Record<string, unknown>): void {
// stream-json output has various message types:
// {"type":"assistant","content":[{"type":"text","text":"..."},...]}
// {"type":"result","subtype":"success",...}
if (msg.type === 'assistant') {
const content = Array.isArray(msg.content)
? (msg.content as Record<string, unknown>[])
: (() => {
const message = msg.message;
if (!message || typeof message !== 'object') return null;
const inner = (message as Record<string, unknown>).content;
return Array.isArray(inner) ? (inner as Record<string, unknown>[]) : null;
})();
const textParts = (content ?? [])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string);
if (textParts.length > 0) {
const text = textParts.join('');
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
// During provisioning (before provisioningComplete), accumulate for live UI preview.
// Emission is handled by the throttled emitLogsProgress() in the stdout data handler.
if (!run.provisioningComplete) {
run.provisioningOutputParts.push(text);
}
if (run.leadRelayCapture) {
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('').trim();
capture.resolveOnce(combined);
}, capture.idleMs);
}
} else if (run.provisioningComplete) {
// Accumulate assistant text for direct user→lead messages (no relay capture).
run.directReplyParts.push(text);
}
}
}
// Capture session_id from any message type (first occurrence wins)
if (!run.detectedSessionId) {
const sid = typeof msg.session_id === 'string' ? msg.session_id : undefined;
if (sid && sid.trim().length > 0) {
run.detectedSessionId = sid.trim();
logger.info(
`[${run.teamName}] Detected session ID from stream-json: ${run.detectedSessionId}`
);
}
}
if (msg.type === 'result') {
const subtype =
typeof msg.subtype === 'string'
? msg.subtype
: (() => {
const result = msg.result;
if (!result || typeof result !== 'object') return undefined;
const inner = (result as Record<string, unknown>).subtype;
return typeof inner === 'string' ? inner : undefined;
})();
if (subtype === 'success') {
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
if (run.provisioningComplete) {
this.setLeadActivity(run, 'idle');
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
const combined = capture.textParts.join('').trim();
capture.resolveOnce(combined);
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
// Flush accumulated assistant reply from direct user→lead message
const rawReply = run.directReplyParts.join('').trim();
run.directReplyParts = [];
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
// Strip agent-only blocks — lead may include coordination content not meant for the user
const replyText = stripAgentBlocks(rawReply);
if (replyText.length > 0) {
const replyMsg: InboxMessage = {
from: leadName,
to: 'user',
text: replyText,
timestamp: nowIso(),
read: true,
summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText,
messageId: `lead-direct-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
// Persist to disk so replies survive app restart
void this.sentMessagesStore
.appendMessage(run.teamName, replyMsg)
.catch(() => undefined);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'lead-direct-reply',
});
}
}
if (!run.provisioningComplete && !run.cancelRequested) {
void this.handleProvisioningTurnComplete(run);
}
} else if (subtype === 'error') {
const errorMsg =
typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown');
logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`);
if (run.leadRelayCapture) {
run.leadRelayCapture.rejectOnce(errorMsg);
}
if (!run.provisioningComplete && !run.cancelRequested) {
const progress = updateProgress(
run,
'failed',
'CLI reported an error during provisioning',
{
error: errorMsg,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
}
);
run.onProgress(progress);
// Kill the process on provisioning error
run.processKilled = true;
run.child?.stdin?.end();
killProcessTree(run.child);
this.cleanupRun(run);
} else if (run.provisioningComplete) {
// Post-provisioning error: process alive, waiting for input
this.setLeadActivity(run, 'idle');
}
}
}
}
/**
* Called when the first stream-json turn completes successfully.
* Verifies provisioning files exist and marks as ready.
* Process stays alive for subsequent tasks.
*/
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
// Guard: must be set synchronously BEFORE any await to prevent
// double-invocation from filesystem monitor + stream-json racing.
if (run.provisioningComplete || run.cancelRequested) return;
run.provisioningComplete = true;
this.setLeadActivity(run, 'idle');
// Clear provisioning timeout — no longer needed
if (run.timeoutHandle) {
clearTimeout(run.timeoutHandle);
run.timeoutHandle = null;
}
this.stopFilesystemMonitor(run);
if (run.isLaunch) {
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
await this.cleanupPrelaunchBackup(run.teamName);
const readyMessage = 'Team launched — process alive and ready';
const progress = updateProgress(run, 'ready', readyMessage, {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
// Pick up any direct messages that arrived before/while reconnecting.
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
return;
}
// Quick verification: config should exist by now
const configProbe = await this.waitForValidConfig(run, 5000);
if (!configProbe.ok) {
logger.warn(
`[${run.teamName}] Provisioning turn completed but no config.json found — marking ready anyway`
);
}
if (configProbe.ok && configProbe.location === 'default') {
const configuredTeamsBasePath = getTeamsBasePath();
const progress = updateProgress(run, 'failed', 'Provisioning failed validation', {
error:
`TeamCreate produced config.json under a different Claude root (${configProbe.configPath}). ` +
`This app is configured to read teams from ${configuredTeamsBasePath}. ` +
'Align the app Claude root setting with the CLI, then retry.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
run.processKilled = true;
run.child?.stdin?.end();
killProcessTree(run.child);
this.cleanupRun(run);
return;
}
// Persist teammates metadata separately from config.json.
await this.persistMembersMeta(run.teamName, run.request);
await this.updateConfigPostLaunch(run.teamName, run.request.cwd, run.detectedSessionId);
const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
// NOTE: do NOT remove from activeByTeam — process stays alive
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
// Pick up any direct messages that arrived during provisioning.
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
}
/**
* Remove a run from tracking maps.
*/
private cleanupRun(run: ProvisioningRun): void {
this.setLeadActivity(run, 'offline');
if (run.timeoutHandle) {
clearTimeout(run.timeoutHandle);
run.timeoutHandle = null;
}
this.stopFilesystemMonitor(run);
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
if (run.child) {
run.child.stdout?.removeAllListeners('data');
run.child.stderr?.removeAllListeners('data');
}
this.activeByTeam.delete(run.teamName);
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
this.liveLeadProcessMessages.delete(run.teamName);
}
/**
* Polls the filesystem to track provisioning progress in real time.
* Emits progress updates as team files appear (config, inboxes, tasks).
*/
private startFilesystemMonitor(run: ProvisioningRun, request: TeamCreateRequest): void {
const configuredTeamDir = path.join(getTeamsBasePath(), run.teamName);
const defaultTeamDir = path.join(getAutoDetectedClaudeBasePath(), 'teams', run.teamName);
const tasksDir = path.join(getTasksBasePath(), run.teamName);
const resolveTeamDir = async (): Promise<string | null> => {
const configPath = path.join(configuredTeamDir, 'config.json');
try {
await fs.promises.access(configPath, fs.constants.F_OK);
return configuredTeamDir;
} catch {
// fallback to default location
}
if (path.resolve(configuredTeamDir) !== path.resolve(defaultTeamDir)) {
const defaultConfigPath = path.join(defaultTeamDir, 'config.json');
try {
await fs.promises.access(defaultConfigPath, fs.constants.F_OK);
return defaultTeamDir;
} catch {
// not found in either location
}
}
return null;
};
const countFiles = async (dir: string, ext: string): Promise<number> => {
try {
const entries = await fs.promises.readdir(dir);
return entries.filter((e) => e.endsWith(ext) && !e.startsWith('.')).length;
} catch {
return 0;
}
};
const poll = async (): Promise<void> => {
if (run.cancelRequested || run.processKilled || run.progress.state === 'ready') {
return;
}
try {
if (run.fsPhase === 'waiting_config') {
const teamDir = await resolveTeamDir();
if (teamDir) {
run.fsPhase = 'waiting_members';
const progress = updateProgress(
run,
'monitoring',
'Team config created, waiting for members'
);
run.onProgress(progress);
}
}
if (run.fsPhase === 'waiting_members') {
const teamDir = (await resolveTeamDir()) ?? configuredTeamDir;
const inboxDir = path.join(teamDir, 'inboxes');
const inboxCount = await countFiles(inboxDir, '.json');
if (inboxCount >= request.members.length) {
run.fsPhase = 'waiting_tasks';
const progress = updateProgress(
run,
'monitoring',
`All ${inboxCount} member inboxes created, waiting for tasks`
);
run.onProgress(progress);
} else if (inboxCount > 0) {
const progress = updateProgress(
run,
'monitoring',
`${inboxCount}/${request.members.length} member inboxes created`
);
run.onProgress(progress);
}
}
if (run.fsPhase === 'waiting_tasks') {
if (run.waitingTasksSince === null) {
run.waitingTasksSince = Date.now();
}
const taskCount = await countFiles(tasksDir, '.json');
const taskFound = taskCount > 0;
const taskFallbackExpired =
!taskFound && Date.now() - run.waitingTasksSince >= TASK_WAIT_FALLBACK_MS;
if (taskFound || taskFallbackExpired) {
run.fsPhase = 'all_files_found';
// Mark provisioning complete early — files are on disk,
// no need to wait for stream-json result.success.
// The process stays alive for subsequent tasks.
if (!run.provisioningComplete) {
void this.handleProvisioningTurnComplete(run);
}
}
}
} catch (error) {
logger.debug(
`FS monitor poll error: ${error instanceof Error ? error.message : String(error)}`
);
}
};
run.fsMonitorHandle = setInterval(() => {
void poll();
}, FS_MONITOR_POLL_MS);
// Run first poll immediately
void poll();
}
private stopFilesystemMonitor(run: ProvisioningRun): void {
if (run.fsMonitorHandle) {
clearInterval(run.fsMonitorHandle);
run.fsMonitorHandle = null;
}
}
private async handleProcessExit(run: ProvisioningRun, code: number | null): Promise<void> {
if (run.finalizingByTimeout) {
return;
}
if (run.progress.state === 'failed' || run.cancelRequested) {
return;
}
// Skip if respawn after auth failure is in progress — the old process is being replaced
if (run.authRetryInProgress) {
logger.info(
`[${run.teamName}] Process exited (code ${code ?? '?'}) during auth-failure respawn — ignoring`
);
return;
}
// === Process exited AFTER provisioning completed ===
// This means the team went offline (crash, kill, or natural exit).
if (run.provisioningComplete) {
const message =
code === 0
? 'Team process exited normally'
: `Team process exited unexpectedly (code ${code ?? 'unknown'})`;
logger.info(`[${run.teamName}] ${message}`);
const progress = updateProgress(run, 'disconnected', message, {
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
return;
}
// === Process exited DURING provisioning ===
// Try to verify if files were created before the process died.
updateProgress(run, 'verifying', 'Process exited — verifying provisioning results');
run.onProgress(run.progress);
if (run.cancelRequested) {
return;
}
const configProbe = await this.waitForValidConfig(run);
if (run.cancelRequested) {
return;
}
if (configProbe.ok && configProbe.location === 'default') {
const configuredTeamsBasePath = getTeamsBasePath();
const progress = updateProgress(run, 'failed', 'Provisioning failed validation', {
error:
`TeamCreate produced config.json under a different Claude root (${configProbe.configPath}). ` +
`This app is configured to read teams from ${configuredTeamsBasePath}. ` +
'Align the app Claude root setting with the CLI, then retry.',
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
return;
}
const visibleInList =
configProbe.ok && configProbe.location === 'configured'
? await this.waitForTeamInList(run.teamName, run)
: false;
if (run.cancelRequested) {
return;
}
if (configProbe.ok && visibleInList) {
// Files exist but process died — provisioned but not alive.
const warnings: string[] = [
`CLI process exited (code ${code ?? 'unknown'}) — team provisioned but not alive`,
];
const missingInboxes = await this.waitForMissingInboxes(run);
if (run.cancelRequested) {
return;
}
if (missingInboxes.length > 0) {
warnings.push('Some inboxes not created yet');
}
if (!run.isLaunch) {
await this.persistMembersMeta(run.teamName, run.request);
}
// Mark as disconnected since the process is dead
const progress = updateProgress(
run,
'disconnected',
'Team provisioned but process is no longer alive',
{
warnings,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
}
);
run.onProgress(progress);
this.cleanupRun(run);
return;
}
if (code === 0) {
const configuredConfigPath = path.join(getTeamsBasePath(), run.teamName, 'config.json');
const defaultTeamsBasePath = path.join(getAutoDetectedClaudeBasePath(), 'teams');
const defaultConfigPath = path.join(defaultTeamsBasePath, run.teamName, 'config.json');
const combinedLogs = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer);
const cleanupHint = logsSuggestShutdownOrCleanup(combinedLogs)
? ' CLI output suggests the team was shut down / cleaned up, so no persisted config was left on disk.'
: '';
const errorMessage = !configProbe.ok
? `No valid config.json found at ${configuredConfigPath}${
path.resolve(defaultTeamsBasePath) === path.resolve(getTeamsBasePath())
? ''
: ` (also checked ${defaultConfigPath})`
} within ${Math.round(VERIFY_TIMEOUT_MS / 1000)}s.${cleanupHint}`
: 'Team did not appear in team:list after provisioning';
const progress = updateProgress(run, 'failed', 'Provisioning failed validation', {
error: errorMessage,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
return;
}
const errorText = buildCliExitError(code, run.stdoutBuffer, run.stderrBuffer);
const progress = updateProgress(run, 'failed', 'Claude CLI exited with an error', {
error: errorText,
cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer),
});
run.onProgress(progress);
this.cleanupRun(run);
logger.warn(`Provisioning failed for ${run.teamName}: ${progress.error ?? errorText}`);
}
private async waitForValidConfig(
run: ProvisioningRun,
timeoutMs: number = VERIFY_TIMEOUT_MS
): Promise<ValidConfigProbeResult> {
const probes = run.teamsBasePathsToProbe.map((probe) => ({
...probe,
configPath: path.join(probe.basePath, run.teamName, 'config.json'),
}));
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (run.cancelRequested) {
return { ok: false };
}
for (const probe of probes) {
try {
const raw = await fs.promises.readFile(probe.configPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object') {
const candidate = parsed as { name?: unknown };
if (typeof candidate.name === 'string' && candidate.name.trim().length > 0) {
return { ok: true, location: probe.location, configPath: probe.configPath };
}
}
} catch {
// Best-effort polling until deadline.
}
}
await sleep(VERIFY_POLL_MS);
}
return { ok: false };
}
private async waitForTeamInList(teamName: string, run?: ProvisioningRun): Promise<boolean> {
const deadline = Date.now() + VERIFY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (run?.cancelRequested) {
return false;
}
try {
const teams = await this.configReader.listTeams();
if (teams.some((team) => team.teamName === teamName)) {
return true;
}
} catch {
// Keep polling until deadline.
}
await sleep(VERIFY_POLL_MS);
}
return false;
}
private async waitForMissingInboxes(run: ProvisioningRun): Promise<string[]> {
if (run.expectedMembers.length === 0) {
return [];
}
const inboxDir = path.join(getTeamsBasePath(), run.teamName, 'inboxes');
const deadline = Date.now() + VERIFY_TIMEOUT_MS;
let missing = new Set(run.expectedMembers);
while (Date.now() < deadline && missing.size > 0) {
if (run.cancelRequested || run.progress.state === 'cancelled') {
return Array.from(missing);
}
const nextMissing = new Set<string>();
for (const member of missing) {
const inboxPath = path.join(inboxDir, `${member}.json`);
if (!(await this.pathExists(inboxPath))) {
nextMissing.add(member);
}
}
missing = nextMissing;
if (missing.size === 0) {
break;
}
await sleep(VERIFY_POLL_MS);
}
return Array.from(missing);
}
private async tryCompleteAfterTimeout(run: ProvisioningRun): Promise<boolean> {
if (run.cancelRequested) {
return false;
}
const configProbe = await this.waitForValidConfig(run);
if (!configProbe.ok || configProbe.location !== 'configured') {
return false;
}
const visibleInList = await this.waitForTeamInList(run.teamName);
if (!visibleInList) {
return false;
}
const warnings: string[] = [
'CLI timed out after config was created — team provisioned but process killed',
];
const missingInboxes = await this.waitForMissingInboxes(run);
if (run.cancelRequested) {
return false;
}
if (missingInboxes.length > 0) {
warnings.push('Some inboxes not created yet');
}
if (!run.isLaunch) {
await this.persistMembersMeta(run.teamName, run.request);
}
// Process was killed by timeout — mark as disconnected, not ready
const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', {
warnings,
});
run.onProgress(progress);
this.cleanupRun(run);
return true;
}
private async pathExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
private async buildProvisioningEnv(): Promise<ProvisioningEnvResolution> {
const shellEnv = await resolveInteractiveShellEnv();
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
// correctly on Windows. Prefer it over process.env which may be garbled.
const electronHome = getHomeDir();
const isWindows = process.platform === 'win32';
const home = shellEnv.HOME?.trim() || electronHome;
let osUsername = '';
try {
osUsername = os.userInfo().username;
} catch {
// os.userInfo() can throw SystemError in restricted environments (no passwd entry, Docker, etc.)
}
const user =
shellEnv.USER?.trim() ||
process.env.USER?.trim() ||
process.env.USERNAME?.trim() ||
osUsername ||
'unknown';
// Shell: on Windows there is no SHELL env var; use COMSPEC (cmd.exe / powershell).
// On Unix, prefer the user's login shell from env or fall back to /bin/zsh.
const shell = isWindows
? (process.env.COMSPEC ?? 'powershell.exe')
: shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
const env: NodeJS.ProcessEnv = {
...process.env,
...shellEnv,
HOME: home,
USERPROFILE: home,
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(),
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
};
// SHELL is a Unix concept — only set it on non-Windows platforms.
if (!isWindows) {
env.SHELL = shell;
}
// XDG directories are a freedesktop.org (Linux/macOS) convention.
// On Windows, these are unused by most tools and can cause confusion.
if (!isWindows) {
const xdgConfigHome =
shellEnv.XDG_CONFIG_HOME?.trim() ||
process.env.XDG_CONFIG_HOME?.trim() ||
`${home}/.config`;
const xdgStateHome =
shellEnv.XDG_STATE_HOME?.trim() ||
process.env.XDG_STATE_HOME?.trim() ||
`${home}/.local/state`;
env.XDG_CONFIG_HOME = xdgConfigHome;
env.XDG_STATE_HOME = xdgStateHome;
}
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
return { env, authSource: 'anthropic_api_key' };
}
// 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var,
// so we must copy it into ANTHROPIC_API_KEY for it to work.
if (
typeof env.ANTHROPIC_AUTH_TOKEN === 'string' &&
env.ANTHROPIC_AUTH_TOKEN.trim().length > 0
) {
env.ANTHROPIC_API_KEY = env.ANTHROPIC_AUTH_TOKEN;
return { env, authSource: 'anthropic_auth_token' };
}
// 3. CLAUDE_CODE_OAUTH_TOKEN already in env (e.g. from `claude setup-token`)
if (
typeof env.CLAUDE_CODE_OAUTH_TOKEN === 'string' &&
env.CLAUDE_CODE_OAUTH_TOKEN.trim().length > 0
) {
return { env, authSource: 'claude_code_oauth_token_env' };
}
// 4. Try reading OAuth token from platform credential storage.
// macOS: Keychain (service "Claude Code-credentials")
// Linux: ~/.claude/.credentials.json
// Note: keychain tokens may be stale — Claude Code refreshes in-memory
// but does not always write back. We still try as best-effort.
const oauthToken = await this.readOAuthTokenFromStorage(home);
if (oauthToken) {
env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
return { env, authSource: 'claude_code_oauth_token_credentials' };
}
return { env, authSource: 'none' };
}
/**
* Attempts to read the OAuth access token from platform-specific storage.
*
* On macOS: reads from the encrypted Keychain (service "Claude Code-credentials").
* On Linux: reads from ~/.claude/.credentials.json.
*
* Warning: the token retrieved here may be expired. Claude Code refreshes
* tokens in-memory but does not always persist the refreshed value back to
* the credential store. A subsequent preflight check (`claude -p "ping"`)
* will detect if the token is actually usable.
*/
private async readOAuthTokenFromStorage(home: string): Promise<string | null> {
const claudeBasePath = getClaudeBasePath();
if (process.platform === 'darwin') {
const keychainToken = await this.readOAuthTokenFromKeychain();
if (keychainToken) {
return keychainToken;
}
// Fallback: ~/.claude/.credentials.json (or overridden Claude root)
return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home);
}
return this.readOAuthTokenFromCredentialsFile(claudeBasePath, home);
}
private async readOAuthTokenFromKeychain(): Promise<string | null> {
try {
const { stdout } = await execFileAsync(
'security',
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
{ timeout: KEYCHAIN_TIMEOUT_MS }
);
const parsed = JSON.parse(stdout.trim()) as unknown;
return this.extractOAuthAccessToken(parsed);
} catch {
return null;
}
}
private async readOAuthTokenFromCredentialsFile(
claudeBasePath: string,
homeFallback: string
): Promise<string | null> {
// Preferred: current Claude root (supports claudeRootPath override)
const primaryPath = path.join(claudeBasePath, '.credentials.json');
// Back-compat: legacy location under HOME
const legacyPath = path.join(homeFallback, '.claude', '.credentials.json');
try {
const raw = await fs.promises.readFile(primaryPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return this.extractOAuthAccessToken(parsed);
} catch {
try {
const raw = await fs.promises.readFile(legacyPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return this.extractOAuthAccessToken(parsed);
} catch {
return null;
}
}
}
private extractOAuthAccessToken(parsed: unknown): string | null {
if (!parsed || typeof parsed !== 'object') {
return null;
}
const root = parsed as { claudeAiOauth?: unknown };
if (!root.claudeAiOauth || typeof root.claudeAiOauth !== 'object') {
return null;
}
const oauth = root.claudeAiOauth as { accessToken?: unknown };
if (typeof oauth.accessToken !== 'string') {
return null;
}
const token = oauth.accessToken.trim();
return token.length > 0 ? token : null;
}
/**
* Immediately update projectPath in config.json at launch start, before CLI spawn.
* Ensures TeamDetailView shows the correct project path even if provisioning
* is interrupted. On failure, restorePrelaunchConfig() reverts to the backup.
*/
private async updateConfigProjectPath(teamName: string, cwd: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>;
config.projectPath = cwd;
const pathHistory = Array.isArray(config.projectPathHistory)
? (config.projectPathHistory as string[]).filter((p) => typeof p === 'string' && p !== cwd)
: [];
pathHistory.push(cwd);
config.projectPathHistory = pathHistory.slice(-500);
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`);
} catch (error) {
// Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds.
logger.warn(
`[${teamName}] Failed to update projectPath early: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Single atomic read-mutate-write for post-launch config updates.
* Combines session history append and projectPath update to avoid
* race conditions with the CLI writing to the same file.
*/
private async updateConfigPostLaunch(
teamName: string,
projectPath: string,
detectedSessionId: string | null
): Promise<void> {
const MAX_SESSION_HISTORY = 5000;
const MAX_PROJECT_PATH_HISTORY = 500;
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>;
const sessionHistory = Array.isArray(config.sessionHistory)
? (config.sessionHistory as string[])
: [];
// Preserve old leadSessionId in history before overwriting
const oldLeadSessionId = config.leadSessionId;
if (typeof oldLeadSessionId === 'string' && oldLeadSessionId.trim().length > 0) {
if (!sessionHistory.includes(oldLeadSessionId)) {
sessionHistory.push(oldLeadSessionId);
}
}
// Update leadSessionId to the new session detected from stream-json
let newSessionId = detectedSessionId;
// Fallback: if stream-json didn't provide session_id, scan project dir for newest JSONL
if (!newSessionId && projectPath.trim()) {
const scannedId = await this.scanForNewestSession(projectPath, sessionHistory);
if (scannedId) {
newSessionId = scannedId;
logger.info(`[${teamName}] Detected new session via project dir scan: ${scannedId}`);
}
}
if (newSessionId) {
config.leadSessionId = newSessionId;
if (!sessionHistory.includes(newSessionId)) {
sessionHistory.push(newSessionId);
}
logger.info(`[${teamName}] Updated leadSessionId: ${newSessionId}`);
}
if (sessionHistory.length > MAX_SESSION_HISTORY) {
config.sessionHistory = sessionHistory.slice(-MAX_SESSION_HISTORY);
} else {
config.sessionHistory = sessionHistory;
}
// Save current language setting
const langCode = ConfigManager.getInstance().getConfig().general.agentLanguage || 'system';
config.language = langCode;
// Ensure projectPath
if (projectPath.trim()) {
config.projectPath = projectPath;
const pathHistory = Array.isArray(config.projectPathHistory)
? (config.projectPathHistory as string[]).filter(
(p) => typeof p === 'string' && p !== projectPath
)
: [];
pathHistory.push(projectPath);
config.projectPathHistory =
pathHistory.length > MAX_PROJECT_PATH_HISTORY
? pathHistory.slice(-MAX_PROJECT_PATH_HISTORY)
: pathHistory;
}
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
} catch (error) {
logger.warn(
`[${teamName}] Failed to update config post-launch: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Fallback: scan the project directory for the newest JSONL file
* that isn't already in sessionHistory. Returns the session ID or null.
*/
private async scanForNewestSession(
projectPath: string,
knownSessions: string[]
): Promise<string | null> {
try {
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const projectDir = path.join(getProjectsBasePath(), baseDir);
const entries = await fs.promises.readdir(projectDir);
const knownSet = new Set(knownSessions);
let newest: { id: string; mtime: number } | null = null;
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
if (knownSet.has(sessionId)) continue;
const filePath = path.join(projectDir, entry);
const stat = await fs.promises.stat(filePath);
if (!newest || stat.mtimeMs > newest.mtime) {
newest = { id: sessionId, mtime: stat.mtimeMs };
}
}
return newest?.id ?? null;
} catch {
return null;
}
}
private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
const backupPath = `${configPath}.prelaunch.bak`;
let parsed: unknown;
try {
parsed = JSON.parse(configRaw) as unknown;
} catch {
return;
}
if (!parsed || typeof parsed !== 'object') {
return;
}
const config = parsed as Record<string, unknown>;
const members = Array.isArray(config.members)
? (config.members as Record<string, unknown>[])
: [];
if (members.length === 0) {
return;
}
// Keep only the lead entry.
const leadMembers = members.filter((member) => {
const agentType = member.agentType;
if (typeof agentType === 'string' && agentType === 'team-lead') {
return true;
}
const leadAgentId = config.leadAgentId;
return (
typeof leadAgentId === 'string' &&
typeof member.agentId === 'string' &&
member.agentId === leadAgentId
);
});
// If already lead-only, no-op.
if (leadMembers.length === members.length) {
return;
}
// Try to determine base teammate names for inbox cleanup (prefer meta).
const baseNames = new Set<string>();
try {
const metaMembers = await this.membersMetaStore.getMembers(teamName);
for (const member of metaMembers) {
const name = member.name.trim();
if (name.length > 0) baseNames.add(name);
}
} catch {
// ignore
}
if (baseNames.size === 0) {
for (const member of members) {
const name = typeof member.name === 'string' ? member.name.trim() : '';
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
if (name && agentType && agentType !== 'team-lead' && !/-\d+$/.test(name)) {
baseNames.add(name);
}
}
}
// Backup current config on disk for crash recovery / debugging.
try {
await atomicWriteAsync(backupPath, configRaw);
} catch (error) {
logger.warn(
`[${teamName}] Failed to write config prelaunch backup: ${
error instanceof Error ? error.message : String(error)
}`
);
}
// Write normalized config atomically.
config.members = leadMembers;
try {
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
logger.info(
`[${teamName}] Normalized config.json for launch: kept ${leadMembers.length} lead member(s)`
);
} catch (error) {
logger.warn(
`[${teamName}] Failed to normalize config.json for launch: ${
error instanceof Error ? error.message : String(error)
}`
);
return;
}
// Best-effort: merge and remove suffixed inboxes like alice-2.json to avoid UI duplicates.
await this.mergeAndRemoveDuplicateInboxes(teamName, baseNames);
}
/**
* Restore config.json from prelaunch backup if launch fails after normalization.
*/
private async restorePrelaunchConfig(teamName: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
const backupPath = `${configPath}.prelaunch.bak`;
try {
const backupRaw = await fs.promises.readFile(backupPath, 'utf8');
await atomicWriteAsync(configPath, backupRaw);
logger.info(`[${teamName}] Restored config.json from prelaunch backup after launch failure`);
} catch {
logger.debug(`[${teamName}] No prelaunch backup to restore (or read failed)`);
}
}
/**
* Remove the prelaunch backup file after a successful launch.
*/
async cleanupPrelaunchBackup(teamName: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
const backupPath = `${configPath}.prelaunch.bak`;
try {
await fs.promises.unlink(backupPath);
} catch {
// Backup may not exist — that's fine
}
}
private async mergeAndRemoveDuplicateInboxes(
teamName: string,
baseNames: Set<string>
): Promise<void> {
if (baseNames.size === 0) return;
const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes');
let entries: string[];
try {
entries = await fs.promises.readdir(inboxDir);
} catch {
return;
}
const existing = new Set(entries.filter((e) => e.endsWith('.json') && !e.startsWith('.')));
for (const baseName of baseNames) {
const canonicalFile = `${baseName}.json`;
if (!existing.has(canonicalFile)) {
continue;
}
const duplicates = Array.from(existing)
.filter((file) => file.startsWith(`${baseName}-`) && file.endsWith('.json'))
.filter((file) => /-\d+\.json$/.test(file));
if (duplicates.length === 0) {
continue;
}
const canonicalPath = path.join(inboxDir, canonicalFile);
let canonicalRaw: string;
try {
canonicalRaw = await fs.promises.readFile(canonicalPath, 'utf8');
} catch {
// If cannot read, skip cleanup for this base.
continue;
}
let canonicalParsed: unknown;
try {
canonicalParsed = JSON.parse(canonicalRaw) as unknown;
} catch {
canonicalParsed = [];
}
const canonicalList = Array.isArray(canonicalParsed) ? (canonicalParsed as unknown[]) : [];
const merged = [...canonicalList];
for (const dupFile of duplicates) {
const dupPath = path.join(inboxDir, dupFile);
let dupRaw: string;
try {
dupRaw = await fs.promises.readFile(dupPath, 'utf8');
} catch {
continue;
}
let dupParsed: unknown;
try {
dupParsed = JSON.parse(dupRaw) as unknown;
} catch {
dupParsed = [];
}
if (Array.isArray(dupParsed)) {
const dupList = dupParsed as unknown[];
merged.push(...dupList);
}
}
// Dedup by messageId when available, then sort by timestamp desc.
const dedupById = new Map<string, unknown>();
const noId: unknown[] = [];
for (const item of merged) {
if (!item || typeof item !== 'object') {
continue;
}
const msg = item as { messageId?: unknown };
if (typeof msg.messageId === 'string' && msg.messageId.trim().length > 0) {
dedupById.set(msg.messageId, item);
} else {
noId.push(item);
}
}
const mergedDeduped = [...Array.from(dedupById.values()), ...noId];
mergedDeduped.sort((a, b) => {
const at =
a && typeof a === 'object'
? Date.parse((a as { timestamp?: string }).timestamp ?? '')
: NaN;
const bt =
b && typeof b === 'object'
? Date.parse((b as { timestamp?: string }).timestamp ?? '')
: NaN;
const atNaN = Number.isNaN(at);
const btNaN = Number.isNaN(bt);
if (atNaN && btNaN) return 0;
if (atNaN) return 1;
if (btNaN) return -1;
return bt - at;
});
try {
await atomicWriteAsync(canonicalPath, JSON.stringify(mergedDeduped, null, 2));
} catch {
continue;
}
for (const dupFile of duplicates) {
try {
await fs.promises.unlink(path.join(inboxDir, dupFile));
existing.delete(dupFile);
} catch {
// Best-effort cleanup.
}
}
}
}
private async persistMembersMeta(teamName: string, request: TeamCreateRequest): Promise<void> {
const teammateMembers = request.members.filter((member) => member.name.trim().length > 0);
if (teammateMembers.length === 0) {
return;
}
const joinedAt = Date.now();
try {
await this.membersMetaStore.writeMembers(
teamName,
teammateMembers.map((member, index) => ({
name: member.name,
role: member.role?.trim() || undefined,
agentType: 'general-purpose',
color: getMemberColor(index),
joinedAt,
}))
);
} catch (error) {
logger.warn(
`[${teamName}] Failed to persist members.meta.json: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
private async resolveLaunchExpectedMembers(
teamName: string,
configRaw: string
): Promise<{
members: TeamCreateRequest['members'];
source: 'members-meta' | 'inboxes' | 'config-fallback';
warning?: string;
}> {
try {
const metaMembers = await this.membersMetaStore.getMembers(teamName);
const byName = new Map<string, TeamCreateRequest['members'][number]>();
for (const member of metaMembers) {
const name = member.name?.trim();
if (!name) continue;
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
const prev = byName.get(name);
if (!prev) {
byName.set(name, { name, role });
} else if (!prev.role && role) {
byName.set(name, { ...prev, role });
}
}
const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
if (members.length > 0) {
return { members, source: 'members-meta' };
}
} catch (error) {
logger.warn(
`[${teamName}] Failed to read members.meta.json: ${
error instanceof Error ? error.message : String(error)
}`
);
}
try {
const inboxNames = Array.from(
new Set(
(await this.inboxReader.listInboxNames(teamName))
.map((name) => name.trim())
.filter((name) => name.length > 0)
)
);
if (inboxNames.length > 0) {
const members = inboxNames.map((name) => ({ name }));
return { members, source: 'inboxes' };
}
} catch (error) {
logger.warn(
`[${teamName}] Failed to read inbox member names: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw);
if (configMembers.length > 0) {
return {
members: configMembers,
source: 'config-fallback',
warning:
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
'Run a fresh team bootstrap to persist stable member metadata.',
};
}
return {
members: [],
source: 'config-fallback',
warning:
'No teammate roster found in members.meta.json, inboxes, or config.json. Launch will continue without explicit teammate names.',
};
}
private extractTeammateSpecsFromConfig(
teamName: string,
configRaw: string
): TeamCreateRequest['members'] {
try {
const parsed = JSON.parse(configRaw) as { members?: { name?: string; agentType?: string }[] };
if (!Array.isArray(parsed.members)) {
return [];
}
const byName = new Map<string, TeamCreateRequest['members'][number]>();
for (const member of parsed.members) {
if (!member || member.agentType === 'team-lead') continue;
const name = typeof member.name === 'string' ? member.name.trim() : '';
if (!name) continue;
byName.set(name, { name });
}
return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
} catch {
logger.warn(`[${teamName}] Failed to parse config.json for launch fallback members`);
return [];
}
}
/**
* Two-stage preflight check:
* 1. `claude --version` — verifies binary is executable and returns version info.
* 2. `claude -p "ping"` — verifies that `-p` mode is actually authenticated.
* This catches the common case where interactive `claude` works (OAuth/keychain)
* but `-p` mode fails with "Not logged in" due to missing env vars.
*/
private async probeClaudeRuntime(
claudePath: string,
cwd: string,
env: NodeJS.ProcessEnv
): Promise<{ warning?: string }> {
// Stage 1: verify binary works
const versionProbe = await this.spawnProbe(
claudePath,
['--version'],
cwd,
env,
CLI_PREPARE_TIMEOUT_MS
);
if (versionProbe.exitCode !== 0) {
const errorText =
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
throw new Error(`Failed to warm up Claude CLI: ${errorText}`);
}
// Stage 2: verify `-p` mode auth actually works (with retry for stale locks after Ctrl+C)
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
try {
pingProbe = await this.spawnProbe(
claudePath,
['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
logger.warn(
`Preflight ping failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
);
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
continue;
}
return {
warning:
'Preflight check for `claude -p` did not complete. ' +
`Proceeding anyway. Details: ${message}`,
};
}
const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr);
const lowerOutput = combinedOutput.toLowerCase();
const isAuthFailure =
lowerOutput.includes('not logged in') ||
lowerOutput.includes('please run /login') ||
lowerOutput.includes('missing api key') ||
lowerOutput.includes('invalid api key');
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
logger.warn(
`Preflight auth failure detected (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms — likely stale locks from interrupted process`
);
await new Promise((resolve) => setTimeout(resolve, PREFLIGHT_AUTH_RETRY_DELAY_MS));
continue;
}
if (isAuthFailure || pingProbe.exitCode !== 0) {
const hint = isAuthFailure
? 'Claude CLI `-p` mode is not authenticated. ' +
'Set ANTHROPIC_API_KEY, or run `claude setup-token` to generate a long-lived OAuth token, ' +
'then export it as CLAUDE_CODE_OAUTH_TOKEN.' +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
return { warning: hint };
}
if (attempt > 1) {
logger.info(
`Preflight auth succeeded on attempt ${attempt} (previous attempt had auth failure)`
);
}
return {};
}
return {};
}
private async spawnProbe(
claudePath: string,
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
timeoutMs: number
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawnCli(claudePath, args, {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const timeoutHandle = setTimeout(() => {
killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
}, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
child.once('error', (error) => {
clearTimeout(timeoutHandle);
reject(error);
});
child.once('close', (exitCode) => {
clearTimeout(timeoutHandle);
resolve({
exitCode,
stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(),
stderr: Buffer.concat(stderrChunks).toString('utf8').trim(),
});
});
});
}
}
/* eslint-enable no-param-reassign -- Re-enable after TeamProvisioningService class */