dsh-taskboard 0.5.3 → 0.5.5

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.
@@ -0,0 +1,330 @@
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
+ /** Dependencies required by the external session sync service. */
54
+ export interface SessionSyncDeps {
55
+ store: TaskStore
56
+ workspaces: WorkspaceFace
57
+ events: EventsFace
58
+ now: () => number
59
+ }
60
+
61
+ /**
62
+ * Service that synchronizes external workspace sessions into the taskboard.
63
+ */
64
+ export class ExternalSessionSyncService {
65
+ private readonly unsubscribe: () => void
66
+
67
+ constructor(private readonly deps: SessionSyncDeps) {
68
+ this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
69
+ void this.handleSessionEvent(sessionId, event, sessionMeta)
70
+ })
71
+ }
72
+
73
+ /** Detach listener on teardown. */
74
+ dispose(): void {
75
+ this.unsubscribe()
76
+ }
77
+
78
+ private async handleSessionEvent(
79
+ sessionId: string,
80
+ event: { type: string; data?: unknown },
81
+ sessionMeta?: { header?: { cwd?: string } },
82
+ ): Promise<void> {
83
+ // 1. Ignore taskboard's internal execution sessions
84
+ if (sessionId.startsWith('session-taskboard-')) return
85
+
86
+ // 2. Check if external session sync is enabled in board settings
87
+ const snapshot = this.deps.store.snapshot()
88
+ if (!defaultSyncExternalSessionsOf(snapshot.settings)) return
89
+
90
+ const now = this.deps.now()
91
+
92
+ if (event.type === 'turn/start') {
93
+ await this.handleTurnStart(sessionId, sessionMeta?.header?.cwd, now)
94
+ return
95
+ }
96
+
97
+ if (event.type === 'user/message') {
98
+ await this.handleUserMessage(sessionId, event.data, now)
99
+ return
100
+ }
101
+
102
+ if (event.type === 'session/title') {
103
+ await this.handleSessionTitle(sessionId, event.data, now)
104
+ return
105
+ }
106
+
107
+ if (event.type === 'turn/end') {
108
+ await this.handleTurnEnd(sessionId, event.data, now)
109
+ return
110
+ }
111
+ }
112
+
113
+ private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {
114
+ // Resolve workspace
115
+ let wsId: string | undefined
116
+ if (cwd !== undefined && cwd.length > 0) {
117
+ const resolved = await this.deps.workspaces.resolveByPath(cwd)
118
+ wsId = resolved?.id
119
+ }
120
+ if (wsId === undefined) {
121
+ wsId = this.deps.workspaces.list()[0]?.id ?? 'default'
122
+ }
123
+
124
+ await this.deps.store.mutate('task-created', (ledger) => {
125
+ // Find existing task linked to this session
126
+ const existing = ledger.tasks.find(
127
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
128
+ )
129
+
130
+ if (existing !== undefined) {
131
+ if (existing.trashedAt !== undefined) return undefined
132
+ // If already in_progress and holding claim, ensure running execution
133
+ if (existing.status === 'in_progress' && existing.claimedBy === sessionId) {
134
+ const hasRunning = existing.executions.some(e => e.sessionId === sessionId && e.outcome === 'running')
135
+ if (!hasRunning) {
136
+ existing.executions.push({
137
+ id: newExecutionId(),
138
+ sessionId,
139
+ trigger: 'manual',
140
+ startedAt: now,
141
+ outcome: 'running',
142
+ isolation: 'none',
143
+ })
144
+ existing.updatedAt = now
145
+ existing.updatedBy = { kind: 'agent', sessionId }
146
+ return [existing]
147
+ }
148
+ return undefined
149
+ }
150
+
151
+ // Resumed or continued turn (e.g. from in_review or todo)
152
+ existing.status = 'in_progress'
153
+ existing.claimedBy = sessionId
154
+ existing.claimedAt = now
155
+ existing.updatedAt = now
156
+ existing.updatedBy = { kind: 'agent', sessionId }
157
+ existing.executions.push({
158
+ id: newExecutionId(),
159
+ sessionId,
160
+ trigger: 'manual',
161
+ startedAt: now,
162
+ outcome: 'running',
163
+ isolation: 'none',
164
+ })
165
+ return [existing]
166
+ }
167
+
168
+ // Create new task for this external session
169
+ const shortId = sessionId.replace(/^session-/, '').slice(0, 8)
170
+ const newTask: TaskRecord = {
171
+ id: newTaskId(),
172
+ title: `会话 ${shortId}`,
173
+ description: '',
174
+ prompt: '',
175
+ workspaceId: wsId,
176
+ urgency: 'normal',
177
+ status: 'in_progress',
178
+ blocked: false,
179
+ execution: { mode: 'claim' },
180
+ isolation: 'none',
181
+ claimedBy: sessionId,
182
+ claimedAt: now,
183
+ version: 1,
184
+ createdAt: now,
185
+ updatedAt: now,
186
+ createdBy: { kind: 'agent', sessionId },
187
+ updatedBy: { kind: 'agent', sessionId },
188
+ comments: [],
189
+ executions: [
190
+ {
191
+ id: newExecutionId(),
192
+ sessionId,
193
+ trigger: 'manual',
194
+ startedAt: now,
195
+ outcome: 'running',
196
+ isolation: 'none',
197
+ },
198
+ ],
199
+ }
200
+ ledger.tasks.push(newTask)
201
+ return [newTask]
202
+ })
203
+ }
204
+
205
+ private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {
206
+ const text = extractUserMessageText(msgData)
207
+ if (text.trim().length === 0) return
208
+
209
+ await this.deps.store.mutate('task-updated', (ledger) => {
210
+ const task = ledger.tasks.find(
211
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
212
+ )
213
+ if (task === undefined || task.trashedAt !== undefined) return undefined
214
+
215
+ let changed = false
216
+ // If title is default placeholder "会话 ...", replace with prompt summary
217
+ if (task.title.startsWith('会话 ') && task.title.length <= 16) {
218
+ const derived = titleFromText(text)
219
+ if (derived.length > 0) {
220
+ task.title = normalizeTitle(derived)
221
+ changed = true
222
+ }
223
+ }
224
+ // If description is empty, record initial prompt
225
+ if (task.description.length === 0) {
226
+ task.description = text.slice(0, 2000)
227
+ changed = true
228
+ }
229
+ if (changed) {
230
+ task.updatedAt = now
231
+ task.updatedBy = { kind: 'user' }
232
+ return [task]
233
+ }
234
+ return undefined
235
+ })
236
+ }
237
+
238
+ private async handleSessionTitle(sessionId: string, titleData: unknown, now: number): Promise<void> {
239
+ const rawTitle = typeof titleData === 'object' && titleData !== null && 'title' in titleData && typeof (titleData as { title: unknown }).title === 'string'
240
+ ? (titleData as { title: string }).title
241
+ : typeof titleData === 'string'
242
+ ? titleData
243
+ : ''
244
+ if (rawTitle.trim().length === 0) return
245
+
246
+ await this.deps.store.mutate('task-updated', (ledger) => {
247
+ const task = ledger.tasks.find(
248
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
249
+ )
250
+ if (task === undefined || task.trashedAt !== undefined) return undefined
251
+ task.title = normalizeTitle(rawTitle)
252
+ task.updatedAt = now
253
+ task.updatedBy = { kind: 'user' }
254
+ return [task]
255
+ })
256
+ }
257
+
258
+ private async handleTurnEnd(sessionId: string, endData: unknown, now: number): Promise<void> {
259
+ const reason = typeof endData === 'object' && endData !== null && 'reason' in endData
260
+ ? (endData as { reason: unknown }).reason
261
+ : endData
262
+
263
+ // Check if error or failure
264
+ let isFailure = false
265
+ let errorMessage = ''
266
+ if (typeof reason === 'object' && reason !== null) {
267
+ const r = reason as Record<string, unknown>
268
+ if (r.kind === 'error' || r.kind === 'failure') {
269
+ isFailure = true
270
+ errorMessage = typeof r.error === 'string' ? r.error : typeof r.message === 'string' ? r.message : 'turn error'
271
+ } else if (r.kind === 'cancel') {
272
+ isFailure = true
273
+ errorMessage = 'cancelled'
274
+ }
275
+ } else if (typeof reason === 'string' && (reason.includes('error') || reason.includes('fail'))) {
276
+ isFailure = true
277
+ errorMessage = reason
278
+ }
279
+
280
+ await this.deps.store.mutate('execution-recorded', (ledger) => {
281
+ const task = ledger.tasks.find(
282
+ t => t.claimedBy === sessionId || t.executions.some(e => e.sessionId === sessionId),
283
+ )
284
+ if (task === undefined || task.trashedAt !== undefined) return undefined
285
+
286
+ // Settle running execution
287
+ for (const exec of task.executions) {
288
+ if (exec.sessionId === sessionId && exec.outcome === 'running') {
289
+ exec.endedAt = now
290
+ if (isFailure) {
291
+ exec.outcome = 'failed'
292
+ exec.error = errorMessage.slice(0, 500)
293
+ } else {
294
+ exec.outcome = 'succeeded'
295
+ }
296
+ }
297
+ }
298
+
299
+ delete task.claimedBy
300
+ delete task.claimedAt
301
+ task.updatedAt = now
302
+ task.updatedBy = { kind: 'agent', sessionId }
303
+
304
+ if (isFailure) {
305
+ // Failed session hands back to todo with comment
306
+ if (task.status === 'in_progress') {
307
+ task.status = 'todo'
308
+ task.comments.push({
309
+ id: newCommentId(),
310
+ body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
311
+ version: 1,
312
+ createdAt: now,
313
+ })
314
+ }
315
+ } else {
316
+ // Successful settlement automatically moves to in_review (待验收)
317
+ if (task.status === 'in_progress') {
318
+ task.status = 'in_review'
319
+ task.comments.push({
320
+ id: newCommentId(),
321
+ body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),
322
+ version: 1,
323
+ createdAt: now,
324
+ })
325
+ }
326
+ }
327
+ return [task]
328
+ })
329
+ }
330
+ }
package/src/host/tools.ts CHANGED
@@ -91,7 +91,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
91
91
  const holder = isClaimedBy(t)
