feat: introduce maintenance API for artifact reconciliation

- Added maintenance module to the agent-teams-controller, enabling the reconciliation of stale kanban entries and linked inbox comments.
- Implemented garbage collection logic in kanbanStore to remove invalid task references and clean up column orders.
- Updated controller to expose maintenance functionalities, allowing for better task and comment management.
- Enhanced tests to validate the new reconciliation process, ensuring idempotency and correctness in handling stale data.
- Integrated new CLI argument options for worktree and custom arguments in team launch and creation dialogs, improving user flexibility.
This commit is contained in:
iliya 2026-03-07 18:48:57 +02:00
parent 95d610f43b
commit 48e5d9d6cd
28 changed files with 1442 additions and 173 deletions

View file

@ -4,6 +4,7 @@ const kanban = require('./internal/kanban.js');
const review = require('./internal/review.js');
const messages = require('./internal/messages.js');
const processes = require('./internal/processes.js');
const maintenance = require('./internal/maintenance.js');
function bindModule(context, moduleApi) {
return Object.fromEntries(
@ -24,6 +25,7 @@ function createController(options) {
review: bindModule(context, review),
messages: bindModule(context, messages),
processes: bindModule(context, processes),
maintenance: bindModule(context, maintenance),
};
}
@ -35,4 +37,5 @@ module.exports = {
review,
messages,
processes,
maintenance,
};

View file

@ -112,8 +112,49 @@ function updateColumnOrder(paths, teamName, columnId, orderedTaskIds) {
return state;
}
function garbageCollect(paths, teamName, validTaskIds) {
const state = readKanbanState(paths, teamName);
let staleKanbanEntriesRemoved = 0;
let staleColumnOrderRefsRemoved = 0;
for (const taskId of Object.keys(state.tasks)) {
if (!validTaskIds.has(taskId)) {
delete state.tasks[taskId];
staleKanbanEntriesRemoved += 1;
}
}
if (state.columnOrder && typeof state.columnOrder === 'object') {
const cleaned = {};
for (const [columnId, orderedTaskIds] of Object.entries(state.columnOrder)) {
if (!Array.isArray(orderedTaskIds)) {
continue;
}
const validIds = orderedTaskIds.filter((taskId) => validTaskIds.has(String(taskId)));
staleColumnOrderRefsRemoved += orderedTaskIds.length - validIds.length;
if (validIds.length > 0) {
cleaned[columnId] = validIds;
}
}
state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
}
if (staleKanbanEntriesRemoved > 0 || staleColumnOrderRefsRemoved > 0) {
writeKanbanState(paths, teamName, state);
}
return {
state,
staleKanbanEntriesRemoved,
staleColumnOrderRefsRemoved,
};
}
module.exports = {
clearKanban,
garbageCollect,
readKanbanState,
setKanbanColumn,
updateColumnOrder,

View file

@ -0,0 +1,170 @@
const fs = require('fs');
const path = require('path');
const kanbanStore = require('./kanbanStore.js');
const taskStore = require('./taskStore.js');
function listInboxNames(paths) {
const inboxDir = path.join(paths.teamDir, 'inboxes');
let entries = [];
try {
entries = fs.readdirSync(inboxDir);
} catch (error) {
if (error && error.code === 'ENOENT') {
return [];
}
throw error;
}
return entries
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
.map((name) => name.replace(/\.json$/, ''));
}
function readInboxMessages(paths) {
const messages = [];
for (const member of listInboxNames(paths)) {
const inboxPath = path.join(paths.teamDir, 'inboxes', `${member}.json`);
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
} catch {
continue;
}
if (!Array.isArray(parsed)) {
continue;
}
for (const item of parsed) {
if (!item || typeof item !== 'object') {
continue;
}
if (
typeof item.from !== 'string' ||
typeof item.text !== 'string' ||
typeof item.timestamp !== 'string'
) {
continue;
}
messages.push({
from: item.from,
to: typeof item.to === 'string' ? item.to : member,
text: item.text,
timestamp: item.timestamp,
summary: typeof item.summary === 'string' ? item.summary : undefined,
messageId: typeof item.messageId === 'string' ? item.messageId : undefined,
source: typeof item.source === 'string' ? item.source : undefined,
});
}
}
messages.sort((a, b) => {
const bt = Date.parse(b.timestamp);
const at = Date.parse(a.timestamp);
if (Number.isNaN(bt) || Number.isNaN(at)) {
return 0;
}
return bt - at;
});
return messages;
}
function isAutomatedCommentNotification(message) {
const summary = typeof message.summary === 'string' ? message.summary : '';
if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false;
const text = typeof message.text === 'string' ? message.text : '';
if (!text) return false;
if (text.includes('Reply to this comment using:')) return true;
if (text.startsWith('Comment on task #')) return true;
if (text.startsWith('New comment from user on your task #')) return true;
return false;
}
function syncLinkedComments(paths, tasks, messages) {
const taskIdPattern = /#([A-Za-z0-9-]+)/g;
const tasksById = new Map();
const processedTexts = new Set();
let linkedCommentsCreated = 0;
for (const task of tasks) {
tasksById.set(task.id, task);
if (task.displayId) {
tasksById.set(task.displayId, task);
}
}
for (const message of messages) {
if (!message.messageId || !message.summary || message.from === 'user') continue;
if (message.source === 'lead_session' || message.source === 'lead_process') continue;
if (message.source === 'system_notification') continue;
if (isAutomatedCommentNotification(message)) continue;
const textKey = `${message.from}\0${message.text}`;
if (processedTexts.has(textKey)) continue;
processedTexts.add(textKey);
const taskRefs = new Set();
for (const match of message.summary.matchAll(taskIdPattern)) {
taskRefs.add(match[1]);
}
for (const taskRef of taskRefs) {
const task = tasksById.get(taskRef);
if (!task) continue;
const commentId = `msg-${message.messageId}`;
const existingComments = Array.isArray(task.comments) ? task.comments : [];
if (existingComments.some((comment) => comment.id === commentId)) {
continue;
}
try {
taskStore.addTaskComment(paths, task.id, message.text, {
id: commentId,
author: message.from,
createdAt: message.timestamp,
});
linkedCommentsCreated += 1;
} catch {
// Best-effort: reconcile should not fail on individual comment sync writes.
}
}
}
return linkedCommentsCreated;
}
function reconcileArtifacts(context, options = {}) {
const garbageCollectKanban = options.garbageCollectKanban !== false;
const shouldSyncLinkedComments = options.syncLinkedComments !== false;
const tasks = taskStore.listTasks(context.paths);
const gcResult = garbageCollectKanban
? kanbanStore.garbageCollect(
context.paths,
context.teamName,
new Set(tasks.map((task) => task.id))
)
: { staleKanbanEntriesRemoved: 0, staleColumnOrderRefsRemoved: 0 };
const linkedCommentsCreated = shouldSyncLinkedComments
? syncLinkedComments(context.paths, tasks, readInboxMessages(context.paths))
: 0;
return {
staleKanbanEntriesRemoved: gcResult.staleKanbanEntriesRemoved,
staleColumnOrderRefsRemoved: gcResult.staleColumnOrderRefsRemoved,
linkedCommentsCreated,
};
}
module.exports = {
reconcileArtifacts,
};

View file

@ -111,4 +111,86 @@ describe('agent-teams-controller API', () => {
expect(rows[0].stoppedAt).toBeTruthy();
expect(rows[1].id).toBe(registered.id);
});
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });
const task = controller.tasks.createTask({
subject: 'Ship migration',
owner: 'bob',
});
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
fs.writeFileSync(
kanbanPath,
JSON.stringify(
{
teamName: 'my-team',
reviewers: [],
tasks: {
[task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
staleTask: { column: 'approved', movedAt: '2026-01-01T00:00:00.000Z' },
},
columnOrder: {
review: [task.id, 'staleTask'],
approved: ['staleTask'],
},
},
null,
2
)
);
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
fs.mkdirSync(inboxDir, { recursive: true });
fs.writeFileSync(
path.join(inboxDir, 'bob.json'),
JSON.stringify(
[
{
from: 'alice',
to: 'bob',
summary: `Please revisit #${task.displayId}`,
messageId: 'm-1',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
text: 'Need one more verification pass.',
},
{
from: 'team-lead',
to: 'bob',
summary: `Comment on #${task.displayId}`,
messageId: 'm-2',
timestamp: '2026-02-23T11:00:00.000Z',
read: false,
text:
`Comment on task #${task.displayId} "Ship migration":\n\nHeads up\n\n` +
'<agent-block>\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n</agent-block>',
},
],
null,
2
)
);
const first = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
expect(first.staleKanbanEntriesRemoved).toBe(1);
expect(first.staleColumnOrderRefsRemoved).toBe(2);
expect(first.linkedCommentsCreated).toBe(1);
const reloaded = controller.tasks.getTask(task.id);
expect(reloaded.comments).toHaveLength(1);
expect(reloaded.comments[0].id).toBe('msg-m-1');
expect(reloaded.comments[0].text).toBe('Need one more verification pass.');
const cleanedKanban = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
expect(cleanedKanban.tasks.staleTask).toBeUndefined();
expect(cleanedKanban.columnOrder.review).toEqual([task.id]);
expect(cleanedKanban.columnOrder.approved).toBeUndefined();
const second = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
expect(second.staleKanbanEntriesRemoved).toBe(0);
expect(second.staleColumnOrderRefsRemoved).toBe(0);
expect(second.linkedCommentsCreated).toBe(0);
});
});

