@linxin666/dsh-client-ui-task-board 0.4.1 → 0.4.3
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.
- package/README.i18n.yaml +2 -2
- package/README.md +28 -3
- package/README.zh.md +28 -3
- package/lib/client.js +1142 -467
- package/lib/client.js.map +1 -1
- package/lib/index.js +2066 -1475
- package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
- package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
- package/lib/types/client/board/NewTaskModal.d.ts +7 -1
- package/lib/types/client/board/TaskCard.d.ts +11 -1
- package/lib/types/client/index.d.ts +15 -1
- package/lib/types/client/locales.d.ts +33 -0
- package/lib/types/client/native-panel.d.ts +62 -0
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/core/controller.d.ts +71 -3
- package/lib/types/core/subtask.d.ts +122 -0
- package/lib/types/core/tasks.d.ts +46 -3
- package/lib/types/core/use-cases/task-archive.d.ts +10 -13
- package/lib/types/core/use-cases/task-create.d.ts +13 -4
- package/lib/types/core/use-cases/task-delete.d.ts +7 -1
- package/lib/types/core/use-cases/task-parent.d.ts +21 -0
- package/lib/types/core/use-cases/task-update.d.ts +1 -1
- package/lib/types/host/agent-tools.d.ts +27 -0
- package/lib/types/host-ledger.d.ts +97 -4
- package/lib/types/host-runner.d.ts +39 -1
- package/lib/types/host-service.d.ts +98 -5
- package/lib/types/index.d.ts +93 -5
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +14 -0
- package/package.json +26 -21
- package/src/client/TaskBoardSettingsCard.tsx +48 -3
- package/src/client/board/LinkSubtaskModal.tsx +82 -0
- package/src/client/board/NewTaskModal.tsx +40 -6
- package/src/client/board/TaskBoard.tsx +88 -8
- package/src/client/board/TaskCard.tsx +52 -2
- package/src/client/board/TaskDetail.tsx +124 -1
- package/src/client/board.module.css +112 -131
- package/src/client/host-api.ts +14 -4
- package/src/client/index.ts +56 -18
- package/src/client/locales.ts +70 -2
- package/src/client/native-panel.tsx +124 -0
- package/src/client/settings-entry-form.ts +142 -0
- package/src/core/controller.ts +128 -10
- package/src/core/store.ts +15 -0
- package/src/core/subtask.ts +286 -0
- package/src/core/tasks.ts +50 -2
- package/src/core/use-cases/task-archive.ts +46 -12
- package/src/core/use-cases/task-create.ts +42 -7
- package/src/core/use-cases/task-delete.ts +11 -1
- package/src/core/use-cases/task-parent.ts +64 -0
- package/src/core/use-cases/task-update.ts +3 -1
- package/src/host/agent-tools.ts +643 -0
- package/src/host-ledger.ts +343 -45
- package/src/host-routes.ts +37 -1
- package/src/host-runner.ts +141 -12
- package/src/host-service.ts +265 -32
- package/src/index.ts +203 -9
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +26 -2
- package/lib/types/client/board-mount.d.ts +0 -23
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -29
- package/src/client/board-mount.tsx +0 -43
- package/src/client/body-mutations.ts +0 -133
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -53
package/src/index.ts
CHANGED
|
@@ -14,11 +14,15 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
|
14
14
|
import type {} from '@deepseek-ai/dsh-api-gateway'
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-workspace'
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
18
|
+
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
17
19
|
import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
18
|
-
import { TaskBoardHostService } from './host-service.ts'
|
|
20
|
+
import { TaskBoardHostService, type HostTimerFace, type TaskBoardTeamDispatcher } from './host-service.ts'
|
|
19
21
|
import { parseTaskDraft, TaskParseError } from './host-ai.ts'
|
|
20
22
|
import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts'
|
|
23
|
+
import { DEFAULT_SUBTASK_DEPTH, SUBTASK_DEPTH_MAX, SUBTASK_DEPTH_MIN } from './core/subtask.ts'
|
|
21
24
|
import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts'
|
|
25
|
+
import { buildTaskBoardTools } from './host/agent-tools.ts'
|
|
22
26
|
import { makeTaskBoardRoutes } from './host-routes.ts'
|
|
23
27
|
import { mountOnce } from './mount-once.ts'
|
|
24
28
|
|
|
@@ -31,16 +35,18 @@ export const DEFAULT_PROXY_TOKEN_ENV = 'DSH_TASK_BOARD_PROXY_TOKEN'
|
|
|
31
35
|
export const inject = ['systemPrompt', 'typertGateway', 'workspaceRegistry', 'webServer', 'agents', 'commands']
|
|
32
36
|
|
|
33
37
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
34
|
-
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent
|
|
38
|
+
export const TASK_BOARD_GUIDANCE = '本机已安装 dsh-task-board 插件(DSH Web GUI 的任务看板):侧边栏「任务看板」入口;在 dsh-web 插件全家桶仓库(packages/dsh-task-board)统一维护,经聚合包 web-ui-all 一键安装。能力:多列看板管理任务;Host 权威账本;关闭浏览器后仍由 Host 执行和结算;任务可钉住工作区、agent 预设和权限;任务可建子任务(深度上限可配 1..3,默认 1 层,子任务不能再带子任务),执行父任务会并发执行其子任务树,子任务可单独覆盖权限与模型;另注册 task_board_* agent 工具(list/get/create/update/set_parent/run/manage/schedule),任何会话都可直接读写看板、子任务与定时计划,但运行任务会真实执行并消耗额度,高于会话默认权限的绑定仍必须由用户在 GUI 人工确认(工具刻意不提供确认能力);支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。'
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
41
|
* Plugin config, validated by the same-named schemastery schema.
|
|
38
42
|
*
|
|
39
43
|
* This schema IS the board's settings page: the 0.1.7 Host serves one
|
|
40
44
|
* configuration form per profile entry from the entry's own Config, so the
|
|
41
|
-
*
|
|
45
|
+
* fields the browser card edits are marked volatile — the Loader commits
|
|
42
46
|
* an edit into the running fiber's references without remounting the row (no
|
|
43
|
-
* settings document of the plugin's own exists any more).
|
|
47
|
+
* settings document of the plugin's own exists any more). A field without the
|
|
48
|
+
* marker is refused by the settings write path, which fences writes on
|
|
49
|
+
* volatility.
|
|
44
50
|
*/
|
|
45
51
|
export interface Config {
|
|
46
52
|
/**
|
|
@@ -67,8 +73,27 @@ export interface Config {
|
|
|
67
73
|
* confirmation before it may run; cron refuses unconfirmed cards.
|
|
68
74
|
*/
|
|
69
75
|
sessionDefaultPermission?: TaskPermission
|
|
76
|
+
/**
|
|
77
|
+
* Subtask depth limit, 1..3 (default 1). At 1 a task may carry one level of
|
|
78
|
+
* subtasks and a subtask cannot be given subtasks of its own; raising it
|
|
79
|
+
* allows deeper trees. Every extra level multiplies the sessions one run
|
|
80
|
+
* of the root task opens, so the ceiling is deliberately small.
|
|
81
|
+
*/
|
|
82
|
+
maxSubtaskDepth?: Volatile<number>
|
|
83
|
+
/**
|
|
84
|
+
* Continuable-subagent provider the Agent Teams service uses to compose a
|
|
85
|
+
* teammate. Matches the Agent Teams tool plugin's `freshProvider` default;
|
|
86
|
+
* only team-mode runs use it.
|
|
87
|
+
*/
|
|
88
|
+
teamProvider?: string
|
|
70
89
|
}
|
|
71
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Continuable-subagent provider for fresh teammates, matching the Agent Teams
|
|
93
|
+
* tool plugin's own `freshProvider` default.
|
|
94
|
+
*/
|
|
95
|
+
export const DEFAULT_TEAM_PROVIDER = 'spawn'
|
|
96
|
+
|
|
72
97
|
/**
|
|
73
98
|
* The schema is left to inference rather than annotated with `z<Config>`: a
|
|
74
99
|
* volatile field's parsed output is a `Volatile` reference while its accepted
|
|
@@ -82,6 +107,8 @@ export const Config = z.object({
|
|
|
82
107
|
trustedProxyHosts: z.array(z.string()).default([]),
|
|
83
108
|
proxyTokenEnv: z.string().min(1).default(DEFAULT_PROXY_TOKEN_ENV),
|
|
84
109
|
sessionDefaultPermission: z.union(TASK_PERMISSIONS).default(DEFAULT_SESSION_PERMISSION),
|
|
110
|
+
maxSubtaskDepth: z.number().min(SUBTASK_DEPTH_MIN).max(SUBTASK_DEPTH_MAX).default(DEFAULT_SUBTASK_DEPTH).volatile(),
|
|
111
|
+
teamProvider: z.string().min(1).default(DEFAULT_TEAM_PROVIDER),
|
|
85
112
|
})
|
|
86
113
|
|
|
87
114
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -130,9 +157,126 @@ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.Proce
|
|
|
130
157
|
return { trustedProxyHosts, proxyToken }
|
|
131
158
|
}
|
|
132
159
|
|
|
160
|
+
/** The registry face the agent tools register into. */
|
|
161
|
+
interface AgentToolRegistry {
|
|
162
|
+
register(definition: ToolDefinition): () => void
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve the optional agent-tool registry. The board deliberately does not
|
|
167
|
+
* inject it: a deployment whose runtime serves no registry must still mount the
|
|
168
|
+
* whole board and lose only the agent-tool surface, the same tolerance the
|
|
169
|
+
* optional llm service gets.
|
|
170
|
+
* @param ctx - the plugin context.
|
|
171
|
+
* @returns the registry, or undefined when this deployment serves none.
|
|
172
|
+
*/
|
|
173
|
+
export function resolveToolRegistry(ctx: Context): AgentToolRegistry | undefined {
|
|
174
|
+
try {
|
|
175
|
+
const tools = ctx.get('tools') as AgentToolRegistry | undefined
|
|
176
|
+
return tools !== undefined && typeof tools.register === 'function' ? tools : undefined
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve the native cordis timer service (dsh-base's own `cordis-plugin-timer`
|
|
184
|
+
* row). The board arms its schedule through it so unloading the row clears
|
|
185
|
+
* every pending timer with the owning fiber, instead of the row tracking raw
|
|
186
|
+
* process handles by hand. A composition that serves no timer service keeps
|
|
187
|
+
* working on the process globals.
|
|
188
|
+
* @param ctx - the plugin context.
|
|
189
|
+
* @returns the timer face, or undefined when this deployment serves none.
|
|
190
|
+
*/
|
|
191
|
+
export function resolveHostTimers(ctx: Context): HostTimerFace | undefined {
|
|
192
|
+
try {
|
|
193
|
+
const timer = ctx.get('timer') as Partial<HostTimerFace> | undefined
|
|
194
|
+
if (timer === undefined) return undefined
|
|
195
|
+
return typeof timer.timeout === 'function' && typeof timer.interval === 'function'
|
|
196
|
+
? { timeout: timer.timeout.bind(timer), interval: timer.interval.bind(timer) }
|
|
197
|
+
: undefined
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
133
203
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
134
204
|
const DEFAULT_ANNOUNCE = false
|
|
135
205
|
|
|
206
|
+
/**
|
|
207
|
+
* How long one teammate may take to reach its durable active or failed edge.
|
|
208
|
+
* Agent Teams provisioning normally resolves in seconds; the bound exists so a
|
|
209
|
+
* hung spawn settles the subtask instead of holding the cascade open forever.
|
|
210
|
+
*/
|
|
211
|
+
const TEAMMATE_PROVISION_TIMEOUT_MS = 600_000
|
|
212
|
+
|
|
213
|
+
/** The subset of the Agent Teams service this plugin drives. */
|
|
214
|
+
interface AgentTeamsFace {
|
|
215
|
+
spawnTeammate(caller: unknown, request: {
|
|
216
|
+
name: string
|
|
217
|
+
description: string
|
|
218
|
+
prompt: Array<{ type: 'text'; text: string }>
|
|
219
|
+
context: 'fresh' | 'fork'
|
|
220
|
+
provider: string
|
|
221
|
+
signal: AbortSignal
|
|
222
|
+
}): Promise<{ member?: { id?: string; phase?: string; error?: string } }>
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve the optional Agent Teams service. Like the tool registry and the llm
|
|
227
|
+
* runtime, it is deliberately not injected: a deployment without Agent Teams
|
|
228
|
+
* must still mount the whole board and only lose team-mode runs.
|
|
229
|
+
* @param ctx - the plugin context.
|
|
230
|
+
* @returns the service, or undefined when this deployment serves none.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveAgentTeams(ctx: Context): AgentTeamsFace | undefined {
|
|
233
|
+
try {
|
|
234
|
+
const teams = ctx.get('agentTeams') as AgentTeamsFace | undefined
|
|
235
|
+
return teams !== undefined && typeof teams.spawnTeammate === 'function' ? teams : undefined
|
|
236
|
+
} catch {
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build the team dispatcher: the run's Lead session spawns one teammate per
|
|
243
|
+
* subtask through the Agent Teams service, and the teammate's session id is
|
|
244
|
+
* attached to that subtask's execution so the ordinary session monitor settles
|
|
245
|
+
* it. Returns undefined when the deployment serves no Agent Teams service or
|
|
246
|
+
* no live-agent registry, which makes a team run fail closed.
|
|
247
|
+
* @param ctx - the plugin context.
|
|
248
|
+
* @param provider - the continuable-subagent provider for fresh teammates.
|
|
249
|
+
* @returns the dispatcher, or undefined when team runs cannot be served.
|
|
250
|
+
*/
|
|
251
|
+
export function buildTeamDispatcher(ctx: Context, provider: string): TaskBoardTeamDispatcher | undefined {
|
|
252
|
+
const teams = resolveAgentTeams(ctx)
|
|
253
|
+
if (teams === undefined) return undefined
|
|
254
|
+
if (typeof (ctx.agents as { get?: unknown } | undefined)?.get !== 'function') return undefined
|
|
255
|
+
return {
|
|
256
|
+
async spawn(input) {
|
|
257
|
+
const lead = ctx.agents.get(input.leadSessionId as Parameters<typeof ctx.agents.get>[0])
|
|
258
|
+
if (lead === undefined) return { error: `lead session ${input.leadSessionId} is not live` }
|
|
259
|
+
try {
|
|
260
|
+
const result = await teams.spawnTeammate(lead, {
|
|
261
|
+
name: input.name,
|
|
262
|
+
description: input.description,
|
|
263
|
+
prompt: [{ type: 'text', text: input.prompt }],
|
|
264
|
+
context: 'fresh',
|
|
265
|
+
provider,
|
|
266
|
+
signal: AbortSignal.timeout(TEAMMATE_PROVISION_TIMEOUT_MS),
|
|
267
|
+
})
|
|
268
|
+
const member = result?.member
|
|
269
|
+
if (member === undefined) return { error: 'Agent Teams returned no teammate' }
|
|
270
|
+
if (member.phase === 'failed') return { error: member.error ?? 'teammate provisioning failed' }
|
|
271
|
+
if (typeof member.id !== 'string' || member.id === '') return { error: 'Agent Teams returned no teammate session' }
|
|
272
|
+
return { sessionId: member.id }
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return { error: `teammate provisioning failed: ${error instanceof Error ? error.message : String(error)}` }
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
136
280
|
/**
|
|
137
281
|
* Read the optional `llm` service. The board deliberately does not inject it:
|
|
138
282
|
* a deployment without a model must still mount the board, and the parse route
|
|
@@ -153,8 +297,8 @@ export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined {
|
|
|
153
297
|
* Activate the board's host half: ledger, routes, cron scheduler, power
|
|
154
298
|
* inhibitor, and the model-facing announcement.
|
|
155
299
|
*
|
|
156
|
-
* The effective settings are the config the Host hands this row. The
|
|
157
|
-
*
|
|
300
|
+
* The effective settings are the config the Host hands this row. The fields
|
|
301
|
+
* the browser card edits are schema-volatile, so the Loader commits an
|
|
158
302
|
* edit into the running fiber's references instead of remounting the row —
|
|
159
303
|
* `sync` therefore reads them at use time and follows
|
|
160
304
|
* `loader/volatile-update`, the event the Loader emits once it has committed
|
|
@@ -171,10 +315,15 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
171
315
|
const announceToAgent = (): boolean => readConfigField(config?.announceToAgent, DEFAULT_ANNOUNCE)
|
|
172
316
|
/** Current idle-sleep protection flag. */
|
|
173
317
|
const preventIdleSleep = (): boolean => readConfigField(config?.preventIdleSleep, false)
|
|
318
|
+
/** Current subtask depth limit (read live: the settings card edits it in place). */
|
|
319
|
+
const maxSubtaskDepth = (): number => readConfigField(config?.maxSubtaskDepth, DEFAULT_SUBTASK_DEPTH)
|
|
174
320
|
|
|
175
321
|
const host = new TaskBoardHostService(ctx.typertGateway, {
|
|
176
322
|
workspaceRegistry: ctx.workspaceRegistry,
|
|
177
323
|
sessionDefaultPermission: config?.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION,
|
|
324
|
+
maxSubtaskDepth: maxSubtaskDepth(),
|
|
325
|
+
timers: resolveHostTimers(ctx),
|
|
326
|
+
team: buildTeamDispatcher(ctx, config?.teamProvider ?? DEFAULT_TEAM_PROVIDER),
|
|
178
327
|
commandDispatcher: {
|
|
179
328
|
async execute(sessionId, line, signal) {
|
|
180
329
|
const agent = ctx.agents.get(sessionId)
|
|
@@ -187,6 +336,45 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
187
336
|
// scheduler tick, which would roll schedules the board is not running.
|
|
188
337
|
host.setConfiguration(enabled(), preventIdleSleep())
|
|
189
338
|
host.start()
|
|
339
|
+
|
|
340
|
+
// Agent tools: the same Host ledger the browser drives, so any session can
|
|
341
|
+
// list, create, link, run and settle board work. Registration follows the
|
|
342
|
+
// master switch (a disabled board answers no tool call), and the mount
|
|
343
|
+
// effect below owns the disposers.
|
|
344
|
+
let disposeTools: (() => void) | undefined
|
|
345
|
+
const setToolsEnabled = (active: boolean): void => {
|
|
346
|
+
if (!active) {
|
|
347
|
+
disposeTools?.()
|
|
348
|
+
disposeTools = undefined
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
if (disposeTools !== undefined) return
|
|
352
|
+
const registry = resolveToolRegistry(ctx)
|
|
353
|
+
if (registry === undefined) return
|
|
354
|
+
const disposers = buildTaskBoardTools(host).map(tool => registry.register(tool))
|
|
355
|
+
disposeTools = () => {
|
|
356
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
setToolsEnabled(enabled())
|
|
360
|
+
// A registry that activates after this row (or a row activated before it) is
|
|
361
|
+
// followed through scoped injection where the runtime serves one; capture-only
|
|
362
|
+
// contexts without it register through the direct resolution above.
|
|
363
|
+
const scopedInject = (ctx as { inject?: (names: readonly string[], callback: (scoped: Context) => unknown) => unknown }).inject
|
|
364
|
+
if (typeof scopedInject === 'function') {
|
|
365
|
+
scopedInject.call(ctx, ['tools'], () => {
|
|
366
|
+
setToolsEnabled(enabled())
|
|
367
|
+
// Cordis unloads and re-runs this callback when the injected service's
|
|
368
|
+
// provider fiber changes, and the old registry dies with its provider.
|
|
369
|
+
// Releasing the guard here is what lets the callback register into the
|
|
370
|
+
// NEW registry instead of short-circuiting on the stale disposer.
|
|
371
|
+
return () => {
|
|
372
|
+
disposeTools?.()
|
|
373
|
+
disposeTools = undefined
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
190
378
|
ctx.effect(() => {
|
|
191
379
|
const disposers: Array<() => void> = []
|
|
192
380
|
try {
|
|
@@ -199,18 +387,22 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
199
387
|
})
|
|
200
388
|
for (const route of routes) disposers.push(ctx.webServer.register(route))
|
|
201
389
|
} catch (error) {
|
|
390
|
+
// A failed mount must not leave the agent tools bound to a disposed
|
|
391
|
+
// host: the effect cleanup never runs when this body throws.
|
|
392
|
+
setToolsEnabled(false)
|
|
202
393
|
for (const dispose of disposers) dispose()
|
|
203
394
|
host.dispose()
|
|
204
395
|
throw error
|
|
205
396
|
}
|
|
206
397
|
return () => {
|
|
398
|
+
setToolsEnabled(false)
|
|
207
399
|
for (const dispose of disposers) dispose()
|
|
208
400
|
host.dispose()
|
|
209
401
|
}
|
|
210
402
|
}, 'task-board: host ledger, scheduler, and routes')
|
|
211
403
|
|
|
212
404
|
let disposeSection: (() => void) | undefined
|
|
213
|
-
let applied: { enabled: boolean; announceToAgent: boolean; preventIdleSleep: boolean } | undefined
|
|
405
|
+
let applied: { enabled: boolean; announceToAgent: boolean; preventIdleSleep: boolean; maxSubtaskDepth: number } | undefined
|
|
214
406
|
|
|
215
407
|
// Apply the current values to the host service and the announcement. A
|
|
216
408
|
// commit that changes nothing visible is a no-op, so following a coarse
|
|
@@ -218,12 +410,14 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
218
410
|
// under one disposer: re-registering first tears the old one down so a
|
|
219
411
|
// duplicate-name registration never throws.
|
|
220
412
|
const sync = (): void => {
|
|
221
|
-
const next = { enabled: enabled(), announceToAgent: announceToAgent(), preventIdleSleep: preventIdleSleep() }
|
|
222
|
-
if (applied !== undefined && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep) {
|
|
413
|
+
const next = { enabled: enabled(), announceToAgent: announceToAgent(), preventIdleSleep: preventIdleSleep(), maxSubtaskDepth: maxSubtaskDepth() }
|
|
414
|
+
if (applied !== undefined && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep && applied.maxSubtaskDepth === next.maxSubtaskDepth) {
|
|
223
415
|
return
|
|
224
416
|
}
|
|
225
417
|
applied = next
|
|
418
|
+
host.ledger.setMaxSubtaskDepth(next.maxSubtaskDepth)
|
|
226
419
|
host.setConfiguration(next.enabled, next.preventIdleSleep)
|
|
420
|
+
setToolsEnabled(next.enabled)
|
|
227
421
|
if (disposeSection !== undefined) {
|
|
228
422
|
disposeSection()
|
|
229
423
|
disposeSection = undefined
|
package/src/mount-once.ts
CHANGED
|
@@ -5,44 +5,138 @@
|
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
-
* and fail the boot. mountOnce makes
|
|
9
|
-
*
|
|
10
|
-
* package name in the client module host).
|
|
8
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
9
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
10
|
+
* deduped by package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
13
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
14
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
15
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
16
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
17
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
18
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
19
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
20
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
21
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
22
|
+
* restart recovered it).
|
|
23
|
+
*
|
|
24
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
25
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
26
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
27
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
28
|
+
* dedupes against it.
|
|
11
29
|
*
|
|
12
30
|
* The registry rides a global symbol so two module instances of the same
|
|
13
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
14
|
-
*
|
|
31
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
32
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
33
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
34
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
35
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
36
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
37
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
38
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
39
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
40
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
41
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
42
|
+
* mounted after it failed with "claims.get is not a function".
|
|
43
|
+
*
|
|
44
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
45
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
46
|
*/
|
|
17
47
|
|
|
48
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
18
49
|
const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
|
|
19
50
|
|
|
51
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
52
|
+
const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
|
|
53
|
+
|
|
54
|
+
/** The slice of a cordis context this guard touches. */
|
|
55
|
+
interface MountContext {
|
|
56
|
+
effect?: (effect: () => unknown) => unknown
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One mount that was refused while another fiber owned the package name. */
|
|
60
|
+
interface PendingMount {
|
|
61
|
+
/** Replay the refused mount; a no-op once its own fiber has been disposed. */
|
|
62
|
+
run(): void
|
|
63
|
+
}
|
|
64
|
+
|
|
20
65
|
interface MountRegistry {
|
|
21
|
-
[MOUNTED]?:
|
|
66
|
+
[MOUNTED]?: unknown
|
|
67
|
+
[WAITERS]?: Map<string, PendingMount[]>
|
|
22
68
|
}
|
|
23
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
72
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
73
|
+
* family plugin down with it.
|
|
74
|
+
* @returns the process-wide set of mounted package names.
|
|
75
|
+
*/
|
|
24
76
|
function mountedSet(): Set<string> {
|
|
25
77
|
const registry = globalThis as MountRegistry
|
|
26
|
-
|
|
78
|
+
const existing = registry[MOUNTED]
|
|
79
|
+
if (existing instanceof Set) return existing as Set<string>
|
|
80
|
+
const created = new Set<string>()
|
|
81
|
+
registry[MOUNTED] = created
|
|
82
|
+
return created
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
86
|
+
function mountWaiters(): Map<string, PendingMount[]> {
|
|
87
|
+
const registry = globalThis as MountRegistry
|
|
88
|
+
return (registry[WAITERS] ??= new Map())
|
|
27
89
|
}
|
|
28
90
|
|
|
29
91
|
/**
|
|
30
92
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
31
|
-
* The first mount registers normally and
|
|
32
|
-
*
|
|
93
|
+
* The first mount registers normally and releases the name when its fiber
|
|
94
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
95
|
+
* then runs, unless its own fiber disposes first.
|
|
33
96
|
* @param packageName - npm package identity shared by every install source.
|
|
34
97
|
* @param fn - the original plugin apply.
|
|
35
98
|
* @returns an apply of the same shape.
|
|
36
99
|
*/
|
|
37
100
|
export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
|
|
38
|
-
|
|
101
|
+
const mount = (...args: unknown[]): unknown => {
|
|
39
102
|
const mounted = mountedSet()
|
|
40
|
-
|
|
103
|
+
const ctx = args[0] as MountContext | undefined
|
|
104
|
+
if (mounted.has(packageName)) {
|
|
105
|
+
// Another fiber (the aggregate row, a standalone install, or a copy built
|
|
106
|
+
// from an older revision of this guard) owns the name. Queue instead of
|
|
107
|
+
// dropping: dropping lost the plugin for the rest of the process when the
|
|
108
|
+
// holder was disposed by a reload.
|
|
109
|
+
const waiters = mountWaiters()
|
|
110
|
+
const queue = waiters.get(packageName) ?? []
|
|
111
|
+
let alive = true
|
|
112
|
+
const pending: PendingMount = {
|
|
113
|
+
run: () => {
|
|
114
|
+
if (alive) mount(...args)
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
// A fiber that dies while it waits must not be replayed afterwards: its
|
|
118
|
+
// context is disposed and registering effects on it would throw.
|
|
119
|
+
ctx?.effect?.(() => () => {
|
|
120
|
+
alive = false
|
|
121
|
+
const index = queue.indexOf(pending)
|
|
122
|
+
if (index >= 0) queue.splice(index, 1)
|
|
123
|
+
})
|
|
124
|
+
queue.push(pending)
|
|
125
|
+
waiters.set(packageName, queue)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
41
128
|
mounted.add(packageName)
|
|
42
|
-
const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
|
|
43
129
|
ctx?.effect?.(() => () => {
|
|
44
130
|
mounted.delete(packageName)
|
|
131
|
+
const waiters = mountWaiters()
|
|
132
|
+
const queue = waiters.get(packageName)
|
|
133
|
+
if (queue === undefined) return
|
|
134
|
+
waiters.delete(packageName)
|
|
135
|
+
// Defer the handover one microtask so the disposing mount's own effects
|
|
136
|
+
// (routes, ledger, timers) are torn down before the next one registers.
|
|
137
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
|
|
45
138
|
})
|
|
46
139
|
return fn(...args)
|
|
47
|
-
}
|
|
140
|
+
}
|
|
141
|
+
return mount as T
|
|
48
142
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -40,6 +40,16 @@ export interface TaskBoardSnapshot {
|
|
|
40
40
|
power: TaskBoardPowerSnapshot
|
|
41
41
|
/** Session-default permission the confirmation gate compares against. */
|
|
42
42
|
sessionDefaultPermission?: TaskPermission
|
|
43
|
+
/**
|
|
44
|
+
* Deployment subtask depth limit (1..3). The browser mirrors it to enable or
|
|
45
|
+
* disable the subtask controls; the Host ledger is the authority.
|
|
46
|
+
*/
|
|
47
|
+
maxSubtaskDepth?: number
|
|
48
|
+
/**
|
|
49
|
+
* Whether this deployment serves the Agent Teams service, so a task may opt
|
|
50
|
+
* into team execution (Team Lead session plus one teammate per subtask).
|
|
51
|
+
*/
|
|
52
|
+
teamRunAvailable?: boolean
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/** SSE event frame: revision/scheduler/power only, never the task list. */
|
|
@@ -95,6 +105,7 @@ export type TaskBoardAction =
|
|
|
95
105
|
| { kind: 'run'; taskId: string }
|
|
96
106
|
| { kind: 'rerun'; taskId: string }
|
|
97
107
|
| { kind: 'confirm-permission'; taskId: string }
|
|
108
|
+
| { kind: 'set-parent'; taskId: string; parentId: string | null }
|
|
98
109
|
|
|
99
110
|
export interface TaskBoardActionEnvelope {
|
|
100
111
|
requestId: string
|
|
@@ -192,6 +203,9 @@ function importedTask(value: unknown): TaskRecord | undefined {
|
|
|
192
203
|
lastTriggeredAt: task.schedule.lastTriggeredAt,
|
|
193
204
|
},
|
|
194
205
|
}),
|
|
206
|
+
// The lineage link rides the import; the Host repairs a link whose parent
|
|
207
|
+
// is missing from the merged ledger instead of trusting the export.
|
|
208
|
+
...(task.parentId === undefined ? {} : { parentId: task.parentId }),
|
|
195
209
|
...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
|
|
196
210
|
...(task.mode === undefined ? {} : { mode: task.mode }),
|
|
197
211
|
...(task.permission === undefined ? {} : { permission: task.permission }),
|
|
@@ -233,10 +247,12 @@ function handoverPayload(value: unknown): TaskHandoverInput | undefined {
|
|
|
233
247
|
|
|
234
248
|
function createInput(value: unknown): value is NewTaskInput {
|
|
235
249
|
const input = record(value)
|
|
236
|
-
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
250
|
+
if (input === undefined || !exactKeys(input, ['title', 'description', 'prompt', 'parentId', 'workspaceId', 'mode', 'permission', 'schedule', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
|
|
251
|
+
if (input.parentId !== undefined && (typeof input.parentId !== 'string' || input.parentId.trim() === '')) return false
|
|
237
252
|
if (typeof input.title !== 'string' || typeof input.description !== 'string' || typeof input.prompt !== 'string') return false
|
|
238
253
|
if (!optionalString(input.workspaceId) || !optionalString(input.mode) || !optionalString(input.model)) return false
|
|
239
254
|
if (input.reuseSession !== undefined && typeof input.reuseSession !== 'boolean') return false
|
|
255
|
+
if (input.teamRun !== undefined && typeof input.teamRun !== 'boolean') return false
|
|
240
256
|
if (input.permission !== undefined && !isTaskPermission(input.permission)) return false
|
|
241
257
|
if (input.tags !== undefined && !isTaskTagList(input.tags)) return false
|
|
242
258
|
if (input.freeze !== undefined && freezePayload(input.freeze) === undefined) return false
|
|
@@ -251,9 +267,10 @@ function createInput(value: unknown): value is NewTaskInput {
|
|
|
251
267
|
|
|
252
268
|
function updatePatch(value: unknown): boolean {
|
|
253
269
|
const patch = record(value)
|
|
254
|
-
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'tags'])) return false
|
|
270
|
+
if (patch === undefined || !exactKeys(patch, ['title', 'description', 'prompt', 'workspaceId', 'mode', 'permission', 'freeze', 'handover', 'model', 'reuseSession', 'teamRun', 'tags'])) return false
|
|
255
271
|
// null (or false) clears the reuse opt-in; only a real boolean is accepted.
|
|
256
272
|
if (patch.reuseSession !== undefined && patch.reuseSession !== null && typeof patch.reuseSession !== 'boolean') return false
|
|
273
|
+
if (patch.teamRun !== undefined && patch.teamRun !== null && typeof patch.teamRun !== 'boolean') return false
|
|
257
274
|
for (const key of ['title', 'description', 'prompt', 'workspaceId', 'mode', 'model'] as const) {
|
|
258
275
|
if (!optionalString(patch[key])) return false
|
|
259
276
|
}
|
|
@@ -326,6 +343,13 @@ function parseEnvelopeAction(value: unknown): TaskBoardActionEnvelope | undefine
|
|
|
326
343
|
: patch
|
|
327
344
|
return { requestId: envelope.requestId, action: { kind: 'update', taskId, patch: sanitized } }
|
|
328
345
|
}
|
|
346
|
+
case 'set-parent': {
|
|
347
|
+
if (!exactKeys(action, ['kind', 'taskId', 'parentId'])) return undefined
|
|
348
|
+
if (taskId === undefined) return undefined
|
|
349
|
+
const parentId = action.parentId
|
|
350
|
+
if (parentId !== null && (typeof parentId !== 'string' || parentId.trim() === '')) return undefined
|
|
351
|
+
return { requestId: envelope.requestId, action: { kind: 'set-parent', taskId, parentId } }
|
|
352
|
+
}
|
|
329
353
|
case 'set-schedule':
|
|
330
354
|
if (!exactKeys(action, ['kind', 'taskId', 'patch'])) return undefined
|
|
331
355
|
return taskId !== undefined && schedulePatch(action.patch)
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Board view mounting (dsh-task-board wrapper).
|
|
3
|
-
*
|
|
4
|
-
* The single-occupant takeover lifecycle — container injection into the
|
|
5
|
-
* center column, sibling eviction, remount resilience, sidebar click-out —
|
|
6
|
-
* lives exactly once in shared/client/panel-mount-core.ts (synced copy); this
|
|
7
|
-
* wrapper supplies the board tree, view dataset key, CSS module, and the html
|
|
8
|
-
* attribute names. Those names are pinned by board.module.css, skins, and the
|
|
9
|
-
* semantic attributes contract.
|
|
10
|
-
*/
|
|
11
|
-
import type { BoardController } from '../core/controller.ts';
|
|
12
|
-
import type { LocaleRefreshSource } from './sidebar-entry.ts';
|
|
13
|
-
/** The injected board container (kept in the DOM, hidden when inactive). */
|
|
14
|
-
export declare const BOARD_VIEW_SELECTOR = "[data-dsh-taskboard-view]";
|
|
15
|
-
/**
|
|
16
|
-
* Mount the board React tree into the center column and bind its visibility
|
|
17
|
-
* to the controller's boardOpen state.
|
|
18
|
-
* @param controller - the board controller driving the view.
|
|
19
|
-
* @param locale - locale-change source; when given, re-renders a mounted board
|
|
20
|
-
* on a Language switch.
|
|
21
|
-
* @returns disposer unmounting the tree and restoring the column.
|
|
22
|
-
*/
|
|
23
|
-
export declare function mountBoard(controller: BoardController, locale?: LocaleRefreshSource): () => void;
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One `document.body` MutationObserver per page, shared by every family
|
|
3
|
-
* plugin bundle.
|
|
4
|
-
*
|
|
5
|
-
* Family plugins that inject at the DOM level must notice when the shell
|
|
6
|
-
* re-renders around their injected node: the sidebar entry rows
|
|
7
|
-
* (sidebar-entry-core), the center-column panel containers
|
|
8
|
-
* (panel-mount-core), and the aggregate shell's column shims. Each consumer
|
|
9
|
-
* used to install its OWN `MutationObserver` on `document.body` with
|
|
10
|
-
* `{ childList: true, subtree: true }`, so a page carrying N family plugins
|
|
11
|
-
* paid N native observers and N callback invocations for EVERY mutation batch
|
|
12
|
-
* the app produced — chat token streaming alone produces many per second, and
|
|
13
|
-
* the count grows with the number of installed plugins.
|
|
14
|
-
*
|
|
15
|
-
* This module keeps exactly ONE body observer per page. The registry lives on
|
|
16
|
-
* `globalThis` under a `Symbol.for` key, so the per-package generated copies
|
|
17
|
-
* of this file (scripts/sync-shared.mjs) and separately bundled plugins all
|
|
18
|
-
* reach the same hub at runtime instead of one hub per module instance.
|
|
19
|
-
* Record subscribers receive the mutations accumulated since the last flush.
|
|
20
|
-
* Consumers that only re-check their DOM use subscribeBodyInvalidations:
|
|
21
|
-
* when all subscribers use that path, the hub retains no mutation records
|
|
22
|
-
* (or detached subtrees) while a background page's animation frames pause.
|
|
23
|
-
* Both paths run at most once per frame. The last subscriber disconnects the
|
|
24
|
-
* observer, cancels the pending frame and releases its records.
|
|
25
|
-
*
|
|
26
|
-
* Failure policy: without a DOM or `MutationObserver` the subscription is a
|
|
27
|
-
* no-op disposer (the same silence the per-consumer observers had), and when
|
|
28
|
-
* `requestAnimationFrame` is unavailable the flush runs synchronously so a
|
|
29
|
-
* subscriber is never silently dropped. A throwing subscriber cannot stop the
|
|
30
|
-
* others.
|
|
31
|
-
*/
|
|
32
|
-
/**
|
|
33
|
-
* Subscribe to a coalesced DOM re-check without retaining mutation records.
|
|
34
|
-
* The marked wrapper also works with an older hub, which delivers records
|
|
35
|
-
* that it simply ignores until a page reload picks up the updated hub.
|
|
36
|
-
*/
|
|
37
|
-
export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
|
|
38
|
-
/**
|
|
39
|
-
* Subscribe to body-level childList mutations.
|
|
40
|
-
* @param subscriber - called at most once per animation frame with the records
|
|
41
|
-
* collected since the previous flush; must be safe to run repeatedly.
|
|
42
|
-
* @returns the disposer removing this subscriber (and the observer when it was
|
|
43
|
-
* the last one).
|
|
44
|
-
*/
|
|
45
|
-
export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
|