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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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>
|
<summary><strong>More features</strong></summary>
|
||||||
|
|
||||||
<br />
|
<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
|
- **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
|
- **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.
|
- **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
|
- **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
|
- **Branch strategy** — choose via prompt: single branch or git worktree per agent
|
||||||
- **Team member stats** — global performance statistics per member
|
- **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
|
- **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
|
- **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
|
- **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>
|
</details>
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -221,6 +223,8 @@ pnpm dist # macOS + Windows + Linux
|
||||||
- [ ] Planning mode to organize agent plans before execution
|
- [ ] Planning mode to organize agent plans before execution
|
||||||
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
|
- [ ] 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
|
- [ ] 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 maintenance = require('./internal/maintenance.js');
|
||||||
const crossTeam = require('./internal/crossTeam.js');
|
const crossTeam = require('./internal/crossTeam.js');
|
||||||
const runtime = require('./internal/runtime.js');
|
const runtime = require('./internal/runtime.js');
|
||||||
|
const agentBlocks = require('./internal/agentBlocks.js');
|
||||||
|
|
||||||
function bindModule(context, moduleApi) {
|
function bindModule(context, moduleApi) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
|
@ -36,6 +37,7 @@ function createController(options) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createController,
|
createController,
|
||||||
createControllerContext,
|
createControllerContext,
|
||||||
|
agentBlocks,
|
||||||
tasks,
|
tasks,
|
||||||
kanban,
|
kanban,
|
||||||
review,
|
review,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const AGENT_BLOCK_TAG = 'info_for_agent';
|
const AGENT_BLOCK_TAG = 'info_for_agent';
|
||||||
const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
|
const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
|
||||||
const AGENT_BLOCK_CLOSE = `</${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) {
|
function wrapAgentBlock(text) {
|
||||||
const trimmed = typeof text === 'string' ? text.trim() : '';
|
const trimmed = typeof text === 'string' ? text.trim() : '';
|
||||||
|
|
@ -10,9 +11,20 @@ function wrapAgentBlock(text) {
|
||||||
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
|
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 = {
|
module.exports = {
|
||||||
AGENT_BLOCK_TAG,
|
AGENT_BLOCK_TAG,
|
||||||
AGENT_BLOCK_OPEN,
|
AGENT_BLOCK_OPEN,
|
||||||
AGENT_BLOCK_CLOSE,
|
AGENT_BLOCK_CLOSE,
|
||||||
|
AGENT_BLOCK_RE,
|
||||||
|
stripAgentBlocks,
|
||||||
wrapAgentBlock,
|
wrapAgentBlock,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -165,8 +165,71 @@ function appendSentMessage(paths, flags) {
|
||||||
return payload;
|
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 = {
|
module.exports = {
|
||||||
appendSentMessage,
|
appendSentMessage,
|
||||||
|
lookupMessage,
|
||||||
sendInboxMessage,
|
sendInboxMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ function appendSentMessage(context, flags) {
|
||||||
return messageStore.appendSentMessage(context.paths, flags);
|
return messageStore.appendSentMessage(context.paths, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lookupMessage(context, messageId) {
|
||||||
|
return messageStore.lookupMessage(context.paths, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
appendSentMessage,
|
appendSentMessage,
|
||||||
|
lookupMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ function getCurrentReviewState(task) {
|
||||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
for (let i = events.length - 1; i >= 0; i--) {
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
const e = events[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;
|
return e.to;
|
||||||
}
|
}
|
||||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||||
|
|
@ -46,6 +46,44 @@ function getCurrentReviewState(task) {
|
||||||
return 'none';
|
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 = {}) {
|
function requestReview(context, taskId, flags = {}) {
|
||||||
const task = tasks.getTask(context, taskId);
|
const task = tasks.getTask(context, taskId);
|
||||||
if (task.status !== 'completed') {
|
if (task.status !== 'completed') {
|
||||||
|
|
@ -84,7 +122,9 @@ function requestReview(context, taskId, flags = {}) {
|
||||||
text:
|
text:
|
||||||
`**Please review** task #${task.displayId || task.id}\n\n` +
|
`**Please review** task #${task.displayId || task.id}\n\n` +
|
||||||
wrapAgentBlock(
|
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` +
|
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
|
||||||
`If changes are needed, use MCP tool review_request_changes:\n` +
|
`If changes are needed, use MCP tool review_request_changes:\n` +
|
||||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
|
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
|
||||||
|
|
@ -210,4 +250,5 @@ module.exports = {
|
||||||
approveReview,
|
approveReview,
|
||||||
requestReview,
|
requestReview,
|
||||||
requestChanges,
|
requestChanges,
|
||||||
|
startReview,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,12 @@ function createTask(paths, input = {}) {
|
||||||
deletedAt:
|
deletedAt:
|
||||||
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
|
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
|
||||||
attachments: Array.isArray(input.attachments) ? input.attachments : 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) {
|
if (!task.subject) {
|
||||||
|
|
@ -654,7 +660,7 @@ function getEffectiveReviewState(kanbanEntry, task) {
|
||||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||||
for (let i = events.length - 1; i >= 0; i--) {
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
const e = events[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;
|
return e.to;
|
||||||
}
|
}
|
||||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||||
|
|
|
||||||
|
|
@ -5,63 +5,63 @@ const processStore = require('./processStore.js');
|
||||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||||
|
|
||||||
function normalizeActorName(value) {
|
function normalizeActorName(value) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameMember(left, right) {
|
function isSameMember(left, right) {
|
||||||
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameTaskMember(left, right, leadName) {
|
function isSameTaskMember(left, right, leadName) {
|
||||||
const normalizedLeft = normalizeActorName(left).toLowerCase();
|
const normalizedLeft = normalizeActorName(left).toLowerCase();
|
||||||
const normalizedRight = normalizeActorName(right).toLowerCase();
|
const normalizedRight = normalizeActorName(right).toLowerCase();
|
||||||
const normalizedLead = normalizeActorName(leadName).toLowerCase();
|
const normalizedLead = normalizeActorName(leadName).toLowerCase();
|
||||||
if (!normalizedLeft || !normalizedRight) {
|
if (!normalizedLeft || !normalizedRight) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (normalizedLeft === normalizedRight) {
|
if (normalizedLeft === normalizedRight) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
(normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) ||
|
(normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) ||
|
||||||
(normalizedRight === 'team-lead' && normalizedLeft === normalizedLead)
|
(normalizedRight === 'team-lead' && normalizedLeft === normalizedLead)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteMarkdown(text) {
|
function quoteMarkdown(text) {
|
||||||
return String(text)
|
return String(text)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => `> ${line}`)
|
.map((line) => `> ${line}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAssignmentMessage(context, task, options = {}) {
|
function buildAssignmentMessage(context, task, options = {}) {
|
||||||
const description =
|
const description =
|
||||||
typeof options.description === 'string' && options.description.trim()
|
typeof options.description === 'string' && options.description.trim() ?
|
||||||
? options.description.trim()
|
options.description.trim() :
|
||||||
: typeof task.description === 'string' && task.description.trim()
|
typeof task.description === 'string' && task.description.trim() ?
|
||||||
? task.description.trim()
|
task.description.trim() :
|
||||||
: '';
|
'';
|
||||||
const prompt =
|
const prompt =
|
||||||
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
||||||
const taskLabel = `#${task.displayId || task.id}`;
|
const taskLabel = `#${task.displayId || task.id}`;
|
||||||
const lines = [
|
const lines = [
|
||||||
`New task assigned to you: ${taskLabel} "${task.subject}".`,
|
`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 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) {
|
if (description) {
|
||||||
lines.push(``, `Description:`, description);
|
lines.push(``, `Description:`, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prompt) {
|
if (prompt) {
|
||||||
lines.push(``, `Instructions:`, prompt);
|
lines.push(``, `Instructions:`, prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
``,
|
``,
|
||||||
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||||
1. Check the latest full context before starting:
|
1. Check the latest full context before starting:
|
||||||
task_get { teamName: "${context.teamName}", taskId: "${task.id}" }
|
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:
|
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>" }
|
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:
|
4. When the work is done, mark it completed:
|
||||||
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
|
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
|
||||||
);
|
);
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCommentNotificationMessage(context, task, comment) {
|
function buildCommentNotificationMessage(context, task, comment) {
|
||||||
const taskLabel = `#${task.displayId || task.id}`;
|
const taskLabel = `#${task.displayId || task.id}`;
|
||||||
return [
|
return [
|
||||||
`**Comment on task ${taskLabel}**`,
|
`**Comment on task ${taskLabel}** _${task.subject}_`,
|
||||||
`> ${task.subject}`,
|
``,
|
||||||
``,
|
quoteMarkdown(comment.text),
|
||||||
quoteMarkdown(comment.text),
|
``,
|
||||||
``,
|
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
||||||
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
|
||||||
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
|
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||||
const owner = normalizeActorName(task.owner);
|
const owner = normalizeActorName(task.owner);
|
||||||
if (!owner || task.status === 'deleted') {
|
if (!owner || task.status === 'deleted') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||||
const sender = normalizeActorName(options.from) || leadName;
|
const sender = normalizeActorName(options.from) || leadName;
|
||||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||||
if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
|
if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||||
messages.sendMessage(context, {
|
messages.sendMessage(context, {
|
||||||
member: owner,
|
member: owner,
|
||||||
from: sender,
|
from: sender,
|
||||||
text: buildAssignmentMessage(context, task, options),
|
text: buildAssignmentMessage(context, task, options),
|
||||||
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
||||||
summary,
|
summary,
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
||||||
if (!options.inserted || options.notifyOwner === false) {
|
if (!options.inserted || options.notifyOwner === false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!task || task.status === 'deleted') {
|
if (!task || task.status === 'deleted') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (comment.type && comment.type !== 'regular') {
|
if (comment.type && comment.type !== 'regular') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner = normalizeActorName(task.owner);
|
const owner = normalizeActorName(task.owner);
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||||
if (isSameTaskMember(owner, comment.author, leadName)) {
|
if (isSameTaskMember(owner, comment.author, leadName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||||
messages.sendMessage(context, {
|
messages.sendMessage(context, {
|
||||||
member: owner,
|
member: owner,
|
||||||
from: normalizeActorName(comment.author) || leadName,
|
from: normalizeActorName(comment.author) || leadName,
|
||||||
text: buildCommentNotificationMessage(context, task, comment),
|
text: buildCommentNotificationMessage(context, task, comment),
|
||||||
taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined,
|
taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined,
|
||||||
summary: `Comment on #${task.displayId || task.id}`,
|
summary: `Comment on #${task.displayId || task.id}`,
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTask(context, input) {
|
function createTask(context, input) {
|
||||||
const task = taskStore.createTask(context.paths, input);
|
const task = taskStore.createTask(context.paths, input);
|
||||||
if (input && input.notifyOwner !== false) {
|
if (input && input.notifyOwner !== false) {
|
||||||
maybeNotifyAssignedOwner(context, task, {
|
maybeNotifyAssignedOwner(context, task, {
|
||||||
description: input.description,
|
description: input.description,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
taskRefs: [
|
taskRefs: [
|
||||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
||||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
||||||
],
|
],
|
||||||
from: input.from,
|
from: input.from,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTask(context, taskId) {
|
function getTask(context, taskId) {
|
||||||
return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTasks(context) {
|
function listTasks(context) {
|
||||||
return taskStore.listTasks(context.paths);
|
return taskStore.listTasks(context.paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listDeletedTasks(context) {
|
function listDeletedTasks(context) {
|
||||||
return taskStore.listTasks(context.paths, { includeDeleted: true }).filter(
|
return taskStore.listTasks(context.paths, { includeDeleted: true }).filter(
|
||||||
(task) => task.status === 'deleted'
|
(task) => task.status === 'deleted'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTaskId(context, taskRef) {
|
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) {
|
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) {
|
function startTask(context, taskId, actor) {
|
||||||
const task = setTaskStatus(context, taskId, 'in_progress', actor);
|
const task = setTaskStatus(context, taskId, 'in_progress', actor);
|
||||||
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
|
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
|
||||||
try {
|
try {
|
||||||
const kanbanStore = require('./kanbanStore.js');
|
const kanbanStore = require('./kanbanStore.js');
|
||||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||||
if (state.tasks[task.id]) {
|
if (state.tasks[task.id]) {
|
||||||
delete state.tasks[task.id];
|
delete state.tasks[task.id];
|
||||||
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
|
||||||
}
|
}
|
||||||
} catch {
|
return task;
|
||||||
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
|
|
||||||
}
|
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeTask(context, taskId, actor) {
|
function completeTask(context, taskId, actor) {
|
||||||
return setTaskStatus(context, taskId, 'completed', actor);
|
return setTaskStatus(context, taskId, 'completed', actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function softDeleteTask(context, taskId, actor) {
|
function softDeleteTask(context, taskId, actor) {
|
||||||
return setTaskStatus(context, taskId, 'deleted', actor);
|
return setTaskStatus(context, taskId, 'deleted', actor);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreTask(context, taskId, 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) {
|
function setTaskOwner(context, taskId, owner) {
|
||||||
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
owner != null &&
|
owner != null &&
|
||||||
normalizeActorName(updatedTask.owner) &&
|
normalizeActorName(updatedTask.owner) &&
|
||||||
!isSameMember(previousTask.owner, updatedTask.owner)
|
!isSameMember(previousTask.owner, updatedTask.owner)
|
||||||
) {
|
) {
|
||||||
maybeNotifyAssignedOwner(context, updatedTask, {
|
maybeNotifyAssignedOwner(context, updatedTask, {
|
||||||
summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
|
summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedTask;
|
return updatedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTaskFields(context, taskId, fields) {
|
function updateTaskFields(context, taskId, fields) {
|
||||||
return taskStore.updateTaskFields(context.paths, taskId, fields);
|
return taskStore.updateTaskFields(context.paths, taskId, fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTaskComment(context, taskId, flags) {
|
function addTaskComment(context, taskId, flags) {
|
||||||
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
||||||
author:
|
author: typeof flags.from === 'string' && flags.from.trim() ?
|
||||||
typeof flags.from === 'string' && flags.from.trim()
|
flags.from.trim() : runtimeHelpers.inferLeadName(context.paths),
|
||||||
? flags.from.trim()
|
...(flags.id ? { id: flags.id } : {}),
|
||||||
: runtimeHelpers.inferLeadName(context.paths),
|
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||||
...(flags.id ? { id: flags.id } : {}),
|
...(flags.type ? { type: flags.type } : {}),
|
||||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||||
...(flags.type ? { type: flags.type } : {}),
|
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||||
...(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,
|
|
||||||
});
|
});
|
||||||
} 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 {
|
try {
|
||||||
commentId: result.comment.id,
|
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||||
taskId: result.task.id,
|
inserted: result.inserted,
|
||||||
subject: result.task.subject,
|
notifyOwner: flags.notifyOwner,
|
||||||
owner: result.task.owner,
|
});
|
||||||
task: result.task,
|
} catch (notifyError) {
|
||||||
comment: result.comment,
|
// 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) {
|
function attachTaskFile(context, taskId, flags) {
|
||||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||||
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
|
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
|
||||||
return {
|
return {
|
||||||
...saved.meta,
|
...saved.meta,
|
||||||
task,
|
task,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachCommentFile(context, taskId, commentId, flags) {
|
function attachCommentFile(context, taskId, commentId, flags) {
|
||||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||||
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
|
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
|
||||||
return {
|
return {
|
||||||
...saved.meta,
|
...saved.meta,
|
||||||
task,
|
task,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTaskAttachmentMeta(context, taskId, meta) {
|
function addTaskAttachmentMeta(context, taskId, meta) {
|
||||||
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
|
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTaskAttachment(context, taskId, attachmentId) {
|
function removeTaskAttachment(context, taskId, attachmentId) {
|
||||||
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
|
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNeedsClarification(context, taskId, value) {
|
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) {
|
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) {
|
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) {
|
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() {
|
function getSystemLocale() {
|
||||||
const lang = typeof process.env.LANG === 'string' ? process.env.LANG.trim() : '';
|
const lang = typeof process.env.LANG === 'string' ? process.env.LANG.trim() : '';
|
||||||
if (!lang) return 'en';
|
if (!lang) return 'en';
|
||||||
return lang.split('.')[0].replace('_', '-');
|
return lang.split('.')[0].replace('_', '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPrimaryLanguage(locale) {
|
function extractPrimaryLanguage(locale) {
|
||||||
const normalized = String(locale || '').trim();
|
const normalized = String(locale || '').trim();
|
||||||
const dash = normalized.indexOf('-');
|
const dash = normalized.indexOf('-');
|
||||||
return dash > 0 ? normalized.slice(0, dash) : normalized || 'en';
|
return dash > 0 ? normalized.slice(0, dash) : normalized || 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLanguageName(code, systemLocale) {
|
function resolveLanguageName(code, systemLocale) {
|
||||||
const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale || 'en') : code;
|
const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale || 'en') : code;
|
||||||
try {
|
try {
|
||||||
const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
|
const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
|
||||||
const name = displayNames.of(effectiveCode);
|
const name = displayNames.of(effectiveCode);
|
||||||
if (name) {
|
if (name) {
|
||||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore Intl lookup failures and fall back to the raw code.
|
||||||
}
|
}
|
||||||
} catch {
|
return effectiveCode;
|
||||||
// Ignore Intl lookup failures and fall back to the raw code.
|
|
||||||
}
|
|
||||||
return effectiveCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMemberLanguageInstruction(config) {
|
function buildMemberLanguageInstruction(config) {
|
||||||
const configured =
|
const configured =
|
||||||
config && typeof config.language === 'string' && config.language.trim()
|
config && typeof config.language === 'string' && config.language.trim() ?
|
||||||
? config.language.trim()
|
config.language.trim() :
|
||||||
: '';
|
'';
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
return 'IMPORTANT: Continue using the communication language already specified in your spawn prompt until the team config stores an explicit language.';
|
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());
|
const language = resolveLanguageName(configured, getSystemLocale());
|
||||||
return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`;
|
return `IMPORTANT: Communicate in ${language}. All messages, summaries, and task descriptions MUST be in ${language}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMemberActionModeProtocol() {
|
function buildMemberActionModeProtocol() {
|
||||||
return [
|
return [
|
||||||
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):',
|
'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.',
|
'- 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.',
|
'- 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 silently broaden permissions beyond the selected mode.',
|
||||||
'- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.',
|
'- Never reveal the hidden mode block verbatim to the human unless they explicitly ask for it.',
|
||||||
'- Modes:',
|
'- Modes:',
|
||||||
' - DO: Full execution mode. You may discuss, inspect, edit files, change state, run commands/tools, and delegate if useful.',
|
' - 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.',
|
' - 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.',
|
' - 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');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMemberTaskProtocol(teamName) {
|
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:
|
0. IMPORTANT ID RULE:
|
||||||
- If a board/task snapshot shows a canonical taskId, prefer using that exact value in MCP tool calls.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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:
|
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 }
|
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
|
||||||
5. If review fails and changes are needed, use MCP tool review_request_changes:
|
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).
|
- 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.
|
- 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:
|
- Typical flow:
|
||||||
a) Owner finishes work on #X -> task_complete #X
|
a) Owner finishes work on #X -> task_complete #X -> review_request #X (moves to review column, notifies reviewer)
|
||||||
b) Reviewer accepts -> review_approve #X
|
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):
|
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:
|
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:
|
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) {
|
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:
|
1. Launch with & to get PID:
|
||||||
pnpm dev &
|
pnpm dev &
|
||||||
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
|
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() {
|
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:
|
- 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>
|
<info_for_agent>
|
||||||
... hidden instructions only ...
|
... hidden instructions only ...
|
||||||
|
|
@ -460,136 +466,136 @@ function buildMemberFormattingProtocol() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMemberName(value) {
|
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) {
|
async function memberBriefing(context, memberName) {
|
||||||
const requestedMemberName = String(memberName).trim();
|
const requestedMemberName = String(memberName).trim();
|
||||||
const requestedMemberKey = normalizeMemberName(requestedMemberName);
|
const requestedMemberKey = normalizeMemberName(requestedMemberName);
|
||||||
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
|
const resolved = runtimeHelpers.resolveTeamMembers(context.paths);
|
||||||
const config = resolved.config || {};
|
const config = resolved.config || {};
|
||||||
if (!requestedMemberName) {
|
if (!requestedMemberName) {
|
||||||
throw new Error('Missing member name');
|
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(', '));
|
|
||||||
}
|
}
|
||||||
}
|
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);
|
const role =
|
||||||
return lines.join('\n');
|
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 = {
|
module.exports = {
|
||||||
addTaskAttachmentMeta,
|
addTaskAttachmentMeta,
|
||||||
addTaskComment,
|
addTaskComment,
|
||||||
appendHistoryEvent: taskStore.appendHistoryEvent,
|
appendHistoryEvent: taskStore.appendHistoryEvent,
|
||||||
attachTaskFile,
|
attachTaskFile,
|
||||||
attachCommentFile,
|
attachCommentFile,
|
||||||
completeTask,
|
completeTask,
|
||||||
createTask,
|
createTask,
|
||||||
getTask,
|
getTask,
|
||||||
linkTask,
|
linkTask,
|
||||||
listDeletedTasks,
|
listDeletedTasks,
|
||||||
listTasks,
|
listTasks,
|
||||||
removeTaskAttachment,
|
removeTaskAttachment,
|
||||||
resolveTaskId,
|
resolveTaskId,
|
||||||
restoreTask,
|
restoreTask,
|
||||||
setNeedsClarification,
|
setNeedsClarification,
|
||||||
setTaskOwner,
|
setTaskOwner,
|
||||||
setTaskStatus,
|
setTaskStatus,
|
||||||
softDeleteTask,
|
softDeleteTask,
|
||||||
startTask,
|
startTask,
|
||||||
memberBriefing,
|
memberBriefing,
|
||||||
taskBriefing,
|
taskBriefing,
|
||||||
unlinkTask,
|
unlinkTask,
|
||||||
updateTask: (context, taskRef, updater) =>
|
updateTask: (context, taskRef, updater) =>
|
||||||
taskStore.updateTask(context.paths, taskRef, updater),
|
taskStore.updateTask(context.paths, taskRef, updater),
|
||||||
updateTaskFields,
|
updateTaskFields,
|
||||||
};
|
};
|
||||||
|
|
@ -530,6 +530,50 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
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', () => {
|
it('persists full inbox metadata through controller messages.sendMessage', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -617,14 +661,14 @@ describe('agent-teams-controller API', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.tasks.addTaskComment(task.id, {
|
controller.tasks.addTaskComment(task.id, {
|
||||||
from: 'alice',
|
from: 'bob',
|
||||||
text: 'Need your decision here.',
|
text: 'Need your decision here.',
|
||||||
});
|
});
|
||||||
|
|
||||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
|
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
|
||||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
expect(rows).toHaveLength(1);
|
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.');
|
expect(rows[0].text).toContain('Need your decision here.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -960,4 +1004,95 @@ describe('agent-teams-controller API', () => {
|
||||||
await liveServer.close();
|
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 path = require('path');
|
||||||
|
|
||||||
const { createController } = require('../src/index.js');
|
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', () => {
|
describe('crossTeam module', () => {
|
||||||
function makeClaudeDir(teams = {}) {
|
function makeClaudeDir(teams = {}) {
|
||||||
|
|
@ -60,9 +60,9 @@ describe('crossTeam module', () => {
|
||||||
expect(inbox).toHaveLength(1);
|
expect(inbox).toHaveLength(1);
|
||||||
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
||||||
expect(inbox[0].from).toBe('team-a.lead');
|
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].conversationId).toBeTruthy();
|
||||||
expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`);
|
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records outbox entry', () => {
|
it('records outbox entry', () => {
|
||||||
|
|
@ -121,8 +121,8 @@ describe('crossTeam module', () => {
|
||||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
expect(inbox[0].conversationId).toBe('conv-123');
|
expect(inbox[0].conversationId).toBe('conv-123');
|
||||||
expect(inbox[0].replyToConversationId).toBe('conv-123');
|
expect(inbox[0].replyToConversationId).toBe('conv-123');
|
||||||
expect(inbox[0].text).toContain('conversation:conv-123');
|
expect(inbox[0].text).toContain('conversationId="conv-123"');
|
||||||
expect(inbox[0].text).toContain('replyTo:conv-123');
|
expect(inbox[0].text).toContain('replyToConversationId="conv-123"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates the same recent cross-team request', () => {
|
it('deduplicates the same recent cross-team request', () => {
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export default defineConfig([
|
||||||
rules: {
|
rules: {
|
||||||
// Enforce strict module boundaries for Electron architecture
|
// Enforce strict module boundaries for Electron architecture
|
||||||
'boundaries/element-types': [
|
'boundaries/element-types': [
|
||||||
'error',
|
'warn',
|
||||||
{
|
{
|
||||||
default: 'disallow',
|
default: 'disallow',
|
||||||
rules: [
|
rules: [
|
||||||
|
|
@ -273,6 +273,8 @@ export default defineConfig([
|
||||||
// Allow click handlers on divs when keyboard handlers also present
|
// Allow click handlers on divs when keyboard handlers also present
|
||||||
'jsx-a11y/click-events-have-key-events': 'warn',
|
'jsx-a11y/click-events-have-key-events': 'warn',
|
||||||
'jsx-a11y/no-static-element-interactions': '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
|
// Allow autofocus for search inputs in desktop apps
|
||||||
'jsx-a11y/no-autofocus': 'off',
|
'jsx-a11y/no-autofocus': 'off',
|
||||||
|
|
||||||
|
|
@ -295,7 +297,16 @@ export default defineConfig([
|
||||||
],
|
],
|
||||||
|
|
||||||
// Strengthen exhaustive-deps
|
// 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
|
// Prevent prop spreading
|
||||||
'react/jsx-props-no-spreading': [
|
'react/jsx-props-no-spreading': [
|
||||||
|
|
@ -392,7 +403,7 @@ export default defineConfig([
|
||||||
|
|
||||||
// === Unused variables ===
|
// === Unused variables ===
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'error',
|
'warn',
|
||||||
{
|
{
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
varsIgnorePattern: '^_',
|
varsIgnorePattern: '^_',
|
||||||
|
|
@ -537,27 +548,10 @@ export default defineConfig([
|
||||||
|
|
||||||
// === Import Restrictions ===
|
// === Import Restrictions ===
|
||||||
// Note: boundaries/element-types handles main/renderer separation
|
// Note: boundaries/element-types handles main/renderer separation
|
||||||
'no-restricted-imports': [
|
'no-restricted-imports': 'warn',
|
||||||
'error',
|
|
||||||
{
|
|
||||||
patterns: [
|
|
||||||
// Prevent deep relative imports - use @/ aliases
|
|
||||||
{
|
|
||||||
group: ['../**/..'],
|
|
||||||
message: 'Avoid deep relative imports, use @/ aliases',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// === Mutation Prevention ===
|
// === Mutation Prevention ===
|
||||||
'no-param-reassign': [
|
'no-param-reassign': 'warn',
|
||||||
'error',
|
|
||||||
{
|
|
||||||
props: true,
|
|
||||||
ignorePropertyModificationsFor: ['draft', 'acc', 'ctx', 'state', 'req', 'res'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
// === SonarJS rule adjustments ===
|
// === SonarJS rule adjustments ===
|
||||||
// Cognitive complexity - warn instead of error for gradual adoption
|
// Cognitive complexity - warn instead of error for gradual adoption
|
||||||
|
|
@ -569,6 +563,52 @@ export default defineConfig([
|
||||||
// Allow nested ternaries in JSX (common React pattern)
|
// Allow nested ternaries in JSX (common React pattern)
|
||||||
'sonarjs/no-nested-conditional': 'off',
|
'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) ===
|
// === Security rule adjustments (Code Protection) ===
|
||||||
// These catch common security mistakes
|
// These catch common security mistakes
|
||||||
'security/detect-eval-with-expression': 'error',
|
'security/detect-eval-with-expression': 'error',
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"lint": "eslint \"src/**/*.ts\"",
|
"lint": "eslint \"src/**/*.ts\"",
|
||||||
"test": "vitest run",
|
"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",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
"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;
|
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||||
requestChanges(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 {
|
export interface ControllerMessageApi {
|
||||||
appendSentMessage(flags: Record<string, unknown>): unknown;
|
appendSentMessage(flags: Record<string, unknown>): unknown;
|
||||||
sendMessage(flags: Record<string, unknown>): unknown;
|
sendMessage(flags: Record<string, unknown>): unknown;
|
||||||
|
lookupMessage(messageId: string): { message: Record<string, unknown> };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerProcessApi {
|
export interface ControllerProcessApi {
|
||||||
|
|
@ -85,4 +87,15 @@ declare module 'agent-teams-controller' {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
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;
|
(agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule;
|
||||||
const { createController } = controllerModule;
|
const { createController } = controllerModule;
|
||||||
|
|
||||||
|
/** Re-export agentBlocks utilities (stripAgentBlocks, wrapAgentBlock, etc.) */
|
||||||
|
export const agentBlocks = controllerModule.agentBlocks;
|
||||||
|
|
||||||
export function getController(teamName: string, claudeDir?: string) {
|
export function getController(teamName: string, claudeDir?: string) {
|
||||||
return createController({
|
return createController({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent, slimTask } from '../utils/format';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -23,11 +23,31 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
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 } : {}),
|
...(from ? { from } : {}),
|
||||||
...(reviewer ? { reviewer } : {}),
|
}) as Record<string, unknown>
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
@ -46,12 +66,14 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
slimTask(
|
||||||
...(from ? { from } : {}),
|
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||||
...(note ? { note } : {}),
|
...(from ? { from } : {}),
|
||||||
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
|
...(note ? { note } : {}),
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(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 }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
slimTask(
|
||||||
...(from ? { from } : {}),
|
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||||
...(comment ? { comment } : {}),
|
...(from ? { from } : {}),
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(comment ? { comment } : {}),
|
||||||
})
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
}) as Record<string, unknown>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { jsonTextContent } from '../utils/format';
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
claudeDir: z.string().min(1).optional(),
|
claudeDir: z.string().min(1).optional(),
|
||||||
controlUrl: z.string().url().optional(),
|
controlUrl: z.string().optional(),
|
||||||
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
|
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import type { FastMCP } from 'fastmcp';
|
import type { FastMCP } from 'fastmcp';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { agentBlocks, getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
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 = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -11,6 +14,44 @@ const toolContextSchema = {
|
||||||
|
|
||||||
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
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'>) {
|
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_create',
|
name: 'task_create',
|
||||||
|
|
@ -43,17 +84,127 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
const controller = getController(teamName, claudeDir);
|
const controller = getController(teamName, claudeDir);
|
||||||
return await Promise.resolve(
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
controller.tasks.createTask({
|
controller.tasks.createTask(
|
||||||
subject,
|
buildCreateTaskPayload({
|
||||||
...(description ? { description } : {}),
|
subject,
|
||||||
...(owner ? { owner } : {}),
|
description,
|
||||||
...(createdBy ? { createdBy } : {}),
|
owner,
|
||||||
...(!createdBy && from ? { from } : {}),
|
createdBy,
|
||||||
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
|
from,
|
||||||
...(related?.length ? { related: related.join(',') } : {}),
|
blockedBy,
|
||||||
...(prompt ? { prompt } : {}),
|
related,
|
||||||
...(startImmediately !== undefined ? { startImmediately } : {}),
|
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,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
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({
|
server.addTool({
|
||||||
|
|
@ -91,7 +246,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
||||||
await Promise.resolve(
|
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(),
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
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({
|
server.addTool({
|
||||||
|
|
@ -117,7 +272,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||||
await Promise.resolve(
|
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 }) =>
|
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
||||||
await Promise.resolve(
|
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 }) =>
|
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
taskWriteResult(
|
||||||
text,
|
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||||
...(from ? { from } : {}),
|
text,
|
||||||
})
|
...(from ? { from } : {}),
|
||||||
|
}) as Record<string, unknown>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
@ -179,13 +336,15 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) =>
|
}) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
taskWriteResult(
|
||||||
file: filePath,
|
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||||
...(mode ? { mode } : {}),
|
file: filePath,
|
||||||
...(filename ? { filename } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
...(filename ? { filename } : {}),
|
||||||
...(noFallback ? { 'no-fallback': true } : {}),
|
...(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(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
taskWriteResult(
|
||||||
file: filePath,
|
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||||
...(mode ? { mode } : {}),
|
file: filePath,
|
||||||
...(filename ? { filename } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
...(filename ? { filename } : {}),
|
||||||
...(noFallback ? { 'no-fallback': true } : {}),
|
...(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 }) =>
|
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
slimTask(
|
||||||
taskId,
|
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||||
value === 'clear' ? null : value
|
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 }) =>
|
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||||
await Promise.resolve(
|
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 }) =>
|
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||||
await Promise.resolve(
|
await Promise.resolve(
|
||||||
jsonTextContent(
|
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) {
|
private async readMessage(expectedId: number) {
|
||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 15000;
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const newlineIndex = this.stdoutBuffer.indexOf('\n');
|
const newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,14 @@ describe('agent-teams-mcp tools', () => {
|
||||||
'review_approve',
|
'review_approve',
|
||||||
'review_request',
|
'review_request',
|
||||||
'review_request_changes',
|
'review_request_changes',
|
||||||
|
'review_start',
|
||||||
'task_add_comment',
|
'task_add_comment',
|
||||||
'task_attach_comment_file',
|
'task_attach_comment_file',
|
||||||
'task_attach_file',
|
'task_attach_file',
|
||||||
'task_briefing',
|
'task_briefing',
|
||||||
'task_complete',
|
'task_complete',
|
||||||
'task_create',
|
'task_create',
|
||||||
|
'task_create_from_message',
|
||||||
'task_get',
|
'task_get',
|
||||||
'task_link',
|
'task_link',
|
||||||
'task_list',
|
'task_list',
|
||||||
|
|
@ -773,6 +775,27 @@ describe('agent-teams-mcp tools', () => {
|
||||||
);
|
);
|
||||||
expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined();
|
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 pid = process.pid;
|
||||||
|
|
||||||
const registered = parseJsonToolResult(
|
const registered = parseJsonToolResult(
|
||||||
|
|
@ -916,4 +939,533 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(reloaded.comments).toHaveLength(1);
|
expect(reloaded.comments).toHaveLength(1);
|
||||||
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
|
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,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['test/**/*.test.ts'],
|
include: ['test/**/*.test.ts'],
|
||||||
|
exclude: ['test/**/*.e2e.test.ts'],
|
||||||
testTimeout: 15_000,
|
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.
|
// On Windows this saturates all threads, blocking the event loop.
|
||||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||||
|
|
||||||
import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||||
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
||||||
|
import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
||||||
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
||||||
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
||||||
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
||||||
import { TeamBackupService } from '@main/services/team/TeamBackupService';
|
import { TeamBackupService } from '@main/services/team/TeamBackupService';
|
||||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
|
||||||
import {
|
import {
|
||||||
CONTEXT_CHANGED,
|
CONTEXT_CHANGED,
|
||||||
SCHEDULE_CHANGE,
|
SCHEDULE_CHANGE,
|
||||||
|
|
@ -51,16 +51,32 @@ import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||||
import { setReviewMainWindow } from './ipc/review';
|
|
||||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
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 { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
|
||||||
import {
|
import {
|
||||||
buildTeamControlApiBaseUrl,
|
buildTeamControlApiBaseUrl,
|
||||||
clearTeamControlApiState,
|
clearTeamControlApiState,
|
||||||
writeTeamControlApiState,
|
writeTeamControlApiState,
|
||||||
} from './services/team/TeamControlApiState';
|
} from './services/team/TeamControlApiState';
|
||||||
|
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
import { getAppIconPath } from './utils/appIcon';
|
import { getAppIconPath } from './utils/appIcon';
|
||||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||||
|
|
@ -80,22 +96,6 @@ import {
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from './services';
|
} 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 { FileChangeEvent } from '@main/types';
|
||||||
import type { TeamChangeEvent } from '@shared/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 summary = msg.summary || extracted.summary;
|
||||||
const msgId = msg.timestamp ?? String(prevCount + i);
|
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
|
void notificationManager
|
||||||
.addTeamNotification({
|
.addTeamNotification({
|
||||||
teamEventType: isLeadInbox ? 'lead_inbox' : 'user_inbox',
|
teamEventType: eventType,
|
||||||
teamName,
|
teamName,
|
||||||
teamDisplayName,
|
teamDisplayName,
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
summary,
|
summary,
|
||||||
body: extracted.body,
|
body: extracted.body,
|
||||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||||
suppressToast,
|
suppressToast: effectiveSuppressToast,
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,9 @@ function validateNotificationsSection(
|
||||||
'snoozeMinutes',
|
'snoozeMinutes',
|
||||||
'notifyOnStatusChange',
|
'notifyOnStatusChange',
|
||||||
'notifyOnTaskComments',
|
'notifyOnTaskComments',
|
||||||
|
'notifyOnTaskCreated',
|
||||||
|
'notifyOnAllTasksCompleted',
|
||||||
|
'notifyOnCrossTeamMessage',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
'statusChangeStatuses',
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
|
|
@ -178,6 +181,24 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnTaskComments = value;
|
result.notifyOnTaskComments = value;
|
||||||
break;
|
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':
|
case 'statusChangeOnlySolo':
|
||||||
if (typeof value !== 'boolean') {
|
if (typeof value !== 'boolean') {
|
||||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ import {
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { isAgentActionMode } from '../services/team/actionModeInstructions';
|
import { isAgentActionMode } from '../services/team/actionModeInstructions';
|
||||||
|
|
||||||
import { validateTaskId, validateTeamName } from './guards';
|
import { validateTaskId, validateTeamName } from './guards';
|
||||||
|
|
||||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
|
||||||
import type { IpcResult, TaskRef } from '@shared/types';
|
import type { IpcResult, TaskRef } from '@shared/types';
|
||||||
|
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
const logger = createLogger('IPC:crossTeam');
|
const logger = createLogger('IPC:crossTeam');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,30 +6,6 @@
|
||||||
* Phase 5: install/uninstall mutations.
|
* 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 {
|
import {
|
||||||
API_KEYS_DELETE,
|
API_KEYS_DELETE,
|
||||||
API_KEYS_LIST,
|
API_KEYS_LIST,
|
||||||
|
|
@ -50,9 +26,32 @@ import {
|
||||||
PLUGIN_INSTALL,
|
PLUGIN_INSTALL,
|
||||||
PLUGIN_UNINSTALL,
|
PLUGIN_UNINSTALL,
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
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');
|
const logger = createLogger('IPC:extensions');
|
||||||
|
|
||||||
/** Allowed scope values */
|
/** Allowed scope values */
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ import {
|
||||||
registerSessionHandlers,
|
registerSessionHandlers,
|
||||||
removeSessionHandlers,
|
removeSessionHandlers,
|
||||||
} from './sessions';
|
} from './sessions';
|
||||||
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
|
|
||||||
import { initializeSkillsHandlers, registerSkillsHandlers, removeSkillsHandlers } from './skills';
|
import { initializeSkillsHandlers, registerSkillsHandlers, removeSkillsHandlers } from './skills';
|
||||||
|
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
|
||||||
import {
|
import {
|
||||||
initializeSubagentHandlers,
|
initializeSubagentHandlers,
|
||||||
registerSubagentHandlers,
|
registerSubagentHandlers,
|
||||||
|
|
@ -103,18 +103,18 @@ import type {
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
|
||||||
import type { TeamBackupService } from '../services/team/TeamBackupService';
|
|
||||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
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 { SkillsCatalogService } from '../services/extensions/skills/SkillsCatalogService';
|
||||||
import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService';
|
import type { SkillsMutationService } from '../services/extensions/skills/SkillsMutationService';
|
||||||
import type { SkillsWatcherService } from '../services/extensions/skills/SkillsWatcherService';
|
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 { 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.
|
* 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 {
|
import {
|
||||||
SKILLS_APPLY_IMPORT,
|
SKILLS_APPLY_IMPORT,
|
||||||
SKILLS_APPLY_UPSERT,
|
SKILLS_APPLY_UPSERT,
|
||||||
|
|
@ -24,6 +9,20 @@ import {
|
||||||
SKILLS_START_WATCHING,
|
SKILLS_START_WATCHING,
|
||||||
SKILLS_STOP_WATCHING,
|
SKILLS_STOP_WATCHING,
|
||||||
} from '@preload/constants/ipcChannels';
|
} 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');
|
const logger = createLogger('IPC:skills');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ import {
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LEAD_CONTEXT,
|
TEAM_LEAD_CONTEXT,
|
||||||
TEAM_MEMBER_SPAWN_STATUSES,
|
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
|
TEAM_MEMBER_SPAWN_STATUSES,
|
||||||
TEAM_PERMANENTLY_DELETE,
|
TEAM_PERMANENTLY_DELETE,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
TEAM_PROCESS_ALIVE,
|
TEAM_PROCESS_ALIVE,
|
||||||
|
|
@ -70,17 +70,18 @@ import {
|
||||||
} from '@shared/utils/cliArgsParser';
|
} from '@shared/utils/cliArgsParser';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||||
|
import crypto from 'crypto';
|
||||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||||
|
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||||
import {
|
import {
|
||||||
buildActionModeAgentBlock,
|
buildActionModeAgentBlock,
|
||||||
isAgentActionMode,
|
isAgentActionMode,
|
||||||
} from '../services/team/actionModeInstructions';
|
} from '../services/team/actionModeInstructions';
|
||||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
|
||||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
|
|
@ -111,13 +112,13 @@ import type {
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadContextUsage,
|
|
||||||
LeadActivitySnapshot,
|
LeadActivitySnapshot,
|
||||||
|
LeadContextUsage,
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberSpawnStatusesSnapshot,
|
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
|
|
@ -546,7 +547,10 @@ async function handlePermanentlyDeleteTeam(
|
||||||
.rm(path.join(appData, 'attachments', validated.value!), { recursive: true, force: true })
|
.rm(path.join(appData, 'attachments', validated.value!), { recursive: true, force: true })
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await fs.promises
|
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);
|
.catch(() => undefined);
|
||||||
// Mark in backup registry AFTER successful deletion
|
// Mark in backup registry AFTER successful deletion
|
||||||
if (teamBackupService) {
|
if (teamBackupService) {
|
||||||
|
|
@ -1204,12 +1208,19 @@ async function handleSendMessage(
|
||||||
// Smart routing: lead + alive → stdin direct, else → inbox
|
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||||
if (isLeadRecipient && isAlive) {
|
if (isLeadRecipient && isAlive) {
|
||||||
const resolvedLeadName = leadName ?? memberName;
|
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
|
// Separate try blocks: stdin delivery vs persistence
|
||||||
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
|
// 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)
|
// Wrap with instructions so lead responds with visible text (not just agent-only blocks)
|
||||||
const wrappedText = [
|
const wrappedText = [
|
||||||
`You received a direct message from the user.`,
|
`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.`,
|
`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:`,
|
`Message from user:`,
|
||||||
buildMessageDeliveryText(payload.text!, {
|
buildMessageDeliveryText(payload.text!, {
|
||||||
|
|
@ -1252,11 +1263,12 @@ async function handleSendMessage(
|
||||||
payload.text!,
|
payload.text!,
|
||||||
payload.summary,
|
payload.summary,
|
||||||
attachmentMeta,
|
attachmentMeta,
|
||||||
validatedTaskRefs.value
|
validatedTaskRefs.value,
|
||||||
|
preGeneratedMessageId
|
||||||
);
|
);
|
||||||
} catch (persistError) {
|
} catch (persistError) {
|
||||||
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(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)
|
// Save attachment binary data to disk (best-effort)
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ export interface DetectedError {
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
|
| 'task_created'
|
||||||
|
| 'all_tasks_completed'
|
||||||
|
| 'cross_team_message'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
/** 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 { 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 {
|
import type {
|
||||||
EnrichedPlugin,
|
EnrichedPlugin,
|
||||||
InstalledMcpEntry,
|
InstalledMcpEntry,
|
||||||
|
|
@ -15,11 +21,6 @@ import type {
|
||||||
PluginCatalogItem,
|
PluginCatalogItem,
|
||||||
} from '@shared/types/extensions';
|
} 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');
|
const logger = createLogger('Extensions:Facade');
|
||||||
|
|
||||||
export class ExtensionFacadeService {
|
export class ExtensionFacadeService {
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@
|
||||||
* Storage file: ~/.claude/api-keys.json
|
* 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 crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { safeStorage } from 'electron';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiKeyEntry,
|
ApiKeyEntry,
|
||||||
ApiKeyLookupResult,
|
ApiKeyLookupResult,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@
|
||||||
|
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
|
import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:GitHubStars');
|
const logger = createLogger('Extensions:GitHubStars');
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ export class GitHubStarsService {
|
||||||
*/
|
*/
|
||||||
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
|
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
|
||||||
const result: 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) {
|
for (const url of repositoryUrls) {
|
||||||
const parsed = parseGitHubOwnerRepo(url);
|
const parsed = parseGitHubOwnerRepo(url);
|
||||||
|
|
@ -107,10 +107,10 @@ export class GitHubStarsService {
|
||||||
* Run async tasks with a concurrency limit.
|
* Run async tasks with a concurrency limit.
|
||||||
*/
|
*/
|
||||||
private async withConcurrencyLimit(
|
private async withConcurrencyLimit(
|
||||||
tasks: Array<() => Promise<void>>,
|
tasks: (() => Promise<void>)[],
|
||||||
limit: number
|
limit: number
|
||||||
): Promise<Array<'ok' | 'error'>> {
|
): Promise<('ok' | 'error')[]> {
|
||||||
const results: Array<'ok' | 'error'> = [];
|
const results: ('ok' | 'error')[] = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
const run = async (): Promise<void> => {
|
const run = async (): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@
|
||||||
* Cursor-based pagination (after), no auth required.
|
* Cursor-based pagination (after), no auth required.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import https from 'node:https';
|
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
|
import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:GlamaMcp');
|
const logger = createLogger('Extensions:GlamaMcp');
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,13 @@
|
||||||
* - Provides getById() for secure install flow
|
* - 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 { normalizeRepoUrl } from '@shared/utils/extensionNormalizers';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { OfficialMcpRegistryService } from './OfficialMcpRegistryService';
|
import { type GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService';
|
||||||
import { GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService';
|
import { type OfficialMcpRegistryService } from './OfficialMcpRegistryService';
|
||||||
|
|
||||||
|
import type { McpCatalogItem, McpSearchResult } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:McpAggregator');
|
const logger = createLogger('Extensions:McpAggregator');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@
|
||||||
* Filters for _meta.isLatest to pick only latest versions.
|
* Filters for _meta.isLatest to pick only latest versions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import https from 'node:https';
|
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
McpAuthHeaderDef,
|
McpAuthHeaderDef,
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@
|
||||||
* - Deduplicates concurrent requests
|
* - Deduplicates concurrent requests
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import https from 'node:https';
|
|
||||||
import http from 'node:http';
|
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 { buildPluginId } from '@shared/utils/extensionNormalizers';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { PluginCatalogItem } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:PluginCatalog');
|
const logger = createLogger('Extensions:PluginCatalog');
|
||||||
|
|
||||||
|
|
@ -260,7 +261,7 @@ export class PluginCatalogService {
|
||||||
|
|
||||||
const json = JSON.parse(response.body) as MarketplaceJson;
|
const json = JSON.parse(response.body) as MarketplaceJson;
|
||||||
const items = this.parseMarketplace(json);
|
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() };
|
this.cache = { items, etag, fetchedAt: Date.now() };
|
||||||
logger.info(`Fetched ${items.length} plugins from marketplace "${json.name}"`);
|
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
|
* e.g. https://github.com/org/repo → https://raw.githubusercontent.com/org/repo/main/README.md
|
||||||
*/
|
*/
|
||||||
private buildReadmeUrl(repoUrl: string): string | null {
|
private buildReadmeUrl(repoUrl: string): string | null {
|
||||||
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
const match = /github\.com\/([^/]+)\/([^/]+)/.exec(repoUrl);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const [, owner, repo] = match;
|
const [, owner, repo] = match;
|
||||||
return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
|
return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,26 @@
|
||||||
* Extension services barrel export.
|
* 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 { ApiKeyService } from './apikeys/ApiKeyService';
|
||||||
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
||||||
export { SkillRootsResolver } from './skills/SkillRootsResolver';
|
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||||
export { SkillScanner } from './skills/SkillScanner';
|
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||||
export { SkillMetadataParser } from './skills/SkillMetadataParser';
|
export { OfficialMcpRegistryService } from './catalog/OfficialMcpRegistryService';
|
||||||
export { SkillValidator } from './skills/SkillValidator';
|
export { PluginCatalogService } from './catalog/PluginCatalogService';
|
||||||
export { SkillsCatalogService } from './skills/SkillsCatalogService';
|
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
||||||
export { SkillScaffoldService } from './skills/SkillScaffoldService';
|
export { McpInstallService } from './install/McpInstallService';
|
||||||
|
export { PluginInstallService } from './install/PluginInstallService';
|
||||||
export { SkillImportService } from './skills/SkillImportService';
|
export { SkillImportService } from './skills/SkillImportService';
|
||||||
|
export { SkillMetadataParser } from './skills/SkillMetadataParser';
|
||||||
export { SkillPlanService } from './skills/SkillPlanService';
|
export { SkillPlanService } from './skills/SkillPlanService';
|
||||||
export { SkillReviewService } from './skills/SkillReviewService';
|
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 { SkillsMutationService } from './skills/SkillsMutationService';
|
||||||
export { SkillsWatcherService } from './skills/SkillsWatcherService';
|
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 { execCli } from '@main/utils/childProcess';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||||
import type {
|
import type {
|
||||||
McpCustomInstallRequest,
|
McpCustomInstallRequest,
|
||||||
McpInstallRequest,
|
McpInstallRequest,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
|
||||||
|
|
||||||
const logger = createLogger('Extensions:McpInstall');
|
const logger = createLogger('Extensions:McpInstall');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
|
||||||
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
||||||
|
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:PluginInstall');
|
const logger = createLogger('Extensions:PluginInstall');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,13 +101,11 @@ export class SkillImportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async walkDirectory(
|
private async walkDirectory(rootDir: string): Promise<{
|
||||||
rootDir: string
|
files: { absolutePath: string; relativePath: string }[];
|
||||||
): Promise<{
|
|
||||||
files: Array<{ absolutePath: string; relativePath: string }>;
|
|
||||||
hiddenEntriesSkipped: number;
|
hiddenEntriesSkipped: number;
|
||||||
}> {
|
}> {
|
||||||
const allFiles: Array<{ absolutePath: string; relativePath: string }> = [];
|
const allFiles: { absolutePath: string; relativePath: string }[] = [];
|
||||||
let hiddenEntriesSkipped = 0;
|
let hiddenEntriesSkipped = 0;
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import YAML from 'yaml';
|
||||||
|
|
||||||
|
import type { ResolvedSkillRoot } from './SkillRootsResolver';
|
||||||
import type {
|
import type {
|
||||||
SkillCatalogItem,
|
SkillCatalogItem,
|
||||||
SkillDetail,
|
SkillDetail,
|
||||||
|
|
@ -8,9 +11,6 @@ import type {
|
||||||
SkillInvocationMode,
|
SkillInvocationMode,
|
||||||
SkillValidationIssue,
|
SkillValidationIssue,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
import YAML from 'yaml';
|
|
||||||
|
|
||||||
import type { ResolvedSkillRoot } from './SkillRootsResolver';
|
|
||||||
|
|
||||||
const logger = createLogger('Extensions:SkillParser');
|
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) {
|
if (!match) {
|
||||||
return {
|
return {
|
||||||
rawFrontmatter: null,
|
rawFrontmatter: null,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
|
import { SkillScanner } from './SkillScanner';
|
||||||
|
|
||||||
|
import type { ImportedSkillSourceFile } from './SkillImportService';
|
||||||
import type {
|
import type {
|
||||||
SkillDraftFile,
|
SkillDraftFile,
|
||||||
SkillReviewFileChange,
|
SkillReviewFileChange,
|
||||||
|
|
@ -10,10 +13,6 @@ import type {
|
||||||
SkillReviewSummary,
|
SkillReviewSummary,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
|
|
||||||
import type { ImportedSkillSourceFile } from './SkillImportService';
|
|
||||||
|
|
||||||
import { SkillScanner } from './SkillScanner';
|
|
||||||
|
|
||||||
type SkillPlanInputFile =
|
type SkillPlanInputFile =
|
||||||
| { relativePath: string; isBinary: false; content: string }
|
| { relativePath: string; isBinary: false; content: string }
|
||||||
| { relativePath: string; isBinary: true; sourceAbsolutePath: string };
|
| { relativePath: string; isBinary: true; sourceAbsolutePath: string };
|
||||||
|
|
@ -73,7 +72,7 @@ export class SkillPlanService {
|
||||||
async applyPlan(plan: SkillExecutionPlan): Promise<void> {
|
async applyPlan(plan: SkillExecutionPlan): Promise<void> {
|
||||||
const backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-backup-'));
|
const backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-plan-backup-'));
|
||||||
const createdPaths: string[] = [];
|
const createdPaths: string[] = [];
|
||||||
const backups: Array<{ absolutePath: string; backupPath: string }> = [];
|
const backups: { absolutePath: string; backupPath: string }[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const [index, change] of plan.changes.entries()) {
|
for (const [index, change] of plan.changes.entries()) {
|
||||||
|
|
@ -200,7 +199,7 @@ export class SkillPlanService {
|
||||||
|
|
||||||
const summary = changes.reduce<SkillReviewSummary>(
|
const summary = changes.reduce<SkillReviewSummary>(
|
||||||
(acc, change) => {
|
(acc, change) => {
|
||||||
acc[`${change.action}d` as 'created' | 'updated' | 'deleted'] += 1;
|
acc[`${change.action}d`] += 1;
|
||||||
if (change.isBinary) {
|
if (change.isBinary) {
|
||||||
acc.binary += 1;
|
acc.binary += 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import type { SkillDraftFile, SkillReviewFileChange } from '@shared/types/extensions';
|
|
||||||
|
|
||||||
import type { ImportedSkillSourceFile } from './SkillImportService';
|
import type { ImportedSkillSourceFile } from './SkillImportService';
|
||||||
|
import type { SkillDraftFile, SkillReviewFileChange } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:SkillReview');
|
const logger = createLogger('Extensions:SkillReview');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
|
|
||||||
import type { SkillRootKind, SkillScope } from '@shared/types/extensions';
|
import type { SkillRootKind, SkillScope } from '@shared/types/extensions';
|
||||||
|
|
||||||
export interface ResolvedSkillRoot {
|
export interface ResolvedSkillRoot {
|
||||||
|
|
@ -10,7 +11,7 @@ export interface ResolvedSkillRoot {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_ROOTS: Array<{ rootKind: SkillRootKind; segments: string[] }> = [
|
const USER_ROOTS: { rootKind: SkillRootKind; segments: string[] }[] = [
|
||||||
{ rootKind: 'claude', segments: ['.claude', 'skills'] },
|
{ rootKind: 'claude', segments: ['.claude', 'skills'] },
|
||||||
{ rootKind: 'cursor', segments: ['.cursor', 'skills'] },
|
{ rootKind: 'cursor', segments: ['.cursor', 'skills'] },
|
||||||
{ rootKind: 'agents', segments: ['.agents', 'skills'] },
|
{ rootKind: 'agents', segments: ['.agents', 'skills'] },
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
|
import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation';
|
||||||
import type { SkillDraftFile, SkillRootKind, SkillScope } from '@shared/types/extensions';
|
|
||||||
|
|
||||||
import { SkillRootsResolver } from './SkillRootsResolver';
|
import { SkillRootsResolver } from './SkillRootsResolver';
|
||||||
|
|
||||||
|
import type { SkillDraftFile, SkillRootKind, SkillScope } from '@shared/types/extensions';
|
||||||
|
|
||||||
export class SkillScaffoldService {
|
export class SkillScaffoldService {
|
||||||
constructor(private readonly rootsResolver = new SkillRootsResolver()) {}
|
constructor(private readonly rootsResolver = new SkillRootsResolver()) {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import type { SkillCatalogItem, SkillDirectoryFlags } from '@shared/types/extensions';
|
|
||||||
|
|
||||||
import { SkillMetadataParser, type SkillRelatedFiles } from './SkillMetadataParser';
|
import { SkillMetadataParser, type SkillRelatedFiles } from './SkillMetadataParser';
|
||||||
|
|
||||||
import type { ResolvedSkillRoot } from './SkillRootsResolver';
|
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;
|
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 * as path from 'node:path';
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
|
||||||
|
|
||||||
import { SkillMetadataParser } from './SkillMetadataParser';
|
import { SkillMetadataParser } from './SkillMetadataParser';
|
||||||
import { SkillRootsResolver, type ResolvedSkillRoot } from './SkillRootsResolver';
|
import { type ResolvedSkillRoot, SkillRootsResolver } from './SkillRootsResolver';
|
||||||
import { SkillScanner } from './SkillScanner';
|
import { SkillScanner } from './SkillScanner';
|
||||||
import { SkillValidator } from './SkillValidator';
|
import { SkillValidator } from './SkillValidator';
|
||||||
|
|
||||||
|
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:SkillsCatalog');
|
const logger = createLogger('Extensions:SkillsCatalog');
|
||||||
|
|
||||||
export class SkillsCatalogService {
|
export class SkillsCatalogService {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
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 {
|
import type {
|
||||||
SkillDeleteRequest,
|
SkillDeleteRequest,
|
||||||
SkillDetail,
|
SkillDetail,
|
||||||
|
|
@ -8,15 +17,6 @@ import type {
|
||||||
SkillReviewPreview,
|
SkillReviewPreview,
|
||||||
SkillUpsertRequest,
|
SkillUpsertRequest,
|
||||||
} from '@shared/types/extensions';
|
} 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 {
|
export class SkillsMutationService {
|
||||||
constructor(
|
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 { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { watch } from 'chokidar';
|
import { watch } from 'chokidar';
|
||||||
|
|
||||||
import { SkillRootsResolver } from './SkillRootsResolver';
|
import { SkillRootsResolver } from './SkillRootsResolver';
|
||||||
|
|
||||||
|
import type { SkillWatcherEvent } from '@shared/types/extensions';
|
||||||
import type { FSWatcher } from 'chokidar';
|
import type { FSWatcher } from 'chokidar';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:SkillsWatcher');
|
const logger = createLogger('Extensions:SkillsWatcher');
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
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 { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
const logger = createLogger('Extensions:McpState');
|
const logger = createLogger('Extensions:McpState');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { InstalledPluginEntry } from '@shared/types/extensions';
|
import type { InstalledPluginEntry } from '@shared/types/extensions';
|
||||||
import type { InstallScope } from '@shared/types/extensions';
|
import type { InstallScope } from '@shared/types/extensions';
|
||||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
|
||||||
|
|
||||||
const logger = createLogger('Extensions:PluginState');
|
const logger = createLogger('Extensions:PluginState');
|
||||||
|
|
||||||
|
|
@ -29,24 +30,24 @@ interface InstalledPluginsJson {
|
||||||
version: number;
|
version: number;
|
||||||
plugins: Record<
|
plugins: Record<
|
||||||
string, // qualifiedName
|
string, // qualifiedName
|
||||||
Array<{
|
{
|
||||||
scope: string;
|
scope: string;
|
||||||
installPath?: string;
|
installPath?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
installedAt?: string;
|
installedAt?: string;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
gitCommitSha?: string;
|
gitCommitSha?: string;
|
||||||
}>
|
}[]
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstallCountsJson {
|
interface InstallCountsJson {
|
||||||
version: number;
|
version: number;
|
||||||
fetchedAt: string;
|
fetchedAt: string;
|
||||||
counts: Array<{
|
counts: {
|
||||||
plugin: string; // qualifiedName format
|
plugin: string; // qualifiedName format
|
||||||
unique_installs: number;
|
unique_installs: number;
|
||||||
}>;
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@
|
||||||
export * from './analysis';
|
export * from './analysis';
|
||||||
export * from './discovery';
|
export * from './discovery';
|
||||||
export * from './error';
|
export * from './error';
|
||||||
|
export * from './extensions';
|
||||||
export * from './infrastructure';
|
export * from './infrastructure';
|
||||||
export * from './parsing';
|
export * from './parsing';
|
||||||
export * from './team';
|
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
export * from './extensions';
|
export * from './team';
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ export interface NotificationConfig {
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
/** Whether to show native OS notifications when a new comment is added to a task */
|
/** Whether to show native OS notifications when a new comment is added to a task */
|
||||||
notifyOnTaskComments: boolean;
|
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) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
@ -264,6 +270,9 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
notifyOnTaskComments: true,
|
notifyOnTaskComments: true,
|
||||||
|
notifyOnTaskCreated: true,
|
||||||
|
notifyOnAllTasksCompleted: true,
|
||||||
|
notifyOnCrossTeamMessage: true,
|
||||||
statusChangeOnlySolo: false,
|
statusChangeOnlySolo: false,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import type { Schedule, ScheduleRun } from '@shared/types';
|
|
||||||
import type { ScheduleRepository } from './ScheduleRepository';
|
import type { ScheduleRepository } from './ScheduleRepository';
|
||||||
|
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:JsonScheduleRepo');
|
const logger = createLogger('Service:JsonScheduleRepo');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ function extractSummaryFromStreamJson(stdout: string): string {
|
||||||
|
|
||||||
const content = (parsed.content ??
|
const content = (parsed.content ??
|
||||||
(parsed.message as Record<string, unknown> | undefined)?.content) as
|
(parsed.message as Record<string, unknown> | undefined)?.content) as
|
||||||
| Array<{ type?: string; text?: string }>
|
| { type?: string; text?: string }[]
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!Array.isArray(content)) continue;
|
if (!Array.isArray(content)) continue;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import { Cron } from 'croner';
|
import { Cron } from 'croner';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
import type { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||||
|
import type { ScheduleRepository } from './ScheduleRepository';
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
Schedule,
|
Schedule,
|
||||||
|
|
@ -18,8 +20,6 @@ import type {
|
||||||
ScheduleRunStatus,
|
ScheduleRunStatus,
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { ScheduleRepository } from './ScheduleRepository';
|
|
||||||
import type { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
|
||||||
|
|
||||||
const logger = createLogger('Service:Scheduler');
|
const logger = createLogger('Service:Scheduler');
|
||||||
|
|
||||||
|
|
@ -511,7 +511,7 @@ export class SchedulerService {
|
||||||
|
|
||||||
private async onCronTick(scheduleId: string): Promise<void> {
|
private async onCronTick(scheduleId: string): Promise<void> {
|
||||||
const schedule = await this.repository.getSchedule(scheduleId);
|
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)`);
|
logger.debug(`Cron tick for ${scheduleId} skipped (not active)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -659,7 +659,7 @@ export class SchedulerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const freshSchedule = await this.repository.getSchedule(schedule.id);
|
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);
|
await this.completeRun(retryRun, 'failed', exitCode, undefined, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { JsonScheduleRepository } from './JsonScheduleRepository';
|
export { JsonScheduleRepository } from './JsonScheduleRepository';
|
||||||
export type { ScheduleRepository } from './ScheduleRepository';
|
|
||||||
export { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
|
||||||
export type {
|
export type {
|
||||||
ExecutionRequest,
|
ExecutionRequest,
|
||||||
InternalScheduleRun,
|
InternalScheduleRun,
|
||||||
ScheduledTaskResult,
|
ScheduledTaskResult,
|
||||||
} from './ScheduledTaskExecutor';
|
} from './ScheduledTaskExecutor';
|
||||||
export { SchedulerService } from './SchedulerService';
|
export { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||||
|
export type { ScheduleRepository } from './ScheduleRepository';
|
||||||
export type { WarmUpFn } from './SchedulerService';
|
export type { WarmUpFn } from './SchedulerService';
|
||||||
|
export { SchedulerService } from './SchedulerService';
|
||||||
|
|
|
||||||
|
|
@ -577,7 +577,7 @@ export class ChangeExtractorService {
|
||||||
|
|
||||||
private async parseJSONLFilesWithConcurrency(
|
private async parseJSONLFilesWithConcurrency(
|
||||||
paths: string[]
|
paths: string[]
|
||||||
): Promise<Array<{ snippets: SnippetDiff[]; mtime: number }>> {
|
): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> {
|
||||||
if (paths.length === 0) return [];
|
if (paths.length === 0) return [];
|
||||||
|
|
||||||
const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length);
|
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 { 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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
|
|
|
||||||
|
|
@ -101,4 +101,7 @@ export class TeamAttachmentStore {
|
||||||
|
|
||||||
return result;
|
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)) {
|
if (raw && isValidConfig(raw)) {
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
manifest.displayName = typeof parsed.name === 'string' ? parsed.name : undefined;
|
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 {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
|
|
@ -392,7 +393,7 @@ export class TeamBackupService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = manifest.fileStats[descriptor.relPath];
|
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
|
return false; // not dirty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,7 +432,7 @@ export class TeamBackupService {
|
||||||
if (stat.size > MAX_FILE_SIZE_BYTES) return; // skip oversized silently during shutdown
|
if (stat.size > MAX_FILE_SIZE_BYTES) return; // skip oversized silently during shutdown
|
||||||
|
|
||||||
const cached = manifest.fileStats[descriptor.relPath];
|
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);
|
const destPath = path.join(backupDir, descriptor.relPath);
|
||||||
|
|
||||||
|
|
@ -572,10 +573,7 @@ export class TeamBackupService {
|
||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async restoreGenericPartial(
|
private async restoreGenericPartial(teamName: string, manifest: BackupManifest): Promise<number> {
|
||||||
teamName: string,
|
|
||||||
manifest: BackupManifest
|
|
||||||
): Promise<number> {
|
|
||||||
const backupDir = this.getBackupDir(teamName);
|
const backupDir = this.getBackupDir(teamName);
|
||||||
const backupFiles = await this.enumerateBackupFiles(teamName);
|
const backupFiles = await this.enumerateBackupFiles(teamName);
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
@ -892,10 +890,20 @@ export class TeamBackupService {
|
||||||
return path.join(getTasksBasePath(), teamName, relPath.slice('tasks/'.length));
|
return path.join(getTasksBasePath(), teamName, relPath.slice('tasks/'.length));
|
||||||
}
|
}
|
||||||
if (relPath.startsWith('attachments/')) {
|
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/')) {
|
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);
|
return path.join(getTeamsBasePath(), teamName, relPath);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,9 @@ export class TeamDataService {
|
||||||
if (event.type === 'review_approved' && event.actor) {
|
if (event.type === 'review_approved' && event.actor) {
|
||||||
return event.actor;
|
return event.actor;
|
||||||
}
|
}
|
||||||
|
if (event.type === 'review_started' && event.actor) {
|
||||||
|
return event.actor;
|
||||||
|
}
|
||||||
if (event.type === 'review_requested' && event.reviewer) {
|
if (event.type === 'review_requested' && event.reviewer) {
|
||||||
return event.reviewer;
|
return event.reviewer;
|
||||||
}
|
}
|
||||||
|
|
@ -873,12 +876,15 @@ export class TeamDataService {
|
||||||
|
|
||||||
// Skip inbox notification when lead starts their own task (solo teams)
|
// Skip inbox notification when lead starts their own task (solo teams)
|
||||||
if (!this.isLeadOwner(task.owner, leadName)) {
|
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()) {
|
if (task.description?.trim()) {
|
||||||
parts.push(`\nDetails:\n${task.description.trim()}`);
|
parts.push(`\nDetails:\n${task.description.trim()}`);
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
`\n${AGENT_BLOCK_OPEN}`,
|
`\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:`,
|
`Update task status using the board MCP tools:`,
|
||||||
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
|
`task_complete { teamName: "${teamName}", taskId: "${task.id}" }`,
|
||||||
AGENT_BLOCK_CLOSE
|
AGENT_BLOCK_CLOSE
|
||||||
|
|
@ -888,7 +894,7 @@ export class TeamDataService {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
text: parts.join('\n'),
|
text: parts.join('\n'),
|
||||||
taskRefs: task.descriptionTaskRefs,
|
taskRefs: task.descriptionTaskRefs,
|
||||||
summary: `Task ${this.getTaskLabel(task)} started`,
|
summary: `Start working on ${this.getTaskLabel(task)}`,
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1510,7 +1516,8 @@ export class TeamDataService {
|
||||||
text: string,
|
text: string,
|
||||||
summary?: string,
|
summary?: string,
|
||||||
attachments?: AttachmentMeta[],
|
attachments?: AttachmentMeta[],
|
||||||
taskRefs?: TaskRef[]
|
taskRefs?: TaskRef[],
|
||||||
|
messageId?: string
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
let leadSessionId: string | undefined;
|
let leadSessionId: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
|
@ -1529,6 +1536,7 @@ export class TeamDataService {
|
||||||
source: 'user_sent',
|
source: 'user_sent',
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
}) as InboxMessage;
|
}) as InboxMessage;
|
||||||
return {
|
return {
|
||||||
deliveredToInbox: false,
|
deliveredToInbox: false,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
interface McpLaunchSpec {
|
interface McpLaunchSpec {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ interface StreamedMetadata {
|
||||||
lastTimestamp: string | null;
|
lastTimestamp: string | null;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
lastOutputPreview: string | null;
|
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. */
|
/** Result of attributing a subagent file to a team member. */
|
||||||
|
|
@ -1178,6 +1181,8 @@ export class TeamMemberLogsFinder {
|
||||||
isOngoing,
|
isOngoing,
|
||||||
filePath,
|
filePath,
|
||||||
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
|
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
|
||||||
|
lastThinkingPreview: metadata.lastThinkingPreview ?? undefined,
|
||||||
|
recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1425,6 +1430,8 @@ export class TeamMemberLogsFinder {
|
||||||
isOngoing,
|
isOngoing,
|
||||||
filePath: jsonlPath,
|
filePath: jsonlPath,
|
||||||
lastOutputPreview: metadata.lastOutputPreview ?? undefined,
|
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 lastTimestamp: string | null = null;
|
||||||
let messageCount = 0;
|
let messageCount = 0;
|
||||||
let lastOutputPreview: string | null = null;
|
let lastOutputPreview: string | null = null;
|
||||||
|
let lastThinkingPreview: string | null = null;
|
||||||
|
const MAX_RECENT_PREVIEWS = 20;
|
||||||
|
const recentPreviews: StreamedMetadata['recentPreviews'] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
|
@ -1458,7 +1468,25 @@ export class TeamMemberLogsFinder {
|
||||||
// Track last assistant text output (cheap regex, overwrites on each match).
|
// Track last assistant text output (cheap regex, overwrites on each match).
|
||||||
if (trimmed.includes('"role":"assistant"') || trimmed.includes('"role": "assistant"')) {
|
if (trimmed.includes('"role":"assistant"') || trimmed.includes('"role": "assistant"')) {
|
||||||
const preview = TeamMemberLogsFinder.extractAssistantPreview(trimmed);
|
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();
|
rl.close();
|
||||||
|
|
@ -1467,7 +1495,7 @@ export class TeamMemberLogsFinder {
|
||||||
// ignore — return whatever we collected so far
|
// 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 {
|
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).
|
* Looks for the first text block content via regex (avoids full JSON parse).
|
||||||
*/
|
*/
|
||||||
private static extractAssistantPreview(line: string): string | null {
|
private static extractAssistantPreview(line: string): string | null {
|
||||||
// Match {"type":"text","text":"..."} blocks
|
// Match {"type":"text","text":"..."} blocks — allow escaped sequences
|
||||||
const textMatch = /"type"\s*:\s*"text"[^}]*"text"\s*:\s*"([^"]{1,200})/.exec(line);
|
const textMatch = /"type"\s*:\s*"text"[^}]*"text"\s*:\s*"((?:[^"\\]|\\.){1,400})/.exec(line);
|
||||||
if (textMatch?.[1]) {
|
if (textMatch?.[1]) {
|
||||||
const raw = textMatch[1]
|
const raw = textMatch[1]
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
.replace(/\\n/g, ' ')
|
.replace(/\\n/g, ' ')
|
||||||
.replace(/\\t/g, ' ')
|
.replace(/\\t/g, ' ')
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.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
|
// Fallback: top-level string content — skip lines with tool_use to avoid
|
||||||
const contentMatch = /"content"\s*:\s*"([^"]{1,200})/.exec(line);
|
// 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]) {
|
if (contentMatch?.[1]) {
|
||||||
const raw = contentMatch[1]
|
const raw = contentMatch[1]
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
.replace(/\\n/g, ' ')
|
.replace(/\\n/g, ' ')
|
||||||
.replace(/\\t/g, ' ')
|
.replace(/\\t/g, ' ')
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.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;
|
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 { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
SourceMessageSnapshot,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskHistoryEvent,
|
TaskHistoryEvent,
|
||||||
|
|
@ -293,6 +294,18 @@ export class TeamTaskReader {
|
||||||
historyEvents,
|
historyEvents,
|
||||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
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>;
|
} satisfies Record<keyof TeamTask, unknown>;
|
||||||
if (task.status === 'deleted') {
|
if (task.status === 'deleted') {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { getTaskChangeSummariesBasePath } from '@main/utils/pathDecoder';
|
|
||||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||||
|
import { getTaskChangeSummariesBasePath } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
|
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
|
||||||
|
|
||||||
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types';
|
|
||||||
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
|
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
|
||||||
|
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types';
|
||||||
|
|
||||||
function normalizeIsoString(value: unknown): string | null {
|
function normalizeIsoString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,37 @@ import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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.
|
* 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> {
|
export async function atomicWriteAsync(targetPath: string, data: string): Promise<void> {
|
||||||
const dir = path.dirname(targetPath);
|
const dir = path.dirname(targetPath);
|
||||||
|
|
@ -24,16 +52,7 @@ export async function atomicWriteAsync(targetPath: string, data: string): Promis
|
||||||
await fd?.close();
|
await fd?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await renameWithRetry(tmpPath, targetPath);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
await fs.promises.unlink(tmpPath).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ export type TeamEventType =
|
||||||
| 'task_clarification'
|
| 'task_clarification'
|
||||||
| 'task_status_change'
|
| 'task_status_change'
|
||||||
| 'task_comment'
|
| 'task_comment'
|
||||||
|
| 'task_created'
|
||||||
|
| 'all_tasks_completed'
|
||||||
|
| 'cross_team_message'
|
||||||
| 'schedule_completed'
|
| 'schedule_completed'
|
||||||
| 'schedule_failed';
|
| 'schedule_failed';
|
||||||
|
|
||||||
|
|
@ -63,6 +66,9 @@ const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> =
|
||||||
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
||||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||||
task_comment: { triggerName: 'Task Comment', triggerColor: 'cyan' },
|
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_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
|
||||||
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,8 @@ interface ParsedTask {
|
||||||
workIntervals?: unknown;
|
workIntervals?: unknown;
|
||||||
historyEvents?: unknown;
|
historyEvents?: unknown;
|
||||||
attachments?: unknown;
|
attachments?: unknown;
|
||||||
|
sourceMessageId?: unknown;
|
||||||
|
sourceMessage?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawWorkInterval {
|
interface RawWorkInterval {
|
||||||
|
|
@ -533,7 +535,12 @@ function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): str
|
||||||
for (let i = events.length - 1; i >= 0; i--) {
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
const e = events[i];
|
const e = events[i];
|
||||||
const t = e.type;
|
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';
|
const to = typeof e.to === 'string' ? e.to : 'none';
|
||||||
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none';
|
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none';
|
||||||
}
|
}
|
||||||
|
|
@ -699,6 +706,18 @@ async function readTasksDirForTeam(
|
||||||
attachments: Array.isArray(parsed.attachments)
|
attachments: Array.isArray(parsed.attachments)
|
||||||
? (parsed.attachments as unknown[])
|
? (parsed.attachments as unknown[])
|
||||||
: undefined,
|
: 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,
|
teamName,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
API_KEYS_DELETE,
|
||||||
|
API_KEYS_LIST,
|
||||||
|
API_KEYS_LOOKUP,
|
||||||
|
API_KEYS_SAVE,
|
||||||
|
API_KEYS_STORAGE_STATUS,
|
||||||
APP_RELAUNCH,
|
APP_RELAUNCH,
|
||||||
CLI_INSTALLER_GET_STATUS,
|
CLI_INSTALLER_GET_STATUS,
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
|
|
@ -34,28 +39,30 @@ import {
|
||||||
HTTP_SERVER_GET_STATUS,
|
HTTP_SERVER_GET_STATUS,
|
||||||
HTTP_SERVER_START,
|
HTTP_SERVER_START,
|
||||||
HTTP_SERVER_STOP,
|
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,
|
PROJECT_LIST_FILES,
|
||||||
RENDERER_BOOT,
|
RENDERER_BOOT,
|
||||||
RENDERER_HEARTBEAT,
|
RENDERER_HEARTBEAT,
|
||||||
RENDERER_LOG,
|
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_APPLY_DECISIONS,
|
||||||
REVIEW_CHECK_CONFLICT,
|
REVIEW_CHECK_CONFLICT,
|
||||||
REVIEW_CLEAR_DECISIONS,
|
REVIEW_CLEAR_DECISIONS,
|
||||||
|
REVIEW_FILE_CHANGE,
|
||||||
REVIEW_GET_AGENT_CHANGES,
|
REVIEW_GET_AGENT_CHANGES,
|
||||||
REVIEW_GET_CHANGE_STATS,
|
REVIEW_GET_CHANGE_STATS,
|
||||||
REVIEW_GET_FILE_CONTENT,
|
REVIEW_GET_FILE_CONTENT,
|
||||||
REVIEW_FILE_CHANGE,
|
|
||||||
REVIEW_GET_GIT_FILE_LOG,
|
REVIEW_GET_GIT_FILE_LOG,
|
||||||
REVIEW_GET_TASK_CHANGES,
|
REVIEW_GET_TASK_CHANGES,
|
||||||
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
|
REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES,
|
||||||
|
|
@ -67,6 +74,27 @@ import {
|
||||||
REVIEW_SAVE_EDITED_FILE,
|
REVIEW_SAVE_EDITED_FILE,
|
||||||
REVIEW_UNWATCH_FILES,
|
REVIEW_UNWATCH_FILES,
|
||||||
REVIEW_WATCH_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_CONNECT,
|
||||||
SSH_DISCONNECT,
|
SSH_DISCONNECT,
|
||||||
SSH_GET_CONFIG_HOSTS,
|
SSH_GET_CONFIG_HOSTS,
|
||||||
|
|
@ -101,8 +129,8 @@ import {
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LEAD_CONTEXT,
|
TEAM_LEAD_CONTEXT,
|
||||||
TEAM_MEMBER_SPAWN_STATUSES,
|
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
|
TEAM_MEMBER_SPAWN_STATUSES,
|
||||||
TEAM_PERMANENTLY_DELETE,
|
TEAM_PERMANENTLY_DELETE,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
TEAM_PROCESS_ALIVE,
|
TEAM_PROCESS_ALIVE,
|
||||||
|
|
@ -149,34 +177,6 @@ import {
|
||||||
WINDOW_IS_MAXIMIZED,
|
WINDOW_IS_MAXIMIZED,
|
||||||
WINDOW_MAXIMIZE,
|
WINDOW_MAXIMIZE,
|
||||||
WINDOW_MINIMIZE,
|
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';
|
} from './constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
||||||
|
|
@ -236,9 +236,9 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivitySnapshot,
|
LeadActivitySnapshot,
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberSpawnStatusesSnapshot,
|
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
RejectResult,
|
RejectResult,
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
|
|
@ -281,28 +281,6 @@ import type {
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
WslClaudeRootCandidate,
|
WslClaudeRootCandidate,
|
||||||
} from '@shared/types';
|
} 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 {
|
import type {
|
||||||
BinaryPreviewResult,
|
BinaryPreviewResult,
|
||||||
CreateDirResponse,
|
CreateDirResponse,
|
||||||
|
|
@ -318,6 +296,28 @@ import type {
|
||||||
SearchInFilesResult,
|
SearchInFilesResult,
|
||||||
WriteFileResponse,
|
WriteFileResponse,
|
||||||
} from '@shared/types/editor';
|
} 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 { PtySpawnOptions } from '@shared/types/terminal';
|
||||||
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import type { SearchMatch } from '@renderer/store/types';
|
|
||||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
|
|
@ -44,6 +43,8 @@ import {
|
||||||
import { FileLink, isRelativeUrl } from './FileLink';
|
import { FileLink, isRelativeUrl } from './FileLink';
|
||||||
import { MermaidDiagram } from './MermaidDiagram';
|
import { MermaidDiagram } from './MermaidDiagram';
|
||||||
|
|
||||||
|
import type { SearchMatch } from '@renderer/store/types';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -69,7 +70,7 @@ interface MarkdownViewerProps {
|
||||||
onTeamClick?: (teamName: string) => void;
|
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_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const EMPTY_SEARCH_MATCHES: SearchMatch[] = [];
|
const EMPTY_SEARCH_MATCHES: SearchMatch[] = [];
|
||||||
const NOOP_TEAM_CLICK = (): void => undefined;
|
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.
|
* that appear in agent messages and cause React "unrecognized tag" warnings.
|
||||||
*/
|
*/
|
||||||
const STANDARD_HTML_TAGS = new Set([
|
const STANDARD_HTML_TAGS = new Set([
|
||||||
'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
|
'a',
|
||||||
'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
|
'abbr',
|
||||||
'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
|
'address',
|
||||||
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
|
'area',
|
||||||
'em', 'embed',
|
'article',
|
||||||
'fieldset', 'figcaption', 'figure', 'footer', 'form',
|
'aside',
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
|
'audio',
|
||||||
'i', 'iframe', 'img', 'input', 'ins',
|
'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',
|
'kbd',
|
||||||
'label', 'legend', 'li', 'link',
|
'label',
|
||||||
'main', 'map', 'mark', 'menu', 'meta', 'meter',
|
'legend',
|
||||||
'nav', 'noscript',
|
'li',
|
||||||
'object', 'ol', 'optgroup', 'option', 'output',
|
'link',
|
||||||
'p', 'picture', 'pre', 'progress',
|
'main',
|
||||||
|
'map',
|
||||||
|
'mark',
|
||||||
|
'menu',
|
||||||
|
'meta',
|
||||||
|
'meter',
|
||||||
|
'nav',
|
||||||
|
'noscript',
|
||||||
|
'object',
|
||||||
|
'ol',
|
||||||
|
'optgroup',
|
||||||
|
'option',
|
||||||
|
'output',
|
||||||
|
'p',
|
||||||
|
'picture',
|
||||||
|
'pre',
|
||||||
|
'progress',
|
||||||
'q',
|
'q',
|
||||||
'rp', 'rt', 'ruby',
|
'rp',
|
||||||
's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'source', 'span',
|
'rt',
|
||||||
'strong', 'style', 'sub', 'summary', 'sup',
|
'ruby',
|
||||||
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
|
's',
|
||||||
'u', 'ul',
|
'samp',
|
||||||
'var', 'video',
|
'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',
|
'wbr',
|
||||||
// SVG elements commonly used inline
|
// SVG elements commonly used inline
|
||||||
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
|
'svg',
|
||||||
'text', 'tspan', 'clippath', 'mask', 'pattern', 'image', 'foreignobject',
|
'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 React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import { AlertTriangle, Bug, Check, Copy, RefreshCw } from 'lucide-react';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type BugReportContext,
|
||||||
buildBugReportText,
|
buildBugReportText,
|
||||||
buildGitHubBugReportUrl,
|
buildGitHubBugReportUrl,
|
||||||
type BugReportContext,
|
|
||||||
} from '@renderer/utils/bugReportUtils';
|
} from '@renderer/utils/bugReportUtils';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { AlertTriangle, Bug, Check, Copy, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
const logger = createLogger('Component:ErrorBoundary');
|
const logger = createLogger('Component:ErrorBoundary');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
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 { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -18,14 +15,17 @@ import {
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} 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 { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||||
|
|
||||||
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
import { ApiKeysPanel } from './apikeys/ApiKeysPanel';
|
||||||
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
|
||||||
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
import { CustomMcpServerDialog } from './mcp/CustomMcpServerDialog';
|
||||||
import { McpServersPanel } from './mcp/McpServersPanel';
|
import { McpServersPanel } from './mcp/McpServersPanel';
|
||||||
import { PluginsPanel } from './plugins/PluginsPanel';
|
import { PluginsPanel } from './plugins/PluginsPanel';
|
||||||
import { SkillsPanel } from './skills/SkillsPanel';
|
import { SkillsPanel } from './skills/SkillsPanel';
|
||||||
|
import { ExtensionsSubTabTrigger } from './ExtensionsSubTabTrigger';
|
||||||
|
|
||||||
export const ExtensionStoreView = (): React.JSX.Element => {
|
export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
const tabId = useTabIdOptional();
|
const tabId = useTabIdOptional();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { TabsTrigger } from '@renderer/components/ui/tabs';
|
import { TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface ExtensionsSubTabTriggerProps {
|
interface ExtensionsSubTabTriggerProps {
|
||||||
value: 'plugins' | 'mcp-servers' | 'skills' | 'api-keys';
|
value: 'plugins' | 'mcp-servers' | 'skills' | 'api-keys';
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -21,7 +20,7 @@ export const ExtensionsSubTabTrigger = ({
|
||||||
return (
|
return (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value={value}
|
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" />
|
<Icon className="size-3.5" />
|
||||||
{label}
|
{label}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
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';
|
import type { ApiKeyEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Check, Loader2, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -15,6 +13,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { Check, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { ExtensionOperationState } from '@shared/types/extensions';
|
import type { ExtensionOperationState } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -28,7 +27,7 @@ interface InstallButtonProps {
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InstallButton({
|
export const InstallButton = ({
|
||||||
state,
|
state,
|
||||||
isInstalled,
|
isInstalled,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
|
@ -36,7 +35,7 @@ export function InstallButton({
|
||||||
disabled,
|
disabled,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}: InstallButtonProps) {
|
}: InstallButtonProps) => {
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
const cliMissing = cliStatus !== null && !cliStatus.installed;
|
||||||
const isDisabled = disabled || cliMissing;
|
const isDisabled = disabled || cliMissing;
|
||||||
|
|
@ -52,7 +51,7 @@ export function InstallButton({
|
||||||
if (state === 'pending') {
|
if (state === 'pending') {
|
||||||
return (
|
return (
|
||||||
<Button size={size} variant="outline" disabled>
|
<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">
|
<span className="ml-1.5">
|
||||||
{pendingAction === 'uninstall' ? 'Removing...' : 'Installing...'}
|
{pendingAction === 'uninstall' ? 'Removing...' : 'Installing...'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -63,7 +62,7 @@ export function InstallButton({
|
||||||
if (state === 'success') {
|
if (state === 'success') {
|
||||||
return (
|
return (
|
||||||
<Button size={size} variant="outline" disabled className="text-green-400">
|
<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>
|
<span className="ml-1.5">Done</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
@ -121,7 +120,7 @@ export function InstallButton({
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
<span className="ml-1.5">Uninstall</span>
|
<span className="ml-1.5">Uninstall</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -153,4 +152,4 @@ export function InstallButton({
|
||||||
}
|
}
|
||||||
|
|
||||||
return button;
|
return button;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
* InstallCountBadge — formatted download count with icon.
|
* InstallCountBadge — formatted download count with icon.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
|
||||||
|
|
||||||
import { formatInstallCount } from '@shared/utils/extensionNormalizers';
|
import { formatInstallCount } from '@shared/utils/extensionNormalizers';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
|
||||||
interface InstallCountBadgeProps {
|
interface InstallCountBadgeProps {
|
||||||
count: number;
|
count: number;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -23,7 +24,6 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { api } from '@renderer/api';
|
|
||||||
import { Plus, Server, Trash2 } from 'lucide-react';
|
import { Plus, Server, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,18 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { api } from '@renderer/api';
|
|
||||||
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
import { Cloud, Clock, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
|
import { Clock, Cloud, 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 { Github as GithubIcon } from 'lucide-react';
|
import { Github as GithubIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
|
||||||
|
|
||||||
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
|
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -82,7 +80,7 @@ export const McpServerCard = ({
|
||||||
{hasIcon && (
|
{hasIcon && (
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||||
<img
|
<img
|
||||||
src={server.iconUrl!}
|
src={server.iconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-7 rounded object-contain"
|
className="size-7 rounded object-contain"
|
||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -12,8 +15,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} 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 { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,12 +25,11 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
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 { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
import { InstallButton } from '../common/InstallButton';
|
import { InstallButton } from '../common/InstallButton';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
|
||||||
|
|
||||||
import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions';
|
import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
|
@ -214,7 +214,7 @@ export const McpServerDetailDialog = ({
|
||||||
{hasIcon && (
|
{hasIcon && (
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
||||||
<img
|
<img
|
||||||
src={server.iconUrl!}
|
src={server.iconUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-8 rounded object-contain"
|
className="size-8 rounded object-contain"
|
||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
|
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
||||||
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
|
import { AlertTriangle, RefreshCw, Search, Server } from 'lucide-react';
|
||||||
|
|
||||||
import { SearchInput } from '../common/SearchInput';
|
import { SearchInput } from '../common/SearchInput';
|
||||||
|
|
@ -27,7 +28,6 @@ import type {
|
||||||
McpCatalogItem,
|
McpCatalogItem,
|
||||||
McpServerDiagnostic,
|
McpServerDiagnostic,
|
||||||
} from '@shared/types/extensions';
|
} from '@shared/types/extensions';
|
||||||
import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
|
||||||
|
|
||||||
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
|
type McpSortValue = 'name-asc' | 'name-desc' | 'tools-desc';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plugin.source === 'official' && (
|
{plugin.source === 'official' && (
|
||||||
<div className="pointer-events-none absolute -left-[1px] -top-[1px] size-16 overflow-hidden">
|
<div className="pointer-events-none absolute -left-px -top-px 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="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
|
Official
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,8 +15,6 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} 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 { Label } from '@renderer/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -22,7 +24,6 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { api } from '@renderer/api';
|
|
||||||
import {
|
import {
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
inferCapabilities,
|
inferCapabilities,
|
||||||
|
|
@ -34,8 +35,6 @@ import { InstallButton } from '../common/InstallButton';
|
||||||
import { InstallCountBadge } from '../common/InstallCountBadge';
|
import { InstallCountBadge } from '../common/InstallCountBadge';
|
||||||
import { SourceBadge } from '../common/SourceBadge';
|
import { SourceBadge } from '../common/SourceBadge';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
|
||||||
|
|
||||||
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
|
import type { EnrichedPlugin, InstallScope } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface PluginDetailDialogProps {
|
interface PluginDetailDialogProps {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ import {
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
|
||||||
import { getSyncLanguageExtension } from '@renderer/utils/codemirrorLanguages';
|
import { getSyncLanguageExtension } from '@renderer/utils/codemirrorLanguages';
|
||||||
|
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||||
|
|
||||||
const skillEditorTheme = EditorView.theme({
|
const skillEditorTheme = EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,13 @@ import { useStore } from '@renderer/store';
|
||||||
import { FileSearch, RotateCcw, X } from 'lucide-react';
|
import { FileSearch, RotateCcw, X } from 'lucide-react';
|
||||||
|
|
||||||
import { SkillCodeEditor } from './SkillCodeEditor';
|
import { SkillCodeEditor } from './SkillCodeEditor';
|
||||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
|
||||||
import {
|
import {
|
||||||
buildSkillDraftFiles,
|
buildSkillDraftFiles,
|
||||||
buildSkillTemplate,
|
buildSkillTemplate,
|
||||||
readSkillTemplateContent,
|
readSkillTemplateContent,
|
||||||
updateSkillTemplateFrontmatter,
|
updateSkillTemplateFrontmatter,
|
||||||
} from './skillDraftUtils';
|
} from './skillDraftUtils';
|
||||||
|
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
SkillDetail,
|
SkillDetail,
|
||||||
|
|
@ -792,7 +792,7 @@ export const SkillEditorDialog = ({
|
||||||
<X className="mr-1.5 size-3.5" />
|
<X className="mr-1.5 size-3.5" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<div className="min-w-[16rem] flex-1">
|
<div className="min-w-64 flex-1">
|
||||||
<p className="text-sm text-text-muted">
|
<p className="text-sm text-text-muted">
|
||||||
Review the file changes first, then confirm save in the next step.
|
Review the file changes first, then confirm save in the next step.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export const SkillImportDialog = ({
|
||||||
<X className="mr-1.5 size-3.5" />
|
<X className="mr-1.5 size-3.5" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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.
|
Review the copied files first, then confirm the import in the next step.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { api } from '@renderer/api';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { useStore } from '@renderer/store';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowUpAZ,
|
ArrowUpAZ,
|
||||||
|
|
@ -25,8 +25,8 @@ import { SkillDetailDialog } from './SkillDetailDialog';
|
||||||
import { SkillEditorDialog } from './SkillEditorDialog';
|
import { SkillEditorDialog } from './SkillEditorDialog';
|
||||||
import { SkillImportDialog } from './SkillImportDialog';
|
import { SkillImportDialog } from './SkillImportDialog';
|
||||||
|
|
||||||
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
|
||||||
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
|
import type { SkillsSortState } from '@renderer/hooks/useExtensionsTabState';
|
||||||
|
import type { SkillCatalogItem, SkillDetail } from '@shared/types/extensions';
|
||||||
|
|
||||||
const SUCCESS_BANNER_MS = 2500;
|
const SUCCESS_BANNER_MS = 2500;
|
||||||
const NEW_SKILL_HIGHLIGHT_MS = 4000;
|
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 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="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
|
<SearchInput
|
||||||
value={skillsSearchQuery}
|
value={skillsSearchQuery}
|
||||||
onChange={setSkillsSearchQuery}
|
onChange={setSkillsSearchQuery}
|
||||||
|
|
@ -318,7 +318,7 @@ export const SkillsPanel = ({
|
||||||
['personal', 'Personal'],
|
['personal', 'Personal'],
|
||||||
['needs-attention', 'Needs attention'],
|
['needs-attention', 'Needs attention'],
|
||||||
['has-scripts', 'Has scripts'],
|
['has-scripts', 'Has scripts'],
|
||||||
] as Array<[SkillsQuickFilter, string]>
|
] as [SkillsQuickFilter, string][]
|
||||||
).map(([value, label]) => (
|
).map(([value, label]) => (
|
||||||
<Button
|
<Button
|
||||||
key={value}
|
key={value}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function buildSkillTemplate(input: SkillDraftTemplateInput): string {
|
||||||
|
|
||||||
export function readSkillTemplateContent(rawContent: string): SkillTemplateParseResult {
|
export function readSkillTemplateContent(rawContent: string): SkillTemplateParseResult {
|
||||||
const content = rawContent.replace(/^\uFEFF/u, '');
|
const content = rawContent.replace(/^\uFEFF/u, '');
|
||||||
const match = content.match(SKILL_FRONTMATTER_PATTERN);
|
const match = SKILL_FRONTMATTER_PATTERN.exec(content);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return {
|
return {
|
||||||
hasStructuredSections: false,
|
hasStructuredSections: false,
|
||||||
|
|
@ -130,7 +130,7 @@ export function updateSkillTemplateFrontmatter(
|
||||||
input: SkillDraftTemplateInput
|
input: SkillDraftTemplateInput
|
||||||
): string {
|
): string {
|
||||||
const content = rawContent.replace(/^\uFEFF/u, '');
|
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;
|
const body = match ? (match[2] ?? '') : content;
|
||||||
|
|
||||||
let data: Record<string, unknown> = {};
|
let data: Record<string, unknown> = {};
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { isElectronMode } from '@renderer/api';
|
import { isElectronMode } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { MoreMenu } from './MoreMenu';
|
import { MoreMenu } from './MoreMenu';
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ import {
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
||||||
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog';
|
||||||
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow';
|
||||||
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge';
|
||||||
import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog';
|
|
||||||
|
|
||||||
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types';
|
||||||
// Constants
|
// Constants
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const STATUS_OPTIONS: Array<{ value: ScheduleStatus | 'all'; label: string }> = [
|
const STATUS_OPTIONS: { value: ScheduleStatus | 'all'; label: string }[] = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'active', label: 'Active' },
|
{ value: 'active', label: 'Active' },
|
||||||
{ value: 'paused', label: 'Paused' },
|
{ value: 'paused', label: 'Paused' },
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ export interface SafeConfig {
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
notifyOnStatusChange: boolean;
|
notifyOnStatusChange: boolean;
|
||||||
notifyOnTaskComments: boolean;
|
notifyOnTaskComments: boolean;
|
||||||
|
notifyOnTaskCreated: boolean;
|
||||||
|
notifyOnAllTasksCompleted: boolean;
|
||||||
|
notifyOnCrossTeamMessage: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: string[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
|
|
@ -181,6 +184,10 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||||
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
notifyOnStatusChange: displayConfig?.notifications?.notifyOnStatusChange ?? true,
|
||||||
notifyOnTaskComments: displayConfig?.notifications?.notifyOnTaskComments ?? 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,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,9 @@ export function useSettingsHandlers({
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
notifyOnStatusChange: true,
|
notifyOnStatusChange: true,
|
||||||
notifyOnTaskComments: true,
|
notifyOnTaskComments: true,
|
||||||
|
notifyOnTaskCreated: true,
|
||||||
|
notifyOnAllTasksCompleted: true,
|
||||||
|
notifyOnCrossTeamMessage: true,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ export const ConfigEditorDialog = ({
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]),
|
keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]),
|
||||||
baseEditorTheme,
|
baseEditorTheme,
|
||||||
configEditorTheme,
|
configEditorTheme,
|
||||||
// eslint-disable-next-line sonarjs/no-nested-functions -- CodeMirror listener callback within useEffect setup
|
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
const text = update.state.doc.toString();
|
const text = update.state.doc.toString();
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@ import {
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
Bell,
|
Bell,
|
||||||
BellRing,
|
BellRing,
|
||||||
|
CheckCircle2,
|
||||||
|
CirclePlus,
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
GitBranch,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Inbox,
|
Inbox,
|
||||||
Info,
|
Info,
|
||||||
|
|
@ -36,7 +39,9 @@ import type { NotificationTrigger } from '@renderer/types/data';
|
||||||
import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
|
import type { TeamReviewState, TeamTaskStatus } from '@shared/types';
|
||||||
|
|
||||||
/** Notification targets span workflow status plus the explicit review axis. */
|
/** 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
|
// Snooze duration options
|
||||||
const SNOOZE_OPTIONS = [
|
const SNOOZE_OPTIONS = [
|
||||||
|
|
@ -64,6 +69,9 @@ interface NotificationsSectionProps {
|
||||||
| 'notifyOnClarifications'
|
| 'notifyOnClarifications'
|
||||||
| 'notifyOnStatusChange'
|
| 'notifyOnStatusChange'
|
||||||
| 'notifyOnTaskComments'
|
| 'notifyOnTaskComments'
|
||||||
|
| 'notifyOnTaskCreated'
|
||||||
|
| 'notifyOnAllTasksCompleted'
|
||||||
|
| 'notifyOnCrossTeamMessage'
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -293,6 +301,39 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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 */}
|
{/* Task Status Change Notifications — nested within team card */}
|
||||||
<div className="last:*:border-b-0">
|
<div className="last:*:border-b-0">
|
||||||
|
|
@ -434,6 +475,7 @@ export const NotificationsSection = ({
|
||||||
const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
|
const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
|
||||||
{ value: 'in_progress', label: 'Started' },
|
{ value: 'in_progress', label: 'Started' },
|
||||||
{ value: 'completed', label: 'Completed' },
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'review', label: 'Review' },
|
||||||
{ value: 'needsFix', label: 'Needs Fixes' },
|
{ value: 'needsFix', label: 'Needs Fixes' },
|
||||||
{ value: 'approved', label: 'Approved' },
|
{ value: 'approved', label: 'Approved' },
|
||||||
{ value: 'pending', label: 'Pending' },
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,6 @@ export const GlobalTaskList = ({
|
||||||
// Reset showArchived when archive becomes empty
|
// Reset showArchived when archive becomes empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showArchived && !hasArchivedTasks) {
|
if (showArchived && !hasArchivedTasks) {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change
|
|
||||||
setShowArchived(false);
|
setShowArchived(false);
|
||||||
}
|
}
|
||||||
}, [showArchived, hasArchivedTasks]);
|
}, [showArchived, hasArchivedTasks]);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
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 { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { projectColor } from '@renderer/utils/projectColor';
|
import { projectColor } from '@renderer/utils/projectColor';
|
||||||
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,24 @@ export const RoleSelect = ({
|
||||||
return opt?.label;
|
return opt?.label;
|
||||||
}, [value]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Combobox
|
<Combobox
|
||||||
|
|
@ -131,6 +149,7 @@ export const RoleSelect = ({
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={triggerClassName}
|
className={triggerClassName}
|
||||||
renderOption={renderRoleOption}
|
renderOption={renderRoleOption}
|
||||||
|
renderTriggerLabel={renderTriggerLabel}
|
||||||
/>
|
/>
|
||||||
{value === CUSTOM_ROLE && onCustomRoleChange ? (
|
{value === CUSTOM_ROLE && onCustomRoleChange ? (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue