diff --git a/README.md b/README.md
index bc9eb1e9..0c7e06dd 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,7 @@
Claude Agent Teams UI
- Terminal tells you nothing. This shows you everything.
-
- You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee.
+ You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other's code. You just look at the kanban board and drink coffee.
diff --git a/bin/kill-dev.js b/bin/kill-dev.js
new file mode 100644
index 00000000..9d4812e8
--- /dev/null
+++ b/bin/kill-dev.js
@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+import { spawnSync } from 'child_process';
+
+const isWindows = process.platform === 'win32';
+
+if (isWindows) {
+ const r = spawnSync('taskkill', ['/F', '/IM', 'electron.exe'], {
+ stdio: 'inherit',
+ shell: true,
+ });
+ if (r.status != null && r.status !== 0 && r.status !== 128 && r.signal == null) {
+ process.exitCode = 1;
+ }
+} else {
+ const r = spawnSync('pkill', ['-f', 'electron-vite|electron \\.'], { stdio: 'inherit' });
+ if (r.status != null && r.status !== 0 && r.status !== 1 && r.signal == null) {
+ process.exitCode = 1;
+ }
+}
+console.log('Done');
diff --git a/package.json b/package.json
index 5b809d3f..b2e39cf9 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"main": "dist-electron/main/index.cjs",
"scripts": {
"dev": "electron-vite dev",
- "dev:kill": "pkill -f 'electron-vite|electron \\.' 2>/dev/null; echo 'Done'",
+ "dev:kill": "node bin/kill-dev.js",
"build": "electron-vite build",
"dist": "electron-builder --mac --win --linux",
"dist:mac": "electron-builder --mac --publish always",
diff --git a/src/main/index.ts b/src/main/index.ts
index 115d1fca..9aa6e4c2 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -311,6 +311,14 @@ function initializeServices(): void {
void new TeamAgentToolsInstaller().ensureInstalled();
httpServer = new HttpServer();
+ // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
+ teamProvisioningService.setTeamChangeEmitter((event) => {
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(TEAM_CHANGE, event);
+ }
+ httpServer?.broadcast('team-change', event);
+ });
+
// Initialize IPC handlers with registry
initializeIpcHandlers(
contextRegistry,
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index af203e8d..62218c45 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -182,16 +182,58 @@ async function handleGetData(
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('getData', async () => {
- const data = await getTeamDataService().getTeamData(validated.value!);
- const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!);
+ const tn = validated.value!;
+ const data = await getTeamDataService().getTeamData(tn);
+ const provisioning = getTeamProvisioningService();
+ const isAlive = provisioning.isTeamAlive(tn);
+
if (isAlive) {
- try {
- await getTeamProvisioningService().relayLeadInboxMessages(validated.value!);
- } catch {
- // Best-effort: never fail getData due to relay issues
- }
+ // Fire-and-forget: relay can take time (waits for lead reply).
+ void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
}
- return { ...data, isAlive };
+
+ const live = provisioning.getLiveLeadProcessMessages(tn);
+ if (live.length === 0) {
+ return { ...data, isAlive };
+ }
+
+ const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
+ const leadSessionTextFingerprints = new Set();
+ for (const msg of data.messages) {
+ if ((msg as { source?: unknown }).source !== 'lead_session') continue;
+ if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
+ leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
+ }
+
+ const keyFor = (m: {
+ messageId?: string;
+ timestamp: string;
+ from: string;
+ text: string;
+ }): string => {
+ if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
+ return m.messageId;
+ }
+ return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
+ };
+
+ const merged: typeof data.messages = [];
+ const seen = new Set();
+ for (const msg of [...data.messages, ...live]) {
+ if ((msg as { source?: unknown }).source === 'lead_process') {
+ const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
+ if (leadSessionTextFingerprints.has(fp)) {
+ continue;
+ }
+ }
+ const key = keyFor(msg);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ merged.push(msg);
+ }
+ merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
+
+ return { ...data, isAlive, messages: merged };
});
}
@@ -242,7 +284,9 @@ async function handleUpdateConfig(
}
function isProvisioningTeamName(teamName: string): boolean {
- return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(teamName) && teamName.length <= 64;
+ if (teamName.length > 64) return false;
+ const parts = teamName.split('-');
+ return parts.every((p) => /^[a-z0-9]+$/.test(p));
}
async function validateProvisioningRequest(
diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts
index 83d1b6db..05f08390 100644
--- a/src/main/ipc/utility.ts
+++ b/src/main/ipc/utility.ts
@@ -12,12 +12,21 @@ import { createLogger } from '@shared/utils/logger';
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
import * as fs from 'fs';
-import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
+import {
+ type ClaudeMdFileInfo,
+ readAgentConfigs,
+ readAllClaudeMdFiles,
+ readDirectoryClaudeMd,
+} from '../services';
import type { AgentConfig } from '@shared/types/api';
const logger = createLogger('IPC:utility');
-import { validateFilePath, validateOpenPath } from '../utils/pathValidation';
+import {
+ validateFilePath,
+ validateOpenPath,
+ validateOpenPathUserSelected,
+} from '../utils/pathValidation';
import { countTokens } from '../utils/tokenizer';
/**
@@ -96,15 +105,19 @@ async function handleShellOpenExternal(
* Handler for 'shell:openPath' IPC call.
* Opens a folder or file in the system's default application (Finder on macOS).
* Validates path security before opening.
+ * When userSelectedFromDialog is true, path was chosen via system folder picker —
+ * only sensitive-pattern checks apply, not project/claude directory restriction.
*/
async function handleShellOpenPath(
_event: IpcMainInvokeEvent,
targetPath: string,
- projectRoot?: string
+ projectRoot?: string,
+ userSelectedFromDialog?: boolean
): Promise<{ success: boolean; error?: string }> {
try {
- // Validate path security
- const validation = validateOpenPath(targetPath, projectRoot ?? null);
+ const validation = userSelectedFromDialog
+ ? validateOpenPathUserSelected(targetPath)
+ : validateOpenPath(targetPath, projectRoot ?? null);
if (!validation.valid) {
logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`);
return { success: false, error: validation.error };
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index adc55287..ade6030a 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -469,7 +469,7 @@ export class TeamDataService {
for (const msg of messages) {
if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
- if (msg.source === 'lead_session') continue;
+ if (msg.source === 'lead_session' || msg.source === 'lead_process') continue;
const textKey = `${msg.from}\0${msg.text}`;
if (processedTexts.has(textKey)) continue;
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 645eceb6..3f53f634 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -24,6 +24,8 @@ import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import type {
+ InboxMessage,
+ TeamChangeEvent,
TeamCreateRequest,
TeamCreateResponse,
TeamLaunchRequest,
@@ -107,6 +109,14 @@ interface ProvisioningRun {
waitingTasksSince: number | null;
provisioningComplete: boolean;
isLaunch: boolean;
+ leadRelayCapture: {
+ leadName: string;
+ startedAt: string;
+ textParts: string[];
+ resolve: (text: string) => void;
+ reject: (error: string) => void;
+ timeoutHandle: NodeJS.Timeout;
+ } | null;
}
type ProvisioningAuthSource =
@@ -242,18 +252,18 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
function buildTaskStatusProtocol(teamName: string): string {
return `MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
1. Use this command to mark task started:
- node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task start
+ node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start
2. Use this command to mark task completed BEFORE sending your final reply:
- node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task complete
+ node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete
3. If you are asked to review and task is accepted, move it to APPROVED (not DONE):
- node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review approve
+ node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review approve
4. If review fails and changes are needed:
- node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review request-changes --comment \\"\\"
+ node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" review request-changes --comment ""
5. NEVER skip status updates. A task is NOT done until completed status is written.
6. To reply to a comment on a task:
- node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment --text \\"\\" --from \\"\\"
+ node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from ""
7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates — record them as a task comment:
- node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment --text \\"\\" --from \\"\\"
+ node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from ""
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
8. When sending a message about a specific task, include # in your SendMessage summary field for traceability.
Failure to follow this protocol means the task board will show incorrect status.`;
@@ -278,11 +288,19 @@ Goal: Provision a Claude Code agent team with live teammates.
${userPromptBlock}
Constraints:
- Do NOT call TeamDelete under any circumstances.
-- Do NOT use TodoWrite — use TaskCreate for tasks.
+- Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members.
- Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
+- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
+- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).
+- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
+
+Communication protocol (CRITICAL — you are running headless, no one sees your text output):
+- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
+- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
+- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Task board operations — use teamctl.js via Bash:
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}"
@@ -297,11 +315,16 @@ Steps (execute in this exact order):
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
- - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then wait for task assignments.
+ - prompt:
+ You are {name}, a {role} on team "${displayName}" (${request.teamName}).
+ Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
+ Then wait for task assignments.
-${taskProtocol}"
+ ${taskProtocol}
-3) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
+3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations").
+ - Prefer fewer, broader tasks over many micro-tasks.
+ - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
4) After all steps, output a short summary.
@@ -329,11 +352,19 @@ Goal: Reconnect with existing team "${request.teamName}".
${userPromptBlock}
Constraints:
- Do NOT call TeamDelete under any circumstances.
-- Do NOT use TodoWrite — use TaskCreate for tasks.
+- Do NOT use TodoWrite.
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
- Do NOT shut down, terminate, or clean up the team or its members.
- Keep assistant text minimal.
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
+- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
+- Use teamctl.js (via Bash) for tasks that must appear on the team board (assigned work, substantial work, or when the user explicitly asks to create a task).
+- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
+
+Communication protocol (CRITICAL — you are running headless, no one sees your text output):
+- When you receive a from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
+- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
+- Example: if you receive ..., respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
Task board operations — use teamctl.js via Bash:
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "" --notify --from "${leadName}"
@@ -343,17 +374,22 @@ Steps (execute in this exact order):
1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state.
-2) Read the task list via TaskList — understand pending work.
+2) Read tasks from ~/.claude/tasks/${request.teamName}/ (JSON files) and kanban state from ~/.claude/teams/${request.teamName}/kanban-state.json — understand pending work.
3) Spawn each existing member as a live teammate using the Task tool:
- team_name: "${request.teamName}"
- name: the member's name
- subagent_type: "general-purpose"
- - prompt: "You are {name}, a {role} on team \\"${request.teamName}\\". The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale. Then check TaskList for pending work and resume.
+ - prompt:
+ You are {name}, a {role} on team "${request.teamName}".
+ The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
+ Then resume any pending work you own (if any) and wait for new assignments.
-${taskProtocol}"
+ ${taskProtocol}
-4) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
+4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks via teamctl.js (see "Task board operations").
+ - Prefer fewer, broader tasks over many micro-tasks.
+ - The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
5) After all steps, output a short summary.
@@ -449,6 +485,8 @@ export class TeamProvisioningService {
private readonly leadInboxRelayInFlight = new Map>();
private readonly relayedLeadInboxMessageIds = new Map>();
private readonly relayedLeadInboxFallbackKeys = new Map>();
+ private readonly liveLeadProcessMessages = new Map();
+ private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@@ -456,6 +494,14 @@ export class TeamProvisioningService {
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
) {}
+ setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
+ this.teamChangeEmitter = emitter;
+ }
+
+ getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
+ return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
+ }
+
async warmup(): Promise {
try {
const claudePath = await ClaudeBinaryResolver.resolve();
@@ -611,6 +657,7 @@ export class TeamProvisioningService {
provisioningComplete: false,
isLaunch: false,
fsPhase: 'waiting_config',
+ leadRelayCapture: null,
progress: {
runId,
teamName: request.teamName,
@@ -878,6 +925,7 @@ export class TeamProvisioningService {
provisioningComplete: false,
isLaunch: true,
fsPhase: 'waiting_members',
+ leadRelayCapture: null,
progress: {
runId,
teamName: request.teamName,
@@ -1177,9 +1225,28 @@ export class TeamProvisioningService {
}),
].join('\n');
+ const captureTimeoutMs = 60_000;
+ const capturePromise = new Promise((resolve, reject) => {
+ const timeoutHandle = setTimeout(() => {
+ reject(new Error('Timed out waiting for lead reply'));
+ }, captureTimeoutMs);
+ run.leadRelayCapture = {
+ leadName,
+ startedAt: nowIso(),
+ textParts: [],
+ resolve,
+ reject,
+ timeoutHandle,
+ };
+ });
+
try {
await this.sendMessageToTeam(teamName, message);
} catch {
+ if (run.leadRelayCapture) {
+ clearTimeout(run.leadRelayCapture.timeoutHandle);
+ run.leadRelayCapture = null;
+ }
return 0;
}
@@ -1199,6 +1266,36 @@ export class TeamProvisioningService {
// Best-effort: relay succeeded; marking read failed.
}
+ let replyText: string | null = null;
+ try {
+ replyText = (await capturePromise).trim() || null;
+ } catch {
+ // ignore
+ } finally {
+ if (run.leadRelayCapture) {
+ clearTimeout(run.leadRelayCapture.timeoutHandle);
+ run.leadRelayCapture = null;
+ }
+ }
+
+ if (replyText) {
+ this.pushLiveLeadProcessMessage(teamName, {
+ from: leadName,
+ to: 'user',
+ text: replyText,
+ timestamp: nowIso(),
+ read: true,
+ summary: 'Lead reply',
+ messageId: `lead-process-${runId}-${Date.now()}`,
+ source: 'lead_process',
+ });
+ this.teamChangeEmitter?.({
+ type: 'inbox',
+ teamName,
+ detail: 'lead-process-reply',
+ });
+ }
+
return batch.length;
})();
@@ -1297,13 +1394,23 @@ export class TeamProvisioningService {
return next;
}
+ private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
+ const MAX = 100;
+ const list = this.liveLeadProcessMessages.get(teamName) ?? [];
+ list.push(message);
+ if (list.length > MAX) {
+ list.splice(0, list.length - MAX);
+ }
+ this.liveLeadProcessMessages.set(teamName, list);
+ }
+
/**
* Stop the running process for a team. No-op if team is not running.
*/
stopTeam(teamName: string): void {
const runId = this.activeByTeam.get(teamName);
if (!runId) {
- throw new Error(`No active process for team "${teamName}"`);
+ return;
}
const run = this.runs.get(runId);
if (!run) {
@@ -1336,6 +1443,9 @@ export class TeamProvisioningService {
if (textParts.length > 0) {
const text = textParts.join('');
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
+ if (run.leadRelayCapture) {
+ run.leadRelayCapture.textParts.push(text);
+ }
}
}
@@ -1343,6 +1453,11 @@ export class TeamProvisioningService {
const subtype = msg.subtype as string | undefined;
if (subtype === 'success') {
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
+ if (run.leadRelayCapture) {
+ const capture = run.leadRelayCapture;
+ const combined = capture.textParts.join('').trim();
+ capture.resolve(combined);
+ }
if (!run.provisioningComplete) {
void this.handleProvisioningTurnComplete(run);
}
@@ -1350,6 +1465,9 @@ export class TeamProvisioningService {
const errorMsg =
typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown');
logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`);
+ if (run.leadRelayCapture) {
+ run.leadRelayCapture.reject(errorMsg);
+ }
if (!run.provisioningComplete) {
const progress = updateProgress(
run,
@@ -1454,6 +1572,7 @@ export class TeamProvisioningService {
this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName);
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
+ this.liveLeadProcessMessages.delete(run.teamName);
}
/**
diff --git a/src/main/services/team/inboxLock.ts b/src/main/services/team/inboxLock.ts
index 96ce677c..a7a79e33 100644
--- a/src/main/services/team/inboxLock.ts
+++ b/src/main/services/team/inboxLock.ts
@@ -1,19 +1,19 @@
-const writeLocks = new Map>();
+const WRITE_LOCKS = new Map>();
export async function withInboxLock(inboxPath: string, fn: () => Promise): Promise {
- const prev = writeLocks.get(inboxPath) ?? Promise.resolve();
+ const prev = WRITE_LOCKS.get(inboxPath) ?? Promise.resolve();
let release!: () => void;
const mine = new Promise((resolve) => {
release = resolve;
});
- writeLocks.set(inboxPath, mine);
+ WRITE_LOCKS.set(inboxPath, mine);
await prev;
try {
return await fn();
} finally {
release();
- if (writeLocks.get(inboxPath) === mine) {
- writeLocks.delete(inboxPath);
+ if (WRITE_LOCKS.get(inboxPath) === mine) {
+ WRITE_LOCKS.delete(inboxPath);
}
}
}
diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts
index 2a92463c..dc9c6d34 100644
--- a/src/main/utils/pathValidation.ts
+++ b/src/main/utils/pathValidation.ts
@@ -198,6 +198,45 @@ export function validateFilePath(
return { valid: true, normalizedPath };
}
+/**
+ * Validates a path for opening when it was explicitly chosen by the user
+ * via the system folder picker. Only checks sensitive patterns, not
+ * allowed-directories (project / ~/.claude).
+ *
+ * @param targetPath - The path to open
+ * @returns Validation result
+ */
+export function validateOpenPathUserSelected(targetPath: string): PathValidationResult {
+ if (!targetPath || typeof targetPath !== 'string') {
+ return { valid: false, error: 'Invalid path' };
+ }
+
+ const expandedPath = targetPath.startsWith('~')
+ ? path.join(os.homedir(), targetPath.slice(1))
+ : targetPath;
+
+ const normalizedPath = path.resolve(path.normalize(expandedPath));
+
+ if (!path.isAbsolute(normalizedPath)) {
+ return { valid: false, error: 'Path must be absolute' };
+ }
+
+ if (matchesSensitivePattern(normalizedPath)) {
+ return { valid: false, error: 'Cannot open sensitive files' };
+ }
+
+ const realTargetPath = resolveRealPathIfExists(normalizedPath);
+ if (realTargetPath) {
+ const isWindows = process.platform === 'win32';
+ const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows);
+ if (matchesSensitivePattern(normalizedRealTarget)) {
+ return { valid: false, error: 'Cannot open sensitive files' };
+ }
+ }
+
+ return { valid: true, normalizedPath };
+}
+
/**
* Validates a path for shell:openPath operation.
* More permissive than file reading - allows opening project directories
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 95821a82..99b3533f 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -388,8 +388,8 @@ const electronAPI: ElectronAPI = {
},
// Shell operations
- openPath: (targetPath: string, projectRoot?: string) =>
- ipcRenderer.invoke('shell:openPath', targetPath, projectRoot),
+ openPath: (targetPath: string, projectRoot?: string, userSelectedFromDialog?: boolean) =>
+ ipcRenderer.invoke('shell:openPath', targetPath, projectRoot, userSelectedFromDialog),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
// Window controls (when title bar is hidden, e.g. Windows / Linux)
diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx
index 3189abdb..3e57503b 100644
--- a/src/renderer/components/dashboard/DashboardView.tsx
+++ b/src/renderer/components/dashboard/DashboardView.tsx
@@ -214,6 +214,40 @@ const RepositoryCard = ({
·
{lastActivity}
+
+ {/* Tasks progress bar */}
+ {taskCounts &&
+ (() => {
+ const pending = taskCounts.pending ?? 0;
+ const inProgress = taskCounts.inProgress ?? 0;
+ const completed = taskCounts.completed ?? 0;
+ const totalTasks = pending + inProgress + completed;
+ if (totalTasks === 0) return null;
+ const completedRatio = completed / totalTasks;
+ const progressPercent = Math.round(completedRatio * 100);
+ return (
+
+
+
+
+ {completed}/{totalTasks}
+
+
+
+ );
+ })()}
);
};
@@ -250,7 +284,7 @@ const NewProjectCard = (): React.JSX.Element => {
}
// No match found - open the folder in file manager as fallback
- const result = await api.openPath(selectedPath);
+ const result = await api.openPath(selectedPath, undefined, true);
if (!result.success) {
logger.error('Failed to open folder:', result.error);
}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index f6f15bf7..dfd91893 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -827,6 +827,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
await sendTeamMessage(teamName, { member, text, summary });
} catch {
setPendingRepliesByMember((prev) => {
+ if (prev[member] !== sentAtMs) return prev;
const next = { ...prev };
delete next[member];
return next;
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx
index b80e51e0..aa48443b 100644
--- a/src/renderer/components/team/TeamListView.tsx
+++ b/src/renderer/components/team/TeamListView.tsx
@@ -250,6 +250,8 @@ export const TeamListView = (): React.JSX.Element => {
try {
await api.teams.stop(teamName);
setAliveTeams((prev) => prev.filter((n) => n !== teamName));
+ } catch (err) {
+ console.error('Failed to stop team:', err);
} finally {
setStoppingTeamName(null);
}
@@ -415,12 +417,26 @@ export const TeamListView = (): React.JSX.Element => {
{filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
+ const matchesCurrentProject =
+ !!currentProjectPath &&
+ (() => {
+ if (team.projectPath && normalizePath(team.projectPath) === currentProjectPath)
+ return true;
+ return (
+ team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
+ false
+ );
+ })();
return (
{/* Header — clickable when system message to toggle expand */}
-
setIsExpanded((v) => !v) : undefined}
onKeyDown={
systemLabel
@@ -216,7 +216,7 @@ export const ActivityItem = ({
/>
) : null}
- {message.source === 'lead_session' ? (
+ {message.source === 'lead_session' || message.source === 'lead_process' ? (
) : (
@@ -275,6 +275,10 @@ export const ActivityItem = ({
session
+ ) : message.source === 'lead_process' ? (
+
+ live
+
) : null}
{/* Recipient — badge like sender, clickable to open member popup */}
@@ -370,7 +374,7 @@ export const ActivityItem = ({
{timestamp}
-
+
{/* Content — collapsed for system messages, expanded for others */}
{isExpanded ? (
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
index c88de2c3..d2b2bcb1 100644
--- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
@@ -148,12 +148,16 @@ export const TaskCommentsSection = ({
{reply ? (
) : (
)}
{showCollapsed && (
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 26da5ba7..a7c5594e 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -139,6 +139,12 @@ export const MemberCard = ({
e.stopPropagation();
onOpenTask?.();
}}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }}
>
#{currentTask.id} {currentTask.subject.slice(0, 36)}
{currentTask.subject.length > 36 ? '…' : ''}
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index ef1df5f2..08c5b5d7 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -462,7 +462,8 @@ export interface ElectronAPI {
// Shell operations
openPath: (
targetPath: string,
- projectRoot?: string
+ projectRoot?: string,
+ userSelectedFromDialog?: boolean
) => Promise<{ success: boolean; error?: string }>;
openExternal: (url: string) => Promise<{ success: boolean; error?: string }>;
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index fd041a77..54557af1 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -78,7 +78,7 @@ export interface InboxMessage {
summary?: string;
color?: string;
messageId?: string;
- source?: 'inbox' | 'lead_session';
+ source?: 'inbox' | 'lead_session' | 'lead_process';
}
export interface SendMessageRequest {
diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts
index b898cf7d..2f3e0b17 100644
--- a/test/main/ipc/teams.test.ts
+++ b/test/main/ipc/teams.test.ts
@@ -109,6 +109,8 @@ describe('ipc teams handlers', () => {
launchTeam: vi.fn(async () => ({ runId: 'run-2' })),
sendMessageToTeam: vi.fn(async () => undefined),
isTeamAlive: vi.fn(() => true),
+ relayLeadInboxMessages: vi.fn(async () => 0),
+ getLiveLeadProcessMessages: vi.fn(() => []),
getAliveTeams: vi.fn(() => ['my-team']),
stopTeam: vi.fn(() => undefined),
};
@@ -207,6 +209,43 @@ describe('ipc teams handlers', () => {
});
});
+ it('dedups live lead replies when lead_session already has same text', async () => {
+ service.getTeamData.mockResolvedValueOnce({
+ teamName: 'my-team',
+ messages: [
+ {
+ from: 'team-lead',
+ to: 'user',
+ text: 'Hello there',
+ timestamp: '2026-02-23T10:00:00.000Z',
+ read: true,
+ source: 'lead_session',
+ },
+ ],
+ });
+ provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
+ {
+ from: 'team-lead',
+ to: 'user',
+ text: 'Hello there',
+ timestamp: '2026-02-23T10:00:01.000Z',
+ read: true,
+ source: 'lead_process',
+ messageId: 'live-1',
+ },
+ ]);
+
+ const getDataHandler = handlers.get(TEAM_GET_DATA)!;
+ const result = (await getDataHandler({} as never, 'my-team')) as {
+ success: boolean;
+ data: { messages: { source?: string }[] };
+ };
+ expect(result.success).toBe(true);
+ const sources = result.data.messages.map((m) => m.source);
+ expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
+ expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
+ });
+
describe('createTask prompt validation', () => {
it('accepts valid prompt string', async () => {
const handler = handlers.get(TEAM_CREATE_TASK)!;
diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts
index a2df78ed..6240114a 100644
--- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts
+++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts
@@ -90,11 +90,27 @@ function attachAliveRun(
processKilled: false,
cancelRequested: false,
provisioningComplete: true,
+ leadRelayCapture: null,
});
return { writeSpy };
}
+async function waitForCapture(service: TeamProvisioningService): Promise {
+ const runs = (service as unknown as { runs: Map }).runs;
+ const run = runs.get('run-1') as any;
+ for (let i = 0; i < 50; i++) {
+ if (run?.leadRelayCapture) return run;
+ // Progress async awaits in relayLeadInboxMessages
+ await Promise.resolve();
+ }
+ for (let i = 0; i < 50; i++) {
+ if (run?.leadRelayCapture) return run;
+ await new Promise((r) => setTimeout(r, 0));
+ }
+ return run;
+}
+
describe('TeamProvisioningService relayLeadInboxMessages', () => {
beforeEach(() => {
hoisted.files.clear();
@@ -119,13 +135,24 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
]);
const { writeSpy } = attachAliveRun(service, teamName);
- const relayed = await service.relayLeadInboxMessages(teamName);
+
+ const relayPromise = service.relayLeadInboxMessages(teamName);
+ const run = await waitForCapture(service);
+ expect(run?.leadRelayCapture).toBeTruthy();
+ (service as any).handleStreamJsonMessage(run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'OK, will do.' }],
+ });
+ (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
+
+ const relayed = await relayPromise;
expect(relayed).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('"type":"user"');
expect(payload).toContain('Please assign this to Alice.');
+ expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
});
it('dedups by messageId even if markRead fails', async () => {
@@ -146,7 +173,15 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
hoisted.setAtomicWriteShouldFail(true);
const { writeSpy } = attachAliveRun(service, teamName);
- const first = await service.relayLeadInboxMessages(teamName);
+ const firstPromise = service.relayLeadInboxMessages(teamName);
+ const run = await waitForCapture(service);
+ expect(run?.leadRelayCapture).toBeTruthy();
+ (service as any).handleStreamJsonMessage(run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Acknowledged.' }],
+ });
+ (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
+ const first = await firstPromise;
const second = await service.relayLeadInboxMessages(teamName);
expect(first).toBe(1);
@@ -180,9 +215,18 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
processKilled: false,
cancelRequested: false,
provisioningComplete: true,
+ leadRelayCapture: null,
});
- const second = await service.relayLeadInboxMessages(teamName);
+ const secondPromise = service.relayLeadInboxMessages(teamName);
+ const run = await waitForCapture(service);
+ expect(run?.leadRelayCapture).toBeTruthy();
+ (service as any).handleStreamJsonMessage(run, {
+ type: 'assistant',
+ content: [{ type: 'text', text: 'Hi.' }],
+ });
+ (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
+ const second = await secondPromise;
expect(second).toBe(1);
expect(writeSpy).toHaveBeenCalledTimes(1);
});
diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts
index d0de930c..430e1e6e 100644
--- a/test/main/utils/pathValidation.test.ts
+++ b/test/main/utils/pathValidation.test.ts
@@ -13,6 +13,7 @@ import {
isPathWithinAllowedDirectories,
validateFilePath,
validateOpenPath,
+ validateOpenPathUserSelected,
} from '../../../src/main/utils/pathValidation';
describe('pathValidation', () => {
@@ -299,4 +300,25 @@ describe('pathValidation', () => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
});
+
+ describe('validateOpenPathUserSelected', () => {
+ it('should allow path outside project when chosen by user', () => {
+ const outsidePath = path.join(homeDir, 'some-other-project');
+ const result = validateOpenPathUserSelected(outsidePath);
+ expect(result.valid).toBe(true);
+ expect(result.normalizedPath).toBe(path.resolve(outsidePath));
+ });
+
+ it('should reject sensitive paths', () => {
+ const result = validateOpenPathUserSelected(path.join(homeDir, '.ssh', 'id_rsa'));
+ expect(result.valid).toBe(false);
+ expect(result.error).toBe('Cannot open sensitive files');
+ });
+
+ it('should reject empty path', () => {
+ const result = validateOpenPathUserSelected('');
+ expect(result.valid).toBe(false);
+ expect(result.error).toBe('Invalid path');
+ });
+ });
});