@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
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The agent-preset roster the execution-target pickers read: one read of the
3
+ * generated api-remotes face, and its normalization into the option rows the
4
+ * board controller and the settings card's new-card-defaults topic both
5
+ * consume.
6
+ *
7
+ * The roster is a runtime fact of the deployment, not ledger state. The
8
+ * namespace mounts with the official api-remotes assembly, so the read itself
9
+ * is wired through a scoped inject at the call site (see src/client/index.ts);
10
+ * this module owns only the shape both consumers agree on, so neither has to
11
+ * know how the wire face answers.
12
+ */
13
+ import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
14
+ import type { ExecutionPresetOption } from '../../core/controller.ts'
15
+
16
+ /** The generated Remote namespace the mode pickers read their roster from. */
17
+ export type PresetRemote = ClientRemote['agentPresets']
18
+
19
+ /** One agent-preset row the wire face answers with. */
20
+ export interface PresetRosterRow {
21
+ id: string
22
+ name?: string
23
+ description?: string
24
+ /** Why this preset cannot compose a session; absent when it can. */
25
+ broken?: string
26
+ isDefault: boolean
27
+ }
28
+
29
+ /**
30
+ * Read the agent-preset roster of one deployment. The caller passes the
31
+ * namespace resolved inside its scoped inject, so a host that serves no such
32
+ * namespace never reaches here; a host that serves it answers `ok: false`
33
+ * instead, which leaves the caller's options untouched.
34
+ * @param remote - the `remote.agentPresets` namespace.
35
+ * @returns the roster, or a failed read the caller ignores.
36
+ */
37
+ export async function readPresetRoster(remote: PresetRemote): Promise<{ ok: boolean; presets: readonly PresetRosterRow[] }> {
38
+ const response = await remote.list()
39
+ if (!response.ok) return { ok: false, presets: [] }
40
+ return { ok: true, presets: response.value.presets }
41
+ }
42
+
43
+ /**
44
+ * Normalize one roster into the picker rows the UI consumes. A preset that
45
+ * cannot compose a session keeps its `broken` reason: the pickers show it and
46
+ * disable it, so a missing preset never looks like a missing roster.
47
+ * @param presets - the roster rows.
48
+ * @returns the picker options.
49
+ */
50
+ export function presetOptionsFromRoster(presets: readonly PresetRosterRow[]): ExecutionPresetOption[] {
51
+ return presets.map(preset => ({
52
+ id: preset.id,
53
+ name: preset.name,
54
+ description: preset.description,
55
+ broken: preset.broken,
56
+ isDefault: preset.isDefault,
57
+ }))
58
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Team-run promotion for a parent card that just gained a subtask. A card with
3
+ * subtasks is exactly what the Agent Team mode is for, so the two join actions
4
+ * (create a subtask, link an existing task) opt the parent in — but only after
5
+ * the Host confirmed the join, and only where the deployment serves Agent
6
+ * Teams. A refused join changes nothing, and a parent that already opted in is
7
+ * left alone rather than rewritten.
8
+ *
9
+ * Nothing here runs on mount or from a render effect: an existing card is only
10
+ * ever touched by the user's own join action, so switching the option off by
11
+ * hand stays off.
12
+ */
13
+ import type { BoardController } from '../../core/controller.ts'
14
+ import type { TaskRecord } from '../../core/tasks.ts'
15
+
16
+ /**
17
+ * Whether a join would promote this parent: the deployment serves Agent Teams
18
+ * and the parent has not opted in yet.
19
+ * @param controller - the board controller (the snapshot carries both facts).
20
+ * @param parent - the task that gained a subtask, if there is one.
21
+ */
22
+ export function parentAwaitsTeamRunPromotion(controller: BoardController, parent: TaskRecord | undefined): boolean {
23
+ if (parent === undefined) return false
24
+ const snapshot = controller.getSnapshot()
25
+ if (snapshot.host?.teamRunAvailable !== true) return false
26
+ // The freshest record wins: the card may have been edited since this modal
27
+ // opened with its own copy.
28
+ return (snapshot.tasks.find(task => task.id === parent.id) ?? parent).teamRun !== true
29
+ }
30
+
31
+ /**
32
+ * Opt the parent in after the Host confirmed the join.
33
+ * @returns true when the opt-in was written; false when it did not apply.
34
+ */
35
+ export async function promoteParentForTeamRun(controller: BoardController, parent: TaskRecord | undefined): Promise<boolean> {
36
+ if (parent === undefined) return false
37
+ if (!parentAwaitsTeamRunPromotion(controller, parent)) return false
38
+ return await controller.updateTask(parent.id, { teamRun: true })
39
+ }
@@ -24,7 +24,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
24
24
  // Type-only: pulls the workspace plugin's Context merge (ctx.uiWorkspace), the
25
25
  // multi-instance navigation face that replaced ISessions.open().
26
26
  import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
27
- import { BoardController, TASK_BOARD_PANEL_ID } from '../core/controller.ts'
27
+ import { BoardController, TASK_BOARD_PANEL_ID, type ExecutionModelOption, type ExecutionPresetOption } from '../core/controller.ts'
28
+ import { modelOptionsFromCatalog, modelOptionsFromDiscoveredModels } from './board/model-options.ts'
29
+ import { presetOptionsFromRoster, readPresetRoster, type PresetRemote } from './board/preset-roster.ts'
28
30
  import { mainViewSessionId } from './main-session.ts'
29
31
  import { LocalStorageTaskStore } from '../core/store.ts'
30
32
  import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
@@ -140,33 +142,6 @@ declare module '@deepseek-ai/cordis' {
140
142
  */
141
143
  export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'configForms', 'locale', 'remote', 'remote.session', 'uiWorkspace', 'layout']
142
144
 
143
- /** The generated Remote namespace the mode picker reads its roster from. */
144
- type PresetRemote = ClientRemote['agentPresets']
145
-
146
- /** One agent-preset row the mode picker consumes. */
147
- interface PresetRosterRow {
148
- id: string
149
- name?: string
150
- description?: string
151
- /** Why this preset cannot compose a session; absent when it can. */
152
- broken?: string
153
- isDefault: boolean
154
- }
155
-
156
- /**
157
- * Read the agent-preset roster through the generated api-remotes face. The
158
- * caller passes the namespace resolved inside its scoped inject, so a host
159
- * that serves no such namespace never reaches here; a host that serves it
160
- * answers `ok: false` instead, which leaves the picker options untouched.
161
- * @param remote - the `remote.agentPresets` namespace.
162
- * @returns the roster, or a failed read the caller ignores.
163
- */
164
- async function readPresetRoster(remote: PresetRemote): Promise<{ ok: boolean; presets: readonly PresetRosterRow[] }> {
165
- const response = await remote.list()
166
- if (!response.ok) return { ok: false, presets: [] }
167
- return { ok: true, presets: response.value.presets }
168
- }
169
-
170
145
  /**
171
146
  * Mount the task board.
172
147
  * @param ctx - client root context (services: sessions, workspaces).
@@ -229,6 +204,48 @@ export function apply(ctx: ClientContext): void {
229
204
  })
230
205
  ctx.effect(() => () => { settingsCard.dispose() }, 'task-board: settings card')
231
206
 
207
+ // The agent-preset roster is a runtime fact of the deployment, not ledger
208
+ // state, and TWO surfaces read it: the board's mode pickers and the settings
209
+ // card's new-card-defaults topic. It is wired here, outside the board view,
210
+ // because the settings card stays reachable while the board itself is
211
+ // disabled — that is where it is switched back on — and its default-preset
212
+ // field must not lose the roster with the view. The namespace mounts with the
213
+ // official api-remotes assembly, which the page applies beside this entry: at
214
+ // mount time it is usually still absent, and reading it through
215
+ // `remote.agentPresets` before it lands throws ("cannot get property ...
216
+ // without inject"), so a one-shot probe would leave both pickers with nothing
217
+ // but "inherit" for the page lifetime. A hard inject is no better — it would
218
+ // pend the whole plugin on a deployment that never mounts the namespace. The
219
+ // scoped inject runs the feed exactly when the namespace exists and re-runs it
220
+ // if the namespace is replaced; the reconnect read inside covers a deployment
221
+ // change behind the same namespace.
222
+ let presetRoster: readonly ExecutionPresetOption[] | undefined
223
+ let liveController: BoardController | undefined
224
+ const pushPresetOptions = async (presetRemote: PresetRemote): Promise<void> => {
225
+ try {
226
+ const roster = await readPresetRoster(presetRemote)
227
+ if (!roster.ok) return
228
+ const presets = presetOptionsFromRoster(roster.presets)
229
+ presetRoster = presets
230
+ settingsCard.setPresetRoster(presets)
231
+ liveController?.setExecutionOptions({ presets })
232
+ } catch (error) {
233
+ // A failed roster read leaves the previous options in place: the pickers
234
+ // stay usable, the default-preset field keeps the inherit choice alone,
235
+ // and the next reconnect retries the read.
236
+ console.error('[dsh-task-board] agent preset roster read failed', error)
237
+ }
238
+ }
239
+ const presetRosterFeed = ctx.inject(['remote.agentPresets'], (inner) => {
240
+ const presetRemote = inner.get('remote.agentPresets') as PresetRemote | undefined
241
+ if (presetRemote === undefined) return
242
+ void pushPresetOptions(presetRemote)
243
+ // Subscribed inside the injected scope, so a namespace that goes away takes
244
+ // its listener with it instead of leaving one per remount.
245
+ return inner.on('connection/reset', () => { void pushPresetOptions(presetRemote) })
246
+ })
247
+ ctx.effect(() => () => { void presetRosterFeed.dispose() }, 'task-board: agent preset roster')
248
+
232
249
  // The sidebar entry and board view mount once the settings form settles;
233
250
  // while the form is still loading, the composition default is unknown, so
234
251
  // nothing mounts yet. Only an unavailable form (no settings surface served)
@@ -275,12 +292,35 @@ export function apply(ctx: ClientContext): void {
275
292
  clientService.attach(controller)
276
293
 
277
294
  const disposers: Array<() => void> = []
295
+ // The board follows the live controller for the roster feed wired above,
296
+ // which serves the settings card whether or not the view is mounted.
297
+ liveController = controller
298
+ if (presetRoster !== undefined) controller.setExecutionOptions({ presets: presetRoster })
299
+
300
+ // The new-card defaults are written through the very settings form this
301
+ // plugin binds (that is the card's save), and the Host republishes its
302
+ // snapshot on that write. Its SSE frame carries the value as of BEFORE the
303
+ // write, though, so a mirror that only followed frames would hand the create
304
+ // dialog the previous defaults for the rest of the page session — the
305
+ // "configure a default, create a card, get nothing" report. Follow the form
306
+ // instead: when the four defaults it holds change, re-read the authoritative
307
+ // snapshot. The signature keeps one write to one GET (the form announces a
308
+ // settled document more than once) and keeps an unrelated settings write, or
309
+ // any board action, from pulling the ledger again.
310
+ let followedDefaults = cardDefaultSignature(settingsForm.getSnapshot().value)
311
+ disposers.push(settingsForm.subscribe(() => {
312
+ const next = cardDefaultSignature(settingsForm.getSnapshot().value)
313
+ if (next === followedDefaults) return
314
+ followedDefaults = next
315
+ controller.refreshHostState()
316
+ }))
278
317
 
279
318
  // Execution-target option feeds: the workspace list drives the workspace
280
319
  // picker, and the agent-preset roster drives the mode picker. Both are
281
320
  // runtime facts (not ledger state), so the wiring pushes them into the
282
- // controller on change; the preset roster is re-read after reconnects
283
- // because a reconnect may serve a different deployment.
321
+ // controller on change; the preset roster feed itself lives beside the
322
+ // settings card (see apply) because that card's new-card-defaults topic
323
+ // reads the same roster while this view may not be mounted at all.
284
324
  const pushWorkspaceOptions = (): void => {
285
325
  const snapshot = workspaces.list.getSnapshot()
286
326
  controller.setExecutionOptions({
@@ -299,47 +339,9 @@ export function apply(ctx: ClientContext): void {
299
339
  const created = await workspaces.create({ path })
300
340
  return { workspaceId: created.workspaceId }
301
341
  })
302
- const pushPresetOptions = async (presetRemote: PresetRemote): Promise<void> => {
303
- try {
304
- const roster = await readPresetRoster(presetRemote)
305
- if (!roster.ok) return
306
- controller.setExecutionOptions({
307
- presets: roster.presets.map(preset => ({
308
- id: preset.id,
309
- name: preset.name,
310
- description: preset.description,
311
- broken: preset.broken,
312
- isDefault: preset.isDefault,
313
- })),
314
- })
315
- } catch (error) {
316
- // A failed roster read leaves the previous options in place; the
317
- // picker stays usable and the next reconnect retries the read.
318
- console.error('[dsh-task-board] agent preset roster read failed', error)
319
- }
320
- }
321
- // The roster namespace mounts with the official api-remotes assembly,
322
- // which the page applies beside this entry: at mount time the namespace is
323
- // usually still absent, and reading it through `remote.agentPresets`
324
- // before it lands throws ("cannot get property ... without inject"), so a
325
- // one-shot probe leaves the mode picker with nothing but "inherit" for the
326
- // page lifetime. A hard inject is no better — it would pend the whole
327
- // board on a deployment that never mounts the namespace. The scoped inject
328
- // runs the feed exactly when the namespace exists and re-runs it if the
329
- // namespace is replaced; the reconnect read inside covers a deployment
330
- // change behind the same namespace.
331
- const presetRosterFeed = ctx.inject(['remote.agentPresets'], (inner) => {
332
- const presetRemote = inner.get('remote.agentPresets') as PresetRemote | undefined
333
- if (presetRemote === undefined) return
334
- void pushPresetOptions(presetRemote)
335
- // Subscribed inside the injected scope, so a namespace that goes away
336
- // takes its listener with it instead of leaving one per remount.
337
- return inner.on('connection/reset', () => { void pushPresetOptions(presetRemote) })
338
- })
339
- disposers.push(() => { void presetRosterFeed.dispose() })
340
342
  const pushModelOptions = async (): Promise<void> => {
341
343
  try {
342
- let models: Array<{ id: string; name?: string; provider?: string }> = []
344
+ let models: ExecutionModelOption[] = []
343
345
  let sessionRemote: ClientRemote['session'] | undefined
344
346
  try {
345
347
  sessionRemote = (remote as Partial<ClientRemote>).session
@@ -348,15 +350,7 @@ export function apply(ctx: ClientContext): void {
348
350
  }
349
351
  if (typeof sessionRemote?.modelCatalog === 'function') {
350
352
  const res = await sessionRemote.modelCatalog()
351
- if (res.ok && Array.isArray(res.value?.groups)) {
352
- for (const g of res.value.groups) {
353
- const provider = (g as { id?: string; provider?: string }).id ?? (g as { id?: string; provider?: string }).provider
354
- for (const m of g.models ?? []) {
355
- const qualifiedId = provider ? `${provider}/${m.id}` : m.id
356
- models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
357
- }
358
- }
359
- }
353
+ if (res.ok) models = modelOptionsFromCatalog(res.value)
360
354
  }
361
355
  if (models.length === 0) {
362
356
  const conn = ctx.get('connection') as {
@@ -368,11 +362,8 @@ export function apply(ctx: ClientContext): void {
368
362
  } | undefined
369
363
  if (conn?.api) {
370
364
  if (typeof conn.api.llm?.discoverModels === 'function') {
371
- const res = await conn.api.llm.discoverModels() as { result?: { value?: { models?: Array<{ id: string; name?: string }> } } }
372
- const list = res?.result?.value?.models
373
- if (Array.isArray(list)) {
374
- models = list.map(m => ({ id: m.id, name: m.name }))
375
- }
365
+ const res = await conn.api.llm.discoverModels() as { result?: { value?: { models?: unknown } } }
366
+ models = modelOptionsFromDiscoveredModels(res?.result?.value?.models)
376
367
  }
377
368
  const catalogFn = typeof conn.api.session?.modelCatalog === 'function'
378
369
  ? conn.api.session.modelCatalog
@@ -380,17 +371,8 @@ export function apply(ctx: ClientContext): void {
380
371
  ? conn.api.sessions.modelCatalog
381
372
  : undefined
382
373
  if (models.length === 0 && catalogFn !== undefined) {
383
- const res = await catalogFn() as { result?: { value?: { groups?: Array<{ id?: string; provider?: string; models?: Array<{ id: string; name?: string }> }> } } }
384
- const groups = res?.result?.value?.groups
385
- if (Array.isArray(groups)) {
386
- for (const g of groups) {
387
- const provider = g.id ?? g.provider
388
- for (const m of g.models ?? []) {
389
- const qualifiedId = provider ? `${provider}/${m.id}` : m.id
390
- models.push({ id: qualifiedId, name: m.name ?? m.id, provider })
391
- }
392
- }
393
- }
374
+ const res = await catalogFn() as { result?: { value?: unknown } }
375
+ models = modelOptionsFromCatalog(res?.result?.value)
394
376
  }
395
377
  }
396
378
  }
@@ -427,6 +409,7 @@ export function apply(ctx: ClientContext): void {
427
409
  for (const dispose of disposers.splice(0)) dispose()
428
410
  clientService.detach()
429
411
  controller.dispose()
412
+ liveController = undefined
430
413
  uiDisposer = undefined
431
414
  }
432
415
  }
@@ -480,6 +463,27 @@ export function bindSettingsForm(ctx: ClientContext): ConfigForm<TaskBoardSettin
480
463
  })
481
464
  }
482
465
 
466
+ /**
467
+ * The four new-card defaults one settings document holds, as a change
468
+ * signature.
469
+ *
470
+ * Deliberately nothing but those four keys: the settings form also carries the
471
+ * board's own switches and the acceptance configuration, and an edit to one of
472
+ * those says nothing about the defaults the create dialog prefills from. Not a
473
+ * second normalization of Host state — the value never leaves this comparison.
474
+ * @param value - the settings form's effective section, if it has one.
475
+ * @returns a stable string that changes exactly when the defaults do.
476
+ */
477
+ function cardDefaultSignature(value: unknown): string {
478
+ const section = (value ?? {}) as Record<string, unknown>
479
+ return JSON.stringify([
480
+ section.cardDefaultMode,
481
+ section.cardDefaultPermission,
482
+ section.cardDefaultModel,
483
+ section.cardDefaultReasoningEffort,
484
+ ])
485
+ }
486
+
483
487
  /**
484
488
  * The profile entry id this package's own row carries, for a page that serves
485
489
  * no family binder.
@@ -247,6 +247,14 @@ export const zh = {
247
247
  'exec.permission.danger-full-access': '完全访问',
248
248
  'exec.model.default': '宿主默认(agent-default-model)',
249
249
  'exec.model.unknown': '(未知模型/回退默认)',
250
+ 'exec.reasoning': '思考档位',
251
+ 'exec.reasoning.inherit': '沿用模型/宿主默认',
252
+ 'exec.reasoning.unknown': '(未知档位)',
253
+ 'exec.reasoning.hint.pickModel': '先选择要钉住的模型,才能设置它的思考档位。',
254
+ 'exec.reasoning.hint.unknownModel': '无法从当前名册确认该模型的可选档位;已保存的值原样保留。',
255
+ 'exec.reasoning.hint.noEfforts': '该模型没有可选的思考档位,始终沿用模型/宿主的默认值。',
256
+ 'exec.reasoning.hint.inherit': '留空即沿用该模型/宿主的默认档位。',
257
+ 'exec.teamRunAutoHint': '创建或关联子任务成功后,父任务会自动开启 Agent Team 执行(可随时手动关闭)。',
250
258
  'exec.reuseSession': '在同一对话继续',
251
259
  'exec.teamRun': '用 Agent Team 执行(Leader/teammate)',
252
260
  'exec.teamRunHint': '开启后:执行本任务只启动一个 Leader 会话,每个子任务由它派生为 teammate 并在该会话内并行工作(子任务自身的权限钉住不适用)。关闭则每个成员各开独立会话。',
@@ -292,6 +300,8 @@ export const zh = {
292
300
  'settings.maxSubtaskDepth': '子任务深度上限',
293
301
  'settings.maxSubtaskDepthHint': '默认 1:一个任务只允许一层子任务,子任务不能再创建或关联子任务。最大 3。执行父任务会并发执行它的整棵子任务树,层级越深,一次执行开启的会话越多。',
294
302
  'settings.maxSubtaskDepthOption': '{depth} 层',
303
+ 'settings.runLaunchWidth': '并发启动上限',
304
+ 'settings.runLaunchWidthHint': '默认 3。一次执行会开启整棵子任务树,但同一刻最多只有这么多个成员在创建会话并入队 Prompt,其余成员排队等待——全部都会启动,只是分批进入运行列。默认值取自本仓「重型实施子代理并发 ≤ 3」的实测纪律。取值 1–16:越大启动越快,Host 的内存与 swap 压力越高。执行中修改只影响尚未入列的启动。',
295
305
  'settings.runBehaviorTitle': '运行行为',
296
306
  'settings.runBehaviorCardHint': '全局原生 /goal 执行开关(总开关,默认关闭)。',
297
307
  'settings.goalRunGlobal': '全局启用 DSH 原生 /goal 执行',
@@ -321,6 +331,11 @@ export const zh = {
321
331
  'settings.goalVerificationEffortFallback': '所选推理强度 {requested} 不被目标模型支持,已回退为 {resolved}。',
322
332
  'settings.goalVerificationEffortFallbackNone': '所选推理强度 {requested} 不被目标模型支持,已回退为该模型默认档位(不显式传参)。',
323
333
  'settings.goalVerificationModelInvalid': '配置的验收模型不是 provider/model 形式,已回退宿主默认。',
334
+ 'settings.cardDefaultsTitle': '新建卡片默认值',
335
+ 'settings.cardDefaultsCardHint': '新建对话框打开时预填的 Agent 预设、权限、模型与思考档位;留空即不设置,创建卡片的行为与未配置时完全一致。它们只是预填,不改变任何既有语义,新建的卡片仍可随时修改;思考档位默认值只在卡片最终钉住的模型就是这里的默认模型时才生效。',
336
+ 'settings.cardDefaultModeHint': '留空不钉住任何值,新建卡片照旧继承部署默认预设。取值优先级为:新建对话框里的显式选择 > 复制源卡或父任务已钉住的值 > 这里的默认值;子任务沿用父卡已钉住的值。',
337
+ 'settings.cardDefaultPermissionHint': '留空为不设置(用会话默认)。高于会话默认权限基线的默认值仍只是预填:卡片执行前照旧需要人工确认。子任务从不沿用父卡的权限,只在父卡未钉住、且这里设置了默认值时才预填。',
338
+ 'settings.cardDefaultModelHint': '留空为宿主默认(agent-default-model)。对话框里的显式选择优先于源卡/父卡已钉住的值,后者优先于这里的默认值;自己选了模型的调用方不借用任何默认档位。',
324
339
  'running.executing': '执行中',
325
340
  'running.verifying': '验收中',
326
341
  'running.repairing': '验收未通过修复中',
@@ -395,7 +410,8 @@ export const zh = {
395
410
  'detail.subtasks.link': '关联现有任务',
396
411
  'detail.subtasks.detach': '解除关联',
397
412
  'detail.subtasks.runningLock': '子任务正在执行,结算后才能解除关联或改挂父任务',
398
- 'detail.subtasks.runHint': '执行本任务会同时并发执行它的 {count} 个子任务;子任务失败不影响其它子任务继续执行。',
413
+ 'detail.subtasks.runHint': '执行本任务会同时并发执行它 {count} 个未完成的子任务;已在完成列的子任务不会重复执行,子任务失败不影响其它子任务继续执行。',
414
+ 'detail.subtasks.runHintSettled': '这个任务的子任务都已在完成列;执行本任务不会重复执行它们,要让某个子任务再跑一次,把它移回待办列或单独执行它。',
399
415
  'detail.subtasks.waiting': '等待子任务结算',
400
416
  'detail.subtasks.depthLimit': '当前子任务深度上限为 {depth} 层,子任务不能再创建或关联子任务。',
401
417
  'detail.subtasks.hostOnly': '连接 Host 后才能创建或关联子任务。',
@@ -677,6 +693,14 @@ export const en: Record<keyof typeof zh, string> = {
677
693
  'exec.permission.danger-full-access': 'Full Access',
678
694
  'exec.model.default': 'Host default (agent-default-model)',
679
695
  'exec.model.unknown': ' (unknown / fallback to default)',
696
+ 'exec.reasoning': 'Reasoning effort',
697
+ 'exec.reasoning.inherit': 'Model or Host default',
698
+ 'exec.reasoning.unknown': ' (unknown effort)',
699
+ 'exec.reasoning.hint.pickModel': 'Pin a model first, then choose its reasoning effort.',
700
+ 'exec.reasoning.hint.unknownModel': "The current roster cannot confirm this model's effort levels; the stored value is kept as it is.",
701
+ 'exec.reasoning.hint.noEfforts': 'This model offers no selectable reasoning effort, so the model or Host default always applies.',
702
+ 'exec.reasoning.hint.inherit': 'Leave it empty to follow the model or Host default.',
703
+ 'exec.teamRunAutoHint': 'Once a subtask is created or linked, the parent task turns on Agent Team execution automatically (you can switch it off at any time).',
680
704
  'exec.reuseSession': 'Continue in the same conversation',
681
705
  'exec.teamRun': 'Run with an Agent Team (Lead + teammates)',
682
706
  'exec.teamRunHint': 'On: running this task starts one Lead session and spawns a teammate per subtask inside it, working in parallel (a subtask permission pin does not apply). Off: every member gets its own independent session.',
@@ -722,6 +746,8 @@ export const en: Record<keyof typeof zh, string> = {
722
746
  'settings.maxSubtaskDepth': 'Subtask depth limit',
723
747
  'settings.maxSubtaskDepthHint': 'Default 1: a task may carry one level of subtasks, and a subtask cannot create or link further subtasks. Maximum 3. Running a task also runs its whole subtask tree concurrently, and every extra level opens more sessions per run.',
724
748
  'settings.maxSubtaskDepthOption': '{depth} levels',
749
+ 'settings.runLaunchWidth': 'Concurrent run launches',
750
+ 'settings.runLaunchWidthHint': 'Default 3. A run opens its whole subtree, but only this many members create their session and queue their prompt at the same time; the rest wait their turn. Every member still starts, entering the running column in batches. The default comes from this repository\'s measured guidance for heavy implementation subagents (at most three). Range 1 to 16: a larger width starts runs sooner and costs the Host more memory and swap. Edited during a run, it bounds the launches that have not been admitted yet.',
725
751
  'settings.runBehaviorTitle': 'Run behavior',
726
752
  'settings.runBehaviorCardHint': 'The global native /goal switch (master control, off by default).',
727
753
  'settings.goalRunGlobal': 'Global: run tasks with dsh native /goal',
@@ -750,6 +776,11 @@ export const en: Record<keyof typeof zh, string> = {
750
776
  'settings.goalVerificationRouteMissing': 'The host serves no model catalog, so no judge route resolves: with acceptance on, a goal completion claim is refused and recorded as an acceptance anomaly.',
751
777
  'settings.goalVerificationEffortFallback': 'The selected reasoning effort {requested} is not supported by the target model; it falls back to {resolved}.',
752
778
  'settings.goalVerificationEffortFallbackNone': 'The selected reasoning effort {requested} is not supported by the target model; it falls back to that model default level (not sent explicitly).',
779
+ 'settings.cardDefaultsTitle': 'New-card defaults',
780
+ 'settings.cardDefaultsCardHint': 'What the new-task dialog opens with: agent preset, permission, model and reasoning effort. Blank sets nothing, so creating a card behaves exactly as it does unconfigured. They are only prefills and change no existing meaning, and every new card stays editable; the default reasoning effort applies only while a card ends up on the configured default model.',
781
+ 'settings.cardDefaultModeHint': 'Blank pins nothing: a new card keeps following the deployment default preset. A target resolves as the explicit choice in the new-task dialog, then the value carried over from the source card or parent, then this default; a subtask keeps what its parent already pinned.',
782
+ 'settings.cardDefaultPermissionHint': 'Blank sets nothing (the session default applies). A default above the session permission baseline is still only a prefill: the card still needs a human confirmation before it may run. A subtask never carries its parent permission; this default fills in only where the parent pinned none.',
783
+ 'settings.cardDefaultModelHint': 'Blank is the host default (agent-default-model). An explicit choice in the dialog wins over the value carried over from the source card or parent, which wins over this default; a caller that pins a model of its own borrows no default reasoning level.',
753
784
  'settings.goalVerificationModelInvalid': 'The configured judge model is not a provider/model route; the host default is used instead.',
754
785
  'running.executing': 'Executing',
755
786
  'running.verifying': 'Verifying',
@@ -825,7 +856,8 @@ export const en: Record<keyof typeof zh, string> = {
825
856
  'detail.subtasks.link': 'Link existing task',
826
857
  'detail.subtasks.detach': 'Detach',
827
858
  'detail.subtasks.runningLock': 'This subtask is running; it can be detached or re-linked once it settles',
828
- 'detail.subtasks.runHint': 'Running this task also runs its {count} subtasks concurrently; one subtask failing does not stop the others.',
859
+ 'detail.subtasks.runHint': 'Running this task also runs its {count} unfinished subtasks concurrently; a subtask already in the done column is not run again, and one subtask failing does not stop the others.',
860
+ 'detail.subtasks.runHintSettled': 'Every subtask of this task is already in the done column; running this task does not repeat them. To run one again, move it back to the todo column or run it on its own.',
829
861
  'detail.subtasks.waiting': 'Waiting for subtasks',
830
862
  'detail.subtasks.depthLimit': 'The subtask depth limit is {depth}; a subtask cannot create or link further subtasks.',
831
863
  'detail.subtasks.hostOnly': 'Connect to the Host to create or link subtasks.',
@@ -52,7 +52,6 @@ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
52
52
  user: undefined,
53
53
  revision: undefined,
54
54
  writable: false,
55
- mode: 'host',
56
55
  }
57
56
  }
58
57
 
@@ -0,0 +1,132 @@
1
+ /**
2
+ * New-card defaults: the execution targets a freshly created card starts from,
3
+ * and how they combine with what the caller picked and with the card a subtask
4
+ * or a duplicate is created from.
5
+ *
6
+ * Framework-free (no cordis, no runtime imports) because BOTH halves compile
7
+ * it: the Host resolves the seed when it writes the card, and the browser
8
+ * resolves the same seed to open the new-task dialog on it. One pure function
9
+ * is the only place the rules live, so the dialog and the ledger cannot drift.
10
+ *
11
+ * Every target is resolved in ONE order: what the caller picked wins over the
12
+ * value carried over from the card this one is created from, which wins over
13
+ * the board's new-card defaults, and a target nothing supplies stays blank
14
+ * ("no pin, resolved at run time"). A duplicate is a copy of its source and a
15
+ * subtask is derived from its parent, so both keep the source's own pins and
16
+ * fill only the fields that source left unset.
17
+ *
18
+ * The permission of a PARENT is the one exception: it never participates. The
19
+ * permission is not copied onto a card at creation — doing so would also have
20
+ * to copy the parent's human confirmation to stay runnable, and a detached
21
+ * child would keep both, so one confirmed parent would mint unbounded confirmed
22
+ * elevated roots. It is resolved along the ancestor chain at launch instead
23
+ * (core/subtask.ts), which is what makes detaching remove the inheritance by
24
+ * construction. A duplicate is not a hierarchy link, so it copies the
25
+ * permission like every other target. The board's own default permission is not
26
+ * a parent binding either and does apply to a subtask; it only pre-fills the
27
+ * card, and a value above the session baseline still needs a human
28
+ * confirmation before the card may run.
29
+ *
30
+ * The reasoning level is bound to the model route it was chosen for and never
31
+ * crosses to another one:
32
+ *
33
+ * - a level the caller picked is used as it stands;
34
+ * - a caller that pinned a model of its own inherits no level at all, not
35
+ * even one the same model happens to carry on the source card;
36
+ * - otherwise the source card's level travels with the source value it was
37
+ * stored on, exactly as the card's own model/level pair does, and wins over
38
+ * the board default level, so a board that configures none behaves exactly
39
+ * as the create path always has;
40
+ * - a board default level applies only while the card's final model IS the
41
+ * configured default model, and a blank default model makes it inert —
42
+ * a level the resolved model does not declare could only be refused by
43
+ * model selection at run time.
44
+ */
45
+ import type { TaskRecord } from './tasks.ts'
46
+
47
+ /** The board's configured new-card defaults; a blank target means "not set". */
48
+ export interface CardDefaults {
49
+ /** Agent preset a new card starts from. */
50
+ mode?: string
51
+ /** Permission a new card starts from. */
52
+ permission?: string
53
+ /** Model a new card starts from. */
54
+ model?: string
55
+ /** Reasoning level a new card starts from, for the model above. */
56
+ reasoningEffort?: string
57
+ }
58
+
59
+ /** The card a new one is created from, and how it is derived from it. */
60
+ export interface CardTargetSource {
61
+ /** A copy of the source card, or a subtask of it. */
62
+ kind: 'duplicate' | 'parent'
63
+ /** The source card's execution targets. */
64
+ task: Pick<TaskRecord, 'mode' | 'permission' | 'model' | 'reasoningEffort'>
65
+ }
66
+
67
+ /** The resolved targets of the card about to be created; blank means "unset". */
68
+ export interface CardTargetSeed {
69
+ mode: string
70
+ permission: string
71
+ model: string
72
+ reasoningEffort: string
73
+ }
74
+
75
+ /** Trim one optional target: blank text means "not set", like every stored pin. */
76
+ function textOf(value: string | undefined): string {
77
+ return value?.trim() ?? ''
78
+ }
79
+
80
+ /**
81
+ * Resolve the execution targets a new card starts from.
82
+ * @param defaults - the board's configured new-card defaults, if any.
83
+ * @param source - the card this one is created from (a duplicate or a parent), if any.
84
+ * @param picked - what the caller chose explicitly; a non-blank value wins.
85
+ * @returns the four targets, blank where nothing supplies a value.
86
+ */
87
+ export function resolveCardTargets(
88
+ defaults: CardDefaults | undefined,
89
+ source: CardTargetSource | undefined,
90
+ picked?: Partial<CardTargetSeed>,
91
+ ): CardTargetSeed {
92
+ const defaultMode = textOf(defaults?.mode)
93
+ const defaultPermission = textOf(defaults?.permission)
94
+ const defaultModel = textOf(defaults?.model)
95
+ // A default level with no default model of its own names no route to bind to
96
+ // (only a hand-edited settings document can express that), so it is inert.
97
+ const defaultEffort = defaultModel === '' ? '' : textOf(defaults?.reasoningEffort)
98
+ const chosen = {
99
+ mode: textOf(picked?.mode),
100
+ permission: textOf(picked?.permission),
101
+ model: textOf(picked?.model),
102
+ reasoningEffort: textOf(picked?.reasoningEffort),
103
+ }
104
+ const copied = source === undefined ? undefined : {
105
+ mode: textOf(source.task.mode),
106
+ permission: textOf(source.task.permission),
107
+ model: textOf(source.task.model),
108
+ reasoningEffort: textOf(source.task.reasoningEffort),
109
+ }
110
+
111
+ /** The value carried over from the source card, or the board default. */
112
+ const inherited = (key: 'mode' | 'permission' | 'model'): string => {
113
+ const fallback = key === 'mode' ? defaultMode : key === 'model' ? defaultModel : defaultPermission
114
+ // A parent's binding never travels; a duplicate's does (it is not a link).
115
+ if (copied === undefined || (key === 'permission' && source?.kind === 'parent')) return fallback
116
+ return copied[key] === '' ? fallback : copied[key]
117
+ }
118
+
119
+ const mode = chosen.mode === '' ? inherited('mode') : chosen.mode
120
+ const permission = chosen.permission === '' ? inherited('permission') : chosen.permission
121
+ const model = chosen.model === '' ? inherited('model') : chosen.model
122
+
123
+ let reasoningEffort = chosen.reasoningEffort
124
+ if (reasoningEffort === '' && chosen.model === '') {
125
+ // The caller pinned no model of its own, so the level follows the source
126
+ // pair first and the board default second.
127
+ const sourced = copied === undefined ? '' : copied.reasoningEffort
128
+ reasoningEffort = sourced !== '' ? sourced : (model !== '' && model === defaultModel ? defaultEffort : '')
129
+ }
130
+
131
+ return { mode, permission, model, reasoningEffort }
132
+ }