@goodandready/dsh-kanban 0.2.13 → 0.2.14

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
@@ -608,6 +608,7 @@ window.__ModuleLoader__.load({
608
608
  }
609
609
 
610
610
  const helpers = {
611
+ ROW_ID, ROW_CONFIG_KEY, PKG, KanbanSettingsCard,
611
612
  neighboursFor, tasksOf, sortTasks, packInfo, groupByAssignee, applyToEach, recallLaunch, rememberLaunch, stillOffered,
612
613
  cardRef, taskRef, cardStatus, facetLabel, boardTitle,
613
614
  columnTitle, planLine, stateLabel, isCollapsed,
@@ -721,6 +722,8 @@ window.__ModuleLoader__.load({
721
722
  '.dkb-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:10px;padding:14px 16px;display:flex;flex-direction:column;gap:8px;margin-bottom:12px}' +'.dkb-section-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between;gap:8px;padding-bottom:4px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +'.dkb-section-badge{font-size:11px;font-weight:500;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}' +'.dkb-section-badge-ok{border-color:var(--dsw-alias-state-success-border,#34d399);color:var(--dsw-alias-state-success-primary,#059669);background:var(--dsw-alias-state-success-bg,rgba(16,185,129,0.1))}' +
722
723
  '.dkb-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
723
724
  '.dkb-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}' +
725
+ '.dkb-page{padding:0}' +
726
+ '.dkb-page .dkb-body{border-top:none;margin:0}' +
724
727
  '.dkb-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
725
728
  '.dkb-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
726
729
  '.dkb-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
@@ -1818,8 +1821,16 @@ window.__ModuleLoader__.load({
1818
1821
  }
1819
1822
 
1820
1823
  function KanbanSettingsCard(props) {
1821
- const ctx = props.ctx
1822
- const t = props.t || fallbackT
1824
+ const ctx = props && props.ctx
1825
+ const t = (props && props.t) || fallbackT
1826
+ ensureCss()
1827
+
1828
+ // Row seat (plugins.row.config): summary is a one-liner
1829
+ if (props && props.view === 'summary') {
1830
+ return h('span', { className: 'dkb-sub' }, t('settings.subtitle') || t('subtitle') || 'Kanban board for sessions and tasks')
1831
+ }
1832
+ const page = !!(props && props.view === 'page')
1833
+
1823
1834
  const [open, setOpen] = React.useState(false)
1824
1835
  const [draft, setDraft] = React.useState(null)
1825
1836
  const [saving, setSaving] = React.useState(false)
@@ -1891,7 +1902,7 @@ window.__ModuleLoader__.load({
1891
1902
  ensureCss()
1892
1903
  const style = null
1893
1904
 
1894
- if (!open) return h('li', { className: 'dkb-card' }, head)
1905
+ if (!page && !open) return h('li', { className: 'dkb-card' }, head)
1895
1906
 
1896
1907
  let body
1897
1908
  if (status === 'loading') {
@@ -1980,15 +1991,10 @@ window.__ModuleLoader__.load({
1980
1991
  body = rows
1981
1992
  }
1982
1993
 
1983
- // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
1984
- // padding, so the summary is a one-liner and the page drops our card chrome.
1985
- if (props && props.view === 'summary') {
1986
- return h('span', { className: 'dkb-sub' }, t('settings.subtitle') || 'Kanban board for sessions and tasks')
1994
+ if (page) {
1995
+ return h('div', { className: 'dkb-page' }, h('div', { className: 'dkb-body' }, body))
1987
1996
  }
1988
- const page = !!(props && props.view === 'page')
1989
-
1990
- return h(page ? 'div' : 'li', { className: page ? 'dkb-page' : 'dkb-card dkb-cardOpen' },
1991
- page ? null : head, h('div', { className: 'dkb-body' }, body))
1997
+ return h('li', { className: 'dkb-card dkb-cardOpen' }, head, h('div', { className: 'dkb-body' }, body))
1992
1998
  }
1993
1999
 
1994
2000
  /** Стрелка влево для кнопки возврата: 14x14, обводка цветом текста. */
@@ -2238,6 +2244,36 @@ window.__ModuleLoader__.load({
2238
2244
  const [syncing, setSyncing] = React.useState(false)
2239
2245
  const [picked, setPicked] = React.useState({})
2240
2246
  const [archive, setArchive] = React.useState(null)
2247
+ const [diffExpanded, setDiffExpanded] = React.useState(false)
2248
+ const [diffLoading, setDiffLoading] = React.useState(false)
2249
+ const [diffData, setDiffData] = React.useState(null)
2250
+ const [commitsData, setCommitsData] = React.useState([])
2251
+ const [merging, setMerging] = React.useState(false)
2252
+
2253
+ const loadDiffAndCommits = React.useCallback(async () => {
2254
+ if (!openTask) return
2255
+ setDiffLoading(true)
2256
+ try {
2257
+ const [dRes, cRes] = await Promise.all([
2258
+ api('/task/' + encodeURIComponent(openTask.id) + '/diff'),
2259
+ api('/task/' + encodeURIComponent(openTask.id) + '/commits'),
2260
+ ])
2261
+ setDiffData(dRes)
2262
+ setCommitsData(cRes?.commits || [])
2263
+ } catch (e) {
2264
+ setError(t(e.key || e.error || 'error.unknown'))
2265
+ } finally {
2266
+ setDiffLoading(false)
2267
+ }
2268
+ }, [openTask, t])
2269
+
2270
+ React.useEffect(() => {
2271
+ setDiffExpanded(false)
2272
+ setDiffLoading(false)
2273
+ setDiffData(null)
2274
+ setCommitsData([])
2275
+ setMerging(false)
2276
+ }, [openTask?.id])
2241
2277
  const [metrics, setMetrics] = React.useState(null)
2242
2278
  const [onlyWaiting, setOnlyWaiting] = React.useState(false)
2243
2279
  const [onlyOverdue, setOnlyOverdue] = React.useState(false)
@@ -4518,25 +4554,59 @@ window.__ModuleLoader__.load({
4518
4554
  // below — the seat is additive, not an either/or candidate. The label is a static
4519
4555
  // string on purpose: it is resolved while the page renders, and a locale lookup
4520
4556
  // there would take the whole client batch down with it.
4521
- try {
4522
- ctx.slots.inject('plugins.item', () => {
4523
- ctx.slots.register({
4557
+ const trySlot = (name, register) => {
4558
+ try {
4559
+ if (typeof ctx.slots?.inject === 'function') ctx.slots.inject(name, register)
4560
+ else register()
4561
+ } catch (e) {
4562
+ console.warn('[dsh-kanban] slot registration failed for ' + name + ':', e && e.message)
4563
+ }
4564
+ }
4565
+
4566
+ // #274: Newest slot first, older slots as fallbacks:
4567
+ // 1. plugins.row.config (Row seat on Plugins page, DSH 0.1.6-alpha.2)
4568
+ let cardSlot = undefined
4569
+ trySlot('plugins.row.config', () => {
4570
+ ctx.slots.register(
4571
+ {
4572
+ name: 'plugins.row.config',
4573
+ key: ROW_CONFIG_KEY,
4574
+ locale: NS,
4575
+ inject: () => ({ ctx }),
4576
+ },
4577
+ SafeSettingsCard,
4578
+ )
4579
+ if (!cardSlot) cardSlot = 'plugins.row.config'
4580
+ })
4581
+
4582
+ // 2. plugins.item (Official group on Plugins page)
4583
+ trySlot('plugins.item', () => {
4584
+ ctx.slots.register(
4585
+ {
4524
4586
  name: 'plugins.item',
4525
4587
  id: ROW_ID,
4526
4588
  order: 60,
4527
4589
  label: () => 'Kanban',
4528
4590
  locale: NS,
4529
4591
  inject: () => ({ ctx }),
4530
- }, SafeSettingsCard)
4531
- })
4532
- } catch (e) {
4533
- console.warn('[dsh-kanban] plugins.item seat unavailable', e)
4534
- }
4592
+ },
4593
+ SafeSettingsCard,
4594
+ )
4595
+ })
4535
4596
 
4536
- const cardSlot = registerFirst(ctx, [
4537
- { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) },
4538
- { name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
4539
- ], SafeSettingsCard)
4597
+ // 3. settings.plugin.item (legacy fallback for older cores)
4598
+ trySlot('settings.plugin.item', () => {
4599
+ ctx.slots.register(
4600
+ {
4601
+ name: 'settings.plugin.item',
4602
+ key: NS,
4603
+ locale: NS,
4604
+ inject: () => ({ ctx }),
4605
+ },
4606
+ SafeSettingsCard,
4607
+ )
4608
+ if (!cardSlot) cardSlot = 'settings.plugin.item'
4609
+ })
4540
4610
 
4541
4611
  const toggle = createToggle()
4542
4612
  ctx.effect(() => {
package/lib/commands.js CHANGED
@@ -51,6 +51,7 @@ const TABLE = {
51
51
  * `undefined` — такой колонки на этой доске нет.
52
52
  */
53
53
  export function commandFor(column, kind = 'project') {
54
+ if (!columnsOfKind(kind).includes(column)) return undefined
54
55
  const board = TABLE[kind]
55
56
  if (board === undefined) return undefined
56
57
  const found = board[column]
package/lib/gitea.js CHANGED
@@ -33,7 +33,9 @@ export function safeSegment(value) {
33
33
 
34
34
  /** Имя учётной записи — имя переменной окружения, а не сам секрет. */
35
35
  export function isCredentialName(value) {
36
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(value || ''))
36
+ const str = String(value || '')
37
+ if (looksLikeSecret(str)) return false
38
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(str)
37
39
  }
38
40
 
39
41
  /** Похоже на сам токен, а не на его имя. */
package/lib/index.js CHANGED
@@ -77,6 +77,9 @@ import { planOutbound, planAssign, createOutbox } from './outbound.js'
77
77
  import { computeNextRun } from './cron.js'
78
78
  import { PowerInhibitor } from './power.js'
79
79
  import { formatProgressDump, parseProgressDump } from './progress-dump.js'
80
+ import { randomUUID } from 'node:crypto'
81
+ import { SessionId } from '@deepseek-ai/dsh-session'
82
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
80
83
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
81
84
  import { defineTool } from '@deepseek-ai/dsh-tools'
82
85
  import z from '@deepseek-ai/schemastery'
@@ -92,7 +95,7 @@ import {
92
95
  rootOf,
93
96
  } from './config.js'
94
97
  import { eventHub } from './events.js'
95
- import { TASK_TEMPLATES } from './templates.js'
98
+ import { TASK_TEMPLATES, getTemplateById } from './templates.js'
96
99
 
97
100
  export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
98
101
 
@@ -838,10 +841,25 @@ export function apply(ctx, config) {
838
841
  },
839
842
  }), 'dsh-kanban: sse events stream')
840
843
 
844
+ // Очистка SSE-клиентов и heartbeat таймера при выгрузке плагина (#282)
845
+ if (typeof ctx.on === 'function') {
846
+ ctx.on('dispose', () => {
847
+ eventHub.closeAll()
848
+ })
849
+ }
850
+
841
851
  ctx.effect(() => ctx.webServer.register({
842
852
  kind: 'exact',
843
853
  path: '/dsh-kanban/templates',
844
854
  handler: (req, res) => {
855
+ const u = new URL(req.url, 'http://localhost')
856
+ const tid = u.searchParams.get('id')
857
+ if (tid) {
858
+ const tmpl = getTemplateById(tid)
859
+ if (!tmpl) { writeJson(res, 404, { error: 'template-not-found' }); return }
860
+ reply(res, { template: tmpl })
861
+ return
862
+ }
845
863
  reply(res, { templates: TASK_TEMPLATES })
846
864
  },
847
865
  }), 'dsh-kanban: task templates')
@@ -1098,16 +1116,15 @@ export function apply(ctx, config) {
1098
1116
  config: getConfig(),
1099
1117
  provider: picked.provider,
1100
1118
  model: picked.model,
1101
- mintSessionId,
1102
- createMessage,
1103
- cwdOf,
1119
+ mintSessionId: () => SessionId(`kanban-${id}-${randomUUID()}`),
1120
+ createMessage: createUserMessage,
1121
+ cwdOf: undefined,
1104
1122
  logger: ctx.logger,
1105
- text: input?.text,
1123
+ text: typeof input?.text === 'string' ? input.text : undefined,
1106
1124
  agentPreset: input?.agentPreset || task.agentPreset,
1107
1125
  permission: input?.permission || task.permission,
1108
1126
  permissions: ctx.get('permissionPresets'),
1109
- claimAs: req.headers['x-dsh-user'],
1110
- gitRunner,
1127
+ claimAs: req.headers['x-dsh-user'] || (await whoAmI()),
1111
1128
  workspaceRegistry: ctx.get('workspaceRegistry'),
1112
1129
  resume: true,
1113
1130
  })
@@ -1281,7 +1298,7 @@ export function apply(ctx, config) {
1281
1298
  handler: (req, res) => {
1282
1299
  const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
1283
1300
  if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
1284
- reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]) }))
1301
+ reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
1285
1302
  },
1286
1303
  }), 'dsh-kanban: task route by session')
1287
1304
 
package/lib/launcher.js CHANGED
@@ -416,7 +416,7 @@ export async function runTask({
416
416
  })
417
417
  }
418
418
 
419
- return { sessionId: taken.sessionId, mode: taken.mode, task: current }
419
+ return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
420
420
  }
421
421
 
422
422
  /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
package/lib/markdown.js CHANGED
@@ -119,7 +119,7 @@ export function parseSpans(text) {
119
119
  const href = piece.slice(cut + 2, -1)
120
120
  // Небезопасная схема — не ссылка, а текст. Молча выбрасывать нельзя:
121
121
  // человек должен видеть, что там было написано.
122
- if (SAFE_LINK.test(href)) out.push({ kind: 'link', text: label, href })
122
+ if (isSafeHref(href)) out.push({ kind: 'link', text: label, href })
123
123
  else out.push({ kind: 'text', text: piece })
124
124
  } else if (piece.startsWith('**') || piece.startsWith('__')) {
125
125
  out.push({ kind: 'strong', text: piece.slice(2, -2) })
package/lib/routes.js CHANGED
@@ -402,12 +402,18 @@ export function taskLog({ store, id }) {
402
402
  }
403
403
 
404
404
  /** Задача, привязанная к сессии; отсутствие задачи — штатный ответ. */
405
- export function taskBySession({ store, sessionId }) {
405
+ export function taskBySession({ store, sessionId, agents }) {
406
406
  const task = store.findTaskBySession(sessionId)
407
407
  if (task === undefined) return { task: null }
408
+ let live
409
+ try { live = agents?.get?.(sessionId) } catch { live = undefined }
408
410
  // Вид доски едет вместе с задачей: чип предлагает колонки, а предлагать
409
411
  // `deploy` задаче с простой доски значит звать в колонку, которой нет.
410
- return { task, columns: columnsOf(store.boardKind(task.board)) }
412
+ return {
413
+ task,
414
+ columns: columnsOf(store.boardKind(task.board)),
415
+ canRevive: canRevive(task, live),
416
+ }
411
417
  }
412
418
 
413
419
  /**
@@ -443,34 +449,42 @@ export function importBoard({ store, input }) {
443
449
  let imported = 0
444
450
  let skipped = 0
445
451
 
446
- for (const board of Array.isArray(input.boards) ? input.boards : []) {
447
- if (typeof board?.id !== 'string' || board.id === '') continue
448
- store.createBoard({ id: board.id, title: board.title, kind: board.kind })
449
- }
450
-
451
- for (const task of input.tasks) {
452
- if (typeof task?.id !== 'string' || task.id === '') continue
453
- if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
454
- store.createTask({
455
- id: task.id,
456
- board: task.board ?? 'main',
457
- column: task.column ?? 'backlog',
458
- title: task.title ?? '',
459
- body: task.body ?? '',
460
- owner: task.owner, repo: task.repo,
461
- issueNumber: task.issueNumber, issueUrl: task.issueUrl,
462
- labels: Array.isArray(task.labels) ? task.labels : [],
463
- branch: task.branch, worktree: task.worktree,
464
- sessionId: task.sessionId, model: task.model, provider: task.provider,
465
- waiting: task.waiting === true,
466
- plan: task.plan ?? '',
467
- labelColors: task.labelColors ?? {},
468
- author: task.author ?? '',
469
- priority: task.priority ?? '',
470
- dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
471
- createdAt: task.createdAt,
472
- })
473
- imported += 1
452
+ const doImport = () => {
453
+ for (const board of Array.isArray(input.boards) ? input.boards : []) {
454
+ if (typeof board?.id !== 'string' || board.id === '') continue
455
+ store.createBoard({ id: board.id, title: board.title, kind: board.kind })
456
+ }
457
+
458
+ for (const task of input.tasks) {
459
+ if (typeof task?.id !== 'string' || task.id === '') continue
460
+ if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
461
+ store.createTask({
462
+ id: task.id,
463
+ board: task.board ?? 'main',
464
+ column: task.column ?? 'backlog',
465
+ title: task.title ?? '',
466
+ body: task.body ?? '',
467
+ owner: task.owner, repo: task.repo,
468
+ issueNumber: task.issueNumber, issueUrl: task.issueUrl,
469
+ labels: Array.isArray(task.labels) ? task.labels : [],
470
+ branch: task.branch, worktree: task.worktree,
471
+ sessionId: task.sessionId, model: task.model, provider: task.provider,
472
+ waiting: task.waiting === true,
473
+ plan: task.plan ?? '',
474
+ labelColors: task.labelColors ?? {},
475
+ author: task.author ?? '',
476
+ priority: task.priority ?? '',
477
+ dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
478
+ createdAt: task.createdAt,
479
+ })
480
+ imported += 1
481
+ }
482
+ }
483
+
484
+ if (typeof store.withTransaction === 'function') {
485
+ store.withTransaction(doImport)
486
+ } else {
487
+ doImport()
474
488
  }
475
489
 
476
490
  return { imported, skipped, backup: backupMeta?.backup }
package/lib/store.js CHANGED
@@ -220,11 +220,20 @@ export function openStore({ dir }) {
220
220
  db.exec('CREATE INDEX IF NOT EXISTS tasks_cron_next ON tasks(archivedAt, nextRunAt)')
221
221
  db.exec('CREATE INDEX IF NOT EXISTS tasks_queued ON tasks(archivedAt, queuedAt)')
222
222
  db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
223
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_updated ON tasks(updatedAt)')
224
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_archivable ON tasks(archivedAt, col, columnAt)')
223
225
  db.exec('PRAGMA synchronous = NORMAL')
224
226
  try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
225
227
 
226
228
  const q = {
227
229
  boards: db.prepare('SELECT * FROM boards ORDER BY position'),
230
+ insertTask: db.prepare(`INSERT INTO tasks
231
+ (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
232
+ labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
233
+ labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
234
+ cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
235
+ checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
236
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
228
237
  boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
229
238
  insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
230
239
  lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
@@ -285,6 +294,18 @@ export function openStore({ dir }) {
285
294
  }
286
295
 
287
296
  return {
297
+ withTransaction(fn) {
298
+ db.exec('BEGIN IMMEDIATE')
299
+ try {
300
+ const res = fn()
301
+ db.exec('COMMIT')
302
+ return res
303
+ } catch (err) {
304
+ db.exec('ROLLBACK')
305
+ throw err
306
+ }
307
+ },
308
+
288
309
  backupDatabase() {
289
310
  const backupsDir = join(dir, 'backups')
290
311
  mkdirSync(backupsDir, { recursive: true })
@@ -378,13 +399,7 @@ export function openStore({ dir }) {
378
399
  // генерирует новый.
379
400
  const id = typeof input.id === 'string' && input.id !== '' ? input.id : randomUUID()
380
401
  const position = positionFor(board, column, input.beforeId, input.afterId)
381
- db.prepare(`INSERT INTO tasks
382
- (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
383
- labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
384
- labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
385
- cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, runs,
386
- checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic)
387
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
402
+ q.insertTask.run(
388
403
  id, board, column, position,
389
404
  input.title ?? '', input.body ?? '',
390
405
  input.owner ?? null, input.repo ?? null,
package/lib/time.js CHANGED
@@ -40,6 +40,7 @@ export function isStale({ now, updatedAt, sessionId, afterMinutes }) {
40
40
  const limit = Number(afterMinutes)
41
41
  if (!Number.isFinite(limit) || limit <= 0) return false
42
42
  if (typeof sessionId !== 'string' || sessionId === '') return false
43
- if (typeof updatedAt !== 'number' || !Number.isFinite(updatedAt)) return false
43
+ const parts = relativeParts(now, updatedAt)
44
+ if (!parts) return false
44
45
  return now - updatedAt >= limit * MINUTE
45
46
  }
package/lib/worktree.js CHANGED
@@ -168,7 +168,12 @@ export async function createTaskWorktree({
168
168
  env,
169
169
  cwdOf,
170
170
  }) {
171
- if (!repoDir) return undefined
171
+ if (!repoDir) {
172
+ if (task?.workspacePath) {
173
+ return prepareMultiRepoMirror({ task, workspacePath: task.workspacePath, gitRunner, config })
174
+ }
175
+ return undefined
176
+ }
172
177
  const isGit = await isGitRepo(repoDir, gitRunner)
173
178
  if (!isGit) return undefined
174
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",