diff --git a/README.md b/README.md
index 0ffe93dc..c3e70e24 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.
-
+
diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts
index 14b888e0..fe90e437 100644
--- a/src/main/services/infrastructure/FileWatcher.ts
+++ b/src/main/services/infrastructure/FileWatcher.ts
@@ -485,7 +485,14 @@ export class FileWatcher extends EventEmitter {
watcher: fs.FSWatcher,
watcherType: 'projects' | 'todos' | 'teams' | 'tasks'
): void {
- watcher.on('error', (error) => {
+ watcher.on('error', (error: NodeJS.ErrnoException) => {
+ // Ephemeral .lock files cause harmless ENOENT when the recursive watcher
+ // tries to scandir a path that was already deleted. Log as debug and skip
+ // the teardown/retry — the watcher is still healthy.
+ if (error.code === 'ENOENT' && error.path?.endsWith('.lock')) {
+ logger.debug(`FileWatcher: ${watcherType} ignoring transient ENOENT on lock file`);
+ return;
+ }
logger.error(`FileWatcher: ${watcherType} watcher error:`, error);
if (watcherType === 'projects') {
this.projectsWatcher = null;
diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts
index 14599520..c3ca6bb5 100644
--- a/src/main/services/team/TeamLaunchStateEvaluator.ts
+++ b/src/main/services/team/TeamLaunchStateEvaluator.ts
@@ -213,13 +213,39 @@ export function createPersistedLaunchSnapshot(params: {
)
);
const members = params.members ?? {};
+ const launchPhase = params.launchPhase ?? 'active';
+
+ // When the launch is over (finished/reconciled), members still in 'starting' state
+ // (never spawned — agentToolAccepted is false) are unreachable and should be marked
+ // as failed. Without this, they stay as 'pending' forever, causing the UI to show
+ // "Last launch is still reconciling" indefinitely after a crash or incomplete launch.
+ if (launchPhase !== 'active') {
+ for (const name of expectedMembers) {
+ const member = members[name];
+ if (
+ member &&
+ member.launchState === 'starting' &&
+ !member.agentToolAccepted &&
+ !member.runtimeAlive &&
+ !member.bootstrapConfirmed &&
+ !member.hardFailure
+ ) {
+ member.hardFailure = true;
+ member.hardFailureReason =
+ member.hardFailureReason ?? 'Teammate was never spawned during launch.';
+ member.launchState = deriveMemberLaunchState(member);
+ member.diagnostics = buildDiagnostics(member);
+ }
+ }
+ }
+
const summary = summarizePersistedLaunchMembers(expectedMembers, members);
return {
version: 2,
teamName: params.teamName,
updatedAt,
...(params.leadSessionId ? { leadSessionId: params.leadSessionId } : {}),
- launchPhase: params.launchPhase ?? 'active',
+ launchPhase,
expectedMembers,
members,
summary,
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 1dc1a7ba..81072cb9 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -4860,11 +4860,59 @@ export class TeamProvisioningService {
// so the lead retains full context of prior work.
// When clearContext is true, skip resume entirely to start a fresh session.
let previousSessionId: string | undefined;
+ let skipResume = false;
if (request.clearContext) {
+ skipResume = true;
logger.info(
`[${request.teamName}] clearContext requested — skipping session resume, starting fresh`
);
} else {
+ // Check persisted launch state: if the previous launch ended with no teammates
+ // ever spawned (all in 'starting' state), resuming would reconnect the lead but
+ // the CLI's deterministic bootstrap won't re-spawn dead teammates in reconnect
+ // mode. Skip resume so the CLI creates a fresh session that fully bootstraps.
+ const persistedLaunchState = await this.launchStateStore.read(request.teamName);
+ if (persistedLaunchState) {
+ const {
+ expectedMembers: prevExpected,
+ members: prevMembers,
+ launchPhase,
+ } = persistedLaunchState;
+ const teammateWasNeverSpawned = (
+ member:
+ | {
+ agentToolAccepted?: boolean;
+ firstSpawnAcceptedAt?: string;
+ runtimeAlive?: boolean;
+ bootstrapConfirmed?: boolean;
+ }
+ | undefined
+ ): boolean => {
+ if (!member) return true;
+ const hasAcceptedSpawn =
+ member.agentToolAccepted === true ||
+ (typeof member.firstSpawnAcceptedAt === 'string' &&
+ member.firstSpawnAcceptedAt.trim().length > 0);
+ return (
+ !hasAcceptedSpawn &&
+ member.runtimeAlive !== true &&
+ member.bootstrapConfirmed !== true
+ );
+ };
+ const allTeammatesNeverSpawned =
+ launchPhase !== 'active' &&
+ prevExpected.length > 0 &&
+ prevExpected.every((name) => teammateWasNeverSpawned(prevMembers[name]));
+ if (allTeammatesNeverSpawned) {
+ skipResume = true;
+ logger.info(
+ `[${request.teamName}] Previous launch had no teammates successfully spawned — ` +
+ `skipping session resume to allow full bootstrap`
+ );
+ }
+ }
+ }
+ if (!skipResume) {
try {
const configParsed = JSON.parse(configRaw) as Record;
const resumeGuard = shouldSkipResumeForProviderRuntimeChange(request, configParsed);
diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx
index cec304fb..ec905ee0 100644
--- a/src/renderer/components/extensions/ExtensionStoreView.tsx
+++ b/src/renderer/components/extensions/ExtensionStoreView.tsx
@@ -18,6 +18,7 @@ import {
import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
import { useStore } from '@renderer/store';
+import { resolveProjectPathById } from '@renderer/utils/projectLookup';
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -45,6 +46,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
openDashboard,
sessions,
projects,
+ repositoryGroups,
} = useStore(
useShallow((s) => ({
fetchPluginCatalog: s.fetchPluginCatalog,
@@ -61,6 +63,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
openDashboard: s.openDashboard,
sessions: s.sessions,
projects: s.projects,
+ repositoryGroups: s.repositoryGroups,
}))
);
const cliInstalled = cliStatus?.installed ?? true;
@@ -74,14 +77,12 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const tabState = useExtensionsTabState();
const [customMcpDialogOpen, setCustomMcpDialogOpen] = useState(false);
- const projectPath = useMemo(
- () => projects.find((project) => project.id === extensionsTabProjectId)?.path ?? null,
- [extensionsTabProjectId, projects]
- );
- const projectLabel = useMemo(
- () => projects.find((project) => project.id === extensionsTabProjectId)?.name ?? null,
- [extensionsTabProjectId, projects]
+ const resolvedProject = useMemo(
+ () => resolveProjectPathById(extensionsTabProjectId, projects, repositoryGroups),
+ [extensionsTabProjectId, projects, repositoryGroups]
);
+ const projectPath = resolvedProject?.path ?? null;
+ const projectLabel = resolvedProject?.name ?? null;
const subTabs = useMemo(
() => [
{
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index dd0075b5..dcca38bb 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -575,6 +575,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
multimodelEnabled,
storedProviderId,
storedEffort: storedEffort === null ? 'medium' : storedEffort,
+ storedLimitContext: localStorage.getItem('team:lastLimitContext') === 'true',
getStoredModel: getStoredTeamModel,
});
setSavedLaunchProviderId(savedProviderId);
@@ -590,10 +591,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setSelectedProviderIdRaw(launchPrefill.providerId);
setSelectedModelRaw(launchPrefill.model);
setSelectedEffortRaw(launchPrefill.effort);
- setLimitContextRaw(
- savedRequest?.limitContext === true ||
- localStorage.getItem('team:lastLimitContext') === 'true'
- );
+ setLimitContextRaw(launchPrefill.limitContext);
setSkipPermissionsRaw(
savedRequest?.skipPermissions ??
localStorage.getItem('team:lastSkipPermissions') !== 'false'
diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
index c1aa208a..c0175dbf 100644
--- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx
+++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
@@ -26,6 +26,7 @@ import {
normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from '@renderer/utils/teamModelCatalog';
+import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { Info } from 'lucide-react';
export { getProviderScopedTeamModelLabel } from '@renderer/utils/teamModelCatalog';
@@ -99,8 +100,11 @@ export function computeEffectiveTeamModel(
limitContext: boolean,
providerId: 'anthropic' | 'codex' | 'gemini' = 'anthropic'
): string | undefined {
- const base = selectedModel || undefined;
- if (providerId !== 'anthropic') return base;
+ if (providerId !== 'anthropic') {
+ return selectedModel.trim() || undefined;
+ }
+
+ const base = extractProviderScopedBaseModel(selectedModel, providerId);
if (limitContext) return base;
if (base === 'haiku') return base;
return base ? `${base}[1m]` : 'opus[1m]';
diff --git a/src/renderer/components/team/dialogs/launchDialogPrefill.ts b/src/renderer/components/team/dialogs/launchDialogPrefill.ts
index 874a3164..5651a207 100644
--- a/src/renderer/components/team/dialogs/launchDialogPrefill.ts
+++ b/src/renderer/components/team/dialogs/launchDialogPrefill.ts
@@ -1,5 +1,6 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
+import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@@ -9,6 +10,7 @@ interface PreviousLaunchParamsLike {
providerId?: TeamProviderId;
model?: string;
effort?: string;
+ limitContext?: boolean;
}
interface LaunchDialogPrefillInput {
@@ -18,6 +20,7 @@ interface LaunchDialogPrefillInput {
multimodelEnabled: boolean;
storedProviderId: TeamProviderId;
storedEffort: string;
+ storedLimitContext: boolean;
getStoredModel: (providerId: TeamProviderId) => string;
}
@@ -25,14 +28,18 @@ interface LaunchDialogPrefillResult {
providerId: TeamProviderId;
model: string;
effort: string;
+ limitContext: boolean;
}
-function normalizeModelCandidate(model: string | undefined): string {
+function normalizeModelCandidate(
+ model: string | undefined,
+ providerId: TeamProviderId | undefined
+): string {
const trimmed = model?.trim() ?? '';
if (!trimmed || trimmed === 'default' || trimmed === '__default__') {
return '';
}
- return trimmed;
+ return extractProviderScopedBaseModel(trimmed, providerId) ?? '';
}
function canReuseModelForSelectedProvider(
@@ -52,6 +59,7 @@ export function resolveLaunchDialogPrefill({
multimodelEnabled,
storedProviderId,
storedEffort,
+ storedLimitContext,
getStoredModel,
}: LaunchDialogPrefillInput): LaunchDialogPrefillResult {
const currentLead = members.find((member) => isLeadMember(member));
@@ -69,15 +77,15 @@ export function resolveLaunchDialogPrefill({
const modelCandidates = [
{
providerId: currentLeadProviderId,
- model: normalizeModelCandidate(currentLead?.model),
+ model: normalizeModelCandidate(currentLead?.model, currentLeadProviderId),
},
{
providerId: savedRequestProviderId,
- model: normalizeModelCandidate(savedRequest?.model),
+ model: normalizeModelCandidate(savedRequest?.model, savedRequestProviderId),
},
{
providerId: previousLaunchProviderId,
- model: normalizeModelCandidate(previousLaunchParams?.model),
+ model: normalizeModelCandidate(previousLaunchParams?.model, previousLaunchProviderId),
},
];
@@ -88,6 +96,8 @@ export function resolveLaunchDialogPrefill({
const effort =
currentLead?.effort ?? savedRequest?.effort ?? previousLaunchParams?.effort ?? storedEffort;
+ const limitContext =
+ previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext;
return {
providerId,
@@ -95,5 +105,6 @@ export function resolveLaunchDialogPrefill({
? normalizeTeamModelForUi(providerId, matchingModel)
: getStoredModel(providerId),
effort,
+ limitContext,
};
}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index 7f79114c..eff3ceea 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -19,13 +19,19 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor';
+import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
+import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
import {
extractTaskRefsFromText,
stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
-import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
+import { parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
+import {
+ inferTeamProviderIdFromModel,
+ normalizeOptionalTeamProviderId,
+} from '@shared/utils/teamProvider';
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -205,20 +211,35 @@ export const MessageComposer = ({
})),
[members, colorMap]
);
+ const leadProviderId = useMemo(() => {
+ const lead = members.find((member) => isLeadMember(member));
+ return (
+ normalizeOptionalTeamProviderId(lead?.providerId) ?? inferTeamProviderIdFromModel(lead?.model)
+ );
+ }, [members]);
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
+ // Project skills as slash command suggestions
+ const projectSkills = useStore(
+ useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : []))
+ );
+ const userSkills = useStore(useShallow((s) => s.skillsUserCatalog));
+ const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
+
+ // Fetch skills catalog for the team's project on mount / project change
+ useEffect(() => {
+ void fetchSkillsCatalog(projectPath ?? undefined);
+ }, [fetchSkillsCatalog, projectPath]);
+
const slashCommandSuggestions = useMemo(
() =>
- KNOWN_SLASH_COMMANDS.map((command) => ({
- id: `command:${command.name}`,
- name: command.name,
- command: command.command,
- description: command.description,
- subtitle: command.description,
- type: 'command',
- })),
- []
+ buildSlashCommandSuggestions(
+ getSuggestedSlashCommandsForProvider(leadProviderId),
+ projectSkills,
+ userSkills
+ ),
+ [leadProviderId, projectSkills, userSkills]
);
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();
diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
index 3df66115..ee29d864 100644
--- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
+++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
@@ -89,7 +89,7 @@ function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDeta
...detail,
logDetail: {
...detail.logDetail,
- chunks: asEnhancedChunkArray(detail.logDetail.chunks),
+ chunks: asEnhancedChunkArray(detail.logDetail.chunks) ?? detail.logDetail.chunks,
},
};
}
diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx
index 141c9460..e64d6341 100644
--- a/src/renderer/components/ui/MentionSuggestionList.tsx
+++ b/src/renderer/components/ui/MentionSuggestionList.tsx
@@ -83,11 +83,12 @@ export const MentionSuggestionList = ({
}
// Categorize suggestions (folders are grouped with files)
- type Section = 'member' | 'team' | 'task' | 'file' | 'command';
+ type Section = 'member' | 'team' | 'task' | 'file' | 'command' | 'skill';
const getSuggestionSection = (s: MentionSuggestion): Section => {
if (s.type === 'file' || s.type === 'folder') return 'file';
if (s.type === 'task') return 'task';
if (s.type === 'command') return 'command';
+ if (s.type === 'skill') return 'skill';
if (s.type === 'team') return 'team';
return 'member';
};
@@ -98,6 +99,7 @@ export const MentionSuggestionList = ({
task: 'Tasks',
file: 'Files',
command: 'Commands',
+ skill: 'Skills',
};
// Determine which sections are present
@@ -117,6 +119,7 @@ export const MentionSuggestionList = ({
const isTeam = section === 'team';
const isTask = section === 'task';
const isCommand = section === 'command';
+ const isSkill = section === 'skill';
const taskTeamColorSet =
isTask && s.color
? getTeamColorSet(s.color)
@@ -165,6 +168,8 @@ export const MentionSuggestionList = ({
) : isCommand ? (
+ ) : isSkill ? (
+
) : isTeam ? (
@@ -218,7 +231,7 @@ export const MentionSuggestionList = ({
{isTask && s.subtitle ? (
{s.subtitle}
) : null}
- {isCommand && s.description ? (
+ {(isCommand || isSkill) && s.description ? (
{s.description}
diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts
index eb44adae..58063575 100644
--- a/src/renderer/store/slices/extensionsSlice.ts
+++ b/src/renderer/store/slices/extensionsSlice.ts
@@ -7,6 +7,7 @@ import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import type { AppState } from '../types';
+import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
import type {
ApiKeyEntry,
ApiKeySaveRequest,
@@ -889,9 +890,24 @@ export const createExtensionsSlice: StateCreator {
const state = get();
+ const currentProjectId = state.selectedProjectId ?? state.activeProjectId ?? undefined;
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
const existingTab = focusedPane?.tabs.find((tab) => tab.type === 'extensions');
if (existingTab) {
+ // Update projectId to reflect the currently selected project
+ if (existingTab.projectId !== currentProjectId) {
+ const pane = findPaneByTabId(state.paneLayout, existingTab.id);
+ if (pane) {
+ set({
+ paneLayout: updatePane(state.paneLayout, {
+ ...pane,
+ tabs: pane.tabs.map((t) =>
+ t.id === existingTab.id ? { ...t, projectId: currentProjectId } : t
+ ),
+ }),
+ });
+ }
+ }
state.setActiveTab(existingTab.id);
return;
}
@@ -899,7 +915,7 @@ export const createExtensionsSlice: StateCreator = (set,
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
// Persist per-team launch params (model, effort, limit context)
- const baseModel = extractBaseModel(request.model);
+ const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default',
@@ -2767,7 +2766,7 @@ export const createTeamSlice: StateCreator = (set,
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
// Persist per-team launch params (model, effort, limit context)
- const baseModel = extractBaseModel(request.model);
+ const baseModel = extractBaseModel(request.model, request.providerId);
const params: TeamLaunchParams = {
providerId: request.providerId ?? 'anthropic',
model: baseModel || 'default',
diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts
index a708abc8..f5150e30 100644
--- a/src/renderer/types/mention.ts
+++ b/src/renderer/types/mention.ts
@@ -9,8 +9,8 @@ export interface MentionSuggestion {
description?: string;
/** Color name from TeamColorSet palette */
color?: string;
- /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */
- type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command';
+ /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', 'command', or 'skill' */
+ type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command' | 'skill';
/** Whether the team is currently online (team suggestions only) */
isOnline?: boolean;
/** Absolute file/folder path (file/folder suggestions only) */
@@ -21,7 +21,7 @@ export interface MentionSuggestion {
insertText?: string;
/** Optional extra searchable text (subject, team name, path, etc.) */
searchText?: string;
- /** Optional slash command string including leading slash (command suggestions only) */
+ /** Optional slash command string including leading slash (command and skill suggestions only) */
command?: `/${string}`;
/** Canonical task id (task suggestions only) */
taskId?: string;
diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts
index a83e1bed..cc979085 100644
--- a/src/renderer/utils/mentionSuggestions.ts
+++ b/src/renderer/utils/mentionSuggestions.ts
@@ -2,12 +2,12 @@ import type { MentionSuggestion } from '@renderer/types/mention';
export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' {
if (suggestion.type === 'task') return '#';
- if (suggestion.type === 'command') return '/';
+ if (suggestion.type === 'command' || suggestion.type === 'skill') return '/';
return '@';
}
export function getSuggestionInsertionText(suggestion: MentionSuggestion): string {
- if (suggestion.type === 'command') {
+ if (suggestion.type === 'command' || suggestion.type === 'skill') {
return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name;
}
return suggestion.insertText ?? suggestion.name;
diff --git a/src/renderer/utils/projectLookup.ts b/src/renderer/utils/projectLookup.ts
index e641024c..f05e179d 100644
--- a/src/renderer/utils/projectLookup.ts
+++ b/src/renderer/utils/projectLookup.ts
@@ -35,3 +35,30 @@ export function resolveProjectIdByPath(
return null;
}
+
+/**
+ * Resolve a filesystem path from an encoded project ID.
+ *
+ * Inverse of `resolveProjectIdByPath`: given a project ID (e.g. `-home-diego-DEV-monorepo-rCAPI`),
+ * returns the decoded path (e.g. `/home/diego/DEV/monorepo/rCAPI`).
+ *
+ * Checks both `projects[]` and `repositoryGroups[].worktrees[]` so the result
+ * is correct regardless of whether the sidebar is in flat or grouped view mode.
+ */
+export function resolveProjectPathById(
+ projectId: string | undefined | null,
+ projects: readonly Pick[],
+ repositoryGroups: readonly Pick[]
+): { path: string; name: string } | null {
+ if (!projectId) return null;
+
+ const fromProjects = projects.find((p) => p.id === projectId);
+ if (fromProjects) return { path: fromProjects.path, name: fromProjects.name };
+
+ for (const group of repositoryGroups) {
+ const worktree = group.worktrees.find((w) => w.id === projectId);
+ if (worktree) return { path: worktree.path, name: worktree.name };
+ }
+
+ return null;
+}
diff --git a/src/renderer/utils/providerSlashCommands.ts b/src/renderer/utils/providerSlashCommands.ts
new file mode 100644
index 00000000..d7cc66af
--- /dev/null
+++ b/src/renderer/utils/providerSlashCommands.ts
@@ -0,0 +1,122 @@
+import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands';
+
+import type { TeamProviderId } from '@shared/types';
+import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
+
+const CODEX_SLASH_COMMAND_SUGGESTIONS: readonly KnownSlashCommandDefinition[] = [
+ {
+ name: 'model',
+ command: '/model',
+ description: 'Choose the active model for this session.',
+ },
+ {
+ name: 'fast',
+ command: '/fast',
+ description: 'Toggle Fast mode on or off.',
+ },
+ {
+ name: 'permissions',
+ command: '/permissions',
+ description: 'Adjust approval requirements for tools and commands.',
+ },
+ {
+ name: 'plan',
+ command: '/plan',
+ description: 'Switch to plan mode with an optional prompt.',
+ },
+ {
+ name: 'review',
+ command: '/review',
+ description: 'Ask Codex to review the current working tree.',
+ },
+ {
+ name: 'diff',
+ command: '/diff',
+ description: 'Show the current Git diff, including untracked files.',
+ },
+ {
+ name: 'status',
+ command: '/status',
+ description: 'Show session configuration and token usage.',
+ },
+ {
+ name: 'mcp',
+ command: '/mcp',
+ description: 'List configured MCP tools for this session.',
+ },
+ {
+ name: 'mention',
+ command: '/mention',
+ description: 'Attach a file or folder to the conversation.',
+ },
+ {
+ name: 'apps',
+ command: '/apps',
+ description: 'Browse available apps and connectors.',
+ },
+ {
+ name: 'plugins',
+ command: '/plugins',
+ description: 'Browse and manage installed plugins.',
+ },
+ {
+ name: 'agent',
+ command: '/agent',
+ description: 'Switch to another agent thread.',
+ },
+ {
+ name: 'personality',
+ command: '/personality',
+ description: 'Change Codex response style for the current thread.',
+ },
+ {
+ name: 'compact',
+ command: '/compact',
+ description: 'Summarize the conversation to free tokens.',
+ },
+ {
+ name: 'clear',
+ command: '/clear',
+ description: 'Clear the terminal and start a fresh chat.',
+ },
+ {
+ name: 'new',
+ command: '/new',
+ description: 'Start a new conversation in the current session.',
+ },
+ {
+ name: 'copy',
+ command: '/copy',
+ description: 'Copy the latest completed Codex output.',
+ },
+ {
+ name: 'fork',
+ command: '/fork',
+ description: 'Fork the current conversation into a new thread.',
+ },
+ {
+ name: 'resume',
+ command: '/resume',
+ description: 'Resume a previous conversation.',
+ },
+ {
+ name: 'quit',
+ command: '/quit',
+ description: 'Exit the CLI.',
+ },
+ {
+ name: 'exit',
+ command: '/exit',
+ description: 'Exit the CLI.',
+ },
+] as const;
+
+export function getSuggestedSlashCommandsForProvider(
+ providerId?: TeamProviderId
+): readonly KnownSlashCommandDefinition[] {
+ if (providerId === 'codex') {
+ return CODEX_SLASH_COMMAND_SUGGESTIONS;
+ }
+
+ return KNOWN_SLASH_COMMANDS;
+}
diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts
new file mode 100644
index 00000000..370df4e9
--- /dev/null
+++ b/src/renderer/utils/skillCommandSuggestions.ts
@@ -0,0 +1,49 @@
+import { isSupportedSlashCommandName } from '@shared/utils/slashCommands';
+
+import type { MentionSuggestion } from '@renderer/types/mention';
+import type { SkillCatalogItem } from '@shared/types/extensions';
+import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands';
+
+export function buildSlashCommandSuggestions(
+ builtIns: readonly KnownSlashCommandDefinition[],
+ projectSkills: readonly SkillCatalogItem[],
+ userSkills: readonly SkillCatalogItem[]
+): MentionSuggestion[] {
+ const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase()));
+ const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({
+ id: `command:${command.name}`,
+ name: command.name,
+ command: command.command,
+ description: command.description,
+ subtitle: command.description,
+ type: 'command',
+ }));
+
+ const seenSkillNames = new Set();
+ const skillSuggestions: MentionSuggestion[] = [];
+ for (const skill of [...projectSkills, ...userSkills]) {
+ const normalizedFolderName = skill.folderName.trim().toLowerCase();
+ if (
+ !skill.isValid ||
+ !normalizedFolderName ||
+ !isSupportedSlashCommandName(normalizedFolderName) ||
+ builtInNames.has(normalizedFolderName) ||
+ seenSkillNames.has(normalizedFolderName)
+ ) {
+ continue;
+ }
+
+ seenSkillNames.add(normalizedFolderName);
+ skillSuggestions.push({
+ id: `skill:${skill.id}`,
+ name: skill.folderName,
+ command: `/${normalizedFolderName}`,
+ description: skill.description,
+ subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill',
+ searchText: `${skill.name} ${skill.folderName}`,
+ type: 'skill',
+ });
+ }
+
+ return [...builtInSuggestions, ...skillSuggestions];
+}
diff --git a/src/renderer/utils/teamModelContext.ts b/src/renderer/utils/teamModelContext.ts
new file mode 100644
index 00000000..706dc691
--- /dev/null
+++ b/src/renderer/utils/teamModelContext.ts
@@ -0,0 +1,32 @@
+import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
+
+import type { TeamProviderId } from '@shared/types';
+
+export function stripTrailingOneMillionSuffixes(model: string | undefined): string | undefined {
+ const trimmed = model?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
+ return trimmed.replace(/(?:\[1m\])+$/, '') || undefined;
+}
+
+export function extractProviderScopedBaseModel(
+ model: string | undefined,
+ providerId?: TeamProviderId
+): string | undefined {
+ const trimmed = model?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
+ const effectiveProviderId =
+ providerId ??
+ inferTeamProviderIdFromModel(trimmed) ??
+ inferTeamProviderIdFromModel(stripTrailingOneMillionSuffixes(trimmed));
+ if (effectiveProviderId !== 'anthropic') {
+ return trimmed;
+ }
+
+ return stripTrailingOneMillionSuffixes(trimmed);
+}
diff --git a/src/shared/utils/slashCommands.ts b/src/shared/utils/slashCommands.ts
index 35e4f19b..e06dd174 100644
--- a/src/shared/utils/slashCommands.ts
+++ b/src/shared/utils/slashCommands.ts
@@ -15,6 +15,7 @@ export interface ParsedStandaloneSlashCommand {
endIndex: number;
}
+const SLASH_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9:-]{0,63}$/i;
const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i;
export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [
@@ -78,6 +79,10 @@ export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition
return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null;
}
+export function isSupportedSlashCommandName(name: string): boolean {
+ return SLASH_COMMAND_NAME_PATTERN.test(name.trim());
+}
+
export function buildSlashCommandMeta(
name: string,
args?: string,
diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts
index 834437f9..95ff14cd 100644
--- a/test/main/services/team/TeamProvisioningService.test.ts
+++ b/test/main/services/team/TeamProvisioningService.test.ts
@@ -51,6 +51,8 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
});
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
+import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
+import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { spawnCli } from '@main/utils/childProcess';
import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller';
@@ -84,6 +86,63 @@ function createRunningChild() {
});
}
+function writeLaunchConfig(
+ teamName: string,
+ projectPath: string,
+ leadSessionId: string,
+ members: string[]
+): void {
+ const teamDir = path.join(tempTeamsBase, teamName);
+ fs.mkdirSync(teamDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(teamDir, 'config.json'),
+ JSON.stringify({
+ name: teamName,
+ projectPath,
+ leadSessionId,
+ members: [
+ { name: 'team-lead', agentType: 'team-lead' },
+ ...members.map((name) => ({ name })),
+ ],
+ }),
+ 'utf8'
+ );
+}
+
+function writeLaunchState(
+ teamName: string,
+ leadSessionId: string,
+ members: Record>
+): void {
+ const snapshot = createPersistedLaunchSnapshot({
+ teamName,
+ leadSessionId,
+ launchPhase: 'finished',
+ expectedMembers: Object.keys(members),
+ members: Object.fromEntries(
+ Object.entries(members).map(([name, member]) => [
+ name,
+ {
+ name,
+ launchState: 'failed_to_start',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: 'Teammate was never spawned during launch.',
+ lastEvaluatedAt: new Date().toISOString(),
+ ...member,
+ },
+ ])
+ ) as any,
+ });
+ fs.writeFileSync(
+ getTeamLaunchStatePath(teamName),
+ `${JSON.stringify(snapshot, null, 2)}\n`,
+ 'utf8'
+ );
+}
+
describe('TeamProvisioningService', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -100,7 +159,6 @@ describe('TeamProvisioningService', () => {
fs.mkdirSync(tempProjectsBase, { recursive: true });
});
-
afterEach(() => {
vi.useRealTimers();
try {
@@ -389,14 +447,12 @@ describe('TeamProvisioningService', () => {
it('expands teammate permission suggestions to the operational tool set only', async () => {
allowConsoleLogs();
- const svc = new TeamProvisioningService(
- {
- getConfig: vi.fn(async () => ({
- projectPath: tempClaudeRoot,
- members: [{ cwd: tempClaudeRoot }],
- })),
- } as any
- );
+ const svc = new TeamProvisioningService({
+ getConfig: vi.fn(async () => ({
+ projectPath: tempClaudeRoot,
+ members: [{ cwd: tempClaudeRoot }],
+ })),
+ } as any);
await (svc as any).respondToTeammatePermission(
{ teamName: 'ops-team' },
@@ -427,14 +483,12 @@ describe('TeamProvisioningService', () => {
it('does not broaden admin/runtime teammate permission suggestions', async () => {
allowConsoleLogs();
- const svc = new TeamProvisioningService(
- {
- getConfig: vi.fn(async () => ({
- projectPath: tempClaudeRoot,
- members: [{ cwd: tempClaudeRoot }],
- })),
- } as any
- );
+ const svc = new TeamProvisioningService({
+ getConfig: vi.fn(async () => ({
+ projectPath: tempClaudeRoot,
+ members: [{ cwd: tempClaudeRoot }],
+ })),
+ } as any);
await (svc as any).respondToTeammatePermission(
{ teamName: 'ops-team' },
@@ -516,4 +570,107 @@ describe('TeamProvisioningService', () => {
})
).toBe('Questions (2): First question with extra spacing.');
});
+
+ it('skips --resume when the persisted launch state shows no teammate ever spawned', async () => {
+ allowConsoleLogs();
+ const teamName = 'resume-skip-team';
+ const leadSessionId = 'lead-session-skip';
+ writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']);
+ writeLaunchState(teamName, leadSessionId, {
+ alice: {
+ launchState: 'failed_to_start',
+ },
+ bob: {
+ launchState: 'starting',
+ hardFailure: false,
+ },
+ });
+
+ vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
+ vi.mocked(spawnCli).mockImplementation(() => {
+ throw new Error('launch spawn EINVAL');
+ });
+
+ const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
+ writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
+ removeConfigFile: vi.fn(async () => {}),
+ } as any);
+ (svc as any).buildProvisioningEnv = vi.fn(async () => ({
+ env: { ANTHROPIC_API_KEY: 'test' },
+ authSource: 'anthropic_api_key',
+ }));
+ (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
+ members: [{ name: 'alice' }, { name: 'bob' }],
+ source: 'members-meta',
+ warning: undefined,
+ }));
+ (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
+ (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
+ (svc as any).updateConfigProjectPath = vi.fn(async () => {});
+ (svc as any).restorePrelaunchConfig = vi.fn(async () => {});
+ (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
+ (svc as any).pathExists = vi.fn(async (targetPath: string) =>
+ targetPath.endsWith(`${leadSessionId}.jsonl`)
+ );
+
+ await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
+ 'launch spawn EINVAL'
+ );
+
+ const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
+ expect(launchArgs).toBeTruthy();
+ expect(launchArgs).not.toContain('--resume');
+ expect(launchArgs).not.toContain(leadSessionId);
+ });
+
+ it('keeps --resume when a teammate had an accepted spawn before failing bootstrap', async () => {
+ allowConsoleLogs();
+ const teamName = 'resume-keep-team';
+ const leadSessionId = 'lead-session-keep';
+ const acceptedAt = '2026-04-14T12:00:00.000Z';
+ writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']);
+ writeLaunchState(teamName, leadSessionId, {
+ alice: {
+ launchState: 'failed_to_start',
+ agentToolAccepted: true,
+ firstSpawnAcceptedAt: acceptedAt,
+ hardFailureReason: 'Teammate did not join within the launch grace window.',
+ },
+ });
+
+ vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
+ vi.mocked(spawnCli).mockImplementation(() => {
+ throw new Error('launch spawn EINVAL');
+ });
+
+ const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
+ writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
+ removeConfigFile: vi.fn(async () => {}),
+ } as any);
+ (svc as any).buildProvisioningEnv = vi.fn(async () => ({
+ env: { ANTHROPIC_API_KEY: 'test' },
+ authSource: 'anthropic_api_key',
+ }));
+ (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
+ members: [{ name: 'alice' }],
+ source: 'members-meta',
+ warning: undefined,
+ }));
+ (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
+ (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
+ (svc as any).updateConfigProjectPath = vi.fn(async () => {});
+ (svc as any).restorePrelaunchConfig = vi.fn(async () => {});
+ (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
+ (svc as any).pathExists = vi.fn(async (targetPath: string) =>
+ targetPath.endsWith(`${leadSessionId}.jsonl`)
+ );
+
+ await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
+ 'launch spawn EINVAL'
+ );
+
+ const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
+ expect(launchArgs).toContain('--resume');
+ expect(launchArgs).toContain(leadSessionId);
+ });
});
diff --git a/test/renderer/components/team/TeamModelSelector.test.ts b/test/renderer/components/team/TeamModelSelector.test.ts
index 87ae7485..f441903a 100644
--- a/test/renderer/components/team/TeamModelSelector.test.ts
+++ b/test/renderer/components/team/TeamModelSelector.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
+import {
+ computeEffectiveTeamModel,
+ formatTeamModelSummary,
+} from '@renderer/components/team/dialogs/TeamModelSelector';
import {
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
@@ -36,3 +39,35 @@ describe('formatTeamModelSummary', () => {
expect(normalizeTeamModelForUi('codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini');
});
});
+
+describe('computeEffectiveTeamModel', () => {
+ it('appends [1m] for anthropic models', () => {
+ expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]');
+ expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]');
+ });
+
+ it('does not double-append [1m] when input already has it', () => {
+ expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
+ expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]');
+ expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
+ });
+
+ it('defaults to opus[1m] when no model selected', () => {
+ expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]');
+ });
+
+ it('returns base model without [1m] when limitContext is true', () => {
+ expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus');
+ expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
+ expect(computeEffectiveTeamModel('opus[1m][1m]', true, 'anthropic')).toBe('opus');
+ });
+
+ it('returns haiku as-is', () => {
+ expect(computeEffectiveTeamModel('haiku', false, 'anthropic')).toBe('haiku');
+ });
+
+ it('returns non-anthropic models as-is', () => {
+ expect(computeEffectiveTeamModel('gpt-5.4', false, 'codex')).toBe('gpt-5.4');
+ expect(computeEffectiveTeamModel('custom-model[1m]', false, 'codex')).toBe('custom-model[1m]');
+ });
+});
diff --git a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts
index 2b1d4d58..35ca0b67 100644
--- a/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts
+++ b/test/renderer/components/team/dialogs/launchDialogPrefill.test.ts
@@ -38,6 +38,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
+ storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
codex: 'gpt-5.4',
@@ -48,6 +49,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
+ limitContext: false,
});
});
@@ -64,7 +66,7 @@ describe('resolveLaunchDialogPrefill', () => {
const savedRequest = {
teamName: 'vector-room-2',
- cwd: '/tmp/project',
+ cwd: '/Users/test/project',
providerId: 'anthropic',
model: 'haiku',
effort: 'low',
@@ -78,6 +80,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
+ storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
codex: 'gpt-5.4',
@@ -88,6 +91,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerId: 'codex',
model: 'gpt-5.4',
effort: 'medium',
+ limitContext: false,
});
});
@@ -103,6 +107,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
+ storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
codex: 'gpt-5.4',
@@ -113,6 +118,7 @@ describe('resolveLaunchDialogPrefill', () => {
providerId: 'codex',
model: 'gpt-5.3-codex',
effort: 'high',
+ limitContext: false,
});
});
@@ -134,6 +140,7 @@ describe('resolveLaunchDialogPrefill', () => {
multimodelEnabled: true,
storedProviderId: 'anthropic',
storedEffort: 'medium',
+ storedLimitContext: false,
getStoredModel: createStoredModelGetter({
anthropic: 'haiku',
codex: 'gpt-5.4',
@@ -144,6 +151,61 @@ describe('resolveLaunchDialogPrefill', () => {
providerId: 'anthropic',
model: 'haiku',
effort: 'medium',
+ limitContext: false,
+ });
+ });
+
+ it('prefers per-team launch params for limitContext over stale global storage', () => {
+ const result = resolveLaunchDialogPrefill({
+ members: [],
+ savedRequest: null,
+ previousLaunchParams: {
+ providerId: 'anthropic',
+ model: 'opus[1m][1m]',
+ effort: 'high',
+ limitContext: true,
+ },
+ multimodelEnabled: true,
+ storedProviderId: 'anthropic',
+ storedEffort: 'medium',
+ storedLimitContext: false,
+ getStoredModel: createStoredModelGetter({
+ anthropic: 'haiku',
+ }),
+ });
+
+ expect(result).toEqual({
+ providerId: 'anthropic',
+ model: 'opus',
+ effort: 'high',
+ limitContext: true,
+ });
+ });
+
+ it('preserves literal [1m] suffixes for non-anthropic providers', () => {
+ const result = resolveLaunchDialogPrefill({
+ members: [],
+ savedRequest: null,
+ previousLaunchParams: {
+ providerId: 'codex',
+ model: 'custom-model[1m]',
+ effort: 'medium',
+ },
+ multimodelEnabled: true,
+ storedProviderId: 'anthropic',
+ storedEffort: 'medium',
+ storedLimitContext: false,
+ getStoredModel: createStoredModelGetter({
+ anthropic: 'haiku',
+ codex: 'gpt-5.4',
+ }),
+ });
+
+ expect(result).toEqual({
+ providerId: 'codex',
+ model: 'custom-model[1m]',
+ effort: 'medium',
+ limitContext: false,
});
});
});
diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts
index 00ad81c9..732bc396 100644
--- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts
+++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts
@@ -379,7 +379,7 @@ describe('TaskLogStreamSection integration', () => {
expect(text).toContain('Grep');
expect(text).toContain('Edit');
expect(text).toContain('Claude');
- expect(text).toContain('2 tool calls');
+ expect(text).toContain('3 tool calls');
expect(text).toContain('Audit complete');
expect(text).not.toContain('[]');
expect(text).not.toContain('lead session');
diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts
index 623a7b06..a69507fa 100644
--- a/test/renderer/store/extensionsSlice.test.ts
+++ b/test/renderer/store/extensionsSlice.test.ts
@@ -258,6 +258,42 @@ describe('extensionsSlice', () => {
expect(count1).toBe(1);
expect(count2).toBe(1); // no duplicate
});
+
+ it('updates projectId on existing tab when selected project changes', () => {
+ // Open Extensions with project-A
+ store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
+ store.getState().openExtensionsTab();
+
+ const tabsBefore = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
+ const extTabBefore = tabsBefore.find((t) => t.type === 'extensions');
+ expect(extTabBefore?.projectId).toBe('project-A');
+
+ // Switch to project-B and reopen Extensions
+ store.setState({ selectedProjectId: 'project-B' });
+ store.getState().openExtensionsTab();
+
+ const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
+ const extTabAfter = tabsAfter.find((t) => t.type === 'extensions');
+ expect(extTabAfter?.projectId).toBe('project-B');
+ // Still only one extensions tab
+ expect(tabsAfter.filter((t) => t.type === 'extensions')).toHaveLength(1);
+ });
+
+ it('does not update projectId when it already matches', () => {
+ store.setState({ selectedProjectId: 'project-A', activeProjectId: null });
+ store.getState().openExtensionsTab();
+
+ const layoutBefore = store.getState().paneLayout;
+
+ // Reopen with same project — layout should be referentially stable (no set() call)
+ store.getState().openExtensionsTab();
+
+ const tabsBefore = layoutBefore.panes.flatMap((p) => p.tabs);
+ const tabsAfter = store.getState().paneLayout.panes.flatMap((p) => p.tabs);
+ const extBefore = tabsBefore.find((t) => t.type === 'extensions');
+ const extAfter = tabsAfter.find((t) => t.type === 'extensions');
+ expect(extAfter?.projectId).toBe(extBefore?.projectId);
+ });
});
describe('installPlugin', () => {
diff --git a/test/renderer/utils/projectLookup.test.ts b/test/renderer/utils/projectLookup.test.ts
index e8685d45..c90b2626 100644
--- a/test/renderer/utils/projectLookup.test.ts
+++ b/test/renderer/utils/projectLookup.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
+import { resolveProjectIdByPath, resolveProjectPathById } from '@renderer/utils/projectLookup';
import type { Project, RepositoryGroup } from '@renderer/types/data';
@@ -9,16 +9,19 @@ import type { Project, RepositoryGroup } from '@renderer/types/data';
// ---------------------------------------------------------------------------
type ProjectLike = Pick;
+type ProjectWithName = Pick;
type RepoGroupLike = Pick;
-const CRYPTO_PROJECT: ProjectLike = {
+const CRYPTO_PROJECT: ProjectWithName = {
id: '-Users-belief-dev-projects-crypto-research',
path: '/Users/belief/dev/projects/crypto_research',
+ name: 'crypto_research',
};
-const CLAUDE_PROJECT: ProjectLike = {
+const CLAUDE_PROJECT: ProjectWithName = {
id: '-Users-belief-dev-projects-claude-claude-team',
path: '/Users/belief/dev/projects/claude/claude_team',
+ name: 'claude_team',
};
function makeRepoGroup(worktrees: { id: string; path: string }[]): RepoGroupLike {
@@ -290,3 +293,156 @@ describe('resolveProjectIdByPath', () => {
});
});
});
+
+// ===========================================================================
+// resolveProjectPathById — inverse lookup (ID → path + name)
+// ===========================================================================
+
+describe('resolveProjectPathById', () => {
+ // -----------------------------------------------------------------------
+ // Null / undefined / empty input
+ // -----------------------------------------------------------------------
+ describe('null/undefined/empty projectId', () => {
+ it('returns null for undefined projectId', () => {
+ expect(resolveProjectPathById(undefined, [CRYPTO_PROJECT], [])).toBeNull();
+ });
+
+ it('returns null for null projectId', () => {
+ expect(resolveProjectPathById(null, [CRYPTO_PROJECT], [])).toBeNull();
+ });
+
+ it('returns null for empty string projectId', () => {
+ expect(resolveProjectPathById('', [CRYPTO_PROJECT], [])).toBeNull();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Lookup from projects (flat view mode)
+ // -----------------------------------------------------------------------
+ describe('lookup from projects (flat mode)', () => {
+ it('finds project by exact id match', () => {
+ const result = resolveProjectPathById(
+ '-Users-belief-dev-projects-crypto-research',
+ [CRYPTO_PROJECT, CLAUDE_PROJECT],
+ []
+ );
+ expect(result).toEqual({
+ path: '/Users/belief/dev/projects/crypto_research',
+ name: 'crypto_research',
+ });
+ });
+
+ it('returns null when id not in projects', () => {
+ expect(
+ resolveProjectPathById('-Users-belief-dev-projects-unknown', [CRYPTO_PROJECT], [])
+ ).toBeNull();
+ });
+
+ it('returns null when projects list is empty', () => {
+ expect(
+ resolveProjectPathById('-Users-belief-dev-projects-crypto-research', [], [])
+ ).toBeNull();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Lookup from repositoryGroups (grouped view mode)
+ // -----------------------------------------------------------------------
+ describe('lookup from repositoryGroups (grouped mode)', () => {
+ it('finds project in worktrees when projects is empty', () => {
+ const result = resolveProjectPathById(
+ '-Users-belief-dev-projects-crypto-research',
+ [],
+ [CRYPTO_REPO_GROUP]
+ );
+ expect(result).toEqual({
+ path: '/Users/belief/dev/projects/crypto_research',
+ name: '-Users-belief-dev-projects-crypto-research',
+ });
+ });
+
+ it('finds project across multiple repo groups', () => {
+ const result = resolveProjectPathById(
+ '-Users-belief-dev-projects-claude-claude-team',
+ [],
+ [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP]
+ );
+ expect(result).toEqual({
+ path: '/Users/belief/dev/projects/claude/claude_team',
+ name: '-Users-belief-dev-projects-claude-claude-team',
+ });
+ });
+
+ it('finds correct worktree in multi-worktree group', () => {
+ const result = resolveProjectPathById(
+ '-Users-belief-dev-projects-app-wt-feature',
+ [],
+ [MULTI_WORKTREE_GROUP]
+ );
+ expect(result).toEqual({
+ path: '/Users/belief/dev/projects/app-wt-feature',
+ name: '-Users-belief-dev-projects-app-wt-feature',
+ });
+ });
+
+ it('returns null when id not in any worktree', () => {
+ expect(
+ resolveProjectPathById('-Users-belief-dev-projects-unknown', [], [CRYPTO_REPO_GROUP])
+ ).toBeNull();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Priority: projects takes precedence over repositoryGroups
+ // -----------------------------------------------------------------------
+ describe('priority order', () => {
+ it('prefers projects match over repositoryGroups match', () => {
+ const projectEntry: ProjectWithName = {
+ id: 'shared-id',
+ path: '/from/projects',
+ name: 'from-projects',
+ };
+
+ const repoGroupEntry = makeRepoGroup([
+ { id: 'shared-id', path: '/from/repo-group' },
+ ]);
+
+ const result = resolveProjectPathById('shared-id', [projectEntry], [repoGroupEntry]);
+ expect(result).toEqual({ path: '/from/projects', name: 'from-projects' });
+ });
+
+ it('falls back to repositoryGroups when projects has no match', () => {
+ const result = resolveProjectPathById(
+ '-Users-belief-dev-projects-crypto-research',
+ [CLAUDE_PROJECT],
+ [CRYPTO_REPO_GROUP]
+ );
+ expect(result).toEqual({
+ path: '/Users/belief/dev/projects/crypto_research',
+ name: '-Users-belief-dev-projects-crypto-research',
+ });
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Regression: Extensions tab with grouped view mode
+ // -----------------------------------------------------------------------
+ describe('regression: Extensions tab skills in grouped view mode', () => {
+ it('resolves projectPath from id when only repositoryGroups is populated', () => {
+ // This is the exact scenario that caused skills not to show:
+ // viewMode=grouped → projects=[] but repositoryGroups has the data
+ // ExtensionStoreView used projects.find(p => p.id === tabProjectId)
+ // which returned null, so projectPath was null and no project skills loaded
+ const emptyProjects: ProjectWithName[] = [];
+ const populatedGroups: RepoGroupLike[] = [CRYPTO_REPO_GROUP, CLAUDE_REPO_GROUP];
+
+ const result = resolveProjectPathById(
+ '-Users-belief-dev-projects-crypto-research',
+ emptyProjects,
+ populatedGroups
+ );
+ expect(result).not.toBeNull();
+ expect(result!.path).toBe('/Users/belief/dev/projects/crypto_research');
+ });
+ });
+});
diff --git a/test/renderer/utils/providerSlashCommands.test.ts b/test/renderer/utils/providerSlashCommands.test.ts
new file mode 100644
index 00000000..0934de33
--- /dev/null
+++ b/test/renderer/utils/providerSlashCommands.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+
+import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands';
+
+describe('getSuggestedSlashCommandsForProvider', () => {
+ it('returns Codex-specific command suggestions without Anthropic-only entries', () => {
+ const commands = getSuggestedSlashCommandsForProvider('codex').map(
+ (command) => command.command
+ );
+
+ expect(commands).toContain('/permissions');
+ expect(commands).toContain('/agent');
+ expect(commands).toContain('/review');
+ expect(commands).not.toContain('/effort');
+ expect(commands).not.toContain('/usage');
+ });
+
+ it('falls back to the default curated list for Anthropic-like providers', () => {
+ const commands = getSuggestedSlashCommandsForProvider('anthropic').map(
+ (command) => command.command
+ );
+
+ expect(commands).toContain('/effort');
+ expect(commands).toContain('/usage');
+ expect(commands).not.toContain('/permissions');
+ });
+});
diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts
new file mode 100644
index 00000000..528a2f2d
--- /dev/null
+++ b/test/renderer/utils/skillCommandSuggestions.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions';
+import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands';
+
+import type { SkillCatalogItem } from '@shared/types/extensions';
+
+function createSkill(overrides: Partial): SkillCatalogItem {
+ return {
+ id: overrides.id ?? 'skill-id',
+ sourceType: 'filesystem',
+ name: overrides.name ?? 'Skill Name',
+ description: overrides.description ?? 'Skill description',
+ folderName: overrides.folderName ?? 'skill-name',
+ scope: overrides.scope ?? 'project',
+ rootKind: overrides.rootKind ?? 'claude',
+ projectRoot: overrides.projectRoot ?? '/Users/test/project',
+ discoveryRoot: overrides.discoveryRoot ?? '/Users/test/project/.claude/skills',
+ skillDir: overrides.skillDir ?? '/Users/test/project/.claude/skills/skill-name',
+ skillFile: overrides.skillFile ?? '/Users/test/project/.claude/skills/skill-name/SKILL.md',
+ metadata: overrides.metadata ?? {},
+ invocationMode: overrides.invocationMode ?? 'manual-only',
+ flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false },
+ isValid: overrides.isValid ?? true,
+ issues: overrides.issues ?? [],
+ modifiedAt: overrides.modifiedAt ?? 0,
+ };
+}
+
+describe('buildSlashCommandSuggestions', () => {
+ it('keeps built-ins and adds valid skills in a separate suggestion type', () => {
+ const suggestions = buildSlashCommandSuggestions(
+ KNOWN_SLASH_COMMANDS,
+ [createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' })],
+ []
+ );
+
+ expect(suggestions[0]?.type).toBe('command');
+ expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true);
+ expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject(
+ {
+ name: 'review-skill',
+ command: '/review-skill',
+ subtitle: 'Project skill',
+ type: 'skill',
+ }
+ );
+ });
+
+ it('filters slash-unsafe names and built-in collisions', () => {
+ const suggestions = buildSlashCommandSuggestions(
+ KNOWN_SLASH_COMMANDS,
+ [
+ createSkill({ id: 'unsafe', folderName: 'bad skill' }),
+ createSkill({ id: 'collision', folderName: 'plan' }),
+ ],
+ []
+ );
+
+ expect(suggestions.find((suggestion) => suggestion.id === 'skill:unsafe')).toBeUndefined();
+ expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined();
+ });
+
+ it('prefers project skills when user and project skills share the same slash name', () => {
+ const suggestions = buildSlashCommandSuggestions(
+ KNOWN_SLASH_COMMANDS,
+ [createSkill({ id: 'project', folderName: 'shared-skill', scope: 'project' })],
+ [createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })]
+ );
+
+ expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(
+ 1
+ );
+ expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe(
+ 'skill:project'
+ );
+ });
+
+ it('uses the provided built-in set when filtering skill collisions', () => {
+ const suggestions = buildSlashCommandSuggestions(
+ [
+ {
+ name: 'custom-cmd',
+ command: '/custom-cmd',
+ description: 'Custom command',
+ },
+ ],
+ [createSkill({ id: 'collision', folderName: 'custom-cmd' })],
+ []
+ );
+
+ expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined();
+ });
+});
diff --git a/test/shared/utils/slashCommands.test.ts b/test/shared/utils/slashCommands.test.ts
index 29a48857..e30492a9 100644
--- a/test/shared/utils/slashCommands.test.ts
+++ b/test/shared/utils/slashCommands.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
getKnownSlashCommand,
+ isSupportedSlashCommandName,
KNOWN_SLASH_COMMANDS,
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
@@ -53,4 +54,11 @@ describe('slashCommands', () => {
expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model');
expect(getKnownSlashCommand('foo')).toBeNull();
});
+
+ it('validates slash-compatible command names', () => {
+ expect(isSupportedSlashCommandName('review')).toBe(true);
+ expect(isSupportedSlashCommandName('skill:name')).toBe(true);
+ expect(isSupportedSlashCommandName('my_skill')).toBe(false);
+ expect(isSupportedSlashCommandName('my skill')).toBe(false);
+ });
});