@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
@@ -0,0 +1,643 @@
1
+ /**
2
+ * Agent tools for the task board: the DSH-native counterpart of the board UI.
3
+ *
4
+ * Every tool drives the same Host ledger the browser drives, so a task created
5
+ * in the GUI is immediately operable from any session and vice versa. Domain
6
+ * refusals come back as ok:false values the model can react to; the Host
7
+ * ledger stays the only authority, so every gate the UI honors still holds
8
+ * here (fail-closed pins, the permission confirmation gate, the subtask depth
9
+ * limit, the running-task locks).
10
+ *
11
+ * Deliberately absent: confirm-permission. That gate exists so a HUMAN lifts an
12
+ * above-default permission binding; a tool that could stamp it would make the
13
+ * gate decorative. An agent that hits it must ask the user to confirm in the
14
+ * board UI.
15
+ *
16
+ * @module dsh-task-board/host/agent-tools
17
+ */
18
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
19
+ import { defineTool, type ToolDefinition } from '@deepseek-ai/dsh-tools'
20
+ import { DEFAULT_SESSION_PERMISSION, requiresPermissionConfirmation } from '../core/handover.ts'
21
+ import { DEFAULT_SUBTASK_DEPTH, directSubtasks, taskDepth } from '../core/subtask.ts'
22
+ import {
23
+ TASK_PERMISSIONS,
24
+ type ExecutionRecord,
25
+ type NewTaskInput,
26
+ type ScheduleRule,
27
+ type TaskPermission,
28
+ type TaskRecord,
29
+ type TaskTag,
30
+ } from '../core/tasks.ts'
31
+ import type { TaskUpdatePatch } from '../core/use-cases/task-update.ts'
32
+ import type { TaskBoardAction, TaskBoardSnapshot } from '../protocol.ts'
33
+
34
+ /**
35
+ * The narrow Host face the tools need. TaskBoardHostService satisfies it
36
+ * structurally, so tests and a future host can drive the same surface.
37
+ */
38
+ export interface TaskBoardToolHost {
39
+ /** Current board snapshot (tasks + scheduler + power + deployment constants). */
40
+ snapshot(): TaskBoardSnapshot
41
+ /** Submit one confirmed Host action; returns the resulting snapshot. */
42
+ apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot
43
+ }
44
+
45
+ /** Registered tool names, in registration order. */
46
+ export const TASK_BOARD_TOOL_NAMES = [
47
+ 'task_board_list',
48
+ 'task_board_get',
49
+ 'task_board_create',
50
+ 'task_board_update',
51
+ 'task_board_set_parent',
52
+ 'task_board_run',
53
+ 'task_board_manage',
54
+ 'task_board_schedule',
55
+ ] as const
56
+
57
+ /** Unconstrained JSON value the tools return (their output schema is the JSON node). */
58
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json }
59
+
60
+ /** Model-facing JSON rendering shared by every tool. */
61
+ function renderJson(_args: unknown, value: unknown): ContentBlock[] {
62
+ return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
63
+ }
64
+
65
+ /** Mark an already JSON-safe projection as the tool's canonical value. */
66
+ function json(value: unknown): Json {
67
+ return value as Json
68
+ }
69
+
70
+ /** A domain refusal the model is expected to read and act on. */
71
+ function refused(code: string, message: string): Json {
72
+ return json({ ok: false, code, message })
73
+ }
74
+
75
+ function messageOf(error: unknown): string {
76
+ return error instanceof Error ? error.message : String(error)
77
+ }
78
+
79
+ function has(value: object, key: string): boolean {
80
+ return Object.prototype.hasOwnProperty.call(value, key)
81
+ }
82
+
83
+ /**
84
+ * The calling session id, for the execution audit trail (the same stamp the
85
+ * browser asserts). Absent when a call has no agent behind it.
86
+ */
87
+ export function callingSessionId(exec: { agent?: unknown }): string | undefined {
88
+ const agent = exec.agent as { session?: { id?: unknown; header?: { id?: unknown } } } | undefined
89
+ const id = agent?.session?.id ?? agent?.session?.header?.id
90
+ return typeof id === 'string' && id !== '' ? id : undefined
91
+ }
92
+
93
+ function scheduleView(schedule: ScheduleRule): Record<string, unknown> {
94
+ return {
95
+ enabled: schedule.enabled,
96
+ cron: schedule.cron,
97
+ ...(schedule.nextRunAt === undefined ? {} : { nextRunAt: schedule.nextRunAt }),
98
+ ...(schedule.lastTriggeredAt === undefined ? {} : { lastTriggeredAt: schedule.lastTriggeredAt }),
99
+ }
100
+ }
101
+
102
+ function tagView(tag: TaskTag): Record<string, unknown> {
103
+ return { name: tag.name, ...(tag.promptPrefix === undefined ? {} : { promptPrefix: tag.promptPrefix }) }
104
+ }
105
+
106
+ function executionView(execution: ExecutionRecord): Record<string, unknown> {
107
+ return {
108
+ id: execution.id,
109
+ ...(execution.sessionId === undefined ? {} : { sessionId: execution.sessionId }),
110
+ startedAt: execution.startedAt,
111
+ ...(execution.endedAt === undefined ? {} : { endedAt: execution.endedAt }),
112
+ ...(execution.result === undefined ? {} : { result: execution.result }),
113
+ ...(execution.error === undefined ? {} : { error: execution.error }),
114
+ // The session that asked for the run (audit only; client-asserted by the
115
+ // browser, the calling session here).
116
+ ...(execution.initiatedBy === undefined ? {} : { initiatedBy: execution.initiatedBy }),
117
+ ...(execution.runGroupId === undefined ? {} : { cascade: true }),
118
+ // A deferred cascade parent has settled its own turn but still waits for
119
+ // its subtasks; that is not a stuck card and the model must not retry it.
120
+ ...(execution.ownResult === undefined
121
+ ? {}
122
+ : { ownResult: execution.ownResult, awaitingSubtasks: execution.endedAt === undefined }),
123
+ }
124
+ }
125
+
126
+ /**
127
+ * One compact task row for the model: identity, column, lineage, execution
128
+ * targets, schedule, and the latest run. The prompt body rides only
129
+ * task_board_get, so a fifty-task list stays readable.
130
+ */
131
+ function taskSummary(
132
+ task: TaskRecord,
133
+ tasks: readonly TaskRecord[],
134
+ sessionDefault: TaskPermission | undefined,
135
+ ): Record<string, unknown> {
136
+ const latest = task.executions.at(-1)
137
+ const subtaskCount = directSubtasks(tasks, task.id).length
138
+ return {
139
+ id: task.id,
140
+ title: task.title,
141
+ status: task.status,
142
+ description: task.description,
143
+ ...(task.parentId === undefined ? {} : { parentId: task.parentId }),
144
+ ...(subtaskCount === 0 ? {} : { subtaskCount }),
145
+ ...(task.archivedAt === undefined ? {} : { archivedAt: task.archivedAt }),
146
+ ...(task.tags === undefined ? {} : { tags: task.tags.map(tagView) }),
147
+ ...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
148
+ ...(task.mode === undefined ? {} : { mode: task.mode }),
149
+ ...(task.permission === undefined ? {} : { permission: task.permission }),
150
+ ...(task.model === undefined ? {} : { model: task.model }),
151
+ ...(task.reuseSession === true ? { reuseSession: true } : {}),
152
+ ...(task.teamRun === true ? { teamRun: true } : {}),
153
+ ...(task.schedule === undefined ? {} : { schedule: scheduleView(task.schedule) }),
154
+ ...(task.freeze === undefined ? {} : { continuationCard: true }),
155
+ ...(task.permissionConfirmedAt === undefined ? {} : { permissionConfirmedAt: task.permissionConfirmedAt }),
156
+ // Whether this card may run at all: an elevated binding still needs a
157
+ // human confirmation, and only a human can give it.
158
+ ...(requiresPermissionConfirmation(task, sessionDefault ?? DEFAULT_SESSION_PERMISSION)
159
+ ? { permissionPending: true }
160
+ : {}),
161
+ executionCount: task.executions.length,
162
+ ...(latest === undefined ? {} : { latestExecution: executionView(latest) }),
163
+ createdAt: task.createdAt,
164
+ updatedAt: task.updatedAt,
165
+ }
166
+ }
167
+
168
+ /** One task's full detail: content, lineage, schedule, and recent runs. */
169
+ function taskDetail(
170
+ task: TaskRecord,
171
+ tasks: readonly TaskRecord[],
172
+ sessionDefault: TaskPermission | undefined,
173
+ ): Record<string, unknown> {
174
+ const parent = task.parentId === undefined ? undefined : tasks.find(item => item.id === task.parentId)
175
+ return {
176
+ ...taskSummary(task, tasks, sessionDefault),
177
+ prompt: task.prompt,
178
+ depth: taskDepth(tasks, task.id),
179
+ ...(parent === undefined ? {} : { parent: { id: parent.id, title: parent.title, status: parent.status } }),
180
+ ...(task.freeze === undefined
181
+ ? {}
182
+ : {
183
+ freeze: {
184
+ goal: task.freeze.goal,
185
+ progress: task.freeze.progress,
186
+ next: task.freeze.next,
187
+ frozenAt: task.freeze.frozenAt,
188
+ ...(task.freeze.frozenBy === undefined ? {} : { frozenBy: task.freeze.frozenBy }),
189
+ ...(task.freeze.redacted === true ? { redacted: true } : {}),
190
+ },
191
+ }),
192
+ ...(task.handover === undefined
193
+ ? {}
194
+ : {
195
+ handover: {
196
+ ...(task.handover.workspaceId === undefined ? {} : { workspaceId: task.handover.workspaceId }),
197
+ ...(task.handover.mode === undefined ? {} : { mode: task.handover.mode }),
198
+ ...(task.handover.permission === undefined ? {} : { permission: task.handover.permission }),
199
+ references: task.handover.references,
200
+ bundledAt: task.handover.bundledAt,
201
+ },
202
+ }),
203
+ subtasks: directSubtasks(tasks, task.id).map(child => taskSummary(child, tasks, sessionDefault)),
204
+ executions: task.executions.slice(-10).map(executionView),
205
+ }
206
+ }
207
+
208
+ /** The executions a run opened: everything that was not in the ledger before. */
209
+ function openedExecutions(before: readonly ExecutionRecord[], after: readonly ExecutionRecord[]): ExecutionRecord[] {
210
+ const known = new Set(before.map(execution => execution.id))
211
+ return after.filter(execution => !known.has(execution.id))
212
+ }
213
+
214
+ /** Board summary attached to task_board_list. */
215
+ function boardView(snapshot: TaskBoardSnapshot, maxSubtaskDepth: number): Record<string, unknown> {
216
+ const counts = { backlog: 0, todo: 0, running: 0, done: 0, failed: 0, archived: 0 }
217
+ for (const task of snapshot.tasks) {
218
+ if (task.archivedAt !== undefined) counts.archived += 1
219
+ else counts[task.status] += 1
220
+ }
221
+ return {
222
+ revision: snapshot.revision,
223
+ timeZone: snapshot.scheduler.timeZone,
224
+ ...(snapshot.scheduler.error === undefined ? {} : { schedulerError: snapshot.scheduler.error }),
225
+ maxSubtaskDepth,
226
+ sessionDefaultPermission: snapshot.sessionDefaultPermission ?? DEFAULT_SESSION_PERMISSION,
227
+ runningSessions: snapshot.power.runningSessions,
228
+ armedSchedules: snapshot.power.armedSchedules,
229
+ counts,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Build every task-board tool bound to one Host service.
235
+ * @param host - the Host ledger/service face (TaskBoardHostService).
236
+ * @returns the tool definitions, in TASK_BOARD_TOOL_NAMES order.
237
+ */
238
+ export function buildTaskBoardTools(host: TaskBoardToolHost): ToolDefinition[] {
239
+ return [
240
+ buildListTool(host),
241
+ buildGetTool(host),
242
+ buildCreateTool(host),
243
+ buildUpdateTool(host),
244
+ buildSetParentTool(host),
245
+ buildRunTool(host),
246
+ buildManageTool(host),
247
+ buildScheduleTool(host),
248
+ ]
249
+ }
250
+
251
+ function buildSetParentTool(host: TaskBoardToolHost): ToolDefinition {
252
+ return defineTool({
253
+ name: 'task_board_set_parent',
254
+ description: [
255
+ 'Attach an existing task under a parent (making it a subtask) or detach it back to the root board by setting its parentId.',
256
+ 'Pass an empty parentId to detach.',
257
+ 'The Host refuses the link when the parent is unknown or archived, when the moved task is one of its own descendants, when the resulting depth exceeds the deployment subtask limit (default one level, maximum three), or while the task has an unsettled execution (its link may still be a running cascade parent pointer).',
258
+ 'Running a task runs its whole subtree concurrently; a parent card settles only after its own turn and every direct subtask settle.',
259
+ 'Triggers: 任务看板, task board, 看板, 子任务, subtask, 关联任务, link task, 解除关联, detach.',
260
+ ].join(' '),
261
+ parameters: {
262
+ taskId: { type: 'string', required: true, description: 'The task whose parent link changes.' },
263
+ parentId: { type: 'string', required: true, description: 'Parent task id, or an empty string to detach the task back to the root board.' },
264
+ },
265
+ output: { schema: { type: 'json' }, render: renderJson },
266
+ async execute(args, exec) {
267
+ try {
268
+ const snapshot = host.apply(
269
+ crypto.randomUUID(),
270
+ { kind: 'set-parent', taskId: args.taskId, parentId: args.parentId === '' ? null : args.parentId },
271
+ callingSessionId(exec),
272
+ )
273
+ const task = snapshot.tasks.find(item => item.id === args.taskId)
274
+ if (task === undefined) return refused('task-not-found', 'no task with id ' + args.taskId)
275
+ return json({ ok: true, task: taskSummary(task, snapshot.tasks, snapshot.sessionDefaultPermission) })
276
+ } catch (error) {
277
+ return refused('refused', messageOf(error))
278
+ }
279
+ },
280
+ })
281
+ }
282
+
283
+ function buildRunTool(host: TaskBoardToolHost): ToolDefinition {
284
+ return defineTool({
285
+ name: 'task_board_run',
286
+ description: [
287
+ 'Run a task on the board now, or re-run a settled one.',
288
+ 'Running a task also runs its whole subtask tree CONCURRENTLY: the Host opens one real DSH session per member under one run group, each replaying its card pinned workspace, agent preset, permission and model; a subtask that does not pin them inherits its nearest ancestor.',
289
+ 'The parent card stays in the running column until its own turn and every direct subtask have settled, and it fails when any member failed; one failing subtask never stops the others.',
290
+ 'A card whose effective permission is above the deployment session default is refused with confirmation-required until a HUMAN confirms the binding in the board UI; this tool cannot confirm it.',
291
+ 'A card whose task carries teamRun instead starts ONE Team Lead session and the Host spawns a teammate per subtask inside it, so those cards run as an Agent Team rather than as independent sessions; that mode needs the deployment Agent Teams service and is refused when it is missing.',
292
+ 'Executions consume real API quota. Sessions are created asynchronously, so read their ids and outcomes back with task_board_get.',
293
+ 'Triggers: 任务看板, task board, 看板, 执行任务, run task, 重新执行, rerun, 子任务, subtask.',
294
+ ].join(' '),
295
+ parameters: {
296
+ taskId: { type: 'string', required: true, description: 'The task to run; running a root task runs its whole subtree.' },
297
+ rerun: { type: 'boolean', description: 'Re-run a settled task: reset its column to todo before starting (default false).' },
298
+ },
299
+ output: { schema: { type: 'json' }, render: renderJson },
300
+ async execute(args, exec) {
301
+ const before = host.snapshot()
302
+ try {
303
+ const snapshot = host.apply(
304
+ crypto.randomUUID(),
305
+ { kind: args.rerun === true ? 'rerun' : 'run', taskId: args.taskId },
306
+ callingSessionId(exec),
307
+ )
308
+ const beforeById = new Map(before.tasks.map(task => [task.id, task]))
309
+ const started = snapshot.tasks.flatMap(task => {
310
+ const previous = beforeById.get(task.id)?.executions ?? []
311
+ return openedExecutions(previous, task.executions).map(execution => ({
312
+ taskId: task.id,
313
+ title: task.title,
314
+ executionId: execution.id,
315
+ ...(execution.sessionId === undefined ? {} : { sessionId: execution.sessionId }),
316
+ }))
317
+ })
318
+ const root = snapshot.tasks.find(task => task.id === args.taskId)
319
+ return json({
320
+ ok: true,
321
+ started,
322
+ ...(root === undefined ? {} : { task: taskSummary(root, snapshot.tasks, snapshot.sessionDefaultPermission) }),
323
+ note: 'Executions run asynchronously; call task_board_get to read session ids and the settled outcome. The parent settles only after its own turn and every direct subtask settle.',
324
+ })
325
+ } catch (error) {
326
+ const message = messageOf(error)
327
+ return refused(message.startsWith('confirmation-required') ? 'confirmation-required' : 'refused', message)
328
+ }
329
+ },
330
+ })
331
+ }
332
+
333
+ function buildManageTool(host: TaskBoardToolHost): ToolDefinition {
334
+ return defineTool({
335
+ name: 'task_board_manage',
336
+ description: [
337
+ 'Move, archive, restore, or delete one task board card.',
338
+ 'move-todo and move-backlog are the manual column moves; a running card cannot be moved.',
339
+ 'archive takes the whole subtask tree off the board and is refused while any member has an unsettled execution; restore brings the task, its ancestors and its subtree back; delete removes one card and is refused while it still has subtasks (detach or delete them first) or while it runs.',
340
+ 'It cannot confirm a permission binding: the confirmation gate is a human act performed in the board UI.',
341
+ 'Triggers: 任务看板, task board, 看板, 归档, archive, 删除任务, delete task, 移动任务, move task.',
342
+ ].join(' '),
343
+ parameters: {
344
+ taskId: { type: 'string', required: true, description: 'The task to act on.' },
345
+ action: {
346
+ type: 'string',
347
+ required: true,
348
+ enum: ['move-todo', 'move-backlog', 'archive', 'restore', 'delete'],
349
+ description: 'The lifecycle operation to perform.',
350
+ },
351
+ },
352
+ output: { schema: { type: 'json' }, render: renderJson },
353
+ async execute(args, exec) {
354
+ const initiator = callingSessionId(exec)
355
+ const actions: Record<string, TaskBoardAction> = {
356
+ 'move-todo': { kind: 'move', taskId: args.taskId, status: 'todo' },
357
+ 'move-backlog': { kind: 'move', taskId: args.taskId, status: 'backlog' },
358
+ archive: { kind: 'archive', taskId: args.taskId },
359
+ restore: { kind: 'restore', taskId: args.taskId },
360
+ delete: { kind: 'delete', taskId: args.taskId },
361
+ }
362
+ const action = actions[args.action]
363
+ if (action === undefined) return refused('unknown-action', 'unsupported action ' + args.action)
364
+ if (!host.snapshot().tasks.some(task => task.id === args.taskId)) {
365
+ return refused('task-not-found', 'no task with id ' + args.taskId)
366
+ }
367
+ try {
368
+ const snapshot = host.apply(crypto.randomUUID(), action, initiator)
369
+ if (args.action === 'delete') return json({ ok: true, action: args.action, taskId: args.taskId })
370
+ const task = snapshot.tasks.find(item => item.id === args.taskId)
371
+ if (task === undefined) return refused('task-not-found', 'no task with id ' + args.taskId)
372
+ return json({ ok: true, action: args.action, task: taskSummary(task, snapshot.tasks, snapshot.sessionDefaultPermission) })
373
+ } catch (error) {
374
+ return refused('refused', messageOf(error))
375
+ }
376
+ },
377
+ })
378
+ }
379
+
380
+ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
381
+ return defineTool({
382
+ name: 'task_board_schedule',
383
+ description: [
384
+ 'Arm, change, or disarm a task scheduled runs (5-field cron in the Host local time zone; day-of-month and day-of-week use OR semantics).',
385
+ 'A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.',
386
+ 'A schedule whose tree contains an unconfirmed above-default permission is refused and rolls to its next occurrence; the reason is reported in the list board summary as schedulerError.',
387
+ 'Missed occurrences during Host downtime are skipped, never queued; a task that is already running skips its occurrence.',
388
+ 'Triggers: 任务看板, task board, 看板, 定时任务, schedule, cron, 每天执行.',
389
+ ].join(' '),
390
+ parameters: {
391
+ taskId: { type: 'string', required: true, description: 'The task whose schedule changes.' },
392
+ enabled: { type: 'boolean', description: 'Arm (true) or disarm (false) the schedule.' },
393
+ cron: { type: 'string', description: '5-field cron expression: minute hour day-of-month month day-of-week.' },
394
+ },
395
+ output: { schema: { type: 'json' }, render: renderJson },
396
+ async execute(args, exec) {
397
+ if (args.enabled === undefined && args.cron === undefined) {
398
+ return refused('nothing-to-change', 'pass enabled and/or cron')
399
+ }
400
+ // A missing card must read as a missing card: the ledger reports an
401
+ // unknown id through the same refusal as a malformed cron.
402
+ if (!host.snapshot().tasks.some(task => task.id === args.taskId)) {
403
+ return refused('task-not-found', 'no task with id ' + args.taskId)
404
+ }
405
+ const patch: { enabled?: boolean; cron?: string } = {
406
+ ...(args.enabled === undefined ? {} : { enabled: args.enabled }),
407
+ ...(args.cron === undefined ? {} : { cron: args.cron }),
408
+ }
409
+ try {
410
+ const snapshot = host.apply(crypto.randomUUID(), { kind: 'set-schedule', taskId: args.taskId, patch }, callingSessionId(exec))
411
+ const task = snapshot.tasks.find(item => item.id === args.taskId)
412
+ if (task === undefined) return refused('task-not-found', 'no task with id ' + args.taskId)
413
+ return json({
414
+ ok: true,
415
+ schedule: task.schedule === undefined ? null : scheduleView(task.schedule),
416
+ task: taskSummary(task, snapshot.tasks, snapshot.sessionDefaultPermission),
417
+ })
418
+ } catch (error) {
419
+ return refused('refused', messageOf(error))
420
+ }
421
+ },
422
+ })
423
+ }
424
+
425
+
426
+ function buildListTool(host: TaskBoardToolHost): ToolDefinition {
427
+ return defineTool({
428
+ name: 'task_board_list',
429
+ description: [
430
+ 'List tasks on the DSH task board (the Host-authoritative ledger behind the Web GUI task board).',
431
+ 'Filters: status column, parentId (direct subtasks), rootsOnly, tag, free-text query, and archived inclusion.',
432
+ 'Returns each task id, title, column, parent/subtask link, execution targets, schedule and latest run outcome, plus the board summary (revision, time zone, subtask depth limit, session-default permission).',
433
+ 'Use task_board_get for one task in full, task_board_create to add work, and task_board_run to execute it.',
434
+ 'Triggers: 任务看板, task board, 看板, 子任务, subtask, task list, 定时任务, scheduled task.',
435
+ ].join(' '),
436
+ parameters: {
437
+ status: { type: 'string', enum: ['backlog', 'todo', 'running', 'done', 'failed'], description: 'Only cards in this column.' },
438
+ parentId: { type: 'string', description: 'Only the direct subtasks of this task id.' },
439
+ rootsOnly: { type: 'boolean', description: 'Only tasks that have no parent (default false).' },
440
+ includeArchived: { type: 'boolean', description: 'Include archived tasks (default false).' },
441
+ tag: { type: 'string', description: 'Only tasks carrying this label name.' },
442
+ query: { type: 'string', description: 'Case-insensitive match against title, description, prompt, and label names.' },
443
+ limit: { type: 'integer', description: 'Maximum rows to return (default 50, maximum 200).' },
444
+ },
445
+ output: { schema: { type: 'json' }, render: renderJson },
446
+ isConcurrencySafe: () => true,
447
+ async execute(args) {
448
+ const snapshot = host.snapshot()
449
+ const needle = typeof args.query === 'string' ? args.query.trim().toLowerCase() : ''
450
+ const limit = Math.min(Math.max(typeof args.limit === 'number' ? Math.trunc(args.limit) : 50, 1), 200)
451
+ const matched = snapshot.tasks.filter(task => {
452
+ if (args.includeArchived !== true && task.archivedAt !== undefined) return false
453
+ if (args.status !== undefined && task.status !== args.status) return false
454
+ if (args.parentId !== undefined && task.parentId !== args.parentId) return false
455
+ if (args.rootsOnly === true && task.parentId !== undefined) return false
456
+ if (args.tag !== undefined && !(task.tags ?? []).some(tag => tag.name === args.tag)) return false
457
+ if (needle !== '') {
458
+ const haystack = [task.title, task.description, task.prompt, ...(task.tags ?? []).map(tag => tag.name)].join('\n').toLowerCase()
459
+ if (!haystack.includes(needle)) return false
460
+ }
461
+ return true
462
+ })
463
+ return json({
464
+ ok: true,
465
+ board: boardView(snapshot, snapshot.maxSubtaskDepth ?? DEFAULT_SUBTASK_DEPTH),
466
+ total: matched.length,
467
+ count: Math.min(matched.length, limit),
468
+ tasks: matched.slice(0, limit).map(task => taskSummary(task, snapshot.tasks, snapshot.sessionDefaultPermission)),
469
+ })
470
+ },
471
+ })
472
+ }
473
+
474
+ function buildGetTool(host: TaskBoardToolHost): ToolDefinition {
475
+ return defineTool({
476
+ name: 'task_board_get',
477
+ description: [
478
+ 'Read one task board card in full: prompt, description, labels, column, parent link, direct subtasks, execution targets, schedule, permission gate state, and the last ten execution attempts with their session ids and outcomes.',
479
+ 'Use it before running or editing a card, and after a run to read the settled outcome.',
480
+ 'Triggers: 任务看板, task board, 看板, 子任务, subtask, task detail.',
481
+ ].join(' '),
482
+ parameters: {
483
+ taskId: { type: 'string', required: true, description: 'Task id, as reported by task_board_list or task_board_create.' },
484
+ },
485
+ output: { schema: { type: 'json' }, render: renderJson },
486
+ isConcurrencySafe: () => true,
487
+ async execute(args) {
488
+ const snapshot = host.snapshot()
489
+ const task = snapshot.tasks.find(item => item.id === args.taskId)
490
+ if (task === undefined) return refused('task-not-found', 'no task with id ' + args.taskId)
491
+ return json({ ok: true, task: taskDetail(task, snapshot.tasks, snapshot.sessionDefaultPermission) })
492
+ },
493
+ })
494
+ }
495
+
496
+ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
497
+ return defineTool({
498
+ name: 'task_board_create',
499
+ description: [
500
+ 'Create a task on the DSH task board, or a subtask by passing parentId.',
501
+ 'A subtask inherits every execution target it does not override (workspace, agent preset, permission, model) from its parent, and inherits the parent human permission confirmation together with an inherited binding.',
502
+ 'The deployment caps subtask depth (default one level, maximum three): a subtask of a subtask is refused unless the board is configured deeper.',
503
+ 'The task lands in the todo column and does NOT run; call task_board_run to execute it.',
504
+ 'Triggers: 任务看板, task board, 看板, 新建任务, create task, 子任务, subtask, 定时任务.',
505
+ ].join(' '),
506
+ parameters: {
507
+ title: { type: 'string', required: true, description: 'Short display title (one line).' },
508
+ description: { type: 'string', description: 'Longer human description shown in the detail view.' },
509
+ prompt: { type: 'string', description: 'The instruction sent to the execution agent; the title is used when blank.' },
510
+ parentId: { type: 'string', description: 'Parent task id, making this a subtask. Omit for a root task.' },
511
+ workspaceId: { type: 'string', description: 'Workspace id the execution must run in; omit to inherit the parent value or use the most recent workspace.' },
512
+ mode: { type: 'string', description: 'Agent preset id the execution session is composed from; omit for the deployment default or the parent value.' },
513
+ permission: { type: 'string', enum: [...TASK_PERMISSIONS], description: 'Permission preset for the execution session. Omit to inherit the parent binding; a value above the session default needs a human confirmation in the board UI before the card can run.' },
514
+ model: { type: 'string', description: 'Pinned model as provider/model (or a model id); omit for the host default or the parent value.' },
515
+ reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session instead of a fresh conversation.' },
516
+ teamRun: { type: 'boolean', description: 'Run this task as an Agent Team: running it starts one Team Lead session and the Host spawns a teammate per subtask inside it. Omit or false for one independent session per member. Refused when the deployment serves no Agent Teams service.' },
517
+ tags: {
518
+ type: 'array',
519
+ description: 'Labels: the name renders as a badge and drives the board filter; promptPrefix is injected ahead of the execution prompt on every run.',
520
+ items: {
521
+ type: 'object',
522
+ additionalProperties: false,
523
+ properties: {
524
+ name: { type: 'string', required: true, description: 'Label name (unique within the task, at most 32 characters).' },
525
+ promptPrefix: { type: 'string', description: 'Optional prompt line injected before the task prompt (at most 200 characters).' },
526
+ },
527
+ },
528
+ },
529
+ schedule: {
530
+ type: 'object',
531
+ additionalProperties: false,
532
+ description: 'Arm a 5-field cron schedule at creation time (Host local time zone).',
533
+ properties: {
534
+ enabled: { type: 'boolean', required: true, description: 'Whether the schedule is armed.' },
535
+ cron: { type: 'string', required: true, description: '5-field cron: minute hour day-of-month month day-of-week.' },
536
+ },
537
+ },
538
+ },
539
+ output: { schema: { type: 'json' }, render: renderJson },
540
+ async execute(args, exec) {
541
+ if (args.title.trim() === '') return refused('title-required', 'title must not be blank')
542
+ const id = crypto.randomUUID()
543
+ const tags = (args.tags ?? []).map(tag => ({
544
+ name: tag.name,
545
+ ...(tag.promptPrefix === undefined ? {} : { promptPrefix: tag.promptPrefix }),
546
+ }))
547
+ const input: NewTaskInput = {
548
+ title: args.title,
549
+ description: args.description ?? '',
550
+ prompt: args.prompt ?? '',
551
+ ...(args.parentId === undefined || args.parentId === '' ? {} : { parentId: args.parentId }),
552
+ ...(args.workspaceId === undefined || args.workspaceId === '' ? {} : { workspaceId: args.workspaceId }),
553
+ ...(args.mode === undefined || args.mode === '' ? {} : { mode: args.mode }),
554
+ ...(args.permission === undefined ? {} : { permission: args.permission }),
555
+ ...(args.model === undefined || args.model === '' ? {} : { model: args.model }),
556
+ ...(args.reuseSession === true ? { reuseSession: true } : {}),
557
+ ...(args.teamRun === true ? { teamRun: true } : {}),
558
+ ...(tags.length === 0 ? {} : { tags }),
559
+ ...(args.schedule === undefined ? {} : { schedule: { enabled: args.schedule.enabled, cron: args.schedule.cron } }),
560
+ }
561
+ try {
562
+ const snapshot = host.apply(crypto.randomUUID(), { kind: 'create', id, input }, callingSessionId(exec))
563
+ const task = snapshot.tasks.find(item => item.id === id)
564
+ if (task === undefined) return refused('create-failed', 'the Host did not confirm the new task')
565
+ return json({ ok: true, task: taskDetail(task, snapshot.tasks, snapshot.sessionDefaultPermission) })
566
+ } catch (error) {
567
+ return refused('refused', messageOf(error))
568
+ }
569
+ },
570
+ })
571
+ }
572
+
573
+ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
574
+ return defineTool({
575
+ name: 'task_board_update',
576
+ description: [
577
+ 'Edit a task board card: title, description, prompt, labels, and the execution targets (workspace, agent preset, permission, model, session reuse).',
578
+ 'Only the fields you pass change; pass an empty string to clear a target, or an empty labels array to clear the labels.',
579
+ 'Title, description and prompt freeze once the task has started executing; the execution targets stay editable because they only affect future runs.',
580
+ 'Changing the pinned permission re-arms the permission confirmation gate.',
581
+ 'Triggers: 任务看板, task board, 看板, 修改任务, edit task, 子任务, 标签, tags.',
582
+ ].join(' '),
583
+ parameters: {
584
+ taskId: { type: 'string', required: true, description: 'Task id to edit.' },
585
+ title: { type: 'string', description: 'New title (a blank title is refused by the Host).' },
586
+ description: { type: 'string', description: 'New description.' },
587
+ prompt: { type: 'string', description: 'New execution prompt.' },
588
+ workspaceId: { type: 'string', description: 'New workspace id; an empty string clears it.' },
589
+ mode: { type: 'string', description: 'New agent preset id; an empty string clears it.' },
590
+ permission: { type: 'string', enum: [...TASK_PERMISSIONS, ''], description: 'New permission preset; an empty string clears it back to the session default.' },
591
+ model: { type: 'string', description: 'New pinned model; an empty string clears it.' },
592
+ reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session.' },
593
+ teamRun: { type: 'boolean', description: 'Switches this task between a plain cascade (one session per member) and an Agent Team run (Lead session plus a teammate per subtask).' },
594
+ tags: {
595
+ type: 'array',
596
+ description: 'Replacement label set; an empty array clears all labels.',
597
+ items: {
598
+ type: 'object',
599
+ additionalProperties: false,
600
+ properties: {
601
+ name: { type: 'string', required: true, description: 'Label name.' },
602
+ promptPrefix: { type: 'string', description: 'Optional prompt line injected before the task prompt.' },
603
+ },
604
+ },
605
+ },
606
+ },
607
+ output: { schema: { type: 'json' }, render: renderJson },
608
+ async execute(args, exec) {
609
+ const patch: TaskUpdatePatch = {}
610
+ if (has(args, 'title') && typeof args.title === 'string') patch.title = args.title
611
+ if (has(args, 'description') && typeof args.description === 'string') patch.description = args.description
612
+ if (has(args, 'prompt') && typeof args.prompt === 'string') patch.prompt = args.prompt
613
+ if (has(args, 'workspaceId') && typeof args.workspaceId === 'string') patch.workspaceId = args.workspaceId
614
+ if (has(args, 'mode') && typeof args.mode === 'string') patch.mode = args.mode
615
+ if (has(args, 'model') && typeof args.model === 'string') patch.model = args.model
616
+ // An empty permission is the wire's clearing value (back to the session default).
617
+ if (has(args, 'permission') && typeof args.permission === 'string') {
618
+ patch.permission = args.permission === '' ? undefined : args.permission
619
+ }
620
+ if (has(args, 'reuseSession')) patch.reuseSession = args.reuseSession === true
621
+ if (has(args, 'teamRun')) patch.teamRun = args.teamRun === true
622
+ if (has(args, 'tags')) {
623
+ const tags = args.tags ?? []
624
+ patch.tags = tags.length === 0
625
+ ? null
626
+ : tags.map(tag => ({
627
+ name: tag.name,
628
+ ...(tag.promptPrefix === undefined ? {} : { promptPrefix: tag.promptPrefix }),
629
+ }))
630
+ }
631
+ if (Object.keys(patch).length === 0) return refused('nothing-to-change', 'pass at least one field to update')
632
+ try {
633
+ const snapshot = host.apply(crypto.randomUUID(), { kind: 'update', taskId: args.taskId, patch }, callingSessionId(exec))
634
+ const task = snapshot.tasks.find(item => item.id === args.taskId)
635
+ if (task === undefined) return refused('task-not-found', 'no task with id ' + args.taskId)
636
+ return json({ ok: true, task: taskDetail(task, snapshot.tasks, snapshot.sessionDefaultPermission) })
637
+ } catch (error) {
638
+ return refused('refused', messageOf(error))
639
+ }
640
+ },
641
+ })
642
+ }
643
+