@linxin666/dsh-client-ui-task-board 0.4.6 → 0.4.7

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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +14 -6
  3. package/README.zh.md +14 -6
  4. package/lib/client.js +1502 -820
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +674 -75
  7. package/lib/types/client/CardDefaultPresetField.d.ts +35 -0
  8. package/lib/types/client/TaskBoardSettingsCard.d.ts +49 -0
  9. package/lib/types/client/board/ReasoningEffortField.d.ts +25 -0
  10. package/lib/types/client/board/model-options.d.ts +67 -0
  11. package/lib/types/client/board/preset-label.d.ts +21 -0
  12. package/lib/types/client/board/preset-roster.d.ts +45 -0
  13. package/lib/types/client/board/team-run-promotion.d.ts +26 -0
  14. package/lib/types/client/locales.d.ts +16 -0
  15. package/lib/types/core/card-defaults.d.ts +78 -0
  16. package/lib/types/core/controller.d.ts +32 -7
  17. package/lib/types/core/launch-width.d.ts +31 -0
  18. package/lib/types/core/reasoning-effort.d.ts +57 -0
  19. package/lib/types/core/subtask.d.ts +30 -8
  20. package/lib/types/core/tasks.d.ts +19 -0
  21. package/lib/types/core/use-cases/task-create.d.ts +10 -5
  22. package/lib/types/core/use-cases/task-update.d.ts +1 -1
  23. package/lib/types/host/launch-admission.d.ts +72 -0
  24. package/lib/types/host-ledger.d.ts +9 -4
  25. package/lib/types/host-runner.d.ts +79 -4
  26. package/lib/types/host-service.d.ts +85 -2
  27. package/lib/types/index.d.ts +87 -0
  28. package/lib/types/protocol.d.ts +13 -0
  29. package/package.json +28 -28
  30. package/src/client/CardDefaultPresetField.tsx +101 -0
  31. package/src/client/TaskBoardSettingsCard.tsx +214 -16
  32. package/src/client/board/LinkSubtaskModal.tsx +8 -0
  33. package/src/client/board/NewTaskModal.tsx +86 -40
  34. package/src/client/board/ReasoningEffortField.tsx +50 -0
  35. package/src/client/board/TaskDetail.tsx +42 -6
  36. package/src/client/board/model-options.ts +178 -0
  37. package/src/client/board/preset-label.ts +32 -0
  38. package/src/client/board/preset-roster.ts +58 -0
  39. package/src/client/board/team-run-promotion.ts +39 -0
  40. package/src/client/index.ts +98 -94
  41. package/src/client/locales.ts +34 -2
  42. package/src/client/settings-entry-form.ts +0 -1
  43. package/src/core/card-defaults.ts +132 -0
  44. package/src/core/controller.ts +44 -9
  45. package/src/core/launch-width.ts +38 -0
  46. package/src/core/reasoning-effort.ts +92 -0
  47. package/src/core/store.ts +5 -0
  48. package/src/core/subtask.ts +48 -13
  49. package/src/core/tasks.ts +20 -0
  50. package/src/core/use-cases/task-create.ts +27 -16
  51. package/src/core/use-cases/task-update.ts +6 -1
  52. package/src/host/agent-tools.ts +21 -8
  53. package/src/host/launch-admission.ts +140 -0
  54. package/src/host-ledger.ts +18 -6
  55. package/src/host-runner.ts +184 -18
  56. package/src/host-service.ts +247 -23
  57. package/src/index.ts +149 -2
  58. package/src/protocol.ts +28 -4
@@ -127,6 +127,17 @@ export interface ExecutionModelOption {
127
127
  name?: string
128
128
  label?: string
129
129
  provider?: string
130
+ /**
131
+ * Reasoning metadata the adapter declares for this exact route. An effort
132
+ * picker is offered only while `supported`, and its choices are `efforts` —
133
+ * the ids the target model really accepts, so the UI never offers (or keeps)
134
+ * a level the model would reject. Absent means the deployment published no
135
+ * capability for this model; the card value is then left untouched.
136
+ */
137
+ reasoning?: {
138
+ supported: boolean
139
+ efforts?: readonly string[]
140
+ }
130
141
  }
131
142
 
132
143
  /** The execution-target option sets the UI feeds into the controller. */
