|
@@ -1,8 +1,5 @@
|
|
|
// Claude 适配器:Agent SDK → 统一事件(docs/design/03-providers.md §2,ADR-0002)
|
|
// Claude 适配器:Agent SDK → 统一事件(docs/design/03-providers.md §2,ADR-0002)
|
|
|
-import { execFile } from 'node:child_process'
|
|
|
|
|
-import fs from 'node:fs'
|
|
|
|
|
import path from 'node:path'
|
|
import path from 'node:path'
|
|
|
-import { promisify } from 'node:util'
|
|
|
|
|
import {
|
|
import {
|
|
|
query,
|
|
query,
|
|
|
type CanUseTool,
|
|
type CanUseTool,
|
|
@@ -16,9 +13,10 @@ import { toClaudeSandbox } from '../../services/access-policy'
|
|
|
import { newId } from '../../lib/ids'
|
|
import { newId } from '../../lib/ids'
|
|
|
import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
|
|
import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
|
|
|
import { DEFAULT_DISALLOWED_TOOLS, decideTool, toolTitle, type PolicyContext } from './policy'
|
|
import { DEFAULT_DISALLOWED_TOOLS, decideTool, toolTitle, type PolicyContext } from './policy'
|
|
|
|
|
+import { runClaudeCli } from './cli'
|
|
|
|
|
+import { localPlugin, resolveSkills } from './skills'
|
|
|
import { OFFICE_SERVER, officeServer } from './tools'
|
|
import { OFFICE_SERVER, officeServer } from './tools'
|
|
|
|
|
|
|
|
-const execFileP = promisify(execFile)
|
|
|
|
|
const MAX_TOOL_OUTPUT = 64 * 1024
|
|
const MAX_TOOL_OUTPUT = 64 * 1024
|
|
|
|
|
|
|
|
export class ClaudeProvider implements AgentProvider {
|
|
export class ClaudeProvider implements AgentProvider {
|
|
@@ -27,6 +25,18 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
private sessionAllowed = new Map<string, Set<string>>()
|
|
private sessionAllowed = new Map<string, Set<string>>()
|
|
|
|
|
|
|
|
async run(ctx: RunContext): Promise<RunResult> {
|
|
async run(ctx: RunContext): Promise<RunResult> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return await this.runOnce(ctx, ctx.providerSessionId)
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ // CLI 中找不到原会话记录(例如更换了运行账号或清理了 ~/.claude),以新上下文重试一次
|
|
|
|
|
+ if (!(err instanceof ProviderRunError) || err.code !== 'session_not_found' || !ctx.providerSessionId) throw err
|
|
|
|
|
+ ctx.log.warn({ providerSessionId: ctx.providerSessionId }, 'claude 会话记录不存在,以新上下文继续')
|
|
|
|
|
+ const note = '(系统说明:此前的对话上下文已无法恢复,请仅根据工作区中的文件和下面的消息继续。)\n\n'
|
|
|
|
|
+ return this.runOnce({ ...ctx, input: { ...ctx.input, text: note + ctx.input.text } }, null)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async runOnce(ctx: RunContext, resume: string | null): Promise<RunResult> {
|
|
|
const allowed = this.sessionAllowed.get(ctx.sessionId) ?? new Set<string>()
|
|
const allowed = this.sessionAllowed.get(ctx.sessionId) ?? new Set<string>()
|
|
|
this.sessionAllowed.set(ctx.sessionId, allowed)
|
|
this.sessionAllowed.set(ctx.sessionId, allowed)
|
|
|
const policyCtx: PolicyContext = {
|
|
const policyCtx: PolicyContext = {
|
|
@@ -94,7 +104,7 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
options: {
|
|
options: {
|
|
|
cwd: ctx.workspaceDir,
|
|
cwd: ctx.workspaceDir,
|
|
|
model: ctx.model,
|
|
model: ctx.model,
|
|
|
- resume: ctx.providerSessionId ?? undefined,
|
|
|
|
|
|
|
+ resume: resume ?? undefined,
|
|
|
pathToClaudeCodeExecutable: ctx.settings.providers.claude.cliPath,
|
|
pathToClaudeCodeExecutable: ctx.settings.providers.claude.cliPath,
|
|
|
settingSources: [],
|
|
settingSources: [],
|
|
|
includePartialMessages: true,
|
|
includePartialMessages: true,
|
|
@@ -103,7 +113,7 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
allowedTools: ctx.allowedTools.length ? ctx.allowedTools : undefined,
|
|
allowedTools: ctx.allowedTools.length ? ctx.allowedTools : undefined,
|
|
|
disallowedTools: [...DEFAULT_DISALLOWED_TOOLS, ...ctx.disallowedTools],
|
|
disallowedTools: [...DEFAULT_DISALLOWED_TOOLS, ...ctx.disallowedTools],
|
|
|
...skillsOptions(ctx),
|
|
...skillsOptions(ctx),
|
|
|
- mcpServers: { [OFFICE_SERVER]: officeServer(ctx.workspaceDir) },
|
|
|
|
|
|
|
+ mcpServers: { [OFFICE_SERVER]: officeServer(ctx.workspaceDir, ctx.settings.paths.pythonRuntime) },
|
|
|
sandbox: toClaudeSandbox(ctx.access, ctx.settings),
|
|
sandbox: toClaudeSandbox(ctx.access, ctx.settings),
|
|
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
|
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
|
|
|
canUseTool,
|
|
canUseTool,
|
|
@@ -162,18 +172,15 @@ export class ClaudeProvider implements AgentProvider {
|
|
|
cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null,
|
|
cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null,
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
|
- const [{ stdout: ver }, { stdout: auth }] = await Promise.all([
|
|
|
|
|
- execFileP(s.cliPath, ['--version'], { timeout: 15000 }),
|
|
|
|
|
- execFileP(s.cliPath, ['auth', 'status', '--json'], { timeout: 15000 }),
|
|
|
|
|
- ])
|
|
|
|
|
- const info = JSON.parse(auth) as { loggedIn?: boolean; authMethod?: string; subscriptionType?: string; email?: string }
|
|
|
|
|
|
|
+ const [ver, auth] = await Promise.all([runClaudeCli(s.cliPath, ['--version'], { timeoutMs: 15000 }), runClaudeCli(s.cliPath, ['auth', 'status', '--json'], { timeoutMs: 15000 })])
|
|
|
|
|
+ const info = JSON.parse(auth.stdout) as { loggedIn?: boolean; subscriptionType?: string; email?: string }
|
|
|
return {
|
|
return {
|
|
|
...base,
|
|
...base,
|
|
|
- version: ver.trim().split(' ')[0] ?? null,
|
|
|
|
|
|
|
+ version: ver.stdout.trim().split(' ')[0] || null,
|
|
|
loggedIn: !!info.loggedIn,
|
|
loggedIn: !!info.loggedIn,
|
|
|
available: s.enabled && !!info.loggedIn,
|
|
available: s.enabled && !!info.loggedIn,
|
|
|
account: info.loggedIn ? [info.email, info.subscriptionType && `(${info.subscriptionType})`].filter(Boolean).join(' ') : null,
|
|
account: info.loggedIn ? [info.email, info.subscriptionType && `(${info.subscriptionType})`].filter(Boolean).join(' ') : null,
|
|
|
- message: info.loggedIn ? null : '未登录:请在终端执行 claude 并完成 /login',
|
|
|
|
|
|
|
+ message: info.loggedIn ? null : '未登录:请在服务器终端执行 agent-claude,然后输入 /login(开发环境直接执行 claude)',
|
|
|
}
|
|
}
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
return { ...base, message: `无法执行 ${s.cliPath}:${(err as Error).message}` }
|
|
return { ...base, message: `无法执行 ${s.cliPath}:${(err as Error).message}` }
|
|
@@ -203,32 +210,9 @@ function buildUserMessage(ctx: RunContext): SDKUserMessage {
|
|
|
return { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null } as SDKUserMessage
|
|
return { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null } as SDKUserMessage
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/**
|
|
|
|
|
- * 共享技能目录按 Claude 插件格式组织:<dir>/.claude-plugin/plugin.json + <dir>/skills/<name>/SKILL.md。
|
|
|
|
|
- * 必须显式列出技能:省略 skills 时 CLI 会加载用户自己的技能(包括 claude.ai 同步的技能)。
|
|
|
|
|
- */
|
|
|
|
|
-function skillsOptions(ctx: RunContext): { plugins?: { type: 'local'; path: string }[]; skills: string[] } {
|
|
|
|
|
- const dir = ctx.settings.paths.skillsDir
|
|
|
|
|
- let pluginName: string
|
|
|
|
|
- try {
|
|
|
|
|
- pluginName = (JSON.parse(fs.readFileSync(path.join(dir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
|
|
|
|
|
- } catch {
|
|
|
|
|
- return { skills: [] }
|
|
|
|
|
- }
|
|
|
|
|
- const available = listSkills(dir)
|
|
|
|
|
- const chosen = ctx.skills === 'all' ? available : ctx.skills.filter(s => available.includes(s))
|
|
|
|
|
- return { plugins: [{ type: 'local', path: dir }], skills: chosen.map(n => `${pluginName}:${n}`) }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export function listSkills(dir: string): string[] {
|
|
|
|
|
- try {
|
|
|
|
|
- return fs.readdirSync(path.join(dir, 'skills'), { withFileTypes: true })
|
|
|
|
|
- .filter(d => d.isDirectory() && fs.existsSync(path.join(dir, 'skills', d.name, 'SKILL.md')))
|
|
|
|
|
- .map(d => d.name)
|
|
|
|
|
- .sort()
|
|
|
|
|
- } catch {
|
|
|
|
|
- return []
|
|
|
|
|
- }
|
|
|
|
|
|
|
+function skillsOptions(ctx: RunContext) {
|
|
|
|
|
+ const local = localPlugin(ctx.settings.paths.skillsDir)
|
|
|
|
|
+ return resolveSkills(ctx.skills, [...(local ? [local] : []), ...ctx.plugins])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function agentEnv(ctx: RunContext): Record<string, string | undefined> {
|
|
function agentEnv(ctx: RunContext): Record<string, string | undefined> {
|
|
@@ -239,6 +223,7 @@ function agentEnv(ctx: RunContext): Record<string, string | undefined> {
|
|
|
PATH: `${path.join(py, 'bin')}:${process.env.PATH ?? ''}`,
|
|
PATH: `${path.join(py, 'bin')}:${process.env.PATH ?? ''}`,
|
|
|
VIRTUAL_ENV: py,
|
|
VIRTUAL_ENV: py,
|
|
|
MATPLOTLIBRC: ctx.settings.paths.matplotlibrc,
|
|
MATPLOTLIBRC: ctx.settings.paths.matplotlibrc,
|
|
|
|
|
+ NODE_PATH: ctx.settings.paths.nodeModules,
|
|
|
MPLCONFIGDIR: path.join(agentDir, 'mpl'),
|
|
MPLCONFIGDIR: path.join(agentDir, 'mpl'),
|
|
|
// 不覆盖 TMPDIR:沙箱在其中创建 Unix socket,路径过长会导致沙箱无法启动
|
|
// 不覆盖 TMPDIR:沙箱在其中创建 Unix socket,路径过长会导致沙箱无法启动
|
|
|
CLAUDE_AGENT_SDK_CLIENT_APP: 'agent-server/0.1.0',
|
|
CLAUDE_AGENT_SDK_CLIENT_APP: 'agent-server/0.1.0',
|
|
@@ -299,6 +284,7 @@ class MessageMapper {
|
|
|
}
|
|
}
|
|
|
if (m.subtype !== 'success' || m.is_error) {
|
|
if (m.subtype !== 'success' || m.is_error) {
|
|
|
const detail = m.subtype === 'success' ? m.result : m.errors.join('; ') || m.subtype
|
|
const detail = m.subtype === 'success' ? m.result : m.errors.join('; ') || m.subtype
|
|
|
|
|
+ if (/No conversation found with session ID/i.test(detail)) throw new ProviderRunError('session_not_found', detail)
|
|
|
throw new ProviderRunError(m.subtype, detail || '运行失败')
|
|
throw new ProviderRunError(m.subtype, detail || '运行失败')
|
|
|
}
|
|
}
|
|
|
return { usage }
|
|
return { usage }
|