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 review = require('./internal/review.js');
|
||||||
const messages = require('./internal/messages.js');
|
const messages = require('./internal/messages.js');
|
||||||
const processes = require('./internal/processes.js');
|
const processes = require('./internal/processes.js');
|
||||||
|
const maintenance = require('./internal/maintenance.js');
|
||||||
|
|
||||||
function bindModule(context, moduleApi) {
|
function bindModule(context, moduleApi) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
|
@ -24,6 +25,7 @@ function createController(options) {
|
||||||
review: bindModule(context, review),
|
review: bindModule(context, review),
|
||||||
messages: bindModule(context, messages),
|
messages: bindModule(context, messages),
|
||||||
processes: bindModule(context, processes),
|
processes: bindModule(context, processes),
|
||||||
|
maintenance: bindModule(context, maintenance),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,4 +37,5 @@ module.exports = {
|
||||||
review,
|
review,
|
||||||
messages,
|
messages,
|
||||||
processes,
|
processes,
|
||||||
|
maintenance,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,49 @@ function updateColumnOrder(paths, teamName, columnId, orderedTaskIds) {
|
||||||
return state;
|
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 = {
|
module.exports = {
|
||||||
clearKanban,
|
clearKanban,
|
||||||
|
garbageCollect,
|
||||||
readKanbanState,
|
readKanbanState,
|
||||||
setKanbanColumn,
|
setKanbanColumn,
|
||||||
updateColumnOrder,
|
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[0].stoppedAt).toBeTruthy();
|
||||||
expect(rows[1].id).toBe(registered.id);
|
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[];
|
listProcesses(): unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ControllerMaintenanceApi {
|
||||||
|
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentTeamsController {
|
export interface AgentTeamsController {
|
||||||
tasks: ControllerTaskApi;
|
tasks: ControllerTaskApi;
|
||||||
kanban: ControllerKanbanApi;
|
kanban: ControllerKanbanApi;
|
||||||
review: ControllerReviewApi;
|
review: ControllerReviewApi;
|
||||||
messages: ControllerMessageApi;
|
messages: ControllerMessageApi;
|
||||||
processes: ControllerProcessApi;
|
processes: ControllerProcessApi;
|
||||||
|
maintenance: ControllerMaintenanceApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||||
|
|
|
||||||
|
|
@ -431,11 +431,9 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
if (row.type === 'inbox') {
|
if (row.type === 'inbox') {
|
||||||
if (teamDataService) {
|
if (teamDataService) {
|
||||||
void teamDataService
|
void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) =>
|
||||||
.reconcileTeamArtifacts(teamName)
|
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
|
||||||
.catch((e: unknown) =>
|
);
|
||||||
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-relay ONLY lead-inbox changes into the live lead process.
|
// 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 ---
|
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
||||||
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
||||||
void teamDataService
|
void teamDataService.reconcileTeamArtifacts(teamName).catch((e: unknown) =>
|
||||||
.reconcileTeamArtifacts(teamName)
|
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
|
||||||
.catch((e: unknown) =>
|
);
|
||||||
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
const taskId = detail.replace('.json', '');
|
const taskId = detail.replace('.json', '');
|
||||||
void teamDataService
|
void teamDataService
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import {
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
TEAM_TOOL_APPROVAL_RESPOND,
|
TEAM_TOOL_APPROVAL_RESPOND,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
|
TEAM_VALIDATE_CLI_ARGS,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
TEAM_UPDATE_MEMBER_ROLE,
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
|
|
@ -59,6 +60,8 @@ import {
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
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 { createLogger } from '@shared/utils/logger';
|
||||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
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_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
|
||||||
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
||||||
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
|
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
|
||||||
|
ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,6 +309,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
||||||
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
||||||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
|
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
|
||||||
|
ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamDataService(): TeamDataService {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -641,6 +646,30 @@ async function validateProvisioningRequest(
|
||||||
return { valid: false, error: 'cwd must be a directory' };
|
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 {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
value: {
|
value: {
|
||||||
|
|
@ -655,6 +684,14 @@ async function validateProvisioningRequest(
|
||||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||||
skipPermissions:
|
skipPermissions:
|
||||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
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,
|
clearContext: payload.clearContext === true ? true : undefined,
|
||||||
skipPermissions:
|
skipPermissions:
|
||||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
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) => {
|
(progress) => {
|
||||||
try {
|
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(
|
async function handlePrepareProvisioning(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
cwd: unknown
|
cwd: unknown
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,26 @@ interface ToolUseInfo {
|
||||||
filePath?: string;
|
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 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']);
|
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 {
|
export class TaskBoundaryParser {
|
||||||
private cache = new Map<string, BoundaryCacheEntry>();
|
private cache = new Map<string, BoundaryCacheEntry>();
|
||||||
private readonly cacheTtl = 60 * 1000; // 60s
|
private readonly cacheTtl = 60 * 1000; // 60s
|
||||||
|
|
@ -91,25 +107,25 @@ export class TaskBoundaryParser {
|
||||||
allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp });
|
allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пробуем TaskUpdate
|
// Prefer structured task markers for modern runtime sessions.
|
||||||
const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp);
|
const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp);
|
||||||
if (taskUpdateBounds.length > 0) {
|
if (taskUpdateBounds.length > 0) {
|
||||||
detectedMechanism = 'TaskUpdate';
|
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'TaskUpdate');
|
||||||
boundaries.push(...taskUpdateBounds);
|
boundaries.push(...taskUpdateBounds);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mcpBounds = this.extractMcpTaskBoundaries(content, lineNumber, timestamp);
|
const mcpBounds = this.extractMcpTaskBoundaries(content, lineNumber, timestamp);
|
||||||
if (mcpBounds.length > 0) {
|
if (mcpBounds.length > 0) {
|
||||||
detectedMechanism = 'mcp';
|
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'mcp');
|
||||||
boundaries.push(...mcpBounds);
|
boundaries.push(...mcpBounds);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пробуем teamctl
|
// Legacy CLI fallback for historical JSONL rows.
|
||||||
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
|
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
|
||||||
if (teamctlBounds.length > 0) {
|
if (teamctlBounds.length > 0) {
|
||||||
detectedMechanism = 'teamctl';
|
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'teamctl');
|
||||||
boundaries.push(...teamctlBounds);
|
boundaries.push(...teamctlBounds);
|
||||||
}
|
}
|
||||||
} catch {
|
} 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+)/
|
* Regex: /task\s+(start|complete|set-status)\s+(\d+)/
|
||||||
*/
|
*/
|
||||||
private extractTeamctlBoundaries(
|
private extractTeamctlBoundaries(
|
||||||
|
|
|
||||||
|
|
@ -1230,93 +1230,9 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcileTeamArtifacts(teamName: string): Promise<void> {
|
async reconcileTeamArtifacts(teamName: string): Promise<void> {
|
||||||
const tasks = await this.taskReader.getTasks(teamName);
|
this.getController(teamName).maintenance.reconcileArtifacts({
|
||||||
await this.kanbanManager.garbageCollect(teamName, new Set(tasks.map((task) => task.id)));
|
reason: 'file-watch',
|
||||||
|
});
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,11 @@ export class TeamMemberLogsFinder {
|
||||||
return paths;
|
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> {
|
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
|
import { parseCliArgs } from '@shared/utils/cliArgsParser';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -1080,6 +1081,9 @@ export class TeamProvisioningService {
|
||||||
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
|
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
|
||||||
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
||||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
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(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
|
|
@ -1867,6 +1871,8 @@ export class TeamProvisioningService {
|
||||||
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
|
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
|
||||||
...(request.model ? ['--model', request.model] : []),
|
...(request.model ? ['--model', request.model] : []),
|
||||||
...(request.effort ? ['--effort', request.effort] : []),
|
...(request.effort ? ['--effort', request.effort] : []),
|
||||||
|
...(request.worktree ? ['--worktree', request.worktree] : []),
|
||||||
|
...parseCliArgs(request.extraCliArgs),
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
child = spawnCli(claudePath, spawnArgs, {
|
child = spawnCli(claudePath, spawnArgs, {
|
||||||
|
|
@ -2209,6 +2215,10 @@ export class TeamProvisioningService {
|
||||||
if (request.effort) {
|
if (request.effort) {
|
||||||
launchArgs.push('--effort', 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
|
// 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.
|
// --resume is for existing sessions and may show an interactive picker if not found.
|
||||||
|
|
||||||
|
|
@ -5227,6 +5237,33 @@ export class TeamProvisioningService {
|
||||||
return {};
|
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(
|
private async spawnProbe(
|
||||||
claudePath: string,
|
claudePath: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,9 @@ export const TEAM_TOOL_APPROVAL_EVENT = 'team:toolApprovalEvent';
|
||||||
/** Invoke: respond to a tool approval request (renderer → main) */
|
/** Invoke: respond to a tool approval request (renderer → main) */
|
||||||
export const TEAM_TOOL_APPROVAL_RESPOND = 'team:toolApprovalRespond';
|
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
|
// CLI Installer API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ import {
|
||||||
TEAM_TOOL_APPROVAL_EVENT,
|
TEAM_TOOL_APPROVAL_EVENT,
|
||||||
TEAM_TOOL_APPROVAL_RESPOND,
|
TEAM_TOOL_APPROVAL_RESPOND,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
|
TEAM_VALIDATE_CLI_ARGS,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
TEAM_UPDATE_MEMBER_ROLE,
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
|
|
@ -221,6 +222,7 @@ import type {
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||||
import type {
|
import type {
|
||||||
BinaryPreviewResult,
|
BinaryPreviewResult,
|
||||||
CreateDirResponse,
|
CreateDirResponse,
|
||||||
|
|
@ -994,6 +996,9 @@ const electronAPI: ElectronAPI = {
|
||||||
message
|
message
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
validateCliArgs: async (rawArgs: string) => {
|
||||||
|
return invokeIpcWithResult<CliArgsValidationResult>(TEAM_VALIDATE_CLI_ARGS, rawArgs);
|
||||||
|
},
|
||||||
onToolApprovalEvent: (
|
onToolApprovalEvent: (
|
||||||
callback: (event: unknown, data: ToolApprovalEvent) => void
|
callback: (event: unknown, data: ToolApprovalEvent) => void
|
||||||
): (() => void) => {
|
): (() => void) => {
|
||||||
|
|
|
||||||
|
|
@ -897,6 +897,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
respondToToolApproval: async (): Promise<void> => {
|
respondToToolApproval: async (): Promise<void> => {
|
||||||
throw new Error('Tool approval not available in browser mode');
|
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) => {
|
onToolApprovalEvent: (): (() => void) => {
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,7 @@ export const defaultTaskFiltersState = (): TaskFiltersState => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
export function taskMatchesStatus(
|
export function taskMatchesStatus(
|
||||||
task: {
|
task: { status: string; reviewState?: 'none' | 'review' | 'approved'; kanbanColumn?: 'review' | 'approved' },
|
||||||
status: string;
|
|
||||||
reviewState?: 'none' | 'review' | 'approved';
|
|
||||||
kanbanColumn?: 'review' | 'approved';
|
|
||||||
},
|
|
||||||
statusIds: Set<TaskStatusFilterId>
|
statusIds: Set<TaskStatusFilterId>
|
||||||
): boolean {
|
): boolean {
|
||||||
if (statusIds.size === 0) return false;
|
if (statusIds.size === 0) return false;
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,9 @@ const NoiseRow = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Detect system/automated messages that should be collapsed by default.
|
// Detect historical system/automated messages that should be collapsed by default.
|
||||||
// These are generated by teamctl.js and contain tool instructions, not
|
// These patterns are kept only for legacy compatibility with old inbox/session rows;
|
||||||
// human-written content, so showing them expanded adds visual noise.
|
// new runtime behavior must not depend on exact legacy wording.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SYSTEM_MESSAGE_PATTERNS: { pattern: RegExp; label: string }[] = [
|
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' },
|
{ 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) {
|
for (const { pattern, label } of SYSTEM_MESSAGE_PATTERNS) {
|
||||||
if (pattern.test(text)) return label;
|
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 { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
|
@ -245,6 +246,21 @@ export const CreateTeamDialog = ({
|
||||||
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
|
() => 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 => {
|
const setSelectedModel = (value: string): void => {
|
||||||
setSelectedModelRaw(value);
|
setSelectedModelRaw(value);
|
||||||
localStorage.setItem('team:lastSelectedModel', value);
|
localStorage.setItem('team:lastSelectedModel', value);
|
||||||
|
|
@ -265,6 +281,23 @@ export const CreateTeamDialog = ({
|
||||||
localStorage.setItem('team:lastSelectedEffort', value);
|
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 => {
|
const resetUIState = (): void => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
|
|
@ -496,6 +529,8 @@ export const CreateTeamDialog = ({
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
|
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||||
|
extraCliArgs: customArgs.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
sanitizedTeamName,
|
sanitizedTeamName,
|
||||||
|
|
@ -508,9 +543,23 @@ export const CreateTeamDialog = ({
|
||||||
effectiveModel,
|
effectiveModel,
|
||||||
selectedEffort,
|
selectedEffort,
|
||||||
skipPermissions,
|
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 activeError = localError ?? provisioningError;
|
||||||
const canOpenExistingTeam =
|
const canOpenExistingTeam =
|
||||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
||||||
|
|
@ -839,6 +888,16 @@ export const CreateTeamDialog = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<AdvancedCliSection
|
||||||
|
teamName={advancedKey}
|
||||||
|
internalArgs={internalArgs}
|
||||||
|
worktreeEnabled={worktreeEnabled}
|
||||||
|
onWorktreeEnabledChange={setWorktreeEnabled}
|
||||||
|
worktreeName={worktreeName}
|
||||||
|
onWorktreeNameChange={setWorktreeName}
|
||||||
|
customArgs={customArgs}
|
||||||
|
onCustomArgsChange={setCustomArgs}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||||
|
|
@ -90,6 +91,36 @@ export const LaunchTeamDialog = ({
|
||||||
const [clearContext, setClearContext] = useState(false);
|
const [clearContext, setClearContext] = useState(false);
|
||||||
const [conflictDismissed, setConflictDismissed] = 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 => {
|
const setSelectedModel = (value: string): void => {
|
||||||
setSelectedModelRaw(value);
|
setSelectedModelRaw(value);
|
||||||
localStorage.setItem('team:lastSelectedModel', value);
|
localStorage.setItem('team:lastSelectedModel', value);
|
||||||
|
|
@ -284,6 +315,21 @@ export const LaunchTeamDialog = ({
|
||||||
[members, colorMap]
|
[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 activeError = localError ?? provisioningError;
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
|
|
@ -304,6 +350,8 @@ export const LaunchTeamDialog = ({
|
||||||
effort: (selectedEffort as EffortLevel) || undefined,
|
effort: (selectedEffort as EffortLevel) || undefined,
|
||||||
clearContext: clearContext || undefined,
|
clearContext: clearContext || undefined,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
|
worktree: worktreeEnabled && worktreeName.trim() ? worktreeName.trim() : undefined,
|
||||||
|
extraCliArgs: customArgs.trim() || undefined,
|
||||||
});
|
});
|
||||||
resetFormState();
|
resetFormState();
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -498,6 +546,17 @@ export const LaunchTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AdvancedCliSection
|
||||||
|
teamName={teamName}
|
||||||
|
internalArgs={internalArgs}
|
||||||
|
worktreeEnabled={worktreeEnabled}
|
||||||
|
onWorktreeEnabledChange={setWorktreeEnabled}
|
||||||
|
worktreeName={worktreeName}
|
||||||
|
onWorktreeNameChange={setWorktreeName}
|
||||||
|
customArgs={customArgs}
|
||||||
|
onCustomArgsChange={setCustomArgs}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeError ? (
|
{activeError ? (
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import type {
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from './team';
|
} from './team';
|
||||||
import type { TerminalAPI } from './terminal';
|
import type { TerminalAPI } from './terminal';
|
||||||
|
import type { CliArgsValidationResult } from '../utils/cliArgsParser';
|
||||||
import type { WaterfallData } from './visualization';
|
import type { WaterfallData } from './visualization';
|
||||||
import type {
|
import type {
|
||||||
ConversationGroup,
|
ConversationGroup,
|
||||||
|
|
@ -518,6 +519,7 @@ export interface TeamsAPI {
|
||||||
allow: boolean,
|
allow: boolean,
|
||||||
message?: string
|
message?: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
validateCliArgs: (rawArgs: string) => Promise<CliArgsValidationResult>;
|
||||||
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
|
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,10 @@ export interface TeamLaunchRequest {
|
||||||
clearContext?: boolean;
|
clearContext?: boolean;
|
||||||
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
||||||
skipPermissions?: boolean;
|
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 {
|
export interface TeamLaunchResponse {
|
||||||
|
|
@ -396,6 +400,10 @@ export interface TeamCreateRequest {
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
||||||
skipPermissions?: boolean;
|
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 {
|
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 {
|
export function isReviewTask(task: ReviewStateLike): boolean {
|
||||||
return getReviewStateFromTask(task) === 'review';
|
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[];
|
listProcesses(): unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ControllerMaintenanceApi {
|
||||||
|
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentTeamsController {
|
export interface AgentTeamsController {
|
||||||
tasks: ControllerTaskApi;
|
tasks: ControllerTaskApi;
|
||||||
kanban: ControllerKanbanApi;
|
kanban: ControllerKanbanApi;
|
||||||
review: ControllerReviewApi;
|
review: ControllerReviewApi;
|
||||||
messages: ControllerMessageApi;
|
messages: ControllerMessageApi;
|
||||||
processes: ControllerProcessApi;
|
processes: ControllerProcessApi;
|
||||||
|
maintenance: ControllerMaintenanceApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
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 { 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', () => {
|
describe('TeamDataService', () => {
|
||||||
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
it('keeps getTeamData read-only and skips kanban garbage-collect', async () => {
|
||||||
|
|
@ -47,52 +47,17 @@ describe('TeamDataService', () => {
|
||||||
expect(order).toEqual(['tasks']);
|
expect(order).toEqual(['tasks']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reconciles linked comments outside getTeamData and skips automated notifications', async () => {
|
it('delegates explicit reconcile to controller maintenance API', async () => {
|
||||||
const tasks: TeamTask[] = [
|
const reconcileArtifacts = vi.fn();
|
||||||
{
|
|
||||||
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>',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
|
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
|
||||||
} as never,
|
} as never,
|
||||||
{
|
|
||||||
getTasks: vi.fn(async () => tasks),
|
|
||||||
} as never,
|
|
||||||
{
|
|
||||||
listInboxNames: vi.fn(async () => []),
|
|
||||||
getMessages: vi.fn(async () => messages),
|
|
||||||
} as never,
|
|
||||||
{} as never,
|
{} as never,
|
||||||
{
|
{} as never,
|
||||||
addComment,
|
{} as never,
|
||||||
} as never,
|
{} as never,
|
||||||
{
|
{
|
||||||
resolveMembers: vi.fn(() => []),
|
resolveMembers: vi.fn(() => []),
|
||||||
} as never,
|
} as never,
|
||||||
|
|
@ -109,32 +74,27 @@ describe('TeamDataService', () => {
|
||||||
} as never,
|
} as never,
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
tasks: {
|
maintenance: {
|
||||||
addTaskComment: addComment,
|
reconcileArtifacts,
|
||||||
},
|
},
|
||||||
}) as never
|
}) as never
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.reconcileTeamArtifacts('my-team');
|
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 () => {
|
it('surfaces controller reconcile failures', async () => {
|
||||||
const garbageCollect = vi.fn(async () => undefined);
|
const reconcileArtifacts = vi.fn(() => {
|
||||||
|
throw new Error('reconcile failed');
|
||||||
|
});
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
|
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
|
||||||
} as never,
|
} as never,
|
||||||
{
|
{} as never,
|
||||||
getTasks: vi.fn(async () => {
|
{} as never,
|
||||||
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,
|
} as never,
|
||||||
{
|
{
|
||||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||||
garbageCollect,
|
garbageCollect: vi.fn(async () => undefined),
|
||||||
} as never
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
maintenance: {
|
||||||
|
reconcileArtifacts,
|
||||||
|
},
|
||||||
|
}) as never
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('tasks failed');
|
await expect(service.reconcileTeamArtifacts('my-team')).rejects.toThrow('reconcile failed');
|
||||||
expect(garbageCollect).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes projectPath from config when creating a task', async () => {
|
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')
|
logsForA.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')
|
||||||
).toBe(false);
|
).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