@@ -141,7 +152,7 @@ export interface ExecutionOptionsSnapshot {
141
152
  * (revision/scheduler/power); a full snapshot also carries the deployment
142
153
  * constants the UI reads (session default permission, subtask depth).
143
154
  */
144
- export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'goalRunEnabled' | 'extensions'>
155
+ export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'goalRunEnabled' | 'cardDefaults' | 'extensions'>
145
156
 
146
157
  /** Immutable controller snapshot for UI subscriptions. */
147
158
  export interface ControllerSnapshot {
@@ -318,6 +329,22 @@ export class BoardController {
318
329
  return await this.initializeRemote()
319
330
  }
320
331
 
332
+ /**
333
+ * Re-read the authoritative Host snapshot.
334
+ *
335
+ * The board's volatile deployment configuration — the new-card defaults among
336
+ * it — is republished the moment the settings document is written, but the SSE
337
+ * frame that republish emits carries the value as of BEFORE the write (the
338
+ * loader hands the update to the fiber while the new document is not yet
339
+ * visible to it), so a mirror that followed frames alone would stay one edit
340
+ * behind and the create dialog would prefill from the previous defaults for
341
+ * the rest of the session. The surface that owns the write asks for the
342
+ * snapshot again instead of trusting the frame.
343
+ */
344
+ refreshHostState(): void {
345
+ void this.refreshRemote()
346
+ }
347
+
321
348
  // --- view state -------------------------------------------------------------
322
349
 
323
350
  /**
@@ -407,7 +434,7 @@ export class BoardController {
407
434
 
408
435
  createTask(input: NewTaskInput): TaskRecord | undefined {
409
436
  const id = this.uuid()
410
- const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth())
437
+ const { task, tasks } = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth(), undefined, this.hostState?.cardDefaults)
411
438
  if (task === undefined) return undefined
412
439
  this.tasks = [...tasks]
413
440
  this.persistAndNotify()
@@ -421,7 +448,7 @@ export class BoardController {
421
448
  // The preview must obey the SAME lineage gate the Host will: a browser-side
422
449
  // default of one level would reject a legal subtask-of-subtask before the
423
450
  // request is ever sent.
424
- const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth()).task
451
+ const preview = applyCreateTask(this.tasks, input, this.now(), id, this.mirrorDepth(), undefined, this.hostState?.cardDefaults).task
425
452
  if (preview === undefined) return undefined
426
453
  return await this.commitRemote({ kind: 'create', id, input }, id)
427
454
  ? this.tasks.find(task => task.id === id)
@@ -804,18 +831,25 @@ export class BoardController {
804
831
  }
805
832
 
806
833
  /**
807
- * Project a Host snapshot onto the browser's mirror. SSE frames and partial
808
- * snapshots carry only the volatile subset (revision/scheduler/power), so the
809
- * deployment constants the UI reads — the session-default permission the
810
- * confirmation banner compares against and the subtask depth limit — are
811
- * carried over from the last full snapshot instead of being dropped by a
812
- * heartbeat frame.
834
+ * Project a Host snapshot onto the browser's mirror. Every snapshot the
835
+ * transport serves is a full document (bootstrap/state/action all answer
836
+ * `TaskBoardHostService.snapshot()`), so the deployment constants the UI reads
837
+ * — the session-default permission the confirmation banner compares against,
838
+ * the subtask depth limit, the platform switches and the extensions a provider
839
+ * published — are taken as published, carried over only where the snapshot
840
+ * itself omits the key.
813
841
  */
814
842
  private mirrorOf(snapshot: TaskBoardSnapshot): HostMirror {
815
843
  const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
816
844
  const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
817
845
  const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
818
846
  const goalRunEnabled = snapshot.goalRunEnabled ?? this.hostState?.goalRunEnabled
847
+ // The new-card defaults are NOT carried over: the Host publishes a target
848
+ // nothing configures by omitting it (protocol.ts states that explicitly), so
849
+ // an absent key means "this deployment configures none". Carrying the last
850
+ // value over would keep a CLEARED default alive in the create dialog, which
851
+ // is the mirror image of the staleness this key is re-read for.
852
+ const cardDefaults = snapshot.cardDefaults
819
853
  const extensions = snapshot.extensions ?? this.hostState?.extensions
820
854
  return {
821
855
  revision: snapshot.revision,
@@ -825,6 +859,7 @@ export class BoardController {
825
859
  ...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
826
860
  ...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
827
861
  ...(goalRunEnabled === undefined ? {} : { goalRunEnabled }),
862
+ ...(cardDefaults === undefined ? {} : { cardDefaults }),
828
863
  ...(extensions === undefined ? {} : { extensions }),
829
864
  }
830
865
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Run-launch admission width: the one bound on how many sessions this board
3
+ * starts at the same time.
4
+ *
5
+ * One board action opens a whole subtree, and a team run hands every member of
6
+ * it to the Lead at once, so without a bound the Host asks DSH for dozens of
7
+ * sessions in a single tick. The repository's own measured guidance caps heavy
8
+ * implementation subagents at three (docs/multi-agent-resources.md) and no
9
+ * runtime mechanism enforced it; this setting is that mechanism.
10
+ *
11
+ * The bounds and the default live here rather than in either half, exactly like
12
+ * the roster cadence: the settings card validates a draft against the same
13
+ * range the Host clamps a hand-edited configuration to, and a programmatic
14
+ * mount that names no width gets the default.
15
+ *
16
+ * @module dsh-task-board/core/launch-width
17
+ */
18
+
19
+ /** Width used when neither the settings document nor the mount names one. */
20
+ export const DEFAULT_RUN_LAUNCH_WIDTH = 3
21
+
22
+ /** Narrowest accepted width: one launch at a time. */
23
+ export const RUN_LAUNCH_WIDTH_MIN = 1
24
+
25
+ /** Widest accepted width. */
26
+ export const RUN_LAUNCH_WIDTH_MAX = 16
27
+
28
+ /**
29
+ * Clamp a configured width into the supported range. A value the schema would
30
+ * refuse (non-finite, below the floor, above the ceiling) still leaves the
31
+ * board launching: an out-of-range setting never stalls a run.
32
+ * @param width - configured width, or undefined for the default.
33
+ * @returns the effective width.
34
+ */
35
+ export function normalizeRunLaunchWidth(width: number | undefined): number {
36
+ if (width === undefined || !Number.isFinite(width)) return DEFAULT_RUN_LAUNCH_WIDTH
37
+ return Math.min(Math.max(Math.trunc(width), RUN_LAUNCH_WIDTH_MIN), RUN_LAUNCH_WIDTH_MAX)
38
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Which reasoning effort one execution may actually send.
3
+ *
4
+ * The rule lives here, in one pure place, so the launch path cannot drift from
5
+ * what the picker promises: an effort is sent ONLY for the exact model route
6
+ * whose adapter declares that id. An unsupported value is dropped rather than
7
+ * passed through — the Host validates the selection against the same
8
+ * capability (`resolveCallConfig` rejects an unsupported effort) and the whole
9
+ * model selection would fail with it. An absent effort stays absent: the SDK
10
+ * then materializes the model's own default, which is exactly what "inherit
11
+ * the model" means.
12
+ *
13
+ * A card that pins a bare model id also gets its provider resolved here: the
14
+ * session wire contract requires `provider` (ModelSelection), so the catalog is
15
+ * the only place that can complete the documented `provider/model` or model-id
16
+ * pin.
17
+ */
18
+ import type { CatalogModel, ModelCatalogView } from './verification.ts'
19
+
20
+ /** One execution route an effort resolves against: `provider/model`, or a bare model id. */
21
+ export interface ReasoningRoute {
22
+ provider?: string
23
+ model: string
24
+ }
25
+
26
+ /** The catalog entry a route names, once the catalog can complete it. */
27
+ export interface DeclaredReasoningRoute {
28
+ provider: string
29
+ model: string
30
+ /** The adapter's reasoning metadata; absent when it exposes none. */
31
+ reasoning?: CatalogModel['reasoning']
32
+ }
33
+
34
+ /** What one card's effort resolves to for one route. */
35
+ export interface ReasoningEffortDecision {
36
+ /** The effort to send; absent means "send none". */
37
+ effort?: string
38
+ /** The card's own value when it will NOT be sent. */
39
+ dropped?: string
40
+ }
41
+
42
+ /** Trim one optional effort: blank text means "not set", like every other pinned target. */
43
+ export function normalizeReasoningEffort(value: string | undefined): string | undefined {
44
+ const trimmed = value?.trim()
45
+ return trimmed === undefined || trimmed === '' ? undefined : trimmed
46
+ }
47
+
48
+ /**
49
+ * Complete one route through the host catalog: a qualified route must match its
50
+ * provider group, and a bare model id matches the first group carrying it —
51
+ * which is the provider the request then names.
52
+ * @param catalog - the host model catalog, when this deployment serves one.
53
+ * @param route - the route as the card pins it.
54
+ * @returns the completed route with its reasoning metadata, or undefined when
55
+ * the catalog declares no such model.
56
+ */
57
+ export function declaredRoute(
58
+ catalog: ModelCatalogView | undefined,
59
+ route: ReasoningRoute,
60
+ ): DeclaredReasoningRoute | undefined {
61
+ const groups = catalog?.groups ?? []
62
+ const matched = route.provider === undefined || route.provider === ''
63
+ ? groups.find(group => group.models.some(model => model.id === route.model))
64
+ : groups.find(group => group.id === route.provider)
65
+ const model = matched?.models.find(candidate => candidate.id === route.model)
66
+ if (matched === undefined || model === undefined) return undefined
67
+ return {
68
+ provider: matched.id,
69
+ model: model.id,
70
+ ...(model.reasoning === undefined ? {} : { reasoning: model.reasoning }),
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Resolve the effort one execution sends.
76
+ * @param requested - the card's stored effort (absent or blank = inherit).
77
+ * @param catalog - the host model catalog, when this deployment serves one.
78
+ * @param route - the route the execution will actually run at.
79
+ * @returns the effort to send, or the dropped value that will not be sent.
80
+ */
81
+ export function resolveReasoningEffort(
82
+ requested: string | undefined,
83
+ catalog: ModelCatalogView | undefined,
84
+ route: ReasoningRoute | undefined,
85
+ ): ReasoningEffortDecision {
86
+ const effort = normalizeReasoningEffort(requested)
87
+ if (effort === undefined) return {}
88
+ if (route === undefined) return { dropped: effort }
89
+ const declared = declaredRoute(catalog, route)
90
+ const supported = declared?.reasoning?.efforts.some(option => option.id === effort) === true
91
+ return supported ? { effort } : { dropped: effort }
92
+ }
package/src/core/store.ts CHANGED
@@ -68,6 +68,7 @@ function isTaskRecordShape(value: unknown): value is Omit<TaskRecord, 'status'>
68
68
  if (record.parentId !== undefined && typeof record.parentId !== 'string') return false
69
69
  if (record.workspaceId !== undefined && typeof record.workspaceId !== 'string') return false
70
70
  if (record.mode !== undefined && typeof record.mode !== 'string') return false
71
+ if (record.reasoningEffort !== undefined && typeof record.reasoningEffort !== 'string') return false
71
72
  if (record.permission !== undefined && typeof record.permission !== 'string') return false
72
73
  if (record.integrations !== undefined && (typeof record.integrations !== 'object' || record.integrations === null || Array.isArray(record.integrations))) return false
73
74
  if (record.hidden !== undefined && typeof record.hidden !== 'boolean') return false
@@ -265,6 +266,10 @@ export function parseLedger(raw: string | null): TaskRecord[] {
265
266
  // fall back to the session default instead of dropping the row.
266
267
  task.workspaceId = normalizeTargetId(row.workspaceId)
267
268
  task.mode = normalizeTargetId(row.mode)
269
+ // The effort is repaired like the other execution-target strings: blank text
270
+ // from a hand-edited document clears the pin instead of dropping the row,
271
+ // and the card then runs at its model's own default level.
272
+ task.reasoningEffort = normalizeTargetId(row.reasoningEffort)
268
273
  task.archivedAt = typeof row.archivedAt === 'number' && Number.isFinite(row.archivedAt) ? row.archivedAt : undefined
269
274
  task.permission = isTaskPermission(row.permission) ? row.permission as TaskPermission : undefined
270
275
  task.reuseSession = row.reuseSession === true ? true : undefined
@@ -183,9 +183,12 @@ export function checkParentLink(
183
183
 
184
184
  /**
185
185
  * The tasks one run request opens executions for: the requested task first,
186
- * then its on-board descendants within the configured depth. Archived tasks
187
- * never run; a participant that already has an open execution is skipped by
188
- * the caller, which cannot start it twice.
186
+ * then its on-board descendants within the configured depth. This is the
187
+ * SHAPE of a cascade — every walk that must see the whole tree (settlement,
188
+ * archive/restore, the running lock) reads it. The run path filters it with
189
+ * {@link joinsCascadeRun}: archived tasks never run, a participant that already
190
+ * has an open execution is skipped by the caller, which cannot start it twice,
191
+ * and a finished descendant is left alone.
189
192
  */
190
193
  export function cascadeTargets(
191
194
  tasks: readonly TaskRecord[],
@@ -197,6 +200,27 @@ export function cascadeTargets(
197
200
  return [root, ...descendantTasks(tasks, rootId, maxSubtaskDepth)]
198
201
  }
199
202
 
203
+ /**
204
+ * Whether a cascade member takes part in a new run.
205
+ *
206
+ * The task the user ran is always a member: the request names that card, and
207
+ * the operator asked for it to run whatever its column says. A descendant does
208
+ * not join once it sits in the `done` column — its work is finished, so running
209
+ * the parent again is a run of the parent, never a second execution of work
210
+ * that is already done. The rule keys on the column alone, so it covers both a
211
+ * subtask that ran and one an operator moved to done by hand, and it holds for
212
+ * every trigger: a manual run, a re-run, and a due occurrence of a schedule.
213
+ * A card whose own schedule is enabled settles back into `todo`, so a scheduled
214
+ * subtask stays a participant; to make a finished one run again, move it back
215
+ * to `todo` or run it on its own.
216
+ * @param task - the candidate member.
217
+ * @param rootId - the task the run request named.
218
+ * @returns false when this member is left alone by the run.
219
+ */
220
+ export function joinsCascadeRun(task: TaskRecord, rootId: string): boolean {
221
+ return task.id === rootId || task.status !== 'done'
222
+ }
223
+
200
224
  /** One still-open execution belonging to a cascade run group. */
201
225
  export interface OpenCascadeChild {
202
226
  taskId: string
@@ -263,11 +287,12 @@ export function effectiveTaskPermission(task: TaskRecord): TaskRecord['permissio
263
287
  /**
264
288
  * Resolve the execution targets a (sub)task actually runs with: its own value
265
289
  * wins, and an unset target inherits the nearest ancestor up to the root.
266
- * Workspace, agent preset and model inherit the ancestor's PIN only — a
267
- * handover bundle is a per-card continuation artefact and is never inherited.
268
- * The permission inherits the ancestor's EFFECTIVE binding, and its human
269
- * confirmation travels with it, so a subtask of an already-confirmed elevated
270
- * card is runnable while a subtask's own unconfirmed binding stays gated.
290
+ * Workspace, agent preset, model and reasoning effort inherit the ancestor's
291
+ * PIN only — a handover bundle is a per-card continuation artefact and is never
292
+ * inherited. The permission inherits the ancestor's EFFECTIVE binding, and its
293
+ * human confirmation travels with it, so a subtask of an already-confirmed
294
+ * elevated card is runnable while a subtask's own unconfirmed binding stays
295
+ * gated.
271
296
  */
272
297
  /**
273
298
  * The immutable teammate name for one team-mode member. Teammate names are
@@ -317,13 +342,22 @@ export function resolveExecutionTargets(
317
342
  ): TaskRecord {
318
343
  if (task.parentId === undefined) return task
319
344
  const chain = [task, ...ancestorChain(tasks, task).slice(0, maxSubtaskDepth)]
320
- const first = (key: 'workspaceId' | 'mode' | 'model'): string | undefined => {
321
- for (const candidate of chain) {
345
+ const first = (key: 'workspaceId' | 'mode' | 'model' | 'reasoningEffort', candidates: readonly TaskRecord[]): string | undefined => {
346
+ for (const candidate of candidates) {
322
347
  const value = candidate[key]
323
348
  if (value !== undefined) return value
324
349
  }
325
350
  return undefined
326
351
  }
352
+ // The model pin is the boundary of the effort: an effort is only ever taken
353
+ // from the card the model came from, or from the card that pinned it below
354
+ // that one. A card with a model of its own thus sends NO effort when it stores
355
+ // none — the model's own default — instead of quietly running at an ancestor
356
+ // level chosen for a different model, even when both models declare that
357
+ // level. A card with no model of its own still inherits the nearest model
358
+ // together with the nearest level pinned at or below it.
359
+ const boundary = chain.findIndex(candidate => candidate.model !== undefined)
360
+ const effortChain = boundary === -1 ? chain : chain.slice(0, boundary + 1)
327
361
  const own = effectiveTaskPermission(task)
328
362
  // An own binding (pin or handover) keeps its own confirmation stamp; only an
329
363
  // absent one borrows the ancestor's binding AND that ancestor's stamp.
@@ -332,9 +366,10 @@ export function resolveExecutionTargets(
332
366
  : undefined
333
367
  return {
334
368
  ...task,
335
- workspaceId: first('workspaceId'),
336
- mode: first('mode'),
337
- model: first('model'),
369
+ workspaceId: first('workspaceId', chain),
370
+ mode: first('mode', chain),
371
+ model: first('model', chain),
372
+ reasoningEffort: first('reasoningEffort', effortChain),
338
373
  permission: own ?? (owner === undefined ? undefined : effectiveTaskPermission(owner)),
339
374
  permissionConfirmedAt: own === undefined ? owner?.permissionConfirmedAt : task.permissionConfirmedAt,
340
375
  }
package/src/core/tasks.ts CHANGED
@@ -406,6 +406,15 @@ export interface TaskRecord {
406
406
  * absent falls back to the host default (agent-default-model).
407
407
  */
408
408
  model?: string
409
+ /**
410
+ * Reasoning effort pinned for that execution route (an adapter-owned id such
411
+ * as "high"). Absent (or blank) means INHERIT: the session runs at the
412
+ * model's own default level, exactly as a card written before the field
413
+ * existed does. The launch sends the value only when the target model's
414
+ * adapter declares that exact id, so switching a card to a model without
415
+ * that level never pushes an invalid selection (see core/reasoning-effort.ts).
416
+ */
417
+ reasoningEffort?: string
409
418
  /**
410
419
  * Whether later executions continue in the previous execution's session
411
420
  * (issue #1419) instead of minting a fresh conversation per run. Absent or
@@ -535,6 +544,16 @@ export interface NewTaskInput {
535
544
  permission?: TaskPermission
536
545
  /** Optional pinned model for the execution session; absent = host default. */
537
546
  model?: string
547
+ /** Optional reasoning effort for that model route; empty/absent = the model's own default. */
548
+ reasoningEffort?: string
549
+ /**
550
+ * Opt in to the deployment's new-card defaults: a target this input leaves
551
+ * out is filled from the configured default (agent preset, permission, model
552
+ * and the level bound to that model). Absent means the create stores exactly
553
+ * what it was given, which is what the extension provider's own create path
554
+ * and the legacy import rely on.
555
+ */
556
+ applyCardDefaults?: boolean
538
557
  /** Reuse the previous execution's session for later runs (issue #1419). */
539
558
  reuseSession?: boolean
540
559
  /** Run the subtree as an Agent Team (Team Lead session plus one teammate per direct subtask). */
@@ -699,6 +718,7 @@ export function createTask(input: NewTaskInput, now: number, id: string): TaskRe
699
718
  mode: normalizeTargetId(input.mode),
700
719
  permission: isTaskPermission(input.permission) ? input.permission : undefined,
701
720
  model: normalizeTargetId(input.model),
721
+ reasoningEffort: normalizeTargetId(input.reasoningEffort),
702
722
  reuseSession: input.reuseSession === true ? true : undefined,
703
723
  teamRun: input.teamRun === true ? true : undefined,
704
724
  // Default OFF: only an explicit true is stored, so a card that never
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { isValidCron, isValidTimeZone, nextRunAtMs, resolveHostTimeZone } from '../schedule.ts'
8
8
  import { DEFAULT_SUBTASK_DEPTH, normalizeSubtaskDepth, taskDepth } from '../subtask.ts'
9
- import { createTask, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
9
+ import { resolveCardTargets, type CardDefaults } from '../card-defaults.ts'
10
+ import { createTask, isTaskPermission, withSchedule, type NewTaskInput, type TaskRecord } from '../tasks.ts'
10
11
 
11
12
  /** Result of a create transition: the new task (when accepted) + the next ledger. */
12
13
  export interface CreateTaskResult {
@@ -23,16 +24,20 @@ export interface CreateTaskResult {
23
24
  * appended ledger, or the unchanged ledger when the title is blank or the
24
25
  * requested parent link is refused.
25
26
  *
26
- * A subtask inherits the execution targets its parent already pins (workspace,
27
- * agent preset, permission, model) unless the form supplied its own; the
28
- * parent human permission confirmation travels with an inherited permission,
29
- * so a subtask of an already-confirmed elevated card is runnable at once.
27
+ * The execution targets resolve through {@link resolveCardTargets}: what the
28
+ * caller sent wins, then the parent's own pins, then - only when the caller
29
+ * opted in with `applyCardDefaults` - the deployment's configured new-card
30
+ * defaults. Without the flag the resolution sees no defaults at all, which is
31
+ * exactly the inline parent inheritance this path has always applied. The
32
+ * workspace is not part of that resolution: a subtask still takes the parent's
33
+ * workspace, and the defaults never name one.
30
34
  * @param tasks - current ledger.
31
35
  * @param input - raw user input (title/description/prompt + optional schedule/parent).
32
36
  * @param now - clock instant (ms epoch).
33
37
  * @param id - minted task id.
34
38
  * @param maxSubtaskDepth - deployment subtask depth limit.
35
39
  * @param hostTimeZone - zone a rule that stores no zone of its own follows.
40
+ * @param cardDefaults - the deployment's new-card defaults, read live at creation.
36
41
  */
37
42
  export function applyCreateTask(
38
43
  tasks: readonly TaskRecord[],
@@ -41,6 +46,7 @@ export function applyCreateTask(
41
46
  id: string,
42
47
  maxSubtaskDepth: number = DEFAULT_SUBTASK_DEPTH,
43
48
  hostTimeZone: string = resolveHostTimeZone(),
49
+ cardDefaults?: CardDefaults,
44
50
  ): CreateTaskResult {
45
51
  if (input.title.trim() === '') return { task: undefined, tasks, error: 'title is required' }
46
52
  const limit = normalizeSubtaskDepth(maxSubtaskDepth)
@@ -53,18 +59,23 @@ export function applyCreateTask(
53
59
  return { task: undefined, tasks, error: `subtask depth limit (${limit}) exceeded` }
54
60
  }
55
61
  }
56
- // A subtask copies the execution targets its parent pins so the card shows
57
- // what it will run with — EXCEPT the permission. A copied permission would
58
- // also have to copy the parent's human confirmation to stay runnable, and the
59
- // child would keep both after being detached: one confirmed parent would mint
60
- // unbounded confirmed elevated roots. The permission is resolved live from the
61
- // attached ancestors at launch instead (see core/subtask.ts), so detaching
62
- // removes the inheritance by construction.
63
- const effective: NewTaskInput = parent === undefined ? input : {
62
+ // The execution targets resolve through the one shared rule: the caller's own
63
+ // choice, then the parent's own pins (never its permission, see
64
+ // core/card-defaults.ts), then the deployment's new-card defaults — and only
65
+ // when the caller asked for them, so a create that never opts in stores
66
+ // exactly what it sent, as it always has.
67
+ const seed = resolveCardTargets(
68
+ input.applyCardDefaults === true ? cardDefaults : undefined,
69
+ parent === undefined ? undefined : { kind: 'parent', task: parent },
70
+ { mode: input.mode, permission: input.permission, model: input.model, reasoningEffort: input.reasoningEffort },
71
+ )
72
+ const effective: NewTaskInput = {
64
73
  ...input,
65
- workspaceId: input.workspaceId ?? parent.workspaceId,
66
- mode: input.mode ?? parent.mode,
67
- model: input.model ?? parent.model,
74
+ workspaceId: input.workspaceId ?? parent?.workspaceId,
75
+ mode: seed.mode,
76
+ permission: isTaskPermission(seed.permission) ? seed.permission : undefined,
77
+ model: seed.model,
78
+ reasoningEffort: seed.reasoningEffort,
68
79
  }
69
80
  let task = createTask(effective, now, id)
70
81
  // Arm the requested schedule (new-task dialog). The Host wire gate already
@@ -16,7 +16,7 @@ import type { TaskHandoverInput } from '../handover.ts'
16
16
  * Editable fields on a task (the update patch surface). `freeze` replaces the
17
17
  * continuation-card snapshot (restamping frozenAt); an explicit null clears it.
18
18
  */
19
- export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reuseSession' | 'teamRun' | 'goalRun' | 'skipVerification'>> & {
19
+ export type TaskUpdatePatch = Partial<Pick<TaskRecord, 'title' | 'description' | 'prompt' | 'workspaceId' | 'mode' | 'permission' | 'model' | 'reasoningEffort' | 'reuseSession' | 'teamRun' | 'goalRun' | 'skipVerification'>> & {
20
20
  freeze?: FreezeSnapshot & { redacted?: boolean } | null
21
21
  /** Replaces the handover bundle (restamping bundledAt); an explicit null clears it. */
22
22
  handover?: TaskHandoverInput | null
@@ -82,6 +82,7 @@ export function applyUpdateTask(
82
82
  const mode = 'mode' in patch ? normalizeTargetId(patch.mode) : undefined
83
83
  const permission = 'permission' in patch ? normalizePermission(task.permission, patch.permission) : undefined
84
84
  const model = 'model' in patch ? normalizeTargetId(patch.model) : undefined
85
+ const reasoningEffort = 'reasoningEffort' in patch ? normalizeTargetId(patch.reasoningEffort) : undefined
85
86
  const next: TaskRecord = { ...task, ...rest, updatedAt: now }
86
87
  // Content fields normalize like creation does (trimmed); an explicit
87
88
  // undefined keeps the current value — content cannot be cleared.
@@ -121,6 +122,10 @@ export function applyUpdateTask(
121
122
  if (mode !== undefined || 'mode' in patch) next.mode = mode
122
123
  if (permission !== undefined || 'permission' in patch) next.permission = permission
123
124
  if (model !== undefined || 'model' in patch) next.model = model
125
+ // The effort is bound to the model route above it: blank text (or an
126
+ // explicit undefined) returns the card to the model's own default level,
127
+ // and only the launch decides whether the value can be sent at all.
128
+ if (reasoningEffort !== undefined || 'reasoningEffort' in patch) next.reasoningEffort = reasoningEffort
124
129
  return next
125
130
  })
126
131
  }
@@ -166,6 +166,7 @@ function taskSummary(
166
166
  ...(task.mode === undefined ? {} : { mode: task.mode }),
167
167
  ...(task.permission === undefined ? {} : { permission: task.permission }),
168
168
  ...(task.model === undefined ? {} : { model: task.model }),
169
+ ...(task.reasoningEffort === undefined ? {} : { reasoningEffort: task.reasoningEffort }),
169
170
  ...(task.reuseSession === true ? { reuseSession: true } : {}),
170
171
  ...(task.teamRun === true ? { teamRun: true } : {}),
171
172
  // Default-OFF opt-in: only a card that asked for the goal run reports it.
@@ -277,7 +278,7 @@ function buildSetParentTool(host: TaskBoardToolHost): ToolDefinition {
277
278
  'Attach an existing task under a parent (making it a subtask) or detach it back to the root board by setting its parentId.',
278
279
  'Pass an empty parentId to detach.',
279
280
  '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).',
280
- 'Running a task runs its whole subtree concurrently; a parent card settles only after its own turn and every direct subtask settle.',
281
+ 'Running a task opens one run group over its subtree concurrently and leaves a subtask that already sits in the done column alone; a parent card settles only after its own turn and every direct subtask it opened have settled.',
281
282
  ].join(' '),
282
283
  parameters: {
283
284
  taskId: { type: 'string', required: true, description: 'The task whose parent link changes.' },
@@ -313,7 +314,7 @@ function buildRunTool(host: TaskBoardToolHost): ToolDefinition {
313
314
  'Executions consume real API quota. Sessions are created asynchronously, so read their ids and outcomes back with task_board_get.',
314
315
  ].join(' '),
315
316
  parameters: {
316
- taskId: { type: 'string', required: true, description: 'The task to run; running a root task runs its whole subtree.' },
317
+ taskId: { type: 'string', required: true, description: 'The task to run; running a root task runs its whole subtree, minus the subtasks that are already in the done column.' },
317
318
  rerun: { type: 'boolean', description: 'Re-run a settled task: reset its column to todo before starting (default false).' },
318
319
  },
319
320
  output: { schema: { type: 'json' }, render: renderJson },
@@ -458,7 +459,7 @@ function buildScheduleTool(host: TaskBoardToolHost): ToolDefinition {
458
459
  'Arm, change, or disarm a task scheduled runs. Two kinds of plan exist: a recurring 5-field cron rule (day-of-month and day-of-week follow Vixie semantics: both restricted means OR, otherwise AND), and a one-shot that runs once at a future instant.',
459
460
  'For a one-shot pass mode "once" with at (the planned instant, ms since epoch UTC). For a recurring rule pass mode "cron" (or omit mode) with a cron expression, optionally capped by maxRuns (a positive whole number of scheduled executions; omit for unlimited, pass 0 to clear a cap).',
460
461
  'The wall clock is read in the rule stored IANA time zone; omit timeZone to use the Host zone reported by task_board_list/task_board_get. DST gaps are skipped and an ambiguous fall-back time fires once, at the earlier instant.',
461
- 'A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree.',
462
+ 'A due scheduled task runs the same cascade a manual run does, so a root task runs its whole subtask tree minus the subtasks already in the done column.',
462
463
  'The scheduler counts executions it actually starts; once a rule reaches its run budget it stops itself in the ledger, and a manual run never consumes the budget. A schedule whose tree contains an unconfirmed above-default permission is refused and does not consume the budget; the reason is reported in the list board summary as schedulerError.',
463
464
  'Missed occurrences during Host downtime are skipped, never queued (a one-shot whose instant passed while the Host was down is stopped as missed); a task that is already running skips its occurrence without consuming the budget.',
464
465
  ].join(' '),
@@ -586,7 +587,7 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
586
587
  name: 'task_board_create',
587
588
  description: [
588
589
  'Create a task on the DSH task board, or a subtask by passing parentId.',
589
- '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.',
590
+ 'A subtask inherits every execution target it does not override (workspace, agent preset, permission, model, reasoning effort) from its parent, and inherits the parent human permission confirmation together with an inherited binding.',
590
591
  'The deployment caps subtask depth (default one level, maximum three): a subtask of a subtask is refused unless the board is configured deeper.',
591
592
  'The task lands in the todo column and does NOT run; call task_board_run to execute it.',
592
593
  ].join(' '),
@@ -596,9 +597,10 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
596
597
  prompt: { type: 'string', description: 'The instruction sent to the execution agent; the title is used when blank.' },
597
598
  parentId: { type: 'string', description: 'Parent task id, making this a subtask. Omit for a root task.' },
598
599
  workspaceId: { type: 'string', description: 'Workspace id the execution must run in; omit to inherit the workspace this session is in (root task) or the parent value (subtask).' },
599
- mode: { type: 'string', description: 'Agent preset id the execution session is composed from; omit for the deployment default or the parent value.' },
600
- 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.' },
601
- model: { type: 'string', description: 'Pinned model as provider/model (or a model id); omit for the host default or the parent value.' },
600
+ mode: { type: 'string', description: 'Agent preset id the execution session is composed from; omit to adopt the board new-card default, and the deployment default when none is configured.' },
601
+ permission: { type: 'string', enum: [...TASK_PERMISSIONS], description: 'Permission preset for the execution session; omit to adopt the board new-card default. An omitted permission is never taken from a parent: the launch resolves the parent binding instead. A value above the session default needs a human confirmation in the board UI before the card can run.' },
602
+ model: { type: 'string', description: 'Pinned model as provider/model (or a model id); omit to adopt the board new-card default, and the host default when none is configured.' },
603
+ reasoningEffort: { type: 'string', description: 'Reasoning effort id for that model (an adapter-owned level such as "high"); omit to adopt the board new-card default for that model, and the model own default when none applies. Only an effort the target model declares is sent; an empty string clears it back to the default.' },
602
604
  reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session instead of a fresh conversation.' },
603
605
  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.' },
604
606
  goalRun: { type: 'boolean', description: 'Start each run with dsh built-in /goal so the session keeps working continuation rounds until the goal completes. Off by default: omit (or pass false) for a single plain turn, and pass true to opt this card in. It only takes effect while the deployment global native /goal switch is on.' },
@@ -645,6 +647,12 @@ function buildCreateTool(host: TaskBoardToolHost): ToolDefinition {
645
647
  ...(args.mode === undefined || args.mode === '' ? {} : { mode: args.mode }),
646
648
  ...(args.permission === undefined ? {} : { permission: args.permission }),
647
649
  ...(args.model === undefined || args.model === '' ? {} : { model: args.model }),
650
+ ...(args.reasoningEffort === undefined || args.reasoningEffort === '' ? {} : { reasoningEffort: args.reasoningEffort }),
651
+ // This tool creates cards on behalf of a deployment that configured its
652
+ // new-card defaults once: an omitted target adopts them instead of
653
+ // leaving the card to resolve the host default at run time. Only the
654
+ // targets the caller omitted are filled.
655
+ applyCardDefaults: true,
648
656
  ...(args.reuseSession === true ? { reuseSession: true } : {}),
649
657
  ...(args.teamRun === true ? { teamRun: true } : {}),
650
658
  ...(args.goalRun === true ? { goalRun: true } : {}),
@@ -676,7 +684,7 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
676
684
  return defineTool({
677
685
  name: 'task_board_update',
678
686
  description: [
679
- 'Edit a task board card: title, description, prompt, labels, and the execution targets (workspace, agent preset, permission, model, session reuse).',
687
+ 'Edit a task board card: title, description, prompt, labels, and the execution targets (workspace, agent preset, permission, model, reasoning effort, session reuse).',
680
688
  'Only the fields you pass change; pass an empty string to clear a target, or an empty labels array to clear the labels.',
681
689
  'Title, description and prompt freeze once the task has started executing; the execution targets stay editable because they only affect future runs.',
682
690
  'Changing the pinned permission re-arms the permission confirmation gate.',
@@ -704,6 +712,7 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
704
712
  description: 'New permission preset; an empty string clears it back to the session default.',
705
713
  },
706
714
  model: { type: 'string', description: 'New pinned model; an empty string clears it.' },
715
+ reasoningEffort: { type: 'string', description: 'New reasoning effort id for the pinned model; an empty string clears it back to the model own default. Only an effort the target model declares is sent at run time.' },
707
716
  reuseSession: { type: 'boolean', description: 'Continue later runs in the previous execution session.' },
708
717
  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).' },
709
718
  goalRun: { type: 'boolean', description: 'Whether each run starts with dsh built-in /goal (default false, one plain turn). Pass true to opt this card in, or false to return it to the default.' },
@@ -730,6 +739,10 @@ function buildUpdateTool(host: TaskBoardToolHost): ToolDefinition {
730
739
  if (has(args, 'workspaceId') && typeof args.workspaceId === 'string') patch.workspaceId = args.workspaceId
731
740
  if (has(args, 'mode') && typeof args.mode === 'string') patch.mode = args.mode
732
741
  if (has(args, 'model') && typeof args.model === 'string') patch.model = args.model
742
+ // An empty effort is the wire's clearing value (back to the model's own default).
743
+ if (has(args, 'reasoningEffort') && typeof args.reasoningEffort === 'string') {
744
+ patch.reasoningEffort = args.reasoningEffort
745
+ }
733
746
  // An empty permission is the wire's clearing value (back to the session default).
734
747
  if (has(args, 'permission') && typeof args.permission === 'string') {
735
748
  patch.permission = args.permission === '' ? undefined : args.permission