Merge pull request #21 from 777genius/dev

Dev
This commit is contained in:
Илия 2026-03-16 21:52:00 +02:00 committed by GitHub
commit 9d7a7885b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
173 changed files with 3665 additions and 1420 deletions

View file

@ -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
--- ---

View file

@ -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,

View file

@ -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,
}; };

View file

@ -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,
}; };

View file

@ -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,
}; };

View file

@ -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,6 +122,8 @@ 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(
`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` + `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` +
@ -210,4 +250,5 @@ module.exports = {
approveReview, approveReview,
requestReview, requestReview,
requestChanges, requestChanges,
startReview,
}; };

View file

@ -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') {

View file

@ -37,11 +37,11 @@ function quoteMarkdown(text) {
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}`;
@ -78,8 +78,7 @@ function buildAssignmentMessage(context, task, options = {}) {
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),
``, ``,
@ -235,10 +234,8 @@ function updateTaskFields(context, 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()
: runtimeHelpers.inferLeadName(context.paths),
...(flags.id ? { id: flags.id } : {}), ...(flags.id ? { id: flags.id } : {}),
...(flags.createdAt ? { createdAt: flags.createdAt } : {}), ...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
...(flags.type ? { type: flags.type } : {}), ...(flags.type ? { type: flags.type } : {}),
@ -342,9 +339,9 @@ function resolveLanguageName(code, systemLocale) {
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.';
} }
@ -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:
@ -486,21 +492,21 @@ async function memberBriefing(context, memberName) {
const effectiveMember = member; const effectiveMember = member;
const role = const role =
typeof effectiveMember.role === 'string' && effectiveMember.role.trim() typeof effectiveMember.role === 'string' && effectiveMember.role.trim() ?
? effectiveMember.role.trim() effectiveMember.role.trim() :
: typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim() typeof effectiveMember.agentType === 'string' && effectiveMember.agentType.trim() ?
? effectiveMember.agentType.trim() effectiveMember.agentType.trim() :
: 'team member'; 'team member';
const workflow = const workflow =
typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim() typeof effectiveMember.workflow === 'string' && effectiveMember.workflow.trim() ?
? effectiveMember.workflow.trim() effectiveMember.workflow.trim() :
: ''; '';
const cwd = const cwd =
typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim() typeof effectiveMember.cwd === 'string' && effectiveMember.cwd.trim() ?
? effectiveMember.cwd.trim() effectiveMember.cwd.trim() :
: typeof config.projectPath === 'string' && config.projectPath.trim() typeof config.projectPath === 'string' && config.projectPath.trim() ?
? config.projectPath.trim() config.projectPath.trim() :
: ''; '';
const activeProcesses = processStore const activeProcesses = processStore
.listProcesses(context.paths) .listProcesses(context.paths)

View file

@ -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'
);
});
});
}); });

View file

@ -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', () => {

View file

@ -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',

View file

@ -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",

View file

@ -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;
} }

View file

@ -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,

View file

@ -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(
slimTask(
getController(teamName, claudeDir).review.requestReview(taskId, { getController(teamName, claudeDir).review.requestReview(taskId, {
...(from ? { from } : {}), ...(from ? { from } : {}),
...(reviewer ? { reviewer } : {}), ...(reviewer ? { reviewer } : {}),
...(leadSessionId ? { leadSessionId } : {}), ...(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 } : {}),
}) as Record<string, unknown>
) )
), ),
}); });
@ -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(
slimTask(
getController(teamName, claudeDir).review.approveReview(taskId, { getController(teamName, claudeDir).review.approveReview(taskId, {
...(from ? { from } : {}), ...(from ? { from } : {}),
...(note ? { note } : {}), ...(note ? { note } : {}),
...(notifyOwner !== false ? { 'notify-owner': true } : {}), ...(notifyOwner !== false ? { 'notify-owner': true } : {}),
...(leadSessionId ? { leadSessionId } : {}), ...(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(
slimTask(
getController(teamName, claudeDir).review.requestChanges(taskId, { getController(teamName, claudeDir).review.requestChanges(taskId, {
...(from ? { from } : {}), ...(from ? { from } : {}),
...(comment ? { comment } : {}), ...(comment ? { comment } : {}),
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),
}) }) as Record<string, unknown>
)
) )
), ),
}); });

View file

@ -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(),
}; };

View file

@ -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,18 +84,128 @@ 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(
buildCreateTaskPayload({
subject, subject,
...(description ? { description } : {}), description,
...(owner ? { owner } : {}), owner,
...(createdBy ? { createdBy } : {}), createdBy,
...(!createdBy && from ? { from } : {}), from,
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}), blockedBy,
...(related?.length ? { related: related.join(',') } : {}), related,
...(prompt ? { prompt } : {}), prompt,
...(startImmediately !== undefined ? { startImmediately } : {}), 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(
taskWriteResult(
getController(teamName, claudeDir).tasks.addTaskComment(taskId, { getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
text, text,
...(from ? { from } : {}), ...(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(
taskWriteResult(
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, { getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
file: filePath, file: filePath,
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
...(filename ? { filename } : {}), ...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}), ...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}), ...(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(
taskWriteResult(
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, { getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
file: filePath, file: filePath,
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
...(filename ? { filename } : {}), ...(filename ? { filename } : {}),
...(mimeType ? { 'mime-type': mimeType } : {}), ...(mimeType ? { 'mime-type': mimeType } : {}),
...(noFallback ? { 'no-fallback': true } : {}), ...(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(
slimTask(
getController(teamName, claudeDir).tasks.setNeedsClarification( getController(teamName, claudeDir).tasks.setNeedsClarification(
taskId, taskId,
value === 'clear' ? null : value 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>)
) )
), ),
}); });

View file

@ -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;
}

View file

@ -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');

View file

@ -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);
});
});
}); });

View file

@ -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,
}, },
}); });

View 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,
},
});

View file

@ -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);
} }

View file

@ -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` };

