@linxin666/dsh-client-ui-task-board 0.4.4 → 0.4.5

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 (108) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -11
  3. package/README.zh.md +42 -11
  4. package/lib/client.js +4958 -472
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +2841 -121
  7. package/lib/types/client/PluginSettingsCard.d.ts +7 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +19 -3
  9. package/lib/types/client/board/ClampedMarkdown.d.ts +3 -0
  10. package/lib/types/client/board/ConfirmDialog.d.ts +4 -1
  11. package/lib/types/client/board/EditTaskModal.d.ts +5 -2
  12. package/lib/types/client/board/LinkSubtaskModal.d.ts +5 -2
  13. package/lib/types/client/board/NewTaskModal.d.ts +4 -1
  14. package/lib/types/client/board/TagManagerModal.d.ts +26 -0
  15. package/lib/types/client/board/TaskBoard.d.ts +10 -1
  16. package/lib/types/client/board/TaskCard.d.ts +14 -1
  17. package/lib/types/client/board/TaskDetail.d.ts +3 -1
  18. package/lib/types/client/board/TaskForm.d.ts +4 -1
  19. package/lib/types/client/board/VerificationReport.d.ts +23 -0
  20. package/lib/types/client/board/card-view.d.ts +113 -0
  21. package/lib/types/client/board/icons.d.ts +32 -0
  22. package/lib/types/client/board/overlay.d.ts +69 -0
  23. package/lib/types/client/board/status-key.d.ts +15 -0
  24. package/lib/types/client/board/task-markdown.d.ts +19 -0
  25. package/lib/types/client/board-card-seat.d.ts +34 -0
  26. package/lib/types/client/index.d.ts +19 -0
  27. package/lib/types/client/locales.d.ts +103 -1
  28. package/lib/types/client/native-panel.d.ts +6 -20
  29. package/lib/types/client/seats.d.ts +18 -0
  30. package/lib/types/client/service.d.ts +32 -0
  31. package/lib/types/client/settings-form.d.ts +16 -0
  32. package/lib/types/core/controller.d.ts +37 -1
  33. package/lib/types/core/extension.d.ts +283 -0
  34. package/lib/types/core/handover.d.ts +13 -1
  35. package/lib/types/core/issue-form.d.ts +16 -0
  36. package/lib/types/core/tasks.d.ts +84 -3
  37. package/lib/types/core/use-cases/task-tag.d.ts +48 -0
  38. package/lib/types/core/use-cases/task-update.d.ts +5 -3
  39. package/lib/types/core/verification.d.ts +386 -0
  40. package/lib/types/core/workspace-target.d.ts +41 -0
  41. package/lib/types/host/agent-tools.d.ts +1 -1
  42. package/lib/types/host/extension-registry.d.ts +111 -0
  43. package/lib/types/host/llm-dispatch.d.ts +63 -0
  44. package/lib/types/host/verification-gate.d.ts +71 -0
  45. package/lib/types/host/verification-runner.d.ts +108 -0
  46. package/lib/types/host/workspace-evidence.d.ts +53 -0
  47. package/lib/types/host-ledger.d.ts +52 -8
  48. package/lib/types/host-runner.d.ts +23 -1
  49. package/lib/types/host-service.d.ts +88 -2
  50. package/lib/types/index.d.ts +80 -26
  51. package/lib/types/protocol.d.ts +49 -5
  52. package/lib/types/tool-surface.d.ts +71 -0
  53. package/package.json +27 -26
  54. package/src/client/PluginSettingsCard.tsx +9 -0
  55. package/src/client/TaskBoardSettingsCard.tsx +304 -65
  56. package/src/client/board/ClampedMarkdown.tsx +69 -0
  57. package/src/client/board/ConfirmDialog.tsx +7 -3
  58. package/src/client/board/EditTaskModal.tsx +5 -2
  59. package/src/client/board/LinkSubtaskModal.tsx +12 -7
  60. package/src/client/board/NewTaskModal.tsx +17 -1
  61. package/src/client/board/TagManagerModal.tsx +235 -0
  62. package/src/client/board/TaskBoard.tsx +259 -103
  63. package/src/client/board/TaskCard.tsx +214 -81
  64. package/src/client/board/TaskDetail.tsx +71 -26
  65. package/src/client/board/TaskForm.tsx +13 -3
  66. package/src/client/board/VerificationReport.tsx +203 -0
  67. package/src/client/board/card-view.ts +220 -0
  68. package/src/client/board/icons.tsx +89 -0
  69. package/src/client/board/overlay.tsx +189 -0
  70. package/src/client/board/status-key.ts +27 -0
  71. package/src/client/board/task-markdown.module.css +137 -0
  72. package/src/client/board/task-markdown.tsx +106 -0
  73. package/src/client/board-card-seat.ts +102 -0
  74. package/src/client/board-settings.module.css +66 -0
  75. package/src/client/board.module.css +488 -63
  76. package/src/client/index.ts +43 -5
  77. package/src/client/locales.ts +208 -4
  78. package/src/client/native-panel.tsx +18 -2
  79. package/src/client/seats.tsx +58 -0
  80. package/src/client/service.ts +94 -0
  81. package/src/client/settings-card.module.css +31 -5
  82. package/src/client/settings-form.ts +27 -1
  83. package/src/core/controller.ts +94 -3
  84. package/src/core/extension.ts +376 -0
  85. package/src/core/freeze-snapshot.ts +2 -2
  86. package/src/core/handover.ts +16 -1
  87. package/src/core/issue-form.ts +19 -0
  88. package/src/core/store.ts +16 -0
  89. package/src/core/tasks.ts +100 -6
  90. package/src/core/use-cases/task-parent.ts +3 -2
  91. package/src/core/use-cases/task-tag.ts +110 -0
  92. package/src/core/use-cases/task-update.ts +10 -4
  93. package/src/core/verification.ts +826 -0
  94. package/src/core/workspace-target.ts +59 -0
  95. package/src/host/agent-tools.ts +20 -18
  96. package/src/host/extension-registry.ts +509 -0
  97. package/src/host/llm-dispatch.ts +87 -0
  98. package/src/host/verification-gate.ts +331 -0
  99. package/src/host/verification-runner.ts +535 -0
  100. package/src/host/workspace-evidence.ts +176 -0
  101. package/src/host-ai.ts +15 -9
  102. package/src/host-ledger.ts +143 -32
  103. package/src/host-routes.ts +15 -2
  104. package/src/host-runner.ts +60 -11
  105. package/src/host-service.ts +253 -10
  106. package/src/index.ts +204 -16
  107. package/src/protocol.ts +102 -8
  108. package/src/tool-surface.ts +95 -0
