@linxin666/dsh-client-ui-task-board 0.4.1 → 0.4.2

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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +24 -0
  3. package/README.zh.md +24 -0
  4. package/lib/client.js +917 -81
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1774 -1420
  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/locales.d.ts +33 -0
  12. package/lib/types/core/controller.d.ts +30 -1
  13. package/lib/types/core/subtask.d.ts +122 -0
  14. package/lib/types/core/tasks.d.ts +46 -3
  15. package/lib/types/core/use-cases/task-archive.d.ts +10 -13
  16. package/lib/types/core/use-cases/task-create.d.ts +13 -4
  17. package/lib/types/core/use-cases/task-delete.d.ts +7 -1
  18. package/lib/types/core/use-cases/task-parent.d.ts +21 -0
  19. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  20. package/lib/types/host/agent-tools.d.ts +27 -0
  21. package/lib/types/host-ledger.d.ts +88 -4
  22. package/lib/types/host-runner.d.ts +39 -1
  23. package/lib/types/host-service.d.ts +47 -0
  24. package/lib/types/index.d.ts +83 -5
  25. package/lib/types/protocol.d.ts +14 -0
  26. package/package.json +23 -21
  27. package/src/client/TaskBoardSettingsCard.tsx +48 -3
  28. package/src/client/board/LinkSubtaskModal.tsx +82 -0
  29. package/src/client/board/NewTaskModal.tsx +40 -6
  30. package/src/client/board/TaskBoard.tsx +88 -8
  31. package/src/client/board/TaskCard.tsx +52 -2
  32. package/src/client/board/TaskDetail.tsx +124 -1
  33. package/src/client/board.module.css +96 -0
  34. package/src/client/host-api.ts +14 -4
  35. package/src/client/index.ts +11 -1
  36. package/src/client/locales.ts +70 -2
  37. package/src/core/controller.ts +69 -8
  38. package/src/core/store.ts +15 -0
  39. package/src/core/subtask.ts +286 -0
  40. package/src/core/tasks.ts +50 -2
  41. package/src/core/use-cases/task-archive.ts +46 -12
  42. package/src/core/use-cases/task-create.ts +42 -7
  43. package/src/core/use-cases/task-delete.ts +11 -1
  44. package/src/core/use-cases/task-parent.ts +64 -0
  45. package/src/core/use-cases/task-update.ts +3 -1
  46. package/src/host/agent-tools.ts +643 -0
  47. package/src/host-ledger.ts +323 -45
  48. package/src/host-runner.ts +141 -12
  49. package/src/host-service.ts +127 -10
  50. package/src/index.ts +181 -9
  51. package/src/protocol.ts +26 -2
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 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,105 @@ 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
+
133
182
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
134
183
  const DEFAULT_ANNOUNCE = false
135
184
 
185
+ /**
186
+ * How long one teammate may take to reach its durable active or failed edge.
187
+ * Agent Teams provisioning normally resolves in seconds; the bound exists so a
188
+ * hung spawn settles the subtask instead of holding the cascade open forever.
189
+ */
190
+ const TEAMMATE_PROVISION_TIMEOUT_MS = 600_000
191
+
192
+ /** The subset of the Agent Teams service this plugin drives. */
193
+ interface AgentTeamsFace {
194
+ spawnTeammate(caller: unknown, request: {
195
+ name: string
196
+ description: string
197
+ prompt: Array<{ type: 'text'; text: string }>
198
+ context: 'fresh' | 'fork'
199
+ provider: string
200
+ signal: AbortSignal
201
+ }): Promise<{ member?: { id?: string; phase?: string; error?: string } }>
202
+ }
203
+
204
+ /**
205
+ * Resolve the optional Agent Teams service. Like the tool registry and the llm
206
+ * runtime, it is deliberately not injected: a deployment without Agent Teams
207
+ * must still mount the whole board and only lose team-mode runs.
208
+ * @param ctx - the plugin context.
209
+ * @returns the service, or undefined when this deployment serves none.
210
+ */
211
+ export function resolveAgentTeams(ctx: Context): AgentTeamsFace | undefined {
212
+ try {
213
+ const teams = ctx.get('agentTeams') as AgentTeamsFace | undefined
214
+ return teams !== undefined && typeof teams.spawnTeammate === 'function' ? teams : undefined
215
+ } catch {
216
+ return undefined
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Build the team dispatcher: the run's Lead session spawns one teammate per
222
+ * subtask through the Agent Teams service, and the teammate's session id is
223
+ * attached to that subtask's execution so the ordinary session monitor settles
224
+ * it. Returns undefined when the deployment serves no Agent Teams service or
225
+ * no live-agent registry, which makes a team run fail closed.
226
+ * @param ctx - the plugin context.
227
+ * @param provider - the continuable-subagent provider for fresh teammates.
228
+ * @returns the dispatcher, or undefined when team runs cannot be served.
229
+ */
230
+ export function buildTeamDispatcher(ctx: Context, provider: string): TaskBoardTeamDispatcher | undefined {
231
+ const teams = resolveAgentTeams(ctx)
232
+ if (teams === undefined) return undefined
233
+ if (typeof (ctx.agents as { get?: unknown } | undefined)?.get !== 'function') return undefined
234
+ return {
235
+ async spawn(input) {
236
+ const lead = ctx.agents.get(input.leadSessionId as Parameters<typeof ctx.agents.get>[0])
237
+ if (lead === undefined) return { error: `lead session ${input.leadSessionId} is not live` }
238
+ try {
239
+ const result = await teams.spawnTeammate(lead, {
240
+ name: input.name,
241
+ description: input.description,
242
+ prompt: [{ type: 'text', text: input.prompt }],
243
+ context: 'fresh',
244
+ provider,
245
+ signal: AbortSignal.timeout(TEAMMATE_PROVISION_TIMEOUT_MS),
246
+ })
247
+ const member = result?.member
248
+ if (member === undefined) return { error: 'Agent Teams returned no teammate' }
249
+ if (member.phase === 'failed') return { error: member.error ?? 'teammate provisioning failed' }
250
+ if (typeof member.id !== 'string' || member.id === '') return { error: 'Agent Teams returned no teammate session' }
251
+ return { sessionId: member.id }
252
+ } catch (error) {
253
+ return { error: `teammate provisioning failed: ${error instanceof Error ? error.message : String(error)}` }
254
+ }
255
+ },
256
+ }
257
+ }
258
+
136
259
  /**
137
260
  * Read the optional `llm` service. The board deliberately does not inject it:
138
261
  * a deployment without a model must still mount the board, and the parse route
@@ -153,8 +276,8 @@ export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined {
153
276
  * Activate the board's host half: ledger, routes, cron scheduler, power
154
277
  * inhibitor, and the model-facing announcement.
155
278
  *
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
279
+ * The effective settings are the config the Host hands this row. The fields
280
+ * the browser card edits are schema-volatile, so the Loader commits an
158
281
  * edit into the running fiber's references instead of remounting the row —
159
282
  * `sync` therefore reads them at use time and follows
160
283
  * `loader/volatile-update`, the event the Loader emits once it has committed
@@ -171,10 +294,14 @@ function applyImpl(ctx: Context, config?: Config): void {
171
294
  const announceToAgent = (): boolean => readConfigField(config?.announceToAgent, DEFAULT_ANNOUNCE)
172
295
  /** Current idle-sleep protection flag. */
173
296
  const preventIdleSleep = (): boolean => readConfigField(config?.preventIdleSleep, false)
297
+ /** Current subtask depth limit (read live: the settings card edits it in place). */
298
+ const maxSubtaskDepth = (): number => readConfigField(config?.maxSubtaskDepth, DEFAULT_SUBTASK_DEPTH)
174
299
 
175
300
  const host = new TaskBoardHostService(ctx.typertGateway, {
176
301
  workspaceRegistry: ctx.workspaceRegistry,
177
302
  sessionDefaultPermission: config?.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION,
303
+ maxSubtaskDepth: maxSubtaskDepth(),
304
+ team: buildTeamDispatcher(ctx, config?.teamProvider ?? DEFAULT_TEAM_PROVIDER),
178
305
  commandDispatcher: {
179
306
  async execute(sessionId, line, signal) {
180
307
  const agent = ctx.agents.get(sessionId)
@@ -187,6 +314,45 @@ function applyImpl(ctx: Context, config?: Config): void {
187
314
  // scheduler tick, which would roll schedules the board is not running.
188
315
  host.setConfiguration(enabled(), preventIdleSleep())
189
316
  host.start()
317
+
318
+ // Agent tools: the same Host ledger the browser drives, so any session can
319
+ // list, create, link, run and settle board work. Registration follows the
320
+ // master switch (a disabled board answers no tool call), and the mount
321
+ // effect below owns the disposers.
322
+ let disposeTools: (() => void) | undefined
323
+ const setToolsEnabled = (active: boolean): void => {
324
+ if (!active) {
325
+ disposeTools?.()
326
+ disposeTools = undefined
327
+ return
328
+ }
329
+ if (disposeTools !== undefined) return
330
+ const registry = resolveToolRegistry(ctx)
331
+ if (registry === undefined) return
332
+ const disposers = buildTaskBoardTools(host).map(tool => registry.register(tool))
333
+ disposeTools = () => {
334
+ for (const dispose of disposers.splice(0)) dispose()
335
+ }
336
+ }
337
+ setToolsEnabled(enabled())
338
+ // A registry that activates after this row (or a row activated before it) is
339
+ // followed through scoped injection where the runtime serves one; capture-only
340
+ // contexts without it register through the direct resolution above.
341
+ const scopedInject = (ctx as { inject?: (names: readonly string[], callback: (scoped: Context) => unknown) => unknown }).inject
342
+ if (typeof scopedInject === 'function') {
343
+ scopedInject.call(ctx, ['tools'], () => {
344
+ setToolsEnabled(enabled())
345
+ // Cordis unloads and re-runs this callback when the injected service's
346
+ // provider fiber changes, and the old registry dies with its provider.
347
+ // Releasing the guard here is what lets the callback register into the
348
+ // NEW registry instead of short-circuiting on the stale disposer.
349
+ return () => {
350
+ disposeTools?.()
351
+ disposeTools = undefined
352
+ }
353
+ })
354
+ }
355
+
190
356
  ctx.effect(() => {
191
357
  const disposers: Array<() => void> = []
192
358
  try {
@@ -199,18 +365,22 @@ function applyImpl(ctx: Context, config?: Config): void {
199
365
  })
200
366
  for (const route of routes) disposers.push(ctx.webServer.register(route))
201
367
  } catch (error) {
368
+ // A failed mount must not leave the agent tools bound to a disposed
369
+ // host: the effect cleanup never runs when this body throws.
370
+ setToolsEnabled(false)
202
371
  for (const dispose of disposers) dispose()
203
372
  host.dispose()
204
373
  throw error
205
374
  }
206
375
  return () => {
376
+ setToolsEnabled(false)
207
377
  for (const dispose of disposers) dispose()
208
378
  host.dispose()
209
379
  }
210
380
  }, 'task-board: host ledger, scheduler, and routes')
211
381
 
212
382
  let disposeSection: (() => void) | undefined
213
- let applied: { enabled: boolean; announceToAgent: boolean; preventIdleSleep: boolean } | undefined
383
+ let applied: { enabled: boolean; announceToAgent: boolean; preventIdleSleep: boolean; maxSubtaskDepth: number } | undefined
214
384
 
215
385
  // Apply the current values to the host service and the announcement. A
216
386
  // commit that changes nothing visible is a no-op, so following a coarse
@@ -218,12 +388,14 @@ function applyImpl(ctx: Context, config?: Config): void {
218
388
  // under one disposer: re-registering first tears the old one down so a
219
389
  // duplicate-name registration never throws.
220
390
  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) {
391
+ const next = { enabled: enabled(), announceToAgent: announceToAgent(), preventIdleSleep: preventIdleSleep(), maxSubtaskDepth: maxSubtaskDepth() }
392
+ if (applied !== undefined && applied.enabled === next.enabled && applied.announceToAgent === next.announceToAgent && applied.preventIdleSleep === next.preventIdleSleep && applied.maxSubtaskDepth === next.maxSubtaskDepth) {
223
393
  return
224
394
  }
225
395
  applied = next
396
+ host.ledger.setMaxSubtaskDepth(next.maxSubtaskDepth)
226
397
  host.setConfiguration(next.enabled, next.preventIdleSleep)
398
+ setToolsEnabled(next.enabled)
227
399
  if (disposeSection !== undefined) {
228
400
  disposeSection()
229
401
  disposeSection = undefined
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)