View file

@ -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');

View file

@ -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 */

View file

@ -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.

View file

@ -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');

View file

@ -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)

View file

@ -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. */

View file

@ -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 {

View file

@ -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,

View file

@ -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> => {

View file

@ -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');

View file

@ -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');

View file

@ -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,

View file

@ -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`;

View file

@ -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';

View file

@ -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');

View file

@ -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');

View file

@ -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;

View file

@ -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,

View file

@ -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;
} }

View file

@ -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');

View file

@ -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'] },

View file

@ -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()) {}

View file

@ -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;

View file

@ -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 {

View file

@ -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(

View file

@ -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');

View file

@ -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');

View file

@ -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 ──────────────────────────────────────────────────────────────────

View file

@ -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';

View file

@ -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,

View file

@ -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');

View file

@ -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;

View file

@ -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;
} }

View file

@ -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';

View file

@ -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);

View file

@ -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';

View file

@ -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.
} }

View file

@ -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);
} }

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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;

View file

@ -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';

View file

@ -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;

View file

@ -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;

View file

@ -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' },
}; };

View file

@ -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) {

View file

@ -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';

View file

@ -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',
]); ]);
/** /**

View file

@ -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');

View file

@ -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();

View file

@ -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}

View file

@ -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';

View file

@ -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;
} };

View file

@ -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;

View file

@ -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 {

View file

@ -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)}

View file

@ -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)}

View file

@ -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';

View file

@ -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>

View file

@ -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 {

View file

@ -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({
'&': { '&': {

View file

@ -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>

View file

@ -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

View file

@ -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}

View file

@ -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> = {};

View file

@ -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';

View file

@ -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' },

View file

@ -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',

View file

@ -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,

View file

@ -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();

View file

@ -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' },

View file

@ -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]);

View file

@ -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';

View file

@ -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