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

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 (57) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +20 -12
  3. package/README.zh.md +20 -12
  4. package/lib/client.js +1327 -229
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1909 -348
  7. package/lib/types/client/TaskBoardSettingsCard.d.ts +12 -0
  8. package/lib/types/client/index.d.ts +4 -5
  9. package/lib/types/client/locales.d.ts +65 -0
  10. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  11. package/lib/types/client/schedule-zone.d.ts +50 -0
  12. package/lib/types/core/controller.d.ts +10 -11
  13. package/lib/types/core/poll-cadence.d.ts +31 -0
  14. package/lib/types/core/tasks.d.ts +144 -14
  15. package/lib/types/core/use-cases/task-schedule.d.ts +68 -10
  16. package/lib/types/core/verification-budget.d.ts +52 -0
  17. package/lib/types/core/verification.d.ts +376 -9
  18. package/lib/types/host/verification-gate.d.ts +24 -0
  19. package/lib/types/host/verification-runner.d.ts +38 -7
  20. package/lib/types/host-ledger.d.ts +82 -7
  21. package/lib/types/host-runner.d.ts +32 -9
  22. package/lib/types/host-service.d.ts +117 -9
  23. package/lib/types/index.d.ts +54 -0
  24. package/lib/types/protocol.d.ts +50 -12
  25. package/package.json +2 -6
  26. package/src/client/TaskBoardSettingsCard.tsx +74 -1
  27. package/src/client/board/NewTaskModal.tsx +151 -20
  28. package/src/client/board/TaskCard.tsx +3 -3
  29. package/src/client/board/TaskDetail.tsx +219 -18
  30. package/src/client/board/VerificationReport.tsx +132 -13
  31. package/src/client/board/status-key.ts +3 -0
  32. package/src/client/index.ts +43 -50
  33. package/src/client/locales.ts +137 -4
  34. package/src/client/plugin-card-seat.ts +7 -0
  35. package/src/client/schedule-zone.ts +100 -2
  36. package/src/core/controller.ts +12 -8
  37. package/src/core/poll-cadence.ts +41 -0
  38. package/src/core/store.ts +64 -15
  39. package/src/core/tasks.ts +193 -39
  40. package/src/core/use-cases/task-create.ts +30 -9
  41. package/src/core/use-cases/task-schedule.ts +190 -25
  42. package/src/core/use-cases/task-update.ts +3 -3
  43. package/src/core/verification-budget.ts +115 -0
  44. package/src/core/verification.ts +785 -17
  45. package/src/host/agent-tools.ts +106 -21
  46. package/src/host/verification-gate.ts +172 -16
  47. package/src/host/verification-runner.ts +207 -40
  48. package/src/host-ledger.ts +328 -42
  49. package/src/host-runner.ts +52 -19
  50. package/src/host-service.ts +299 -36
  51. package/src/index.ts +101 -5
  52. package/src/protocol.ts +132 -28
  53. package/lib/invariant.js +0 -6
  54. package/lib/types/client/board-card-seat.d.ts +0 -34
  55. package/lib/types/invariant.d.ts +0 -3
  56. package/src/client/board-card-seat.ts +0 -102
  57. package/src/invariant.ts +0 -4
@@ -33,7 +33,7 @@ import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardS
33
33
  import { en, zh, setRuntimeTranslate, type TaskBoardKey } from './locales.ts'
34
34
  import { HttpTaskBoardHostTransport } from './host-api.ts'
35
35
  import { reportDailyHeartbeat } from './telemetry.ts'
36
- import { installBoardCard } from './board-card-seat.ts'
36
+ import { installPluginCard } from './plugin-card-seat.ts'
37
37
  import { createServedEntryForm } from './settings-entry-form.ts'
38
38
  import { TaskBoardClientService } from './service.ts'
