@linxin666/dsh-client-ui-task-board 0.1.19 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +82 -104
  3. package/README.zh.md +82 -104
  4. package/lib/client.js +589 -602
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2187 -6
  7. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
  9. package/lib/types/client/board/TaskCard.d.ts +5 -2
  10. package/lib/types/client/board/status-key.d.ts +4 -0
  11. package/lib/types/client/host-api.d.ts +18 -0
  12. package/lib/types/client/locales.d.ts +10 -0
  13. package/lib/types/core/controller.d.ts +40 -15
  14. package/lib/types/core/schedule.d.ts +4 -2
  15. package/lib/types/core/tasks.d.ts +7 -5
  16. package/lib/types/core/use-cases/task-archive.d.ts +5 -4
  17. package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
  18. package/lib/types/dsh-home.d.ts +16 -0
  19. package/lib/types/host-ledger.d.ts +42 -0
  20. package/lib/types/host-routes.d.ts +24 -0
  21. package/lib/types/host-runner.d.ts +30 -0
  22. package/lib/types/host-service.d.ts +41 -0
  23. package/lib/types/index.d.ts +20 -10
  24. package/lib/types/loopback.d.ts +24 -0
  25. package/lib/types/mount-once.d.ts +24 -0
  26. package/lib/types/power-inhibitor.d.ts +73 -0
  27. package/lib/types/protocol.d.ts +72 -0
  28. package/package.json +12 -9
  29. package/src/client/PluginSettingsCard.tsx +10 -0
  30. package/src/client/TaskBoardSettingsCard.tsx +51 -4
  31. package/src/client/board/NewTaskModal.tsx +9 -6
  32. package/src/client/board/TaskBoard.tsx +22 -13
  33. package/src/client/board/TaskCard.tsx +24 -8
  34. package/src/client/board/TaskDetail.tsx +84 -59
  35. package/src/client/board/status-key.ts +11 -0
  36. package/src/client/host-api.ts +95 -0
  37. package/src/client/index.ts +9 -78
  38. package/src/client/locales.ts +24 -4
  39. package/src/core/controller.ts +205 -24
  40. package/src/core/schedule.ts +20 -3
  41. package/src/core/store.ts +5 -14
  42. package/src/core/tasks.ts +7 -7
  43. package/src/core/use-cases/task-archive.ts +14 -5
  44. package/src/core/use-cases/task-schedule.ts +6 -5
  45. package/src/core/use-cases/task-update.ts +1 -6
  46. package/src/dsh-home.ts +36 -0
  47. package/src/host-ledger.ts +478 -0
  48. package/src/host-routes.ts +188 -0
  49. package/src/host-runner.ts +134 -0
  50. package/src/host-service.ts +209 -0
  51. package/src/index.ts +58 -12
  52. package/src/loopback.ts +63 -0
  53. package/src/mount-once.ts +48 -0
  54. package/src/power-inhibitor.ts +321 -0
  55. package/src/protocol.ts +213 -0
  56. package/lib/types/core/scheduler.d.ts +0 -67
  57. package/src/core/scheduler.ts +0 -151
