/** 独立自动化的 Cordis Host 插件入口。 */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-connection' import z from '@deepseek-ai/schemastery' import { readSessionEvents } from './executor.ts' import { AUTOMATION_PROMPT_NAME, AUTOMATION_PROMPT_ORDER, AUTOMATION_PROMPT_TEXT } from './prompt.ts' import { registerAutomationRpc } from './rpc.ts' import { AutomationService } from './service.ts' import { registerAutomationTools } from './tools.ts' import { registerPluginUpdater } from './plugin-updater.ts' export const name = 'dsh-automation' export const inject = [ 'storageDomain', 'agents', 'sessions', 'workspaceRegistry', 'agentDefaultModel', 'agentPresets', 'permissionPresets', 'tools', 'connection', 'llm', 'webServer', ] export interface Config { readonly maxConcurrentRuns?: number readonly runTimeoutMinutes?: number readonly misfireGraceMinutes?: number readonly historyLimit?: number } export const Config = z.object({ maxConcurrentRuns: z.number().step(1).min(1).max(32).default(2), runTimeoutMinutes: z.number().step(1).min(1).max(1_440).default(60), misfireGraceMinutes: z.number().step(1).min(0).max(10_080).default(15), historyLimit: z.number().step(1).min(1).max(5_000).default(200), }) const MUTATING_TOOLS = new Set([ 'automation_create', 'automation_update', 'automation_run_now', 'automation_delete', ]) export type SessionApprovalPolicy = 'ask' | 'never' export interface ApprovalPolicyReader { readonly config?: { readonly policy?: SessionApprovalPolicy } overrideOf?(session: unknown): SessionApprovalPolicy | undefined } /** 读取当前会话实际审批策略;自定义权限预设也以 Host 投影结果为准。 */ export function sessionApprovalPolicy( approval: ApprovalPolicyReader | undefined, session: unknown, ): SessionApprovalPolicy | undefined { const override = approval?.overrideOf?.(session) if (override === 'ask' || override === 'never') return override const fallback = approval?.config?.policy if (fallback === 'ask' || fallback === 'never') return fallback return undefined } /** * 只在实际 ask 策略下二次确认。never 策略再 ask, * 会被映射成 “the user rejected tool”,且不会弹窗。 */ export function needsHumanApproval( exec: { readonly name: string; readonly arguments?: unknown; readonly signal: AbortSignal }, isMountedAgent: boolean, policy?: SessionApprovalPolicy, ): boolean { if (!isMountedAgent || exec.signal.aborted || !MUTATING_TOOLS.has(exec.name)) return false if (policy !== 'ask') return false if (exec.name !== 'automation_update') return true const args = typeof exec.arguments === 'object' && exec.arguments !== null ? exec.arguments as Record : {} return !(args.status === 'paused' && Object.keys(args).every(key => key === 'id' || key === 'status')) } export function humanApprovalReason(toolName: string): string { return toolName === 'automation_delete' ? '此操作会永久删除自动化定义。运行历史会保留,但计划无法自动恢复。' : '此操作会创建或扩大无人值守的未来工作。请核对任务说明、计划、工作区和权限边界。' } export async function apply(ctx: Context, rawConfig: Config): Promise { ctx.effect(() => registerPluginUpdater(ctx, { endpoint: '/api/michengai/dsh-automation/update', packageName: '@michengai/dsh-automation', manifestUrl: new URL('../package.json', import.meta.url), }), 'dsh-automation: plugin updater') const config = rawConfig as Required await ctx.effect(async () => { let alive = true const service = await AutomationService.open(ctx, { maxConcurrentRuns: config.maxConcurrentRuns, runTimeoutMs: config.runTimeoutMinutes * 60_000, misfireGraceMs: config.misfireGraceMinutes * 60_000, historyLimit: config.historyLimit, }) const agentTools = new Map void | Promise>() let cleaned = false let stopCreated = () => {} let stopDisposed = () => {} let stopApproval = () => {} let stopPrompt = () => {} let stopSessionGone = () => {} let removeRpc = async (): Promise => {} const cleanup = async (): Promise => { if (cleaned) return cleaned = true alive = false for (const stop of [stopCreated, stopDisposed, stopApproval, stopPrompt, stopSessionGone]) { try { stop() } catch (error: unknown) { ctx.logger.warn(`dsh-automation: lifecycle cleanup failed: ${String(error)}`) } } const results = await Promise.allSettled([ removeRpc(), ...[...agentTools.values()].reverse().map(dispose => Promise.resolve().then(dispose)), ]) for (const result of results) { if (result.status === 'rejected') { ctx.logger.warn(`dsh-automation: contribution cleanup failed: ${String(result.reason)}`) } } agentTools.clear() await service.dispose() } try { const mountTools = (agent: any): void => { if (!alive || agentTools.has(agent) || service.ownsSession(String(agent.id), readSessionEvents(agent.session))) return if (!ctx.agents.roots().includes(agent)) return const dispose = agent.ctx.effect( () => registerAutomationTools(service, agent), 'dsh-automation: management tools', ) agentTools.set(agent, dispose) } for (const agent of ctx.agents.roots()) mountTools(agent) stopCreated = ctx.on('agent/created', ({ agent }: any) => { mountTools(agent) }) stopDisposed = ctx.on('agent/disposed', ({ agent }: any) => { agentTools.delete(agent) }) stopSessionGone = ctx.on('session/disposed', (session: { readonly id?: string }) => { const id = String(session?.id ?? '') if (id === '') return void service.forgetSession(id) }) const systemPrompt = ctx.get('systemPrompt') as { section?(input: { name: string; order: number; text: string }): () => void } | undefined if (typeof systemPrompt?.section === 'function') { stopPrompt = systemPrompt.section({ name: AUTOMATION_PROMPT_NAME, order: AUTOMATION_PROMPT_ORDER, text: AUTOMATION_PROMPT_TEXT, }) } stopApproval = ctx.on('tools/pre-execute', async (exec: any, next: () => Promise) => { const downstream = await next() const approval = ctx.get('approval') as ApprovalPolicyReader | undefined const policy = sessionApprovalPolicy(approval, exec.agent?.session) if (downstream.kind !== 'allow' || !needsHumanApproval(exec, agentTools.has(exec.agent), policy)) { return downstream } return { kind: 'ask' as const, reason: humanApprovalReason(exec.name), } }) removeRpc = registerAutomationRpc(ctx, service) const loader = ctx.get('loader') as { await(): Promise } | undefined if (loader === undefined) service.start() else { void loader.await().then(() => { if (alive) service.start() }, (error: unknown) => { if (alive) ctx.logger.warn(`dsh-automation: Loader did not settle; clock remains stopped: ${String(error)}`) }) } return cleanup } catch (error) { await cleanup() throw error } }, 'dsh-automation: host service') } export type * from './types.ts' export { automationDomainSpec } from './domain.ts'