@goodandready/dsh-kanban 0.2.32 → 0.2.33

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.
package/lib/client.js CHANGED
@@ -1266,6 +1266,29 @@ window.__ModuleLoader__.load({
1266
1266
  'error.crossSite': 'Request rejected as cross-site.',
1267
1267
  'error.timeout': 'Request timed out. Please try again.',
1268
1268
  'error.unknown': 'Request failed.',
1269
+ 'field.costMeterEnabled': 'Cost Meter Telemetry',
1270
+ 'hint.costMeterEnabled': 'Enable token and cost telemetry integration via @goodandready/dsh-cost-meter.',
1271
+ 'field.taskCostLimit': 'Task Cost Limit (USD)',
1272
+ 'hint.taskCostLimit': 'Cost threshold per task in USD; 0 disables threshold warnings.',
1273
+ 'card.costLimitExceeded': 'Cost limit exceeded',
1274
+ 'card.nudge': 'Nudge',
1275
+ 'card.nudgeHint': 'Send live instruction to running agent',
1276
+ 'nudge.title': 'Live Steering',
1277
+ 'nudge.placeholder': 'Enter immediate instruction or correction for the agent...',
1278
+ 'nudge.send': 'Send',
1279
+ 'nudge.sending': 'Sending...',
1280
+ 'nudge.success': 'Instruction delivered to agent',
1281
+ 'nudge.failed': 'Failed to deliver instruction',
1282
+ 'costMeter.notice': 'Cost and token tracking requires @goodandready/dsh-cost-meter to be installed and active in DSH profile.',
1283
+ 'costMeter.active': 'Plugin @goodandready/dsh-cost-meter is active',
1284
+ 'costMeter.inactive': 'Plugin @goodandready/dsh-cost-meter not detected',
1285
+ 'telemetry.title': 'Resource Telemetry',
1286
+ 'telemetry.tokens': 'Tokens',
1287
+ 'telemetry.input': 'Input',
1288
+ 'telemetry.output': 'Output',
1289
+ 'telemetry.cache': 'Cached',
1290
+ 'telemetry.cost': 'Estimated Cost',
1291
+ 'telemetry.limit': 'Budget Limit',
1269
1292
  }
1270
1293
 
1271
1294
  const zh = {
@@ -1667,6 +1690,29 @@ window.__ModuleLoader__.load({
1667
1690
  'error.crossSite': '请求被判定为跨站而拒绝。',
1668
1691
  'error.timeout': '请求超时,请重试。',
1669
1692
  'error.unknown': '请求失败。',
1693
+ 'field.costMeterEnabled': '成本计量遥测',
1694
+ 'hint.costMeterEnabled': '通过 @goodandready/dsh-cost-meter 启用 Token 和成本遥测集成。',
1695
+ 'field.taskCostLimit': '任务成本限额 (USD)',
1696
+ 'hint.taskCostLimit': '每个任务的成本阈值(美元);0 表示禁用超额警告。',
1697
+ 'card.costLimitExceeded': '超出成本限额',
1698
+ 'card.nudge': '引导',
1699
+ 'card.nudgeHint': '向正在运行的 Agent 发送实时引导指令',
1700
+ 'nudge.title': '实时引导',
1701
+ 'nudge.placeholder': '输入对 Agent 的即时指令或修正建议...',
1702
+ 'nudge.send': '发送',
1703
+ 'nudge.sending': '正在发送...',
1704
+ 'nudge.success': '指令已发送给 Agent',
1705
+ 'nudge.failed': '发送指令失败',
1706
+ 'costMeter.notice': 'Token 及成本追踪功能需要 DSH Profile 中安装并激活 @goodandready/dsh-cost-meter 插件。',
1707
+ 'costMeter.active': '已激活 @goodandready/dsh-cost-meter 插件',
1708
+ 'costMeter.inactive': '未检测到 @goodandready/dsh-cost-meter 插件',
1709
+ 'telemetry.title': '资源遥测',
1710
+ 'telemetry.tokens': 'Token 统计',
1711
+ 'telemetry.input': '输入',
1712
+ 'telemetry.output': '输出',
1713
+ 'telemetry.cache': '缓存命中',
1714
+ 'telemetry.cost': '预估成本',
1715
+ 'telemetry.limit': '预算限额',
1670
1716
  }
1671
1717
 
1672
1718
  function fallbackT(key, vars) {
@@ -1715,6 +1761,8 @@ window.__ModuleLoader__.load({
1715
1761
  { field: 'verificationGateEnabled', kind: 'bool', group: 'general' },
1716
1762
  { field: 'verificationCommand', kind: 'text', group: 'general' },
1717
1763
  { field: 'verificationTimeoutMs', kind: 'number', group: 'general' },
1764
+ { field: 'costMeterEnabled', kind: 'bool', group: 'sync' },
1765
+ { field: 'taskCostLimit', kind: 'number', group: 'limits' },
1718
1766
  ]
1719
1767
 
1720
1768
  function hintKey(spec) {
@@ -2014,6 +2062,29 @@ window.__ModuleLoader__.load({
2014
2062
  else if (!writable) note = h('p', { className: 'dkb-note', role: 'status' }, t('settings.readOnly'))
2015
2063
  else if (saved) note = h('p', { className: 'dkb-note', role: 'status' }, t('settings.saved'))
2016
2064
  else note = h('p', { className: 'dkb-note' })
2065
+ const costMeterDetected = Boolean(ctx?.costMeter || (ctx?.registry && Array.from(ctx.registry.keys()).some((k) => k === '@goodandready/dsh-cost-meter' || k?.name === '@goodandready/dsh-cost-meter')))
2066
+ rows.push(h('div', {
2067
+ className: 'dkb-section-card',
2068
+ key: 'cost-meter-notice',
2069
+ style: {
2070
+ borderLeft: '3px solid var(--dsw-alias-state-business-primary, #0969da)',
2071
+ background: 'var(--dsw-alias-bg-layer-1, #f6f8fa)',
2072
+ padding: '12px 14px',
2073
+ marginBottom: '12px',
2074
+ },
2075
+ },
2076
+ h('div', {
2077
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' },
2078
+ },
2079
+ h('span', { style: { fontWeight: '600', fontSize: '13px' } }, '🪙 ' + t('telemetry.title')),
2080
+ h('span', {
2081
+ className: 'dkb-section-badge' + (costMeterDetected ? ' dkb-section-badge-ok' : ''),
2082
+ }, costMeterDetected ? t('costMeter.active') : t('costMeter.inactive')),
2083
+ ),
2084
+ h('p', { className: 'dkb-hint', style: { margin: 0, fontSize: '12px', lineHeight: '1.4' } },
2085
+ t('costMeter.notice'),
2086
+ ),
2087
+ ))
2017
2088
  rows.push(h(PluginUpdaterSection, { t, key: 'updater' }))
2018
2089
  rows.push(h('div', { className: 'dkb-foot', key: 'foot' },
2019
2090
  note,
@@ -2094,6 +2165,20 @@ window.__ModuleLoader__.load({
2094
2165
  : (task.report?.verification?.status === 'failed'
2095
2166
  ? h('span', { className: 'dkb-tag', style: { background: 'var(--dsw-alias-state-error-secondary, #ffebe9)', color: 'var(--dsw-alias-state-error-primary, #cf222e)', borderColor: 'var(--dsw-alias-state-error-secondary, #ffc1c0)' }, title: task.report.verification.command || 'Tests failed' }, '✗ FAIL')
2096
2167
  : null),
2168
+ task.costMetrics && task.costMetrics.totalTokens > 0
2169
+ ? h('span', {
2170
+ className: 'dkb-tag',
2171
+ style: { background: 'var(--dsw-alias-state-business-tertiary, #ddf4ff)', color: 'var(--dsw-alias-state-business-primary, #0969da)', borderColor: 'var(--dsw-alias-state-business-tertiary, #54aeff)' },
2172
+ title: 'Tokens: ' + task.costMetrics.totalTokens.toLocaleString()
2173
+ }, '🪙 ' + (task.costMetrics.totalTokens / 1000).toFixed(1) + 'k' + (task.costMetrics.costFormatted ? ' · ' + task.costMetrics.costFormatted : ''))
2174
+ : null,
2175
+ task.costLimitExceeded
2176
+ ? h('span', {
2177
+ className: 'dkb-tag',
2178
+ style: { background: 'var(--dsw-alias-state-warn-secondary, #fff8c5)', color: 'var(--dsw-alias-state-warn-primary, #9a6700)', borderColor: 'var(--dsw-alias-state-warn-secondary, #d4a72c)' },
2179
+ title: 'Budget limit exceeded'
2180
+ }, '⚠️ ' + t('card.costLimitExceeded'))
2181
+ : null,
2097
2182
  task.report && task.report.summary
2098
2183
  ? h('span', { className: 'dkb-tag', title: 'Execution Report' }, '📄 report')
2099
2184
  : null,
@@ -2104,6 +2189,16 @@ window.__ModuleLoader__.load({
2104
2189
  className: 'dkb-tag', key: l, style: tagStyle(task, l),
2105
2190
  }, l))),
2106
2191
  h('div', { className: 'dkb-taskTitle' }, task.title),
2192
+ task.state === 'running' && task.liveStep
2193
+ ? h('div', {
2194
+ className: 'dkb-liveStep',
2195
+ style: { fontSize: '11px', color: 'var(--dsw-alias-state-business-primary, #0969da)', display: 'flex', alignItems: 'center', gap: '4px', margin: '3px 0' },
2196
+ title: task.liveStep
2197
+ },
2198
+ h('span', { style: { display: 'inline-block' } }, '⟳'),
2199
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, task.liveStep)
2200
+ )
2201
+ : null,
2107
2202
  task.subtasksCount && task.subtasksCount.total > 0
2108
2203
  ? h('div', { className: 'dkb-progressBar', title: `${task.subtasksCount.completed}/${task.subtasksCount.total}` },
2109
2204
  h('div', {
@@ -2160,6 +2255,26 @@ window.__ModuleLoader__.load({
2160
2255
  onClick: (e) => { e.stopPropagation(); props.onOpenChat(task) },
2161
2256
  }, t('card.toChat'))
2162
2257
  : null,
2258
+ (task.column === 'in-progress' || task.col === 'in-progress') && task.sessionId && (task.state === 'running' || task.state === 'idle')
2259
+ ? h('button', {
2260
+ type: 'button',
2261
+ className: 'dkb-nudgeBtn',
2262
+ title: t('card.nudgeHint'),
2263
+ style: {
2264
+ fontSize: '11px',
2265
+ padding: '1px 6px',
2266
+ borderRadius: '4px',
2267
+ cursor: 'pointer',
2268
+ border: '1px solid var(--dsw-alias-state-business-tertiary, #54aeff)',
2269
+ background: 'var(--dsw-alias-state-business-tertiary, #ddf4ff)',
2270
+ color: 'var(--dsw-alias-state-business-primary, #0969da)',
2271
+ },
2272
+ onClick: (e) => {
2273
+ e.stopPropagation();
2274
+ if (props.onNudge) props.onNudge(task);
2275
+ }
2276
+ }, '💬 ' + t('card.nudge'))
2277
+ : null,
2163
2278
  touched
2164
2279
  ? h('span', {
2165
2280
  className: task.stale ? 'dkb-countOver' : '',
@@ -2317,6 +2432,32 @@ window.__ModuleLoader__.load({
2317
2432
  const [agentPreset, setAgentPreset] = React.useState('')
2318
2433
  const [permission, setPermission] = React.useState('')
2319
2434
  const dragged = React.useRef(null)
2435
+ const [nudgeTask, setNudgeTask] = React.useState(null)
2436
+ const [nudgePrompt, setNudgePrompt] = React.useState('')
2437
+ const [nudgeSending, setNudgeSending] = React.useState(false)
2438
+ const [nudgeSuccess, setNudgeSuccess] = React.useState(false)
2439
+
2440
+ const handleNudgeSubmit = React.useCallback(async () => {
2441
+ if (!nudgeTask || !nudgePrompt.trim() || nudgeSending) return
2442
+ setNudgeSending(true)
2443
+ try {
2444
+ await api('/task/' + encodeURIComponent(nudgeTask.id) + '/nudge', {
2445
+ method: 'POST',
2446
+ body: JSON.stringify({ prompt: nudgePrompt.trim() }),
2447
+ })
2448
+ setNudgeSuccess(true)
2449
+ setTimeout(() => {
2450
+ setNudgeTask(null)
2451
+ setNudgePrompt('')
2452
+ setNudgeSuccess(false)
2453
+ }, 1200)
2454
+ load(board)
2455
+ } catch (e) {
2456
+ setError(t(e.key || e.error || 'nudge.failed'))
2457
+ } finally {
2458
+ setNudgeSending(false)
2459
+ }
2460
+ }, [nudgeTask, nudgePrompt, nudgeSending, load, board, t])
2320
2461
 
2321
2462
  const load = React.useCallback(async (nextBoard) => {
2322
2463
  try {
@@ -2868,6 +3009,7 @@ window.__ModuleLoader__.load({
2868
3009
  if (manyGroups && groups[col.id + '/' + (task.repo || '')] === true) return
2869
3010
  rows.push(h(TaskCard, {
2870
3011
  key: task.id, task, t, now: state.now,
3012
+ onNudge: (t) => { setNudgeTask(t); setNudgePrompt(''); setNudgeSuccess(false) },
2871
3013
  columns: (state.columns || []).map((c) => c.id),
2872
3014
  menuOpen: cardMenu,
2873
3015
  onMenu: setCardMenu,
@@ -3328,6 +3470,59 @@ window.__ModuleLoader__.load({
3328
3470
  },
3329
3471
  }, t('panel.openChat'))
3330
3472
  : null,
3473
+ openTask.sessionId && (openTask.column === 'in-progress' || openTask.col === 'in-progress')
3474
+ ? h('button', {
3475
+ type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },
3476
+ onClick: () => { setNudgeTask(openTask); setNudgePrompt(''); setNudgeSuccess(false) },
3477
+ }, '💬 ' + t('card.nudge'))
3478
+ : null,
3479
+ openTask.costMetrics && openTask.costMetrics.totalTokens > 0
3480
+ ? h('div', {
3481
+ className: 'dkb-section-card',
3482
+ style: {
3483
+ margin: '8px 0', padding: 12, border: '1px solid var(--dsw-alias-border-l2)',
3484
+ borderRadius: 8, display: 'flex', flexDirection: 'column', gap: 6,
3485
+ },
3486
+ },
3487
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3488
+ h('div', { className: 'dkb-group', style: { margin: 0, fontWeight: 600 } },
3489
+ '🪙 ' + t('telemetry.title')
3490
+ ),
3491
+ h('span', {
3492
+ className: 'dkb-tag',
3493
+ style: { background: 'var(--dsw-alias-state-business-tertiary, #ddf4ff)', color: 'var(--dsw-alias-state-business-primary, #0969da)', borderColor: 'var(--dsw-alias-state-business-tertiary, #54aeff)' }
3494
+ }, openTask.costMetrics.costFormatted || '$0.00'),
3495
+ ),
3496
+ h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, fontSize: '11px', textAlign: 'center' } },
3497
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1)', padding: '6px', borderRadius: '4px' } },
3498
+ h('div', { style: { opacity: 0.7 } }, t('telemetry.tokens')),
3499
+ h('div', { style: { fontWeight: 600 } }, openTask.costMetrics.totalTokens.toLocaleString()),
3500
+ ),
3501
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1)', padding: '6px', borderRadius: '4px' } },
3502
+ h('div', { style: { opacity: 0.7 } }, t('telemetry.input')),
3503
+ h('div', { style: { fontWeight: 600 } }, (openTask.costMetrics.uncachedInputTokens || 0).toLocaleString()),
3504
+ ),
3505
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1)', padding: '6px', borderRadius: '4px' } },
3506
+ h('div', { style: { opacity: 0.7 } }, t('telemetry.output')),
3507
+ h('div', { style: { fontWeight: 600 } }, (openTask.costMetrics.outputTokens || 0).toLocaleString()),
3508
+ ),
3509
+ h('div', { style: { background: 'var(--dsw-alias-bg-layer-1)', padding: '6px', borderRadius: '4px' } },
3510
+ h('div', { style: { opacity: 0.7 } }, t('telemetry.cache')),
3511
+ h('div', { style: { fontWeight: 600 } }, (openTask.costMetrics.cacheReadTokens || 0).toLocaleString()),
3512
+ ),
3513
+ ),
3514
+ openTask.costLimitExceeded
3515
+ ? h('div', {
3516
+ style: {
3517
+ fontSize: '11px',
3518
+ color: 'var(--dsw-alias-state-warn-primary, #9a6700)',
3519
+ background: 'var(--dsw-alias-state-warn-secondary, #fff8c5)',
3520
+ padding: '4px 8px', borderRadius: '4px', marginTop: '4px'
3521
+ }
3522
+ }, '⚠️ ' + t('card.costLimitExceeded'))
3523
+ : null
3524
+ )
3525
+ : null,
3331
3526
  log.length
3332
3527
  ? h('div', null,
3333
3528
  h('div', { className: 'dkb-group' }, t('panel.log')),
@@ -4372,11 +4567,76 @@ window.__ModuleLoader__.load({
4372
4567
  )
4373
4568
  : null
4374
4569
 
4570
+ const nudgeDialogEl = nudgeTask
4571
+ ? h('div', {
4572
+ className: 'dkb-modal',
4573
+ onClick: () => { setNudgeTask(null); setNudgePrompt(''); setNudgeSuccess(false) },
4574
+ },
4575
+ h('div', {
4576
+ className: 'dkb-dialog',
4577
+ style: { maxWidth: '440px', width: '90%', padding: '16px' },
4578
+ onClick: (e) => e.stopPropagation(),
4579
+ },
4580
+ h('div', { className: 'dkb-dialogHead', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' } },
4581
+ h('h3', { style: { margin: 0, fontSize: '15px' } }, t('nudge.title') + ': ' + (nudgeTask.title || '')),
4582
+ h('button', {
4583
+ type: 'button',
4584
+ className: 'dkb-discard',
4585
+ style: { border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '16px', padding: '0 4px' },
4586
+ onClick: () => { setNudgeTask(null); setNudgePrompt(''); setNudgeSuccess(false) },
4587
+ }, '✕'),
4588
+ ),
4589
+ nudgeSuccess
4590
+ ? h('div', {
4591
+ style: {
4592
+ padding: '12px',
4593
+ borderRadius: '6px',
4594
+ background: 'var(--dsw-alias-state-success-secondary, #dafbe1)',
4595
+ color: 'var(--dsw-alias-state-success-primary, #1a7f37)',
4596
+ marginBottom: '12px',
4597
+ textAlign: 'center',
4598
+ fontWeight: '600',
4599
+ },
4600
+ }, '✓ ' + t('nudge.success'))
4601
+ : h('div', null,
4602
+ h('textarea', {
4603
+ className: 'dkb-area',
4604
+ style: { width: '100%', minHeight: '90px', marginBottom: '12px', boxSizing: 'border-box' },
4605
+ placeholder: t('nudge.placeholder'),
4606
+ value: nudgePrompt,
4607
+ autoFocus: true,
4608
+ onChange: (e) => setNudgePrompt(e.target.value),
4609
+ onKeyDown: (e) => {
4610
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
4611
+ e.preventDefault();
4612
+ handleNudgeSubmit();
4613
+ }
4614
+ },
4615
+ }),
4616
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px' } },
4617
+ h('button', {
4618
+ type: 'button',
4619
+ className: 'dkb-discard',
4620
+ disabled: nudgeSending,
4621
+ onClick: () => { setNudgeTask(null); setNudgePrompt(''); setNudgeSuccess(false) },
4622
+ }, t('panel.close')),
4623
+ h('button', {
4624
+ type: 'button',
4625
+ className: 'dkb-save',
4626
+ disabled: !nudgePrompt.trim() || nudgeSending,
4627
+ onClick: handleNudgeSubmit,
4628
+ }, nudgeSending ? t('nudge.sending') : t('nudge.send')),
4629
+ ),
4630
+ ),
4631
+ ),
4632
+ )
4633
+ : null;
4634
+
4375
4635
  return h('div', { className: 'dkb-screen' },
4376
4636
  style, bar,
4377
4637
  error ? h('p', { className: 'dkb-failed' }, error) : null,
4378
4638
  metrics ? metricsEl : archive ? archiveEl : columns,
4379
- panel, dialogEl, moveDialogEl, batchEl, queueEl,
4639
+ panel, dialogEl, moveDialogEl, batchEl, queueEl, nudgeDialogEl,
4380
4640
  )
4381
4641
  }
4382
4642
 
package/lib/config.js CHANGED
@@ -79,6 +79,8 @@ export const CONFIG_DEFAULTS = {
79
79
  verificationGateEnabled: true,
80
80
  verificationCommand: 'npm test',
81
81
  verificationTimeoutMs: 120000,
82
+ costMeterEnabled: true,
83
+ taskCostLimit: 0,
82
84
  }
83
85
 
84
86
  /** Field descriptions reused in schema and settings card. */
@@ -117,6 +119,8 @@ export const CONFIG_HINTS = {
117
119
  verificationGateEnabled: 'Run automated verification gate (e.g. npm test) before allowing cards to move to Review.',
118
120
  verificationCommand: 'Default verification command to execute in task worktree before Review (default: npm test).',
119
121
  verificationTimeoutMs: 'Timeout in milliseconds for verification gate execution (default: 120000).',
122
+ costMeterEnabled: 'Enable token and cost telemetry integration via @goodandready/dsh-cost-meter.',
123
+ taskCostLimit: 'Cost threshold per task in USD; 0 disables threshold warnings.',
120
124
  }
121
125
 
122
126
  /** Setting key for column WIP limit; not all columns have limits. */
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Token and cost telemetry integration with @goodandready/dsh-cost-meter (#374).
3
+ * All functions fail-soft without throwing when cost-meter is absent.
4
+ */
5
+
6
+ /**
7
+ * Check whether @goodandready/dsh-cost-meter plugin is active in current runtime.
8
+ *
9
+ * @param {object} ctx - Cordis context
10
+ * @returns {boolean}
11
+ */
12
+ export function isCostMeterAvailable(ctx) {
13
+ if (!ctx || typeof ctx !== 'object') return false
14
+ try {
15
+ if (ctx.costMeter || (typeof ctx.get === 'function' && ctx.get('costMeter'))) return true
16
+ if (ctx.settings && typeof ctx.settings.describe === 'function') {
17
+ const rows = ctx.settings.describe({ redactSecrets: true })
18
+ if (Array.isArray(rows) && rows.some((r) => r?.ns === 'dsh-cost-meter')) return true
19
+ }
20
+ if (ctx.registry && typeof ctx.registry[Symbol.iterator] === 'function') {
21
+ for (const [plugin] of ctx.registry) {
22
+ if (plugin === '@goodandready/dsh-cost-meter' || plugin?.name === '@goodandready/dsh-cost-meter') {
23
+ return true
24
+ }
25
+ }
26
+ }
27
+ return false
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Extract aggregated token usage from a session's costByModel projection view.
35
+ *
36
+ * @param {object} projectionView - View object from usageByModelProjection
37
+ * @returns {{
38
+ * totalTokens: number,
39
+ * uncachedInputTokens: number,
40
+ * outputTokens: number,
41
+ * cacheReadTokens: number,
42
+ * cacheWriteTokens: number,
43
+ * turnsCount: number,
44
+ * models: Array<string>
45
+ * }}
46
+ */
47
+ export function extractSessionUsage(projectionView) {
48
+ const result = {
49
+ totalTokens: 0,
50
+ uncachedInputTokens: 0,
51
+ outputTokens: 0,
52
+ cacheReadTokens: 0,
53
+ cacheWriteTokens: 0,
54
+ turnsCount: 0,
55
+ models: [],
56
+ }
57
+ if (!projectionView || typeof projectionView !== 'object') return result
58
+
59
+ const modelsList = Array.isArray(projectionView.models) ? projectionView.models : []
60
+ for (const m of modelsList) {
61
+ const key = (m.provider ? m.provider + '/' : '') + (m.model || '')
62
+ if (key && !result.models.includes(key)) result.models.push(key)
63
+ const slots = m.slots && typeof m.slots === 'object' ? m.slots : {}
64
+ for (const slotKey of Object.keys(slots)) {
65
+ const b = slots[slotKey] || {}
66
+ result.uncachedInputTokens += Number(b.uncachedInputTokens) || 0
67
+ result.outputTokens += Number(b.outputTokens) || 0
68
+ result.cacheReadTokens += Number(b.cacheReadTokens) || 0
69
+ result.cacheWriteTokens += Number(b.cacheWriteTokens) || 0
70
+ }
71
+ }
72
+
73
+ if (Array.isArray(projectionView.turns)) {
74
+ result.turnsCount = projectionView.turns.length
75
+ if (result.totalTokens === 0 && projectionView.turns.length > 0) {
76
+ for (const t of projectionView.turns) {
77
+ result.uncachedInputTokens += Number(t.uncachedInputTokens) || 0
78
+ result.outputTokens += Number(t.outputTokens) || 0
79
+ result.cacheReadTokens += Number(t.cacheReadTokens) || 0
80
+ result.cacheWriteTokens += Number(t.cacheWriteTokens) || 0
81
+ }
82
+ }
83
+ }
84
+
85
+ result.totalTokens = result.uncachedInputTokens + result.outputTokens + result.cacheReadTokens + result.cacheWriteTokens
86
+ return result
87
+ }
88
+
89
+ /**
90
+ * Calculate cost for token usage given rate schedule or fallback tariff.
91
+ * Default tariff: standard DeepSeek rates ($0.14/M input, $0.28/M output, $0.014/M cache hit).
92
+ *
93
+ * @param {{
94
+ * usage: object,
95
+ * schedule?: object,
96
+ * usdRate?: number,
97
+ * currency?: string
98
+ * }} options
99
+ * @returns {{
100
+ * costUsd: number,
101
+ * costFormatted: string,
102
+ * currency: string
103
+ * }}
104
+ */
105
+ export function calculateTokenCost({ usage, schedule, usdRate = 1, currency = '$' }) {
106
+ if (!usage || typeof usage !== 'object') {
107
+ return { costUsd: 0, costFormatted: currency + '0.00', currency }
108
+ }
109
+
110
+ const uncachedInput = Number(usage.uncachedInputTokens) || 0
111
+ const output = Number(usage.outputTokens) || 0
112
+ const cacheRead = Number(usage.cacheReadTokens) || 0
113
+ const cacheWrite = Number(usage.cacheWriteTokens) || 0
114
+
115
+ const rateInput = Number(schedule?.inputPerMillion ?? schedule?.input) || 0.14
116
+ const rateOutput = Number(schedule?.outputPerMillion ?? schedule?.output) || 0.28
117
+ const rateCacheRead = Number(schedule?.cacheReadPerMillion ?? schedule?.cacheHit) || 0.014
118
+ const rateCacheWrite = Number(schedule?.cacheWritePerMillion) || rateInput
119
+
120
+ const costUsd = (uncachedInput * rateInput + output * rateOutput + cacheRead * rateCacheRead + cacheWrite * rateCacheWrite) / 1_000_000
121
+ const converted = costUsd * (Number(usdRate) > 0 ? Number(usdRate) : 1)
122
+
123
+ const costFormatted = currency + (converted < 0.01 && converted > 0 ? '<0.01' : converted.toFixed(2))
124
+
125
+ return {
126
+ costUsd: Math.round(costUsd * 10000) / 10000,
127
+ costFormatted,
128
+ currency,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Check whether task cost exceeds configured budget limit.
134
+ *
135
+ * @param {{ costUsd: number, limit: number }} options
136
+ * @returns {{ exceeded: boolean, limit: number, costUsd: number }}
137
+ */
138
+ export function checkCostLimit({ costUsd = 0, limit = 0 }) {
139
+ const numLimit = Number(limit) || 0
140
+ if (numLimit <= 0) return { exceeded: false, limit: 0, costUsd }
141
+ return {
142
+ exceeded: costUsd >= numLimit,
143
+ limit: numLimit,
144
+ costUsd,
145
+ }
146
+ }
package/lib/index.js CHANGED
@@ -51,7 +51,9 @@ import {
51
51
  handleCreateSubtask,
52
52
  handleToggleChecklistItem,
53
53
  handleCreateTaskPr,
54
+ handleTaskNudge,
54
55
  } from './routes.js'
56
+ import { isCostMeterAvailable } from './cost-telemetry.js'
55
57
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
56
58
  import {
57
59
  runTask, resolveModel, buildStartMessage,
@@ -867,7 +869,20 @@ export function apply(ctx, config) {
867
869
  /**
868
870
  * Return active agent state or undefined if session is inactive.
869
871
  */
870
- const liveOf = (sessionId) => ctx.agents.get(sessionId)?.status
872
+ const liveOf = (sessionId) => {
873
+ try {
874
+ const a = ctx.agents?.get?.(sessionId);
875
+ if (!a) return undefined;
876
+ return {
877
+ status: a.status || (typeof a.isIdle === 'function' && a.isIdle() ? 'idle' : 'running'),
878
+ state: a.state || a.lifecycleState,
879
+ step: a.step || a.currentStep || a.stateDetail || a.activity || '',
880
+ activity: a.activity || a.statusDetail || '',
881
+ };
882
+ } catch {
883
+ return undefined;
884
+ }
885
+ };
871
886
 
872
887
  ctx.effect(() => ctx.webServer.register({
873
888
  kind: 'exact',
@@ -885,6 +900,7 @@ export function apply(ctx, config) {
885
900
  sync: syncState.snapshot(),
886
901
  // Resolved project root passed with board payload for session launch modal.
887
902
  projectRoot: rootOf(getConfig()),
903
+ costMeterAvailable: isCostMeterAvailable(ctx),
888
904
  }))
889
905
  },
890
906
  }), 'dsh-kanban: board route')
@@ -1230,6 +1246,18 @@ export function apply(ctx, config) {
1230
1246
  writeJson(res, resObj.status, resObj.body)
1231
1247
  return
1232
1248
  }
1249
+ if (req.method === 'POST' && (action === 'nudge' || (input && input.action === 'nudge'))) {
1250
+ const resObj = await handleTaskNudge({
1251
+ store,
1252
+ id,
1253
+ prompt: input?.prompt || input?.text,
1254
+ agents: ctx.agents,
1255
+ createMessage: createUserMessage,
1256
+ eventHub,
1257
+ })
1258
+ writeJson(res, resObj.status, resObj.body)
1259
+ return
1260
+ }
1233
1261
  if (req.method === 'GET' && action === 'diff') {
1234
1262
  const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff, getTaskDiffInspector })
1235
1263
  writeJson(res, resObj.status, resObj.body)
package/lib/routes.js CHANGED
@@ -308,10 +308,23 @@ function withPlan(task, liveOf, config, now, sameSession) {
308
308
  try {
309
309
  if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
310
310
  } catch { live = undefined }
311
+ const liveStatus = typeof live === 'object' && live !== null ? live.status : live
312
+ const liveStep = typeof live === 'object' && live !== null
313
+ ? (live.step || live.activity || live.stateDetail || '')
314
+ : ''
315
+
316
+ const costMetrics = task.costMetrics || null
317
+ const costLimitExceeded = (config?.costMeterEnabled !== false && Number(config?.taskCostLimit) > 0 && costMetrics?.costUsd)
318
+ ? costMetrics.costUsd >= Number(config.taskCostLimit)
319
+ : Boolean(task.costLimitExceeded)
320
+
311
321
  return {
312
322
  ...task,
313
323
  plan: { items: planItems(plan), progress: planProgress(plan) },
314
- state: taskState({ task, live }),
324
+ state: taskState({ task, live: liveStatus }),
325
+ liveStep: liveStep || task.liveStep || '',
326
+ costMetrics,
327
+ costLimitExceeded,
315
328
  // Queue position calculated dynamically across active tasks in same session.
316
329
  queuePos: queuePosition(task, sameSession),
317
330
  // Label facets extracted on server for consistent client filtering.
@@ -344,7 +357,7 @@ function orderForBoard(tasks, columns) {
344
357
  /**
345
358
  * Build board response payload: columns in canonical order, tasks, and limits.
346
359
  */
347
- export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot }) {
360
+ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot, costMeterAvailable = false }) {
348
361
  // Uniform timestamp across snapshot calculation.
349
362
  const now = Date.now()
350
363
  // Tasks are sorted and grouped on server: repo grouping with active items first.
@@ -384,6 +397,7 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
384
397
  facets: facetsOf(tasks),
385
398
  // Sync telemetry state attached directly to board snapshot.
386
399
  sync: sync ?? { state: 'never' },
400
+ costMeterAvailable: Boolean(costMeterAvailable),
387
401
  }
388
402
  }
389
403
 
@@ -1119,4 +1133,75 @@ export async function handleCreateTaskPr({ store, gitea, task, input }) {
1119
1133
  } catch (err) {
1120
1134
  return { status: 502, body: { error: 'gitea-pr-failed', detail: err?.message } }
1121
1135
  }
1122
- }
1136
+ }
1137
+ /**
1138
+ * Handle interactive live steering (nudge) for an active task agent (#373).
1139
+ *
1140
+ * @param {{
1141
+ * store: object,
1142
+ * id: string,
1143
+ * prompt: string,
1144
+ * agents?: object,
1145
+ * createMessage?: Function,
1146
+ * eventHub?: object,
1147
+ * now?: number
1148
+ * }} options
1149
+ * @returns {Promise<{ status: number, body: object }>}
1150
+ */
1151
+ export async function handleTaskNudge({ store, id, prompt, agents, createMessage, eventHub, now = Date.now() }) {
1152
+ if (!store || !id) return { status: 400, body: { error: 'invalid-parameters' } }
1153
+ const task = store.getTask(id)
1154
+ if (!task) return { status: 404, body: { error: 'task-not-found' } }
1155
+
1156
+ const text = typeof prompt === 'string' ? prompt.trim() : ''
1157
+ if (!text) return { status: 400, body: { error: 'prompt-required' } }
1158
+
1159
+ const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
1160
+ if (!sessionId) return { status: 409, body: { error: 'no-session' } }
1161
+
1162
+ let agent
1163
+ try { agent = agents?.get?.(sessionId) } catch { agent = undefined }
1164
+ if (!agent || typeof agent.followup !== 'function') {
1165
+ return { status: 409, body: { error: 'session-not-live' } }
1166
+ }
1167
+
1168
+ const msg = typeof createMessage === 'function'
1169
+ ? createMessage({
1170
+ content: [{ type: 'text', text }],
1171
+ source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: 'task-nudge' },
1172
+ })
1173
+ : { content: [{ type: 'text', text }] }
1174
+
1175
+ agent.followup(msg)
1176
+
1177
+ // Append user steering comment to task
1178
+ if (typeof store.addComment === 'function') {
1179
+ store.addComment(id, {
1180
+ author: 'user',
1181
+ role: 'user',
1182
+ text: `[Steering Nudge] ${text}`,
1183
+ })
1184
+ } else if (typeof store.addTaskComment === 'function') {
1185
+ store.addTaskComment(id, {
1186
+ author: 'user',
1187
+ role: 'user',
1188
+ text: `[Steering Nudge] ${text}`,
1189
+ })
1190
+ }
1191
+
1192
+ // Broadcast change via eventHub
1193
+ if (eventHub && typeof eventHub.broadcast === 'function') {
1194
+ eventHub.broadcast('task:change', { id, col: task.col || task.column })
1195
+ }
1196
+
1197
+ return {
1198
+ status: 200,
1199
+ body: {
1200
+ success: true,
1201
+ id,
1202
+ sessionId,
1203
+ prompt: text,
1204
+ nudgedAt: now,
1205
+ },
1206
+ }
1207
+ }
package/lib/store.js CHANGED
@@ -109,6 +109,7 @@ const WRITABLE = [
109
109
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
110
110
  'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
111
111
  'verificationCommand', 'verificationStatus',
112
+ 'costMetrics', 'costLimitExceeded',
112
113
  ]
113
114
 
114
115
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -162,6 +163,16 @@ function rowToTask(row) {
162
163
  reasoningEffort: rest.reasoningEffort ?? '',
163
164
  isEpic: isEpic === 1,
164
165
  parentId: parentId ?? '',
166
+ costMetrics: (() => {
167
+ if (typeof rest.costMetrics === 'string' && rest.costMetrics.trim()) {
168
+ try {
169
+ const val = JSON.parse(rest.costMetrics);
170
+ if (val && typeof val === 'object') return val;
171
+ } catch {}
172
+ }
173
+ return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
174
+ })(),
175
+ costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
165
176
  }
166
177
  }
167
178
 
@@ -252,6 +263,8 @@ export function openStore({ dir }) {
252
263
  ['queuedRequest', "TEXT NOT NULL DEFAULT ''"],
253
264
  ['verificationCommand', "TEXT NOT NULL DEFAULT ''"],
254
265
  ['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
266
+ ['costMetrics', "TEXT NOT NULL DEFAULT ''"],
267
+ ['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
255
268
  ]) {
256
269
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
257
270
  }
@@ -653,6 +666,15 @@ export function openStore({ dir }) {
653
666
  })
654
667
  },
655
668
 
669
+ appendComment(id, comment) {
670
+ if (!comment || typeof comment !== 'object') return undefined
671
+ return this.addComment(id, {
672
+ author: comment.author || 'user',
673
+ role: comment.role || 'user',
674
+ text: comment.text || comment.body || '',
675
+ })
676
+ },
677
+
656
678
  updateChecklist(id, items) {
657
679
  const task = rowToTask(q.taskById.get(id))
658
680
  if (!task) return undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.32",
3
+ "version": "0.2.33",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",