@@ -0,0 +1,134 @@
1
+ import type { ApiProxy, RpcId } from '@deepseek-ai/dsh-host-apiproxy'
2
+ import type { TaskRecord } from './core/tasks.ts'
3
+
4
+ function request<T>(payload: T) {
5
+ return { rpcId: `task-board-${crypto.randomUUID()}` as RpcId, payload }
6
+ }
7
+
8
+ function failure(error: { code: string; message: string }): Error {
9
+ return new Error(`${error.code}: ${error.message}`)
10
+ }
11
+
12
+ export type ExecutionInspection =
13
+ | { outcome: 'pending' }
14
+ | { outcome: 'succeeded' }
15
+ | { outcome: 'failed'; error: string }
16
+ | { outcome: 'cancelled'; error: string }
17
+
18
+ /** A post-create launch failure that still identifies the session to the ledger. */
19
+ export class SessionLaunchError extends Error {
20
+ constructor(readonly sessionId: string, cause: unknown) {
21
+ super(`execution session ${sessionId} failed during launch: ${cause instanceof Error ? cause.message : String(cause)}`, { cause })
22
+ this.name = 'SessionLaunchError'
23
+ }
24
+ }
25
+
26
+ function isErrorTurnEnd(data: unknown): boolean {
27
+ if (typeof data !== 'object' || data === null) return false
28
+ const reason = (data as { reason?: unknown }).reason
29
+ return typeof reason === 'object' && reason !== null && (reason as { kind?: unknown }).kind === 'error'
30
+ }
31
+
32
+ export class HostExecutionRunner {
33
+ constructor(private readonly api: ApiProxy) {}
34
+
35
+ async launch(task: TaskRecord): Promise<string> {
36
+ if (task.workspaceId !== undefined) {
37
+ const workspaces = await this.api.workspace.list(request({}))
38
+ if (!workspaces.result.ok) throw failure(workspaces.result.error)
39
+ if (!workspaces.result.value.items.some(item => item.workspaceId === task.workspaceId)) {
40
+ throw new Error(`workspace not found: ${task.workspaceId}`)
41
+ }
42
+ }
43
+ if (task.mode !== undefined) {
44
+ const presets = await this.api.agentPresets.list(request({}))
45
+ if (!presets.result.ok) throw failure(presets.result.error)
46
+ const preset = presets.result.value.presets.find(item => item.id === task.mode)
47
+ if (preset === undefined) throw new Error(`agent preset not found: ${task.mode}`)
48
+ if (preset.broken !== undefined) throw new Error(`agent preset is unavailable: ${preset.broken}`)
49
+ }
50
+ const created = await this.api.sessions.create(request({
51
+ ...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId as never }),
52
+ ...(task.mode === undefined ? {} : { agentPreset: task.mode }),
53
+ }))
54
+ if (!created.result.ok) throw failure(created.result.error)
55
+ const sessionId = created.result.value.sessionId
56
+ try {
57
+ const renamed = await this.api.sessions.rename(request({ sessionId, title: task.title }))
58
+ if (!renamed.result.ok) throw failure(renamed.result.error)
59
+ if (task.permission !== undefined) {
60
+ const command = await this.api.sessions.prompt(request({
61
+ sessionId,
62
+ mode: 'queue' as const,
63
+ content: [{ type: 'text' as const, text: `/permission ${task.permission}` }],
64
+ }))
65
+ if (!command.result.ok) throw failure(command.result.error)
66
+ if (command.result.value.command?.kind !== 'success') throw new Error('permission command was not acknowledged')
67
+ }
68
+ const prompt = await this.api.sessions.prompt(request({
69
+ sessionId,
70
+ mode: 'queue' as const,
71
+ content: [{ type: 'text' as const, text: task.prompt !== '' ? task.prompt : task.title }],
72
+ }))
73
+ if (!prompt.result.ok) throw failure(prompt.result.error)
74
+ } catch (error) {
75
+ throw new SessionLaunchError(sessionId, error)
76
+ }
77
+ return sessionId
78
+ }
79
+
80
+ async listRunning(): Promise<{ known: true; count: number } | { known: false }> {
81
+ try {
82
+ const response = await this.api.sessions.list(request({}))
83
+ return response.result.ok
84
+ ? { known: true, count: response.result.value.items.filter(item => item.running).length }
85
+ : { known: false }
86
+ } catch {
87
+ return { known: false }
88
+ }
89
+ }
90
+
91
+ async inspect(sessionId: string, startedAt = 0): Promise<ExecutionInspection> {
92
+ const sessions = await this.api.sessions.list(request({}))
93
+ if (!sessions.result.ok) return { outcome: 'pending' }
94
+ const summary = sessions.result.value.items.find(item => item.sessionId === sessionId)
95
+ if (summary === undefined) return { outcome: 'cancelled', error: 'execution session no longer exists' }
96
+ if (summary.running) return { outcome: 'pending' }
97
+ const events: Array<{ event: { type: string; seq?: number; time?: number; data: unknown } }> = []
98
+ let beforeSeq: number | undefined
99
+ let reachedExecutionBoundary = false
100
+ for (let page = 0; page < 100; page += 1) {
101
+ const history = await this.api.sessions.history(request({
102
+ sessionId: summary.sessionId,
103
+ maxMessages: 100,
104
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
105
+ }))
106
+ if (!history.result.ok) return { outcome: 'pending' }
107
+ events.push(...history.result.value.events)
108
+ const oldestTime = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
109
+ const time = entry.event.time
110
+ return typeof time !== 'number' ? oldest : oldest === undefined ? time : Math.min(oldest, time)
111
+ }, undefined)
112
+ if (!history.result.value.hasMore || (oldestTime !== undefined && oldestTime <= startedAt)) {
113
+ reachedExecutionBoundary = true
114
+ break
115
+ }
116
+ const oldestSeq = history.result.value.events.reduce<number | undefined>((oldest, entry) => {
117
+ const seq = entry.event.seq
118
+ return typeof seq !== 'number' ? oldest : oldest === undefined ? seq : Math.min(oldest, seq)
119
+ }, undefined)
120
+ if (oldestSeq === undefined || oldestSeq === beforeSeq) return { outcome: 'pending' }
121
+ beforeSeq = oldestSeq
122
+ }
123
+ if (!reachedExecutionBoundary) return { outcome: 'pending' }
124
+ const turnEnd = events
125
+ .filter(entry => entry.event.type === 'turn/end' && (
126
+ startedAt <= 0 || (typeof entry.event.time === 'number' && entry.event.time >= startedAt)
127
+ ))
128
+ .sort((a, b) => (a.event.seq ?? Number.MAX_SAFE_INTEGER) - (b.event.seq ?? Number.MAX_SAFE_INTEGER))[0]
129
+ if (turnEnd === undefined) return { outcome: 'pending' }
130
+ return isErrorTurnEnd(turnEnd.event.data)
131
+ ? { outcome: 'failed', error: 'agent turn ended with an error' }
132
+ : { outcome: 'succeeded' }
133
+ }
134
+ }
@@ -0,0 +1,209 @@
1
+ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
2
+ import { nextRunAtMs } from './core/schedule.ts'
3
+ import type { TaskRecord } from './core/tasks.ts'
4
+ import { HostTaskLedger, type OpenedRun } from './host-ledger.ts'
5
+ import { HostExecutionRunner, SessionLaunchError } from './host-runner.ts'
6
+ import { PowerInhibitor } from './power-inhibitor.ts'
7
+ import type { TaskBoardAction, TaskBoardSnapshot } from './protocol.ts'
8
+
9
+ const SESSION_POLL_MS = 5_000
10
+ const SCHEDULE_TICK_MS = 30_000
11
+ const RESUME_GAP_MS = SCHEDULE_TICK_MS + 15_000
12
+
13
+ export class TaskBoardHostService {
14
+ readonly ledger: HostTaskLedger
15
+ readonly runner: HostExecutionRunner
16
+ readonly power: PowerInhibitor
17
+ private readonly listeners = new Set<() => void>()
18
+ private timers: Array<ReturnType<typeof setInterval>> = []
19
+ private lastScheduleTick: number | undefined
20
+ private disposed = false
21
+ private pollInFlight = false
22
+ private tickInFlight = false
23
+ private active = true
24
+ private preventIdleSleep = false
25
+ private readonly now: () => number
26
+
27
+ constructor(api: ApiProxy, options: { ledger?: HostTaskLedger; power?: PowerInhibitor; now?: () => number } = {}) {
28
+ this.ledger = options.ledger ?? new HostTaskLedger()
29
+ this.runner = new HostExecutionRunner(api)
30
+ this.power = options.power ?? new PowerInhibitor()
31
+ this.now = options.now ?? Date.now
32
+ this.ledger.subscribe(() => {
33
+ this.syncPowerReasons()
34
+ this.emit()
35
+ })
36
+ this.power.subscribe(() => { this.emit() })
37
+ }
38
+
39
+ start(): void {
40
+ if (this.disposed || this.timers.length > 0) return
41
+ this.syncPowerReasons()
42
+ this.timers.push(setInterval(() => { this.schedulePoll() }, SESSION_POLL_MS))
43
+ this.timers.push(setInterval(() => { this.scheduleTick(false) }, SCHEDULE_TICK_MS))
44
+ this.schedulePoll()
45
+ this.scheduleTick(true)
46
+ }
47
+
48
+ setConfiguration(active: boolean, preventIdleSleep: boolean): void {
49
+ const resumed = !this.active && active
50
+ this.active = active
51
+ this.preventIdleSleep = preventIdleSleep
52
+ if (resumed) {
53
+ const current = this.power.snapshot()
54
+ this.power.updateReasons({
55
+ runningSessions: current.runningSessions,
56
+ armedSchedules: this.armedSchedules(),
57
+ sessionStateKnown: false,
58
+ })
59
+ }
60
+ this.power.setEnabled(active && preventIdleSleep)
61
+ if (resumed) {
62
+ this.schedulePoll()
63
+ this.scheduleTick(true)
64
+ }
65
+ this.emit()
66
+ }
67
+
68
+ snapshot(): TaskBoardSnapshot {
69
+ const state = this.ledger.state()
70
+ return {
71
+ schemaVersion: 2,
72
+ revision: state.revision,
73
+ tasks: state.tasks,
74
+ scheduler: state.scheduler,
75
+ power: this.power.snapshot(),
76
+ }
77
+ }
78
+
79
+ subscribe(listener: () => void): () => void {
80
+ this.listeners.add(listener)
81
+ return () => { this.listeners.delete(listener) }
82
+ }
83
+
84
+ apply(requestId: string, action: TaskBoardAction): TaskBoardSnapshot {
85
+ if (!this.active) throw new Error('task board is disabled')
86
+ const result = this.ledger.applyRequest(requestId, action)
87
+ if (result.run !== undefined) this.scheduleLaunch(result.run)
88
+ return {
89
+ schemaVersion: 2,
90
+ revision: result.state.revision,
91
+ tasks: result.state.tasks,
92
+ scheduler: result.state.scheduler,
93
+ power: this.power.snapshot(),
94
+ }
95
+ }
96
+
97
+ dispose(): void {
98
+ this.disposed = true
99
+ for (const timer of this.timers.splice(0)) clearInterval(timer)
100
+ this.power.dispose()
101
+ this.ledger.dispose()
102
+ this.listeners.clear()
103
+ }
104
+
105
+ private async launch(opened: OpenedRun): Promise<void> {
106
+ try {
107
+ const sessionId = await this.runner.launch(opened.task)
108
+ this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
109
+ } catch (error) {
110
+ if (error instanceof SessionLaunchError) {
111
+ this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
112
+ }
113
+ this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
114
+ }
115
+ }
116
+
117
+ private async pollSessions(): Promise<void> {
118
+ if (this.disposed) return
119
+ if (!this.active && !this.hasOpenExecutions()) return
120
+ const running = await this.runner.listRunning()
121
+ const previous = this.power.snapshot()
122
+ this.power.updateReasons({
123
+ runningSessions: running.known ? running.count : previous.runningSessions,
124
+ armedSchedules: this.armedSchedules(),
125
+ sessionStateKnown: running.known,
126
+ })
127
+ if (running.known) await this.reconcileExecutions()
128
+ this.emit()
129
+ }
130
+
131
+ private async reconcileExecutions(): Promise<void> {
132
+ for (const task of this.ledger.state().tasks) {
133
+ for (const execution of task.executions) {
134
+ if (execution.sessionId === undefined || execution.endedAt !== undefined) continue
135
+ try {
136
+ const result = await this.runner.inspect(execution.sessionId, execution.startedAt)
137
+ if (result.outcome === 'pending') continue
138
+ this.ledger.settle(task.id, execution.id, result.outcome, 'error' in result ? result.error : undefined)
139
+ } catch {
140
+ // A transient inspection failure never settles a running execution.
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ private async tickSchedule(first: boolean): Promise<void> {
147
+ if (this.disposed || !this.active) return
148
+ const now = this.now()
149
+ const recovered = first || (this.lastScheduleTick !== undefined && now - this.lastScheduleTick > RESUME_GAP_MS)
150
+ this.lastScheduleTick = now
151
+ this.ledger.setScheduler({ lastTickAt: now })
152
+ if (recovered) {
153
+ this.ledger.skipMissed(now)
154
+ return
155
+ }
156
+ for (const task of this.ledger.state().tasks) {
157
+ if (task.archivedAt !== undefined) continue
158
+ const schedule = task.schedule
159
+ if (schedule === undefined || !schedule.enabled || schedule.nextRunAt === undefined || schedule.nextRunAt > now) continue
160
+ const next = nextRunAtMs(schedule.cron, schedule.nextRunAt)
161
+ const opened = this.ledger.openScheduled(task.id, next, now)
162
+ if (opened !== undefined) this.scheduleLaunch(opened)
163
+ }
164
+ }
165
+
166
+ private armedSchedules(): number {
167
+ return this.ledger.state().tasks.filter((task: TaskRecord) => task.archivedAt === undefined && task.schedule?.enabled === true).length
168
+ }
169
+
170
+ private hasOpenExecutions(): boolean {
171
+ return this.ledger.state().tasks.some(task => task.executions.some(execution => execution.endedAt === undefined))
172
+ }
173
+
174
+ private scheduleLaunch(opened: OpenedRun): void {
175
+ void this.launch(opened).catch(error => {
176
+ console.error('[dsh-task-board] execution launch settlement failed', error)
177
+ })
178
+ }
179
+
180
+ private schedulePoll(): void {
181
+ if (this.pollInFlight || this.disposed) return
182
+ this.pollInFlight = true
183
+ void this.pollSessions().catch(error => {
184
+ console.error('[dsh-task-board] session polling failed', error)
185
+ }).finally(() => { this.pollInFlight = false })
186
+ }
187
+
188
+ private scheduleTick(first: boolean): void {
189
+ if (this.tickInFlight || this.disposed) return
190
+ this.tickInFlight = true
191
+ void this.tickSchedule(first).catch(error => {
192
+ console.error('[dsh-task-board] scheduler tick failed', error)
193
+ }).finally(() => { this.tickInFlight = false })
194
+ }
195
+
196
+ private syncPowerReasons(): void {
197
+ const current = this.power.snapshot()
198
+ this.power.updateReasons({
199
+ runningSessions: current.runningSessions,
200
+ armedSchedules: this.armedSchedules(),
201
+ sessionStateKnown: current.sessionStateKnown,
202
+ })
203
+ this.power.setEnabled(this.active && this.preventIdleSleep)
204
+ }
205
+
206
+ private emit(): void {
207
+ for (const listener of [...this.listeners]) listener()
208
+ }
209
+ }
package/src/index.ts CHANGED
@@ -1,28 +1,31 @@
1
1
  /**
2
2
  * Host loader entry for the task-board plugin.
3
3
  *
4
- * Everything the board does is browser work (DOM, localStorage, driving the
5
- * client runtime's session services over the wire), so the host half's main
6
- * behavior is a system-prompt section announcing the plugin to every agent.
7
- * The section registers while this plugin is in the host composition (mount /
8
- * DSH restart) and disappears when the plugin leaves it (unmount / restart),
9
- * so agents always know the board exists and how to cooperate with it. The
10
- * announcement can be turned off through the web settings plugin-configuration
11
- * surface (`announceToAgent`); the section then disappears live.
4
+ * The Host owns the v2 ledger, action API, cron scheduler, session runner,
5
+ * execution reconciliation, and optional idle-sleep inhibitor. The browser is
6
+ * a same-origin asynchronous view over that service.
12
7
  */
13
8
 
14
9
  import type { Context } from '@deepseek-ai/cordis'
15
10
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
16
11
  import z from 'schemastery'
17
12
  import type {} from '@deepseek-ai/dsh-system-prompt'
13
+ import type {} from '@deepseek-ai/dsh-host-apiproxy'
14
+ import type {} from '@deepseek-ai/dsh-host-webserver'
15
+ import { TaskBoardHostService } from './host-service.ts'
16
+ import { makeTaskBoardRoutes } from './host-routes.ts'
17
+ import { mountOnce } from './mount-once.ts'
18
18
 
19
19
  /** Order of the announcement section within the tool-guidance band. */
20
20
  const SECTION_ORDER = 200
21
21
 
22
- export const inject = ['systemPrompt']
22
+ /** Default environment variable holding the authenticated proxy token. */
23
+ export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
24
+
25
+ export const inject = ['systemPrompt', 'apiProxy', 'webServer']
23
26
 
24
27
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
25
- export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;任务可真实执行(驱动 agent 会话);任务可钉住执行目标——工作区 / 模式(agent 预设)/ 权限(read-only / workspace-write / danger-full-access),缺省用运行时默认;任务支持 5 段 cron 定时执行(如 0 23 * * *);数据存浏览器 localStorage(键 dsh.taskBoard.v1)。限制:定时调度在浏览器端,需 GUI 标签页打开,错过即跳过;执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。'
28
+ export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web-ui 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。'
26
29
 
27
30
  /**
28
31
  * Settings namespace of the board's announcement capability — the section the
@@ -41,13 +44,35 @@ export interface Config {
41
44
  announceToAgent?: boolean
42
45
  /** Master switch for the plugin (browser half + host announcement). */
43
46
  enabled?: boolean
47
+ /** Prevent idle system sleep while sessions run or schedules are armed. */
48
+ preventIdleSleep?: boolean
49
+ /** Canonical reverse-proxy Host authorities admitted with a server-side token. */
50
+ trustedProxyHosts?: string[]
51
+ /** Environment variable whose value the authenticated proxy injects upstream. */
52
+ proxyTokenEnv?: string
44
53
  }
45
54
 
46
55
  export const Config: z<Config> = z.object({
47
56
  announceToAgent: z.boolean().default(true),
48
57
  enabled: z.boolean().default(true),
58
+ preventIdleSleep: z.boolean().default(false),
59
+ trustedProxyHosts: z.array(z.string()).default([]),
60
+ proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
49
61
  })
50
62
 
63
+ /** Resolve proxy access without ever placing the token value in plugin config. */
64
+ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.ProcessEnv = process.env): { trustedProxyHosts: string[]; proxyToken?: string } {
65
+ const trustedProxyHosts = config?.trustedProxyHosts ?? []
66
+ if (trustedProxyHosts.length === 0) return { trustedProxyHosts }
67
+ const proxyTokenEnv = config?.proxyTokenEnv ?? DEFAULT_PROXY_TOKEN_ENV
68
+ if (proxyTokenEnv.trim() === '') throw new Error('task-board: proxyTokenEnv must not be empty')
69
+ const proxyToken = env[proxyTokenEnv]
70
+ if (proxyToken === undefined || proxyToken === '') {
71
+ throw new Error(`task-board: trustedProxyHosts requires a non-empty ${proxyTokenEnv} environment variable`)
72
+ }
73
+ return { trustedProxyHosts, proxyToken }
74
+ }
75
+
51
76
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
52
77
  const DEFAULT_ANNOUNCE = true
53
78
 
@@ -59,7 +84,26 @@ const DEFAULT_ANNOUNCE = true
59
84
  * @param ctx - the plugin context (systemPrompt injected).
60
85
  * @param config - resolved plugin config (schema defaults applied by the loader).
61
86
  */
62
- export function apply(ctx: Context, config?: Config): void {
87
+ export const apply = mountOnce('@linxin666/dsh-client-ui-task-board', applyImpl)
88
+
89
+ function applyImpl(ctx: Context, config?: Config): void {
90
+ const host = new TaskBoardHostService(ctx.apiProxy)
91
+ host.setConfiguration(config?.enabled ?? true, config?.preventIdleSleep ?? false)
92
+ host.start()
93
+ ctx.effect(() => {
94
+ const disposers: Array<() => void> = []
95
+ try {
96
+ for (const route of makeTaskBoardRoutes(host, resolveProxyAccess(config))) disposers.push(ctx.webServer.register(route))
97
+ } catch (error) {
98
+ for (const dispose of disposers) dispose()
99
+ host.dispose()
100
+ throw error
101
+ }
102
+ return () => {
103
+ for (const dispose of disposers) dispose()
104
+ host.dispose()
105
+ }
106
+ }, 'task-board: host ledger, scheduler, and routes')
63
107
  // The live source the announcement reads: the settings section once the web
64
108
  // settings surface is served, the composition entry otherwise
65
109
  // (installSettingsSection swaps it when the namespace registers).
@@ -74,7 +118,9 @@ export function apply(ctx: Context, config?: Config): void {
74
118
  disposeSection()
75
119
  disposeSection = undefined
76
120
  }
77
- if ((current().enabled ?? true) === false) return
121
+ const active = current().enabled ?? true
122
+ host.setConfiguration(active, current().preventIdleSleep ?? false)
123
+ if (!active) return
78
124
  if ((current().announceToAgent ?? DEFAULT_ANNOUNCE) === false) return
79
125
  disposeSection = ctx.systemPrompt.section({
80
126
  name: 'plugin:task-board',
@@ -0,0 +1,63 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,48 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Host single-instance guard shared by the plugin family. The family bundle
4
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
5
+ * the loader accepts a standalone install of the same package side by side;
6
+ * without this guard the second instance would still re-register the same
7
+ * webserver routes, tools, settings namespaces, and system-prompt sections
8
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
9
+ * lifetime of the first instance (the browser half is already deduped by
10
+ * package name in the client module host).
11
+ *
12
+ * The registry rides a global symbol so two module instances of the same
13
+ * package (npm copy vs repository link) still share one verdict. cordis
14
+ * `ctx.effect` runs its callback immediately and treats the callback's
15
+ * return value as the fiber disposer, so the unmarker is returned, not run.
16
+ */
17
+
18
+ const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
19
+
20
+ interface MountRegistry {
21
+ [MOUNTED]?: Set<string>
22
+ }
23
+
24
+ function mountedSet(): Set<string> {
25
+ const registry = globalThis as MountRegistry
26
+ return (registry[MOUNTED] ??= new Set())
27
+ }
28
+
29
+ /**
30
+ * Wrap a cordis plugin apply so the package runs at most once per process.
31
+ * The first mount registers normally and unmarks when its fiber disposes;
32
+ * any later mount of the same package name is a no-op.
33
+ * @param packageName - npm package identity shared by every install source.
34
+ * @param fn - the original plugin apply.
35
+ * @returns an apply of the same shape.
36
+ */
37
+ export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
+ return ((...args: unknown[]) => {
39
+ const mounted = mountedSet()
40
+ if (mounted.has(packageName)) return
41
+ mounted.add(packageName)
42
+ const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
+ ctx?.effect?.(() => () => {
44
+ mounted.delete(packageName)
45
+ })
46
+ return fn(...args)
47
+ }) as T
48
+ }