@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.
Files changed (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +28 -3
  3. package/README.zh.md +28 -3
  4. package/lib/client.js +1142 -467
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2066 -1475
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +4 -0
  8. package/lib/types/client/board/LinkSubtaskModal.d.ts +11 -0
  9. package/lib/types/client/board/NewTaskModal.d.ts +7 -1
  10. package/lib/types/client/board/TaskCard.d.ts +11 -1
  11. package/lib/types/client/index.d.ts +15 -1
  12. package/lib/types/client/locales.d.ts +33 -0
  13. package/lib/types/client/native-panel.d.ts +62 -0
  14. package/lib/types/client/settings-entry-form.d.ts +49 -0
  15. package/lib/types/core/controller.d.ts +71 -3
  16. package/lib/types/core/subtask.d.ts +122 -0
  17. package/lib/types/core/tasks.d.ts +46 -3
  18. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  19. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  20. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  21. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/agent-tools.d.ts +27 -0
  24. package/lib/types/host-ledger.d.ts +97 -4
  25. package/lib/types/host-runner.d.ts +39 -1
  26. package/lib/types/host-service.d.ts +98 -5
  27. package/lib/types/index.d.ts +93 -5
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/protocol.d.ts +14 -0
  30. package/package.json +26 -21
  31. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  32. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  33. package/src/client/board/NewTaskModal.tsx +40 -6
  34. package/src/client/board/TaskBoard.tsx +88 -8
  35. package/src/client/board/TaskCard.tsx +52 -2
  36. package/src/client/board/TaskDetail.tsx +124 -1
  37. package/src/client/board.module.css +112 -131
  38. package/src/client/host-api.ts +14 -4
  39. package/src/client/index.ts +56 -18
  40. package/src/client/locales.ts +70 -2
  41. package/src/client/native-panel.tsx +124 -0
  42. package/src/client/settings-entry-form.ts +142 -0
  43. package/src/core/controller.ts +128 -10
  44. package/src/core/store.ts +15 -0
  45. package/src/core/subtask.ts +286 -0
  46. package/src/core/tasks.ts +50 -2
  47. package/src/core/use-cases/task-archive.ts +46 -12
  48. package/src/core/use-cases/task-create.ts +42 -7
  49. package/src/core/use-cases/task-delete.ts +11 -1
  50. package/src/core/use-cases/task-parent.ts +64 -0
  51. package/src/core/use-cases/task-update.ts +3 -1
  52. package/src/host/agent-tools.ts +643 -0
  53. package/src/host-ledger.ts +343 -45
  54. package/src/host-routes.ts +37 -1
  55. package/src/host-runner.ts +141 -12
  56. package/src/host-service.ts +265 -32
  57. package/src/index.ts +203 -9
  58. package/src/mount-once.ts +107 -13
  59. package/src/protocol.ts +26 -2
  60. package/lib/types/client/board-mount.d.ts +0 -23
  61. package/lib/types/client/body-mutations.d.ts +0 -45
  62. package/lib/types/client/panel-mount-core.d.ts +0 -56
  63. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  64. package/lib/types/client/sidebar-entry.d.ts +0 -29
  65. package/src/client/board-mount.tsx +0 -43
  66. package/src/client/body-mutations.ts +0 -133
  67. package/src/client/panel-mount-core.ts +0 -155
  68. package/src/client/sidebar-entry-core.ts +0 -237
  69. 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 预设和权限;支持 Host 本地时区的 5 段 cron,错过的触发点不补跑;可选且默认关闭的空闲系统睡眠保护允许屏幕熄灭,但不承诺拦截合盖、手动睡眠、休眠、关机或唤醒已睡眠机器。执行消耗 API 额度。用户提到「任务看板 / 看板 / 定时任务」时即指本插件,请据此协作。若你同时用 todo_write 维护会话顶部的可见计划列表,最终回复前必须再次调用 todo_write 收尾:没有剩余工作时不要保留 in_progress,已完成的最后一步要标为 completed。'
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
- * three fields the browser card edits are marked volatile — the Loader commits
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 three
157
- * fields the browser card edits are schema-volatile, so the Loader commits an
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 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).
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. cordis
14
- * `ctx.effect` runs its callback immediately and treats the callback's
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]?: Set<string>
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
- return (registry[MOUNTED] ??= new Set())
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 unmarks when its fiber disposes;
32
- * any later mount of the same package name is a no-op.
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
- return ((...args: unknown[]) => {
101
+ const mount = (...args: unknown[]): unknown => {
39
102
  const mounted = mountedSet()
40
- if (mounted.has(packageName)) return
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
- }) as T
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;