39
39
  import {
@@ -133,15 +133,17 @@ declare module '@deepseek-ai/cordis' {
133
133
 
134
134
  /**
135
135
  * Required services (fiber inject waiting — the runtime must be up first).
136
- * The generated remote faces are probed at use time instead of injected:
137
- * `remote.agentPresets` only registers on 0.1.2-alpha.2 hosts (the
138
- * api-remotes contribution), so a hard wait would pend the entry forever
139
- * on hosts below that cohort, which serve the same roster through the
140
- * connection RPC face.
136
+ * The generated remote namespaces are not all hard injects: `remote.session`
137
+ * is one, while `remote.agentPresets` is reached through a scoped inject at
138
+ * use time (see mountUi), because the namespace mounts with the official
139
+ * api-remotes assembly applied beside this entry rather than before it.
141
140
  */
142
141
  export const inject = ['slots', 'sessions', 'workspaces', 'connection', 'configForms', 'locale', 'remote', 'remote.session', 'uiWorkspace', 'layout']
143
142
 
144
- /** One agent-preset row the mode picker consumes (either face's wire shape). */
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. */
145
147
  interface PresetRosterRow {
146
148
  id: string
147
149
  name?: string
@@ -152,41 +154,17 @@ interface PresetRosterRow {
152
154
  }
153
155
 
154
156
  /**
155
- * Read the agent-preset roster through whichever face the running host
156
- * serves: the generated api-remotes face (`remote.agentPresets`,
157
- * 0.1.2-alpha.2) or the connection RPC face
158
- * (`connection.api.agentPresets`, hosts below that cohort). Answers
159
- * undefined when the host serves neither, so the caller leaves the picker
160
- * options untouched instead of erroring.
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.
161
163
  */
162
- async function readPresetRoster(
163
- ctx: ClientContext,
164
- remote: ClientRemote,
165
- ): Promise<{ ok: boolean; presets: readonly PresetRosterRow[] } | undefined> {
166
- // The cordis `remote` proxy throws on a property that was never injected
167
- // ("cannot get property X without inject") rather than returning undefined,
168
- // so the probe must guard the access — a hard read would abort mounting on
169
- // hosts below the 0.1.2-alpha.2 cohort instead of degrading to the legacy
170
- // connection RPC face below.
171
- let remotes: ClientRemote['agentPresets'] | undefined
172
- try {
173
- remotes = (remote as Partial<ClientRemote>).agentPresets
174
- } catch {
175
- remotes = undefined
176
- }
177
- if (remotes !== undefined) {
178
- const response = await remotes.list()
179
- if (!response.ok) return { ok: false, presets: [] }
180
- return { ok: true, presets: response.value.presets }
181
- }
182
- const connection = ctx.get('connection') as unknown as {
183
- api?: { agentPresets?: { list(request: Record<string, never>): Promise<{ result: { ok: boolean; value?: { presets?: readonly PresetRosterRow[] } } }> } }
184
- }
185
- const legacy = connection.api?.agentPresets
186
- if (legacy === undefined) return undefined
187
- const response = await legacy.list({})
188
- if (!response.result.ok || response.result.value === undefined) return { ok: false, presets: [] }
189
- return { ok: true, presets: response.result.value.presets ?? [] }
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 }
190
168
  }
191
169
 
192
170
  /**
@@ -238,7 +216,7 @@ export function apply(ctx: ClientContext): void {
238
216
  ctx.effect(() => () => { clientService.detach() }, 'task-board: extension service')
239
217
 
240
218
  const settingsCard = new TaskBoardSettingsCardController(settingsForm, request => clientService.dispatch(request))
241
- installBoardCard(ctx, {
219
+ installPluginCard(ctx, {
242
220
  bundle: '@linxin666/dsh-client-ui-task-board',
243
221
  id: 'task-board',
244
222
  order: 110,
@@ -321,10 +299,10 @@ export function apply(ctx: ClientContext): void {
321
299
  const created = await workspaces.create({ path })
322
300
  return { workspaceId: created.workspaceId }
323
301
  })
324
- const pushPresetOptions = async (): Promise<void> => {
302
+ const pushPresetOptions = async (presetRemote: PresetRemote): Promise<void> => {
325
303
  try {
326
- const roster = await readPresetRoster(ctx, remote)
327
- if (roster === undefined || !roster.ok) return
304
+ const roster = await readPresetRoster(presetRemote)
305
+ if (!roster.ok) return
328
306
  controller.setExecutionOptions({
329
307
  presets: roster.presets.map(preset => ({
330
308
  id: preset.id,
@@ -340,6 +318,25 @@ export function apply(ctx: ClientContext): void {
340
318
  console.error('[dsh-task-board] agent preset roster read failed', error)
341
319
  }
342
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() })
343
340
  const pushModelOptions = async (): Promise<void> => {
344
341
  try {
345
342
  let models: Array<{ id: string; name?: string; provider?: string }> = []
@@ -404,12 +401,8 @@ export function apply(ctx: ClientContext): void {
404
401
  console.error('[dsh-task-board] model options read failed', error)
405
402
  }
406
403
  }
407
- void pushPresetOptions()
408
404
  void pushModelOptions()
409
- disposers.push(ctx.on('connection/reset', () => {
410
- void pushPresetOptions()
411
- void pushModelOptions()
412
- }))
405
+ disposers.push(ctx.on('connection/reset', () => { void pushModelOptions() }))
413
406
  // Native panel surfaces: one sidebar row and one center-column page, both
414
407
  // through the official slot seats the shell itself renders. The layout's
415
408
  // panel selection is reconciled back into the controller so the board's own
@@ -152,6 +152,7 @@ export const zh = {
152
152
  'detail.noSession': '暂无会话',
153
153
  'detail.executionStarted': '已启动',
154
154
  'detail.execution.initiator': '发起会话 {session}',
155
+ 'detail.execution.external': 'Host 之外完成:{agent}',
155
156
  'detail.executionEnded': '已结束',
156
157
  'detail.result.succeeded': '成功',
157
158
  'detail.result.failed': '失败',
@@ -194,7 +195,35 @@ export const zh = {
194
195
  'detail.schedule.duration.hours': '{count} 小时',
195
196
  'detail.schedule.duration.minutes': '{count} 分钟',
196
197
  'detail.schedule.duration.seconds': '{count} 秒',
198
+ // 调度方式:单次计划与循环规则是两类计划,不是同一 cron 的日期限定写法。
199
+ 'detail.schedule.mode': '调度方式',
200
+ 'detail.schedule.mode.cron': '循环',
201
+ 'detail.schedule.mode.once': '单次',
202
+ 'detail.schedule.at': '执行时间',
203
+ 'detail.schedule.atHint': '按所选时区解读的计划时刻;必须是未来时间。',
204
+ 'detail.schedule.atInvalid': '请选择未来的有效时间',
205
+ 'detail.schedule.plannedAt': '计划时间',
206
+ 'detail.schedule.zoneOnceHint': '所选时刻按此时区解读。',
207
+ 'detail.schedule.maxRuns': '次数上限',
208
+ 'detail.schedule.maxRunsUnlimited': '不限次数',
209
+ 'detail.schedule.maxRunsValue': '最多 {count} 次',
210
+ 'detail.schedule.maxRunsCustom': '自定义',
211
+ 'detail.schedule.maxRunsInvalid': '次数上限必须是正整数',
212
+ 'detail.schedule.runs': '已执行 {done}/{total} 次',
213
+ 'detail.schedule.runsUnlimited': '已执行 {done} 次',
214
+ 'detail.schedule.remaining': '剩余 {count} 次',
215
+ 'detail.schedule.cancel': '关闭调度',
216
+ 'detail.schedule.ended': '调度已结束:{reason}',
217
+ 'detail.schedule.skipped': '上次跳过:{reason}',
218
+ 'detail.schedule.stop.fired': '单次计划已执行',
219
+ 'detail.schedule.stop.limit': '已达次数上限',
220
+ 'detail.schedule.stop.no-target': '计划已无可执行时刻',
221
+ 'detail.schedule.stop.missed': '错过执行时刻,已跳过',
222
+ 'detail.schedule.stop.busy': '触发时任务正在运行,已跳过',
223
+ 'detail.schedule.stop.permission': '权限待人工确认,已跳过',
224
+ 'detail.schedule.stop.launch-failed': '启动失败未创建执行,次数已退回',
197
225
  'card.scheduled': '定时',
226
+ 'card.scheduledOnce': '单次',
198
227
  'new.workspace': '工作区',
199
228
  'new.agentPreset': 'Agent 预设',
200
229
  'new.permission': '权限',
@@ -224,7 +253,7 @@ export const zh = {
224
253
  'exec.teamRunUnavailable': '当前部署未提供 Agent Teams 服务,无法启用。',
225
254
  'exec.reuseSessionHint': '开启后,本任务的后续执行在上一次会话里继续(该会话空闲且仍存在时),不再每次新建对话;每次复用时都会重新应用上面钉住的权限与模型。',
226
255
  'exec.goalRun': '以 dsh 内置的 /goal 开始执行任务',
227
- 'exec.goalRunHint': '默认开启:执行时把任务目标作为持久目标交给 dsh 内置的 /goal,会话会自动续跑多轮,直到 agent 标记目标完成;看板在该目标真正结束后才结算本次执行。关闭则只执行一轮普通对话。',
256
+ 'exec.goalRunHint': '默认关闭:勾选后执行时把任务目标作为持久目标交给 dsh 内置的 /goal,会话会自动续跑多轮,直到 agent 标记目标完成;看板在该目标真正结束后才结算本次执行。不勾选则只执行一轮普通对话。',
228
257
  'exec.skipVerification': '跳过本任务的验收',
229
258
  'exec.skipVerificationHint': '默认关闭:勾选后本任务以 goal 形式执行时不再拦截 update_goal 标记完成,也不要求通过验收;执行记录会标注「该卡片跳过验收」。仅对之后新开的执行生效,验收总开关关闭时本项无意义。',
230
259
  'detail.executionSettings': '执行设置',
@@ -258,9 +287,16 @@ export const zh = {
258
287
  'settings.unsaved': '未保存',
259
288
  'settings.saveFailed': '部署未接受这些值,已保留供你修改。',
260
289
  'settings.invalidNumber': '请输入数字,留空则使用默认值。',
290
+ 'settings.sessionPollSeconds': '会话轮询间隔(秒)',
291
+ 'settings.sessionPollSecondsHint': '默认 5 秒。仅在看板有需要核对的事情——运行中的卡片或未结算的执行——时才读取会话名册;两者都没有时不再轮询,Host 不会持续扫描持久化会话目录。取值 1–300 秒:间隔越短结算越快、Host 负载越高。',
261
292
  'settings.maxSubtaskDepth': '子任务深度上限',
262
293
  'settings.maxSubtaskDepthHint': '默认 1:一个任务只允许一层子任务,子任务不能再创建或关联子任务。最大 3。执行父任务会并发执行它的整棵子任务树,层级越深,一次执行开启的会话越多。',
263
294
  'settings.maxSubtaskDepthOption': '{depth} 层',
295
+ 'settings.runBehaviorTitle': '运行行为',
296
+ 'settings.runBehaviorCardHint': '全局原生 /goal 执行开关(总开关,默认关闭)。',
297
+ 'settings.goalRunGlobal': '全局启用 DSH 原生 /goal 执行',
298
+ 'settings.goalRunGlobalHint': '默认关闭。关闭时所有任务都按普通单回合执行,不调用 dsh 内置的 /goal;只有这里开启、且任务级「以 /goal 开始执行」被勾选时,才执行原生 /goal。任务级勾选始终可操作、也不会被这里的开关改写,总开关只决定它是否生效。开关按执行启动时冻结,只影响之后新开的执行。',
299
+ 'settings.goalRunGlobalDisabledTaskOption': '全局「原生 /goal 执行」总开关已关闭:任务级选项仍可随时勾选或取消,但暂不生效,你保存的偏好也不会被改写;开启总开关后即按该偏好执行。本次执行按普通单回合进行,未执行 goal 验收。',
264
300
  'settings.goalVerificationTitle': '任务验收',
265
301
  'settings.goalVerification': '启用任务验收',
266
302
  'settings.goalVerificationCardHint': '验收开关、裁判模型与判据阈值。',
@@ -290,6 +326,7 @@ export const zh = {
290
326
  'running.repairing': '验收未通过修复中',
291
327
  'running.verificationPassed': '验收通过',
292
328
  'running.verificationFailed': '验收未通过',
329
+ 'running.verificationInvalid': '验收无效,等待处理',
293
330
  'verify.title': '验收报告',
294
331
  'verify.status.passed': '通过',
295
332
  'verify.status.failed': '未通过',
@@ -297,6 +334,34 @@ export const zh = {
297
334
  'verify.status.verifying': '验收中',
298
335
  'verify.status.pending': '待验收',
299
336
  'verify.status.off': '本次执行未启用验收',
337
+ 'verify.status.invalid': '验收无效',
338
+ 'verify.status.invalidHeld': '验收无效,等待人工处理',
339
+ 'verify.attemptInvalid': '第 {index} 次验收无效',
340
+ 'verify.invalidReason.missing-finding': '未达标的判据没有对应的结构化问题反馈',
341
+ 'verify.invalidReason.unlocatable-quote': '问题反馈引用的位置不存在于本次裁判所见的证据中',
342
+ 'verify.invalidReason.baseline-finding': '问题反馈指向空工作基线,而不是本次执行的工作',
343
+ 'verify.invalidReason.vacuous-finding': '问题反馈为空或空泛评价,没有说明实际观测与差异',
344
+ 'verify.invalidReason.insufficient-evidence': '裁判看到的证据不足(例如轨迹被截断),无法支撑否决',
345
+ 'verify.invalidReason.unknown': '未产生可用的质量判定',
346
+ 'verify.invalidCounts': '有效质量判定 {quality}/{max} · 无效验收 {invalid}/{maxInvalid} · 验收异常 {exceptions}/{max}',
347
+ 'verify.invalidDetail': '无效原因:{reason}',
348
+ 'verify.criterionFindings': '结构化问题反馈(判据 · 引用位置 · 实际引用)',
349
+ 'verify.criterionFinding': '{criterion} · {location}:“{quote}”',
350
+ 'verify.criterionFindingFull': '{criterion} · {location}:“{quote}” 要求:{requirement} 观测:{observation} 差异:{gap}',
351
+ 'verify.location.task': '任务要求',
352
+ 'verify.location.trajectory': '执行轨迹',
353
+ 'verify.location.workspace': '宿主记录的变更',
354
+ 'verify.location.baseline': '空工作基线(不构成否决证据)',
355
+ 'verify.location.unknown': '未标明位置',
356
+ 'verify.detailRemoved': '本条验收的详细材料已在通过并成功结算后自动清理(不是裁判未举证)。',
357
+ 'verify.cleanup.cleaned': '本次执行的验收专用详细材料已在通过并成功结算后自动清理;以下为保留的轻量审计凭据。',
358
+ 'verify.cleanup.pending': '验收专用详细材料待清理(将通过并结算后启动)。',
359
+ 'verify.cleanup.failed': '验收详细材料清理失败(已重试 {attempts} 次):{error};通过记录仍然有效,宿主重启后会补清。',
360
+ 'verify.cleanup.audit': '保留凭据:第 {index} 次 · {stage} · 分数 {score} · 证据哈希 {hash}',
361
+ 'verify.cleanup.stage.quality': '质量判定',
362
+ 'verify.cleanup.stage.invalid': '无效验收',
363
+ 'verify.cleanup.stage.exception': '验收异常',
364
+ 'verify.cleanup.stage.budget': '时间预算耗尽',
300
365
  'verify.summary': '总分 {score}(阈值 {threshold})· 空工作基线 {baseline}',
301
366
  'verify.criteria': '逐项判据',
302
367
  'verify.criterion': '{name}:{score}(阈值 {threshold})',
@@ -315,9 +380,10 @@ export const zh = {
315
380
  'verify.usageIncomplete': '(至少一次请求的用量未知,以上为下限)',
316
381
  'verify.finalFailure': '判定依据:{reason}',
317
382
  'verify.applicability.disabled': '本次执行启动时验收开关关闭,按原有回合判定结算。',
318
- 'verify.applicability.goalUnavailable': '本次执行未成为 goal 执行(/goal 被拒绝或不可用),验收未强制执行。',
383
+ 'verify.applicability.goalUnavailable': '本次执行未成为 goal 执行(卡片未勾选「以 /goal 开始执行任务」,或 /goal 被拒绝/不可用),验收未强制执行。',
319
384
  'verify.applicability.skipped': '该任务卡勾选了「跳过验收」,本次执行未经验收判定。',
320
385
  'verify.applicability.teamMember': '团队执行成员:由 Lead 的团队汇总证据统一验收。',
386
+ 'verify.applicability.goalDisabled': '本次执行启动时全局「原生 /goal 执行」总开关关闭,按普通单回合执行:本次未执行 goal 验收,既不算已验收通过,也不因此判失败。',
321
387
  'verify.effortFallback': '推理强度回退:{requested} → {resolved}',
322
388
  'verify.thresholdValue': '{value}%',
323
389
  // 子任务(父子关系、级联执行、关联/解除关联)。
@@ -515,6 +581,7 @@ export const en: Record<keyof typeof zh, string> = {
515
581
  'detail.noSession': 'No session',
516
582
  'detail.executionStarted': 'Started',
517
583
  'detail.execution.initiator': 'Initiated by session {session}',
584
+ 'detail.execution.external': 'Completed outside this Host by {agent}',
518
585
  'detail.executionEnded': 'Ended',
519
586
  'detail.result.succeeded': 'Succeeded',
520
587
  'detail.result.failed': 'Failed',
@@ -557,7 +624,36 @@ export const en: Record<keyof typeof zh, string> = {
557
624
  'detail.schedule.duration.hours': '{count}h',
558
625
  'detail.schedule.duration.minutes': '{count}m',
559
626
  'detail.schedule.duration.seconds': '{count}s',
627
+ // Scheduling mode: a one-shot and a recurring rule are two kinds of plan,
628
+ // not the same cron expression restricted to a date.
629
+ 'detail.schedule.mode': 'Mode',
630
+ 'detail.schedule.mode.cron': 'Recurring',
631
+ 'detail.schedule.mode.once': 'Once',
632
+ 'detail.schedule.at': 'Run at',
633
+ 'detail.schedule.atHint': 'Planned instant, read in the selected zone; it must be in the future.',
634
+ 'detail.schedule.atInvalid': 'Choose a valid future time',
635
+ 'detail.schedule.plannedAt': 'Planned time',
636
+ 'detail.schedule.zoneOnceHint': 'The chosen instant is read in this zone.',
637
+ 'detail.schedule.maxRuns': 'Run limit',
638
+ 'detail.schedule.maxRunsUnlimited': 'Unlimited',
639
+ 'detail.schedule.maxRunsValue': 'At most {count} runs',
640
+ 'detail.schedule.maxRunsCustom': 'Custom',
641
+ 'detail.schedule.maxRunsInvalid': 'The run limit must be a positive whole number',
642
+ 'detail.schedule.runs': 'Ran {done}/{total}',
643
+ 'detail.schedule.runsUnlimited': 'Ran {done}',
644
+ 'detail.schedule.remaining': '{count} left',
645
+ 'detail.schedule.cancel': 'Cancel schedule',
646
+ 'detail.schedule.ended': 'Schedule ended: {reason}',
647
+ 'detail.schedule.skipped': 'Last skip: {reason}',
648
+ 'detail.schedule.stop.fired': 'the one-shot ran',
649
+ 'detail.schedule.stop.limit': 'the run limit was reached',
650
+ 'detail.schedule.stop.no-target': 'the plan has no reachable occurrence',
651
+ 'detail.schedule.stop.missed': 'the instant was missed and skipped',
652
+ 'detail.schedule.stop.busy': 'the task was already running, so the occurrence was skipped',
653
+ 'detail.schedule.stop.permission': 'a permission awaited human confirmation, so the occurrence was skipped',
654
+ 'detail.schedule.stop.launch-failed': 'the launch failed before any execution existed, so the run was refunded',
560
655
  'card.scheduled': 'scheduled',
656
+ 'card.scheduledOnce': 'once',
561
657
  'new.workspace': 'Workspace',
562
658
  'new.agentPreset': 'Agent preset',
563
659
  'new.permission': 'Permission',
@@ -587,7 +683,7 @@ export const en: Record<keyof typeof zh, string> = {
587
683
  'exec.teamRunUnavailable': 'This deployment serves no Agent Teams service, so team runs cannot be enabled.',
588
684
  'exec.reuseSessionHint': 'When on, later runs continue in the previous session (when that session is idle and still exists) instead of starting a new conversation each time; the pinned permission and model above are re-applied on every reuse.',
589
685
  'exec.goalRun': "Start the run with dsh's built-in /goal",
590
- 'exec.goalRunHint': 'On by default: the run arms dsh built-in /goal with the task objective, so the session keeps working automatic continuation rounds until the agent marks the goal complete, and the board settles the execution only when that goal really ends. Off: one plain turn.',
686
+ 'exec.goalRunHint': 'Off by default: when checked, the run arms dsh built-in /goal with the task objective, so the session keeps working automatic continuation rounds until the agent marks the goal complete, and the board settles the execution only when that goal really ends. Unchecked: one plain turn.',
591
687
  'exec.skipVerification': 'Skip acceptance for this task',
592
688
  'exec.skipVerificationHint': 'Off by default: when checked, a goal-form run of this task no longer gates update_goal on a passing acceptance, and the execution record is marked as skipped. It applies only to executions started afterwards, and means nothing while the board-wide acceptance switch is off.',
593
689
  'detail.executionSettings': 'Execution Settings',
@@ -621,9 +717,16 @@ export const en: Record<keyof typeof zh, string> = {
621
717
  'settings.unsaved': 'Unsaved',
622
718
  'settings.saveFailed': 'The deployment did not accept these values; they were left for you to correct.',
623
719
  'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
720
+ 'settings.sessionPollSeconds': 'Session poll interval (seconds)',
721
+ 'settings.sessionPollSecondsHint': 'Default 5. The Host reads the DSH session roster only while the board has something to reconcile: a running card or an open execution. With neither it stops polling and never re-scans the persisted session directory. Range 1 to 300 seconds: a shorter interval settles runs sooner and costs the Host more.',
624
722
  'settings.maxSubtaskDepth': 'Subtask depth limit',
625
723
  '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.',
626
724
  'settings.maxSubtaskDepthOption': '{depth} levels',
725
+ 'settings.runBehaviorTitle': 'Run behavior',
726
+ 'settings.runBehaviorCardHint': 'The global native /goal switch (master control, off by default).',
727
+ 'settings.goalRunGlobal': 'Global: run tasks with dsh native /goal',
728
+ 'settings.goalRunGlobalHint': 'Off by default. While off, every task runs one plain turn and never calls dsh built-in /goal; a run uses native /goal only when this is on AND the task-level option is checked (see the task option below). The task-level checkbox always stays operable and this switch never rewrites it: the master switch only decides whether that choice takes effect. The switch is frozen when an execution starts, so it only affects runs opened afterwards.',
729
+ 'settings.goalRunGlobalDisabledTaskOption': 'The global native /goal switch is off: the task-level option can still be checked or cleared, but it has no effect right now and the preference you save is never rewritten; turning the global switch back on applies it. This execution runs one plain turn and performs no goal acceptance.',
627
730
  'settings.goalVerificationTitle': 'Task acceptance',
628
731
  'settings.goalVerification': 'Enable task acceptance',
629
732
  'settings.goalVerificationCardHint': 'The switch, the judge model and the criteria threshold.',
@@ -653,6 +756,7 @@ export const en: Record<keyof typeof zh, string> = {
653
756
  'running.repairing': 'Fixing a failed acceptance',
654
757
  'running.verificationPassed': 'Acceptance passed',
655
758
  'running.verificationFailed': 'Acceptance failed',
759
+ 'running.verificationInvalid': 'Invalid acceptance, waiting for a human',
656
760
  'verify.title': 'Acceptance report',
657
761
  'verify.status.passed': 'Passed',
658
762
  'verify.status.failed': 'Failed',
@@ -660,6 +764,34 @@ export const en: Record<keyof typeof zh, string> = {
660
764
  'verify.status.verifying': 'Verifying',
661
765
  'verify.status.pending': 'Not yet verified',
662
766
  'verify.status.off': 'Acceptance was off for this execution',
767
+ 'verify.status.invalid': 'Invalid acceptance',
768
+ 'verify.status.invalidHeld': 'Invalid acceptance, waiting for a human',
769
+ 'verify.attemptInvalid': 'Invalid acceptance {index}',
770
+ 'verify.invalidReason.missing-finding': 'A failing criterion carries no structured finding of its own',
771
+ 'verify.invalidReason.unlocatable-quote': 'A finding cites a passage that is not in the evidence the judge saw',
772
+ 'verify.invalidReason.baseline-finding': 'A finding points at the empty-work baseline instead of this execution\'s work',
773
+ 'verify.invalidReason.vacuous-finding': 'A finding is empty or a generic evaluation with no observation or difference',
774
+ 'verify.invalidReason.insufficient-evidence': 'The judge saw too little evidence (for example a truncated trace) to support a veto',
775
+ 'verify.invalidReason.unknown': 'No usable quality verdict was produced',
776
+ 'verify.invalidCounts': 'Valid quality verdicts {quality}/{max} · invalid acceptances {invalid}/{maxInvalid} · anomalies {exceptions}/{max}',
777
+ 'verify.invalidDetail': 'Invalid because: {reason}',
778
+ 'verify.criterionFindings': 'Structured findings (criterion · cited location · verbatim citation)',
779
+ 'verify.criterionFinding': '{criterion} · {location}: "{quote}"',
780
+ 'verify.criterionFindingFull': '{criterion} · {location}: "{quote}" requirement: {requirement} observed: {observation} gap: {gap}',
781
+ 'verify.location.task': 'task statement',
782
+ 'verify.location.trajectory': 'execution trajectory',
783
+ 'verify.location.workspace': 'host-recorded changes',
784
+ 'verify.location.baseline': 'empty-work baseline (not evidence against the work)',
785
+ 'verify.location.unknown': 'location not named',
786
+ 'verify.detailRemoved': 'This attempt detail was cleaned up automatically after the execution passed and settled (the judge did report evidence).',
787
+ 'verify.cleanup.cleaned': 'The acceptance-only detail of this execution was cleaned up automatically after it passed and settled; what remains is the lightweight audit credential.',
788
+ 'verify.cleanup.pending': 'The acceptance-only detail is pending cleanup (started once the execution passes and settles).',
789
+ 'verify.cleanup.failed': 'Cleaning the acceptance detail failed after {attempts} attempts: {error}. The pass record is still valid and the host retries on its next start.',
790
+ 'verify.cleanup.audit': 'Kept credential: attempt {index} · {stage} · score {score} · evidence hash {hash}',
791
+ 'verify.cleanup.stage.quality': 'quality verdict',
792
+ 'verify.cleanup.stage.invalid': 'invalid acceptance',
793
+ 'verify.cleanup.stage.exception': 'acceptance anomaly',
794
+ 'verify.cleanup.stage.budget': 'time budget exhausted',
663
795
  'verify.summary': 'Total {score} (threshold {threshold}) · empty-work baseline {baseline}',
664
796
  'verify.criteria': 'Per-criterion scores',
665
797
  'verify.criterion': '{name}: {score} (threshold {threshold})',
@@ -678,9 +810,10 @@ export const en: Record<keyof typeof zh, string> = {
678
810
  'verify.usageIncomplete': ' (at least one request usage is unknown; these counts are a floor)',
679
811
  'verify.finalFailure': 'Verdict basis: {reason}',
680
812
  'verify.applicability.disabled': 'Acceptance was off when this execution started; it settles on the historical verdict.',
681
- 'verify.applicability.goalUnavailable': 'This execution never became a goal run (/goal was refused or unavailable), so acceptance was not enforced.',
813
+ 'verify.applicability.goalUnavailable': 'This execution never became a goal run (the card did not check Start with /goal, or /goal was refused or unavailable), so acceptance was not enforced.',
682
814
  'verify.applicability.skipped': 'This card checked Skip acceptance, so the execution was never judged.',
683
815
  'verify.applicability.teamMember': 'Team member: the Lead team-summary evidence is accepted as one execution.',
816
+ 'verify.applicability.goalDisabled': 'The global native /goal switch was off when this execution started, so it ran one plain turn: no goal acceptance was performed, which neither counts as a pass nor fails the run.',
684
817
  'verify.effortFallback': 'Reasoning effort fallback: {requested} → {resolved}',
685
818
  'verify.thresholdValue': '{value}%',
686
819
  // Subtasks (parent/child links, cascade runs, link/detach).
@@ -86,6 +86,11 @@ export interface PluginCardSeat {
86
86
  label?: () => string
87
87
  /** Locale namespace the card renders with. */
88
88
  locale: string
89
+ /**
90
+ * Child slots this card declares and renders. Forwarded to whichever seat
91
+ * the host renders; absent for a card that declares none.
92
+ */
93
+ children?: Record<string, unknown>
89
94
  /** Business-face factory of the registration. */
90
95
  inject?: () => object
91
96
  /**
@@ -162,12 +167,14 @@ export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat):
162
167
  ...(seat.order === undefined ? {} : { order: seat.order }),
163
168
  ...(seat.label === undefined ? {} : { label: seat.label }),
164
169
  locale: seat.locale,
170
+ ...(seat.children === undefined ? {} : { children: seat.children }),
165
171
  ...(seat.inject === undefined ? {} : { inject }),
166
172
  }
167
173
  : {
168
174
  name: OFFICIAL_PLUGIN_CARD_SEAT,
169
175
  key: seat.bundle,
170
176
  locale: seat.locale,
177
+ ...(seat.children === undefined ? {} : { children: seat.children }),
171
178
  ...(seat.inject === undefined ? {} : { inject }),
172
179
  }) as never, component) as () => void
173
180
  current = target
@@ -7,8 +7,9 @@
7
7
  * the Host schedules with (`core/schedule.ts`), so the preview a user sees is
8
8
  * computed by the code that will actually arm the rule.
9
9
  */
10
- import { isValidTimeZone } from '../core/schedule.ts'
11
- import { t } from './locales.ts'
10
+ import { isValidTimeZone, partsInZone, resolveHostTimeZone, zonedEpoch } from '../core/schedule.ts'
11
+ import { scheduleRunBudget, type ScheduleRule, type ScheduleStopReason } from '../core/tasks.ts'
12
+ import { t, type TaskBoardKey } from './locales.ts'
12
13
 
13
14
  /** One selectable zone, in the order the picker shows it. */
14
15
  export interface ZoneChoice {
@@ -112,6 +113,103 @@ export function relativeTimeLabel(targetMs: number, now: number): string {
112
113
  return `${durationParts(delta)}`
113
114
  }
114
115
 
116
+ /** Zero-pad a calendar/clock field for a `datetime-local` value. */
117
+ function pad(value: number): string {
118
+ return String(value).padStart(2, '0')
119
+ }
120
+
121
+ /**
122
+ * The `datetime-local` input value for an instant, read in an explicit zone:
123
+ * `YYYY-MM-DDTHH:mm`. Uses the shared engine's zone reader, so the editor
124
+ * shows the same wall clock the Host schedules by.
125
+ * @param epochMs - the instant to render.
126
+ * @param timeZone - zone to read it in; absent uses the Host zone.
127
+ * @returns the input value.
128
+ */
129
+ export function zonedInputValue(epochMs: number, timeZone?: string): string {
130
+ const parts = partsInZone(usableZone(timeZone), epochMs)
131
+ return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`
132
+ }
133
+
134
+ /**
135
+ * Parse a `datetime-local` input value back to an instant, read in an explicit
136
+ * zone. Returns undefined when the text is not a whole calendar wall clock or
137
+ * names one the zone skips (a spring-forward gap), so the editor can refuse an
138
+ * unusable instant instead of silently moving it.
139
+ * @param value - the input text.
140
+ * @param timeZone - zone to interpret it in; absent uses the Host zone.
141
+ * @returns the instant, or undefined when it is not a usable wall clock.
142
+ */
143
+ export function parseZonedInput(value: string, timeZone?: string): number | undefined {
144
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim())
145
+ if (match === null) return undefined
146
+ const [year, month, day, hour, minute] = match.slice(1).map(Number)
147
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59) return undefined
148
+ return zonedEpoch(usableZone(timeZone), year, month, day, hour, minute)
149
+ }
150
+
151
+ /**
152
+ * The default instant a new one-shot editor opens on: the next whole hour,
153
+ * rendered in the zone the rule will be read in.
154
+ * @param now - reference instant.
155
+ * @param timeZone - zone the rule is read in.
156
+ * @returns the input value for a near-future default.
157
+ */
158
+ export function defaultZonedInputValue(now: number, timeZone?: string): string {
159
+ const nextHour = Math.ceil(now / 3_600_000) * 3_600_000
160
+ return zonedInputValue(nextHour, timeZone)
161
+ }
162
+
163
+ /** The zone the input helpers resolve against: an explicit resolved zone, or the Host zone. */
164
+ function usableZone(timeZone: string | undefined): string {
165
+ return timeZone !== undefined && isValidTimeZone(timeZone) ? timeZone : resolveHostTimeZone()
166
+ }
167
+
168
+ /**
169
+ * The editor's run-cap option for a stored cap: the two named presets, the
170
+ * unlimited entry, or a custom value the editor shows in its own field.
171
+ * @param maxRuns - the stored cap, when any.
172
+ * @returns the select option value.
173
+ */
174
+ export function runCapOption(maxRuns: number | undefined): string {
175
+ if (maxRuns === undefined) return 'unlimited'
176
+ if (maxRuns === 1) return '1'
177
+ if (maxRuns === 2) return '2'
178
+ return 'custom'
179
+ }
180
+
181
+ /** The locale key describing why a rule stopped or skipped an occurrence. */
182
+ export function stopReasonKey(reason: ScheduleStopReason): TaskBoardKey {
183
+ return `detail.schedule.stop.${reason}` as TaskBoardKey
184
+ }
185
+
186
+ /**
187
+ * The run-budget line: how many scheduled executions the Host opened and how
188
+ * many remain. Derived from the counter and the cap rather than stored, so the
189
+ * display cannot drift from the ledger.
190
+ * @param rule - the schedule rule.
191
+ * @returns localized wording.
192
+ */
193
+ export function budgetLabel(rule: ScheduleRule): string {
194
+ const budget = scheduleRunBudget(rule)
195
+ const done = String(rule.runCount)
196
+ if (budget === undefined) return t('detail.schedule.runsUnlimited', { done })
197
+ const remaining = Math.max(budget - rule.runCount, 0)
198
+ return `${t('detail.schedule.runs', { done, total: String(budget) })} · ${t('detail.schedule.remaining', { count: String(remaining) })}`
199
+ }
200
+
201
+ /** The line describing a rule that stopped itself, or undefined while it may still fire. */
202
+ export function endedLabel(rule: ScheduleRule): string | undefined {
203
+ if (rule.endedReason === undefined) return undefined
204
+ return t('detail.schedule.ended', { reason: t(stopReasonKey(rule.endedReason)) })
205
+ }
206
+
207
+ /** The line describing the last skipped occurrence, or undefined when none. */
208
+ export function skippedLabel(rule: ScheduleRule): string | undefined {
209
+ if (rule.skippedReason === undefined) return undefined
210
+ return t('detail.schedule.skipped', { reason: t(stopReasonKey(rule.skippedReason)) })
211
+ }
212
+
115
213
  /**
116
214
  * The full next-run label: the absolute wall clock in the schedule's own zone,
117
215
  * plus the relative distance in parentheses. Showing both matters because a
@@ -16,7 +16,7 @@ import { hasOpenExecution, withStatus, type NewTaskInput, type TaskRecord, type
16
16
  import { applyArchiveTask, applyRestoreTask } from './use-cases/task-archive.ts'
17
17
  import { applyCreateTask } from './use-cases/task-create.ts'
18
18
  import { applyDeleteTask } from './use-cases/task-delete.ts'
19
- import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule } from './use-cases/task-schedule.ts'
19
+ import { applyScheduleNextRun as applyScheduleRollForward, applySetSchedule, type SetSchedulePatch } from './use-cases/task-schedule.ts'
20
20
  import { resolveHostTimeZone } from './schedule.ts'
21
21
  import { applySetParent } from './use-cases/task-parent.ts'
22
22
  import { applyDeleteTag, applyRenameTag } from './use-cases/task-tag.ts'
@@ -141,7 +141,7 @@ export interface ExecutionOptionsSnapshot {
141
141
  * (revision/scheduler/power); a full snapshot also carries the deployment
142
142
  * constants the UI reads (session default permission, subtask depth).
143
143
  */
144
- export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'extensions'>
144
+ export type HostMirror = Pick<TaskBoardSnapshot, 'revision' | 'scheduler' | 'power' | 'sessionDefaultPermission' | 'maxSubtaskDepth' | 'teamRunAvailable' | 'goalRunEnabled' | 'extensions'>
145
145
 
146
146
  /** Immutable controller snapshot for UI subscriptions. */
147
147
  export interface ControllerSnapshot {
@@ -597,15 +597,17 @@ export class BoardController {
597
597
  // --- scheduling ---------------------------------------------------------------
598
598
 
599
599
  /**
600
- * Update a task's schedule rule. A blank or invalid cron expression is
601
- * rejected (returns false, state untouched). When the rule ends up enabled
602
- * the next run instant is computed immediately; a disabled rule carries no
603
- * next-run instant. Delegates the domain transition to the schedule use case.
600
+ * Update a task's schedule rule. An unusable plan (blank or invalid cron, a
601
+ * one-shot with no future instant, a non-positive run cap) is rejected
602
+ * (returns false, state untouched). When the rule ends up enabled the next
603
+ * run instant is computed immediately — a one-shot's next instant IS its
604
+ * planned instant — and a disabled rule carries none. Delegates the domain
605
+ * transition to the schedule use case.
604
606
  * @param id - the task to schedule.
605
607
  * @param patch - fields to change (absent fields keep their current value).
606
- * @returns true when applied, false when rejected (invalid cron / unknown task).
608
+ * @returns true when applied, false when rejected (unknown task / unusable plan).
607
609
  */
608
- setSchedule(id: string, patch: { enabled?: boolean; cron?: string; timeZone?: string | null }): boolean {
610
+ setSchedule(id: string, patch: SetSchedulePatch): boolean {
609
611
  // The Host zone is the fallback a rule with no stored zone follows: the
610
612
  // mirrored snapshot carries it, and a controller without a Host mirror
611
613
  // (pure client tests) falls back to the process zone.
@@ -813,6 +815,7 @@ export class BoardController {
813
815
  const sessionDefaultPermission = snapshot.sessionDefaultPermission ?? this.hostState?.sessionDefaultPermission
814
816
  const maxSubtaskDepth = snapshot.maxSubtaskDepth ?? this.hostState?.maxSubtaskDepth
815
817
  const teamRunAvailable = snapshot.teamRunAvailable ?? this.hostState?.teamRunAvailable
818
+ const goalRunEnabled = snapshot.goalRunEnabled ?? this.hostState?.goalRunEnabled
816
819
  const extensions = snapshot.extensions ?? this.hostState?.extensions
817
820
  return {
818
821
  revision: snapshot.revision,
@@ -821,6 +824,7 @@ export class BoardController {
821
824
  ...(sessionDefaultPermission === undefined ? {} : { sessionDefaultPermission }),
822
825
  ...(maxSubtaskDepth === undefined ? {} : { maxSubtaskDepth }),
823
826
  ...(teamRunAvailable === undefined ? {} : { teamRunAvailable }),
827
+ ...(goalRunEnabled === undefined ? {} : { goalRunEnabled }),
824
828
  ...(extensions === undefined ? {} : { extensions }),
825
829
  }
826
830
  }