/** * Host loader entry for the task-board plugin. * * The Host owns the v2 ledger, action API, cron scheduler, session runner, * execution reconciliation, and optional idle-sleep inhibitor. The browser is * a same-origin asynchronous view over that service. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-commands' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-api-gateway' import type {} from '@deepseek-ai/dsh-workspace' import type {} from '@deepseek-ai/dsh-host-webserver' import type { LlmRuntime } from '@deepseek-ai/dsh-llm' import { TaskBoardHostService } from './host-service.ts' import { parseTaskDraft, TaskParseError } from './host-ai.ts' import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts' import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts' import { makeTaskBoardRoutes } from './host-routes.ts' import { mountOnce } from './mount-once.ts' /** Order of the announcement section within the tool-guidance band. */ const SECTION_ORDER = 200 /** Default environment variable holding the authenticated proxy token. */ export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN' export const inject = ['systemPrompt', 'typertGateway', 'workspaceRegistry', 'webServer', 'agents', 'commands'] /** Model-facing announcement: plugin presence, capabilities, and limits. */ export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。' /** * Settings namespace of the board's announcement capability — the section the * web settings surface edits. Spelled here rather than imported: the browser * half spells the same value and must not depend on a Host package. */ export const TASK_BOARD_SETTINGS_NAMESPACE = 'task-board' as SettingsNamespace /** Plugin config, validated by the same-named schemastery schema. */ export interface Config { /** * When true (default), a system-prompt section announces the board to every * agent. Set false to keep the board silent in prompts; agents then learn * about it only when the user mentions it. */ announceToAgent?: boolean /** Master switch for the plugin (browser half + host announcement). */ enabled?: boolean /** Prevent idle system sleep while sessions run or schedules are armed. */ preventIdleSleep?: boolean /** Canonical reverse-proxy Host authorities admitted with a server-side token. */ trustedProxyHosts?: string[] /** Environment variable whose value the authenticated proxy injects upstream. */ proxyTokenEnv?: string /** * The deployment's session-default permission. A card whose effective * permission (handover bundle or pin) is above this value requires a human * confirmation before it may run; cron refuses unconfirmed cards. */ sessionDefaultPermission?: TaskPermission } export const Config: z = z.object({ announceToAgent: z.boolean().default(false), enabled: z.boolean().default(true), preventIdleSleep: z.boolean().default(false), trustedProxyHosts: z.array(z.string()).default([]), proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV), sessionDefaultPermission: z.union(TASK_PERMISSIONS).default(DEFAULT_SESSION_PERMISSION), }) /** Resolve proxy access without ever placing the token value in plugin config. */ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.ProcessEnv = process.env): { trustedProxyHosts: string[]; proxyToken?: string } { const trustedProxyHosts = config?.trustedProxyHosts ?? [] if (trustedProxyHosts.length === 0) return { trustedProxyHosts } const proxyTokenEnv = config?.proxyTokenEnv ?? DEFAULT_PROXY_TOKEN_ENV if (proxyTokenEnv.trim() === '') throw new Error('task-board: proxyTokenEnv must not be empty') const proxyToken = env[proxyTokenEnv] if (proxyToken === undefined || proxyToken === '') { throw new Error(`task-board: trustedProxyHosts requires a non-empty ${proxyTokenEnv} environment variable`) } return { trustedProxyHosts, proxyToken } } /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */ const DEFAULT_ANNOUNCE = false /** * Read the optional `llm` service. The board deliberately does not inject it: * a deployment without a model must still mount the board, and the parse route * answers a typed failure instead of the plugin failing to load (issue #1540). * @param ctx - the plugin context. * @returns the llm service, or undefined when this deployment serves none. */ export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined { try { const llm = ctx.get('llm') as LlmRuntime | undefined return llm !== undefined && typeof (llm as { stream?: unknown }).stream === 'function' ? llm : undefined } catch { return undefined } } /** * Register the board's announcement section, gated on the composition entry's * `announceToAgent` (and the live settings value once the web settings * surface is served). The section is re-registered whenever the source * changes, so a settings edit takes effect without a restart. * @param ctx - the plugin context (systemPrompt injected). * @param config - resolved plugin config (schema defaults applied by the loader). */ export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl) function applyImpl(ctx: Context, config?: Config): void { const host = new TaskBoardHostService(ctx.typertGateway, { workspaceRegistry: ctx.workspaceRegistry, sessionDefaultPermission: config?.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION, commandDispatcher: { async execute(sessionId, line, signal) { const agent = ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`execution session ${sessionId} is not available`) return (await ctx.commands.execute(agent, line, [], signal))?.result }, }, }) host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false) host.start() ctx.effect(() => { const disposers: Array<() => void> = [] try { const routes = makeTaskBoardRoutes(host, resolveProxyAccess(config), { parseTask: async (request, signal) => { const llm = resolveLlmRuntime(ctx) if (llm === undefined) throw new TaskParseError('no-model', 'this deployment serves no llm service') return await parseTaskDraft(llm, request, signal) }, }) for (const route of routes) disposers.push(ctx.webServer.register(route)) } catch (error) { for (const dispose of disposers) dispose() host.dispose() throw error } return () => { for (const dispose of disposers) dispose() host.dispose() } }, 'task-board: host ledger, scheduler, and routes') // The live source the announcement reads: the settings section once the web // settings surface is served, the composition entry otherwise // (installSection swaps it when the namespace registers). let current: () => Config = () => config ?? {} let disposeSection: (() => void) | undefined // Register (or drop) the announcement to match the current source. The // section is kept under one disposer: re-registering first tears the old // one down so a duplicate-name registration never throws. const sync = (): void => { if (disposeSection !== undefined) { disposeSection() disposeSection = undefined } const active = current().enabled ?? true host.setConfiguration(active, current().preventIdleSleep ?? false) if (!active) return if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return disposeSection = ctx.systemPrompt.section({ name: 'plugin:task-board', order: SECTION_ORDER, text: TASK_BOARD_GUIDANCE, }) } ctx.inject(['settings'], (settingsCtx) => { try { if (typeof settingsCtx.settings?.installSection === 'function') { settingsCtx.settings.installSection(ctx, TASK_BOARD_SETTINGS_NAMESPACE, Config, config ?? {}, { setSource: (source) => { current = source }, onChange: sync, }) } else if (typeof settingsCtx.settings?.register === 'function') { const scope = settingsCtx.settings.register(TASK_BOARD_SETTINGS_NAMESPACE, Config, { base: config ?? {} }) current = () => scope?.get?.() ?? (config ?? {}) scope?.watch?.(() => { sync() }) } } catch { // Defensive fallback against settings registration differences } }) // Initial registration from the composition entry (covers deployments with // no settings service, whose installSection never fires its hooks). sync() }