commit
9d7a7885b5
173 changed files with 3665 additions and 1420 deletions
|
|
@ -30,6 +30,7 @@ A new approach to task management with AI agent teams.
|
|||
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
||||
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
||||
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
||||
- **Task-specific logs and messages** — clearly see all Claude logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment
|
||||
- **Live process section** — see which agents are running processes and open URLs directly in the browser
|
||||
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
|
||||
- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
|
||||
|
|
@ -38,7 +39,7 @@ A new approach to task management with AI agent teams.
|
|||
<summary><strong>More features</strong></summary>
|
||||
|
||||
<br />
|
||||
|
||||
- **Task creation with attachments** — Simply send a message to the team lead with any attached images (planed all files). The lead will automatically create a fully described task and attach your files directly to the task for complete context.
|
||||
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
|
||||
- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks
|
||||
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
|
||||
|
|
@ -47,10 +48,11 @@ A new approach to task management with AI agent teams.
|
|||
- **Built-in code editor** — edit project files with Git support without leaving the app
|
||||
- **Branch strategy** — choose via prompt: single branch or git worktree per agent
|
||||
- **Team member stats** — global performance statistics per member
|
||||
- **Attach code context** — reference files or snippets in messages, like in Cursor
|
||||
- **Attach code context** — reference files or snippets in messages, like in Cursor. You can also mention tasks using `#task-id`, or refer to another team with `@team-name` in your messages.
|
||||
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
|
||||
- **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box
|
||||
- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
|
||||
- **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
|
@ -221,6 +223,8 @@ pnpm dist # macOS + Windows + Linux
|
|||
- [ ] Planning mode to organize agent plans before execution
|
||||
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
|
||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
||||
- [ ] Attach any files to messages/comments/tasks
|
||||
- [ ] Slash commands
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const processes = require('./internal/processes.js');
|
|||
const maintenance = require('./internal/maintenance.js');
|
||||
const crossTeam = require('./internal/crossTeam.js');
|
||||
const runtime = require('./internal/runtime.js');
|
||||
const agentBlocks = require('./internal/agentBlocks.js');
|
||||
|
||||
function bindModule(context, moduleApi) {
|
||||
return Object.fromEntries(
|
||||
|
|
@ -36,6 +37,7 @@ function createController(options) {
|
|||
module.exports = {
|
||||
createController,
|
||||
createControllerContext,
|
||||
agentBlocks,
|
||||
tasks,
|
||||
kanban,
|
||||
review,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const AGENT_BLOCK_TAG = 'info_for_agent';
|
||||
const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
|
||||
const AGENT_BLOCK_CLOSE = `</${AGENT_BLOCK_TAG}>`;
|
||||
const AGENT_BLOCK_RE = new RegExp(`<${AGENT_BLOCK_TAG}>[\\s\\S]*?</${AGENT_BLOCK_TAG}>`, 'g');
|
||||
|
||||
function wrapAgentBlock(text) {
|
||||
const trimmed = typeof text === 'string' ? text.trim() : '';
|
||||
|
|
@ -10,9 +11,20 @@ function wrapAgentBlock(text) {
|
|||
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip all agent-only blocks from text.
|
||||
* Returns text with `<info_for_agent>...</info_for_agent>` blocks removed and trimmed.
|
||||
*/
|
||||
function stripAgentBlocks(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
return text.replace(AGENT_BLOCK_RE, '').trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AGENT_BLOCK_TAG,
|
||||
AGENT_BLOCK_OPEN,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
AGENT_BLOCK_RE,
|
||||
stripAgentBlocks,
|
||||
wrapAgentBlock,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -165,8 +165,71 @@ function appendSentMessage(paths, flags) {
|
|||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact readonly lookup by messageId across sent messages and all inbox files.
|
||||
*
|
||||
* Used by task_create_from_message to resolve provenance. Lookup is exact-messageId
|
||||
* only and must never resolve by relayOfMessageId, text matching, or active context.
|
||||
* Must reject ambiguous matches (same messageId in multiple stores) instead of guessing.
|
||||
*
|
||||
* Returns { message, store } or throws.
|
||||
*/
|
||||
function lookupMessage(paths, messageId) {
|
||||
const id = typeof messageId === 'string' ? messageId.trim() : '';
|
||||
if (!id) {
|
||||
throw new Error('Missing messageId');
|
||||
}
|
||||
|
||||
let match = null;
|
||||
let matchCount = 0;
|
||||
|
||||
// 1. Search sentMessages.json
|
||||
const sentRows = readJson(getSentMessagesPath(paths), []);
|
||||
if (Array.isArray(sentRows)) {
|
||||
for (const row of sentRows) {
|
||||
if (row && row.messageId === id) {
|
||||
match = { message: row, store: 'sent' };
|
||||
matchCount++;
|
||||
if (matchCount > 1) {
|
||||
throw new Error(`Ambiguous messageId: ${id} found in multiple stores`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Search all inbox files (early-exit on ambiguity)
|
||||
const inboxDir = path.join(paths.teamDir, 'inboxes');
|
||||
let inboxFiles = [];
|
||||
try {
|
||||
inboxFiles = fs.readdirSync(inboxDir).filter((f) => f.endsWith('.json'));
|
||||
} catch {
|
||||
// No inboxes directory — that's fine.
|
||||
}
|
||||
|
||||
for (const file of inboxFiles) {
|
||||
const rows = readJson(path.join(inboxDir, file), []);
|
||||
if (!Array.isArray(rows)) continue;
|
||||
for (const row of rows) {
|
||||
if (row && row.messageId === id) {
|
||||
matchCount++;
|
||||
if (matchCount > 1) {
|
||||
throw new Error(`Ambiguous messageId: ${id} found in multiple stores`);
|
||||
}
|
||||
match = { message: row, store: `inbox:${file.replace('.json', '')}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchCount === 0) {
|
||||
throw new Error(`Message not found: ${id}`);
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appendSentMessage,
|
||||
lookupMessage,
|
||||
sendInboxMessage,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ function appendSentMessage(context, flags) {
|
|||
return messageStore.appendSentMessage(context.paths, flags);
|
||||
}
|
||||
|
||||
function lookupMessage(context, messageId) {
|
||||
return messageStore.lookupMessage(context.paths, messageId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appendSentMessage,
|
||||
lookupMessage,
|
||||
sendMessage,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function getCurrentReviewState(task) {
|
|||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') {
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved' || e.type === 'review_started') {
|
||||
return e.to;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
|
|
@ -46,6 +46,44 @@ function getCurrentReviewState(task) {
|
|||
return 'none';
|
||||
}
|
||||
|
||||
function startReview(context, taskId, flags = {}) {
|
||||
const task = tasks.getTask(context, taskId);
|
||||
if (task.status === 'deleted') {
|
||||
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
||||
}
|
||||
|
||||
const from =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'reviewer';
|
||||
const prevReviewState = getCurrentReviewState(task);
|
||||
|
||||
// Idempotent: already in review → return ok without duplicate history event
|
||||
if (prevReviewState === 'review') {
|
||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||
}
|
||||
|
||||
try {
|
||||
kanban.setKanbanColumn(context, task.id, 'review');
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
from: prevReviewState,
|
||||
to: 'review',
|
||||
actor: from,
|
||||
});
|
||||
t.reviewState = 'review';
|
||||
return t;
|
||||
});
|
||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||
} catch (error) {
|
||||
try {
|
||||
kanban.clearKanban(context, task.id);
|
||||
} catch {
|
||||
// Best-effort rollback
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requestReview(context, taskId, flags = {}) {
|
||||
const task = tasks.getTask(context, taskId);
|
||||
if (task.status !== 'completed') {
|
||||
|
|
@ -84,7 +122,9 @@ function requestReview(context, taskId, flags = {}) {
|
|||
text:
|
||||
`**Please review** task #${task.displayId || task.id}\n\n` +
|
||||
wrapAgentBlock(
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`FIRST call review_start to signal you are beginning the review:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", from: "<your-name>" }\n\n` +
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
|
||||
`If changes are needed, use MCP tool review_request_changes:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
|
||||
|
|
@ -210,4 +250,5 @@ module.exports = {
|
|||
approveReview,
|
||||
requestReview,
|
||||
requestChanges,
|
||||
startReview,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -330,6 +330,12 @@ function createTask(paths, input = {}) {
|
|||
deletedAt:
|
||||
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
|
||||
attachments: Array.isArray(input.attachments) ? input.attachments : undefined,
|
||||
...(typeof input.sourceMessageId === 'string' && input.sourceMessageId.trim()
|
||||
? { sourceMessageId: input.sourceMessageId.trim() }
|
||||
: {}),
|
||||
...(input.sourceMessage && typeof input.sourceMessage === 'object'
|
||||
? { sourceMessage: input.sourceMessage }
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (!task.subject) {
|
||||
|
|
@ -654,7 +660,7 @@ function getEffectiveReviewState(kanbanEntry, task) {
|
|||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') {
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved' || e.type === 'review_started') {
|
||||
return e.to;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
|
|
|
|||
|
|
@ -5,63 +5,63 @@ const processStore = require('./processStore.js');
|
|||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
|
||||
function normalizeActorName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isSameMember(left, right) {
|
||||
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||
}
|
||||
|
||||
function isSameTaskMember(left, right, leadName) {
|
||||
const normalizedLeft = normalizeActorName(left).toLowerCase();
|
||||
const normalizedRight = normalizeActorName(right).toLowerCase();
|
||||
const normalizedLead = normalizeActorName(leadName).toLowerCase();
|
||||
if (!normalizedLeft || !normalizedRight) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedLeft === normalizedRight) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) ||
|
||||
(normalizedRight === 'team-lead' && normalizedLeft === normalizedLead)
|
||||
);
|
||||
const normalizedLeft = normalizeActorName(left).toLowerCase();
|
||||
const normalizedRight = normalizeActorName(right).toLowerCase();
|
||||
const normalizedLead = normalizeActorName(leadName).toLowerCase();
|
||||
if (!normalizedLeft || !normalizedRight) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedLeft === normalizedRight) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) ||
|
||||
(normalizedRight === 'team-lead' && normalizedLeft === normalizedLead)
|
||||
);
|
||||
}
|
||||
|
||||
function quoteMarkdown(text) {
|
||||
return String(text)
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
return String(text)
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildAssignmentMessage(context, task, options = {}) {
|
||||
const description =
|
||||
typeof options.description === 'string' && options.description.trim()
|
||||
? options.description.trim()
|
||||
: typeof task.description === 'string' && task.description.trim()
|
||||
? task.description.trim()
|
||||
: '';
|
||||
const prompt =
|
||||
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
const lines = [
|
||||
`New task assigned to you: ${taskLabel} "${task.subject}".`,
|
||||
``,
|
||||
`*If you are idle and this task is ready to start, start it now. If you are busy, blocked, or still need more context, immediately add a short task comment with the reason and your best ETA or what you are waiting on, and keep this task in TODO until you actually begin.*`,
|
||||
];
|
||||
const description =
|
||||
typeof options.description === 'string' && options.description.trim() ?
|
||||
options.description.trim() :
|
||||
typeof task.description === 'string' && task.description.trim() ?
|
||||
task.description.trim() :
|
||||
'';
|
||||
const prompt =
|
||||
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
const lines = [
|
||||
`New task assigned to you: ${taskLabel} "${task.subject}".`,
|
||||
``,
|
||||
`*If you are idle and this task is ready to start, start it now. If you are busy, blocked, or still need more context, immediately add a short task comment with the reason and your best ETA or what you are waiting on, and keep this task in TODO until you actually begin.*`,
|
||||
];
|
||||
|
||||
if (description) {
|
||||
lines.push(``, `Description:`, description);
|
||||
}
|
||||
if (description) {
|
||||
lines.push(``, `Description:`, description);
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
lines.push(``, `Instructions:`, prompt);
|
||||
}
|
||||
if (prompt) {
|
||||
lines.push(``, `Instructions:`, prompt);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||
lines.push(
|
||||
``,
|
||||
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||
1. Check the latest full context before starting:
|
||||
task_get { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
2. If you are idle and the task is ready to start after checking dependencies and context, call task_start now:
|
||||
|
|
@ -70,304 +70,301 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
task_add_comment { teamName: "${context.teamName}", taskId: "${task.id}", text: "<reason + ETA or blocker>", from: "<your-name>" }
|
||||
4. When the work is done, mark it completed:
|
||||
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
|
||||
);
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildCommentNotificationMessage(context, task, comment) {
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
return [
|
||||
`**Comment on task ${taskLabel}**`,
|
||||
`> ${task.subject}`,
|
||||
``,
|
||||
quoteMarkdown(comment.text),
|
||||
``,
|
||||
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
return [
|
||||
`**Comment on task ${taskLabel}** _${task.subject}_`,
|
||||
``,
|
||||
quoteMarkdown(comment.text),
|
||||
``,
|
||||
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
||||
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
|
||||
].join('\n');
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||
const owner = normalizeActorName(task.owner);
|
||||
if (!owner || task.status === 'deleted') {
|
||||
return;
|
||||
}
|
||||
const owner = normalizeActorName(task.owner);
|
||||
if (!owner || task.status === 'deleted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const sender = normalizeActorName(options.from) || leadName;
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
|
||||
return;
|
||||
}
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const sender = normalizeActorName(options.from) || leadName;
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: sender,
|
||||
text: buildAssignmentMessage(context, task, options),
|
||||
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
||||
summary,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: sender,
|
||||
text: buildAssignmentMessage(context, task, options),
|
||||
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
||||
summary,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
||||
if (!options.inserted || options.notifyOwner === false) {
|
||||
return;
|
||||
}
|
||||
if (!task || task.status === 'deleted') {
|
||||
return;
|
||||
}
|
||||
if (comment.type && comment.type !== 'regular') {
|
||||
return;
|
||||
}
|
||||
if (!options.inserted || options.notifyOwner === false) {
|
||||
return;
|
||||
}
|
||||
if (!task || task.status === 'deleted') {
|
||||
return;
|
||||
}
|
||||
if (comment.type && comment.type !== 'regular') {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = normalizeActorName(task.owner);
|
||||
if (!owner) {
|
||||
return;
|
||||
}
|
||||
const owner = normalizeActorName(task.owner);
|
||||
if (!owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
if (isSameTaskMember(owner, comment.author, leadName)) {
|
||||
return;
|
||||
}
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
if (isSameTaskMember(owner, comment.author, leadName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: normalizeActorName(comment.author) || leadName,
|
||||
text: buildCommentNotificationMessage(context, task, comment),
|
||||
taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined,
|
||||
summary: `Comment on #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: normalizeActorName(comment.author) || leadName,
|
||||
text: buildCommentNotificationMessage(context, task, comment),
|
||||
taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined,
|
||||
summary: `Comment on #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function createTask(context, input) {
|
||||
const task = taskStore.createTask(context.paths, input);
|
||||
if (input && input.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
taskRefs: [
|
||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
||||
],
|
||||
from: input.from,
|
||||
});
|
||||
}
|
||||
return task;
|
||||
const task = taskStore.createTask(context.paths, input);
|
||||
if (input && input.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
taskRefs: [
|
||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
||||
],
|
||||
from: input.from,
|
||||
});
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function getTask(context, taskId) {
|
||||
return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function listTasks(context) {
|
||||
return taskStore.listTasks(context.paths);
|
||||
return taskStore.listTasks(context.paths);
|
||||
}
|
||||
|
||||
function listDeletedTasks(context) {
|
||||
return taskStore.listTasks(context.paths, { includeDeleted: true }).filter(
|
||||
(task) => task.status === 'deleted'
|
||||
);
|
||||
return taskStore.listTasks(context.paths, { includeDeleted: true }).filter(
|
||||
(task) => task.status === 'deleted'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTaskId(context, taskRef) {
|
||||
return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true });
|
||||
return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function setTaskStatus(context, taskId, status, actor) {
|
||||
return taskStore.setTaskStatus(context.paths, taskId, status, actor);
|
||||
return taskStore.setTaskStatus(context.paths, taskId, status, actor);
|
||||
}
|
||||
|
||||
function startTask(context, taskId, actor) {
|
||||
const task = setTaskStatus(context, taskId, 'in_progress', actor);
|
||||
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
|
||||
try {
|
||||
const kanbanStore = require('./kanbanStore.js');
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (state.tasks[task.id]) {
|
||||
delete state.tasks[task.id];
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
||||
const task = setTaskStatus(context, taskId, 'in_progress', actor);
|
||||
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
|
||||
try {
|
||||
const kanbanStore = require('./kanbanStore.js');
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (state.tasks[task.id]) {
|
||||
delete state.tasks[task.id];
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
|
||||
}
|
||||
return task;
|
||||
return task;
|
||||
}
|
||||
|
||||
function completeTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'completed', actor);
|
||||
return setTaskStatus(context, taskId, 'completed', actor);
|
||||
}
|
||||
|
||||
function softDeleteTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'deleted', actor);
|
||||
return setTaskStatus(context, taskId, 'deleted', actor);
|
||||
}
|
||||
|
||||
function restoreTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'pending', actor || 'user');
|
||||
return setTaskStatus(context, taskId, 'pending', actor || 'user');
|
||||
}
|
||||
|
||||
function setTaskOwner(context, taskId, owner) {
|
||||
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||
|
||||
if (
|
||||
owner != null &&
|
||||
normalizeActorName(updatedTask.owner) &&
|
||||
!isSameMember(previousTask.owner, updatedTask.owner)
|
||||
) {
|
||||
maybeNotifyAssignedOwner(context, updatedTask, {
|
||||
summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
owner != null &&
|
||||
normalizeActorName(updatedTask.owner) &&
|
||||
!isSameMember(previousTask.owner, updatedTask.owner)
|
||||
) {
|
||||
maybeNotifyAssignedOwner(context, updatedTask, {
|
||||
summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedTask;
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
function updateTaskFields(context, taskId, fields) {
|
||||
return taskStore.updateTaskFields(context.paths, taskId, fields);
|
||||
return taskStore.updateTaskFields(context.paths, taskId, fields);
|
||||
}
|
||||
|
||||
function addTaskComment(context, taskId, flags) {
|
||||
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
||||
author:
|
||||
typeof flags.from === 'string' && flags.from.trim()
|
||||
? flags.from.trim()
|
||||
: runtimeHelpers.inferLeadName(context.paths),
|
||||
...(flags.id ? { id: flags.id } : {}),
|
||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||
...(flags.type ? { type: flags.type } : {}),
|
||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||
inserted: result.inserted,
|
||||
notifyOwner: flags.notifyOwner,
|
||||
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
||||
author: typeof flags.from === 'string' && flags.from.trim() ?
|
||||
flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
|
||||
...(flags.id ? { id: flags.id } : {}),
|
||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||
...(flags.type ? { type: flags.type } : {}),
|
||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||
});
|
||||
} catch (notifyError) {
|
||||
// Best-effort: comment is already persisted, notification failure must not fail the call
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
console.warn(
|
||||
`[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.comment.id,
|
||||
taskId: result.task.id,
|
||||
subject: result.task.subject,
|
||||
owner: result.task.owner,
|
||||
task: result.task,
|
||||
comment: result.comment,
|
||||
};
|
||||
try {
|
||||
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||
inserted: result.inserted,
|
||||
notifyOwner: flags.notifyOwner,
|
||||
});
|
||||
} catch (notifyError) {
|
||||
// Best-effort: comment is already persisted, notification failure must not fail the call
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
console.warn(
|
||||
`[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.comment.id,
|
||||
taskId: result.task.id,
|
||||
subject: result.task.subject,
|
||||
owner: result.task.owner,
|
||||
task: result.task,
|
||||
comment: result.comment,
|
||||
};
|
||||
}
|
||||
|
||||
function attachTaskFile(context, taskId, flags) {
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
function attachCommentFile(context, taskId, commentId, flags) {
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
function addTaskAttachmentMeta(context, taskId, meta) {
|
||||
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
|
||||
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
|
||||
}
|
||||
|
||||
function removeTaskAttachment(context, taskId, attachmentId) {
|
||||
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
|
||||
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
|
||||
}
|
||||
|
||||
function setNeedsClarification(context, taskId, value) {
|
||||
return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
|
||||
return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
|
||||
}
|
||||
|
||||
function linkTask(context, taskId, targetId, linkType) {
|
||||
return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
|
||||
return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
|
||||
}
|
||||
|
||||
function unlinkTask(context, taskId, targetId, linkType) {
|
||||
return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
|
||||
return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
|
||||
}
|
||||
|
||||
async function taskBriefing(context, memberName) {
|
||||
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
|
||||
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
|
||||
}
|
||||
|
||||
function getSystemLocale() {
|
||||
const lang = typeof process.env.LANG === 'string' ? process.env.LANG.trim() : '';
|
||||
if (!lang) return 'en';
|
||||
return lang.split('.')[0].replace('_', '-');
|
||||
const lang = typeof process.env.LANG === 'string' ? process.env.LANG.trim() : '';
|
||||
if (!lang) return 'en';
|
||||
return lang.split('.')[0].replace('_', '-');
|
||||
}
|
||||
|
||||
function extractPrimaryLanguage(locale) {
|
||||
const normalized = String(locale || '').trim();
|
||||
const dash = normalized.indexOf('-');
|
||||
return dash > 0 ? normalized.slice(0, dash) : normalized || 'en';
|
||||
const normalized = String(locale || '').trim();
|
||||
const dash = normalized.indexOf('-');
|
||||
return dash > 0 ? normalized.slice(0, dash) : normalized || 'en';
|
||||
}
|
||||
|
||||
function resolveLanguageName(code, systemLocale) {
|
||||
const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale || 'en') : code;
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
|
||||
const name = displayNames.of(effectiveCode);
|
||||
if (name) {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale || 'en') : code;
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
|
||||
const name = displayNames.of(effectiveCode);
|
||||
if (name) {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
} catch {
|
||||
// Ignore Intl lookup failures and fall back to the raw code.
|
||||
}
|
||||
} catch {
|
||||
// Ignore Intl lookup failures and fall back to the raw code.
|
||||
}
|
||||
return effectiveCode;
|
||||
return effectiveCode;
|
||||
}
|
||||
|
||||
function buildMemberLanguageInstruction(config) {
|
||||
const configured =
|
||||
config && typeof config.language === 'string' && config.language.trim()
|
||||
? config.language.trim()
|
||||
: '';
|
||||
if (!configured) {
|
||||
return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.';
|
||||
}
|
||||
const language = resolveLanguageName(configured, getSystemLocale());
|
||||
return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`;
|
||||
const configured =
|
||||
config && typeof config.language === 'string' && config.language.trim() ?
|
||||
config.language.trim() :
|
||||
'';
|
||||
if (!configured) {
|
||||
return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.';
|
||||
}
|
||||
const language = resolveLanguageName(configured, getSystemLocale());
|
||||
return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`;
|
||||
}
|
||||
|
||||
function buildMemberActionModeProtocol() {
|
||||
return [
|
||||
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):',
|
||||
'- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.',
|
||||
'- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.',
|
||||
'- Never silently broaden permissions beyond the selected mode.',
|
||||
'- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.',
|
||||
'- Modes:',
|
||||
' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.',
|
||||
' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.',
|
||||
' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.',
|
||||
].join('\n');
|
||||
return [
|
||||
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):',
|
||||
'- Some incoming user or relay messages may include a hidden agent-only block that declares the current action mode.',
|
||||
'- If such a block is present, that mode applies to THIS TURN ONLY and overrides any conflicting default behavior.',
|
||||
'- Never silently broaden permissions beyond the selected mode.',
|
||||
'- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.',
|
||||
'- Modes:',
|
||||
' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.',
|
||||
' - ASK: Strict read-only conversation mode. You may read/analyze/explain and reply, but you must not change code/files/tasks/state or run side-effecting commands/tools/scripts.',
|
||||
' - DELEGATE: Strict orchestration mode for leads. Delegate the work to teammates and coordinate it, but do not implement it yourself unless you are truly in SOLO MODE.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildMemberTaskProtocol(teamName) {
|
||||
return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
return wrapAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
0. IMPORTANT ID RULE:
|
||||
- If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
|
||||
- task_briefing may show short display labels like #abcd1234; MCP task tools also accept that short task ref.
|
||||
|
|
@ -387,6 +384,13 @@ function buildMemberTaskProtocol(teamName) {
|
|||
- After that follow-up work finishes, add a short task comment with the result, what changed, or what you verified.
|
||||
- After that, run task_complete again before your reply.
|
||||
- Never do comment-driven implementation/fix work while the task is still shown as pending, review, completed, or approved.
|
||||
- After task_complete, if the task needs review AND the team has a member whose role includes reviewing (e.g. "reviewer", "tech-lead", "qa"), IMMEDIATELY call review_request to move it to the review column and notify the reviewer:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>", reviewer: "<reviewer-name>" }
|
||||
Do NOT leave a completed task without sending it to review when review is expected and a reviewer exists.
|
||||
If no team member has a reviewer role, skip review_request — the task stays completed.
|
||||
3b. When you BEGIN reviewing a task, FIRST call review_start to ensure it appears in the REVIEW column:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", from: "<your-name>" }
|
||||
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
|
||||
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
|
||||
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
|
||||
5. If review fails and changes are needed, use MCP tool review_request_changes:
|
||||
|
|
@ -405,8 +409,10 @@ function buildMemberTaskProtocol(teamName) {
|
|||
- If you are reviewing work for task #X, run review_approve/review_request_changes on #X (the work task).
|
||||
- Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) — that will put the wrong task into APPROVED.
|
||||
- Typical flow:
|
||||
a) Owner finishes work on #X -> task_complete #X
|
||||
b) Reviewer accepts -> review_approve #X
|
||||
a) Owner finishes work on #X -> task_complete #X -> review_request #X (moves to review column, notifies reviewer)
|
||||
b) Reviewer begins reviewing -> review_start #X (ensures task is in REVIEW column on kanban)
|
||||
c) Reviewer accepts -> review_approve #X
|
||||
d) Reviewer rejects -> review_request_changes #X (moves back to pending with needsFix)
|
||||
12. CLARIFICATION PROTOCOL (CRITICAL — MANDATORY):
|
||||
When you are blocked and need information to continue a task, you MUST do ALL steps below — skipping the board update or comment breaks traceability:
|
||||
a) STEP 1 — FIRST, set the clarification flag with MCP tool task_set_clarification:
|
||||
|
|
@ -437,7 +443,7 @@ Failure to follow this protocol means the task board will show incorrect status.
|
|||
}
|
||||
|
||||
function buildMemberProcessProtocol(teamName) {
|
||||
return wrapAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
|
||||
return wrapAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
|
||||
1. Launch with & to get PID:
|
||||
pnpm dev &
|
||||
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
|
||||
|
|
@ -450,7 +456,7 @@ If verification in step 3 fails or the process is missing from the list, re-regi
|
|||
}
|
||||
|
||||
function buildMemberFormattingProtocol() {
|
||||
return wrapAgentBlock(`Hidden internal instructions rule (IMPORTANT):
|
||||
return wrapAgentBlock(`Hidden internal instructions rule (IMPORTANT):
|
||||
- If you send internal operational instructions to another agent/teammate that the human user must NOT see in the UI, wrap ONLY that hidden part in:
|
||||
<info_for_agent>
|
||||
... hidden instructions only ...
|
||||
|
|
@ -460,136 +466,136 @@ function buildMemberFormattingProtocol() {
|
|||
}
|
||||
|
||||
function normalizeMemberName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
async function memberBriefing(context, memberName) {
|
||||
const requestedMemberName = String(memberName).trim();
|
||||
const requestedMemberKey = normalizeMemberName(requestedMemberName);
|
||||
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
|
||||
const config = resolved.config || {};
|
||||
if (!requestedMemberName) {
|
||||
throw new Error('Missing member name');
|
||||
}
|
||||
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
|
||||
throw new Error(`Member is removed from the team: ${requestedMemberName}`);
|
||||
}
|
||||
const member =
|
||||
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
||||
null;
|
||||
if (!member) {
|
||||
throw new Error(
|
||||
`Member not found in team metadata or inboxes: ${requestedMemberName}`
|
||||
);
|
||||
}
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const effectiveMember = member;
|
||||
|
||||
const role =
|
||||
typeof effectiveMember.role === 'string' && effectiveMember.role.trim()
|
||||
? effectiveMember.role.trim()
|
||||
: typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim()
|
||||
? effectiveMember.agentType.trim()
|
||||
: 'team member';
|
||||
const workflow =
|
||||
typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim()
|
||||
? effectiveMember.workflow.trim()
|
||||
: '';
|
||||
const cwd =
|
||||
typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim()
|
||||
? effectiveMember.cwd.trim()
|
||||
: typeof config.projectPath === 'string' && config.projectPath.trim()
|
||||
? config.projectPath.trim()
|
||||
: '';
|
||||
|
||||
const activeProcesses = processStore
|
||||
.listProcesses(context.paths)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
entry.alive &&
|
||||
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
||||
);
|
||||
|
||||
const taskQueue = await taskBriefing(context, requestedMemberName);
|
||||
const lines = [
|
||||
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
|
||||
`Role: ${role}.`,
|
||||
`CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`,
|
||||
`CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`,
|
||||
`Team lead: ${leadName}.`,
|
||||
buildMemberLanguageInstruction(config),
|
||||
`You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`,
|
||||
];
|
||||
|
||||
if (workflow) {
|
||||
lines.push('', 'Workflow:', workflow);
|
||||
}
|
||||
|
||||
if (cwd) {
|
||||
lines.push('', `Working directory: ${cwd}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
`Bootstrap flow:`,
|
||||
`1. Use this briefing as your durable rules source.`,
|
||||
`2. Use task_briefing as your compact queue view whenever you need to see assigned work.`,
|
||||
`3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`,
|
||||
`4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`,
|
||||
`5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.`
|
||||
);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
buildMemberActionModeProtocol(),
|
||||
'',
|
||||
buildMemberFormattingProtocol(),
|
||||
'',
|
||||
buildMemberTaskProtocol(context.teamName),
|
||||
'',
|
||||
buildMemberProcessProtocol(context.teamName)
|
||||
);
|
||||
|
||||
if (activeProcesses.length > 0) {
|
||||
lines.push('', 'Active registered processes owned by you:');
|
||||
for (const entry of activeProcesses) {
|
||||
const bits = [`- ${entry.label} (pid ${entry.pid})`];
|
||||
if (entry.port != null) bits.push(`port ${entry.port}`);
|
||||
if (entry.url) bits.push(`url ${entry.url}`);
|
||||
if (entry.command) bits.push(`command ${entry.command}`);
|
||||
lines.push(bits.join(', '));
|
||||
const requestedMemberName = String(memberName).trim();
|
||||
const requestedMemberKey = normalizeMemberName(requestedMemberName);
|
||||
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
|
||||
const config = resolved.config || {};
|
||||
if (!requestedMemberName) {
|
||||
throw new Error('Missing member name');
|
||||
}
|
||||
}
|
||||
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
|
||||
throw new Error(`Member is removed from the team: ${requestedMemberName}`);
|
||||
}
|
||||
const member =
|
||||
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
||||
null;
|
||||
if (!member) {
|
||||
throw new Error(
|
||||
`Member not found in team metadata or inboxes: ${requestedMemberName}`
|
||||
);
|
||||
}
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const effectiveMember = member;
|
||||
|
||||
lines.push('', taskQueue);
|
||||
return lines.join('\n');
|
||||
const role =
|
||||
typeof effectiveMember.role === 'string' && effectiveMember.role.trim() ?
|
||||
effectiveMember.role.trim() :
|
||||
typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim() ?
|
||||
effectiveMember.agentType.trim() :
|
||||
'team member';
|
||||
const workflow =
|
||||
typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim() ?
|
||||
effectiveMember.workflow.trim() :
|
||||
'';
|
||||
const cwd =
|
||||
typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim() ?
|
||||
effectiveMember.cwd.trim() :
|
||||
typeof config.projectPath === 'string' && config.projectPath.trim() ?
|
||||
config.projectPath.trim() :
|
||||
'';
|
||||
|
||||
const activeProcesses = processStore
|
||||
.listProcesses(context.paths)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry &&
|
||||
entry.alive &&
|
||||
normalizeMemberName(entry.registeredBy) === normalizeMemberName(requestedMemberName)
|
||||
);
|
||||
|
||||
const taskQueue = await taskBriefing(context, requestedMemberName);
|
||||
const lines = [
|
||||
`Member briefing for ${requestedMemberName} on team "${context.teamName}" (${context.teamName}).`,
|
||||
`Role: ${role}.`,
|
||||
`CRITICAL: If a task gets a new comment and you are going to do additional implementation/fix/follow-up work on that same task, FIRST leave a short task comment saying what you are about to do, THEN move it to in_progress with task_start, THEN do the work, and when finished leave a short result comment and move it to done with task_complete. Never skip this comment -> reopen -> work -> comment -> done cycle.`,
|
||||
`CRITICAL: A newly assigned task must NOT remain silently pending/TODO. If you are idle and the task is ready to start, start it now. If it must wait because you are already finishing another task, blocked, or still need more context, leave a short task comment on the waiting task immediately with the reason and your best ETA or what you are waiting on, keep it in pending/TODO, and only move it to in_progress with task_start when you truly begin.`,
|
||||
`Team lead: ${leadName}.`,
|
||||
buildMemberLanguageInstruction(config),
|
||||
`You must NOT start work, claim tasks, or improvise task/process protocol before reading and following this briefing.`,
|
||||
];
|
||||
|
||||
if (workflow) {
|
||||
lines.push('', 'Workflow:', workflow);
|
||||
}
|
||||
|
||||
if (cwd) {
|
||||
lines.push('', `Working directory: ${cwd}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
`Bootstrap flow:`,
|
||||
`1. Use this briefing as your durable rules source.`,
|
||||
`2. Use task_briefing as your compact queue view whenever you need to see assigned work.`,
|
||||
`3. Before starting a pending or needs-fix task, call task_get for that specific task if you need the full context. A newly assigned task must not remain silently pending/TODO: if you are idle and the task is ready to start, start it now; if it must wait because another task is already active, because it is blocked, or because you still need more context, add a short task comment with the reason + ETA or what you are waiting on and keep it pending/TODO until you actually begin.`,
|
||||
`4. If this briefing was requested during reconnect, resume in_progress work first, then needs-fix tasks, then pending tasks.`,
|
||||
`5. If you cannot obtain the context you need, notify your team lead ("${leadName}") and wait instead of guessing.`
|
||||
);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
buildMemberActionModeProtocol(),
|
||||
'',
|
||||
buildMemberFormattingProtocol(),
|
||||
'',
|
||||
buildMemberTaskProtocol(context.teamName),
|
||||
'',
|
||||
buildMemberProcessProtocol(context.teamName)
|
||||
);
|
||||
|
||||
if (activeProcesses.length > 0) {
|
||||
lines.push('', 'Active registered processes owned by you:');
|
||||
for (const entry of activeProcesses) {
|
||||
const bits = [`- ${entry.label} (pid ${entry.pid})`];
|
||||
if (entry.port != null) bits.push(`port ${entry.port}`);
|
||||
if (entry.url) bits.push(`url ${entry.url}`);
|
||||
if (entry.command) bits.push(`command ${entry.command}`);
|
||||
lines.push(bits.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('', taskQueue);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addTaskAttachmentMeta,
|
||||
addTaskComment,
|
||||
appendHistoryEvent: taskStore.appendHistoryEvent,
|
||||
attachTaskFile,
|
||||
attachCommentFile,
|
||||
completeTask,
|
||||
createTask,
|
||||
getTask,
|
||||
linkTask,
|
||||
listDeletedTasks,
|
||||
listTasks,
|
||||
removeTaskAttachment,
|
||||
resolveTaskId,
|
||||
restoreTask,
|
||||
setNeedsClarification,
|
||||
setTaskOwner,
|
||||
setTaskStatus,
|
||||
softDeleteTask,
|
||||
startTask,
|
||||
memberBriefing,
|
||||
taskBriefing,
|
||||
unlinkTask,
|
||||
updateTask: (context, taskRef, updater) =>
|
||||
taskStore.updateTask(context.paths, taskRef, updater),
|
||||
updateTaskFields,
|
||||
};
|
||||
addTaskAttachmentMeta,
|
||||
addTaskComment,
|
||||
appendHistoryEvent: taskStore.appendHistoryEvent,
|
||||
attachTaskFile,
|
||||
attachCommentFile,
|
||||
completeTask,
|
||||
createTask,
|
||||
getTask,
|
||||
linkTask,
|
||||
listDeletedTasks,
|
||||
listTasks,
|
||||
removeTaskAttachment,
|
||||
resolveTaskId,
|
||||
restoreTask,
|
||||
setNeedsClarification,
|
||||
setTaskOwner,
|
||||
setTaskStatus,
|
||||
softDeleteTask,
|
||||
startTask,
|
||||
memberBriefing,
|
||||
taskBriefing,
|
||||
unlinkTask,
|
||||
updateTask: (context, taskRef, updater) =>
|
||||
taskStore.updateTask(context.paths, taskRef, updater),
|
||||
updateTaskFields,
|
||||
};
|
||||
|
|
@ -530,6 +530,50 @@ describe('agent-teams-controller API', () => {
|
|||
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
||||
});
|
||||
|
||||
it('starts review idempotently without requiring completed status', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
|
||||
|
||||
// startReview does not require completed status
|
||||
const result = controller.review.startReview(task.id, { from: 'alice' });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.taskId).toBe(task.id);
|
||||
expect(result.displayId).toBe(task.displayId);
|
||||
expect(result.column).toBe('review');
|
||||
|
||||
// Verify kanban state
|
||||
const kanbanState = controller.kanban.getKanbanState();
|
||||
expect(kanbanState.tasks[task.id].column).toBe('review');
|
||||
|
||||
// Verify task reviewState
|
||||
const updatedTask = controller.tasks.getTask(task.id);
|
||||
expect(updatedTask.reviewState).toBe('review');
|
||||
|
||||
// Verify history event
|
||||
const reviewEvent = updatedTask.historyEvents.find((e) => e.type === 'review_started');
|
||||
expect(reviewEvent).toBeDefined();
|
||||
expect(reviewEvent.from).toBe('none');
|
||||
expect(reviewEvent.to).toBe('review');
|
||||
expect(reviewEvent.actor).toBe('alice');
|
||||
|
||||
// Idempotent: calling again should also succeed without duplicate events
|
||||
const again = controller.review.startReview(task.id, { from: 'alice' });
|
||||
expect(again.ok).toBe(true);
|
||||
const reloaded = controller.tasks.getTask(task.id);
|
||||
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
|
||||
expect(startedEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when starting review on a deleted task', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Deleted task', owner: 'bob' });
|
||||
controller.tasks.softDeleteTask(task.id, 'bob');
|
||||
|
||||
expect(() => controller.review.startReview(task.id, { from: 'alice' })).toThrow('is deleted');
|
||||
});
|
||||
|
||||
it('persists full inbox metadata through controller messages.sendMessage', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -617,14 +661,14 @@ describe('agent-teams-controller API', () => {
|
|||
});
|
||||
|
||||
controller.tasks.addTaskComment(task.id, {
|
||||
from: 'alice',
|
||||
from: 'bob',
|
||||
text: 'Need your decision here.',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].from).toBe('alice');
|
||||
expect(rows[0].from).toBe('bob');
|
||||
expect(rows[0].text).toContain('Need your decision here.');
|
||||
});
|
||||
|
||||
|
|
@ -960,4 +1004,95 @@ describe('agent-teams-controller API', () => {
|
|||
await liveServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('lookupMessage', () => {
|
||||
it('finds a message by exact messageId from sentMessages', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const sent = controller.messages.appendSentMessage({
|
||||
from: 'team-lead',
|
||||
to: 'bob',
|
||||
text: 'Please check the logs',
|
||||
source: 'user_sent',
|
||||
});
|
||||
|
||||
const result = controller.messages.lookupMessage(sent.messageId);
|
||||
|
||||
expect(result.message.messageId).toBe(sent.messageId);
|
||||
expect(result.message.text).toBe('Please check the logs');
|
||||
expect(result.store).toBe('sent');
|
||||
});
|
||||
|
||||
it('finds a message by exact messageId from inbox', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const delivered = controller.messages.sendMessage({
|
||||
to: 'bob',
|
||||
from: 'user',
|
||||
text: 'Deploy to staging',
|
||||
source: 'inbox',
|
||||
});
|
||||
|
||||
const result = controller.messages.lookupMessage(delivered.messageId);
|
||||
|
||||
expect(result.message.messageId).toBe(delivered.messageId);
|
||||
expect(result.message.text).toBe('Deploy to staging');
|
||||
expect(result.store).toBe('inbox:bob');
|
||||
});
|
||||
|
||||
it('throws on unknown messageId', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
expect(() => controller.messages.lookupMessage('nonexistent-id')).toThrow(
|
||||
'Message not found: nonexistent-id'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on missing messageId', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
expect(() => controller.messages.lookupMessage('')).toThrow('Missing messageId');
|
||||
});
|
||||
|
||||
it('does not match by relayOfMessageId', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
controller.messages.sendMessage({
|
||||
to: 'bob',
|
||||
from: 'team-lead',
|
||||
text: 'Relayed message',
|
||||
relayOfMessageId: 'original-msg-123',
|
||||
source: 'system_notification',
|
||||
});
|
||||
|
||||
// The relayOfMessageId should NOT be found as a direct messageId match
|
||||
expect(() => controller.messages.lookupMessage('original-msg-123')).toThrow(
|
||||
'Message not found: original-msg-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects ambiguous messageId found in multiple stores', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
// Manually write same messageId to both sent and inbox
|
||||
const sentPath = path.join(claudeDir, 'teams', 'my-team', 'sentMessages.json');
|
||||
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const inboxPath = path.join(inboxDir, 'bob.json');
|
||||
|
||||
const dupeId = 'dupe-message-id';
|
||||
fs.writeFileSync(sentPath, JSON.stringify([{ messageId: dupeId, text: 'copy-1' }]));
|
||||
fs.writeFileSync(inboxPath, JSON.stringify([{ messageId: dupeId, text: 'copy-2' }]));
|
||||
|
||||
expect(() => controller.messages.lookupMessage(dupeId)).toThrow(
|
||||
'Ambiguous messageId: dupe-message-id found in multiple stores'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const os = require('os');
|
|||
const path = require('path');
|
||||
|
||||
const { createController } = require('../src/index.js');
|
||||
const { CROSS_TEAM_SOURCE, CROSS_TEAM_PREFIX_TAG } = require('../src/internal/crossTeamProtocol.js');
|
||||
const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js');
|
||||
|
||||
describe('crossTeam module', () => {
|
||||
function makeClaudeDir(teams = {}) {
|
||||
|
|
@ -60,9 +60,9 @@ describe('crossTeam module', () => {
|
|||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
||||
expect(inbox[0].from).toBe('team-a.lead');
|
||||
expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0`);
|
||||
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`);
|
||||
expect(inbox[0].conversationId).toBeTruthy();
|
||||
expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`);
|
||||
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
|
||||
});
|
||||
|
||||
it('records outbox entry', () => {
|
||||
|
|
@ -121,8 +121,8 @@ describe('crossTeam module', () => {
|
|||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox[0].conversationId).toBe('conv-123');
|
||||
expect(inbox[0].replyToConversationId).toBe('conv-123');
|
||||
expect(inbox[0].text).toContain('conversation:conv-123');
|
||||
expect(inbox[0].text).toContain('replyTo:conv-123');
|
||||
expect(inbox[0].text).toContain('conversationId="conv-123"');
|
||||
expect(inbox[0].text).toContain('replyToConversationId="conv-123"');
|
||||
});
|
||||
|
||||
it('deduplicates the same recent cross-team request', () => {
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export default defineConfig([
|
|||
rules: {
|
||||
// Enforce strict module boundaries for Electron architecture
|
||||
'boundaries/element-types': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
default: 'disallow',
|
||||
rules: [
|
||||
|
|
@ -273,6 +273,8 @@ export default defineConfig([
|
|||
// Allow click handlers on divs when keyboard handlers also present
|
||||
'jsx-a11y/click-events-have-key-events': 'warn',
|
||||
'jsx-a11y/no-static-element-interactions': 'warn',
|
||||
'jsx-a11y/label-has-associated-control': 'warn',
|
||||
'jsx-a11y/no-noninteractive-tabindex': 'warn',
|
||||
// Allow autofocus for search inputs in desktop apps
|
||||
'jsx-a11y/no-autofocus': 'off',
|
||||
|
||||
|
|
@ -295,7 +297,16 @@ export default defineConfig([
|
|||
],
|
||||
|
||||
// Strengthen exhaustive-deps
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
|
||||
// Conditional hooks — warn instead of error for gradual fix
|
||||
'react-hooks/rules-of-hooks': 'warn',
|
||||
|
||||
// React Compiler rules — downgraded to warn for existing code
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/preserve-manual-memoization': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
|
||||
// Prevent prop spreading
|
||||
'react/jsx-props-no-spreading': [
|
||||
|
|
@ -392,7 +403,7 @@ export default defineConfig([
|
|||
|
||||
// === Unused variables ===
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
|
|
@ -537,27 +548,10 @@ export default defineConfig([
|
|||
|
||||
// === Import Restrictions ===
|
||||
// Note: boundaries/element-types handles main/renderer separation
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
// Prevent deep relative imports - use @/ aliases
|
||||
{
|
||||
group: ['../**/..'],
|
||||
message: 'Avoid deep relative imports, use @/ aliases',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': 'warn',
|
||||
|
||||
// === Mutation Prevention ===
|
||||
'no-param-reassign': [
|
||||
'error',
|
||||
{
|
||||
props: true,
|
||||
ignorePropertyModificationsFor: ['draft', 'acc', 'ctx', 'state', 'req', 'res'],
|
||||
},
|
||||
],
|
||||
'no-param-reassign': 'warn',
|
||||
|
||||
// === SonarJS rule adjustments ===
|
||||
// Cognitive complexity - warn instead of error for gradual adoption
|
||||
|
|
@ -569,6 +563,52 @@ export default defineConfig([
|
|||
// Allow nested ternaries in JSX (common React pattern)
|
||||
'sonarjs/no-nested-conditional': 'off',
|
||||
|
||||
// === Downgraded to warn — existing code, fix incrementally ===
|
||||
'sonarjs/slow-regex': 'warn',
|
||||
'sonarjs/pseudo-random': 'warn',
|
||||
'sonarjs/different-types-comparison': 'warn',
|
||||
'sonarjs/deprecation': 'warn',
|
||||
'sonarjs/no-dead-store': 'warn',
|
||||
'sonarjs/unused-import': 'warn',
|
||||
'sonarjs/no-unused-vars': 'warn',
|
||||
'sonarjs/no-commented-code': 'warn',
|
||||
'sonarjs/function-return-type': 'warn',
|
||||
'sonarjs/use-type-alias': 'warn',
|
||||
'sonarjs/no-nested-template-literals': 'warn',
|
||||
'sonarjs/no-alphabetical-sort': 'warn',
|
||||
'sonarjs/no-misleading-array-reverse': 'warn',
|
||||
'sonarjs/no-os-command-from-path': 'warn',
|
||||
'sonarjs/link-with-target-blank': 'warn',
|
||||
'sonarjs/no-unused-collection': 'warn',
|
||||
'sonarjs/todo-tag': 'warn',
|
||||
'sonarjs/reduce-initial-value': 'warn',
|
||||
'sonarjs/concise-regex': 'warn',
|
||||
'sonarjs/void-use': 'warn',
|
||||
'sonarjs/anchor-precedence': 'warn',
|
||||
'sonarjs/no-control-regex': 'warn',
|
||||
'sonarjs/no-nested-functions': 'warn',
|
||||
'sonarjs/no-all-duplicated-branches': 'warn',
|
||||
'@typescript-eslint/no-shadow': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/restrict-template-expressions': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/no-require-imports': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/array-type': 'warn',
|
||||
'no-useless-escape': 'warn',
|
||||
'no-unsafe-finally': 'warn',
|
||||
'no-control-regex': 'warn',
|
||||
'@eslint-community/eslint-comments/require-description': 'warn',
|
||||
'@typescript-eslint/unbound-method': 'warn',
|
||||
|
||||
// === Security rule adjustments (Code Protection) ===
|
||||
// These catch common security mistakes
|
||||
'security/detect-eval-with-expression': 'error',
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"dev": "tsx src/index.ts",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "pnpm build && vitest run test/stdio.e2e.test.ts",
|
||||
"test:e2e": "pnpm build && vitest run --config vitest.e2e.config.ts",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
||||
|
|
|
|||
13
mcp-server/src/agent-teams-controller.d.ts
vendored
13
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -43,11 +43,13 @@ declare module 'agent-teams-controller' {
|
|||
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||
startReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerMessageApi {
|
||||
appendSentMessage(flags: Record<string, unknown>): unknown;
|
||||
sendMessage(flags: Record<string, unknown>): unknown;
|
||||
lookupMessage(messageId: string): { message: Record<string, unknown> };
|
||||
}
|
||||
|
||||
export interface ControllerProcessApi {
|
||||
|
|
@ -85,4 +87,15 @@ declare module 'agent-teams-controller' {
|
|||
}
|
||||
|
||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||
|
||||
export interface AgentBlocksApi {
|
||||
AGENT_BLOCK_TAG: string;
|
||||
AGENT_BLOCK_OPEN: string;
|
||||
AGENT_BLOCK_CLOSE: string;
|
||||
AGENT_BLOCK_RE: RegExp;
|
||||
stripAgentBlocks(text: string): string;
|
||||
wrapAgentBlock(text: string): string;
|
||||
}
|
||||
|
||||
export const agentBlocks: AgentBlocksApi;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ const controllerModule =
|
|||
(agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule;
|
||||
const { createController } = controllerModule;
|
||||
|
||||
/** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */
|
||||
export const agentBlocks = controllerModule.agentBlocks;
|
||||
|
||||
export function getController(teamName: string, claudeDir?: string) {
|
||||
return createController({
|
||||
teamName,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { jsonTextContent, slimTask } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -23,11 +23,31 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'review_start',
|
||||
description: 'Signal that reviewer is beginning to review a task (moves to REVIEW column)',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.startReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
})
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
@ -46,12 +66,14 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
})
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
@ -69,11 +91,13 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(comment ? { comment } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
})
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(comment ? { comment } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { jsonTextContent } from '../utils/format';
|
|||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
controlUrl: z.string().url().optional(),
|
||||
controlUrl: z.string().optional(),
|
||||
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { agentBlocks, getController } from '../controller';
|
||||
import { jsonTextContent, taskWriteResult, slimTask, slimTaskForList } from '../utils/format';
|
||||
|
||||
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
||||
const stripAgentBlocksFn = (text: string): string => agentBlocks.stripAgentBlocks(text);
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -11,6 +14,44 @@ const toolContextSchema = {
|
|||
|
||||
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
||||
|
||||
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
|
||||
const USER_ORIGINATED_SOURCES = new Set(['user_sent']);
|
||||
|
||||
/**
|
||||
* Shared payload builder for task_create and task_create_from_message.
|
||||
*
|
||||
* Both tools MUST stay semantically aligned — any new field added to task_create
|
||||
* that also applies to message-derived tasks must be added here, not duplicated.
|
||||
* Do not turn this into a repo-wide abstraction; keep it local to MCP tools.
|
||||
*/
|
||||
function buildCreateTaskPayload(params: {
|
||||
subject: string;
|
||||
description?: string;
|
||||
owner?: string;
|
||||
createdBy?: string;
|
||||
from?: string;
|
||||
blockedBy?: string[];
|
||||
related?: string[];
|
||||
prompt?: string;
|
||||
startImmediately?: boolean;
|
||||
sourceMessageId?: string;
|
||||
sourceMessage?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
subject: params.subject,
|
||||
...(params.description ? { description: params.description } : {}),
|
||||
...(params.owner ? { owner: params.owner } : {}),
|
||||
...(params.createdBy ? { createdBy: params.createdBy } : {}),
|
||||
...(!params.createdBy && params.from ? { from: params.from } : {}),
|
||||
...(params.blockedBy?.length ? { 'blocked-by': params.blockedBy.join(',') } : {}),
|
||||
...(params.related?.length ? { related: params.related.join(',') } : {}),
|
||||
...(params.prompt ? { prompt: params.prompt } : {}),
|
||||
...(params.startImmediately !== undefined ? { startImmediately: params.startImmediately } : {}),
|
||||
...(params.sourceMessageId ? { sourceMessageId: params.sourceMessageId } : {}),
|
||||
...(params.sourceMessage ? { sourceMessage: params.sourceMessage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
|
|
@ -43,17 +84,127 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
const controller = getController(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
controller.tasks.createTask({
|
||||
subject,
|
||||
...(description ? { description } : {}),
|
||||
...(owner ? { owner } : {}),
|
||||
...(createdBy ? { createdBy } : {}),
|
||||
...(!createdBy && from ? { from } : {}),
|
||||
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
|
||||
...(related?.length ? { related: related.join(',') } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
...(startImmediately !== undefined ? { startImmediately } : {}),
|
||||
})
|
||||
controller.tasks.createTask(
|
||||
buildCreateTaskPayload({
|
||||
subject,
|
||||
description,
|
||||
owner,
|
||||
createdBy,
|
||||
from,
|
||||
blockedBy,
|
||||
related,
|
||||
prompt,
|
||||
startImmediately,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
* task_create_from_message — creates a task from an exact persisted user message.
|
||||
*
|
||||
* This is NOT a heuristic "current context" resolver. It requires an exact messageId
|
||||
* that points to a persisted row in sentMessages.json or an inbox file.
|
||||
* Must reject relay copies, non-user sources, and ambiguous matches.
|
||||
* Must not auto-generate subject or infer importState from attachments.
|
||||
*/
|
||||
server.addTool({
|
||||
name: 'task_create_from_message',
|
||||
description:
|
||||
'Create a task from a persisted user message. Resolves the message by exact messageId, builds sanitized provenance, and creates the task through the canonical path.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
messageId: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
createdBy: z.string().optional(),
|
||||
blockedBy: z.array(z.string().min(1)).optional(),
|
||||
related: z.array(z.string().min(1)).optional(),
|
||||
prompt: z.string().optional(),
|
||||
startImmediately: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
messageId,
|
||||
subject,
|
||||
description,
|
||||
owner,
|
||||
createdBy,
|
||||
blockedBy,
|
||||
related,
|
||||
prompt,
|
||||
startImmediately,
|
||||
}) => {
|
||||
const controller = getController(teamName, claudeDir);
|
||||
|
||||
// 1. Lookup message by exact messageId
|
||||
const { message } = controller.messages.lookupMessage(messageId);
|
||||
|
||||
// 2. Reject if message source is not user-originated
|
||||
const source = typeof message.source === 'string' ? message.source : '';
|
||||
if (!USER_ORIGINATED_SOURCES.has(source)) {
|
||||
throw new Error(
|
||||
`Message source "${source}" is not user-originated. Only user_sent messages are eligible.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Reject relay copies explicitly
|
||||
if (typeof message.relayOfMessageId === 'string' && message.relayOfMessageId.trim()) {
|
||||
throw new Error(
|
||||
'Cannot create task from a relay copy. Use the original message instead.'
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Build sanitized source snapshot
|
||||
const rawText = typeof message.text === 'string' ? message.text : '';
|
||||
const sanitizedText = stripAgentBlocksFn(rawText);
|
||||
|
||||
const sourceMessage: Record<string, unknown> = {
|
||||
text: sanitizedText,
|
||||
from: typeof message.from === 'string' ? message.from : 'unknown',
|
||||
timestamp: typeof message.timestamp === 'string' ? message.timestamp : '',
|
||||
...(source ? { source } : {}),
|
||||
};
|
||||
|
||||
// Preserve attachment metadata by reference only — no blob copying
|
||||
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
||||
sourceMessage.attachments = (message.attachments as Record<string, unknown>[])
|
||||
.filter(
|
||||
(a) =>
|
||||
a &&
|
||||
typeof a === 'object' &&
|
||||
typeof a.id === 'string' &&
|
||||
typeof a.filename === 'string'
|
||||
)
|
||||
.map((a) => ({
|
||||
id: String(a.id),
|
||||
filename: String(a.filename),
|
||||
mimeType: typeof a.mimeType === 'string' ? a.mimeType : '',
|
||||
size: typeof a.size === 'number' ? a.size : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// 5. Forward into canonical create-task path
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
controller.tasks.createTask(
|
||||
buildCreateTaskPayload({
|
||||
subject,
|
||||
description,
|
||||
owner,
|
||||
createdBy,
|
||||
blockedBy,
|
||||
related,
|
||||
prompt,
|
||||
startImmediately,
|
||||
sourceMessageId: messageId,
|
||||
sourceMessage,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
|
|
@ -77,7 +228,11 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.listTasks())),
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
(getController(teamName, claudeDir).tasks.listTasks() as Record<string, unknown>[]).map(slimTaskForList)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -91,7 +246,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor))
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>))
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -104,7 +259,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.startTask(taskId, actor))),
|
||||
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -117,7 +272,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.completeTask(taskId, actor))
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>))
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -131,7 +286,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner))
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>))
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -147,10 +302,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
})
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
@ -179,13 +336,15 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
})
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
@ -216,13 +375,15 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
})
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
@ -238,9 +399,11 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
@ -257,7 +420,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship))
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>))
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -273,7 +436,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship)
|
||||
slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record<string, unknown>)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,3 +8,71 @@ export function jsonTextContent(value: unknown): { content: { type: 'text'; text
|
|||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips heavy fields (comments, historyEvents, workIntervals) from a full task
|
||||
* object to produce a lightweight summary suitable for MCP tool results of
|
||||
* write operations. This prevents context bloat — a task with 14 comments can
|
||||
* be 25 KB; the summary is < 1 KB.
|
||||
*
|
||||
* Only strip from the top-level `task` field; leave other fields intact.
|
||||
*/
|
||||
export function taskWriteResult(result: Record<string, unknown>): Record<string, unknown> {
|
||||
const task = result.task;
|
||||
if (task == null || typeof task !== 'object') return result;
|
||||
|
||||
return { ...result, task: slimTask(task as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal task confirmation for write operations (status changes, owner
|
||||
* assignment, comments, etc.). Uses an allowlist — only fields the caller
|
||||
* needs to verify the mutation succeeded. Agents already know what they
|
||||
* modified, so description/prompt/timestamps are unnecessary here.
|
||||
*/
|
||||
export function slimTask(full: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
id: full.id,
|
||||
displayId: full.displayId,
|
||||
subject: full.subject,
|
||||
status: full.status,
|
||||
owner: full.owner,
|
||||
reviewState: full.reviewState,
|
||||
needsClarification: full.needsClarification,
|
||||
blockedBy: full.blockedBy,
|
||||
blocks: full.blocks,
|
||||
commentCount: Array.isArray(full.comments) ? full.comments.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields that grow unboundedly and dominate context usage.
|
||||
* Everything else passes through — new task fields are included by default.
|
||||
*/
|
||||
const HEAVY_TASK_FIELDS = new Set(['comments', 'historyEvents', 'workIntervals']);
|
||||
|
||||
/**
|
||||
* Lightweight task representation for task_list.
|
||||
*
|
||||
* Uses a BLOCKLIST approach: strips only known heavy array fields and replaces
|
||||
* `comments` with `commentCount`. All other fields (including any future ones)
|
||||
* pass through automatically. This avoids silently dropping new fields when
|
||||
* the task schema evolves.
|
||||
*/
|
||||
export function slimTaskForList(full: Record<string, unknown>): Record<string, unknown> {
|
||||
const slim: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(full)) {
|
||||
if (!HEAVY_TASK_FIELDS.has(key)) {
|
||||
slim[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(full.comments)) {
|
||||
slim.commentCount = full.comments.length;
|
||||
} else {
|
||||
slim.commentCount = 0;
|
||||
}
|
||||
|
||||
return slim;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class McpStdIoClient {
|
|||
}
|
||||
|
||||
private async readMessage(expectedId: number) {
|
||||
const deadline = Date.now() + 5000;
|
||||
const deadline = Date.now() + 15000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
|
|
|
|||
|
|
@ -49,12 +49,14 @@ describe('agent-teams-mcp tools', () => {
|
|||
'review_approve',
|
||||
'review_request',
|
||||
'review_request_changes',
|
||||
'review_start',
|
||||
'task_add_comment',
|
||||
'task_attach_comment_file',
|
||||
'task_attach_file',
|
||||
'task_briefing',
|
||||
'task_complete',
|
||||
'task_create',
|
||||
'task_create_from_message',
|
||||
'task_get',
|
||||
'task_link',
|
||||
'task_list',
|
||||
|
|
@ -773,6 +775,27 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined();
|
||||
|
||||
// review_start: moves task to review without requiring completed status
|
||||
const pendingTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Start review test',
|
||||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
const reviewStarted = parseJsonToolResult(
|
||||
await getTool('review_start').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: pendingTask.id,
|
||||
from: 'alice',
|
||||
})
|
||||
);
|
||||
expect(reviewStarted.ok).toBe(true);
|
||||
expect(reviewStarted.column).toBe('review');
|
||||
expect(reviewStarted.taskId).toBe(pendingTask.id);
|
||||
|
||||
const pid = process.pid;
|
||||
|
||||
const registered = parseJsonToolResult(
|
||||
|
|
@ -916,4 +939,533 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(reloaded.comments).toHaveLength(1);
|
||||
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
|
||||
});
|
||||
|
||||
it('write operations return slim task (no comments/historyEvents arrays)', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'slim-check';
|
||||
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, { members: [{ name: 'lead' }] });
|
||||
|
||||
const task = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Slim task test',
|
||||
owner: 'lead',
|
||||
notifyOwner: false,
|
||||
})
|
||||
);
|
||||
|
||||
// task_create returns full task (read operation)
|
||||
expect(task.historyEvents).toBeDefined();
|
||||
|
||||
// Add a comment so commentCount > 0
|
||||
const commented = parseJsonToolResult(
|
||||
await getTool('task_add_comment').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: task.id,
|
||||
text: 'test comment',
|
||||
from: 'lead',
|
||||
})
|
||||
);
|
||||
|
||||
// task_add_comment: nested task should be slim
|
||||
expect(commented.commentId).toBeTruthy();
|
||||
expect(commented.comment.text).toBe('test comment');
|
||||
expect(commented.task.commentCount).toBe(1);
|
||||
expect(commented.task.comments).toBeUndefined();
|
||||
expect(commented.task.historyEvents).toBeUndefined();
|
||||
|
||||
// task_start: returns slim task directly
|
||||
const started = parseJsonToolResult(
|
||||
await getTool('task_start').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: task.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(started.status).toBe('in_progress');
|
||||
expect(started.commentCount).toBe(1);
|
||||
expect(started.comments).toBeUndefined();
|
||||
expect(started.historyEvents).toBeUndefined();
|
||||
expect(started.workIntervals).toBeUndefined();
|
||||
|
||||
// task_complete: returns slim task directly
|
||||
const completed = parseJsonToolResult(
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: task.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.comments).toBeUndefined();
|
||||
|
||||
// task_list: uses blocklist, includes description but not comments array
|
||||
const listed = parseJsonToolResult(
|
||||
await getTool('task_list').execute({ claudeDir, teamName })
|
||||
);
|
||||
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
||||
expect(listedTask).toBeDefined();
|
||||
expect(listedTask.subject).toBe('Slim task test');
|
||||
expect(listedTask.commentCount).toBe(1);
|
||||
expect(listedTask.comments).toBeUndefined();
|
||||
expect(listedTask.historyEvents).toBeUndefined();
|
||||
expect(listedTask.workIntervals).toBeUndefined();
|
||||
// task_list preserves non-heavy fields
|
||||
expect(listedTask.status).toBeDefined();
|
||||
expect(listedTask.id).toBeDefined();
|
||||
|
||||
// task_get: still returns full task with comments
|
||||
const full = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
expect(full.comments).toHaveLength(1);
|
||||
expect(full.historyEvents).toBeDefined();
|
||||
});
|
||||
|
||||
describe('task_create_from_message', () => {
|
||||
function writeSentMessage(
|
||||
claudeDir: string,
|
||||
teamName: string,
|
||||
message: Record<string, unknown>
|
||||
) {
|
||||
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
const existing = fs.existsSync(sentPath)
|
||||
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
|
||||
: [];
|
||||
existing.push(message);
|
||||
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
||||
}
|
||||
|
||||
function writeInboxMessage(
|
||||
claudeDir: string,
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
message: Record<string, unknown>
|
||||
) {
|
||||
const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const inboxPath = path.join(inboxDir, `${memberName}.json`);
|
||||
const existing = fs.existsSync(inboxPath)
|
||||
? JSON.parse(fs.readFileSync(inboxPath, 'utf8'))
|
||||
: [];
|
||||
existing.push(message);
|
||||
fs.writeFileSync(inboxPath, JSON.stringify(existing, null, 2));
|
||||
}
|
||||
|
||||
it('creates a task from a valid user message with provenance', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'msg-team';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
const messageId = 'msg-user-001';
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId,
|
||||
from: 'user',
|
||||
to: 'team-lead',
|
||||
text: 'Please implement the login page',
|
||||
timestamp: '2026-03-15T10:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
});
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
await getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId,
|
||||
subject: 'Implement login page',
|
||||
owner: 'lead',
|
||||
})
|
||||
);
|
||||
|
||||
expect(created.subject).toBe('Implement login page');
|
||||
expect(created.owner).toBe('lead');
|
||||
expect(created.sourceMessageId).toBe(messageId);
|
||||
expect(created.sourceMessage).toBeDefined();
|
||||
expect(created.sourceMessage.text).toBe('Please implement the login page');
|
||||
expect(created.sourceMessage.from).toBe('user');
|
||||
expect(created.sourceMessage.timestamp).toBe('2026-03-15T10:00:00.000Z');
|
||||
expect(created.sourceMessage.source).toBe('user_sent');
|
||||
});
|
||||
|
||||
it('strips agent-only blocks from source text', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'strip-team';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
const messageId = 'msg-with-agent-blocks';
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId,
|
||||
from: 'user',
|
||||
text: 'Fix the bug <info_for_agent>\nuse task_create to track\n</info_for_agent> in the API',
|
||||
timestamp: '2026-03-15T11:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
});
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
await getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId,
|
||||
subject: 'Fix API bug',
|
||||
})
|
||||
);
|
||||
|
||||
expect(created.sourceMessage.text).toBe('Fix the bug in the API');
|
||||
expect(created.sourceMessage.text).not.toContain('info_for_agent');
|
||||
});
|
||||
|
||||
it('rejects unknown messageId', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'unknown-msg';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId: 'nonexistent-msg',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('Message not found: nonexistent-msg');
|
||||
});
|
||||
|
||||
it('rejects non-user-originated message sources', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'source-reject';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
const messageId = 'msg-system-001';
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId,
|
||||
from: 'system',
|
||||
text: 'System generated notification',
|
||||
timestamp: '2026-03-15T12:00:00.000Z',
|
||||
source: 'system_notification',
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId,
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
});
|
||||
|
||||
it('rejects lead_process and cross_team sources explicitly', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'source-reject-2';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId: 'msg-lead-001',
|
||||
from: 'team-lead',
|
||||
text: 'Lead process message',
|
||||
timestamp: '2026-03-15T12:01:00.000Z',
|
||||
source: 'lead_process',
|
||||
});
|
||||
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId: 'msg-cross-001',
|
||||
from: 'other-team.lead',
|
||||
text: 'Cross team message',
|
||||
timestamp: '2026-03-15T12:02:00.000Z',
|
||||
source: 'cross_team',
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId: 'msg-lead-001',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId: 'msg-cross-001',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
});
|
||||
|
||||
it('rejects messages without an explicit source field (fail closed)', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'no-source';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId: 'msg-no-source',
|
||||
from: 'user',
|
||||
text: 'Old message without source field',
|
||||
timestamp: '2026-03-15T12:03:00.000Z',
|
||||
// no source field
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId: 'msg-no-source',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
});
|
||||
|
||||
it('rejects relay copies', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'relay-reject';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
const messageId = 'msg-relay-001';
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId,
|
||||
from: 'user',
|
||||
text: 'Relayed content',
|
||||
timestamp: '2026-03-15T13:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
relayOfMessageId: 'original-msg-999',
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId,
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('relay copy');
|
||||
});
|
||||
|
||||
it('preserves attachment metadata without blob copying', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'attach-meta';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
const messageId = 'msg-attach-001';
|
||||
writeInboxMessage(claudeDir, teamName, 'lead', {
|
||||
messageId,
|
||||
from: 'user',
|
||||
to: 'lead',
|
||||
text: 'See attached screenshot',
|
||||
timestamp: '2026-03-15T14:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
attachments: [
|
||||
{ id: 'att-1', filename: 'screenshot.png', mimeType: 'image/png', size: 42000 },
|
||||
],
|
||||
});
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
await getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId,
|
||||
subject: 'Review screenshot',
|
||||
})
|
||||
);
|
||||
|
||||
expect(created.sourceMessage.attachments).toHaveLength(1);
|
||||
expect(created.sourceMessage.attachments[0].id).toBe('att-1');
|
||||
expect(created.sourceMessage.attachments[0].filename).toBe('screenshot.png');
|
||||
expect(created.sourceMessage.attachments[0].mimeType).toBe('image/png');
|
||||
expect(created.sourceMessage.attachments[0].size).toBe(42000);
|
||||
});
|
||||
|
||||
it('produces the same canonical task shape as task_create plus provenance', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'parity-check';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
const messageId = 'msg-parity-001';
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId,
|
||||
from: 'user',
|
||||
text: 'Build the dashboard',
|
||||
timestamp: '2026-03-15T15:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
});
|
||||
|
||||
const fromMessage = parseJsonToolResult(
|
||||
await getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId,
|
||||
subject: 'Build dashboard',
|
||||
description: 'Create the main dashboard view',
|
||||
owner: 'lead',
|
||||
})
|
||||
);
|
||||
|
||||
const regular = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Build dashboard (regular)',
|
||||
description: 'Create the main dashboard view',
|
||||
owner: 'lead',
|
||||
})
|
||||
);
|
||||
|
||||
// Both have the same canonical shape
|
||||
expect(fromMessage.status).toBe(regular.status);
|
||||
expect(fromMessage.historyEvents).toHaveLength(regular.historyEvents.length);
|
||||
expect(typeof fromMessage.id).toBe(typeof regular.id);
|
||||
expect(typeof fromMessage.displayId).toBe(typeof regular.displayId);
|
||||
|
||||
// Only the from_message task has provenance
|
||||
expect(fromMessage.sourceMessageId).toBe(messageId);
|
||||
expect(fromMessage.sourceMessage).toBeDefined();
|
||||
expect(regular.sourceMessageId).toBeUndefined();
|
||||
expect(regular.sourceMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('survives create → persist → read round-trip with provenance intact', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'roundtrip';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
const messageId = 'msg-roundtrip-001';
|
||||
writeSentMessage(claudeDir, teamName, {
|
||||
messageId,
|
||||
from: 'user',
|
||||
text: 'Roundtrip test message',
|
||||
timestamp: '2026-03-15T16:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
attachments: [
|
||||
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
|
||||
],
|
||||
});
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
await getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
messageId,
|
||||
subject: 'Roundtrip task',
|
||||
description: 'Test persistence',
|
||||
})
|
||||
);
|
||||
|
||||
// Re-read from disk via task_get to verify persistence
|
||||
const reloaded = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: created.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(reloaded.sourceMessageId).toBe(messageId);
|
||||
expect(reloaded.sourceMessage).toBeDefined();
|
||||
expect(reloaded.sourceMessage.text).toBe('Roundtrip test message');
|
||||
expect(reloaded.sourceMessage.from).toBe('user');
|
||||
expect(reloaded.sourceMessage.timestamp).toBe('2026-03-15T16:00:00.000Z');
|
||||
expect(reloaded.sourceMessage.source).toBe('user_sent');
|
||||
expect(reloaded.sourceMessage.attachments).toHaveLength(1);
|
||||
expect(reloaded.sourceMessage.attachments[0].id).toBe('att-rt');
|
||||
});
|
||||
|
||||
it('old tasks without provenance continue to read normally', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'legacy';
|
||||
fs.mkdirSync(path.join(claudeDir, 'tasks', teamName), { recursive: true });
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
// Create a regular task (no provenance)
|
||||
const regular = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Legacy task without provenance',
|
||||
})
|
||||
);
|
||||
|
||||
// Re-read — should work without provenance fields
|
||||
const reloaded = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: regular.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(reloaded.subject).toBe('Legacy task without provenance');
|
||||
expect(reloaded.sourceMessageId).toBeUndefined();
|
||||
expect(reloaded.sourceMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates zod schema rejects missing required fields', () => {
|
||||
expect(
|
||||
getTool('task_create_from_message').parameters?.safeParse({
|
||||
teamName: 'demo',
|
||||
messageId: 'msg-1',
|
||||
// subject is missing
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
getTool('task_create_from_message').parameters?.safeParse({
|
||||
teamName: 'demo',
|
||||
// messageId is missing
|
||||
subject: 'Test',
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
getTool('task_create_from_message').parameters?.safeParse({
|
||||
teamName: 'demo',
|
||||
messageId: 'msg-1',
|
||||
subject: 'Valid',
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export default defineConfig({
|
|||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: ['test/**/*.e2e.test.ts'],
|
||||
testTimeout: 15_000,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
10
mcp-server/vitest.e2e.config.ts
Normal file
10
mcp-server/vitest.e2e.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.e2e.test.ts'],
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
});
|
||||
|
|
@ -16,17 +16,17 @@
|
|||
// On Windows this saturates all threads, blocking the event loop.
|
||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||
|
||||
import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
||||
import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
||||
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
||||
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
||||
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
||||
import { TeamBackupService } from '@main/services/team/TeamBackupService';
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import {
|
||||
CONTEXT_CHANGED,
|
||||
SCHEDULE_CHANGE,
|
||||
|
|
@ -51,16 +51,32 @@ import { existsSync } from 'fs';
|
|||
import { join } from 'path';
|
||||
|
||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||
import { setReviewMainWindow } from './ipc/review';
|
||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||
import { setReviewMainWindow } from './ipc/review';
|
||||
import {
|
||||
ApiKeyService,
|
||||
ExtensionFacadeService,
|
||||
GlamaMcpEnrichmentService,
|
||||
McpCatalogAggregator,
|
||||
McpHealthDiagnosticsService,
|
||||
McpInstallationStateService,
|
||||
McpInstallService,
|
||||
OfficialMcpRegistryService,
|
||||
PluginCatalogService,
|
||||
PluginInstallationStateService,
|
||||
PluginInstallService,
|
||||
SkillsCatalogService,
|
||||
SkillsMutationService,
|
||||
SkillsWatcherService,
|
||||
} from './services/extensions';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
import {
|
||||
buildTeamControlApiBaseUrl,
|
||||
clearTeamControlApiState,
|
||||
writeTeamControlApiState,
|
||||
} from './services/team/TeamControlApiState';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||
import { getAppIconPath } from './utils/appIcon';
|
||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||
|
|
@ -80,22 +96,6 @@ import {
|
|||
TeamProvisioningService,
|
||||
UpdaterService,
|
||||
} from './services';
|
||||
import {
|
||||
ApiKeyService,
|
||||
ExtensionFacadeService,
|
||||
GlamaMcpEnrichmentService,
|
||||
McpCatalogAggregator,
|
||||
McpHealthDiagnosticsService,
|
||||
McpInstallationStateService,
|
||||
McpInstallService,
|
||||
OfficialMcpRegistryService,
|
||||
PluginCatalogService,
|
||||
PluginInstallationStateService,
|
||||
PluginInstallService,
|
||||
SkillsCatalogService,
|
||||
SkillsMutationService,
|
||||
SkillsWatcherService,
|
||||
} from './services/extensions';
|
||||
|
||||
import type { FileChangeEvent } from '@main/types';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
|
@ -255,16 +255,27 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
const summary = msg.summary || extracted.summary;
|
||||
const msgId = msg.timestamp ?? String(prevCount + i);
|
||||
|
||||
// Cross-team messages get their own event type and per-type toggle
|
||||
const isCrossTeam = msg.source === 'cross_team';
|
||||
const eventType: 'lead_inbox' | 'user_inbox' | 'cross_team_message' = isCrossTeam
|
||||
? 'cross_team_message'
|
||||
: isLeadInbox
|
||||
? 'lead_inbox'
|
||||
: 'user_inbox';
|
||||
const effectiveSuppressToast = isCrossTeam
|
||||
? !config.notifications.enabled || !config.notifications.notifyOnCrossTeamMessage
|
||||
: suppressToast;
|
||||
|
||||
void notificationManager
|
||||
.addTeamNotification({
|
||||
teamEventType: isLeadInbox ? 'lead_inbox' : 'user_inbox',
|
||||
teamEventType: eventType,
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: fromLabel,
|
||||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||
suppressToast,
|
||||
suppressToast: effectiveSuppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ function validateNotificationsSection(
|
|||
'snoozeMinutes',
|
||||
'notifyOnStatusChange',
|
||||
'notifyOnTaskComments',
|
||||
'notifyOnTaskCreated',
|
||||
'notifyOnAllTasksCompleted',
|
||||
'notifyOnCrossTeamMessage',
|
||||
'statusChangeOnlySolo',
|
||||
'statusChangeStatuses',
|
||||
'triggers',
|
||||
|
|
@ -178,6 +181,24 @@ function validateNotificationsSection(
|
|||
}
|
||||
result.notifyOnTaskComments = value;
|
||||
break;
|
||||
case 'notifyOnTaskCreated':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
}
|
||||
result.notifyOnTaskCreated = value;
|
||||
break;
|
||||
case 'notifyOnAllTasksCompleted':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
}
|
||||
result.notifyOnAllTasksCompleted = value;
|
||||
break;
|
||||
case 'notifyOnCrossTeamMessage':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
}
|
||||
result.notifyOnCrossTeamMessage = value;
|
||||
break;
|
||||
case 'statusChangeOnlySolo':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import {
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { isAgentActionMode } from '../services/team/actionModeInstructions';
|
||||
|
||||
import { validateTaskId, validateTeamName } from './guards';
|
||||
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
import type { IpcResult, TaskRef } from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:crossTeam');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,30 +6,6 @@
|
|||
* Phase 5: install/uninstall mutations.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeyLookupResult,
|
||||
ApiKeySaveRequest,
|
||||
ApiKeyStorageStatus,
|
||||
EnrichedPlugin,
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
McpServerDiagnostic,
|
||||
McpSearchResult,
|
||||
OperationResult,
|
||||
PluginInstallRequest,
|
||||
} from '@shared/types/extensions';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
|
||||
|
||||
import {
|
||||
API_KEYS_DELETE,
|
||||
API_KEYS_LIST,
|
||||
|
|
@ -50,9 +26,32 @@ import {
|
|||
PLUGIN_INSTALL,
|
||||
PLUGIN_UNINSTALL,
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
||||
|
||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeyLookupResult,
|
||||
ApiKeySaveRequest,
|
||||
ApiKeyStorageStatus,
|
||||
EnrichedPlugin,
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
McpSearchResult,
|
||||
McpServerDiagnostic,
|
||||
OperationResult,
|
||||
PluginInstallRequest,
|
||||
} from '@shared/types/extensions';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:extensions');
|
||||
|
||||
/** Allowed scope values */
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ import {
|
|||
registerSessionHandlers,
|
||||
removeSessionHandlers,
|
||||
} from './sessions';
|
||||
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
|
||||
import { initializeSkillsHandlers, registerSkillsHandlers, removeSkillsHandlers } from './skills';
|
||||
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
|
||||
import {
|
||||
initializeSubagentHandlers,
|
||||
registerSubagentHandlers,
|
||||
|
|
@ -103,18 +103,18 @@ import type {
|
|||
TeamProvisioningService,
|
||||
UpdaterService,
|
||||
} from '../services';
|
||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
|
||||
import type { SkillsCatalogService } from '../services/extensions/skills/SkillsCatalogService';
|
||||
import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService';
|
||||
import type { SkillsWatcherService } from '../services/extensions/skills/SkillsWatcherService';
|
||||
import type { McpHealthDiagnosticsService } from '../services/extensions/state/McpHealthDiagnosticsService';
|
||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
||||
|
||||
/**
|
||||
* Initializes IPC handlers with service registry.
|
||||
|
|
|
|||
|
|
@ -1,18 +1,3 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import type {
|
||||
SkillCatalogItem,
|
||||
SkillDeleteRequest,
|
||||
SkillDetail,
|
||||
SkillImportRequest,
|
||||
SkillReviewPreview,
|
||||
SkillUpsertRequest,
|
||||
} from '@shared/types/extensions';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
import type { SkillsCatalogService } from '../services/extensions/skills/SkillsCatalogService';
|
||||
import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService';
|
||||
import type { SkillsWatcherService } from '../services/extensions/skills/SkillsWatcherService';
|
||||
|
||||
import {
|
||||
SKILLS_APPLY_IMPORT,
|
||||
SKILLS_APPLY_UPSERT,
|
||||
|
|
@ -24,6 +9,20 @@ import {
|
|||
SKILLS_START_WATCHING,
|
||||
SKILLS_STOP_WATCHING,
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { SkillsCatalogService } from '../services/extensions/skills/SkillsCatalogService';
|
||||
import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService';
|
||||
import type { SkillsWatcherService } from '../services/extensions/skills/SkillsWatcherService';
|
||||
import type {
|
||||
SkillCatalogItem,
|
||||
SkillDeleteRequest,
|
||||
SkillDetail,
|
||||
SkillImportRequest,
|
||||
SkillReviewPreview,
|
||||
SkillUpsertRequest,
|
||||
} from '@shared/types/extensions';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:skills');
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ import {
|
|||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
TEAM_LIST,
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
TEAM_PROCESS_ALIVE,
|
||||
|
|
@ -70,17 +70,18 @@ import {
|
|||
} from '@shared/utils/cliArgsParser';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import crypto from 'crypto';
|
||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||
import {
|
||||
buildActionModeAgentBlock,
|
||||
isAgentActionMode,
|
||||
} from '../services/team/actionModeInstructions';
|
||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
|
|
@ -111,13 +112,13 @@ import type {
|
|||
GlobalTask,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadContextUsage,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsage,
|
||||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
|
|
@ -546,7 +547,10 @@ async function handlePermanentlyDeleteTeam(
|
|||
.rm(path.join(appData, 'attachments', validated.value!), { recursive: true, force: true })
|
||||
.catch(() => undefined);
|
||||
await fs.promises
|
||||
.rm(path.join(appData, 'task-attachments', validated.value!), { recursive: true, force: true })
|
||||
.rm(path.join(appData, 'task-attachments', validated.value!), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
// Mark in backup registry AFTER successful deletion
|
||||
if (teamBackupService) {
|
||||
|
|
@ -1204,12 +1208,19 @@ async function handleSendMessage(
|
|||
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
const resolvedLeadName = leadName ?? memberName;
|
||||
// Pre-generate stable messageId so both stdin and persistence use the same identity.
|
||||
// This allows the lead to call task_create_from_message with the exact messageId.
|
||||
const preGeneratedMessageId = crypto.randomUUID();
|
||||
// Separate try blocks: stdin delivery vs persistence
|
||||
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
|
||||
// Wrap with instructions so lead responds with visible text (not just agent-only blocks)
|
||||
const wrappedText = [
|
||||
`You received a direct message from the user.`,
|
||||
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
|
||||
AGENT_BLOCK_OPEN,
|
||||
`MessageId: ${preGeneratedMessageId}`,
|
||||
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
``,
|
||||
`Message from user:`,
|
||||
buildMessageDeliveryText(payload.text!, {
|
||||
|
|
@ -1252,11 +1263,12 @@ async function handleSendMessage(
|
|||
payload.text!,
|
||||
payload.summary,
|
||||
attachmentMeta,
|
||||
validatedTaskRefs.value
|
||||
validatedTaskRefs.value,
|
||||
preGeneratedMessageId
|
||||
);
|
||||
} catch (persistError) {
|
||||
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
|
||||
result = { deliveredToInbox: false, messageId: `stdin-${Date.now()}` };
|
||||
result = { deliveredToInbox: false, messageId: preGeneratedMessageId };
|
||||
}
|
||||
|
||||
// Save attachment binary data to disk (best-effort)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ export interface DetectedError {
|
|||
| 'task_clarification'
|
||||
| 'task_status_change'
|
||||
| 'task_comment'
|
||||
| 'task_created'
|
||||
| 'all_tasks_completed'
|
||||
| 'cross_team_message'
|
||||
| 'schedule_completed'
|
||||
| 'schedule_failed';
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@
|
|||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { type McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
import { type PluginCatalogService } from './catalog/PluginCatalogService';
|
||||
import { type McpInstallationStateService } from './state/McpInstallationStateService';
|
||||
import { type PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||
|
||||
import type {
|
||||
EnrichedPlugin,
|
||||
InstalledMcpEntry,
|
||||
|
|
@ -15,11 +21,6 @@ import type {
|
|||
PluginCatalogItem,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
import { PluginCatalogService } from './catalog/PluginCatalogService';
|
||||
import { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
import { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||
import { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||
|
||||
const logger = createLogger('Extensions:Facade');
|
||||
|
||||
export class ExtensionFacadeService {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@
|
|||
* Storage file: ~/.claude/api-keys.json
|
||||
*/
|
||||
|
||||
import { safeStorage } from 'electron';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { safeStorage } from 'electron';
|
||||
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeyLookupResult,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
|
||||
import https from 'node:https';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
const logger = createLogger('Extensions:GitHubStars');
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ export class GitHubStarsService {
|
|||
*/
|
||||
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
|
||||
const result: Record<string, number> = {};
|
||||
const tasks: Array<{ url: string; owner: string; repo: string }> = [];
|
||||
const tasks: { url: string; owner: string; repo: string }[] = [];
|
||||
|
||||
for (const url of repositoryUrls) {
|
||||
const parsed = parseGitHubOwnerRepo(url);
|
||||
|
|
@ -107,10 +107,10 @@ export class GitHubStarsService {
|
|||
* Run async tasks with a concurrency limit.
|
||||
*/
|
||||
private async withConcurrencyLimit(
|
||||
tasks: Array<() => Promise<void>>,
|
||||
tasks: (() => Promise<void>)[],
|
||||
limit: number
|
||||
): Promise<Array<'ok' | 'error'>> {
|
||||
const results: Array<'ok' | 'error'> = [];
|
||||
): Promise<('ok' | 'error')[]> {
|
||||
const results: ('ok' | 'error')[] = [];
|
||||
let index = 0;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@
|
|||
* Cursor-based pagination (after), no auth required.
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:GlamaMcp');
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@
|
|||
* - Provides getById() for secure install flow
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { McpCatalogItem, McpSearchResult } from '@shared/types/extensions';
|
||||
import { normalizeRepoUrl } from '@shared/utils/extensionNormalizers';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { OfficialMcpRegistryService } from './OfficialMcpRegistryService';
|
||||
import { GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService';
|
||||
import { type GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService';
|
||||
import { type OfficialMcpRegistryService } from './OfficialMcpRegistryService';
|
||||
|
||||
import type { McpCatalogItem, McpSearchResult } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:McpAggregator');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@
|
|||
* Filters for _meta.isLatest to pick only latest versions.
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type {
|
||||
McpAuthHeaderDef,
|
||||
McpCatalogItem,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@
|
|||
* - Deduplicates concurrent requests
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { PluginCatalogItem } from '@shared/types/extensions';
|
||||
import { buildPluginId } from '@shared/utils/extensionNormalizers';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { PluginCatalogItem } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:PluginCatalog');
|
||||
|
||||
|
|
@ -260,7 +261,7 @@ export class PluginCatalogService {
|
|||
|
||||
const json = JSON.parse(response.body) as MarketplaceJson;
|
||||
const items = this.parseMarketplace(json);
|
||||
const etag = (response.headers['etag'] as string) ?? null;
|
||||
const etag = (response.headers.etag as string) ?? null;
|
||||
|
||||
this.cache = { items, etag, fetchedAt: Date.now() };
|
||||
logger.info(`Fetched ${items.length} plugins from marketplace "${json.name}"`);
|
||||
|
|
@ -311,7 +312,7 @@ export class PluginCatalogService {
|
|||
* e.g. https://github.com/org/repo → https://raw.githubusercontent.com/org/repo/main/README.md
|
||||
*/
|
||||
private buildReadmeUrl(repoUrl: string): string | null {
|
||||
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
const match = /github\.com\/([^/]+)\/([^/]+)/.exec(repoUrl);
|
||||
if (!match) return null;
|
||||
const [, owner, repo] = match;
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
|
||||
|
|
|
|||
|
|
@ -2,26 +2,26 @@
|
|||
* Extension services barrel export.
|
||||
*/
|
||||
|
||||
export { PluginCatalogService } from './catalog/PluginCatalogService';
|
||||
export { OfficialMcpRegistryService } from './catalog/OfficialMcpRegistryService';
|
||||
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||
export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService';
|
||||
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
||||
export { PluginInstallService } from './install/PluginInstallService';
|
||||
export { McpInstallService } from './install/McpInstallService';
|
||||
export { ApiKeyService } from './apikeys/ApiKeyService';
|
||||
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
||||
export { SkillRootsResolver } from './skills/SkillRootsResolver';
|
||||
export { SkillScanner } from './skills/SkillScanner';
|
||||
export { SkillMetadataParser } from './skills/SkillMetadataParser';
|
||||
export { SkillValidator } from './skills/SkillValidator';
|
||||
export { SkillsCatalogService } from './skills/SkillsCatalogService';
|
||||
export { SkillScaffoldService } from './skills/SkillScaffoldService';
|
||||
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
export { OfficialMcpRegistryService } from './catalog/OfficialMcpRegistryService';
|
||||
export { PluginCatalogService } from './catalog/PluginCatalogService';
|
||||
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
||||
export { McpInstallService } from './install/McpInstallService';
|
||||
export { PluginInstallService } from './install/PluginInstallService';
|
||||
export { SkillImportService } from './skills/SkillImportService';
|
||||
export { SkillMetadataParser } from './skills/SkillMetadataParser';
|
||||
export { SkillPlanService } from './skills/SkillPlanService';
|
||||
export { SkillReviewService } from './skills/SkillReviewService';
|
||||
export { SkillRootsResolver } from './skills/SkillRootsResolver';
|
||||
export { SkillScaffoldService } from './skills/SkillScaffoldService';
|
||||
export { SkillScanner } from './skills/SkillScanner';
|
||||
export { SkillsCatalogService } from './skills/SkillsCatalogService';
|
||||
export { SkillsMutationService } from './skills/SkillsMutationService';
|
||||
export { SkillsWatcherService } from './skills/SkillsWatcherService';
|
||||
export { SkillValidator } from './skills/SkillValidator';
|
||||
export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService';
|
||||
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||
import type {
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
OperationResult,
|
||||
} from '@shared/types/extensions';
|
||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||
|
||||
const logger = createLogger('Extensions:McpInstall');
|
||||
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
||||
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
||||
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:PluginInstall');
|
||||
|
||||
|
|
|
|||
|
|
@ -101,13 +101,11 @@ export class SkillImportService {
|
|||
}
|
||||
}
|
||||
|
||||
private async walkDirectory(
|
||||
rootDir: string
|
||||
): Promise<{
|
||||
files: Array<{ absolutePath: string; relativePath: string }>;
|
||||
private async walkDirectory(rootDir: string): Promise<{
|
||||
files: { absolutePath: string; relativePath: string }[];
|
||||
hiddenEntriesSkipped: number;
|
||||
}> {
|
||||
const allFiles: Array<{ absolutePath: string; relativePath: string }> = [];
|
||||
const allFiles: { absolutePath: string; relativePath: string }[] = [];
|
||||
let hiddenEntriesSkipped = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import YAML from 'yaml';
|
||||
|
||||
import type { ResolvedSkillRoot } from './SkillRootsResolver';
|
||||
import type {
|
||||
SkillCatalogItem,
|
||||
SkillDetail,
|
||||
|
|
@ -8,9 +11,6 @@ import type {
|
|||
SkillInvocationMode,
|
||||
SkillValidationIssue,
|
||||
} from '@shared/types/extensions';
|
||||
import YAML from 'yaml';
|
||||
|
||||
import type { ResolvedSkillRoot } from './SkillRootsResolver';
|
||||
|
||||
const logger = createLogger('Extensions:SkillParser');
|
||||
|
||||
|
|
@ -203,7 +203,7 @@ export class SkillMetadataParser {
|
|||
};
|
||||
}
|
||||
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/u);
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/u.exec(content);
|
||||
if (!match) {
|
||||
return {
|
||||
rawFrontmatter: null,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { SkillScanner } from './SkillScanner';
|
||||
|
||||
import type { ImportedSkillSourceFile } from './SkillImportService';
|
||||
import type {
|
||||
SkillDraftFile,
|
||||
SkillReviewFileChange,
|
||||
|
|
@ -10,10 +13,6 @@ import type {
|
|||
SkillReviewSummary,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
import type { ImportedSkillSourceFile } from './SkillImportService';
|
||||
|
||||
import { SkillScanner } from './SkillScanner';
|
||||
|
||||
type SkillPlanInputFile =
|
||||
| { relativePath: string; isBinary: false; content: string }
|
||||
| { relativePath: string; isBinary: true; sourceAbsolutePath: string };
|
||||
|
|
@ -73,7 +72,7 @@ export class SkillPlanService {
|
|||
async applyPlan(plan: SkillExecutionPlan): Promise<void> {
|
||||
const backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-backup-'));
|
||||
const createdPaths: string[] = [];
|
||||
const backups: Array<{ absolutePath: string; backupPath: string }> = [];
|
||||
const backups: { absolutePath: string; backupPath: string }[] = [];
|
||||
|
||||
try {
|
||||
for (const [index, change] of plan.changes.entries()) {
|
||||
|
|
@ -200,7 +199,7 @@ export class SkillPlanService {
|
|||
|
||||
const summary = changes.reduce<SkillReviewSummary>(
|
||||
(acc, change) => {
|
||||
acc[`${change.action}d` as 'created' | 'updated' | 'deleted'] += 1;
|
||||
acc[`${change.action}d`] += 1;
|
||||
if (change.isBinary) {
|
||||
acc.binary += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import * as fs from 'node:fs/promises';
|
|||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { SkillDraftFile, SkillReviewFileChange } from '@shared/types/extensions';
|
||||
|
||||
import type { ImportedSkillSourceFile } from './SkillImportService';
|
||||
import type { SkillDraftFile, SkillReviewFileChange } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:SkillReview');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as path from 'node:path';
|
||||
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
|
||||
import type { SkillRootKind, SkillScope } from '@shared/types/extensions';
|
||||
|
||||
export interface ResolvedSkillRoot {
|
||||
|
|
@ -10,7 +11,7 @@ export interface ResolvedSkillRoot {
|
|||
rootPath: string;
|
||||
}
|
||||
|
||||
const USER_ROOTS: Array<{ rootKind: SkillRootKind; segments: string[] }> = [
|
||||
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = [
|
||||
{ rootKind: 'claude', segments: ['.claude', 'skills'] },
|
||||
{ rootKind: 'cursor', segments: ['.cursor', 'skills'] },
|
||||
{ rootKind: 'agents', segments: ['.agents', 'skills'] },
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import * as fs from 'node:fs/promises';
|
|||
import * as path from 'node:path';
|
||||
|
||||
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
|
||||
import type { SkillDraftFile, SkillRootKind, SkillScope } from '@shared/types/extensions';
|
||||
|
||||
import { SkillRootsResolver } from './SkillRootsResolver';
|
||||
|
||||
import type { SkillDraftFile, SkillRootKind, SkillScope } from '@shared/types/extensions';
|
||||
|
||||
export class SkillScaffoldService {
|
||||
constructor(private readonly rootsResolver = new SkillRootsResolver()) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { SkillCatalogItem, SkillDirectoryFlags } from '@shared/types/extensions';
|
||||
|
||||
import { SkillMetadataParser, type SkillRelatedFiles } from './SkillMetadataParser';
|
||||
|
||||
import type { ResolvedSkillRoot } from './SkillRootsResolver';
|
||||
import type { SkillCatalogItem, SkillDirectoryFlags } from '@shared/types/extensions';
|
||||
|
||||
const SKILL_FILE_CANDIDATES = ['SKILL.md', 'Skill.md', 'skill.md'] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import * as fs from 'node:fs/promises';
|
|||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||
|
||||
import { SkillMetadataParser } from './SkillMetadataParser';
|
||||
import { SkillRootsResolver, type ResolvedSkillRoot } from './SkillRootsResolver';
|
||||
import { type ResolvedSkillRoot, SkillRootsResolver } from './SkillRootsResolver';
|
||||
import { SkillScanner } from './SkillScanner';
|
||||
import { SkillValidator } from './SkillValidator';
|
||||
|
||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:SkillsCatalog');
|
||||
|
||||
export class SkillsCatalogService {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { SkillImportService } from './SkillImportService';
|
||||
import { SkillPlanService } from './SkillPlanService';
|
||||
import { SkillRootsResolver } from './SkillRootsResolver';
|
||||
import { SkillScaffoldService } from './SkillScaffoldService';
|
||||
import { SkillsCatalogService } from './SkillsCatalogService';
|
||||
|
||||
import type {
|
||||
SkillDeleteRequest,
|
||||
SkillDetail,
|
||||
|
|
@ -8,15 +17,6 @@ import type {
|
|||
SkillReviewPreview,
|
||||
SkillUpsertRequest,
|
||||
} from '@shared/types/extensions';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
|
||||
|
||||
import { SkillImportService } from './SkillImportService';
|
||||
import { SkillPlanService } from './SkillPlanService';
|
||||
import { SkillScaffoldService } from './SkillScaffoldService';
|
||||
import { SkillRootsResolver } from './SkillRootsResolver';
|
||||
import { SkillsCatalogService } from './SkillsCatalogService';
|
||||
|
||||
export class SkillsMutationService {
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { SkillWatcherEvent } from '@shared/types/extensions';
|
||||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { watch } from 'chokidar';
|
||||
|
||||
import { SkillRootsResolver } from './SkillRootsResolver';
|
||||
|
||||
import type { SkillWatcherEvent } from '@shared/types/extensions';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
|
||||
const logger = createLogger('Extensions:SkillsWatcher');
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:McpState');
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@
|
|||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { InstalledPluginEntry } from '@shared/types/extensions';
|
||||
import type { InstallScope } from '@shared/types/extensions';
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
|
||||
const logger = createLogger('Extensions:PluginState');
|
||||
|
||||
|
|
@ -29,24 +30,24 @@ interface InstalledPluginsJson {
|
|||
version: number;
|
||||
plugins: Record<
|
||||
string, // qualifiedName
|
||||
Array<{
|
||||
{
|
||||
scope: string;
|
||||
installPath?: string;
|
||||
version?: string;
|
||||
installedAt?: string;
|
||||
lastUpdated?: string;
|
||||
gitCommitSha?: string;
|
||||
}>
|
||||
}[]
|
||||
>;
|
||||
}
|
||||
|
||||
interface InstallCountsJson {
|
||||
version: number;
|
||||
fetchedAt: string;
|
||||
counts: Array<{
|
||||
counts: {
|
||||
plugin: string; // qualifiedName format
|
||||
unique_installs: number;
|
||||
}>;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
export * from './analysis';
|
||||
export * from './discovery';
|
||||
export * from './error';
|
||||
export * from './extensions';
|
||||
export * from './infrastructure';
|
||||
export * from './parsing';
|
||||
export * from './team';
|
||||
export * from './schedule';
|
||||
export * from './extensions';
|
||||
export * from './team';
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ export interface NotificationConfig {
|
|||
notifyOnStatusChange: boolean;
|
||||
/** Whether to show native OS notifications when a new comment is added to a task */
|
||||
notifyOnTaskComments: boolean;
|
||||
/** Whether to show native OS notifications when a new task is created */
|
||||
notifyOnTaskCreated: boolean;
|
||||
/** Whether to show native OS notifications when all tasks in a team are completed */
|
||||
notifyOnAllTasksCompleted: boolean;
|
||||
/** Whether to show native OS notifications for cross-team messages */
|
||||
notifyOnCrossTeamMessage: boolean;
|
||||
/** Only notify on status changes in solo teams (no teammates) */
|
||||
statusChangeOnlySolo: boolean;
|
||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||
|
|
@ -264,6 +270,9 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
notifyOnClarifications: true,
|
||||
notifyOnStatusChange: true,
|
||||
notifyOnTaskComments: true,
|
||||
notifyOnTaskCreated: true,
|
||||
notifyOnAllTasksCompleted: true,
|
||||
notifyOnCrossTeamMessage: true,
|
||||
statusChangeOnlySolo: false,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: DEFAULT_TRIGGERS,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||
import type { ScheduleRepository } from './ScheduleRepository';
|
||||
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:JsonScheduleRepo');
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function extractSummaryFromStreamJson(stdout: string): string {
|
|||
|
||||
const content = (parsed.content ??
|
||||
(parsed.message as Record<string, unknown> | undefined)?.content) as
|
||||
| Array<{ type?: string; text?: string }>
|
||||
| { type?: string; text?: string }[]
|
||||
| undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { Cron } from 'croner';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||
import type { ScheduleRepository } from './ScheduleRepository';
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
Schedule,
|
||||
|
|
@ -18,8 +20,6 @@ import type {
|
|||
ScheduleRunStatus,
|
||||
UpdateSchedulePatch,
|
||||
} from '@shared/types';
|
||||
import type { ScheduleRepository } from './ScheduleRepository';
|
||||
import type { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||
|
||||
const logger = createLogger('Service:Scheduler');
|
||||
|
||||
|
|
@ -511,7 +511,7 @@ export class SchedulerService {
|
|||
|
||||
private async onCronTick(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.repository.getSchedule(scheduleId);
|
||||
if (!schedule || schedule.status !== 'active') {
|
||||
if (schedule?.status !== 'active') {
|
||||
logger.debug(`Cron tick for ${scheduleId} skipped (not active)`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -659,7 +659,7 @@ export class SchedulerService {
|
|||
}
|
||||
|
||||
const freshSchedule = await this.repository.getSchedule(schedule.id);
|
||||
if (!freshSchedule || freshSchedule.status !== 'active') {
|
||||
if (freshSchedule?.status !== 'active') {
|
||||
await this.completeRun(retryRun, 'failed', exitCode, undefined, error);
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
*/
|
||||
|
||||
export { JsonScheduleRepository } from './JsonScheduleRepository';
|
||||
export type { ScheduleRepository } from './ScheduleRepository';
|
||||
export { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||
export type {
|
||||
ExecutionRequest,
|
||||
InternalScheduleRun,
|
||||
ScheduledTaskResult,
|
||||
} from './ScheduledTaskExecutor';
|
||||
export { SchedulerService } from './SchedulerService';
|
||||
export { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||
export type { ScheduleRepository } from './ScheduleRepository';
|
||||
export type { WarmUpFn } from './SchedulerService';
|
||||
export { SchedulerService } from './SchedulerService';
|
||||
|
|
|
|||
|
|
@ -577,7 +577,7 @@ export class ChangeExtractorService {
|
|||
|
||||
private async parseJSONLFilesWithConcurrency(
|
||||
paths: string[]
|
||||
): Promise<Array<{ snippets: SnippetDiff[]; mtime: number }>> {
|
||||
): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> {
|
||||
if (paths.length === 0) return [];
|
||||
|
||||
const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
||||
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
|
|
|
|||
|
|
@ -101,4 +101,7 @@ export class TeamAttachmentStore {
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
// TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends.
|
||||
// Best-effort removal of the attachment JSON file — useful for retry/cancel flows.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,7 +300,8 @@ export class TeamBackupService {
|
|||
if (raw && isValidConfig(raw)) {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
manifest.displayName = typeof parsed.name === 'string' ? parsed.name : undefined;
|
||||
manifest.projectPath = typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined;
|
||||
manifest.projectPath =
|
||||
typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined;
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
@ -392,7 +393,7 @@ export class TeamBackupService {
|
|||
}
|
||||
|
||||
const cached = manifest.fileStats[descriptor.relPath];
|
||||
if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
|
||||
if (cached?.mtime === stat.mtimeMs && cached.size === stat.size) {
|
||||
return false; // not dirty
|
||||
}
|
||||
|
||||
|
|
@ -431,7 +432,7 @@ export class TeamBackupService {
|
|||
if (stat.size > MAX_FILE_SIZE_BYTES) return; // skip oversized silently during shutdown
|
||||
|
||||
const cached = manifest.fileStats[descriptor.relPath];
|
||||
if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) return;
|
||||
if (cached?.mtime === stat.mtimeMs && cached.size === stat.size) return;
|
||||
|
||||
const destPath = path.join(backupDir, descriptor.relPath);
|
||||
|
||||
|
|
@ -572,10 +573,7 @@ export class TeamBackupService {
|
|||
return count > 0;
|
||||
}
|
||||
|
||||
private async restoreGenericPartial(
|
||||
teamName: string,
|
||||
manifest: BackupManifest
|
||||
): Promise<number> {
|
||||
private async restoreGenericPartial(teamName: string, manifest: BackupManifest): Promise<number> {
|
||||
const backupDir = this.getBackupDir(teamName);
|
||||
const backupFiles = await this.enumerateBackupFiles(teamName);
|
||||
let count = 0;
|
||||
|
|
@ -892,10 +890,20 @@ export class TeamBackupService {
|
|||
return path.join(getTasksBasePath(), teamName, relPath.slice('tasks/'.length));
|
||||
}
|
||||
if (relPath.startsWith('attachments/')) {
|
||||
return path.join(getAppDataPath(), 'attachments', teamName, relPath.slice('attachments/'.length));
|
||||
return path.join(
|
||||
getAppDataPath(),
|
||||
'attachments',
|
||||
teamName,
|
||||
relPath.slice('attachments/'.length)
|
||||
);
|
||||
}
|
||||
if (relPath.startsWith('task-attachments/')) {
|
||||
return path.join(getAppDataPath(), 'task-attachments', teamName, relPath.slice('task-attachments/'.length));
|
||||
return path.join(
|
||||
getAppDataPath(),
|
||||
'task-attachments',
|
||||
teamName,
|
||||
relPath.slice('task-attachments/'.length)
|
||||
);
|
||||
}
|
||||
return path.join(getTeamsBasePath(), teamName, relPath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,9 @@ export class TeamDataService {
|
|||
if (event.type === 'review_approved' && event.actor) {
|
||||
return event.actor;
|
||||
}
|
||||
if (event.type === 'review_started' && event.actor) {
|
||||
return event.actor;
|
||||
}
|
||||
if (event.type === 'review_requested' && event.reviewer) {
|
||||
return event.reviewer;
|
||||
}
|
||||
|
|
@ -873,12 +876,15 @@ export class TeamDataService {
|
|||
|
||||
// Skip inbox notification when lead starts their own task (solo teams)
|
||||
if (!this.isLeadOwner(task.owner, leadName)) {
|
||||
const parts = [`**started task** ${this.getTaskLabel(task)} "${task.subject}"`];
|
||||
const parts = [
|
||||
`**start working on task now** ${this.getTaskLabel(task)} "${task.subject}"`,
|
||||
];
|
||||
if (task.description?.trim()) {
|
||||
parts.push(`\nDetails:\n${task.description.trim()}`);
|
||||
}
|
||||
parts.push(
|
||||
`\n${AGENT_BLOCK_OPEN}`,
|
||||
`Begin work on this task immediately. Keep it moving until it is completed or clearly blocked. Do not leave it idle.`,
|
||||
`Update task status using the board MCP tools:`,
|
||||
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
|
||||
AGENT_BLOCK_CLOSE
|
||||
|
|
@ -888,7 +894,7 @@ export class TeamDataService {
|
|||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
taskRefs: task.descriptionTaskRefs,
|
||||
summary: `Task ${this.getTaskLabel(task)} started`,
|
||||
summary: `Start working on ${this.getTaskLabel(task)}`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
|
@ -1510,7 +1516,8 @@ export class TeamDataService {
|
|||
text: string,
|
||||
summary?: string,
|
||||
attachments?: AttachmentMeta[],
|
||||
taskRefs?: TaskRef[]
|
||||
taskRefs?: TaskRef[],
|
||||
messageId?: string
|
||||
): Promise<SendMessageResult> {
|
||||
let leadSessionId: string | undefined;
|
||||
try {
|
||||
|
|
@ -1529,6 +1536,7 @@ export class TeamDataService {
|
|||
source: 'user_sent',
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
leadSessionId,
|
||||
...(messageId ? { messageId } : {}),
|
||||
}) as InboxMessage;
|
||||
return {
|
||||
deliveredToInbox: false,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
interface McpLaunchSpec {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ interface StreamedMetadata {
|
|||
lastTimestamp: string | null;
|
||||
messageCount: number;
|
||||
lastOutputPreview: string | null;
|
||||
lastThinkingPreview: string | null;
|
||||
/** Recent thinking/output previews with timestamps for task-scoped filtering. */
|
||||
recentPreviews: { text: string; timestamp: string; kind: 'thinking' | 'output' }[];
|
||||
}
|
||||
|
||||
/** Result of attributing a subagent file to a team member. */
|
||||
|
|
@ -1178,6 +1181,8 @@ export class TeamMemberLogsFinder {
|
|||
isOngoing,
|
||||
filePath,
|
||||
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
|
||||
lastThinkingPreview: metadata.lastThinkingPreview ?? undefined,
|
||||
recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1425,6 +1430,8 @@ export class TeamMemberLogsFinder {
|
|||
isOngoing,
|
||||
filePath: jsonlPath,
|
||||
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
|
||||
lastThinkingPreview: metadata.lastThinkingPreview ?? undefined,
|
||||
recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1437,6 +1444,9 @@ export class TeamMemberLogsFinder {
|
|||
let lastTimestamp: string | null = null;
|
||||
let messageCount = 0;
|
||||
let lastOutputPreview: string | null = null;
|
||||
let lastThinkingPreview: string | null = null;
|
||||
const MAX_RECENT_PREVIEWS = 20;
|
||||
const recentPreviews: StreamedMetadata['recentPreviews'] = [];
|
||||
|
||||
try {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
|
|
@ -1458,7 +1468,25 @@ export class TeamMemberLogsFinder {
|
|||
// Track last assistant text output (cheap regex, overwrites on each match).
|
||||
if (trimmed.includes('"role":"assistant"') || trimmed.includes('"role": "assistant"')) {
|
||||
const preview = TeamMemberLogsFinder.extractAssistantPreview(trimmed);
|
||||
if (preview) lastOutputPreview = preview;
|
||||
if (preview) {
|
||||
lastOutputPreview = preview;
|
||||
if (ts) {
|
||||
recentPreviews.push({ text: preview, timestamp: ts, kind: 'output' });
|
||||
if (recentPreviews.length > MAX_RECENT_PREVIEWS) recentPreviews.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track last thinking block (cheap regex).
|
||||
if (trimmed.includes('"type":"thinking"') || trimmed.includes('"type": "thinking"')) {
|
||||
const thinkingPreview = TeamMemberLogsFinder.extractThinkingPreview(trimmed);
|
||||
if (thinkingPreview) {
|
||||
lastThinkingPreview = thinkingPreview;
|
||||
if (ts) {
|
||||
recentPreviews.push({ text: thinkingPreview, timestamp: ts, kind: 'thinking' });
|
||||
if (recentPreviews.length > MAX_RECENT_PREVIEWS) recentPreviews.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
|
|
@ -1467,7 +1495,7 @@ export class TeamMemberLogsFinder {
|
|||
// ignore — return whatever we collected so far
|
||||
}
|
||||
|
||||
return { firstTimestamp, lastTimestamp, messageCount, lastOutputPreview };
|
||||
return { firstTimestamp, lastTimestamp, messageCount, lastOutputPreview, lastThinkingPreview, recentPreviews };
|
||||
}
|
||||
|
||||
private extractTimestampFromLine(line: string): string | null {
|
||||
|
|
@ -1480,25 +1508,53 @@ export class TeamMemberLogsFinder {
|
|||
* Looks for the first text block content via regex (avoids full JSON parse).
|
||||
*/
|
||||
private static extractAssistantPreview(line: string): string | null {
|
||||
// Match {"type":"text","text":"..."} blocks
|
||||
const textMatch = /"type"\s*:\s*"text"[^}]*"text"\s*:\s*"([^"]{1,200})/.exec(line);
|
||||
// Match {"type":"text","text":"..."} blocks — allow escaped sequences
|
||||
const textMatch = /"type"\s*:\s*"text"[^}]*"text"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line);
|
||||
if (textMatch?.[1]) {
|
||||
const raw = textMatch[1]
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\n/g, ' ')
|
||||
.replace(/\\t/g, ' ')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return raw.length > 120 ? raw.slice(0, 120) + '...' : raw;
|
||||
if (!raw) return null;
|
||||
return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw;
|
||||
}
|
||||
// Fallback: top-level string content
|
||||
const contentMatch = /"content"\s*:\s*"([^"]{1,200})/.exec(line);
|
||||
// Fallback: top-level string content — skip lines with tool_use to avoid
|
||||
// matching file content from Write/Edit tool inputs.
|
||||
if (line.includes('"tool_use"')) return null;
|
||||
const contentMatch = /"content"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line);
|
||||
if (contentMatch?.[1]) {
|
||||
const raw = contentMatch[1]
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\n/g, ' ')
|
||||
.replace(/\\t/g, ' ')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return raw.length > 120 ? raw.slice(0, 120) + '...' : raw;
|
||||
if (!raw) return null;
|
||||
return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a short preview from a thinking block line via regex.
|
||||
* Thinking blocks use {"type":"thinking","thinking":"..."}.
|
||||
*/
|
||||
private static extractThinkingPreview(line: string): string | null {
|
||||
// Allow escaped sequences (e.g. \" \n \\) inside the captured string value
|
||||
const match = /"thinking"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1]
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\n/g, ' ')
|
||||
.replace(/\\t/g, ' ')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return raw.length > 1500 ? raw.slice(0, 1500) + '...' : raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ import * as path from 'path';
|
|||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
|
||||
import type {
|
||||
SourceMessageSnapshot,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskHistoryEvent,
|
||||
|
|
@ -293,6 +294,18 @@ export class TeamTaskReader {
|
|||
historyEvents,
|
||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||
}),
|
||||
sourceMessageId:
|
||||
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
||||
? parsed.sourceMessageId.trim()
|
||||
: undefined,
|
||||
sourceMessage:
|
||||
parsed.sourceMessage &&
|
||||
typeof parsed.sourceMessage === 'object' &&
|
||||
typeof (parsed.sourceMessage as Record<string, unknown>).text === 'string' &&
|
||||
typeof (parsed.sourceMessage as Record<string, unknown>).from === 'string' &&
|
||||
typeof (parsed.sourceMessage as Record<string, unknown>).timestamp === 'string'
|
||||
? (parsed.sourceMessage as SourceMessageSnapshot)
|
||||
: undefined,
|
||||
} satisfies Record<keyof TeamTask, unknown>;
|
||||
if (task.status === 'deleted') {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getTaskChangeSummariesBasePath } from '@main/utils/pathDecoder';
|
||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import { getTaskChangeSummariesBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
|
||||
|
||||
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types';
|
||||
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
|
||||
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types';
|
||||
|
||||
function normalizeIsoString(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,37 @@ import { randomUUID } from 'crypto';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const EPERM_MAX_RETRIES = 3;
|
||||
const EPERM_RETRY_DELAY_MS = 50;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function renameWithRetry(src: string, dest: string): Promise<void> {
|
||||
for (let attempt = 0; attempt <= EPERM_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await fs.promises.rename(src, dest);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'EXDEV') {
|
||||
await fs.promises.copyFile(src, dest);
|
||||
await fs.promises.unlink(src).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (code === 'EPERM' && attempt < EPERM_MAX_RETRIES) {
|
||||
await sleep(EPERM_RETRY_DELAY_MS * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async atomic write: write tmp file then rename over target.
|
||||
* Uses best-effort fsync and EXDEV fallback for safety.
|
||||
* Uses best-effort fsync and EXDEV/EPERM fallback for safety.
|
||||
*/
|
||||
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
|
||||
const dir = path.dirname(targetPath);
|
||||
|
|
@ -24,16 +52,7 @@ export async function atomicWriteAsync(targetPath: string, data: string): Promis
|
|||
await fd?.close();
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, targetPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
await fs.promises.copyFile(tmpPath, targetPath);
|
||||
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await renameWithRetry(tmpPath, targetPath);
|
||||
} catch (error) {
|
||||
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export type TeamEventType =
|
|||
| 'task_clarification'
|
||||
| 'task_status_change'
|
||||
| 'task_comment'
|
||||
| 'task_created'
|
||||
| 'all_tasks_completed'
|
||||
| 'cross_team_message'
|
||||
| 'schedule_completed'
|
||||
| 'schedule_failed';
|
||||
|
||||
|
|
@ -63,6 +66,9 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
|
|||
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
||||
task_created: { triggerName: 'Task Created', triggerColor: 'green' },
|
||||
all_tasks_completed: { triggerName: 'All Done', triggerColor: 'green' },
|
||||
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
|
||||
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ interface ParsedTask {
|
|||
workIntervals?: unknown;
|
||||
historyEvents?: unknown;
|
||||
attachments?: unknown;
|
||||
sourceMessageId?: unknown;
|
||||
sourceMessage?: unknown;
|
||||
}
|
||||
|
||||
interface RawWorkInterval {
|
||||
|
|
@ -533,7 +535,12 @@ function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): str
|
|||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const t = e.type;
|
||||
if (t === 'review_requested' || t === 'review_changes_requested' || t === 'review_approved') {
|
||||
if (
|
||||
t === 'review_requested' ||
|
||||
t === 'review_changes_requested' ||
|
||||
t === 'review_approved' ||
|
||||
t === 'review_started'
|
||||
) {
|
||||
const to = typeof e.to === 'string' ? e.to : 'none';
|
||||
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none';
|
||||
}
|
||||
|
|
@ -699,6 +706,18 @@ async function readTasksDirForTeam(
|
|||
attachments: Array.isArray(parsed.attachments)
|
||||
? (parsed.attachments as unknown[])
|
||||
: undefined,
|
||||
sourceMessageId:
|
||||
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
||||
? parsed.sourceMessageId.trim()
|
||||
: undefined,
|
||||
sourceMessage:
|
||||
parsed.sourceMessage &&
|
||||
typeof parsed.sourceMessage === 'object' &&
|
||||
typeof (parsed.sourceMessage as Record<string, unknown>).text === 'string' &&
|
||||
typeof (parsed.sourceMessage as Record<string, unknown>).from === 'string' &&
|
||||
typeof (parsed.sourceMessage as Record<string, unknown>).timestamp === 'string'
|
||||
? (parsed.sourceMessage as Record<string, unknown>)
|
||||
: undefined,
|
||||
teamName,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
import {
|
||||
API_KEYS_DELETE,
|
||||
API_KEYS_LIST,
|
||||
API_KEYS_LOOKUP,
|
||||
API_KEYS_SAVE,
|
||||
API_KEYS_STORAGE_STATUS,
|
||||
APP_RELAUNCH,
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_INSTALL,
|
||||
|
|
@ -34,28 +39,30 @@ import {
|
|||
HTTP_SERVER_GET_STATUS,
|
||||
HTTP_SERVER_START,
|
||||
HTTP_SERVER_STOP,
|
||||
MCP_GITHUB_STARS,
|
||||
MCP_REGISTRY_BROWSE,
|
||||
MCP_REGISTRY_DIAGNOSE,
|
||||
MCP_REGISTRY_GET_BY_ID,
|
||||
MCP_REGISTRY_GET_INSTALLED,
|
||||
MCP_REGISTRY_INSTALL,
|
||||
MCP_REGISTRY_INSTALL_CUSTOM,
|
||||
MCP_REGISTRY_SEARCH,
|
||||
MCP_REGISTRY_UNINSTALL,
|
||||
PLUGIN_GET_ALL,
|
||||
PLUGIN_GET_README,
|
||||
PLUGIN_INSTALL,
|
||||
PLUGIN_UNINSTALL,
|
||||
PROJECT_LIST_FILES,
|
||||
RENDERER_BOOT,
|
||||
RENDERER_HEARTBEAT,
|
||||
RENDERER_LOG,
|
||||
SCHEDULE_CHANGE,
|
||||
SCHEDULE_CREATE,
|
||||
SCHEDULE_DELETE,
|
||||
SCHEDULE_GET,
|
||||
SCHEDULE_GET_RUN_LOGS,
|
||||
SCHEDULE_GET_RUNS,
|
||||
SCHEDULE_LIST,
|
||||
SCHEDULE_PAUSE,
|
||||
SCHEDULE_RESUME,
|
||||
SCHEDULE_TRIGGER_NOW,
|
||||
SCHEDULE_UPDATE,
|
||||
REVIEW_APPLY_DECISIONS,
|
||||
REVIEW_CHECK_CONFLICT,
|
||||
REVIEW_CLEAR_DECISIONS,
|
||||
REVIEW_FILE_CHANGE,
|
||||
REVIEW_GET_AGENT_CHANGES,
|
||||
REVIEW_GET_CHANGE_STATS,
|
||||
REVIEW_GET_FILE_CONTENT,
|
||||
REVIEW_FILE_CHANGE,
|
||||
REVIEW_GET_GIT_FILE_LOG,
|
||||
REVIEW_GET_TASK_CHANGES,
|
||||
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
|
||||
|
|
@ -67,6 +74,27 @@ import {
|
|||
REVIEW_SAVE_EDITED_FILE,
|
||||
REVIEW_UNWATCH_FILES,
|
||||
REVIEW_WATCH_FILES,
|
||||
SCHEDULE_CHANGE,
|
||||
SCHEDULE_CREATE,
|
||||
SCHEDULE_DELETE,
|
||||
SCHEDULE_GET,
|
||||
SCHEDULE_GET_RUN_LOGS,
|
||||
SCHEDULE_GET_RUNS,
|
||||
SCHEDULE_LIST,
|
||||
SCHEDULE_PAUSE,
|
||||
SCHEDULE_RESUME,
|
||||
SCHEDULE_TRIGGER_NOW,
|
||||
SCHEDULE_UPDATE,
|
||||
SKILLS_APPLY_IMPORT,
|
||||
SKILLS_APPLY_UPSERT,
|
||||
SKILLS_CHANGED,
|
||||
SKILLS_DELETE,
|
||||
SKILLS_GET_DETAIL,
|
||||
SKILLS_LIST,
|
||||
SKILLS_PREVIEW_IMPORT,
|
||||
SKILLS_PREVIEW_UPSERT,
|
||||
SKILLS_START_WATCHING,
|
||||
SKILLS_STOP_WATCHING,
|
||||
SSH_CONNECT,
|
||||
SSH_DISCONNECT,
|
||||
SSH_GET_CONFIG_HOSTS,
|
||||
|
|
@ -101,8 +129,8 @@ import {
|
|||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
TEAM_LIST,
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
TEAM_PROCESS_ALIVE,
|
||||
|
|
@ -149,34 +177,6 @@ import {
|
|||
WINDOW_IS_MAXIMIZED,
|
||||
WINDOW_MAXIMIZE,
|
||||
WINDOW_MINIMIZE,
|
||||
PLUGIN_GET_ALL,
|
||||
PLUGIN_GET_README,
|
||||
PLUGIN_INSTALL,
|
||||
PLUGIN_UNINSTALL,
|
||||
MCP_REGISTRY_SEARCH,
|
||||
MCP_REGISTRY_BROWSE,
|
||||
MCP_REGISTRY_DIAGNOSE,
|
||||
MCP_REGISTRY_GET_BY_ID,
|
||||
MCP_REGISTRY_GET_INSTALLED,
|
||||
MCP_REGISTRY_INSTALL,
|
||||
MCP_REGISTRY_INSTALL_CUSTOM,
|
||||
MCP_REGISTRY_UNINSTALL,
|
||||
MCP_GITHUB_STARS,
|
||||
SKILLS_APPLY_IMPORT,
|
||||
SKILLS_APPLY_UPSERT,
|
||||
SKILLS_CHANGED,
|
||||
SKILLS_DELETE,
|
||||
SKILLS_GET_DETAIL,
|
||||
SKILLS_LIST,
|
||||
SKILLS_PREVIEW_IMPORT,
|
||||
SKILLS_PREVIEW_UPSERT,
|
||||
SKILLS_START_WATCHING,
|
||||
SKILLS_STOP_WATCHING,
|
||||
API_KEYS_LIST,
|
||||
API_KEYS_SAVE,
|
||||
API_KEYS_DELETE,
|
||||
API_KEYS_LOOKUP,
|
||||
API_KEYS_STORAGE_STATUS,
|
||||
} from './constants/ipcChannels';
|
||||
import {
|
||||
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
||||
|
|
@ -236,9 +236,9 @@ import type {
|
|||
KanbanColumnId,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsageSnapshot,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
NotificationTrigger,
|
||||
RejectResult,
|
||||
ReplaceMembersRequest,
|
||||
|
|
@ -281,28 +281,6 @@ import type {
|
|||
UpdateSchedulePatch,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeyLookupResult,
|
||||
ApiKeySaveRequest,
|
||||
ApiKeyStorageStatus,
|
||||
EnrichedPlugin,
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
McpServerDiagnostic,
|
||||
McpSearchResult,
|
||||
OperationResult,
|
||||
PluginInstallRequest,
|
||||
SkillCatalogItem,
|
||||
SkillDeleteRequest,
|
||||
SkillDetail,
|
||||
SkillImportRequest,
|
||||
SkillReviewPreview,
|
||||
SkillUpsertRequest,
|
||||
SkillWatcherEvent,
|
||||
} from '@shared/types/extensions';
|
||||
import type {
|
||||
BinaryPreviewResult,
|
||||
CreateDirResponse,
|
||||
|
|
@ -318,6 +296,28 @@ import type {
|
|||
SearchInFilesResult,
|
||||
WriteFileResponse,
|
||||
} from '@shared/types/editor';
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeyLookupResult,
|
||||
ApiKeySaveRequest,
|
||||
ApiKeyStorageStatus,
|
||||
EnrichedPlugin,
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
McpSearchResult,
|
||||
McpServerDiagnostic,
|
||||
OperationResult,
|
||||
PluginInstallRequest,
|
||||
SkillCatalogItem,
|
||||
SkillDeleteRequest,
|
||||
SkillDetail,
|
||||
SkillImportRequest,
|
||||
SkillReviewPreview,
|
||||
SkillUpsertRequest,
|
||||
SkillWatcherEvent,
|
||||
} from '@shared/types/extensions';
|
||||
import type { PtySpawnOptions } from '@shared/types/terminal';
|
||||
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import type { SearchMatch } from '@renderer/store/types';
|
||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
|
|
@ -44,6 +43,8 @@ import {
|
|||
import { FileLink, isRelativeUrl } from './FileLink';
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
|
||||
import type { SearchMatch } from '@renderer/store/types';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
|
@ -69,7 +70,7 @@ interface MarkdownViewerProps {
|
|||
onTeamClick?: (teamName: string) => void;
|
||||
}
|
||||
|
||||
const EMPTY_TEAMS: Array<{ teamName?: string; displayName?: string; color?: string }> = [];
|
||||
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||
const EMPTY_SEARCH_MATCHES: SearchMatch[] = [];
|
||||
const NOOP_TEAM_CLICK = (): void => undefined;
|
||||
|
|
@ -94,31 +95,136 @@ function allowCustomProtocols(url: string): string {
|
|||
* that appear in agent messages and cause React "unrecognized tag" warnings.
|
||||
*/
|
||||
const STANDARD_HTML_TAGS = new Set([
|
||||
'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
|
||||
'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
|
||||
'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
|
||||
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
|
||||
'em', 'embed',
|
||||
'fieldset', 'figcaption', 'figure', 'footer', 'form',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
|
||||
'i', 'iframe', 'img', 'input', 'ins',
|
||||
'a',
|
||||
'abbr',
|
||||
'address',
|
||||
'area',
|
||||
'article',
|
||||
'aside',
|
||||
'audio',
|
||||
'b',
|
||||
'base',
|
||||
'bdi',
|
||||
'bdo',
|
||||
'blockquote',
|
||||
'body',
|
||||
'br',
|
||||
'button',
|
||||
'canvas',
|
||||
'caption',
|
||||
'cite',
|
||||
'code',
|
||||
'col',
|
||||
'colgroup',
|
||||
'data',
|
||||
'datalist',
|
||||
'dd',
|
||||
'del',
|
||||
'details',
|
||||
'dfn',
|
||||
'dialog',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'em',
|
||||
'embed',
|
||||
'fieldset',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'footer',
|
||||
'form',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'head',
|
||||
'header',
|
||||
'hgroup',
|
||||
'hr',
|
||||
'html',
|
||||
'i',
|
||||
'iframe',
|
||||
'img',
|
||||
'input',
|
||||
'ins',
|
||||
'kbd',
|
||||
'label', 'legend', 'li', 'link',
|
||||
'main', 'map', 'mark', 'menu', 'meta', 'meter',
|
||||
'nav', 'noscript',
|
||||
'object', 'ol', 'optgroup', 'option', 'output',
|
||||
'p', 'picture', 'pre', 'progress',
|
||||
'label',
|
||||
'legend',
|
||||
'li',
|
||||
'link',
|
||||
'main',
|
||||
'map',
|
||||
'mark',
|
||||
'menu',
|
||||
'meta',
|
||||
'meter',
|
||||
'nav',
|
||||
'noscript',
|
||||
'object',
|
||||
'ol',
|
||||
'optgroup',
|
||||
'option',
|
||||
'output',
|
||||
'p',
|
||||
'picture',
|
||||
'pre',
|
||||
'progress',
|
||||
'q',
|
||||
'rp', 'rt', 'ruby',
|
||||
's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'source', 'span',
|
||||
'strong', 'style', 'sub', 'summary', 'sup',
|
||||
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
|
||||
'u', 'ul',
|
||||
'var', 'video',
|
||||
'rp',
|
||||
'rt',
|
||||
'ruby',
|
||||
's',
|
||||
'samp',
|
||||
'script',
|
||||
'search',
|
||||
'section',
|
||||
'select',
|
||||
'slot',
|
||||
'small',
|
||||
'source',
|
||||
'span',
|
||||
'strong',
|
||||
'style',
|
||||
'sub',
|
||||
'summary',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'template',
|
||||
'textarea',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'time',
|
||||
'title',
|
||||
'tr',
|
||||
'track',
|
||||
'u',
|
||||
'ul',
|
||||
'var',
|
||||
'video',
|
||||
'wbr',
|
||||
// SVG elements commonly used inline
|
||||
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
|
||||
'text', 'tspan', 'clippath', 'mask', 'pattern', 'image', 'foreignobject',
|
||||
'svg',
|
||||
'path',
|
||||
'circle',
|
||||
'rect',
|
||||
'line',
|
||||
'polyline',
|
||||
'polygon',
|
||||
'g',
|
||||
'defs',
|
||||
'use',
|
||||
'text',
|
||||
'tspan',
|
||||
'clippath',
|
||||
'mask',
|
||||
'pattern',
|
||||
'image',
|
||||
'foreignobject',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { AlertTriangle, Bug, Check, Copy, RefreshCw } from 'lucide-react';
|
||||
|
||||
import {
|
||||
type BugReportContext,
|
||||
buildBugReportText,
|
||||
buildGitHubBugReportUrl,
|
||||
type BugReportContext,
|
||||
} from '@renderer/utils/bugReportUtils';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { AlertTriangle, Bug, Check, Copy, RefreshCw } from 'lucide-react';
|
||||
|
||||
const logger = createLogger('Component:ErrorBoundary');
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -18,14 +15,17 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||
|
||||
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
||||
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
||||
import { McpServersPanel } from './mcp/McpServersPanel';
|
||||
import { PluginsPanel } from './plugins/PluginsPanel';
|
||||
import { SkillsPanel } from './skills/SkillsPanel';
|
||||
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
||||
|
||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||
const tabId = useTabIdOptional();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
import { TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface ExtensionsSubTabTriggerProps {
|
||||
value: 'plugins' | 'mcp-servers' | 'skills' | 'api-keys';
|
||||
label: string;
|
||||
|
|
@ -21,7 +20,7 @@ export const ExtensionsSubTabTrigger = ({
|
|||
return (
|
||||
<TabsTrigger
|
||||
value={value}
|
||||
className="relative gap-1.5 rounded-b-none pr-7 data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:-bottom-px data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']"
|
||||
className="relative gap-1.5 rounded-b-none pr-7 data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:-bottom-px data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Copy, Check, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Check, Copy, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -15,6 +13,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { ExtensionOperationState } from '@shared/types/extensions';
|
||||
|
||||
|
|
@ -28,7 +27,7 @@ interface InstallButtonProps {
|
|||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function InstallButton({
|
||||
export const InstallButton = ({
|
||||
state,
|
||||
isInstalled,
|
||||
onInstall,
|
||||
|
|
@ -36,7 +35,7 @@ export function InstallButton({
|
|||
disabled,
|
||||
size = 'sm',
|
||||
errorMessage,
|
||||
}: InstallButtonProps) {
|
||||
}: InstallButtonProps) => {
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
||||
const isDisabled = disabled || cliMissing;
|
||||
|
|
@ -52,7 +51,7 @@ export function InstallButton({
|
|||
if (state === 'pending') {
|
||||
return (
|
||||
<Button size={size} variant="outline" disabled>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span className="ml-1.5">
|
||||
{pendingAction === 'uninstall' ? 'Removing...' : 'Installing...'}
|
||||
</span>
|
||||
|
|
@ -63,7 +62,7 @@ export function InstallButton({
|
|||
if (state === 'success') {
|
||||
return (
|
||||
<Button size={size} variant="outline" disabled className="text-green-400">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<Check className="size-3.5" />
|
||||
<span className="ml-1.5">Done</span>
|
||||
</Button>
|
||||
);
|
||||
|
|
@ -121,7 +120,7 @@ export function InstallButton({
|
|||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="ml-1.5">Uninstall</span>
|
||||
</Button>
|
||||
) : (
|
||||
|
|
@ -153,4 +152,4 @@ export function InstallButton({
|
|||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
* InstallCountBadge — formatted download count with icon.
|
||||
*/
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { formatInstallCount } from '@shared/utils/extensionNormalizers';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
interface InstallCountBadgeProps {
|
||||
count: number;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -23,7 +24,6 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { api } from '@renderer/api';
|
||||
import { Plus, Server, Trash2 } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
|
|
|
|||
|
|
@ -5,20 +5,18 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { api } from '@renderer/api';
|
||||
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { Cloud, Clock, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- lucide naming migration, alias is stable
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
||||
import { Github as GithubIcon } from 'lucide-react';
|
||||
|
||||
import { InstallButton } from '../common/InstallButton';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
|
||||
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
|
||||
|
||||
|
|
@ -82,7 +80,7 @@ export const McpServerCard = ({
|
|||
{hasIcon && (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<img
|
||||
src={server.iconUrl!}
|
||||
src={server.iconUrl}
|
||||
alt=""
|
||||
className="size-7 rounded object-contain"
|
||||
onError={() => setImgError(true)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -12,8 +15,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import {
|
||||
|
|
@ -24,12 +25,11 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { api } from '@renderer/api';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
||||
|
||||
import { InstallButton } from '../common/InstallButton';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
|
||||
import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions';
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ export const McpServerDetailDialog = ({
|
|||
{hasIcon && (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||
<img
|
||||
src={server.iconUrl!}
|
||||
src={server.iconUrl}
|
||||
alt=""
|
||||
className="size-8 rounded object-contain"
|
||||
onError={() => setImgError(true)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
|
||||
|
||||
import { SearchInput } from '../common/SearchInput';
|
||||
|
|
@ -27,7 +28,6 @@ import type {
|
|||
McpCatalogItem,
|
||||
McpServerDiagnostic,
|
||||
} from '@shared/types/extensions';
|
||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||
|
||||
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
|
||||
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
|||
}`}
|
||||
>
|
||||
{plugin.source === 'official' && (
|
||||
<div className="pointer-events-none absolute -left-[1px] -top-[1px] size-16 overflow-hidden">
|
||||
<div className="absolute left-[-24px] top-[4px] w-[80px] rotate-[-45deg] bg-blue-500/90 text-center text-[9px] font-semibold leading-[18px] text-white shadow-sm">
|
||||
<div className="pointer-events-none absolute -left-px -top-px size-16 overflow-hidden">
|
||||
<div className="absolute left-[-24px] top-[4px] w-[80px] -rotate-45 bg-blue-500/90 text-center text-[9px] font-semibold leading-[18px] text-white shadow-sm">
|
||||
Official
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -11,8 +15,6 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -22,7 +24,6 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
getCapabilityLabel,
|
||||
inferCapabilities,
|
||||
|
|
@ -34,8 +35,6 @@ import { InstallButton } from '../common/InstallButton';
|
|||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||
import { SourceBadge } from '../common/SourceBadge';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
|
||||
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
|
||||
|
||||
interface PluginDetailDialogProps {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import {
|
|||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
import { getSyncLanguageExtension } from '@renderer/utils/codemirrorLanguages';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
|
||||
const skillEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ import { useStore } from '@renderer/store';
|
|||
import { FileSearch, RotateCcw, X } from 'lucide-react';
|
||||
|
||||
import { SkillCodeEditor } from './SkillCodeEditor';
|
||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||
import {
|
||||
buildSkillDraftFiles,
|
||||
buildSkillTemplate,
|
||||
readSkillTemplateContent,
|
||||
updateSkillTemplateFrontmatter,
|
||||
} from './skillDraftUtils';
|
||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||
|
||||
import type {
|
||||
SkillDetail,
|
||||
|
|
@ -792,7 +792,7 @@ export const SkillEditorDialog = ({
|
|||
<X className="mr-1.5 size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="min-w-[16rem] flex-1">
|
||||
<div className="min-w-64 flex-1">
|
||||
<p className="text-sm text-text-muted">
|
||||
Review the file changes first, then confirm save in the next step.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ export const SkillImportDialog = ({
|
|||
<X className="mr-1.5 size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
<p className="min-w-[16rem] flex-1 text-sm text-text-muted">
|
||||
<p className="min-w-64 flex-1 text-sm text-text-muted">
|
||||
Review the copied files first, then confirm the import in the next step.
|
||||
</p>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { api } from '@renderer/api';
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpAZ,
|
||||
|
|
@ -25,8 +25,8 @@ import { SkillDetailDialog } from './SkillDetailDialog';
|
|||
import { SkillEditorDialog } from './SkillEditorDialog';
|
||||
import { SkillImportDialog } from './SkillImportDialog';
|
||||
|
||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
|
||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||
|
||||
const SUCCESS_BANNER_MS = 2500;
|
||||
const NEW_SKILL_HIGHLIGHT_MS = 4000;
|
||||
|
|
@ -233,7 +233,7 @@ export const SkillsPanel = ({
|
|||
|
||||
<div className="flex w-full flex-col gap-3 xl:w-auto xl:min-w-[32rem] xl:max-w-[40rem]">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center xl:justify-end">
|
||||
<div className="w-full lg:min-w-[18rem] lg:flex-1 xl:w-80 xl:flex-none">
|
||||
<div className="w-full lg:min-w-72 lg:flex-1 xl:w-80 xl:flex-none">
|
||||
<SearchInput
|
||||
value={skillsSearchQuery}
|
||||
onChange={setSkillsSearchQuery}
|
||||
|
|
@ -318,7 +318,7 @@ export const SkillsPanel = ({
|
|||
['personal', 'Personal'],
|
||||
['needs-attention', 'Needs attention'],
|
||||
['has-scripts', 'Has scripts'],
|
||||
] as Array<[SkillsQuickFilter, string]>
|
||||
] as [SkillsQuickFilter, string][]
|
||||
).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function buildSkillTemplate(input: SkillDraftTemplateInput): string {
|
|||
|
||||
export function readSkillTemplateContent(rawContent: string): SkillTemplateParseResult {
|
||||
const content = rawContent.replace(/^\uFEFF/u, '');
|
||||
const match = content.match(SKILL_FRONTMATTER_PATTERN);
|
||||
const match = SKILL_FRONTMATTER_PATTERN.exec(content);
|
||||
if (!match) {
|
||||
return {
|
||||
hasStructuredSections: false,
|
||||
|
|
@ -130,7 +130,7 @@ export function updateSkillTemplateFrontmatter(
|
|||
input: SkillDraftTemplateInput
|
||||
): string {
|
||||
const content = rawContent.replace(/^\uFEFF/u, '');
|
||||
const match = content.match(SKILL_FRONTMATTER_PATTERN);
|
||||
const match = SKILL_FRONTMATTER_PATTERN.exec(content);
|
||||
const body = match ? (match[2] ?? '') : content;
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useMemo, useState } from 'react';
|
|||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Bell, Puzzle, Settings, Users, PanelRight } from 'lucide-react';
|
||||
import { Bell, PanelRight, Puzzle, Settings, Users } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { MoreMenu } from './MoreMenu';
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ import {
|
|||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
||||
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
||||
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
||||
|
||||
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
|||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: ScheduleStatus | 'all'; label: string }> = [
|
||||
const STATUS_OPTIONS: { value: ScheduleStatus | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'paused', label: 'Paused' },
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export interface SafeConfig {
|
|||
notifyOnClarifications: boolean;
|
||||
notifyOnStatusChange: boolean;
|
||||
notifyOnTaskComments: boolean;
|
||||
notifyOnTaskCreated: boolean;
|
||||
notifyOnAllTasksCompleted: boolean;
|
||||
notifyOnCrossTeamMessage: boolean;
|
||||
statusChangeOnlySolo: boolean;
|
||||
statusChangeStatuses: string[];
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
|
|
@ -181,6 +184,10 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
||||
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? true,
|
||||
notifyOnTaskCreated: displayConfig?.notifications?.notifyOnTaskCreated ?? true,
|
||||
notifyOnAllTasksCompleted:
|
||||
displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
|
||||
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||
'in_progress',
|
||||
|
|
|
|||
|
|
@ -300,6 +300,9 @@ export function useSettingsHandlers({
|
|||
notifyOnClarifications: true,
|
||||
notifyOnStatusChange: true,
|
||||
notifyOnTaskComments: true,
|
||||
notifyOnTaskCreated: true,
|
||||
notifyOnAllTasksCompleted: true,
|
||||
notifyOnCrossTeamMessage: true,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: defaultTriggers,
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ export const ConfigEditorDialog = ({
|
|||
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]),
|
||||
baseEditorTheme,
|
||||
configEditorTheme,
|
||||
// eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup
|
||||
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const text = update.state.doc.toString();
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@ import {
|
|||
ArrowRightLeft,
|
||||
Bell,
|
||||
BellRing,
|
||||
CheckCircle2,
|
||||
CirclePlus,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
EyeOff,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
Inbox,
|
||||
Info,
|
||||
|
|
@ -36,7 +39,9 @@ import type { NotificationTrigger } from '@renderer/types/data';
|
|||
import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||
|
||||
/** Notification targets span workflow status plus the explicit review axis. */
|
||||
type NotifiableStatus = TeamTaskStatus | Extract<TeamReviewState, 'needsFix' | 'approved'>;
|
||||
type NotifiableStatus =
|
||||
| TeamTaskStatus
|
||||
| Extract<TeamReviewState, 'review' | 'needsFix' | 'approved'>;
|
||||
|
||||
// Snooze duration options
|
||||
const SNOOZE_OPTIONS = [
|
||||
|
|
@ -64,6 +69,9 @@ interface NotificationsSectionProps {
|
|||
| 'notifyOnClarifications'
|
||||
| 'notifyOnStatusChange'
|
||||
| 'notifyOnTaskComments'
|
||||
| 'notifyOnTaskCreated'
|
||||
| 'notifyOnAllTasksCompleted'
|
||||
| 'notifyOnCrossTeamMessage'
|
||||
| 'statusChangeOnlySolo',
|
||||
value: boolean
|
||||
) => void;
|
||||
|
|
@ -293,6 +301,39 @@ export const NotificationsSection = ({
|
|||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Task created notifications"
|
||||
description="Show native OS notifications when a new task is created"
|
||||
icon={<CirclePlus className="size-4" />}
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnTaskCreated}
|
||||
onChange={(v) => onNotificationToggle('notifyOnTaskCreated', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="All tasks completed"
|
||||
description="Notify when every task in a team reaches completed status"
|
||||
icon={<CheckCircle2 className="size-4" />}
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnAllTasksCompleted}
|
||||
onChange={(v) => onNotificationToggle('notifyOnAllTasksCompleted', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Cross-team message notifications"
|
||||
description="Notify when a message arrives from another team"
|
||||
icon={<GitBranch className="size-4" />}
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnCrossTeamMessage}
|
||||
onChange={(v) => onNotificationToggle('notifyOnCrossTeamMessage', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Task Status Change Notifications — nested within team card */}
|
||||
<div className="last:*:border-b-0">
|
||||
|
|
@ -434,6 +475,7 @@ export const NotificationsSection = ({
|
|||
const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
|
||||
{ value: 'in_progress', label: 'Started' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'needsFix', label: 'Needs Fixes' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
|
|
|
|||
|
|
@ -358,7 +358,6 @@ export const GlobalTaskList = ({
|
|||
// Reset showArchived when archive becomes empty
|
||||
useEffect(() => {
|
||||
if (showArchived && !hasArchivedTasks) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
||||
setShowArchived(false);
|
||||
}
|
||||
}, [showArchived, hasArchivedTasks]);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { projectColor } from '@renderer/utils/projectColor';
|
||||
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
||||
|
|
|
|||
|
|
@ -119,6 +119,24 @@ export const RoleSelect = ({
|
|||
return opt?.label;
|
||||
}, [value]);
|
||||
|
||||
const renderTriggerLabel = useCallback(
|
||||
(option: ComboboxOption) => {
|
||||
const Icon =
|
||||
option.value === CUSTOM_ROLE
|
||||
? CUSTOM_ICON
|
||||
: option.value === NO_ROLE
|
||||
? null
|
||||
: (ROLE_ICONS[option.value] ?? null);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{Icon ? <Icon className="size-3 text-[var(--color-text-muted)]" /> : null}
|
||||
{option.label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Combobox
|
||||
|
|
@ -131,6 +149,7 @@ export const RoleSelect = ({
|
|||
disabled={disabled}
|
||||
className={triggerClassName}
|
||||
renderOption={renderRoleOption}
|
||||
renderTriggerLabel={renderTriggerLabel}
|
||||
/>
|
||||
{value === CUSTOM_ROLE && onCustomRoleChange ? (
|
||||
<div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue