dsh-taskboard 0.5.4 → 0.6.0

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 (42) hide show
  1. package/README.md +27 -160
  2. package/lib/client.js +2564 -678
  3. package/lib/host/execution.js +3 -0
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/routes.js +31 -1
  6. package/lib/host/routes.js.map +1 -1
  7. package/lib/host/session-sync.js +449 -0
  8. package/lib/host/session-sync.js.map +1 -0
  9. package/lib/host/store.js +9 -2
  10. package/lib/host/store.js.map +1 -1
  11. package/lib/index.js +109 -2
  12. package/lib/index.js.map +1 -1
  13. package/lib/shared/api.js.map +1 -1
  14. package/lib/shared/protocol.js +27 -1
  15. package/lib/shared/protocol.js.map +1 -1
  16. package/package.json +75 -75
  17. package/src/client/api.ts +8 -0
  18. package/src/client/board/AlertModal.tsx +3 -1
  19. package/src/client/board/ImportModal.tsx +26 -24
  20. package/src/client/board/SettingsModal.tsx +98 -22
  21. package/src/client/board/SlashPromptInput.tsx +272 -0
  22. package/src/client/board/TaskBoard.tsx +53 -49
  23. package/src/client/board/TaskCard.tsx +33 -21
  24. package/src/client/board/TaskDetail.tsx +169 -104
  25. package/src/client/board/TaskFormModal.tsx +254 -202
  26. package/src/client/board/TemplateManager.tsx +32 -29
  27. package/src/client/board/labels.ts +36 -27
  28. package/src/client/controller.ts +62 -2
  29. package/src/client/i18n/en.ts +455 -0
  30. package/src/client/i18n/runtime.ts +155 -0
  31. package/src/client/i18n/zh.ts +460 -0
  32. package/src/client/index.ts +182 -42
  33. package/src/client/sidebar-entry.ts +13 -3
  34. package/src/client/styles.ts +131 -0
  35. package/src/host/execution.ts +14 -1
  36. package/src/host/routes.ts +49 -1
  37. package/src/host/session-sync.ts +650 -0
  38. package/src/host/store.ts +15 -1
  39. package/src/index.ts +125 -1
  40. package/src/shared/api.ts +49 -0
  41. package/src/shared/protocol.ts +54 -0
  42. package/src/shared/version.ts +1 -1
@@ -0,0 +1,650 @@
1
+ /**
2
+ * External workspace session synchronization service.
3
+ *
4
+ * When `settings.syncExternalSessions` is enabled (0.5.4):
5
+ * - Listens to session lifecycle events from outside the taskboard.
6
+ * - On `turn/start`: automatically captures or resumes the session on the board
7
+ * (status: `in_progress`, claimedBy: sessionId).
8
+ * - On `user/message` / `session/title`: enriches/updates task title & description.
9
+ * - On `turn/end`: settles the execution (success -> `in_review` 待验收, failure -> `todo`).
10
+ *
11
+ * @module dsh-taskboard/host/session-sync
12
+ */
13
+ import {
14
+ defaultSyncExternalSessionsOf,
15
+ newCommentId,
16
+ newExecutionId,
17
+ newTaskId,
18
+ normalizeBody,
19
+ normalizeTitle,
20
+ type TaskRecord,
21
+ } from '../shared/protocol.ts'
22
+ import type { EventsFace } from './execution.ts'
23
+ import type { TaskStore } from './store.ts'
24
+ import type { WorkspaceFace } from './tools.ts'
25
+
26
+ /** Extract text content from a user message event payload. */
27
+ export function extractUserMessageText(msg: unknown): string {
28
+ if (typeof msg !== 'object' || msg === null) return ''
29
+ const content = (msg as { content?: unknown }).content
30
+ if (typeof content === 'string') return content
31
+ if (Array.isArray(content)) {
32
+ return content
33
+ .map(part => {
34
+ if (typeof part === 'string') return part
35
+ if (typeof part === 'object' && part !== null && 'text' in part && typeof (part as { text: unknown }).text === 'string') {
36
+ return (part as { text: string }).text
37
+ }
38
+ return ''
39
+ })
40
+ .filter(Boolean)
41
+ .join('\n')
42
+ }
43
+ return ''
44
+ }
45
+
46
+ /** Extract a short one-line title from prompt text. */
47
+ export function titleFromText(text: string): string {
48
+ const clean = text.trim().replace(/^#+\s*/, '')
49
+ const firstLine = clean.split('\n')[0]?.trim() ?? ''
50
+ return firstLine.slice(0, 50).trim()
51
+ }
52
+
53
+ /**
54
+ * Detect whether a session represents a subagent child conversation.
55
+ * Subagents are created by agent delegation (e.g. invoke_subagent / subagents service)
56
+ * and should never be automatically converted into user tasks on the taskboard.
57
+ */
58
+ export function isSubagentSession(
59
+ sessionId: string,
60
+ sessionMeta?: unknown,
61
+ event?: { type: string; data?: unknown },
62
+ ): boolean {
63
+ if (typeof sessionId === 'string') {
64
+ if (sessionId.startsWith('subagent-') || sessionId.startsWith('child-') || sessionId.startsWith('delegate-')) {
65
+ return true
66
+ }
67
+ }
68
+
69
+ if (typeof sessionMeta === 'object' && sessionMeta !== null) {
70
+ const s = sessionMeta as {
71
+ header?: Record<string, unknown>
72
+ meta?: Record<string, unknown>
73
+ options?: Record<string, unknown>
74
+ }
75
+
76
+ const header = s.header
77
+ const meta = s.meta
78
+ const options = s.options
79
+
80
+ // Check origin
81
+ if (header?.origin === 'subagent' || meta?.origin === 'subagent') return true
82
+
83
+ // Check parent session lineage
84
+ if (
85
+ header?.parentSession !== undefined
86
+ || header?.parentSessionId !== undefined
87
+ || meta?.parentSession !== undefined
88
+ || meta?.parentSessionId !== undefined
89
+ ) {
90
+ return true
91
+ }
92
+
93
+ // Check delegation depth
94
+ if (typeof header?.delegationDepth === 'number' && header.delegationDepth > 0) return true
95
+ if (typeof meta?.delegationDepth === 'number' && meta.delegationDepth > 0) return true
96
+ if (typeof options?.subagentDepth === 'number' && options.subagentDepth > 0) return true
97
+ }
98
+
99
+ if (event !== undefined) {
100
+ if (event.type === 'subagent/descriptor' || event.type === 'subagent/start' || event.type === 'subagent/end') {
101
+ return true
102
+ }
103
+ if (typeof event.data === 'object' && event.data !== null) {
104
+ const d = event.data as Record<string, unknown>
105
+ if (
106
+ d.origin === 'subagent'
107
+ || d.subagent === true
108
+ || typeof d.subagentId === 'string'
109
+ || typeof d.parentSession === 'string'
110
+ ) {
111
+ return true
112
+ }
113
+ }
114
+ }
115
+
116
+ return false
117
+ }
118
+
119
+ /**
120
+ * Detect whether a session represents an active working conversation.
121
+ * Inspects state, status, isWorking/isBusy methods, running flags, and active turns.
122
+ */
123
+ export function isSessionActiveWorking(session: unknown): boolean {
124
+ if (typeof session !== 'object' || session === null) return false
125
+ const s = session as Record<string, unknown>
126
+
127
+ // 1. Method checks
128
+ if (typeof s.isWorking === 'function') {
129
+ try { if (Boolean((s.isWorking as () => boolean)())) return true } catch { /* ignore */ }
130
+ } else if (s.isWorking === true) {
131
+ return true
132
+ }
133
+
134
+ if (typeof s.isBusy === 'function') {
135
+ try { if (Boolean((s.isBusy as () => boolean)())) return true } catch { /* ignore */ }
136
+ } else if (s.busy === true || s.isBusy === true) {
137
+ return true
138
+ }
139
+
140
+ // 2. Boolean flags
141
+ if (s.running === true || s.active === true || s.isGenerating === true || s.generating === true) {
142
+ return true
143
+ }
144
+
145
+ // 3. State string
146
+ if (typeof s.state === 'string') {
147
+ const st = s.state.toLowerCase()
148
+ if (st === 'running' || st === 'working' || st === 'busy' || st === 'generating' || st === 'executing') {
149
+ return true
150
+ }
151
+ }
152
+
153
+ // 4. Status string
154
+ if (typeof s.status === 'string') {
155
+ const st = s.status.toLowerCase()
156
+ if (st === 'running' || st === 'working' || st === 'busy' || st === 'active' || st === 'generating' || st === 'executing') {
157
+ return true
158
+ }
159
+ }
160
+
161
+ // 5. Active turn checks
162
+ if (s.activeTurn !== undefined && s.activeTurn !== null && s.activeTurn !== false) {
163
+ return true
164
+ }
165
+ if (s.currentTurn !== undefined && s.currentTurn !== null) {
166
+ if (typeof s.currentTurn === 'object') {
167
+ const ct = s.currentTurn as Record<string, unknown>
168
+ if (ct.status === 'running' || ct.state === 'running' || ct.outcome === 'running' || ct.endedAt === undefined) {
169
+ return true
170
+ }
171
+ } else {
172
+ return true
173
+ }
174
+ }
175
+
176
+ // 6. Turns list
177
+ if (Array.isArray(s.turns) && s.turns.length > 0) {
178
+ const lastTurn = s.turns[s.turns.length - 1]
179
+ if (typeof lastTurn === 'object' && lastTurn !== null) {
180
+ const lt = lastTurn as Record<string, unknown>
181
+ if (lt.status === 'running' || lt.state === 'running' || lt.outcome === 'running' || (lt.startedAt !== undefined && lt.endedAt === undefined)) {
182
+ return true
183
+ }
184
+ }
185
+ }
186
+
187
+ return false
188
+ }
189
+
190
+ /** Dependencies required by the external session sync service. */
191
+ export interface SessionSyncDeps {
192
+ store: TaskStore
193
+ workspaces: WorkspaceFace
194
+ events: EventsFace
195
+ sessions?: {
196
+ get?: (id: string) => unknown
197
+ list?: () => unknown[]
198
+ }
199
+ now: () => number
200
+ scanIntervalMs?: number
201
+ }
202
+
203
+ /** Default scan interval: 4s. */
204
+ export const DEFAULT_SCAN_INTERVAL_MS = 4000
205
+
206
+ /**
207
+ * Service that synchronizes external workspace sessions into the taskboard.
208
+ */
209
+ export class ExternalSessionSyncService {
210
+ private readonly unsubscribe: () => void
211
+ private readonly ignoredSessions = new Set<string>()
212
+ private scanTimer?: NodeJS.Timeout | number
213
+
214
+ constructor(private readonly deps: SessionSyncDeps) {
215
+ this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
216
+ void this.handleSessionEvent(sessionId, event, sessionMeta)
217
+ })
218
+
219
+ const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS
220
+ if (interval > 0) {
221
+ this.scanTimer = setInterval(() => {
222
+ void this.scanActiveSessions()
223
+ }, interval)
224
+ }
225
+ }
226
+
227
+ /** Detach listener and clear scanner on teardown. */
228
+ dispose(): void {
229
+ this.unsubscribe()
230
+ if (this.scanTimer !== undefined) {
231
+ clearInterval(this.scanTimer as NodeJS.Timeout)
232
+ this.scanTimer = undefined
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Periodic active scan: checks whether external sessions linked to board tasks
238
+ * are actively working, ensuring tasks in `in_review` / `todo` / `backlog`
239
+ * automatically pull back to `in_progress`.
240
+ */
241
+ async scanActiveSessions(): Promise<void> {
242
+ const snapshot = this.deps.store.snapshot()
243
+ if (!defaultSyncExternalSessionsOf(snapshot.settings)) return
244
+ if (this.deps.sessions === undefined) return
245
+
246
+ const now = this.deps.now()
247
+ const tasks = snapshot.tasks.filter(t => t.trashedAt === undefined)
248
+
249
+ for (const task of tasks) {
250
+ const sessionId = task.claimedBy ?? task.executions[task.executions.length - 1]?.sessionId
251
+ if (sessionId === undefined || typeof sessionId !== 'string') continue
252
+ if (this.ignoredSessions.has(sessionId) || sessionId.startsWith('session-taskboard-')) continue
253
+
254
+ let session: unknown
255
+ try {
256
+ session = this.deps.sessions.get?.(sessionId)
257
+ } catch { /* ignore */ }
258
+
259
+ if (session === undefined && typeof this.deps.sessions.list === 'function') {
260
+ try {
261
+ const list = this.deps.sessions.list()
262
+ session = list?.find(s => (s as { id?: string })?.id === sessionId)
263
+ } catch { /* ignore */ }
264
+ }
265
+
266
+ if (session === undefined || session === null) continue
267
+ if (isSubagentSession(sessionId, session)) {
268
+ this.ignoredSessions.add(sessionId)
269
+ continue
270
+ }
271
+
272
+ const isWorking = isSessionActiveWorking(session)
273
+ if (isWorking) {
274
+ if (task.status !== 'in_progress') {
275
+ await this.deps.store.mutate('task-updated', (ledger) => {
276
+ const current = ledger.tasks.find(t => t.id === task.id)
277
+ if (current === undefined || current.trashedAt !== undefined) return undefined
278
+ current.status = 'in_progress'
279
+ current.claimedBy = sessionId
280
+ current.claimedAt = current.claimedAt ?? now
281
+ current.updatedAt = now
282
+ current.updatedBy = { kind: 'agent', sessionId }
283
+ const hasRunning = current.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
284
+ if (!hasRunning) {
285
+ current.executions.push({
286
+ id: newExecutionId(),
287
+ sessionId,
288
+ trigger: 'manual',
289
+ startedAt: now,
290
+ outcome: 'running',
291
+ isolation: 'none',
292
+ })
293
+ }
294
+ return [current]
295
+ })
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ private async handleSessionEvent(
302
+ sessionId: string,
303
+ event: { type: string; data?: unknown },
304
+ sessionMeta?: {
305
+ header?: { cwd?: string; origin?: string; parentSession?: string; parentSessionId?: string; delegationDepth?: number }
306
+ meta?: { origin?: string; parentSession?: string; delegationDepth?: number }
307
+ options?: { subagentDepth?: number }
308
+ },
309
+ ): Promise<void> {
310
+ // 1. Check if already ignored
311
+ if (this.ignoredSessions.has(sessionId)) return
312
+
313
+ // 2. Ignore taskboard's internal execution sessions
314
+ if (sessionId.startsWith('session-taskboard-')) {
315
+ this.ignoredSessions.add(sessionId)
316
+ return
317
+ }
318
+
319
+ // 3. Ignore subagent sessions (delegated children)
320
+ if (isSubagentSession(sessionId, sessionMeta, event)) {
321
+ this.ignoredSessions.add(sessionId)
322
+ // If a task was previously created for this subagent before detection, clean it up
323
+ await this.deps.store.mutate('task-deleted', (ledger) => {
324
+ const idx = ledger.tasks.findIndex(
325
+ t => t.claimedBy === sessionId && t.createdBy.kind === 'agent' && t.createdBy.sessionId === sessionId,
326
+ )
327
+ if (idx >= 0) {
328
+ ledger.tasks.splice(idx, 1)
329
+ return []
330
+ }
331
+ return undefined
332
+ })
333
+ return
334
+ }
335
+
336
+ // 4. Check if external session sync is enabled in board settings
337
+ const snapshot = this.deps.store.snapshot()
338
+ if (!defaultSyncExternalSessionsOf(snapshot.settings)) return
339
+
340
+ const now = this.deps.now()
341
+
342
+ if (event.type === 'turn/start') {
343
+ await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)
344
+ return
345
+ }
346
+
347
+ if (event.type === 'user/message') {
348
+ await this.handleUserMessage(sessionId, event.data, now)
349
+ return
350
+ }
351
+
352
+ if (
353
+ event.type === 'turn/step'
354
+ || event.type === 'turn/progress'
355
+ || event.type === 'agent/step'
356
+ || event.type === 'agent/thought'
357
+ || event.type === 'agent/turn/start'
358
+ ) {
359
+ await this.ensureSessionInProgress(sessionId, now)
360
+ return
361
+ }
362
+
363
+ if (event.type === 'session/title') {
364
+ await this.handleSessionTitle(sessionId, event.data, now)
365
+ return
366
+ }
367
+
368
+ if (event.type === 'turn/end') {
369
+ await this.handleTurnEnd(sessionId, event.data, now)
370
+ return
371
+ }
372
+ }
373
+
374
+ private async ensureSessionInProgress(sessionId: string, now: number): Promise<void> {
375
+ await this.deps.store.mutate('task-updated', (ledger) => {
376
+ const task = ledger.tasks.find(
377
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
378
+ )
379
+ if (task === undefined || task.trashedAt !== undefined) return undefined
380
+
381
+ let changed = false
382
+ if (task.status !== 'in_progress') {
383
+ task.status = 'in_progress'
384
+ task.claimedBy = sessionId
385
+ task.claimedAt = task.claimedAt ?? now
386
+ changed = true
387
+ }
388
+ const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
389
+ if (!hasRunning) {
390
+ task.executions.push({
391
+ id: newExecutionId(),
392
+ sessionId,
393
+ trigger: 'manual',
394
+ startedAt: now,
395
+ outcome: 'running',
396
+ isolation: 'none',
397
+ })
398
+ changed = true
399
+ }
400
+ if (changed) {
401
+ task.updatedAt = now
402
+ task.updatedBy = { kind: 'agent', sessionId }
403
+ return [task]
404
+ }
405
+ return undefined
406
+ })
407
+ }
408
+
409
+ private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {
410
+ // Resolve workspace
411
+ let wsId: string | undefined
412
+ if (cwd !== undefined && cwd.length > 0) {
413
+ const resolved = await this.deps.workspaces.resolveByPath(cwd)
414
+ wsId = resolved?.id
415
+ }
416
+ if (wsId === undefined) {
417
+ wsId = this.deps.workspaces.list()[0]?.id ?? 'default'
418
+ }
419
+
420
+ await this.deps.store.mutate('task-created', (ledger) => {
421
+ // Find existing task linked to this session
422
+ const existing = ledger.tasks.find(
423
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
424
+ )
425
+
426
+ if (existing !== undefined) {
427
+ if (existing.trashedAt !== undefined) return undefined
428
+ // If already in_progress and holding claim, ensure running execution
429
+ if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {
430
+ const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
431
+ if (!hasRunning) {
432
+ existing.executions.push({
433
+ id: newExecutionId(),
434
+ sessionId,
435
+ trigger: 'manual',
436
+ startedAt: now,
437
+ outcome: 'running',
438
+ isolation: 'none',
439
+ })
440
+ existing.updatedAt = now
441
+ existing.updatedBy = { kind: 'agent', sessionId }
442
+ return [existing]
443
+ }
444
+ return undefined
445
+ }
446
+
447
+ // Resumed or continued turn (e.g. from in_review or todo)
448
+ existing.status = 'in_progress'
449
+ existing.claimedBy = sessionId
450
+ existing.claimedAt = now
451
+ existing.updatedAt = now
452
+ existing.updatedBy = { kind: 'agent', sessionId }
453
+ existing.executions.push({
454
+ id: newExecutionId(),
455
+ sessionId,
456
+ trigger: 'manual',
457
+ startedAt: now,
458
+ outcome: 'running',
459
+ isolation: 'none',
460
+ })
461
+ return [existing]
462
+ }
463
+
464
+ // Create new task for this external session
465
+ const shortId = sessionId.replace(/^session-/, '').slice(0, 8)
466
+ const newTask: TaskRecord = {
467
+ id: newTaskId(),
468
+ title: `会话 ${shortId}`,
469
+ description: '',
470
+ prompt: '',
471
+ workspaceId: wsId,
472
+ urgency: 'normal',
473
+ status: 'in_progress',
474
+ blocked: false,
475
+ execution: { mode: 'claim' },
476
+ isolation: 'none',
477
+ claimedBy: sessionId,
478
+ claimedAt: now,
479
+ version: 1,
480
+ createdAt: now,
481
+ updatedAt: now,
482
+ createdBy: { kind: 'agent', sessionId },
483
+ updatedBy: { kind: 'agent', sessionId },
484
+ comments: [],
485
+ executions: [
486
+ {
487
+ id: newExecutionId(),
488
+ sessionId,
489
+ trigger: 'manual',
490
+ startedAt: now,
491
+ outcome: 'running',
492
+ isolation: 'none',
493
+ },
494
+ ],
495
+ }
496
+ ledger.tasks.push(newTask)
497
+ return [newTask]
498
+ })
499
+ }
500
+
501
+ private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {
502
+ const text = extractUserMessageText(msgData)
503
+
504
+ await this.deps.store.mutate('task-updated', (ledger) => {
505
+ const task = ledger.tasks.find(
506
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
507
+ )
508
+ if (task === undefined || task.trashedAt !== undefined) return undefined
509
+
510
+ let changed = false
511
+ if (text.trim().length > 0) {
512
+ // If title is default placeholder "会话 ...", replace with prompt summary
513
+ if (task.title.startsWith('会话 ') && task.title.length <= 16) {
514
+ const derived = titleFromText(text)
515
+ if (derived.length > 0) {
516
+ task.title = normalizeTitle(derived)
517
+ changed = true
518
+ }
519
+ }
520
+ // If description is empty, record initial prompt
521
+ if (task.description.length === 0) {
522
+ task.description = text.slice(0, 2000)
523
+ changed = true
524
+ }
525
+ }
526
+
527
+ // If task was in in_review, todo, or backlog, user message resumes work -> move to in_progress
528
+ if (task.status !== 'in_progress') {
529
+ task.status = 'in_progress'
530
+ task.claimedBy = sessionId
531
+ task.claimedAt = now
532
+ changed = true
533
+ }
534
+
535
+ // Ensure running execution exists
536
+ const hasRunning = task.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
537
+ if (!hasRunning) {
538
+ task.executions.push({
539
+ id: newExecutionId(),
540
+ sessionId,
541
+ trigger: 'manual',
542
+ startedAt: now,
543
+ outcome: 'running',
544
+ isolation: 'none',
545
+ })
546
+ changed = true
547
+ }
548
+
549
+ if (changed) {
550
+ task.updatedAt = now
551
+ task.updatedBy = { kind: 'user' }
552
+ return [task]
553
+ }
554
+ return undefined
555
+ })
556
+ }
557
+
558
+ private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {
559
+ const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'
560
+ ? (titleData as { title: string }).title
561
+ : typeof titleData === 'string'
562
+ ? titleData
563
+ : ''
564
+ if (rawTitle.trim().length === 0) return
565
+
566
+ await this.deps.store.mutate('task-updated', (ledger) => {
567
+ const task = ledger.tasks.find(
568
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
569
+ )
570
+ if (task === undefined || task.trashedAt !== undefined) return undefined
571
+ task.title = normalizeTitle(rawTitle)
572
+ task.updatedAt = now
573
+ task.updatedBy = { kind: 'user' }
574
+ return [task]
575
+ })
576
+ }
577
+
578
+ private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {
579
+ const reason = typeof endData === 'object' && endData !== null && 'reason' in endData
580
+ ? (endData as { reason: unknown }).reason
581
+ : endData
582
+
583
+ // Check if error or failure
584
+ let isFailure = false
585
+ let errorMessage = ''
586
+ if (typeof reason === 'object' && reason !== null) {
587
+ const r = reason as Record<string, unknown>
588
+ if (r.kind === 'error' || r.kind === 'failure') {
589
+ isFailure = true
590
+ errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'
591
+ } else if (r.kind === 'cancel') {
592
+ isFailure = true
593
+ errorMessage = 'cancelled'
594
+ }
595
+ } else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {
596
+ isFailure = true
597
+ errorMessage = reason
598
+ }
599
+
600
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
601
+ const task = ledger.tasks.find(
602
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
603
+ )
604
+ if (task === undefined || task.trashedAt !== undefined) return undefined
605
+
606
+ // Settle running execution
607
+ for (const exec of task.executions) {
608
+ if (exec.sessionId === sessionId && exec.outcome === 'running') {
609
+ exec.endedAt = now
610
+ if (isFailure) {
611
+ exec.outcome = 'failed'
612
+ exec.error = errorMessage.slice(0, 500)
613
+ } else {
614
+ exec.outcome = 'succeeded'
615
+ }
616
+ }
617
+ }
618
+
619
+ delete task.claimedBy
620
+ delete task.claimedAt
621
+ task.updatedAt = now
622
+ task.updatedBy = { kind: 'agent', sessionId }
623
+
624
+ if (isFailure) {
625
+ // Failed session hands back to todo with comment
626
+ if (task.status === 'in_progress') {
627
+ task.status = 'todo'
628
+ task.comments.push({
629
+ id: newCommentId(),
630
+ body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
631
+ version: 1,
632
+ createdAt: now,
633
+ })
634
+ }
635
+ } else {
636
+ // Successful settlement automatically moves to in_review (待验收)
637
+ if (task.status === 'in_progress') {
638
+ task.status = 'in_review'
639
+ task.comments.push({
640
+ id: newCommentId(),
641
+ body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),
642
+ version: 1,
643
+ createdAt: now,
644
+ })
645
+ }
646
+ }
647
+ return [task]
648
+ })
649
+ }
650
+ }
package/src/host/store.ts CHANGED
@@ -10,6 +10,7 @@ import { mkdir, open, readFile, rename } from 'node:fs/promises'
10
10
  import { dirname, join } from 'node:path'
11
11
  import {
12
12
  LEDGER_SCHEMA_VERSION,
13
+ asBoardSettings,
13
14
  emptyLedger,
14
15
  isPlausibleTaskRecord,
15
16
  pruneExecutions,
@@ -81,7 +82,20 @@ export class TaskStore {
81
82
  task.claimedAt = task.updatedAt
82
83
  }
83
84
  }
84
- this.ledger = { schemaVersion: LEDGER_SCHEMA_VERSION, revision: parsed.revision, tasks }
85
+ let settings = undefined
86
+ if (parsed.settings !== undefined) {
87
+ try {
88
+ settings = asBoardSettings(parsed.settings)
89
+ } catch {
90
+ console.warn('[dsh-taskboard] dropping invalid board settings on load')
91
+ }
92
+ }
93
+ this.ledger = {
94
+ schemaVersion: LEDGER_SCHEMA_VERSION,
95
+ revision: parsed.revision,
96
+ tasks,
97
+ ...(settings !== undefined ? { settings } : {}),
98
+ }
85
99
  }
86
100
  } catch (error) {
87
101
  const code = (error as NodeJS.ErrnoException).code