View file

@ -56,12 +56,17 @@ declare module 'agent-teams-controller' {
listProcesses(): unknown[];
}
export interface ControllerMaintenanceApi {
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
review: ControllerReviewApi;
messages: ControllerMessageApi;
processes: ControllerProcessApi;
maintenance: ControllerMaintenanceApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;

View file

@ -431,11 +431,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') {
if (teamDataService) {
void teamDataService
.reconcileTeamArtifacts(teamName)
.catch((e: unknown) =>
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
);
void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) =>
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
);
}
// Auto-relay ONLY lead-inbox changes into the live lead process.
@ -487,11 +485,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// --- Task change events: notify lead when teammate starts a task via CLI ---
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
void teamDataService
.reconcileTeamArtifacts(teamName)
.catch((e: unknown) =>
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
);
void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) =>
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
);
const taskId = detail.replace('.json', '');
void teamDataService

View file

@ -48,6 +48,7 @@ import {
TEAM_STOP,
TEAM_TOOL_APPROVAL_RESPOND,
TEAM_UPDATE_CONFIG,
TEAM_VALIDATE_CLI_ARGS,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_MEMBER_ROLE,
@ -59,6 +60,8 @@ import {
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
import { extractFlagsFromHelp, extractUserFlags, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
@ -250,6 +253,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs);
logger.info('Team handlers registered');
}
@ -305,6 +309,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
}
function getTeamDataService(): TeamDataService {
@ -641,6 +646,30 @@ async function validateProvisioningRequest(
return { valid: false, error: 'cwd must be a directory' };
}
if (payload.worktree !== undefined) {
if (typeof payload.worktree !== 'string') {
return { valid: false, error: 'worktree must be a string' };
}
const wt = payload.worktree.trim();
if (wt.length > 128) {
return { valid: false, error: 'worktree name too long (max 128)' };
}
if (wt && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(wt)) {
return {
valid: false,
error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]',
};
}
}
if (payload.extraCliArgs !== undefined) {
if (typeof payload.extraCliArgs !== 'string') {
return { valid: false, error: 'extraCliArgs must be a string' };
}
if (payload.extraCliArgs.length > 1024) {
return { valid: false, error: 'extraCliArgs too long (max 1024)' };
}
}
return {
valid: true,
value: {
@ -655,6 +684,14 @@ async function validateProvisioningRequest(
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
worktree:
typeof payload.worktree === 'string' && payload.worktree.trim()
? payload.worktree.trim()
: undefined,
extraCliArgs:
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
? payload.extraCliArgs.trim()
: undefined,
},
};
}
@ -763,6 +800,12 @@ async function handleLaunchTeam(
clearContext: payload.clearContext === true ? true : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
worktree:
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
extraCliArgs:
typeof payload.extraCliArgs === 'string'
? payload.extraCliArgs.trim() || undefined
: undefined,
},
(progress) => {
try {
@ -776,6 +819,32 @@ async function handleLaunchTeam(
);
}
async function handleValidateCliArgs(
_event: IpcMainInvokeEvent,
rawArgs: unknown
): Promise<IpcResult<CliArgsValidationResult>> {
if (typeof rawArgs !== 'string') {
return { success: false, error: 'rawArgs must be a string' };
}
if (rawArgs.length > 2048) {
return { success: false, error: 'rawArgs too long (max 2048)' };
}
return wrapTeamHandler('validateCliArgs', async () => {
const helpOutput = await getTeamProvisioningService().getCliHelpOutput();
const knownFlags = extractFlagsFromHelp(helpOutput);
const userFlags = extractUserFlags(rawArgs);
const invalidFlags = userFlags.filter((f) => !knownFlags.has(f));
const protectedFlags = userFlags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
const allBad = [...new Set([...invalidFlags, ...protectedFlags])];
return {
valid: allBad.length === 0,
invalidFlags: allBad.length > 0 ? allBad : undefined,
};
});
}
async function handlePrepareProvisioning(
_event: IpcMainInvokeEvent,
cwd: unknown

View file

@ -31,10 +31,26 @@ interface ToolUseInfo {
filePath?: string;
}
/** Regex для teamctl task команд */
/**
* Historical fallback for legacy CLI sessions only.
* New runtime sessions are expected to emit structured task updates via MCP/TaskUpdate.
*/
const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/;
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
function pickDetectedMechanism(
current: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none',
next: 'TaskUpdate' | 'teamctl' | 'mcp'
): 'TaskUpdate' | 'teamctl' | 'mcp' | 'none' {
const priority = {
none: 0,
teamctl: 1,
TaskUpdate: 2,
mcp: 3,
} as const;
return priority[next] > priority[current] ? next : current;
}
export class TaskBoundaryParser {
private cache = new Map<string, BoundaryCacheEntry>();
private readonly cacheTtl = 60 * 1000; // 60s
@ -91,25 +107,25 @@ export class TaskBoundaryParser {
allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp });
}
// Пробуем TaskUpdate
// Prefer structured task markers for modern runtime sessions.
const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp);
if (taskUpdateBounds.length > 0) {
detectedMechanism = 'TaskUpdate';
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'TaskUpdate');
boundaries.push(...taskUpdateBounds);
continue;
}
const mcpBounds = this.extractMcpTaskBoundaries(content, lineNumber, timestamp);
if (mcpBounds.length > 0) {
detectedMechanism = 'mcp';
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'mcp');
boundaries.push(...mcpBounds);
continue;
}
// Пробуем teamctl
// Legacy CLI fallback for historical JSONL rows.
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
if (teamctlBounds.length > 0) {
detectedMechanism = 'teamctl';
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'teamctl');
boundaries.push(...teamctlBounds);
}
} catch {
@ -273,7 +289,7 @@ export class TaskBoundaryParser {
}
/**
* Найти teamctl task start/complete/set-status команды в Bash tool_use блоках.
* Historical fallback: detect legacy teamctl task commands in Bash tool_use blocks.
* Regex: /task\s+(start|complete|set-status)\s+(\d+)/
*/
private extractTeamctlBoundaries(

View file

@ -1230,93 +1230,9 @@ export class TeamDataService {
}
async reconcileTeamArtifacts(teamName: string): Promise<void> {
const tasks = await this.taskReader.getTasks(teamName);
await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id)));
const messages = await this.inboxReader.getMessages(teamName);
if (messages.length === 0) {
return;
}
await this.syncLinkedComments(teamName, tasks, messages);
}
/**
* Scans inbox messages for task-related discussions and auto-creates
* linked comments on disk. Uses deterministic comment ID for dedup.
* Returns true if any new comments were synced (caller should re-read tasks).
*/
private async syncLinkedComments(
teamName: string,
tasks: TeamTask[],
messages: InboxMessage[]
): Promise<boolean> {
const TASK_ID_PATTERN = /#([A-Za-z0-9-]+)/g;
let synced = false;
const tasksById = new Map<string, TeamTask>();
for (const t of tasks) {
tasksById.set(t.id, t);
if (t.displayId) {
tasksById.set(t.displayId, t);
}
}
// Dedup broadcasts: same sender + same text → process only once
const processedTexts = new Set<string>();
function isAutomatedCommentNotification(msg: InboxMessage): boolean {
const summary = typeof msg.summary === 'string' ? msg.summary : '';
if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false;
const text = typeof msg.text === 'string' ? msg.text : '';
if (!text) return false;
// These are system-generated inbox messages that already correspond to a real task comment.
// Syncing them into task.comments causes an immediate "duplicate" (lead echo) in the UI.
if (text.includes('Reply to this comment using:')) return true;
if (text.startsWith('Comment on task #')) return true;
if (text.startsWith('New comment from user on your task #')) return true;
return false;
}
for (const msg of messages) {
if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
if (msg.source === 'lead_session' || msg.source === 'lead_process') continue;
if (msg.source === 'system_notification') continue;
if (isAutomatedCommentNotification(msg)) continue;
const textKey = `${msg.from}\0${msg.text}`;
if (processedTexts.has(textKey)) continue;
processedTexts.add(textKey);
const matches = msg.summary.matchAll(TASK_ID_PATTERN);
const taskIds = new Set<string>();
for (const match of matches) {
taskIds.add(match[1]);
}
for (const taskId of taskIds) {
const task = tasksById.get(taskId);
if (!task) continue;
const commentId = `msg-${msg.messageId}`;
const existing = task.comments ?? [];
if (existing.some((c) => c.id === commentId)) continue;
try {
this.getController(teamName).tasks.addTaskComment(task.id, {
text: msg.text,
id: commentId,
from: msg.from,
createdAt: msg.timestamp,
});
synced = true;
} catch {
// Best-effort — don't fail reconciliation on sync errors
}
}
}
return synced;
this.getController(teamName).maintenance.reconcileArtifacts({
reason: 'file-watch',
});
}
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {

View file

@ -307,7 +307,11 @@ export class TeamMemberLogsFinder {
return paths;
}
/** Быстрая проверка: содержит ли файл TaskUpdate/teamctl маркер для данного taskId */
/**
* Fast marker probe for task-related logs.
* Prefer structured MCP/TaskUpdate markers for modern sessions; keep teamctl text matching
* only as historical fallback for old JSONL data.
*/
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

View file

@ -19,6 +19,7 @@ import {
} from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { parseCliArgs } from '@shared/utils/cliArgsParser';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@ -1080,6 +1081,9 @@ export class TeamProvisioningService {
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
private helpOutputCache: string | null = null;
private helpOutputCacheTime = 0;
private static readonly HELP_CACHE_TTL_MS = 5 * 60 * 1000;
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -1867,6 +1871,8 @@ export class TeamProvisioningService {
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
...(request.model ? ['--model', request.model] : []),
...(request.effort ? ['--effort', request.effort] : []),
...(request.worktree ? ['--worktree', request.worktree] : []),
...parseCliArgs(request.extraCliArgs),
];
try {
child = spawnCli(claudePath, spawnArgs, {
@ -2209,6 +2215,10 @@ export class TeamProvisioningService {
if (request.effort) {
launchArgs.push('--effort', request.effort);
}
if (request.worktree) {
launchArgs.push('--worktree', request.worktree);
}
launchArgs.push(...parseCliArgs(request.extraCliArgs));
// 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.
@ -5227,6 +5237,33 @@ export class TeamProvisioningService {
return {};
}
/**
* Run `claude --help` and return the output. Cached for 5 minutes.
* Used by the validateCliArgs IPC handler to check user-entered flags.
*/
async getCliHelpOutput(cwd?: string): Promise<string> {
if (
this.helpOutputCache &&
Date.now() - this.helpOutputCacheTime < TeamProvisioningService.HELP_CACHE_TTL_MS
) {
return this.helpOutputCache;
}
const targetCwd = cwd ?? process.cwd();
const probeResult = await this.getCachedOrProbeResult(targetCwd);
if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found');
}
const { env } = await this.buildProvisioningEnv();
const result = await this.spawnProbe(probeResult.claudePath, ['--help'], targetCwd, env, 10_000);
const output = (result.stdout + '\n' + result.stderr).trim();
if (!output) {
throw new Error(`claude --help returned empty output (exit code: ${result.exitCode})`);
}
this.helpOutputCache = output;
this.helpOutputCacheTime = Date.now();
return output;
}
private async spawnProbe(
claudePath: string,
args: string[],

View file

@ -364,6 +364,9 @@ export const TEAM_TOOL_APPROVAL_EVENT = 'team:toolApprovalEvent';
/** Invoke: respond to a tool approval request (renderer → main) */
export const TEAM_TOOL_APPROVAL_RESPOND = 'team:toolApprovalRespond';
/** Validate custom CLI args against `claude --help` output */
export const TEAM_VALIDATE_CLI_ARGS = 'team:validateCliArgs';
// =============================================================================
// CLI Installer API Channels
// =============================================================================

View file

@ -106,6 +106,7 @@ import {
TEAM_TOOL_APPROVAL_EVENT,
TEAM_TOOL_APPROVAL_RESPOND,
TEAM_UPDATE_CONFIG,
TEAM_VALIDATE_CLI_ARGS,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_MEMBER_ROLE,
@ -221,6 +222,7 @@ import type {
UpdateKanbanPatch,
WslClaudeRootCandidate,
} from '@shared/types';
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
import type {
BinaryPreviewResult,
CreateDirResponse,
@ -994,6 +996,9 @@ const electronAPI: ElectronAPI = {
message
);
},
validateCliArgs: async (rawArgs: string) => {
return invokeIpcWithResult<CliArgsValidationResult>(TEAM_VALIDATE_CLI_ARGS, rawArgs);
},
onToolApprovalEvent: (
callback: (event: unknown, data: ToolApprovalEvent) => void
): (() => void) => {

View file

@ -897,6 +897,9 @@ export class HttpAPIClient implements ElectronAPI {
respondToToolApproval: async (): Promise<void> => {
throw new Error('Tool approval not available in browser mode');
},
validateCliArgs: async (): Promise<never> => {
throw new Error('CLI args validation not available in browser mode');
},
onToolApprovalEvent: (): (() => void) => {
return () => {};
},

View file

@ -26,11 +26,7 @@ export const defaultTaskFiltersState = (): TaskFiltersState => ({
});
export function taskMatchesStatus(
task: {
status: string;
reviewState?: 'none' | 'review' | 'approved';
kanbanColumn?: 'review' | 'approved';
},
task: { status: string; reviewState?: 'none' | 'review' | 'approved'; kanbanColumn?: 'review' | 'approved' },
statusIds: Set<TaskStatusFilterId>
): boolean {
if (statusIds.size === 0) return false;

View file

@ -128,9 +128,9 @@ const NoiseRow = ({
);
// ---------------------------------------------------------------------------
// Detect system/automated messages that should be collapsed by default.
// These are generated by teamctl.js and contain tool instructions, not
// human-written content, so showing them expanded adds visual noise.
// Detect historical system/automated messages that should be collapsed by default.
// These patterns are kept only for legacy compatibility with old inbox/session rows;
// new runtime behavior must not depend on exact legacy wording.
// ---------------------------------------------------------------------------
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
@ -139,7 +139,7 @@ const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
{ pattern: /^Task #[A-Za-z0-9-]+\s+needs fixes/, label: 'Review changes requested' },
];
function getSystemMessageLabel(text: string): string | null {
export function getSystemMessageLabel(text: string): string | null {
for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) {
if (pattern.test(text)) return label;
}

View file

@ -0,0 +1,329 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { Popover, PopoverAnchor, PopoverContent } from '@renderer/components/ui/popover';
import { parseCliArgs, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser';
import {
AlertTriangle,
CheckCircle2,
ChevronRight,
Clock,
Loader2,
Terminal,
XCircle,
} from 'lucide-react';
interface AdvancedCliSectionProps {
teamName: string;
/** All CLI args from parent (model, effort, permissions, resume, etc.) */
internalArgs: string[];
worktreeEnabled: boolean;
onWorktreeEnabledChange: (enabled: boolean) => void;
worktreeName: string;
onWorktreeNameChange: (name: string) => void;
customArgs: string;
onCustomArgsChange: (args: string) => void;
}
/** Infrastructure flags that are dimmed in command preview. */
const INFRA_FLAGS = new Set([
'--input-format',
'--output-format',
'--setting-sources',
'--mcp-config',
'--disallowedTools',
'--verbose',
]);
type ValidationState = 'idle' | 'loading' | 'success' | 'error';
type TokenType = 'command' | 'visible' | 'infra' | 'custom';
/** Map token type → Tailwind color class (pure function, no state dependency). */
const TOKEN_COLOR_CLASS: Record<TokenType, string> = {
command: 'text-text',
visible: 'text-text',
infra: 'text-text-muted',
custom: 'text-emerald-400',
};
/** Read worktree history from localStorage for a given team. */
function readWorktreeHistory(teamName: string): string[] {
try {
const raw = localStorage.getItem(`team:worktreeHistory:${teamName}`);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
/**
* Collapsible "Advanced" section for CreateTeamDialog and LaunchTeamDialog.
* Contains: worktree checkbox with history, command preview, custom args + validate.
*/
export const AdvancedCliSection: React.FC<AdvancedCliSectionProps> = ({
teamName,
internalArgs,
worktreeEnabled,
onWorktreeEnabledChange,
worktreeName,
onWorktreeNameChange,
customArgs,
onCustomArgsChange,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [validationState, setValidationState] = useState<ValidationState>('idle');
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
// Read worktree history from localStorage; re-read when teamName changes
const [worktreeHistory, setWorktreeHistory] = useState<string[]>(() =>
readWorktreeHistory(teamName)
);
useEffect(() => {
setWorktreeHistory(readWorktreeHistory(teamName));
}, [teamName]);
// Commit worktree name to history on blur
const commitWorktreeName = useCallback(() => {
const name = worktreeName.trim();
if (!name) return;
setWorktreeHistory((prev) => {
const next = [name, ...prev.filter((n) => n !== name)].slice(0, 10);
localStorage.setItem(`team:worktreeHistory:${teamName}`, JSON.stringify(next));
return next;
});
}, [worktreeName, teamName]);
// Build command preview tokens
const previewTokens = useMemo(() => {
const tokens: { text: string; type: 'command' | 'visible' | 'infra' | 'custom' }[] = [];
tokens.push({ text: 'claude', type: 'command' });
// Process internalArgs: classify each as visible or infra
let i = 0;
while (i < internalArgs.length) {
const arg = internalArgs[i];
const isInfra = INFRA_FLAGS.has(arg);
const type = isInfra ? 'infra' : 'visible';
tokens.push({ text: arg, type });
// Check if next token is the value for this flag (not starting with --)
if (i + 1 < internalArgs.length && !internalArgs[i + 1].startsWith('--')) {
tokens.push({ text: internalArgs[i + 1], type });
i += 2;
} else {
i += 1;
}
}
// Worktree
if (worktreeEnabled && worktreeName.trim()) {
tokens.push({ text: '--worktree', type: 'visible' });
tokens.push({ text: worktreeName.trim(), type: 'visible' });
}
// Custom args
const parsed = parseCliArgs(customArgs);
for (const t of parsed) {
tokens.push({ text: t, type: 'custom' });
}
return tokens;
}, [internalArgs, worktreeEnabled, worktreeName, customArgs]);
// Validate handler
const handleValidate = useCallback(async () => {
if (!customArgs.trim()) return;
setValidationState('loading');
setValidationMessage(null);
try {
const result = await window.electronAPI.teams.validateCliArgs(customArgs);
if (result.valid) {
setValidationState('success');
setValidationMessage('All flags valid');
} else {
setValidationState('error');
const flags = result.invalidFlags ?? [];
const unknown = flags.filter((f) => !PROTECTED_CLI_FLAGS.has(f));
const protectedOnes = flags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
const parts: string[] = [];
if (unknown.length > 0) parts.push(`Unknown: ${unknown.join(', ')}`);
if (protectedOnes.length > 0) parts.push(`Protected: ${protectedOnes.join(', ')}`);
setValidationMessage(parts.join(' | '));
}
} catch (err) {
setValidationState('error');
setValidationMessage(err instanceof Error ? err.message : 'Validation failed');
}
}, [customArgs]);
// Reset validation when custom args change
const handleCustomArgsChange = useCallback(
(value: string) => {
onCustomArgsChange(value);
if (validationState !== 'idle') {
setValidationState('idle');
setValidationMessage(null);
}
},
[onCustomArgsChange, validationState]
);
const filteredHistory = useMemo(
() =>
worktreeHistory.filter(
(name) => name !== worktreeName && (!worktreeName || name.includes(worktreeName))
),
[worktreeHistory, worktreeName]
);
return (
<div className="mt-3">
{/* Collapsible header */}
<button
type="button"
className="flex items-center gap-1 text-xs text-text-secondary hover:text-text transition-colors"
onClick={() => setIsOpen(!isOpen)}
>
<ChevronRight
className={`size-3.5 transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
/>
<Terminal className="size-3" />
<span>Advanced</span>
</button>
{isOpen && (
<div className="mt-2 space-y-3 pl-5">
{/* Worktree */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Checkbox
id={`worktree-${teamName}`}
checked={worktreeEnabled}
onCheckedChange={(value) => onWorktreeEnabledChange(value === true)}
/>
<Label
htmlFor={`worktree-${teamName}`}
className="cursor-pointer text-xs font-normal text-text-secondary"
>
Use worktree
</Label>
</div>
{worktreeEnabled && (
<Popover open={showHistory && filteredHistory.length > 0}>
<PopoverAnchor asChild>
<Input
placeholder="worktree-name"
className="h-7 text-xs font-mono"
value={worktreeName}
onChange={(e) => onWorktreeNameChange(e.target.value)}
onFocus={() => setShowHistory(true)}
onBlur={() => {
// Delay to allow click on history items
setTimeout(() => {
setShowHistory(false);
commitWorktreeName();
}, 150);
}}
/>
</PopoverAnchor>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-1"
align="start"
sideOffset={2}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex items-center gap-1.5 px-2 py-1 text-[10px] text-text-muted">
<Clock className="size-3" />
<span>Recent</span>
</div>
{filteredHistory.map((name) => (
<button
key={name}
type="button"
className="w-full rounded px-2 py-1 text-left text-xs font-mono text-text-secondary hover:bg-surface-raised hover:text-text"
onMouseDown={(e) => {
e.preventDefault(); // Prevent input blur
onWorktreeNameChange(name);
setShowHistory(false);
}}
>
{name}
</button>
))}
</PopoverContent>
</Popover>
)}
</div>
{/* Command preview */}
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">
Command preview
</span>
<div className="overflow-x-auto rounded border border-border bg-surface-sidebar px-2.5 py-1.5">
<code className="flex flex-wrap gap-x-1 gap-y-0.5 text-[11px] font-mono leading-relaxed">
{previewTokens.map((token, i) => (
<span key={i} className={TOKEN_COLOR_CLASS[token.type]}>
{token.text}
</span>
))}
</code>
</div>
</div>
{/* Custom arguments */}
<div className="space-y-1.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">
Custom arguments
</span>
<div className="flex items-center gap-2">
<Input
placeholder="--max-turns 5"
className="h-7 flex-1 text-xs font-mono"
value={customArgs}
onChange={(e) => handleCustomArgsChange(e.target.value)}
/>
{customArgs.trim() && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={validationState === 'loading'}
onClick={handleValidate}
>
{validationState === 'loading' ? (
<Loader2 className="mr-1 size-3 animate-spin" />
) : null}
Validate
</Button>
)}
</div>
{/* Validation result */}
{validationState === 'success' && validationMessage && (
<div className="flex items-center gap-1.5 text-xs text-emerald-400">
<CheckCircle2 className="size-3" />
<span>{validationMessage}</span>
</div>
)}
{validationState === 'error' && validationMessage && (
<div className="flex items-start gap-1.5 text-xs">
{validationMessage.includes('Protected') ? (
<AlertTriangle className="mt-0.5 size-3 shrink-0 text-amber-400" />
) : (
<XCircle className="mt-0.5 size-3 shrink-0 text-red-400" />
)}
<span className="text-text-secondary">{validationMessage}</span>
</div>
)}
</div>
</div>
)}
</div>
);
};

View file

@ -31,6 +31,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector';
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { ProjectPathSelector } from './ProjectPathSelector';
@ -245,6 +246,21 @@ export const CreateTeamDialog = ({
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
);
// Advanced CLI section state (use teamName-derived key for localStorage)
const advancedKey = sanitizeTeamName(teamName.trim()) || '_new_';
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(false);
const [worktreeName, setWorktreeNameRaw] = useState('');
const [customArgs, setCustomArgsRaw] = useState('');
// Re-read localStorage when advancedKey changes
useEffect(() => {
const storedEnabled = localStorage.getItem(`team:lastWorktreeEnabled:${advancedKey}`) === 'true';
const storedName = localStorage.getItem(`team:lastWorktreeName:${advancedKey}`) ?? '';
setWorktreeEnabledRaw(storedEnabled && Boolean(storedName));
setWorktreeNameRaw(storedName);
setCustomArgsRaw(localStorage.getItem(`team:lastCustomArgs:${advancedKey}`) ?? '');
}, [advancedKey]);
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
localStorage.setItem('team:lastSelectedModel', value);
@ -265,6 +281,23 @@ export const CreateTeamDialog = ({
localStorage.setItem('team:lastSelectedEffort', value);
};
const setWorktreeEnabled = (value: boolean): void => {
setWorktreeEnabledRaw(value);
localStorage.setItem(`team:lastWorktreeEnabled:${advancedKey}`, String(value));
if (!value) {
setWorktreeNameRaw('');
localStorage.setItem(`team:lastWorktreeName:${advancedKey}`, '');
}
};
const setWorktreeName = (value: string): void => {
setWorktreeNameRaw(value);
localStorage.setItem(`team:lastWorktreeName:${advancedKey}`, value);
};
const setCustomArgs = (value: string): void => {
setCustomArgsRaw(value);
localStorage.setItem(`team:lastCustomArgs:${advancedKey}`, value);
};
const resetUIState = (): void => {
setLocalError(null);
setFieldErrors({});
@ -496,6 +529,8 @@ export const CreateTeamDialog = ({
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
}),
[
sanitizedTeamName,
@ -508,9 +543,23 @@ export const CreateTeamDialog = ({
effectiveModel,
selectedEffort,
skipPermissions,
worktreeEnabled,
worktreeName,
customArgs,
]
);
const internalArgs = useMemo(() => {
const args: string[] = [];
args.push('--input-format', 'stream-json', '--output-format', 'stream-json');
args.push('--verbose', '--setting-sources', 'user,project,local');
args.push('--mcp-config', '<auto>', '--disallowedTools', 'TeamDelete,TodoWrite');
if (skipPermissions) args.push('--dangerously-skip-permissions');
if (effectiveModel) args.push('--model', effectiveModel);
if (selectedEffort) args.push('--effort', selectedEffort);
return args;
}, [skipPermissions, effectiveModel, selectedEffort]);
const activeError = localError ?? provisioningError;
const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
@ -839,6 +888,16 @@ export const CreateTeamDialog = ({
/>
)}
</div>
<AdvancedCliSection
teamName={advancedKey}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/>
</div>
) : null}
</div>

View file

@ -24,6 +24,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
import { EffortLevelSelector } from './EffortLevelSelector';
import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
@ -90,6 +91,36 @@ export const LaunchTeamDialog = ({
const [clearContext, setClearContext] = useState(false);
const [conflictDismissed, setConflictDismissed] = useState(false);
// Advanced CLI section state (with localStorage persistence)
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
() =>
localStorage.getItem(`team:lastWorktreeEnabled:${teamName}`) === 'true' &&
Boolean(localStorage.getItem(`team:lastWorktreeName:${teamName}`))
);
const [worktreeName, setWorktreeNameRaw] = useState(
() => localStorage.getItem(`team:lastWorktreeName:${teamName}`) ?? ''
);
const [customArgs, setCustomArgsRaw] = useState(
() => localStorage.getItem(`team:lastCustomArgs:${teamName}`) ?? ''
);
const setWorktreeEnabled = (value: boolean): void => {
setWorktreeEnabledRaw(value);
localStorage.setItem(`team:lastWorktreeEnabled:${teamName}`, String(value));
if (!value) {
setWorktreeNameRaw('');
localStorage.setItem(`team:lastWorktreeName:${teamName}`, '');
}
};
const setWorktreeName = (value: string): void => {
setWorktreeNameRaw(value);
localStorage.setItem(`team:lastWorktreeName:${teamName}`, value);
};
const setCustomArgs = (value: string): void => {
setCustomArgsRaw(value);
localStorage.setItem(`team:lastCustomArgs:${teamName}`, value);
};
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
localStorage.setItem('team:lastSelectedModel', value);
@ -284,6 +315,21 @@ export const LaunchTeamDialog = ({
[members, colorMap]
);
const internalArgs = useMemo(() => {
const args: string[] = [];
// Infrastructure (always present, dimmed in preview)
args.push('--input-format', 'stream-json', '--output-format', 'stream-json');
args.push('--verbose', '--setting-sources', 'user,project,local');
args.push('--mcp-config', '<auto>', '--disallowedTools', 'TeamDelete,TodoWrite');
// User-visible
if (skipPermissions) args.push('--dangerously-skip-permissions');
const model = computeEffectiveTeamModel(selectedModel, extendedContext);
if (model) args.push('--model', model);
if (selectedEffort) args.push('--effort', selectedEffort);
if (!clearContext) args.push('--resume', '<previous>');
return args;
}, [skipPermissions, selectedModel, extendedContext, selectedEffort, clearContext]);
const activeError = localError ?? provisioningError;
const handleSubmit = (): void => {
@ -304,6 +350,8 @@ export const LaunchTeamDialog = ({
effort: (selectedEffort as EffortLevel) || undefined,
clearContext: clearContext || undefined,
skipPermissions,
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
extraCliArgs: customArgs.trim() || undefined,
});
resetFormState();
onClose();
@ -498,6 +546,17 @@ export const LaunchTeamDialog = ({
</div>
)}
</div>
<AdvancedCliSection
teamName={teamName}
internalArgs={internalArgs}
worktreeEnabled={worktreeEnabled}
onWorktreeEnabledChange={setWorktreeEnabled}
worktreeName={worktreeName}
onWorktreeNameChange={setWorktreeName}
customArgs={customArgs}
onCustomArgsChange={setCustomArgs}
/>
</div>
{activeError ? (

View file

@ -64,6 +64,7 @@ import type {
UpdateKanbanPatch,
} from './team';
import type { TerminalAPI } from './terminal';
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
import type { WaterfallData } from './visualization';
import type {
ConversationGroup,
@ -518,6 +519,7 @@ export interface TeamsAPI {
allow: boolean,
message?: string
) => Promise<void>;
validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>;
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
}

View file

@ -313,6 +313,10 @@ export interface TeamLaunchRequest {
clearContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
skipPermissions?: boolean;
/** Worktree name — CLI: --worktree <name>. */
worktree?: string;
/** Raw custom CLI args string, shell-split and appended to CLI command. */
extraCliArgs?: string;
}
export interface TeamLaunchResponse {
@ -396,6 +400,10 @@ export interface TeamCreateRequest {
effort?: EffortLevel;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
skipPermissions?: boolean;
/** Worktree name — CLI: --worktree <name>. */
worktree?: string;
/** Raw custom CLI args string, shell-split and appended to CLI command. */
extraCliArgs?: string;
}
export interface TeamCreateConfigRequest {

View file

@ -0,0 +1,120 @@
/**
* CLI argument parsing and validation utilities.
*
* Used for:
* - Parsing user-entered custom CLI args into an array for spawn()
* - Extracting known flags from `claude --help` output for validation
* - Identifying which user-entered flags are invalid
*/
/** Результат валидации пользовательских аргументов через `claude --help`. */
export interface CliArgsValidationResult {
valid: boolean;
invalidFlags?: string[];
}
/**
* Набор CLI-флагов, которые управляются приложением автоматически.
* Если пользователь указал один из них в custom args Validate покажет warning.
*/
export const PROTECTED_CLI_FLAGS = new Set([
'--input-format',
'--output-format',
'--setting-sources',
'--mcp-config',
'--disallowedTools',
'--verbose',
]);
/**
* Shell-like split: разбивает строку на токены, учитывая кавычки.
*
* - Поддерживает одинарные и двойные кавычки
* - НЕ обрабатывает backslash-escaping (не нужно для CLI-флагов)
* - Множественные пробелы/табы игнорируются
*
* @example
* parseCliArgs('--verbose --max-turns 5') // ['--verbose', '--max-turns', '5']
* parseCliArgs('--message "hello world"') // ['--message', 'hello world']
* parseCliArgs("--message 'it works'") // ['--message', 'it works']
* parseCliArgs(undefined) // []
*/
export function parseCliArgs(raw: string | undefined): string[] {
if (!raw) return [];
const result: string[] = [];
let current = '';
let inSingleQuote = false;
let inDoubleQuote = false;
let hasQuote = false;
for (const ch of raw) {
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
hasQuote = true;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
hasQuote = true;
continue;
}
if ((ch === ' ' || ch === '\t') && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0 || hasQuote) {
result.push(current);
current = '';
hasQuote = false;
}
continue;
}
current += ch;
}
if (current.length > 0 || hasQuote) {
result.push(current);
}
return result;
}
/**
* Извлекает все CLI-флаги из вывода `claude --help`.
*
* Парсит:
* - Long flags: `--model`, `--max-turns`, `--dangerously-skip-permissions`
* - Short flags: `-p`, `-w`, `-m`
*
* Regex осторожно выбирает только флаги в "позиции флага" (после пробела/начала строки),
* чтобы не ловить дефисы из обычного текста.
*/
export function extractFlagsFromHelp(helpOutput: string): Set<string> {
const flags = new Set<string>();
// Long flags: --word-word-word (после пробела, начала строки, или запятой)
const longFlagRegex = /(?:^|[\s,])(-{2}[a-zA-Z][a-zA-Z0-9-]*)/gm;
let match: RegExpExecArray | null;
while ((match = longFlagRegex.exec(helpOutput)) !== null) {
flags.add(match[1]);
}
// Short flags: -X (одна буква, после пробела/начала строки/запятой)
const shortFlagRegex = /(?:^|[\s,])(-[a-zA-Z])\b/gm;
while ((match = shortFlagRegex.exec(helpOutput)) !== null) {
flags.add(match[1]);
}
return flags;
}
/**
* Извлекает только флаги (начинающиеся с `-`) из строки пользовательских аргументов.
*
* @example
* extractUserFlags('--verbose --max-turns 5 foo') // ['--verbose', '--max-turns']
* extractUserFlags('-p -w') // ['-p', '-w']
* extractUserFlags('') // []
*/
export function extractUserFlags(raw: string): string[] {
const tokens = parseCliArgs(raw);
return tokens.filter((token) => token.startsWith('-'));
}

View file

@ -40,3 +40,4 @@ export function isApprovedTask(task: ReviewStateLike): boolean {
export function isReviewTask(task: ReviewStateLike): boolean {
return getReviewStateFromTask(task) === 'review';
}

View file

@ -56,12 +56,17 @@ declare module 'agent-teams-controller' {
listProcesses(): unknown[];
}
export interface ControllerMaintenanceApi {
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
}
export interface AgentTeamsController {
tasks: ControllerTaskApi;
kanban: ControllerKanbanApi;
review: ControllerReviewApi;
messages: ControllerMessageApi;
processes: ControllerProcessApi;
maintenance: ControllerMaintenanceApi;
}
export function createController(options: ControllerContextOptions): AgentTeamsController;

View file

@ -0,0 +1,159 @@
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import { TaskBoundaryParser } from '../../../../src/main/services/team/TaskBoundaryParser';
describe('TaskBoundaryParser', () => {
let tmpDir: string | null = null;
afterEach(async () => {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
});
it('detects MCP task boundaries for modern runtime sessions', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'mcp.jsonl');
await fs.writeFile(
jsonlPath,
[
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'task_start',
input: { taskId: 'task-123' },
},
],
},
}),
JSON.stringify({
timestamp: '2026-03-01T10:10:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'task_complete',
input: { taskId: 'task-123' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
expect(result.detectedMechanism).toBe('mcp');
expect(result.boundaries).toHaveLength(2);
expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']);
expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true);
});
it('falls back to legacy teamctl bash parsing for historical logs', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'teamctl.jsonl');
await fs.writeFile(
jsonlPath,
[
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'Bash',
input: { command: 'node "teamctl.js" --team demo task start 123' },
},
],
},
}),
JSON.stringify({
timestamp: '2026-03-01T10:10:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'Bash',
input: { command: 'node "teamctl.js" --team demo task set-status 123 completed' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
expect(result.detectedMechanism).toBe('teamctl');
expect(result.boundaries).toHaveLength(2);
expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']);
expect(result.boundaries.every((entry) => entry.mechanism === 'teamctl')).toBe(true);
});
it('prefers structured mechanisms over legacy teamctl in mixed logs', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'mixed.jsonl');
await fs.writeFile(
jsonlPath,
[
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'task_start',
input: { taskId: 'task-123' },
},
],
},
}),
JSON.stringify({
timestamp: '2026-03-01T10:05:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'Bash',
input: { command: 'node "teamctl.js" --team demo task complete 123' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
expect(result.detectedMechanism).toBe('mcp');
});
});

View file

@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { TeamDataService } from '../../../../src/main/services/team/TeamDataService';
import type { InboxMessage, TeamTask } from '../../../../src/shared/types/team';
import type { TeamTask } from '../../../../src/shared/types/team';
describe('TeamDataService', () => {
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
@ -47,52 +47,17 @@ describe('TeamDataService', () => {
expect(order).toEqual(['tasks']);
});
it('reconciles linked comments outside getTeamData and skips automated notifications', async () => {
const tasks: TeamTask[] = [
{
id: '12',
subject: 'Task',
status: 'pending',
},
];
const addComment = vi.fn(async () => {
throw new Error('Should not be called');
});
const messages: InboxMessage[] = [
{
from: 'team-lead',
to: 'alice',
summary: 'Comment on #12',
messageId: 'm1',
timestamp: new Date().toISOString(),
read: false,
text:
'Comment on task #12 "Task":\n\nHello\n\n' +
'<agent-block>\n' +
'Reply to this comment using:\n' +
'node "tool.js" --team my-team task comment 12 --text "..." --from "alice"\n' +
'</agent-block>',
},
];
it('delegates explicit reconcile to controller maintenance API', async () => {
const reconcileArtifacts = vi.fn();
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
} as never,
{
getTasks: vi.fn(async () => tasks),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => messages),
} as never,
{} as never,
{
addComment,
} as never,
{} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
@ -109,32 +74,27 @@ describe('TeamDataService', () => {
} as never,
() =>
({
tasks: {
addTaskComment: addComment,
maintenance: {
reconcileArtifacts,
},
}) as never
);
await service.reconcileTeamArtifacts('my-team');
expect(addComment).not.toHaveBeenCalled();
expect(reconcileArtifacts).toHaveBeenCalledWith({ reason: 'file-watch' });
});
it('skips reconcile writes when tasks fail to load', async () => {
const garbageCollect = vi.fn(async () => undefined);
it('surfaces controller reconcile failures', async () => {
const reconcileArtifacts = vi.fn(() => {
throw new Error('reconcile failed');
});
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
} as never,
{
getTasks: vi.fn(async () => {
throw new Error('tasks failed');
}),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
@ -142,12 +102,20 @@ describe('TeamDataService', () => {
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
garbageCollect,
} as never
garbageCollect: vi.fn(async () => undefined),
} as never,
{} as never,
{} as never,
{} as never,
() =>
({
maintenance: {
reconcileArtifacts,
},
}) as never
);
await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('tasks failed');
expect(garbageCollect).not.toHaveBeenCalled();
await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('reconcile failed');
});
it('includes projectPath from config when creating a task', async () => {

View file

@ -588,4 +588,65 @@ describe('TeamMemberLogsFinder', () => {
logsForA.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')
).toBe(false);
});
it('detects structured task markers while keeping legacy teamctl matching as fallback', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-marker-logs-'));
const structuredPath = path.join(tmpDir, 'structured.jsonl');
await fs.writeFile(
structuredPath,
JSON.stringify({
timestamp: '2026-01-01T00:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'task_start',
input: { teamName: 'demo', taskId: 'task-42' },
},
],
},
}) + '\n',
'utf8'
);
const legacyPath = path.join(tmpDir, 'legacy.jsonl');
await fs.writeFile(
legacyPath,
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'Bash',
input: { command: 'node \"teamctl.js\" --team demo task start task-42' },
},
],
},
}) + '\n',
'utf8'
);
const noisePath = path.join(tmpDir, 'noise.jsonl');
await fs.writeFile(
noisePath,
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'No task markers here' }] },
}) + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
await expect(finder.hasTaskUpdateMarker(structuredPath, 'task-42')).resolves.toBe(true);
await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(true);
await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false);
});
});

View file

@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import {
extractFlagsFromHelp,
extractUserFlags,
parseCliArgs,
PROTECTED_CLI_FLAGS,
} from '@shared/utils/cliArgsParser';
describe('parseCliArgs', () => {
it('returns empty array for undefined', () => {
expect(parseCliArgs(undefined)).toEqual([]);
});
it('returns empty array for empty string', () => {
expect(parseCliArgs('')).toEqual([]);
});
it('splits simple flags and values', () => {
expect(parseCliArgs('--verbose --max-turns 5')).toEqual(['--verbose', '--max-turns', '5']);
});
it('handles double-quoted strings', () => {
expect(parseCliArgs('--message "hello world"')).toEqual(['--message', 'hello world']);
});
it('handles single-quoted strings', () => {
expect(parseCliArgs("--message 'it works'")).toEqual(['--message', 'it works']);
});
it('trims leading/trailing whitespace', () => {
expect(parseCliArgs(' --verbose ')).toEqual(['--verbose']);
});
it('handles multiple consecutive spaces', () => {
expect(parseCliArgs('--foo --bar baz')).toEqual(['--foo', '--bar', 'baz']);
});
it('handles tabs as separators', () => {
expect(parseCliArgs('--foo\t--bar')).toEqual(['--foo', '--bar']);
});
it('handles mixed quotes', () => {
expect(parseCliArgs(`--a "hello 'inner'" --b 'world "nested"'`)).toEqual([
'--a',
"hello 'inner'",
'--b',
'world "nested"',
]);
});
it('handles short flags', () => {
expect(parseCliArgs('-p "prompt text" -w name')).toEqual(['-p', 'prompt text', '-w', 'name']);
});
it('handles flag=value format', () => {
expect(parseCliArgs('--model=opus-4')).toEqual(['--model=opus-4']);
});
it('handles empty quoted strings', () => {
expect(parseCliArgs('--value ""')).toEqual(['--value', '']);
});
});
describe('extractFlagsFromHelp', () => {
const SAMPLE_HELP = `
Usage: claude [options] [prompt]
Options:
-p, --print Print response without interactive mode
-w, --worktree [name] Run in a git worktree
--model <model> Specify the model to use
--max-turns <number> Maximum conversation turns
--verbose Enable verbose logging
--dangerously-skip-permissions Skip permission checks
--input-format <format> Input format (text, stream-json)
--output-format <format> Output format
--no-session-persistence Don't persist session
-h, --help Display this help
-V, --version Display version
For more information, visit https://docs.anthropic.com
This is a non-interactive tool for automated workflows.
`;
it('extracts long flags', () => {
const flags = extractFlagsFromHelp(SAMPLE_HELP);
expect(flags.has('--model')).toBe(true);
expect(flags.has('--max-turns')).toBe(true);
expect(flags.has('--verbose')).toBe(true);
expect(flags.has('--dangerously-skip-permissions')).toBe(true);
expect(flags.has('--input-format')).toBe(true);
expect(flags.has('--output-format')).toBe(true);
expect(flags.has('--no-session-persistence')).toBe(true);
expect(flags.has('--worktree')).toBe(true);
});
it('extracts short flags', () => {
const flags = extractFlagsFromHelp(SAMPLE_HELP);
expect(flags.has('-p')).toBe(true);
expect(flags.has('-w')).toBe(true);
expect(flags.has('-h')).toBe(true);
expect(flags.has('-V')).toBe(true);
});
it('does not match hyphens in regular text', () => {
const flags = extractFlagsFromHelp(SAMPLE_HELP);
// "non-interactive" should not produce --non or -n from hyphenated words
expect(flags.has('--non')).toBe(false);
});
it('returns empty set for empty input', () => {
expect(extractFlagsFromHelp('').size).toBe(0);
});
});
describe('extractUserFlags', () => {
it('extracts flags from mixed input', () => {
expect(extractUserFlags('--verbose --max-turns 5 foo')).toEqual([
'--verbose',
'--max-turns',
]);
});
it('extracts short flags', () => {
expect(extractUserFlags('-p -w')).toEqual(['-p', '-w']);
});
it('returns empty for empty string', () => {
expect(extractUserFlags('')).toEqual([]);
});
it('returns empty when no flags present', () => {
expect(extractUserFlags('hello world')).toEqual([]);
});
});
describe('PROTECTED_CLI_FLAGS', () => {
it('contains expected flags', () => {
expect(PROTECTED_CLI_FLAGS.has('--input-format')).toBe(true);
expect(PROTECTED_CLI_FLAGS.has('--output-format')).toBe(true);
expect(PROTECTED_CLI_FLAGS.has('--mcp-config')).toBe(true);
expect(PROTECTED_CLI_FLAGS.has('--disallowedTools')).toBe(true);
expect(PROTECTED_CLI_FLAGS.has('--verbose')).toBe(true);
});
it('does not contain user-facing flags', () => {
expect(PROTECTED_CLI_FLAGS.has('--model')).toBe(false);
expect(PROTECTED_CLI_FLAGS.has('--effort')).toBe(false);
expect(PROTECTED_CLI_FLAGS.has('--worktree')).toBe(false);
});
});