dsh-taskboard 0.5.5 → 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.
- package/README.md +22 -2
- package/lib/client.js +2604 -767
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/routes.js +31 -1
- package/lib/host/routes.js.map +1 -1
- package/lib/host/session-sync.js +210 -10
- package/lib/host/session-sync.js.map +1 -1
- package/lib/host/store.js +9 -2
- package/lib/host/store.js.map +1 -1
- package/lib/index.js +100 -1
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +19 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +75 -75
- package/src/client/api.ts +8 -0
- package/src/client/board/AlertModal.tsx +3 -1
- package/src/client/board/ImportModal.tsx +26 -24
- package/src/client/board/SettingsModal.tsx +66 -26
- package/src/client/board/SlashPromptInput.tsx +272 -0
- package/src/client/board/TaskBoard.tsx +53 -49
- package/src/client/board/TaskCard.tsx +33 -21
- package/src/client/board/TaskDetail.tsx +169 -104
- package/src/client/board/TaskFormModal.tsx +254 -202
- package/src/client/board/TemplateManager.tsx +32 -29
- package/src/client/board/labels.ts +36 -27
- package/src/client/controller.ts +62 -2
- package/src/client/i18n/en.ts +455 -0
- package/src/client/i18n/runtime.ts +155 -0
- package/src/client/i18n/zh.ts +460 -0
- package/src/client/index.ts +182 -42
- package/src/client/sidebar-entry.ts +13 -3
- package/src/client/styles.ts +131 -0
- package/src/host/execution.ts +13 -0
- package/src/host/routes.ts +49 -1
- package/src/host/session-sync.ts +334 -14
- package/src/host/store.ts +15 -1
- package/src/index.ts +115 -1
- package/src/shared/api.ts +47 -0
- package/src/shared/protocol.ts +41 -0
- package/src/shared/version.ts +1 -1
package/src/host/session-sync.ts
CHANGED
|
@@ -50,40 +50,290 @@ export function titleFromText(text: string): string {
|
|
|
50
50
|
return firstLine.slice(0, 50).trim()
|
|
51
51
|
}
|
|
52
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
|
+
|
|
53
190
|
/** Dependencies required by the external session sync service. */
|
|
54
191
|
export interface SessionSyncDeps {
|
|
55
192
|
store: TaskStore
|
|
56
193
|
workspaces: WorkspaceFace
|
|
57
194
|
events: EventsFace
|
|
195
|
+
sessions?: {
|
|
196
|
+
get?: (id: string) => unknown
|
|
197
|
+
list?: () => unknown[]
|
|
198
|
+
}
|
|
58
199
|
now: () => number
|
|
200
|
+
scanIntervalMs?: number
|
|
59
201
|
}
|
|
60
202
|
|
|
203
|
+
/** Default scan interval: 4s. */
|
|
204
|
+
export const DEFAULT_SCAN_INTERVAL_MS = 4000
|
|
205
|
+
|
|
61
206
|
/**
|
|
62
207
|
* Service that synchronizes external workspace sessions into the taskboard.
|
|
63
208
|
*/
|
|
64
209
|
export class ExternalSessionSyncService {
|
|
65
210
|
private readonly unsubscribe: () => void
|
|
211
|
+
private readonly ignoredSessions = new Set<string>()
|
|
212
|
+
private scanTimer?: NodeJS.Timeout | number
|
|
66
213
|
|
|
67
214
|
constructor(private readonly deps: SessionSyncDeps) {
|
|
68
215
|
this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
|
|
69
216
|
void this.handleSessionEvent(sessionId, event, sessionMeta)
|
|
70
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
|
+
}
|
|
71
225
|
}
|
|
72
226
|
|
|
73
|
-
/** Detach listener on teardown. */
|
|
227
|
+
/** Detach listener and clear scanner on teardown. */
|
|
74
228
|
dispose(): void {
|
|
75
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
|
+
}
|
|
76
299
|
}
|
|
77
300
|
|
|
78
301
|
private async handleSessionEvent(
|
|
79
302
|
sessionId: string,
|
|
80
303
|
event: { type: string; data?: unknown },
|
|
81
|
-
sessionMeta?: {
|
|
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
|
+
},
|
|
82
309
|
): Promise<void> {
|
|
83
|
-
// 1.
|
|
84
|
-
if (
|
|
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
|
+
}
|
|
85
335
|
|
|
86
|
-
//
|
|
336
|
+
// 4. Check if external session sync is enabled in board settings
|
|
87
337
|
const snapshot = this.deps.store.snapshot()
|
|
88
338
|
if (!defaultSyncExternalSessionsOf(snapshot.settings)) return
|
|
89
339
|
|
|
@@ -99,6 +349,17 @@ export class ExternalSessionSyncService {
|
|
|
99
349
|
return
|
|
100
350
|
}
|
|
101
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
|
+
|
|
102
363
|
if (event.type === 'session/title') {
|
|
103
364
|
await this.handleSessionTitle(sessionId, event.data, now)
|
|
104
365
|
return
|
|
@@ -110,6 +371,41 @@ export class ExternalSessionSyncService {
|
|
|
110
371
|
}
|
|
111
372
|
}
|
|
112
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
|
+
|
|
113
409
|
private async handleTurnStart(sessionId: string, cwd: string | undefined, now: number): Promise<void> {
|
|
114
410
|
// Resolve workspace
|
|
115
411
|
let wsId: string | undefined
|
|
@@ -204,7 +500,6 @@ export class ExternalSessionSyncService {
|
|
|
204
500
|
|
|
205
501
|
private async handleUserMessage(sessionId: string, msgData: unknown, now: number): Promise<void> {
|
|
206
502
|
const text = extractUserMessageText(msgData)
|
|
207
|
-
if (text.trim().length === 0) return
|
|
208
503
|
|
|
209
504
|
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
210
505
|
const task = ledger.tasks.find(
|
|
@@ -213,19 +508,44 @@ export class ExternalSessionSyncService {
|
|
|
213
508
|
if (task === undefined || task.trashedAt !== undefined) return undefined
|
|
214
509
|
|
|
215
510
|
let changed = false
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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)
|
|
221
523
|
changed = true
|
|
222
524
|
}
|
|
223
525
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
})
|
|
227
546
|
changed = true
|
|
228
547
|
}
|
|
548
|
+
|
|
229
549
|
if (changed) {
|
|
230
550
|
task.updatedAt = now
|
|
231
551
|
task.updatedBy = { kind: 'user' }
|
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
|
-
|
|
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
|
package/src/index.ts
CHANGED
|
@@ -94,15 +94,31 @@ export function apply(ctx: Context): void {
|
|
|
94
94
|
// Settlement listener over the session event bus.
|
|
95
95
|
const events: EventsFace = {
|
|
96
96
|
onSessionEvent: (listener) => wsCtx.on('session/event', (session, event) => {
|
|
97
|
-
listener(session.id, event as { type: string; data?: unknown }, session as
|
|
97
|
+
listener(session.id, event as { type: string; data?: unknown }, session as never)
|
|
98
98
|
}),
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
let agentSessions: { get?: (id: string) => unknown; list?: () => unknown[] } | undefined
|
|
102
|
+
|
|
101
103
|
// External workspace sessions sync service (0.5.4).
|
|
102
104
|
const sessionSync = new ExternalSessionSyncService({
|
|
103
105
|
store,
|
|
104
106
|
workspaces: workspaceFace(wsCtx.workspaceRegistry),
|
|
105
107
|
events,
|
|
108
|
+
sessions: {
|
|
109
|
+
get: id => {
|
|
110
|
+
try {
|
|
111
|
+
const registry = (agentSessions ?? wsCtx.get('sessions') ?? wsCtx.get('sessionRegistry') ?? wsCtx.root?.get('sessions')) as { get?: (id: string) => unknown } | undefined
|
|
112
|
+
return registry?.get?.(id)
|
|
113
|
+
} catch { return undefined }
|
|
114
|
+
},
|
|
115
|
+
list: () => {
|
|
116
|
+
try {
|
|
117
|
+
const registry = (agentSessions ?? wsCtx.get('sessions') ?? wsCtx.get('sessionRegistry') ?? wsCtx.root?.get('sessions')) as { list?: () => unknown[] } | undefined
|
|
118
|
+
return registry?.list?.() ?? []
|
|
119
|
+
} catch { return [] }
|
|
120
|
+
},
|
|
121
|
+
},
|
|
106
122
|
now,
|
|
107
123
|
})
|
|
108
124
|
disposers.push(() => sessionSync.dispose())
|
|
@@ -112,6 +128,7 @@ export function apply(ctx: Context): void {
|
|
|
112
128
|
const git = createGitFace()
|
|
113
129
|
|
|
114
130
|
wsCtx.inject(['agents'], (agentCtx: Context) => {
|
|
131
|
+
agentSessions = agentCtx.get('sessions') as { get?: (id: string) => unknown; list?: () => unknown[] } | undefined
|
|
115
132
|
const execution = new ExecutionService({
|
|
116
133
|
store,
|
|
117
134
|
agents: {
|
|
@@ -160,6 +177,16 @@ export function apply(ctx: Context): void {
|
|
|
160
177
|
return read === undefined ? undefined : read.call(selection)
|
|
161
178
|
} catch { return undefined }
|
|
162
179
|
},
|
|
180
|
+
setPermission: (sessionId, permission) => {
|
|
181
|
+
try {
|
|
182
|
+
const permService = agentCtx.get('permissionPresets') as { set(session: unknown, name: string): void } | undefined
|
|
183
|
+
const sessions = agentCtx.get('sessions') as { get(id: string): unknown } | undefined
|
|
184
|
+
const session = sessions?.get(sessionId)
|
|
185
|
+
if (session !== undefined && permService !== undefined) {
|
|
186
|
+
permService.set(session, permission)
|
|
187
|
+
}
|
|
188
|
+
} catch { /* cosmetic */ }
|
|
189
|
+
},
|
|
163
190
|
maxConcurrent,
|
|
164
191
|
})
|
|
165
192
|
|
|
@@ -175,6 +202,93 @@ export function apply(ctx: Context): void {
|
|
|
175
202
|
modelProviders,
|
|
176
203
|
git,
|
|
177
204
|
templates,
|
|
205
|
+
promptCompletions: async () => {
|
|
206
|
+
try {
|
|
207
|
+
const skillsService = agentCtx.get('skills') as { list?(options?: unknown): Promise<Array<{ name: string; description?: string }>> } | undefined
|
|
208
|
+
const commandsService = agentCtx.get('commands') as { list?(): Array<{ name: string; description?: string; input?: { hint?: string } }> } | undefined
|
|
209
|
+
const rawSkills = skillsService?.list ? await skillsService.list().catch(() => []) : []
|
|
210
|
+
const rawCommands = commandsService?.list ? commandsService.list() : []
|
|
211
|
+
return {
|
|
212
|
+
skills: Array.isArray(rawSkills) ? rawSkills.map(s => ({ name: s.name, description: s.description })) : [],
|
|
213
|
+
commands: Array.isArray(rawCommands) ? rawCommands.map(c => ({ name: c.name, description: c.description, hint: c.input?.hint })) : [],
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
return { skills: [], commands: [] }
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
modelCatalog: async () => {
|
|
220
|
+
try {
|
|
221
|
+
type ModelItem = {
|
|
222
|
+
provider: string
|
|
223
|
+
model: string
|
|
224
|
+
name?: string
|
|
225
|
+
description?: string
|
|
226
|
+
reasoning?: {
|
|
227
|
+
efforts: Array<{ id: string; name: string; description?: string }>
|
|
228
|
+
defaultEffort?: string
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const models: ModelItem[] = []
|
|
232
|
+
|
|
233
|
+
const llm = (agentCtx.get('llm') ?? wsCtx.get('llm')) as {
|
|
234
|
+
listProviders?(): Array<{ id: string; name?: string }>
|
|
235
|
+
listModels?(provider: string): Promise<Array<{ id: string; name?: string; description?: string }>>
|
|
236
|
+
resolveModelInfo?(provider: string, model: string): Promise<{ reasoning?: { efforts: Array<{ id: string; name: string; description?: string }>; defaultEffort?: string } }>
|
|
237
|
+
resolveModel?(provider: string, model: string): Promise<{ reasoning?: { efforts: Array<{ id: string; name: string; description?: string }>; defaultEffort?: string } }>
|
|
238
|
+
} | undefined
|
|
239
|
+
|
|
240
|
+
if (llm?.listProviders !== undefined && llm.listModels !== undefined) {
|
|
241
|
+
const providers = llm.listProviders()
|
|
242
|
+
for (const p of providers) {
|
|
243
|
+
try {
|
|
244
|
+
const list = await llm.listModels(p.id)
|
|
245
|
+
for (const m of list) {
|
|
246
|
+
let reasoning: { efforts: Array<{ id: string; name: string; description?: string }>; defaultEffort?: string } | undefined
|
|
247
|
+
try {
|
|
248
|
+
const meta = llm.resolveModelInfo !== undefined
|
|
249
|
+
? await llm.resolveModelInfo(p.id, m.id)
|
|
250
|
+
: llm.resolveModel !== undefined ? await llm.resolveModel(p.id, m.id) : undefined
|
|
251
|
+
if (meta?.reasoning !== undefined) {
|
|
252
|
+
reasoning = meta.reasoning
|
|
253
|
+
}
|
|
254
|
+
} catch { /* ignore */ }
|
|
255
|
+
|
|
256
|
+
models.push({
|
|
257
|
+
provider: p.id,
|
|
258
|
+
model: m.id,
|
|
259
|
+
name: m.name,
|
|
260
|
+
...(m.description ? { description: m.description } : {}),
|
|
261
|
+
...(reasoning !== undefined ? { reasoning } : {}),
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
} catch { /* continue */ }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const presetsService = agentCtx.get('agentPresets') as {
|
|
269
|
+
list?(): Promise<{ ok: boolean; value?: { presets: Array<{ id: string; name?: string; isDefault?: boolean }> } } | Array<{ id: string; name?: string; isDefault?: boolean }>>
|
|
270
|
+
} | undefined
|
|
271
|
+
const presets: Array<{ id: string; name?: string }> = []
|
|
272
|
+
let defaultPresetId: string | undefined
|
|
273
|
+
|
|
274
|
+
if (presetsService?.list !== undefined) {
|
|
275
|
+
try {
|
|
276
|
+
const raw = await presetsService.list()
|
|
277
|
+
const list = (raw as { ok?: boolean; value?: { presets?: unknown[] } }).ok === true
|
|
278
|
+
? (raw as { value: { presets: Array<{ id: string; name?: string; isDefault?: boolean }> } }).value.presets
|
|
279
|
+
: Array.isArray(raw) ? raw : []
|
|
280
|
+
for (const p of list) {
|
|
281
|
+
presets.push({ id: p.id, name: p.name })
|
|
282
|
+
if (p.isDefault) defaultPresetId = p.id
|
|
283
|
+
}
|
|
284
|
+
} catch { /* continue */ }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { models, presets, ...(defaultPresetId !== undefined ? { defaultPresetId } : {}) }
|
|
288
|
+
} catch {
|
|
289
|
+
return { models: [], presets: [] }
|
|
290
|
+
}
|
|
291
|
+
},
|
|
178
292
|
})
|
|
179
293
|
return () => disposeRoutes?.()
|
|
180
294
|
})
|
package/src/shared/api.ts
CHANGED
|
@@ -56,6 +56,8 @@ export type CreateTaskBody = {
|
|
|
56
56
|
isolation?: string
|
|
57
57
|
/** Agent preset for execution sessions; omitted = deployment default. */
|
|
58
58
|
presetId?: string
|
|
59
|
+
/** Execution permission preset ('workspace-write' | 'read-only' | 'danger-full-access'); omitted = default. */
|
|
60
|
+
permission?: string
|
|
59
61
|
/** Acceptance checklist item texts (host mints ids, all unchecked). */
|
|
60
62
|
checklist?: string[]
|
|
61
63
|
}
|
|
@@ -76,6 +78,8 @@ export type UpdateTaskBody = {
|
|
|
76
78
|
isolation?: string
|
|
77
79
|
/** Change the execution preset (takes effect on the next run). */
|
|
78
80
|
presetId?: string | null
|
|
81
|
+
/** Change the execution permission (0.5.5; 'workspace-write' | 'read-only' | 'danger-full-access'). */
|
|
82
|
+
permission?: string | null
|
|
79
83
|
/** Replace the whole checklist (GUI owner surface); null clears it. */
|
|
80
84
|
checklist?: unknown
|
|
81
85
|
}
|
|
@@ -133,6 +137,8 @@ export type TaskTemplateSpec = {
|
|
|
133
137
|
model?: TaskModel
|
|
134
138
|
isolation?: string
|
|
135
139
|
presetId?: string
|
|
140
|
+
/** Execution permission preset (0.5.5). */
|
|
141
|
+
permission?: string
|
|
136
142
|
/** Checklist item texts (host mints ids at create time). */
|
|
137
143
|
checklist?: string[]
|
|
138
144
|
}
|
|
@@ -160,6 +166,47 @@ export type UpdateSettingsBody = {
|
|
|
160
166
|
defaultIsolation?: string
|
|
161
167
|
/** Automatically capture external workspace sessions into the taskboard. */
|
|
162
168
|
syncExternalSessions?: boolean
|
|
169
|
+
/** Default permission preset for NEW tasks ('workspace-write' | 'read-only' | 'danger-full-access'). */
|
|
170
|
+
defaultPermission?: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Prompt completion item for skills and slash commands (0.5.5). */
|
|
174
|
+
export type PromptCompletionItem = {
|
|
175
|
+
name: string
|
|
176
|
+
kind: 'skill' | 'command'
|
|
177
|
+
description?: string
|
|
178
|
+
hint?: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Prompt completions response (0.5.5). */
|
|
182
|
+
export type PromptCompletionsResponse = {
|
|
183
|
+
commands: PromptCompletionItem[]
|
|
184
|
+
skills: PromptCompletionItem[]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Model item in catalog (0.5.5). */
|
|
188
|
+
export type CatalogModelItem = {
|
|
189
|
+
provider: string
|
|
190
|
+
model: string
|
|
191
|
+
name?: string
|
|
192
|
+
description?: string
|
|
193
|
+
reasoning?: {
|
|
194
|
+
efforts: Array<{ id: string; name: string; description?: string }>
|
|
195
|
+
defaultEffort?: string
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Preset item in catalog (0.5.5). */
|
|
200
|
+
export type CatalogPresetItem = {
|
|
201
|
+
id: string
|
|
202
|
+
name?: string
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Model and preset catalog response (0.5.5). */
|
|
206
|
+
export type ModelCatalogResponse = {
|
|
207
|
+
models: CatalogModelItem[]
|
|
208
|
+
presets: CatalogPresetItem[]
|
|
209
|
+
defaultPresetId?: string
|
|
163
210
|
}
|
|
164
211
|
|
|
165
212
|
/** Import dry-run response (0.4.0): every task classified, nothing written. */
|