92
92
  if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
93
93
  if (t.execution.nextRunAt !== undefined) lines.push(`下次触发: ${new Date(t.execution.nextRunAt).toISOString()}`)
94
- if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}`)
94
+ if (t.model !== undefined) lines.push(`固定模型: ${t.model.provider}/${t.model.model}${t.model.reasoningEffort !== undefined ? ` (思考强度: ${t.model.reasoningEffort})` : ''}`)
95
95
  if (t.presetId !== undefined) lines.push(`执行模式: ${t.presetId}(未指定时为部署默认 preset)`)
96
96
  lines.push(`描述: ${t.description.length > 0 ? t.description : '(无)'}`)
97
97
  lines.push(`执行 Prompt: ${t.effectivePrompt ?? effectivePrompt(t)}`)
@@ -383,10 +383,11 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
383
383
  model: {
384
384
  type: 'object',
385
385
  additionalProperties: false,
386
- description: 'Pin executions to one configured model: { provider, model }. Omit to use the default model.',
386
+ description: 'Pin executions to one configured model: { provider, model, reasoningEffort? }. Omit to use the default model.',
387
387
  properties: {
388
388
  provider: { type: 'string', description: 'Provider route id.' },
389
389
  model: { type: 'string', description: 'Provider-owned model id.' },
390
+ reasoningEffort: { type: 'string', description: 'Optional thinking intensity / reasoning effort (e.g. low, medium, high).' },
390
391
  },
391
392
  },
392
393
  isolation: {
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ import { SchedulerService } from './host/scheduler.ts'
27
27
  import { dshHomePath } from './host/sdk.ts'
28
28
  import { TaskStore } from './host/store.ts'
29
29
  import { TemplateStore } from './host/templates.ts'
30
+ import { ExternalSessionSyncService } from './host/session-sync.ts'
30
31
  import { registerTaskboardTools, workspaceFace } from './host/tools.ts'
31
32
 
32
33
  /** Ledger file name under the DSH home. */
@@ -93,10 +94,19 @@ export function apply(ctx: Context): void {
93
94
  // Settlement listener over the session event bus.
94
95
  const events: EventsFace = {
95
96
  onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {
96
- listener(session.id, event as { type: string; data?: unknown })
97
+ listener(session.id, event as { type: string; data?: unknown }, session as { header?: { cwd?: string } })
97
98
  }),
98
99
  }
99
100
 
101
+ // External workspace sessions sync service (0.5.4).
102
+ const sessionSync = new ExternalSessionSyncService({
103
+ store,
104
+ workspaces: workspaceFace(wsCtx.workspaceRegistry),
105
+ events,
106
+ now,
107
+ })
108
+ disposers.push(() => sessionSync.dispose())
109
+
100
110
  // The narrow git face shared by execution (worktree isolation) and the
101
111
  // routes (merge / remove / workspace detection).
102
112
  const git = createGitFace()
package/src/shared/api.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * @module dsh-taskboard/shared/api
7
7
  */
8
- import type { BoardSettings, TaskLedger, TaskRecord, TaskSummary } from './protocol.ts'
8
+ import type { BoardSettings, TaskLedger, TaskModel, TaskRecord, TaskSummary } from './protocol.ts'
9
9
 
10
- export type { TaskRecord }
10
+ export type { TaskModel, TaskRecord }
11
11
 
12
12
  /** Route prefix on the shared DSH webserver (same origin as the GUI). */
13
13
  export const ROUTE_PREFIX = '/dsh-taskboard'
@@ -51,7 +51,7 @@ export type CreateTaskBody = {
51
51
  description?: string
52
52
  prompt?: string
53
53
  execution?: { mode?: string; cron?: string }
54
- model?: { provider: string; model: string }
54
+ model?: TaskModel
55
55
  /** Code isolation for executions ('worktree' | 'none'); omitted = default. */
56
56
  isolation?: string
57
57
  /** Agent preset for execution sessions; omitted = deployment default. */
@@ -71,7 +71,7 @@ export type UpdateTaskBody = {
71
71
  /** Rebind the task to another project (GUI owner surface only). */
72
72
  workspaceId?: string
73
73
  execution?: { mode?: string; cron?: string }
74
- model?: { provider: string; model: string } | null
74
+ model?: TaskModel | null
75
75
  /** Change isolation; locked once the task has execution history. */
76
76
  isolation?: string
77
77
  /** Change the execution preset (takes effect on the next run). */
@@ -130,7 +130,7 @@ export type TaskTemplateSpec = {
130
130
  prompt?: string
131
131
  urgency?: string
132
132
  execution?: { mode?: string; cron?: string }
133
- model?: { provider: string; model: string }
133
+ model?: TaskModel
134
134
  isolation?: string
135
135
  presetId?: string
136
136
  /** Checklist item texts (host mints ids at create time). */
@@ -158,6 +158,8 @@ export type SettingsResponse = BoardSettings
158
158
  export type UpdateSettingsBody = {
159
159
  /** Default code isolation for NEW tasks ('worktree' | 'none'). */
160
160
  defaultIsolation?: string
161
+ /** Automatically capture external workspace sessions into the taskboard. */
162
+ syncExternalSessions?: boolean
161
163
  }
162
164
 
163
165
  /** Import dry-run response (0.4.0): every task classified, nothing written. */
@@ -141,6 +141,8 @@ export function effectiveIsolation(task: Pick<TaskRecord, 'isolation'>): Isolati
141
141
  export type BoardSettings = {
142
142
  /** Default code isolation applied when a NEW task is created without an explicit choice. */
143
143
  defaultIsolation?: IsolationMode
144
+ /** Automatically capture external workspace sessions into the taskboard (default: false). */
145
+ syncExternalSessions?: boolean
144
146
  }
145
147
 
146
148
  /** Validate raw input into sanitized {@link BoardSettings} (unknown fields dropped). */
@@ -156,6 +158,12 @@ export function asBoardSettings(raw: unknown): BoardSettings {
156
158
  }
157
159
  out.defaultIsolation = asIsolation(e.defaultIsolation)
158
160
  }
161
+ if (e.syncExternalSessions !== undefined) {
162
+ if (typeof e.syncExternalSessions !== 'boolean') {
163
+ throw new Error('syncExternalSessions must be a boolean')
164
+ }
165
+ out.syncExternalSessions = e.syncExternalSessions
166
+ }
159
167
  return out
160
168
  }
161
169
 
@@ -164,6 +172,11 @@ export function defaultIsolationOf(settings?: BoardSettings): IsolationMode {
164
172
  return settings?.defaultIsolation ?? DEFAULT_ISOLATION
165
173
  }
166
174
 
175
+ /** The effective external session sync switch (board setting → factory default false). */
176
+ export function defaultSyncExternalSessionsOf(settings?: BoardSettings): boolean {
177
+ return settings?.syncExternalSessions ?? false
178
+ }
179
+
167
180
  /** How a task may run. */
168
181
  export type ExecutionMode = 'claim' | 'scheduled'
169
182
 
@@ -378,6 +391,8 @@ export type ExecutionRecord = {
378
391
  export type TaskModel = {
379
392
  provider: string
380
393
  model: string
394
+ /** Thinking intensity / reasoning effort (optional; e.g. 'low', 'medium', 'high', 'none'). */
395
+ reasoningEffort?: string
381
396
  }
382
397
 
383
398
  /** One task on the board. */
@@ -638,8 +653,8 @@ export function syncClaim(task: TaskRecord, to: TaskStatus, now: number, holder?
638
653
  }
639
654
 
640
655
  /**
641
- * Validate and normalize a pinned model: `{ provider, model }`, both
642
- * non-empty trimmed strings.
656
+ * Validate and normalize a pinned model: `{ provider, model, reasoningEffort? }`,
657
+ * provider and model must be non-empty trimmed strings.
643
658
  * @param raw - the raw input.
644
659
  * @returns the normalized model.
645
660
  * @throws when the shape or the fields are invalid.
@@ -648,7 +663,7 @@ export function normalizeModel(raw: unknown): TaskModel {
648
663
  if (typeof raw !== 'object' || raw === null) {
649
664
  throw new Error('model must be { provider: string, model: string }')
650
665
  }
651
- const { provider, model } = raw as { provider?: unknown; model?: unknown }
666
+ const { provider, model, reasoningEffort } = raw as { provider?: unknown; model?: unknown; reasoningEffort?: unknown }
652
667
  if (typeof provider !== 'string' || typeof model !== 'string') {
653
668
  throw new Error('model must be { provider: string, model: string }')
654
669
  }
@@ -657,7 +672,8 @@ export function normalizeModel(raw: unknown): TaskModel {
657
672
  if (p.length === 0 || m.length === 0) {
658
673
  throw new Error('model.provider and model.model must be non-empty strings')
659
674
  }
660
- return { provider: p, model: m }
675
+ const eff = typeof reasoningEffort === 'string' && reasoningEffort.trim().length > 0 ? reasoningEffort.trim() : undefined
676
+ return { provider: p, model: m, ...(eff !== undefined ? { reasoningEffort: eff } : {}) }
661
677
  }
662
678
 
663
679
  // ---------------------------------------------------------------------------
@@ -6,4 +6,4 @@
6
6
  */
7
7
 
8
8
  /** The package version (must equal package.json "version"). */
9
- export const PLUGIN_VERSION = '0.5.3'
9
+ export const PLUGIN_VERSION = '0.5.4'