dsh-taskboard 0.5.5 → 0.6.1

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 +26 -2
  2. package/lib/client.js +2663 -767
  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 +210 -10
  8. package/lib/host/session-sync.js.map +1 -1
  9. package/lib/host/store.js +9 -2
  10. package/lib/host/store.js.map +1 -1
  11. package/lib/index.js +100 -1
  12. package/lib/index.js.map +1 -1
  13. package/lib/shared/api.js.map +1 -1
  14. package/lib/shared/protocol.js +19 -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 +66 -26
  21. package/src/client/board/SlashPromptInput.tsx +340 -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 +133 -0
  35. package/src/host/execution.ts +13 -0
  36. package/src/host/routes.ts +49 -1
  37. package/src/host/session-sync.ts +334 -14
  38. package/src/host/store.ts +15 -1
  39. package/src/index.ts +115 -1
  40. package/src/shared/api.ts +47 -0
  41. package/src/shared/protocol.ts +41 -0
  42. package/src/shared/version.ts +1 -1
@@ -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?: { header?: { cwd?: string } },
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. Ignore taskboard's internal execution sessions
84
- if (sessionId.startsWith('session-taskboard-')) return
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
- // 2. Check if external session sync is enabled in board settings
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
- // 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)
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
- // If description is empty, record initial prompt
225
- if (task.description.length === 0) {
226
- task.description = text.slice(0, 2000)
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
- 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
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 { header?: { cwd?: string } })
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. */