@@ -3,9 +3,13 @@ import type { SessionAddress, SessionHistoryRecord, SessionListValue, SessionPag
3
3
  import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
4
4
  import type { Workspace } from '@deepseek-ai/dsh-workspace/types'
5
5
  import { teammateName } from './core/subtask.ts'
6
+ import { mostRecentWorkspaceId } from './core/workspace-target.ts'
6
7
  import type { TaskPermission, TaskRecord } from './core/tasks.ts'
7
8
 
8
- /** Host services needed to validate a task's workspace before creating a session. */
9
+ /**
10
+ * Host services needed to validate a task's workspace before creating a
11
+ * session, and to resolve the workspace an unpinned card runs in.
12
+ */
9
13
  export interface TaskBoardWorkspaceRegistry {
10
14
  list(): readonly Workspace[]
11
15
  }
@@ -298,6 +302,8 @@ function nonCompletedTurnEnd(data: unknown): string | undefined {
298
302
  */
299
303
  function invokeWireArgs(namespace: string, method: string, request: Record<string, unknown>): Record<string, unknown> {
300
304
  if (namespace === 'agentPresets' && method === 'list') return {}
305
+ // session/modelCatalog declares zero parameters, so its args must be {}.
306
+ if (namespace === 'session' && method === 'modelCatalog') return {}
301
307
  if (namespace === 'session' && method === 'list') return { _request: request }
302
308
  return { request }
303
309
  }
@@ -328,6 +334,18 @@ export class HostExecutionRunner {
328
334
  return this.gateway.stream({ namespace, method, args: { request }, ...(signal === undefined ? {} : { signal }) })
329
335
  }
330
336
 
337
+ /**
338
+ * The workspace an unpinned run lands in: the deployment's most recently used
339
+ * one. Undefined when the deployment serves no workspace registry or has
340
+ * registered none yet; the launch then keeps the "let the Host decide" shape,
341
+ * which is the only remaining path to the Host's own working directory.
342
+ * @returns the workspace id to create the session in, when one is known.
343
+ */
344
+ private recentWorkspaceId(): string | undefined {
345
+ if (this.workspaceRegistry === undefined) return undefined
346
+ return mostRecentWorkspaceId(this.workspaceRegistry.list())
347
+ }
348
+
331
349
  /**
332
350
  * Launch one execution. Without `options.reuseSessionId` a fresh session is
333
351
  * created, renamed, pinned, and prompted (the historical contract). With it,
@@ -338,10 +356,14 @@ export class HostExecutionRunner {
338
356
  * @param options - optional session to continue in.
339
357
  * @returns the session id the execution runs in.
340
358
  */
341
- async launch(task: TaskRecord, options: { reuseSessionId?: string; promptContext?: PromptContext } = {}): Promise<string> {
359
+ async launch(task: TaskRecord, options: { reuseSessionId?: string; promptContext?: PromptContext; onSession?: (sessionId: string) => void; onGoalArmed?: (armed: boolean) => void } = {}): Promise<string> {
342
360
  // A handover bundle overrides the legacy pin fields: the bundle is the
343
- // authoritative execution triplet for a continuation card (issue #5).
344
- const workspaceId = task.handover?.workspaceId ?? task.workspaceId
361
+ // authoritative execution triplet for a continuation card (issue #5). A
362
+ // card that pins nothing is resolved to the deployment's most recently used
363
+ // workspace instead of being left to `session.create`, whose own fallback is
364
+ // the Host process working directory (the desktop app's profile directory) —
365
+ // never the project the user was in when the card was written.
366
+ const workspaceId = task.handover?.workspaceId ?? task.workspaceId ?? this.recentWorkspaceId()
345
367
  const mode = task.handover?.mode ?? task.mode
346
368
  const permission = task.handover?.permission ?? task.permission
347
369
 
@@ -369,8 +391,12 @@ export class HostExecutionRunner {
369
391
  // whose session was composed from a different preset now fails closed
370
392
  // instead of silently running under the wrong composition, matching
371
393
  // the fresh branch's `agentPreset` assertion (issue #1708).
394
+ // Report the session before anything is queued into it, so the board
395
+ // can bind the execution first and a completion claim can never arrive
396
+ // at the gate without its binding.
397
+ options.onSession?.(reused)
372
398
  await this.assertReusedPreset(reused, mode)
373
- await this.pinAndPrompt(reused, task, permission, options.promptContext)
399
+ await this.pinAndPrompt(reused, task, permission, options.promptContext, options.onGoalArmed)
374
400
  } catch (error) {
375
401
  throw new SessionLaunchError(reused, error)
376
402
  }
@@ -381,9 +407,10 @@ export class HostExecutionRunner {
381
407
  ...(mode === undefined ? {} : { agentPreset: mode }),
382
408
  }) as { sessionId: ExecutionSessionId }
383
409
  const sessionId = created.sessionId
410
+ options.onSession?.(sessionId)
384
411
  try {
385
412
  await this.invoke('session', 'rename', { sessionId, title: task.title })
386
- await this.pinAndPrompt(sessionId, task, permission, options.promptContext)
413
+ await this.pinAndPrompt(sessionId, task, permission, options.promptContext, options.onGoalArmed)
387
414
  } catch (error) {
388
415
  throw new SessionLaunchError(sessionId, error)
389
416
  }
@@ -428,6 +455,7 @@ export class HostExecutionRunner {
428
455
  task: TaskRecord,
429
456
  permission: TaskPermission | undefined,
430
457
  context: PromptContext = {},
458
+ onGoalArmed?: (armed: boolean) => void,
431
459
  ): Promise<void> {
432
460
  if (permission !== undefined) {
433
461
  if (this.commands === undefined) throw new Error('permission command dispatcher is unavailable')
@@ -456,7 +484,25 @@ export class HostExecutionRunner {
456
484
  mode: 'queue' as const,
457
485
  content: [{ type: 'text' as const, text: promptText(task, context) }],
458
486
  })
459
- await this.armGoal(sessionId, task, context)
487
+ const armed = await this.armGoal(sessionId, task, context)
488
+ onGoalArmed?.(armed)
489
+ }
490
+
491
+ /**
492
+ * Read the host's model catalog: the default route an unconfigured session
493
+ * starts at, and each provider's models with the reasoning metadata its
494
+ * adapter exposes. The acceptance settings card and the frozen contract both
495
+ * resolve against it, so "inherit host" means the host's own configuration
496
+ * rather than a session's or a card's pinned model.
497
+ * @returns the raw catalog value, or undefined when this cohort serves none.
498
+ */
499
+ async modelCatalog(): Promise<unknown> {
500
+ try {
501
+ return await this.invoke('session', 'modelCatalog', {})
502
+ } catch (error) {
503
+ console.warn('[dsh-task-board] session/modelCatalog failed; the acceptance settings fall back to the configured route', error)
504
+ return undefined
505
+ }
460
506
  }
461
507
 
462
508
  /**
@@ -472,11 +518,11 @@ export class HostExecutionRunner {
472
518
  * plain turn: the task was asked to run, and a missing goal mode must not
473
519
  * lose the work.
474
520
  */
475
- private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<void> {
476
- if (task.goalRun === false) return
521
+ private async armGoal(sessionId: ExecutionSessionId, task: TaskRecord, context: PromptContext): Promise<boolean> {
522
+ if (task.goalRun === false) return false
477
523
  if (this.commands === undefined) {
478
524
  console.warn('[dsh-task-board] no command dispatcher is available; task ' + task.id + ' runs without /goal')
479
- return
525
+ return false
480
526
  }
481
527
  try {
482
528
  const command = await this.commands.execute(
@@ -486,13 +532,16 @@ export class HostExecutionRunner {
486
532
  )
487
533
  if (command === undefined) {
488
534
  console.warn('[dsh-task-board] /goal was not acknowledged for session ' + sessionId + '; the run continues as a plain turn')
489
- return
535
+ return false
490
536
  }
491
537
  if (command.kind !== 'success') {
492
538
  console.warn('[dsh-task-board] /goal was refused for session ' + sessionId + ': ' + (command.text ?? 'no reason reported') + '; the run continues as a plain turn')
539
+ return false
493
540
  }
541
+ return true
494
542
  } catch (error) {
495
543
  console.warn('[dsh-task-board] could not arm /goal on session ' + sessionId + '; the run continues as a plain turn', error)
544
+ return false
496
545
  }
497
546
  }
498
547
 
@@ -4,8 +4,13 @@ import { reusableSessionId } from './core/session-reuse.ts'
4
4
  import { HostTaskLedger, type OpenedRun, type OpenExecutionReference } from './host-ledger.ts'
5
5
  import { HostExecutionRunner, SessionLaunchError, promptText, type SessionCommandDispatcher, type SessionSummary, type TaskBoardWorkspaceRegistry } from './host-runner.ts'
6
6
  import { teammateName } from './core/subtask.ts'
7
+ import { workspaceOwningSession } from './core/workspace-target.ts'
7
8
  import { PowerInhibitor } from './power-inhibitor.ts'
8
9
  import { TASK_BOARD_SCHEMA_VERSION, type TaskBoardAction, type TaskBoardEventPayload, type TaskBoardSnapshot } from './protocol.ts'
10
+ import { TaskBoardExtensionRegistry } from './host/extension-registry.ts'
11
+ import type { TaskBoardExtension } from './core/extension.ts'
12
+ import type { ExecutionOutcome, TaskStatus } from './core/tasks.ts'
13
+ import { passedAttempt, resolveContract, verificationRequired, type ExecutionVerification, type ModelCatalogView, type VerificationContract, type VerificationSettings } from './core/verification.ts'
9
14
  import type { TaskPermission } from './core/handover.ts'
10
15
 
11
16
  /** One teammate the Host asks the Agent Teams service to spawn for a team run. */
@@ -129,10 +134,20 @@ export class TaskBoardHostService {
129
134
  */
130
135
  private readonly unreadablePolls = new Map<string, number>()
131
136
  private preventIdleSleep = false
137
+ private readonly verificationSettings: () => VerificationSettings
138
+ private readonly verificationCatalog: () => Promise<ModelCatalogView | undefined>
132
139
  private readonly team: TaskBoardTeamDispatcher | undefined
133
140
  private readonly timers: HostTimerFace
134
141
  private lastPowerJson = ''
135
142
  private readonly now: () => number
143
+ /** The deployment's workspaces, consulted to resolve a card that pins none. */
144
+ private readonly workspaceRegistry: TaskBoardWorkspaceRegistry | undefined
145
+ /**
146
+ * External provider extensions. The board owns task lifecycle and execution;
147
+ * a provider observes them through the registry's capability face and never
148
+ * reaches into the ledger itself.
149
+ */
150
+ readonly extensions: TaskBoardExtensionRegistry
136
151
 
137
152
  constructor(gateway: TypertGateway, options: {
138
153
  ledger?: HostTaskLedger
@@ -140,20 +155,42 @@ export class TaskBoardHostService {
140
155
  now?: () => number
141
156
  commandDispatcher?: SessionCommandDispatcher
142
157
  workspaceRegistry?: TaskBoardWorkspaceRegistry
143
- sessionDefaultPermission?: TaskPermission
158
+ /**
159
+ * Permission baseline of the confirmation gate: a fixed value, or a live
160
+ * resolver the board re-reads so a Host Settings change needs no remount.
161
+ */
162
+ sessionDefaultPermission?: TaskPermission | (() => TaskPermission)
144
163
  maxSubtaskDepth?: number
145
164
  team?: TaskBoardTeamDispatcher
146
165
  timers?: HostTimerFace
166
+ /**
167
+ * Live acceptance settings (volatile config reads). Absent keeps goal
168
+ * acceptance OFF for every execution this service opens, which is what a
169
+ * programmatic mount without the settings domain gets.
170
+ */
171
+ verificationSettings?: () => VerificationSettings
172
+ /** The host model catalog the acceptance contract resolves its route from. */
173
+ verificationCatalog?: () => Promise<ModelCatalogView | undefined>
147
174
  } = {}) {
148
175
  this.ledger = options.ledger ?? new HostTaskLedger(undefined, undefined, {
149
176
  sessionDefaultPermission: options.sessionDefaultPermission,
150
177
  maxSubtaskDepth: options.maxSubtaskDepth,
151
178
  })
152
179
  this.runner = new HostExecutionRunner(gateway, options.commandDispatcher, options.workspaceRegistry)
180
+ this.workspaceRegistry = options.workspaceRegistry
153
181
  this.team = options.team
154
182
  this.timers = options.timers ?? PROCESS_TIMERS
155
183
  this.power = options.power ?? new PowerInhibitor()
156
184
  this.now = options.now ?? Date.now
185
+ this.verificationSettings = options.verificationSettings ?? (() => ({ enabled: false, model: '', reasoningEffort: '' }))
186
+ this.verificationCatalog = options.verificationCatalog ?? (async () => undefined)
187
+ this.extensions = new TaskBoardExtensionRegistry({
188
+ ledger: this.ledger,
189
+ // Capability writes go through the board's own action path, so every
190
+ // board gate (content freeze, running lock, permission gate) still holds.
191
+ apply: (action, initiator) => this.applyBoardAction(crypto.randomUUID(), action, initiator),
192
+ now: this.now,
193
+ })
157
194
  installStreamErrorGuards()
158
195
  this.ledger.subscribe(() => {
159
196
  this.syncPowerReasons()
@@ -195,6 +232,9 @@ export class TaskBoardHostService {
195
232
  })
196
233
  }
197
234
  this.power.setEnabled(active && preventIdleSleep)
235
+ // Providers follow the board's master switch: the registry stops their
236
+ // provider-side work and hides their seats without clearing any data.
237
+ this.extensions.setEnabled(active)
198
238
  if (resumed) {
199
239
  this.schedulePoll()
200
240
  this.recoverSchedule()
@@ -208,6 +248,7 @@ export class TaskBoardHostService {
208
248
 
209
249
  snapshot(): TaskBoardSnapshot {
210
250
  const state = this.ledger.state()
251
+ const published = this.extensions.published()
211
252
  return {
212
253
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
213
254
  revision: state.revision,
@@ -217,6 +258,38 @@ export class TaskBoardHostService {
217
258
  sessionDefaultPermission: this.ledger.sessionDefaultPermission,
218
259
  maxSubtaskDepth: this.ledger.maxSubtaskDepth,
219
260
  teamRunAvailable: this.team !== undefined,
261
+ ...(Object.keys(published).length === 0 ? {} : { extensions: published }),
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Admit one external provider. Idempotent by extension id; the returned
267
+ * disposer releases it (its stored data stays).
268
+ */
269
+ registerExtension(extension: TaskBoardExtension): () => void {
270
+ return this.extensions.registerExtension(extension)
271
+ }
272
+
273
+ /**
274
+ * The acceptance configuration the settings card displays: the live settings,
275
+ * the contract they resolve to, and the host catalog the choices come from.
276
+ *
277
+ * The card shows the RESOLVED route, not the raw configuration, so "inherit
278
+ * host" reads as the concrete model and reasoning level the next execution
279
+ * will actually freeze, including any recorded effort fallback.
280
+ * @returns the resolved acceptance options.
281
+ */
282
+ async verificationOptions(): Promise<{
283
+ settings: VerificationSettings
284
+ contract: VerificationContract
285
+ catalog: ModelCatalogView
286
+ }> {
287
+ const settings = this.verificationSettings()
288
+ const catalog = await this.verificationCatalog()
289
+ return {
290
+ settings,
291
+ contract: resolveContract(settings, catalog),
292
+ catalog: catalog ?? { groups: [] },
220
293
  }
221
294
  }
222
295
 
@@ -231,8 +304,55 @@ export class TaskBoardHostService {
231
304
  return () => { this.listeners.delete(listener) }
232
305
  }
233
306
 
234
- apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot {
307
+ /**
308
+ * Give a root card created without a workspace pin the workspace its creator
309
+ * is in.
310
+ *
311
+ * An agent's `task_board_create` arrives with the calling session, and the
312
+ * GUI submits the session the main view shows, so the card lands where the
313
+ * work was asked for instead of where the Host process happens to run. A card
314
+ * that names a workspace keeps it, and a subtask keeps inheriting its lineage
315
+ * (the runner walks the ancestor chain for that).
316
+ *
317
+ * The initiator is client-asserted, so it only chooses among workspaces the
318
+ * deployment already knows: it can neither register one nor reach outside the
319
+ * list the user sees.
320
+ * @param action - the action about to be applied.
321
+ * @param initiator - the session that issued the action, when one was asserted.
322
+ * @returns the action, with an inherited workspace on a root creation.
323
+ */
324
+ private withInheritedWorkspace(action: TaskBoardAction, initiator?: string): TaskBoardAction {
325
+ if (action.kind !== 'create' || initiator === undefined || initiator === '') return action
326
+ const input = action.input
327
+ // A subtask inherits its lineage instead, and an explicit pin is the user's.
328
+ if ((input.parentId ?? '').trim() !== '') return action
329
+ if ((input.workspaceId ?? '').trim() !== '') return action
330
+ const workspaceId = workspaceOwningSession(this.workspaceRegistry?.list() ?? [], initiator)
331
+ if (workspaceId === undefined) return action
332
+ return { ...action, input: { ...input, workspaceId } }
333
+ }
334
+
335
+ apply(requestId: string, action: Extract<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): Promise<TaskBoardSnapshot>
336
+ apply(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot
337
+ apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot>
338
+ apply(requestId: string, action: TaskBoardAction, initiator?: string): TaskBoardSnapshot | Promise<TaskBoardSnapshot> {
235
339
  if (!this.active) throw new Error('task board is disabled')
340
+ // Provider actions never touch the ledger directly: the registry routes the
341
+ // action to the owning extension, whose own capability calls come back
342
+ // through applyBoardAction and every board gate below.
343
+ if (action.kind === 'extension-action') {
344
+ return this.handleExtensionAction(action)
345
+ }
346
+ return this.applyBoardAction(requestId, action, initiator)
347
+ }
348
+
349
+ /**
350
+ * Apply one board-owned action with every gate intact. Provider capability
351
+ * calls and the same-origin wire both land here, so the two surfaces can
352
+ * never disagree about a card.
353
+ */
354
+ private applyBoardAction(requestId: string, action: Exclude<TaskBoardAction, { kind: 'extension-action' }>, initiator?: string): TaskBoardSnapshot {
355
+ const before = this.statusMap()
236
356
  // Fail closed before the ledger opens anything: a card opted into team
237
357
  // execution cannot run in a deployment that serves no Agent Teams service,
238
358
  // and silently degrading it to a plain cascade would misreport the work.
@@ -240,12 +360,14 @@ export class TaskBoardHostService {
240
360
  const task = this.ledger.state().tasks.find(item => item.id === action.taskId)
241
361
  if (task?.teamRun === true) throw new Error('Agent Teams is unavailable in this deployment')
242
362
  }
243
- const result = this.ledger.applyRequest(requestId, action, initiator)
363
+ const result = this.ledger.applyRequest(requestId, this.withInheritedWorkspace(action, initiator), initiator)
244
364
  if (result.runs !== undefined) this.dispatchRuns(result.runs)
245
365
  // A committed schedule write (create / update / toggle / delete) moves the
246
366
  // nearest trigger; re-arm on every action so a newly enabled schedule fires
247
367
  // at its own instant without waiting for the previous target to elapse.
248
368
  if (SCHEDULE_WRITE_ACTIONS.has(action.kind)) this.refreshSchedule()
369
+ this.emitStatusChanges(before, initiator)
370
+ if (action.kind === 'delete') this.extensions.emitTaskDeleted({ taskId: action.taskId })
249
371
  return {
250
372
  schemaVersion: TASK_BOARD_SCHEMA_VERSION,
251
373
  revision: result.state.revision,
@@ -255,8 +377,39 @@ export class TaskBoardHostService {
255
377
  }
256
378
  }
257
379
 
380
+ /** Route one provider action and answer the resulting board snapshot. */
381
+ private async handleExtensionAction(action: Extract<TaskBoardAction, { kind: 'extension-action' }>): Promise<TaskBoardSnapshot> {
382
+ await this.extensions.handleAction({
383
+ extensionId: action.extensionId,
384
+ action: action.action,
385
+ ...(action.taskId === undefined ? {} : { taskId: action.taskId }),
386
+ ...(action.payload === undefined ? {} : { payload: action.payload }),
387
+ })
388
+ return this.snapshot()
389
+ }
390
+
391
+ /** Current task id -> status map, for change detection around a mutation. */
392
+ private statusMap(): Map<string, TaskStatus> {
393
+ return new Map(this.ledger.allTasks().map(task => [task.id, task.status]))
394
+ }
395
+
396
+ /** Emit a status change for every task whose column moved, isolated per callback. */
397
+ private emitStatusChanges(before: Map<string, TaskStatus>, initiator?: string): void {
398
+ for (const task of this.ledger.allTasks()) {
399
+ const previous = before.get(task.id)
400
+ if (previous === undefined || previous === task.status) continue
401
+ this.extensions.emitStatusChanged({
402
+ taskId: task.id,
403
+ status: task.status,
404
+ previous,
405
+ ...(initiator === undefined || initiator === '' ? {} : { initiator }),
406
+ })
407
+ }
408
+ }
409
+
258
410
  dispose(): void {
259
411
  this.disposed = true
412
+ this.extensions.dispose()
260
413
  this.clearScheduleTimer()
261
414
  this.pollTimer?.()
262
415
  this.pollTimer = undefined
@@ -286,17 +439,48 @@ export class TaskBoardHostService {
286
439
  ...(team ? { team: true } : {}),
287
440
  ...(schedule === undefined ? {} : { schedule }),
288
441
  }
442
+ // Freeze this execution's acceptance contract BEFORE the session is
443
+ // prompted: a settings change made while the run is in flight must not
444
+ // retrofit the rule that will judge it. A card that opted out resolves the
445
+ // same contract from an OFF switch: the board-wide switch decides the
446
+ // default, the card decides this execution, and both are read here.
447
+ const settings = this.verificationSettings()
448
+ // The card's opt-out is a DISTINCT reason, not the board switch: the
449
+ // report must be able to say which of the two turned the gate off.
450
+ const skippedBy: ExecutionVerification['applicability'] = opened.task.skipVerification === true ? 'skipped' : 'disabled'
451
+ const contract = resolveContract(
452
+ { ...settings, enabled: settings.enabled && opened.task.skipVerification !== true },
453
+ await this.verificationCatalog(),
454
+ )
455
+ const initial: ExecutionVerification = {
456
+ contract,
457
+ attempts: [],
458
+ applicability: team ? 'team-member' : !contract.enabled ? skippedBy : 'goal-unavailable',
459
+ }
460
+ this.ledger.setVerification(opened.task.id, opened.execution.id, initial)
461
+ let attached: string | undefined
289
462
  const sessionId = await this.runner.launch(opened.task, {
290
463
  ...(reuseSessionId === undefined ? {} : { reuseSessionId }),
291
464
  ...(promptContext === undefined ? {} : { promptContext }),
465
+ // Bind the session to the execution before the prompt is queued, so the
466
+ // completion gate can never observe the agent without its binding.
467
+ onSession: (id) => {
468
+ attached = id
469
+ this.ledger.attachSession(opened.task.id, opened.execution.id, id)
470
+ },
471
+ onGoalArmed: (armed) => {
472
+ this.setApplicability(opened.task.id, opened.execution.id, team
473
+ ? 'team-member'
474
+ : !contract.enabled ? skippedBy : armed ? 'enforced' : 'goal-unavailable')
475
+ },
292
476
  })
293
- this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
477
+ if (attached === undefined) this.ledger.attachSession(opened.task.id, opened.execution.id, sessionId)
294
478
  if (team) for (const teammate of others) this.scheduleTeammate(teammate, sessionId)
295
479
  } catch (error) {
296
480
  if (error instanceof SessionLaunchError) {
297
481
  this.ledger.attachSession(opened.task.id, opened.execution.id, error.sessionId)
298
482
  }
299
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
483
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
300
484
  }
301
485
  }
302
486
 
@@ -316,7 +500,7 @@ export class TaskBoardHostService {
316
500
  private async spawnTeammate(opened: OpenedRun, leadSessionId: string): Promise<void> {
317
501
  const team = this.team
318
502
  if (team === undefined) {
319
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
503
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', 'Agent Teams is unavailable in this deployment')
320
504
  return
321
505
  }
322
506
  try {
@@ -327,12 +511,12 @@ export class TaskBoardHostService {
327
511
  prompt: promptText(opened.task),
328
512
  })
329
513
  if (member.sessionId === undefined || member.sessionId === '') {
330
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
514
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', member.error ?? 'teammate provisioning failed')
331
515
  return
332
516
  }
333
517
  this.ledger.attachSession(opened.task.id, opened.execution.id, member.sessionId)
334
518
  } catch (error) {
335
- this.ledger.settle(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
519
+ this.settleAndNotify(opened.task.id, opened.execution.id, 'failed', error instanceof Error ? error.message : String(error))
336
520
  }
337
521
  }
338
522
 
@@ -354,7 +538,9 @@ export class TaskBoardHostService {
354
538
  // Fold whatever the board can already decide before spending inspection
355
539
  // RPCs: a team run whose Lead recorded its verdict, and any lineage whose
356
540
  // members are all settled. Idempotent, so an already folded board is free.
541
+ const foldedBefore = this.statusMap()
357
542
  this.ledger.finalizeReadyRuns()
543
+ this.emitStatusChanges(foldedBefore)
358
544
  // Read after the RPC so executions attached while it was in flight are
359
545
  // included in this pass, matching the former full-state snapshot timing.
360
546
  const runtime = this.ledger.runtimeView()
@@ -375,6 +561,16 @@ export class TaskBoardHostService {
375
561
  ): Promise<void> {
376
562
  for (const execution of executions) {
377
563
  if (execution.sessionId === undefined) continue
564
+ const record = this.ledger.getTask(execution.taskId)?.executions.find(entry => entry.id === execution.executionId)
565
+ const verification = record?.verification
566
+ // A cycle the gate already closed is decided here, without another
567
+ // inspection: the goal may still be active (or un-blockable) exactly
568
+ // because the acceptance failed, and the card must not stay in the
569
+ // running column waiting for a verdict that is already recorded.
570
+ if (verification?.failedReason !== undefined) {
571
+ this.settleAndNotify(execution.taskId, execution.executionId, 'failed', verification.failedReason)
572
+ continue
573
+ }
378
574
  try {
379
575
  // A team member's turn is read even while the roster calls its session
380
576
  // running: a durable teammate never goes idle for good.
@@ -387,7 +583,20 @@ export class TaskBoardHostService {
387
583
  continue
388
584
  }
389
585
  this.unreadablePolls.delete(execution.executionId)
390
- this.ledger.settle(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
586
+ // Forced acceptance: the old fallback paths — a completed turn, a
587
+ // paused goal, an unreadable projection, a manual settle — must never
588
+ // be mistaken for a verified success. Only a matching pass record
589
+ // settles this execution as succeeded.
590
+ if (result.outcome === 'succeeded' && verificationRequired(verification) && passedAttempt(verification) === undefined) {
591
+ this.settleAndNotify(
592
+ execution.taskId,
593
+ execution.executionId,
594
+ 'failed',
595
+ 'goal 验收:本次执行没有匹配的验收通过记录(验收未运行、未通过或报告来自其他执行),按未验收判失败。',
596
+ )
597
+ continue
598
+ }
599
+ this.settleAndNotify(execution.taskId, execution.executionId, result.outcome, 'error' in result ? result.error : undefined)
391
600
  } catch {
392
601
  // A transient inspection failure never settles a running execution.
393
602
  }
@@ -407,6 +616,28 @@ export class TaskBoardHostService {
407
616
  * ancestor of it — in the running column with no way out. The first poll of
408
617
  * each streak is logged, so the Host log names the session.
409
618
  */
619
+ private settleAndNotify(taskId: string, executionId: string, outcome: ExecutionOutcome, error?: string): void {
620
+ const before = this.ledger.getTask(taskId)
621
+ if (before?.executions.find(entry => entry.id === executionId)?.endedAt !== undefined) {
622
+ // Already settled: the ledger would no-op, and re-emitting would report
623
+ // the same settlement twice.
624
+ this.ledger.settle(taskId, executionId, outcome, error)
625
+ return
626
+ }
627
+ const previousStatus = before?.status
628
+ this.ledger.settle(taskId, executionId, outcome, error)
629
+ const task = this.ledger.getTask(taskId)
630
+ if (task !== undefined && previousStatus !== undefined && previousStatus !== task.status) {
631
+ this.extensions.emitStatusChanged({ taskId, status: task.status, previous: previousStatus })
632
+ }
633
+ this.extensions.emitExecutionSettled({
634
+ taskId,
635
+ executionId,
636
+ outcome,
637
+ ...(error === undefined ? {} : { error }),
638
+ })
639
+ }
640
+
410
641
  private noteUnreadableInspection(execution: OpenExecutionReference, reason: string | undefined): void {
411
642
  const polls = (this.unreadablePolls.get(execution.executionId) ?? 0) + 1
412
643
  this.unreadablePolls.set(execution.executionId, polls)
@@ -417,7 +648,7 @@ export class TaskBoardHostService {
417
648
  }
418
649
  if (polls < UNREADABLE_SETTLE_POLLS) return
419
650
  this.unreadablePolls.delete(execution.executionId)
420
- this.ledger.settle(
651
+ this.settleAndNotify(
421
652
  execution.taskId,
422
653
  execution.executionId,
423
654
  'failed',
@@ -484,6 +715,7 @@ export class TaskBoardHostService {
484
715
  this.recoverSchedule()
485
716
  return
486
717
  }
718
+ const before = this.statusMap()
487
719
  for (const schedule of this.ledger.dueSchedules(now)) {
488
720
  const next = nextRunAtMs(schedule.cron, schedule.nextRunAt, schedule.timeZone)
489
721
  this.dispatchRuns(
@@ -491,6 +723,7 @@ export class TaskBoardHostService {
491
723
  { triggeredAt: now, timeZone: schedule.timeZone, cron: schedule.cron },
492
724
  )
493
725
  }
726
+ this.emitStatusChanges(before)
494
727
  // The launched run (or the rolled-forward target) moved every due schedule,
495
728
  // so the next nearest target has to be recomputed from the ledger.
496
729
  this.armSchedule()
@@ -517,6 +750,16 @@ export class TaskBoardHostService {
517
750
  }
518
751
  }
519
752
 
753
+ /**
754
+ * Move one execution's applicability without touching its frozen contract or
755
+ * its recorded attempts (used when the run's goal form becomes known).
756
+ */
757
+ private setApplicability(taskId: string, executionId: string, applicability: ExecutionVerification['applicability']): void {
758
+ const verification = this.ledger.getTask(taskId)?.executions.find(entry => entry.id === executionId)?.verification
759
+ if (verification === undefined || verification.applicability === applicability) return
760
+ this.ledger.setVerification(taskId, executionId, { ...verification, applicability })
761
+ }
762
+
520
763
  private scheduleLaunch(opened: OpenedRun, others: readonly OpenedRun[] = [], schedule?: ScheduledRunContext): void {
521
764
  void this.launch(opened, others, schedule).catch(error => {
522
765
  safeConsoleError('[dsh-task-board] execution launch settlement failed', error)