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:
parent
95d610f43b
commit
48e5d9d6cd
28 changed files with 1442 additions and 173 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
170
agent-teams-controller/src/internal/maintenance.js
Normal file
170
agent-teams-controller/src/internal/maintenance.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
5
mcp-server/src/agent-teams-controller.d.ts
vendored
5
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 () => {};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
329
src/renderer/components/team/dialogs/AdvancedCliSection.tsx
Normal file
329
src/renderer/components/team/dialogs/AdvancedCliSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
120
src/shared/utils/cliArgsParser.ts
Normal file
120
src/shared/utils/cliArgsParser.ts
Normal 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('-'));
|
||||
}
|
||||
|
|
@ -40,3 +40,4 @@ export function isApprovedTask(task: ReviewStateLike): boolean {
|
|||
export function isReviewTask(task: ReviewStateLike): boolean {
|
||||
return getReviewStateFromTask(task) === 'review';
|
||||
}
|
||||
|
||||
|
|
|
|||
5
src/types/agent-teams-controller.d.ts
vendored
5
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
159
test/main/services/team/TaskBoundaryParser.test.ts
Normal file
159
test/main/services/team/TaskBoundaryParser.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
152
test/shared/utils/cliArgsParser.test.ts
Normal file
152
test/shared/utils/cliArgsParser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue