Compare commits

...

17 Commits

Author SHA1 Message Date
陈大猫
110e050d20 Merge pull request #708 from binaricat/feat/claude-agent-dynamic-model-probe
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: dynamically probe claude-agent-acp for available models
2026-04-13 19:55:13 +08:00
bincxz
ebcfe49ed6 fix: clear stale model cache when ACP probe returns empty
Address Codex review feedback on #708: the previous guard silently
returned on an empty-but-ok probe response, which left any previously
cached runtimeAgentModelPresets[currentAgentId] in place. That kept
Claude/Copilot pickers showing stale model IDs (and skipped currentModelId
reconciliation) instead of falling back to the hardcoded presets when the
backend no longer advertised a catalog.

Now we explicitly drop the cache entry so the agentModelPresets memo falls
through to getAgentModelPresets(...) via the `?? ` branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:46:39 +08:00
bincxz
bc8ac08b9a feat: probe claude-agent-acp for available models instead of hardcoded presets
Claude agents now advertise their real model catalog via the ACP
initSession response, just like Copilot already does. Confirmed locally
that `claude-agent-acp` returns `models.availableModels` with full ids +
names + descriptions (default / sonnet / haiku on subscription; and would
return Bedrock/Vertex/custom-proxy ids when the user has configured those).

This closes the gap where the Claude picker was stuck on three hardcoded
entries from CLAUDE_MODEL_PRESETS regardless of what the underlying CLI
actually supports. If the probe fails or returns an empty list, we keep
the hardcoded presets as a fallback.

Codex keeps its existing path via `aiCodexGetIntegration` (reads
~/.codex/config.toml) — we deliberately do not probe codex-acp, since
probing would just return the stock OpenAI model list even when the
user has a custom model_provider set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:37:19 +08:00
陈大猫
309fbdbe7a Merge pull request #707 from binaricat/fix/claude-agent-independent-from-custom-provider
fix: decouple Claude agent auth from netcatty provider list
2026-04-13 19:28:24 +08:00
bincxz
11f831d820 fix: decouple Claude agent auth from netcatty provider list
Apply the same fix as #706 to the Claude Code agent. The `claude` CLI has
its own auth surface (`claude auth login/logout/status`) that manages
subscription-based logins (Claude Max / Pro via claude.ai) alongside
ANTHROPIC_API_KEY / settings-based configs. Silently forwarding a
netcatty-configured provider's API key to claude-agent-acp overrides that
login — the user's subscription gets bypassed and charges go to their API
balance without their knowledge.

Claude's settings card never surfaced the `claude auth status` so this
regression was more hidden than the Codex one, but the underlying coupling
is the same class of bug.

Changes:
- Stop forwarding any providerId for managed ACP agents from the renderer;
  claude-agent-acp now resolves auth purely from its own CLI config / login
  state / shell env.
- Remove ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL injection at all three
  codex-acp / claude-acp spawn sites in aiBridge.
- Drop Claude from the authFingerprint computation (it no longer has any
  netcatty-side input to hash).
- Delete the now-unused `findManagedAgentProvider` helper and its
  ProviderConfig import from managedAgents.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:22:58 +08:00
陈大猫
806fb6cf29 Merge pull request #706 from binaricat/fix/issue-705-codex-independent-from-custom-provider
fix: decouple Codex agent auth from netcatty provider list (#705)
2026-04-13 19:14:08 +08:00
bincxz
cc2702b825 fix: decouple Codex agent auth from netcatty provider list (#705)
Codex agent auth must be determined entirely by ~/.codex/auth.json or
~/.codex/config.toml. Before this change, if the user configured any
OpenAI-compatible API provider in netcatty settings (for Catty agent use),
useAIChatStreaming would silently hand that provider's apiKey to the Codex
agent too, causing aiBridge to spawn codex-acp with authMethodId
"codex-api-key" and completely override the user's ChatGPT login.

The regression was introduced in PR #702 (v1.0.89) when findManagedAgent
Provider started matching generic "custom" providers for Codex. Users who
logged into Codex via ChatGPT and also had a netcatty-configured custom
provider saw the UI flip to "API mode" on refresh and their ChatGPT
session get ignored.

Remove the codex branch from the agentProviderId resolver and from
findManagedAgentProvider itself. Also drop the now-meaningless
hasCompatibleProvider hint on the Codex settings card and its i18n copy.
Claude agent behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:08:18 +08:00
陈大猫
af2589e60b Merge pull request #704 from tces1/MoreSkills
feat: add Netcatty user skills scanning and chat selection flow
2026-04-13 13:33:12 +08:00
Eric Chan
971c8a4d8b fix: harden user skills prompt injection 2026-04-13 12:49:53 +08:00
Eric Chan
59364e0c75 fix: preserve user skill selections on refresh errors 2026-04-13 12:39:33 +08:00
Eric Chan
ac83c4c27d fix: keep user skills state in sync 2026-04-13 11:15:32 +08:00
Eric Chan
aa10f962ea fix: harden user skills scanning 2026-04-13 11:08:09 +08:00
Eric Chan
1f3e531d7b Fix AI skill selection handling 2026-04-13 11:03:43 +08:00
Eric Chan
50b20eaa05 chore: triple-pass review and hardening of AI Skills logic 2026-04-12 17:25:45 +08:00
Eric Chan
3ab42bf588 chore: final hardening of User Skills logic and async IO 2026-04-12 17:14:49 +08:00
Eric Chan
84423a0096 fix: resolve TypeScript errors and optimize User Skills with async IO 2026-04-12 17:11:50 +08:00
Eric Chan
58bc08a045 Add user skills injection and picker UI 2026-04-10 20:53:39 +08:00
29 changed files with 1828 additions and 244 deletions

View File

@@ -1764,7 +1764,6 @@ const en: Messages = {
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
'ai.codex.apiKeyHint': 'Detected an enabled OpenAI-compatible provider API key. Codex ACP can use it without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1797,6 +1796,17 @@ const en: Messages = {
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': 'User Skills',
'ai.userSkills.description': 'Open the Netcatty skills folder to add your own skill directories. Netcatty scans these skills automatically and injects only lightweight indexes unless a skill clearly matches the current request.',
'ai.userSkills.openFolder': 'Open Skills Folder',
'ai.userSkills.reload': 'Reload Skills',
'ai.userSkills.location': 'Location',
'ai.userSkills.loading': 'Scanning user skills...',
'ai.userSkills.summary': '{ready} ready, {warnings} warnings',
'ai.userSkills.empty': 'No user skills found yet. Open the folder to add skill directories with a SKILL.md file.',
'ai.userSkills.unavailable': 'User skills are unavailable in this environment.',
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
@@ -1851,6 +1861,7 @@ const en: Messages = {
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',

View File

@@ -1772,7 +1772,6 @@ const zhCN: Messages = {
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的兼容 OpenAI 的 API Key。Codex ACP 也可以不走 ChatGPT 登录直接使用它。',
// AI Claude Code
'ai.claude.title': 'Claude Code',
@@ -1805,6 +1804,17 @@ const zhCN: Messages = {
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Skills + CLI',
'ai.userSkills.title': '用户 Skills',
'ai.userSkills.description': '打开 Netcatty 的 Skills 文件夹以添加你自己的技能目录。Netcatty 会自动扫描这些 skills默认只注入轻量索引只有在请求明显命中某个 skill 时才展开正文。',
'ai.userSkills.openFolder': '打开 Skills 文件夹',
'ai.userSkills.reload': '重新加载 Skills',
'ai.userSkills.location': '位置',
'ai.userSkills.loading': '正在扫描用户 skills...',
'ai.userSkills.summary': '已就绪 {ready} 个,警告 {warnings} 个',
'ai.userSkills.empty': '暂未发现用户 skills。打开文件夹后可添加包含 SKILL.md 的技能目录。',
'ai.userSkills.unavailable': '当前环境不支持用户 skills。',
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
@@ -1859,6 +1869,7 @@ const zhCN: Messages = {
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from "react";
import React, { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";

View File

@@ -40,6 +40,11 @@ import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import {
getReadyUserSkillOptions,
getNextSelectedUserSkillSlugsMap,
type UserSkillOption,
} from './ai/userSkillsState';
import {
useAIChatStreaming,
getNetcattyBridge,
@@ -147,11 +152,11 @@ function generateId(): string {
}
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
return messages.flatMap((message) => {
return messages.flatMap((message): Array<{ role: 'user' | 'assistant'; content: string }> => {
if (message.role === 'system') return [];
if (message.role === 'user') {
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
return message.content ? [{ role: 'user', content: message.content }] : [];
}
if (message.role === 'assistant') {
@@ -161,12 +166,12 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
if (!parts.length) return [];
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
return [{ role: 'assistant', content: parts.join('\n\n') }];
}
if (message.role === 'tool' && message.toolResults?.length) {
return message.toolResults.map((tr) => ({
role: 'assistant' as const,
role: 'assistant',
content: `Tool result:\n${tr.content}`,
}));
}
@@ -249,6 +254,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
const [selectedUserSkillSlugsMap, setSelectedUserSkillSlugsMap] = useState<Record<string, string[]>>({});
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const { openSettingsWindow } = useWindowControls();
@@ -415,6 +422,43 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [terminalSessions, scopeKey, activeSessionId]);
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
const applyUserSkillsStatus = (result: { ok: boolean; skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}> } | null | undefined) => {
const nextOptions = getReadyUserSkillOptions(result);
setUserSkillOptions(nextOptions);
setSelectedUserSkillSlugsMap((prev) => getNextSelectedUserSkillSlugsMap(prev, result));
};
const bridge = getNetcattyBridge();
if (!bridge?.aiUserSkillsGetStatus) {
applyUserSkillsStatus(null);
return;
}
void bridge.aiUserSkillsGetStatus()
.then((result) => {
if (cancelled) return;
applyUserSkillsStatus(result);
})
.catch(() => {
if (cancelled) return;
applyUserSkillsStatus(null);
});
return () => {
cancelled = true;
};
}, [activeSessionIdForScope, isVisible, toolIntegrationMode, scopeKey]);
// Sync provider configs to main process so it can decrypt API keys server-side.
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
useEffect(() => {
@@ -459,6 +503,18 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
);
const messages = activeSession?.messages ?? [];
const selectedUserSkillSlugs = useMemo(
() => selectedUserSkillSlugsMap[scopeKey] ?? [],
[selectedUserSkillSlugsMap, scopeKey],
);
const selectedUserSkills = useMemo(
() =>
selectedUserSkillSlugs.map((slug) => {
const option = userSkillOptions.find((skill) => skill.slug === slug);
return option ?? { id: slug, slug, name: slug, description: '' };
}),
[selectedUserSkillSlugs, userSkillOptions],
);
// ── Export hook ──
const { handleExport } = useConversationExport(activeSession);
@@ -485,6 +541,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'codex') : false,
[currentAgentConfig],
);
const isClaudeManagedAgent = useMemo(
() => currentAgentConfig ? matchesManagedAgentConfig(currentAgentConfig, 'claude') : false,
[currentAgentConfig],
);
// For Codex, pick up the model declared in ~/.codex/config.toml (if any)
// so the picker can show just that model instead of the hardcoded ChatGPT
@@ -523,7 +583,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
if (!isCopilotExternalAgent) return;
// Codex has its own path via aiCodexGetIntegration (reads config.toml).
// Everyone else that speaks ACP can be asked for their available models
// directly — in particular, Claude Code through claude-agent-acp
// advertises the real catalog (including Bedrock/Vertex model ids when
// the user configured those) instead of the hardcoded CLAUDE_MODEL_PRESETS.
if (!isCopilotExternalAgent && !isClaudeManagedAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
@@ -537,6 +602,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
`models_${currentAgentId}`,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
// If the probe came back empty, drop any stale cached catalog for this
// agent so `agentModelPresets` falls back to the hardcoded presets via
// the `?? getAgentModelPresets(...)` branch. Without this, a previously
// successful probe would keep surfacing models the backend no longer
// advertises.
if (result.models.length === 0) {
setRuntimeAgentModelPresets((prev) => {
if (!(currentAgentId in prev)) return prev;
const { [currentAgentId]: _removed, ...rest } = prev;
return rest;
});
return;
}
const knownModelIds = new Set(result.models.map((model) => model.id));
setRuntimeAgentModelPresets((prev) => ({
...prev,
@@ -555,7 +633,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, setAgentModel]);
// When Codex is backed by a ~/.codex/config.toml custom provider, the
// stock CODEX_MODEL_PRESETS catalog is invalid for that endpoint.
@@ -613,6 +691,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setActiveSessionId(session.id);
setShowHistory(false);
setInputValue('');
setSelectedUserSkillSlugsMap((prev) => {
if (!(scopeKey in prev)) return prev;
const next = { ...prev };
delete next[scopeKey];
return next;
});
}, [
scopeType,
scopeTargetId,
@@ -621,6 +705,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
createSession,
setActiveSessionId,
setInputValue,
scopeKey,
]);
const handleOpenSettings = useCallback(() => {
@@ -663,6 +748,41 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
};
}, []);
const addSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
setSelectedUserSkillSlugsMap((prev) => {
const current = prev[scopeKey] ?? [];
if (current.includes(normalizedSlug)) return prev;
return { ...prev, [scopeKey]: [...current, normalizedSlug] };
});
}, [scopeKey]);
const removeSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
setSelectedUserSkillSlugsMap((prev) => {
const current = prev[scopeKey] ?? [];
const nextSkills = current.filter((entry) => entry !== normalizedSlug);
if (nextSkills.length === current.length) return prev;
if (nextSkills.length === 0) {
const next = { ...prev };
delete next[scopeKey];
return next;
}
return { ...prev, [scopeKey]: nextSkills };
});
}, [scopeKey]);
const clearSelectedUserSkills = useCallback(() => {
setSelectedUserSkillSlugsMap((prev) => {
if (!(scopeKey in prev)) return prev;
const next = { ...prev };
delete next[scopeKey];
return next;
});
}, [scopeKey]);
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
@@ -702,6 +822,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const trimmed = inputValueRef.current.trim();
const sendScopeKey = scopeKey;
if (!trimmed || isStreaming) return;
const selectedSkillSlugs = selectedUserSkillSlugs;
const isExternalAgent = currentAgentId !== 'catty';
@@ -728,6 +849,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
});
setInputValue('');
clearFiles();
clearSelectedUserSkills();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
@@ -761,6 +883,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
providers,
selectedAgentModel,
toolIntegrationMode,
selectedUserSkillSlugs: selectedSkillSlugs,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
@@ -788,6 +911,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
}, attachments.length > 0 ? attachments : undefined);
}
}, [
@@ -799,6 +923,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
toolIntegrationMode,
selectedUserSkillSlugs, clearSelectedUserSkills,
]);
const handleStop = useCallback(() => {
@@ -961,6 +1086,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>

View File

@@ -67,27 +67,27 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
<DialogTitle>{t('dialog.createWorkspace.title', { defaultValue: 'Create Workspace' })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
<div className="space-y-2">
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
<Label htmlFor="workspace-name">{t('field.name', { defaultValue: 'Name' })}</Label>
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
placeholder={t('placeholder.workspaceName', { defaultValue: 'Workspace Name' })}
autoFocus
/>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
<Label>{t('field.selectHosts', { defaultValue: 'Select Hosts' })}</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -99,7 +99,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
<div className="p-2 space-y-1">
{filteredHosts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
{t('common.noResults', 'No hosts found')}
{t('common.noResults', { defaultValue: 'No hosts found' })}
</div>
) : (
filteredHosts.map(host => {
@@ -126,15 +126,15 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
</ScrollArea>
</div>
<div className="text-xs text-muted-foreground text-right">
{selectedHostIds.size} {t('common.selected', 'selected')}
{selectedHostIds.size} {t('common.selected', { defaultValue: 'selected' })}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', { defaultValue: 'Cancel' })}</Button>
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
{t('common.create', 'Create')}
{t('common.create', { defaultValue: 'Create' })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -41,7 +41,7 @@ class AITabErrorBoundary extends React.Component<
</div>
);
}
return this.props.children;
return (this.props as { children: React.ReactNode }).children;
}
}

View File

@@ -33,6 +33,7 @@ import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKe
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
@@ -1646,8 +1647,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// recomputing scope resolution from scratch on every tab switch.
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
const sessionById = new Map<string, TerminalSession>(sessions.map((session) => [session.id, session]));
const workspaceById = new Map<string, Workspace>(workspaces.map((workspace) => [workspace.id, workspace]));
const tabIds = new Set<string>(mountedAiTabIds);
if (activeTabId) tabIds.add(activeTabId);

View File

@@ -8,7 +8,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
className={cn('relative flex-1 overflow-x-hidden overflow-y-hidden', className)}
initial="instant"
resize="smooth"
role="log"
@@ -20,7 +20,7 @@ export type ConversationContentProps = ComponentProps<typeof StickToBottom.Conte
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content
className={cn('flex flex-col gap-4 p-4', className)}
className={cn('flex min-w-0 max-w-full flex-col gap-4 overflow-x-hidden p-4', className)}
{...props}
/>
);

View File

@@ -62,7 +62,7 @@ export const MessageResponse = memo(
// Style the rendered markdown
// Code: base styles (code-block overrides are in index.css)
'[&_code]:text-[12px] [&_code]:font-mono',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%] [&_p_code]:whitespace-normal [&_p_code]:[overflow-wrap:anywhere]',
'[&_p]:my-1.5',
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -40,6 +41,7 @@ function formatToolResult(result: unknown): string {
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
className?: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;

View File

@@ -6,7 +6,7 @@
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
@@ -22,6 +22,7 @@ import {
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
import { ScrollArea } from '../ui/scroll-area';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
@@ -51,6 +52,14 @@ interface ChatInputProps {
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** User skills currently selected for the next send */
selectedUserSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Available user skills for /skill-slug insertion */
userSkills?: Array<{ id: string; slug: string; name: string; description: string }>;
/** Callback to add a selected user skill */
onAddUserSkill?: (slug: string) => void;
/** Callback to remove a selected user skill */
onRemoveUserSkill?: (slug: string) => void;
/** Permission mode (only shown for Catty Agent) */
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
@@ -75,38 +84,74 @@ const ChatInput: React.FC<ChatInputProps> = ({
onAddFiles,
onRemoveFile,
hosts = [],
selectedUserSkills = [],
userSkills = [],
onAddUserSkill,
onRemoveUserSkill,
permissionMode,
onPermissionModeChange,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'slashSkill' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [inputPanelPos, setInputPanelPos] = useState<{ left: number; bottom: number; width: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showSlashSkillPicker = activeMenu === 'slashSkill';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
setActiveMenu(null);
setMenuPos(null);
setInputPanelPos(null);
setHoveredModelId(null);
setShowHostSubmenu(false);
setSlashQuery('');
setSlashRange(null);
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputShellRef = useRef<HTMLDivElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const findSlashTrigger = useCallback((text: string, caretPosition: number) => {
const beforeCaret = text.slice(0, caretPosition);
const match = /(^|\s)\/([a-z0-9-]*)$/i.exec(beforeCaret);
if (!match) return null;
const start = beforeCaret.length - match[0].length + match[1].length;
return {
start,
end: beforeCaret.length,
query: String(match[2] || '').toLowerCase(),
};
}, []);
const getInputPanelMenuPos = useCallback(() => {
const rect = inputShellRef.current?.getBoundingClientRect();
if (!rect) return null;
const horizontalMargin = 12;
const safeRight = window.innerWidth - horizontalMargin;
const width = Math.min(rect.width, safeRight - rect.left);
return {
left: rect.left,
bottom: window.innerHeight - rect.top + 8,
width,
};
}, []);
const handleInputChange = useCallback((newValue: string) => {
onChange(newValue);
const caretPosition = textareaRef.current?.selectionStart ?? newValue.length;
// Detect if user just typed @
if (
hosts.length > 0 &&
@@ -114,16 +159,28 @@ const ChatInput: React.FC<ChatInputProps> = ({
newValue.endsWith('@')
) {
// Position the popover near the textarea
const el = textareaRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
}
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setActiveMenu('atMention');
} else if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
return;
}
}, [onChange, value, hosts.length, showAtMention]);
const slashTrigger = findSlashTrigger(newValue, caretPosition);
if (userSkills.length > 0 && slashTrigger) {
const pos = getInputPanelMenuPos();
if (pos) setInputPanelPos(pos);
setSlashQuery(slashTrigger.query);
setSlashRange({ start: slashTrigger.start, end: slashTrigger.end });
setActiveMenu('slashSkill');
return;
}
if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
} else if (showSlashSkillPicker) {
closeAllMenus();
}
}, [onChange, value, hosts.length, showAtMention, findSlashTrigger, userSkills.length, showSlashSkillPicker, closeAllMenus, getInputPanelMenuPos]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
@@ -136,10 +193,45 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const openInputPanelMenu = useCallback((menu: 'atMention' | 'slashSkill') => {
const pos = getInputPanelMenuPos();
if (!pos) return;
setInputPanelPos(pos);
if (menu === 'slashSkill') {
setSlashQuery('');
setSlashRange(null);
}
setActiveMenu(menu);
}, [getInputPanelMenuPos]);
const filteredUserSkills = userSkills.filter((skill) => {
if (!slashQuery) return true;
const lowerQuery = slashQuery.toLowerCase();
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
});
const removeSlashQueryFromInput = useCallback(() => {
if (!slashRange) return value;
const before = value.slice(0, slashRange.start);
const after = value.slice(slashRange.end);
if (/\s$/.test(before) && /^\s/.test(after)) {
return `${before}${after.slice(1)}`;
}
return `${before}${after}`;
}, [slashRange, value]);
const insertUserSkillToken = useCallback((skill: { slug: string }) => {
onAddUserSkill?.(skill.slug);
if (slashRange) {
onChange(removeSlashQueryFromInput());
}
closeAllMenus();
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
.map((item: DataTransferItem) => item.getAsFile())
.filter((f): f is File => !!f);
if (pastedFiles.length > 0) {
e.preventDefault();
onAddFiles?.(pastedFiles);
@@ -195,11 +287,14 @@ const ChatInput: React.FC<ChatInputProps> = ({
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const selectedSkillChipClassName =
'inline-flex h-7 items-center gap-1.5 rounded-full border border-primary/18 bg-primary/8 pl-2.5 pr-1.5 text-[11px] font-medium text-foreground/86 shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]';
const iconButtonClassName =
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
return (
<div className="shrink-0 px-4 pb-4">
<div ref={inputShellRef} className="relative">
<PromptInput onSubmit={handleSubmit}>
{/* File attachment chips */}
{files.length > 0 && (
@@ -243,13 +338,43 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
{selectedUserSkills.length > 0 && (
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<div
key={skill.id}
className={selectedSkillChipClassName}
title={skill.description || skill.name || skill.slug}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
))}
</div>
</div>
)}
<PromptInputTextarea
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled}
className={expanded ? 'max-h-[220px]' : undefined}
className={[
selectedUserSkills.length > 0 ? 'pt-1.5' : undefined,
expanded ? 'max-h-[220px]' : undefined,
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<button
type="button"
@@ -262,31 +387,78 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
{/* @ mention popover */}
{showAtMention && hosts.length > 0 && menuPos && createPortal(
{showAtMention && hosts.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Mention host"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuHosts')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="truncate">{host.label || host.hostname}</span>
</div>
{host.label && host.hostname !== host.label ? (
<div className="mt-0.5 pl-3.5 text-[10px] text-muted-foreground/60 truncate">
{host.hostname}
</div>
) : null}
</button>
))}
</div>
</ScrollArea>
</div>
</>,
document.body,
)}
{/* / skill popover */}
{showSlashSkillPicker && filteredUserSkills.length > 0 && inputPanelPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Insert user skill"
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
>
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuUserSkills')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{filteredUserSkills.map((skill) => (
<button
key={skill.id}
type="button"
role="option"
onClick={() => insertUserSkillToken(skill)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
{skill.description ? (
<div className="mt-0.5 pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
</div>
) : null}
</button>
))}
</div>
</ScrollArea>
</div>
</>,
document.body,
@@ -341,48 +513,30 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<div
className="relative"
onMouseEnter={() => setShowHostSubmenu(true)}
onMouseLeave={() => setShowHostSubmenu(false)}
onFocus={() => setShowHostSubmenu(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
<button
type="button"
role="menuitem"
aria-label="Mention host"
onClick={() => openInputPanelMenu('atMention')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{userSkills.length > 0 && (
<button
type="button"
role="menuitem"
aria-label="Mention host"
aria-expanded={showHostSubmenu && hosts.length > 0}
aria-label="Insert user skill"
onClick={() => openInputPanelMenu('slashSkill')}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
<Package size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuUserSkills')}</span>
<ChevronRight size={10} className="text-muted-foreground/50" />
</button>
{showHostSubmenu && hosts.length > 0 && (
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="menuitem"
onClick={() => {
const mention = `@${host.label || host.hostname} `;
onChange(value + mention);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
</>,
document.body,
@@ -579,6 +733,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div>
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
};

View File

@@ -177,13 +177,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
return (
<React.Fragment key={message.id}>
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
<div key={tr.toolCallId}>
<ToolCall
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
</div>
))}
</React.Fragment>
);
@@ -255,15 +256,16 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
@@ -308,34 +310,35 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
<div key={tc.id}>
<ToolCall
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
</div>
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
.map((entry) => {
const [id, req] = entry;
.filter(([id, req]) => id.startsWith('mcp_approval_') && (!activeSessionId || req.chatSessionId === activeSessionId))
.map(([id, req]) => {
return (
<ToolCall
key={id}
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
<div key={id}>
<ToolCall
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
</div>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}

View File

@@ -30,7 +30,6 @@ import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { findManagedAgentProvider, matchesManagedAgentConfig } from '../../../infrastructure/ai/managedAgents';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
// -------------------------------------------------------------------
@@ -122,6 +121,17 @@ export interface PanelBridge extends NetcattyBridge {
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
ok: boolean;
skills?: Array<{
id: string;
slug: string;
name: string;
description: string;
status: 'ready' | 'warning';
}>;
}>;
aiUserSkillsBuildContext?: (prompt: string, selectedSkillSlugs?: string[]) => Promise<{ ok: boolean; context?: string; error?: string }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
@@ -156,6 +166,45 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const USER_SKILLS_CONTEXT_TIMEOUT_MS = 500;
interface UserSkillsContextResult {
ok: boolean;
context?: string;
error?: string;
}
function buildExplicitUserSkillsFallback(selectedUserSkillSlugs?: string[]): string {
if (!selectedUserSkillSlugs?.length) return '';
return `The user explicitly selected these Netcatty user skills for this request: ${selectedUserSkillSlugs.map((slug) => `/${slug}`).join(', ')}. Honor those selections even if their expanded skill content is unavailable.`;
}
async function resolveUserSkillsContext(
bridge: PanelBridge | undefined,
prompt: string,
selectedUserSkillSlugs?: string[],
): Promise<string> {
if (!bridge?.aiUserSkillsBuildContext) {
return buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const buildContextPromise: Promise<UserSkillsContextResult> = bridge
.aiUserSkillsBuildContext(prompt, selectedUserSkillSlugs)
.catch(() => ({ ok: false, context: '' }));
const hasExplicitSelections = (selectedUserSkillSlugs?.length ?? 0) > 0;
const result = hasExplicitSelections
? await buildContextPromise
: await Promise.race([
buildContextPromise,
new Promise<UserSkillsContextResult>((resolve) =>
setTimeout(() => resolve({ ok: false, context: '' }), USER_SKILLS_CONTEXT_TIMEOUT_MS),
),
]);
return result.context || buildExplicitUserSkillsFallback(selectedUserSkillSlugs);
}
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
@@ -240,6 +289,7 @@ export interface SendToCattyContext {
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
selectedUserSkillSlugs?: string[];
}
/** Context values needed by sendToExternalAgent that change frequently. */
@@ -252,6 +302,7 @@ export interface SendToExternalContext {
providers: ProviderConfig[];
selectedAgentModel?: string;
toolIntegrationMode: AIToolIntegrationMode;
selectedUserSkillSlugs?: string[];
}
// -------------------------------------------------------------------
@@ -543,6 +594,11 @@ export function useAIChatStreaming({
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
if (agentConfig.acpCommand && bridge) {
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
@@ -552,21 +608,6 @@ export function useAIChatStreaming({
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
// avoiding plaintext key transit across the IPC boundary.
// Resolve the correct provider based on agent type:
// - Claude agent → anthropic provider (prefer over generic custom)
// - Codex agent → openai provider (fallback to openai-compatible custom)
const agentProviderId = (() => {
if (matchesManagedAgentConfig(agentConfig, 'claude')) {
return findManagedAgentProvider(context.providers, 'claude')?.id;
}
if (matchesManagedAgentConfig(agentConfig, 'codex')) {
return findManagedAgentProvider(context.providers, 'codex')?.id;
}
return undefined;
})();
// Mutable flag: set after tool-result, cleared when new assistant msg is created
let needsNewAssistantMsg = false;
const maybeCreateAssistantMsg = () => {
@@ -648,19 +689,23 @@ export function useAIChatStreaming({
onDone: () => {},
},
abortController.signal,
agentProviderId,
// Managed ACP agents (codex, claude) must resolve auth from their own
// CLI config/login state, so we deliberately pass no providerId here.
// See issue #705 for Codex; same reasoning for Claude.
undefined,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
context.toolIntegrationMode,
context.defaultTargetSession,
userSkillsContext,
);
} else {
// Fallback: spawn as raw process
await runExternalAgentTurn(
agentConfig,
trimmed,
userSkillsContext ? `${userSkillsContext}\n\nUser request:\n${trimmed}` : trimmed,
{
onTextDelta: (text: string) => {
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
@@ -694,6 +739,11 @@ export function useAIChatStreaming({
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const userSkillsContext = await resolveUserSkillsContext(
bridge,
trimmed,
context.selectedUserSkillSlugs,
);
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
@@ -721,6 +771,7 @@ export function useAIChatStreaming({
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
userSkillsContext,
});
// Guard: activeProvider must exist for Catty agent path

View File

@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getNextSelectedUserSkillSlugsMap,
getReadyUserSkillOptions,
pruneSelectedUserSkillSlugsMap,
} from "./userSkillsState.ts";
test("getReadyUserSkillOptions returns only ready skills and clears invalid payloads", () => {
assert.deepEqual(getReadyUserSkillOptions(null), []);
assert.deepEqual(getReadyUserSkillOptions({ ok: false }), []);
assert.deepEqual(
getReadyUserSkillOptions({
ok: true,
skills: [
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
status: "ready",
},
{
id: "beta",
slug: "beta",
name: "Beta",
description: "Beta helper",
status: "warning",
},
],
}),
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
);
});
test("pruneSelectedUserSkillSlugsMap removes stale slugs and empty scopes", () => {
assert.deepEqual(
pruneSelectedUserSkillSlugsMap(
{
"terminal:1": ["alpha", "missing"],
"workspace:1": ["missing"],
},
[
{
id: "alpha",
slug: "alpha",
name: "Alpha",
description: "Alpha helper",
},
],
),
{
"terminal:1": ["alpha"],
},
);
});
test("getNextSelectedUserSkillSlugsMap preserves selections when refresh fails", () => {
const selected = {
"terminal:1": ["alpha", "missing"],
"workspace:1": ["beta"],
};
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, null),
selected,
);
assert.equal(
getNextSelectedUserSkillSlugsMap(selected, { ok: false }),
selected,
);
});

View File

@@ -0,0 +1,73 @@
export interface UserSkillStatusItemLike {
id: string;
slug: string;
name: string;
description: string;
status: "ready" | "warning";
}
export interface UserSkillsStatusLike {
ok: boolean;
skills?: UserSkillStatusItemLike[];
}
export interface UserSkillOption {
id: string;
slug: string;
name: string;
description: string;
}
export function getReadyUserSkillOptions(
status: UserSkillsStatusLike | null | undefined,
): UserSkillOption[] {
if (!status?.ok || !Array.isArray(status.skills)) return [];
return status.skills
.filter((skill) => skill.status === "ready" && typeof skill.slug === "string" && skill.slug.length > 0)
.map((skill) => ({
id: skill.id,
slug: skill.slug,
name: skill.name,
description: skill.description,
}));
}
export function pruneSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
options: UserSkillOption[],
): Record<string, string[]> {
const validSlugs = new Set(options.map((option) => option.slug));
let changed = false;
const nextEntries: Array<[string, string[]]> = [];
for (const [scopeKey, slugs] of Object.entries(selectedByScope)) {
const filteredSlugs = slugs.filter((slug) => validSlugs.has(slug));
if (filteredSlugs.length !== slugs.length) changed = true;
if (filteredSlugs.length > 0) {
nextEntries.push([scopeKey, filteredSlugs]);
} else if (slugs.length > 0) {
changed = true;
}
}
if (!changed) {
return selectedByScope;
}
return Object.fromEntries(nextEntries);
}
export function getNextSelectedUserSkillSlugsMap(
selectedByScope: Record<string, string[]>,
status: UserSkillsStatusLike | null | undefined,
): Record<string, string[]> {
if (!status?.ok || !Array.isArray(status.skills)) {
return selectedByScope;
}
return pruneSelectedUserSkillSlugsMap(
selectedByScope,
getReadyUserSkillOptions(status),
);
}

View File

@@ -7,7 +7,7 @@
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import { AlertTriangle, Bot, FolderOpen, Globe, Link, Package, RefreshCcw } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
@@ -18,7 +18,6 @@ import type {
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
findManagedAgentProvider,
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
@@ -26,6 +25,7 @@ import {
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Select, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
@@ -33,6 +33,7 @@ import type {
AgentPathInfo,
CodexIntegrationStatus,
CodexLoginSession,
UserSkillsStatusResult,
} from "./ai/types";
import {
AGENT_DEFAULTS,
@@ -188,6 +189,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
const [userSkillsStatus, setUserSkillsStatus] = useState<UserSkillsStatusResult | null>(null);
const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false);
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
@@ -305,8 +308,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [externalAgents, t]);
const hasCodexCompatibleProvider = Boolean(findManagedAgentProvider(providers, "codex"));
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
@@ -424,6 +425,54 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}
}, [refreshCodexIntegration]);
const refreshUserSkillsStatus = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsGetStatus) {
setUserSkillsStatus({
ok: false,
error: t('ai.userSkills.unavailable'),
});
return;
}
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsGetStatus();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, [t]);
useEffect(() => {
let cancelled = false;
void refreshUserSkillsStatus().then(() => {
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshUserSkillsStatus]);
const handleOpenUserSkillsFolder = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiUserSkillsOpenFolder) return;
setIsLoadingUserSkills(true);
try {
const result = await bridge.aiUserSkillsOpenFolder();
setUserSkillsStatus(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setUserSkillsStatus({ ok: false, error: message });
} finally {
setIsLoadingUserSkills(false);
}
}, []);
return (
<TabsContent
value="ai"
@@ -523,7 +572,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasCompatibleProvider={hasCodexCompatibleProvider}
error={codexError}
onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true })}
onConnect={() => void handleStartCodexLogin()}
@@ -591,7 +639,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<div className="space-y-4">
<div className="flex items-center gap-2">
<Bot size={18} className="text-muted-foreground" />
<Link size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
</div>
@@ -613,6 +661,106 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Package size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.userSkills.title')}</h3>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void refreshUserSkillsStatus()}
disabled={isLoadingUserSkills}
>
<RefreshCcw size={14} className="mr-2" />
{t('ai.userSkills.reload')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleOpenUserSkillsFolder()}
disabled={isLoadingUserSkills}
>
<FolderOpen size={14} className="mr-2" />
{t('ai.userSkills.openFolder')}
</Button>
</div>
</div>
<div className="rounded-lg bg-muted/30 p-4 space-y-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('ai.userSkills.description')}
</p>
{userSkillsStatus?.directoryPath ? (
<p className="text-xs text-muted-foreground">
{t('ai.userSkills.location')}:{" "}
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
</p>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{isLoadingUserSkills
? t('ai.userSkills.loading')
: userSkillsStatus?.ok
? t('ai.userSkills.summary', {
ready: String(userSkillsStatus.readyCount ?? 0),
warnings: String(userSkillsStatus.warningCount ?? 0),
})
: userSkillsStatus?.error || t('ai.userSkills.unavailable')}
</div>
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
<div className="space-y-3">
{userSkillsStatus.skills.map((skill) => (
<div
key={skill.id}
className="rounded-md border border-border/60 bg-background/70 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="font-medium">{skill.name}</div>
<div className="text-sm text-muted-foreground">{skill.description}</div>
<div className="text-xs text-muted-foreground font-mono break-all">
{skill.directoryName}
</div>
</div>
<span
className={
skill.status === "ready"
? "rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-medium text-emerald-600"
: "rounded-full bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600"
}
>
{skill.status === "ready"
? t('ai.userSkills.status.ready')
: t('ai.userSkills.status.warning')}
</span>
</div>
{skill.warnings.length > 0 ? (
<div className="mt-3 space-y-1 text-sm text-amber-700">
{skill.warnings.map((warning, index) => (
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
</div>
))}
</div>
) : userSkillsStatus?.ok ? (
<div className="text-sm text-muted-foreground">
{t('ai.userSkills.empty')}
</div>
) : null}
</div>
</div>
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}

View File

@@ -15,7 +15,6 @@ export const CodexConnectionCard: React.FC<{
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasCompatibleProvider: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
@@ -31,7 +30,6 @@ export const CodexConnectionCard: React.FC<{
integration,
loginSession,
isLoading,
hasCompatibleProvider,
error,
onRefresh,
onConnect,
@@ -192,12 +190,6 @@ export const CodexConnectionCard: React.FC<{
)}
</>
)}
{hasCompatibleProvider && integration?.state !== "connected_custom_config" && (
<p className="text-xs text-emerald-500">
{t('ai.codex.apiKeyHint')}
</p>
)}
</>
)}

View File

@@ -50,6 +50,28 @@ export interface AgentPathInfo {
available: boolean;
}
export interface UserSkillStatusItem {
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: "ready" | "warning";
warnings: string[];
}
export interface UserSkillsStatusResult {
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: UserSkillStatusItem[];
warnings?: string[];
error?: string;
}
export interface ProviderFormState {
name: string;
apiKey: string;
@@ -76,6 +98,8 @@ export interface NetcattyAiBridge {
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: () => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string }) => Promise<AgentPathInfo>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
openExternal?: (url: string) => Promise<void>;
}

View File

@@ -46,6 +46,8 @@ interface UseSftpViewPaneCallbacksParams {
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
}
export const useSftpViewPaneCallbacks = ({

View File

@@ -0,0 +1,511 @@
const fsPromises = require("node:fs/promises");
const path = require("node:path");
const USER_SKILLS_DIR_NAME = "Skills";
const USER_SKILLS_README_NAME = "README.txt";
const MAX_SKILL_BYTES = 24 * 1024;
const MAX_DESCRIPTION_LENGTH = 280;
const MAX_INDEX_SKILLS = 8;
const MAX_EXPLICIT_SKILLS = 4;
const MAX_MATCHED_SKILLS = 2;
const MAX_MATCHED_SKILL_CHARS = 6000;
const MAX_TOTAL_INJECTED_SKILL_CHARS = 12000;
const USER_SKILLS_README_CONTENT = [
"Netcatty user skills",
"",
"Add one folder per skill inside this directory.",
"Each skill folder must contain a SKILL.md file.",
"",
"Example layout:",
" Skills/",
" My Skill/",
" SKILL.md",
"",
"Minimal SKILL.md:",
" ---",
" name: My Skill",
" description: Short summary of what this skill helps with.",
" ---",
"",
" Write the skill instructions here.",
"",
"After adding or editing a skill, reopen the AI settings page or start a new chat to refresh the list.",
"",
].join("\n");
const STOPWORDS = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "when", "then",
"only", "your", "will", "should", "have", "has", "had", "using", "use",
"agent", "skill", "skills", "task", "file", "files", "user", "into", "about",
]);
function stripQuotes(value) {
const trimmed = String(value || "").trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function slugifySkill(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function tokenize(value) {
return String(value || "")
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map((token) => token.trim())
.filter((token) => token.length >= 3 && !STOPWORDS.has(token));
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatSkillReadWarning(error) {
const code = typeof error?.code === "string" ? error.code : null;
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
return code
? `Failed to read SKILL.md (${code}: ${message}).`
: `Failed to read SKILL.md (${message}).`;
}
function containsPlaintextPhrase(prompt, phrase) {
const trimmedPhrase = String(phrase || "").trim();
if (!trimmedPhrase) return false;
const pattern = new RegExp(`(^|\\s)${escapeRegExp(trimmedPhrase)}(?=$|\\s|[.,!?;:])`, "i");
return pattern.test(String(prompt || ""));
}
function parseFrontmatter(content) {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
if (!match) {
return { attributes: {}, body: content, hasFrontmatter: false };
}
const attributes = {};
for (const rawLine of match[1].split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const colonIndex = line.indexOf(":");
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim();
const value = stripQuotes(line.slice(colonIndex + 1).trim());
if (key) attributes[key] = value;
}
return {
attributes,
body: content.slice(match[0].length),
hasFrontmatter: true,
};
}
function summarizeSkillSlugs(skillsOrSlugs, maxItems = 4) {
const values = (Array.isArray(skillsOrSlugs) ? skillsOrSlugs : [])
.map((entry) => {
if (typeof entry === "string") return entry;
const slug = typeof entry?.slug === "string" ? entry.slug : "";
return slug;
})
.filter(Boolean)
.map((slug) => `/${slug}`);
if (values.length <= maxItems) {
return values.join(", ");
}
return `${values.slice(0, maxItems).join(", ")}, and ${values.length - maxItems} more`;
}
function getUserSkillsDir(electronApp) {
const userDataDir = electronApp?.getPath?.("userData");
if (!userDataDir) {
throw new Error("Electron app userData path is unavailable.");
}
return path.join(userDataDir, USER_SKILLS_DIR_NAME);
}
async function ensureUserSkillsDir(electronApp) {
const skillsDir = getUserSkillsDir(electronApp);
await fsPromises.mkdir(skillsDir, { recursive: true });
return skillsDir;
}
async function ensureUserSkillsReadme(electronApp) {
const skillsDir = await ensureUserSkillsDir(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir);
if (dirEntries.length === 0) {
await fsPromises.writeFile(
path.join(skillsDir, USER_SKILLS_README_NAME),
USER_SKILLS_README_CONTENT,
"utf8",
);
}
return skillsDir;
}
async function scanUserSkills(electronApp) {
const skillsDir = await ensureUserSkillsReadme(electronApp);
const dirEntries = await fsPromises.readdir(skillsDir, { withFileTypes: true });
const skills = [];
const warnings = [];
for (const entry of dirEntries) {
// Only process actual directories, skipping symlinks for security
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
const dirName = entry.name;
// Basic path traversal protection: skip any directory name containing path separators
if (dirName.includes("/") || dirName.includes("\\") || dirName === ".." || dirName === ".") {
continue;
}
const skillDir = path.join(skillsDir, dirName);
const skillPath = path.join(skillDir, "SKILL.md");
const baseItem = {
id: dirName,
slug: slugifySkill(dirName),
directoryName: dirName,
directoryPath: skillDir,
skillPath,
name: dirName,
description: "",
status: "warning",
warnings: [],
};
try {
await fsPromises.access(skillPath);
} catch {
baseItem.warnings.push("Missing SKILL.md");
warnings.push(`${dirName}: Missing SKILL.md`);
skills.push(baseItem);
continue;
}
try {
const stat = await fsPromises.lstat(skillPath);
if (stat.isSymbolicLink()) {
baseItem.warnings.push("SKILL.md must not be a symbolic link.");
warnings.push(`${dirName}: SKILL.md must not be a symbolic link.`);
skills.push(baseItem);
continue;
}
if (!stat.isFile()) {
baseItem.warnings.push("SKILL.md must be a regular file.");
warnings.push(`${dirName}: SKILL.md must be a regular file.`);
skills.push(baseItem);
continue;
}
if (stat.size > MAX_SKILL_BYTES) {
baseItem.warnings.push(`SKILL.md is too large (${stat.size} bytes > ${MAX_SKILL_BYTES} bytes).`);
warnings.push(`${dirName}: SKILL.md is too large.`);
skills.push(baseItem);
continue;
}
const content = await fsPromises.readFile(skillPath, "utf8");
const { attributes, body, hasFrontmatter } = parseFrontmatter(content);
const name = stripQuotes(attributes.name || "").trim();
const description = stripQuotes(attributes.description || "").trim();
const usableSlug = slugifySkill(name || dirName);
if (!hasFrontmatter) {
baseItem.warnings.push("Missing YAML frontmatter.");
}
if (!name) {
baseItem.warnings.push("Missing frontmatter field: name.");
}
if (!description) {
baseItem.warnings.push("Missing frontmatter field: description.");
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
baseItem.warnings.push(`Description is too long (${description.length} chars > ${MAX_DESCRIPTION_LENGTH}).`);
}
if (!usableSlug) {
baseItem.warnings.push("Skill name must include ASCII letters or digits to generate a usable slug.");
}
if (baseItem.warnings.length > 0) {
warnings.push(...baseItem.warnings.map((warning) => `${dirName}: ${warning}`));
skills.push({
...baseItem,
slug: usableSlug,
name: name || dirName,
description,
});
continue;
}
skills.push({
...baseItem,
slug: usableSlug,
name,
description,
status: "ready",
warnings: [],
body,
mtimeMs: stat.mtimeMs,
});
} catch (error) {
const warning = formatSkillReadWarning(error);
baseItem.warnings.push(warning);
warnings.push(`${dirName}: ${warning}`);
skills.push(baseItem);
}
}
const readySkillsBySlug = new Map();
for (const skill of skills) {
if (skill.status !== "ready" || !skill.slug) continue;
const matches = readySkillsBySlug.get(skill.slug);
if (matches) {
matches.push(skill);
} else {
readySkillsBySlug.set(skill.slug, [skill]);
}
}
for (const [slug, duplicateSkills] of readySkillsBySlug.entries()) {
if (duplicateSkills.length < 2) continue;
const duplicateWarning = `Duplicate skill slug "${slug}". Rename the skill or change its frontmatter name.`;
for (const skill of duplicateSkills) {
skill.status = "warning";
skill.warnings = [...skill.warnings, duplicateWarning];
warnings.push(`${skill.directoryName}: ${duplicateWarning}`);
}
}
const readyCount = skills.filter((skill) => skill.status === "ready").length;
const warningCount = skills.filter((skill) => skill.status === "warning").length;
return {
directoryPath: skillsDir,
readyCount,
warningCount,
skills: skills.map((skill) => ({
id: skill.id,
slug: skill.slug,
directoryName: skill.directoryName,
directoryPath: skill.directoryPath,
skillPath: skill.skillPath,
name: skill.name,
description: skill.description,
status: skill.status,
warnings: skill.warnings,
})),
warnings,
_readySkills: skills.filter((skill) => skill.status === "ready"),
};
}
/**
* Scores how well a skill matches a user prompt.
*
* Scored based on:
* - 50 points: Plain-text name/directory mention (e.g. prompt contains "my skill")
* - 1 point per keyword overlap (after tokenization/stopword filtering)
*
* @param {string} prompt - The user prompt
* @param {object} skill - The skill object from scanUserSkills
* @returns {number} The score (higher is better)
*/
function scoreSkillMatch(prompt, skill) {
const name = String(skill.name || "").trim();
const directoryName = String(skill.directoryName || "").trim();
// High weight for an exact plain-text mention of the skill name.
if (
(name && containsPlaintextPhrase(prompt, name)) ||
(directoryName && containsPlaintextPhrase(prompt, directoryName))
) {
return 50;
}
// Fallback to token keyword overlap
const promptTokens = new Set(tokenize(prompt));
const skillTokens = tokenize(`${skill.name} ${skill.description}`);
let overlap = 0;
for (const token of skillTokens) {
if (promptTokens.has(token)) overlap += 1;
}
return overlap;
}
/**
* Builds the contextual prompt part from matched user skills.
*
* @param {object} electronApp - The Electron app instance
* @param {string} prompt - The user's input prompt
* @param {string[]} selectedSkillSlugs - Explicitly requested skill slugs
* @returns {Promise<{context: string, status: object}>} The built prompt part and scan status
*/
async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs = []) {
const status = await scanUserSkills(electronApp);
const readySkills = status._readySkills || [];
const trimmedPrompt = String(prompt || "").trim();
if (readySkills.length === 0) {
return { context: "", status };
}
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
const remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
const indexLine = indexSkills
.map((skill) => `${skill.name}: ${skill.description}`)
.join("; ");
const orderedExplicitSlugs = [];
const seenExplicitSlugs = new Set();
for (const rawSlug of Array.isArray(selectedSkillSlugs) ? selectedSkillSlugs : []) {
const slug = slugifySkill(rawSlug);
if (!slug || seenExplicitSlugs.has(slug)) continue;
seenExplicitSlugs.add(slug);
orderedExplicitSlugs.push(slug);
}
const additionalExplicitCount = Math.max(orderedExplicitSlugs.length - MAX_EXPLICIT_SKILLS, 0);
const cappedExplicitSlugs = orderedExplicitSlugs.slice(0, MAX_EXPLICIT_SKILLS);
const explicitSlugSet = new Set(cappedExplicitSlugs);
const readySkillsBySlug = new Map(readySkills.map((skill) => [skill.slug, skill]));
const explicitSkills = [];
const unavailableExplicitSlugs = [];
for (const slug of cappedExplicitSlugs) {
const skill = readySkillsBySlug.get(slug);
if (skill) {
explicitSkills.push(skill);
} else {
unavailableExplicitSlugs.push(slug);
}
}
const matchedSkills = readySkills
.filter((skill) => !explicitSlugSet.has(skill.slug))
.map((skill) => ({ skill, score: scoreSkillMatch(trimmedPrompt, skill) }))
.filter((entry) => entry.score >= 2)
.sort((left, right) => right.score - left.score)
.slice(0, MAX_MATCHED_SKILLS)
.map((entry) => entry.skill);
const finalSkills = [...explicitSkills, ...matchedSkills];
const parts = [
"User-managed skills are installed in Netcatty.",
`Available user skills: ${indexLine}${remainingCount > 0 ? `; and ${remainingCount} more.` : "."}`,
"Use a user-managed skill only when it clearly matches the current request.",
];
if (additionalExplicitCount > 0) {
parts.push(
`The user selected ${additionalExplicitCount} additional Netcatty user skills that were omitted to stay within the prompt budget.`,
);
}
if (unavailableExplicitSlugs.length > 0) {
parts.push(
`The user explicitly selected these Netcatty user skills for this request, but their content is currently unavailable: ${summarizeSkillSlugs(unavailableExplicitSlugs)}.`,
);
}
if (finalSkills.length > 0) {
const includedSkillSections = [];
const omittedSkills = [];
const truncatedSkills = [];
let remainingSkillChars = MAX_TOTAL_INJECTED_SKILL_CHARS;
let budgetStopIndex = finalSkills.length;
for (let index = 0; index < finalSkills.length; index += 1) {
const skill = finalSkills[index];
const heading = `### ${skill.name}\n`;
const maxBodyChars = Math.min(
MAX_MATCHED_SKILL_CHARS,
Math.max(remainingSkillChars - heading.length, 0),
);
if (maxBodyChars <= 0) {
omittedSkills.push(skill);
continue;
}
const rawBody = String(skill.body || "").trim();
if (!rawBody) {
omittedSkills.push(skill);
continue;
}
if (rawBody.length > maxBodyChars && includedSkillSections.length > 0) {
omittedSkills.push(skill);
budgetStopIndex = index;
continue;
}
const body = rawBody.slice(0, maxBodyChars);
if (!body) {
omittedSkills.push(skill);
continue;
}
includedSkillSections.push(`${heading}${body}`);
remainingSkillChars -= heading.length + body.length;
if (body.length < rawBody.length) {
truncatedSkills.push(skill);
budgetStopIndex = index + 1;
break;
}
}
parts.push("Matched user-managed skills for this request:");
if (includedSkillSections.length > 0) {
parts.push(...includedSkillSections);
}
const omittedAfterIncluded = finalSkills.slice(budgetStopIndex);
for (const skill of omittedAfterIncluded) {
if (!omittedSkills.includes(skill) && !truncatedSkills.includes(skill)) {
omittedSkills.push(skill);
}
}
if (truncatedSkills.length > 0) {
parts.push(
`Some matched user-managed skill content was truncated to stay within the prompt budget: ${summarizeSkillSlugs(truncatedSkills)}.`,
);
}
if (omittedSkills.length > 0) {
parts.push(
`Additional matched user-managed skills were omitted to stay within the prompt budget: ${summarizeSkillSlugs(omittedSkills)}.`,
);
}
}
return {
context: parts.join("\n\n"),
status,
};
}
function toPublicUserSkillsStatus(status) {
if (!status || typeof status !== "object") {
return status;
}
const publicStatus = { ...status };
delete publicStatus._readySkills;
return publicStatus;
}
module.exports = {
USER_SKILLS_DIR_NAME,
getUserSkillsDir,
ensureUserSkillsDir,
ensureUserSkillsReadme,
scanUserSkills,
buildUserSkillsContext,
toPublicUserSkillsStatus,
};

View File

@@ -0,0 +1,336 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { buildUserSkillsContext, scanUserSkills } = require("./userSkills.cjs");
async function withUserSkills(skillDefinitions, run) {
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-user-skills-"));
const userDataDir = path.join(rootDir, "userData");
const skillsDir = path.join(userDataDir, "Skills");
await fs.mkdir(skillsDir, { recursive: true });
for (const skill of skillDefinitions) {
const skillDir = path.join(skillsDir, skill.directoryName);
await fs.mkdir(skillDir, { recursive: true });
const content = [
"---",
`name: ${skill.name}`,
`description: ${skill.description}`,
"---",
"",
skill.body,
"",
].join("\n");
await fs.writeFile(path.join(skillDir, "SKILL.md"), content, "utf8");
}
const electronApp = {
getPath(key) {
return key === "userData" ? userDataDir : "";
},
};
try {
await run(electronApp);
} finally {
await fs.rm(rootDir, { recursive: true, force: true });
}
}
test("does not auto-match a user skill from an absolute path segment", async () => {
await withUserSkills(
[
{
directoryName: "Tmp Helper",
name: "tmp",
description: "Helper for scratch space workflows.",
body: "Body for tmp",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"please inspect /tmp/netcatty.log",
[],
);
assert.equal(result.context.includes("Matched user-managed skills for this request:"), false);
assert.equal(result.context.includes("Body for tmp"), false);
},
);
});
test("keeps every explicitly selected skill in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Alpha One",
name: "Alpha One",
description: "Alpha helper.",
body: "Body for Alpha One",
},
{
directoryName: "Beta Two",
name: "Beta Two",
description: "Beta helper.",
body: "Body for Beta Two",
},
{
directoryName: "Gamma Three",
name: "Gamma Three",
description: "Gamma helper.",
body: "Body for Gamma Three",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["alpha-one", "beta-two", "gamma-three"],
);
assert.equal(result.context.includes("Body for Alpha One"), true);
assert.equal(result.context.includes("Body for Beta Two"), true);
assert.equal(result.context.includes("Body for Gamma Three"), true);
},
);
});
test("preserves an unavailable explicit selection in the built context", async () => {
await withUserSkills(
[
{
directoryName: "Beta",
name: "Beta",
description: "Beta helper.",
body: "Body for Beta",
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["missing-skill"],
);
assert.equal(result.context.includes("Available user skills: Beta: Beta helper."), true);
assert.equal(result.context.includes("/missing-skill"), true);
assert.match(result.context, /explicitly selected/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("initializing an empty skills directory creates only an instructions file", async () => {
await withUserSkills([], async (electronApp) => {
const status = await scanUserSkills(electronApp);
const entries = await fs.readdir(status.directoryPath);
assert.deepEqual(status.skills, []);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 0);
assert.deepEqual(entries.sort(), ["README.txt"]);
});
});
test("unreadable SKILL.md becomes a warning instead of aborting the entire scan", async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
{
directoryName: "Broken Skill",
name: "Broken Skill",
description: "This file will be unreadable.",
body: "Broken body",
},
],
async (electronApp) => {
const unreadablePath = path.join(
electronApp.getPath("userData"),
"Skills",
"Broken Skill",
"SKILL.md",
);
await fs.chmod(unreadablePath, 0o000);
try {
const status = await scanUserSkills(electronApp);
const workingSkill = status.skills.find((skill) => skill.name === "Working Skill");
const brokenSkill = status.skills.find((skill) => skill.directoryName === "Broken Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(workingSkill?.status, "ready");
assert.equal(brokenSkill?.status, "warning");
assert.match(brokenSkill?.warnings?.[0] || "", /Failed to read SKILL\.md/i);
} finally {
await fs.chmod(unreadablePath, 0o644);
}
},
);
});
test("symlinked SKILL.md is downgraded to a warning and never injected", async () => {
await withUserSkills(
[
{
directoryName: "Working Skill",
name: "Working Skill",
description: "A valid skill.",
body: "Working body",
},
],
async (electronApp) => {
const skillsDir = path.join(electronApp.getPath("userData"), "Skills");
const linkedDir = path.join(skillsDir, "Linked Skill");
const externalTarget = path.join(skillsDir, "..", "outside-secret.md");
await fs.mkdir(linkedDir, { recursive: true });
await fs.writeFile(
externalTarget,
[
"---",
"name: Linked Skill",
"description: Linked helper.",
"---",
"",
"TOPSECRET",
"",
].join("\n"),
"utf8",
);
await fs.symlink(externalTarget, path.join(linkedDir, "SKILL.md"));
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["linked-skill"]);
const linkedSkill = status.skills.find((skill) => skill.directoryName === "Linked Skill");
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 1);
assert.equal(linkedSkill?.status, "warning");
assert.match(linkedSkill?.warnings?.[0] || "", /symbolic link/i);
assert.equal(result.context.includes("TOPSECRET"), false);
assert.match(result.context, /linked-skill/i);
assert.match(result.context, /unavailable/i);
},
);
});
test("duplicate normalized slugs are downgraded to warnings and not injected explicitly", async () => {
await withUserSkills(
[
{
directoryName: "Foo Bar",
name: "Foo Bar",
description: "First skill.",
body: "Body for Foo Bar",
},
{
directoryName: "foo-bar",
name: "foo-bar",
description: "Second skill.",
body: "Body for foo-bar",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(electronApp, "plain prompt", ["foo-bar"]);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 2);
assert.equal(status.skills.every((skill) => skill.status === "warning"), true);
assert.equal(
status.skills.every((skill) =>
skill.warnings.some((warning) => warning.includes('Duplicate skill slug "foo-bar"')),
),
true,
);
assert.equal(result.context.includes("Body for Foo Bar"), false);
assert.equal(result.context.includes("Body for foo-bar"), false);
},
);
});
test("skills without a usable ASCII slug are downgraded to warnings", async () => {
await withUserSkills(
[
{
directoryName: "部署助手",
name: "部署助手",
description: "Deployment helper.",
body: "Body for 部署助手",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
assert.equal(status.readyCount, 0);
assert.equal(status.warningCount, 1);
assert.equal(status.skills[0]?.status, "warning");
assert.equal(status.skills[0]?.slug, "");
assert.match(
status.skills[0]?.warnings?.[0] || "",
/usable slug/i,
);
},
);
});
test("explicit selections are capped to stay within the prompt budget", async () => {
await withUserSkills(
[
{
directoryName: "Skill One",
name: "Skill One",
description: "Helper one.",
body: "BODY_ONE_" + "a".repeat(3500),
},
{
directoryName: "Skill Two",
name: "Skill Two",
description: "Helper two.",
body: "BODY_TWO_" + "b".repeat(3500),
},
{
directoryName: "Skill Three",
name: "Skill Three",
description: "Helper three.",
body: "BODY_THREE_" + "c".repeat(3500),
},
{
directoryName: "Skill Four",
name: "Skill Four",
description: "Helper four.",
body: "BODY_FOUR_" + "d".repeat(3500),
},
{
directoryName: "Skill Five",
name: "Skill Five",
description: "Helper five.",
body: "BODY_FIVE_" + "e".repeat(3500),
},
],
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
["skill-one", "skill-two", "skill-three", "skill-four", "skill-five"],
);
assert.equal(result.context.includes("BODY_ONE_"), true);
assert.equal(result.context.includes("BODY_TWO_"), true);
assert.equal(result.context.includes("BODY_THREE_"), true);
assert.equal(result.context.includes("BODY_FOUR_"), false);
assert.equal(result.context.includes("BODY_FIVE_"), false);
assert.match(result.context, /prompt budget|additional selected/i);
},
);
});

View File

@@ -15,6 +15,11 @@ const { existsSync } = fs;
const mcpServerBridge = require("./mcpServerBridge.cjs");
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
const {
scanUserSkills,
buildUserSkillsContext,
toPublicUserSkillsStatus,
} = require("./ai/userSkills.cjs");
// ── Extracted modules ──
const {
@@ -99,7 +104,8 @@ function getSkillsCliInvocation() {
};
}
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession }) {
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession, userSkillsContext }) {
const userSkillsPreamble = userSkillsContext ? `${userSkillsContext}\n\n` : "";
if (mode === "skills") {
const { commandPrefix: cliCommandPrefix, launcherPath, usesLauncher } = getSkillsCliInvocation();
const skillHint = existsSync(NETCATTY_TOOL_SKILL_PATH)
@@ -137,6 +143,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
: `Start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `;
return (
`${userSkillsPreamble}` +
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
`${skillHint}` +
`${cliHint}` +
@@ -165,6 +172,7 @@ function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defau
}
return (
`${userSkillsPreamble}` +
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
@@ -766,6 +774,41 @@ function streamRequest(url, options, event, requestId, skipTLS) {
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:user-skills:status", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
return { ok: true, ...toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:open", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const status = await scanUserSkills(electronModule?.app);
const openResult = await electronModule?.shell?.openPath?.(status.directoryPath);
return {
ok: !openResult,
error: openResult || undefined,
...toPublicUserSkillsStatus(status),
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:user-skills:build-context", async (event, { prompt, selectedSkillSlugs }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const { context, status } = await buildUserSkillsContext(electronModule?.app, prompt, selectedSkillSlugs);
return { ok: true, context, status: toPublicUserSkillsStatus(status) };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
// ── Provider config sync (renderer → main, keys stay encrypted) ──
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
if (!validateSenderOrSettings(event)) return { ok: false };
@@ -2186,12 +2229,9 @@ function registerHandlers(ipcMain) {
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
if (isClaudeAgent && apiKey) {
agentEnv.ANTHROPIC_API_KEY = apiKey;
}
if (isClaudeAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.ANTHROPIC_BASE_URL = resolvedProvider.provider.baseURL;
}
// Claude agent auth is owned entirely by its CLI config/login state
// (`claude auth login`, ~/.claude settings, or ANTHROPIC_* in the user's
// shell env). netcatty's provider list must not override it.
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
@@ -2268,7 +2308,7 @@ function registerHandlers(ipcMain) {
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession }) => {
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
@@ -2373,7 +2413,7 @@ function registerHandlers(ipcMain) {
}
}
const authFingerprint = isCodexAgent || isClaudeAgent
const authFingerprint = isCodexAgent
? getAcpProviderAuthFingerprint(apiKey, resolvedProvider?.provider, codexCustomConfig)
: null;
const mcpSnapshot = isCodexAgent
@@ -2448,12 +2488,7 @@ function registerHandlers(ipcMain) {
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
if (isClaudeAgent && apiKey) {
agentEnv.ANTHROPIC_API_KEY = apiKey;
}
if (isClaudeAgent && resolvedProvider?.provider?.baseURL) {
agentEnv.ANTHROPIC_BASE_URL = resolvedProvider.provider.baseURL;
}
// See comment above: Claude auth is CLI-owned, not provider-driven.
let copilotConfigInfo = null;
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
@@ -2575,12 +2610,7 @@ function registerHandlers(ipcMain) {
if (isCodexAgent && resolvedProvider?.provider?.baseURL) {
fallbackEnv.OPENAI_BASE_URL = resolvedProvider.provider.baseURL;
}
if (isClaudeAgent && apiKey) {
fallbackEnv.ANTHROPIC_API_KEY = apiKey;
}
if (isClaudeAgent && resolvedProvider?.provider?.baseURL) {
fallbackEnv.ANTHROPIC_BASE_URL = resolvedProvider.provider.baseURL;
}
// See comment above: Claude auth is CLI-owned, not provider-driven.
if (isCopilotAgent) {
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
@@ -2640,6 +2670,7 @@ function registerHandlers(ipcMain) {
prompt,
chatSessionId,
defaultTargetSession,
userSkillsContext,
});
// Build message content: text + optional attachments

View File

@@ -1230,6 +1230,15 @@ const api = {
aiMcpSetToolIntegrationMode: async (mode) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-tool-integration-mode", { mode });
},
aiUserSkillsGetStatus: async () => {
return ipcRenderer.invoke("netcatty:ai:user-skills:status");
},
aiUserSkillsOpenFolder: async () => {
return ipcRenderer.invoke("netcatty:ai:user-skills:open");
},
aiUserSkillsBuildContext: async (prompt, selectedSkillSlugs) => {
return ipcRenderer.invoke("netcatty:ai:user-skills:build-context", { prompt, selectedSkillSlugs });
},
// MCP approval gate: renderer receives approval requests from main process
onMcpApprovalRequest: (cb) => {
const handler = (_event, payload) => cb(payload);
@@ -1246,8 +1255,8 @@ const api = {
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
},
// ACP streaming
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession });
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession, userSkillsContext });
},
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });

58
global.d.ts vendored
View File

@@ -6,16 +6,13 @@ declare module "*.cjs" {
export = value;
}
declare global {
// Extend HTMLInputElement to support webkitdirectory attribute
namespace JSX {
interface IntrinsicElements {
input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement> & {
webkitdirectory?: string;
}, HTMLInputElement>;
}
declare module 'react' {
interface InputHTMLAttributes<T> extends HTMLAttributes<T> {
webkitdirectory?: string | boolean;
}
}
declare global {
// Proxy configuration for SSH connections
interface NetcattyProxyConfig {
type: 'http' | 'socks5';
@@ -805,11 +802,54 @@ declare global {
connected: boolean;
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
aiMcpSetToolIntegrationMode?(mode: 'mcp' | 'skills'): Promise<{ ok: boolean; error?: string }>;
aiUserSkillsGetStatus?(): Promise<{
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: Array<{
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: 'ready' | 'warning';
warnings: string[];
}>;
warnings?: string[];
error?: string;
}>;
aiUserSkillsOpenFolder?(): Promise<{
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: Array<{
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: 'ready' | 'warning';
warnings: string[];
}>;
warnings?: string[];
error?: string;
}>;
aiUserSkillsBuildContext?(prompt: string, selectedSkillSlugs?: string[]): Promise<{
ok: boolean;
context?: string;
error?: string;
}>;
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }, userSkillsContext?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;

View File

@@ -48,6 +48,7 @@ interface AcpBridge {
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
@@ -87,6 +88,7 @@ export async function runAcpAgentTurn(
images?: FileAttachment[],
toolIntegrationMode?: AIToolIntegrationMode,
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<void> {
const acpBridge = bridge as unknown as AcpBridge;
@@ -161,6 +163,7 @@ export async function runAcpAgentTurn(
images?.length ? images : undefined,
toolIntegrationMode,
defaultTargetSession,
userSkillsContext,
).then((result) => {
if (result?.ok === false) {
settle(() => {

View File

@@ -14,10 +14,11 @@ export interface SystemPromptContext {
}>;
permissionMode: 'observer' | 'confirm' | 'autonomous';
webSearchEnabled?: boolean;
userSkillsContext?: string;
}
export function buildSystemPrompt(context: SystemPromptContext): string {
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled } = context;
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled, userSkillsContext } = context;
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
const hostList = buildHostList(hosts);
@@ -59,7 +60,8 @@ ${permissionRules}
10. **Network device sessions.** Sessions with \`protocol: serial\` (shell: raw) or \`deviceType: network\` (SSH-connected network equipment) are connected to network devices or embedded systems. They do NOT run a standard shell (bash/zsh/etc). Commands are sent as-is without shell wrapping. Do not use shell syntax (pipes, redirects, environment variables, subshells). Use the device's native CLI commands (e.g. Cisco IOS, Huawei VRP, Juniper JunOS). Exit codes are unavailable. Consider disabling pagination first (\`screen-length 0 temporary\` for Huawei, \`terminal length 0\` for Cisco). SFTP is not available for serial sessions.${webSearchEnabled ? `
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
11. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}
${userSkillsContext ? `\n\n## User Skills\n\n${userSkillsContext}` : ''}`;
}
function buildScopeDescription(

View File

@@ -1,4 +1,4 @@
import type { DiscoveredAgent, ExternalAgentConfig, ProviderConfig } from './types';
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
@@ -68,23 +68,3 @@ export function getManagedAgentStoredPath(
return fallbackAgent?.command ?? null;
}
export function findManagedAgentProvider(
providers: ProviderConfig[],
agentKey: ManagedAgentKey,
): ProviderConfig | undefined {
if (agentKey === 'codex') {
return (
providers.find((provider) => provider.providerId === 'openai' && provider.enabled && !!provider.apiKey)
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
);
}
if (agentKey === 'claude') {
return (
providers.find((provider) => provider.providerId === 'anthropic' && provider.enabled && !!provider.apiKey)
?? providers.find((provider) => provider.providerId === 'custom' && provider.enabled && !!provider.apiKey && !!provider.baseURL)
);
}
return undefined;
}

38
package-lock.json generated
View File

@@ -1154,7 +1154,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1800,6 +1799,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1821,6 +1821,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1837,6 +1838,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1851,6 +1853,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3306,7 +3309,6 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -6295,7 +6297,6 @@
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "*"
}
@@ -6376,7 +6377,6 @@
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0",
@@ -6406,7 +6406,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -6936,7 +6935,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6987,7 +6985,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7548,7 +7545,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8291,7 +8287,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8575,7 +8572,6 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -8957,6 +8953,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8977,6 +8974,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9206,7 +9204,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10555,7 +10552,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12045,7 +12041,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -12663,8 +12658,7 @@
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
@@ -12919,6 +12913,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -12931,7 +12926,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -13691,6 +13685,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13708,6 +13703,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13898,7 +13894,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13908,7 +13903,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -15229,6 +15223,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15293,6 +15288,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -15367,7 +15363,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15560,7 +15555,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15581,7 +15575,6 @@
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "^3.0.0",
"bail": "^2.0.0",
@@ -15920,7 +15913,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16014,7 +16006,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16293,7 +16284,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}