@fonlan/dsh-task-kanban 0.1.1 → 0.2.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.
@@ -10,9 +10,9 @@ import { appendFile, readFile } from 'node:fs/promises'
10
10
  import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
11
11
  import { join } from 'node:path'
12
12
  import type { Context } from '@deepseek-ai/cordis'
13
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
14
- import type { AgentHandle } from '@deepseek-ai/dsh-agent'
15
- import type { KanbanCard, ErrorKind, Lane, Plan } from '../shared/card.js'
13
+ import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
14
+ import { installModelSelection, type AgentHandle, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
15
+ import type { KanbanCard, ErrorKind, KanbanSessionKind, Lane, Plan } from '../shared/card.js'
16
16
  import type { TaskStore } from './task-store.js'
17
17
  import type { KanbanSettingsFace } from './settings.js'
18
18
  import { currentBranch, detectBaseRef, hasStashMessage, isGitRepo, isTreeDirty, revParse, runGit, unmergedPaths } from './git.js'
@@ -49,6 +49,11 @@ export class KanbanRunner implements KanbanToolResolver {
49
49
  private merging = new Set<string>()
50
50
  /** Retained agent handles (kept so ended sessions stay in the store/UI). */
51
51
  private handles = new Map<string, AgentHandle[]>()
52
+ /** Whether startup recovery + session indexing ran (deferred until workspaces exist). */
53
+ private recovered = false
54
+ /** Agents that already got their scoped kanban tools (event idempotency). */
55
+ private toolScopedAgents = new WeakSet<object>()
56
+ private agentStartOff: (() => void) | undefined
52
57
 
53
58
  constructor(ctx: Context, store: TaskStore, settings: KanbanSettingsFace) {
54
59
  this.ctx = ctx
@@ -62,10 +67,22 @@ export class KanbanRunner implements KanbanToolResolver {
62
67
 
63
68
  start(): void {
64
69
  this.pumpTimer = setInterval(() => { void this.pump() }, 2500)
70
+ // `agent/session-start` fires for BOTH agents.create (startup) and
71
+ // agents.resume (host re-open after a restart). Kanban tools are scoped
72
+ // per agent, so a resumed refinement/phase/merge session would otherwise
73
+ // lose them — register there instead of only in createAgent's setup.
74
+ this.agentStartOff = this.ctx.on('agent/session-start', (payload: { agent?: { id?: string; ctx?: Context } }) => {
75
+ void this.onAgentSessionStart(payload.agent)
76
+ })
65
77
  }
66
78
 
67
79
  stop(): void {
68
80
  if (this.pumpTimer !== undefined) clearInterval(this.pumpTimer)
81
+ this.pumpTimer = undefined
82
+ if (this.agentStartOff !== undefined) {
83
+ this.agentStartOff()
84
+ this.agentStartOff = undefined
85
+ }
69
86
  }
70
87
 
71
88
  private newSessionId(): SessionId {
@@ -157,6 +174,63 @@ export class KanbanRunner implements KanbanToolResolver {
157
174
  }
158
175
  }
159
176
 
177
+ /**
178
+ * Rebuild sessionId → cardId from the task store. Runs once the workspace
179
+ * registry is available (see pump), so kanban sessions resumed after a
180
+ * restart still resolve their card.
181
+ */
182
+ private async indexSessions(): Promise<void> {
183
+ for (const ws of await this.workspaceList()) {
184
+ const cards = await this.store.list(ws.path)
185
+ for (const card of cards) this.mapCardSessions(card.id, card)
186
+ }
187
+ }
188
+
189
+ private mapCardSessions(cardId: string, card: KanbanCard): void {
190
+ for (const sid of card.sessions.refinement) this.sessionCards.set(sid, cardId)
191
+ for (const attempt of card.sessions.phases) this.sessionCards.set(attempt.sessionId, cardId)
192
+ for (const sid of card.sessions.merge) this.sessionCards.set(sid, cardId)
193
+ }
194
+
195
+ /** Fallback lookup when a session starts before indexSessions ran. */
196
+ private async lookupCardOfSession(sessionId: string): Promise<string | undefined> {
197
+ for (const ws of await this.workspaceList()) {
198
+ const cards = await this.store.list(ws.path)
199
+ for (const card of cards) {
200
+ const hit =
201
+ card.sessions.refinement.includes(sessionId) ||
202
+ card.sessions.phases.some((a) => a.sessionId === sessionId) ||
203
+ card.sessions.merge.includes(sessionId)
204
+ if (hit) {
205
+ this.mapCardSessions(card.id, card)
206
+ return card.id
207
+ }
208
+ }
209
+ }
210
+ return undefined
211
+ }
212
+
213
+ /**
214
+ * The single place kanban tools get registered. `agent/session-start` fires
215
+ * for BOTH `agents.create` (startup) and `agents.resume` (host re-open after
216
+ * a restart), so a resumed refinement/phase/merge session keeps its scoped
217
+ * tools. Idempotent per agent object; non-kanban sessions are skipped.
218
+ */
219
+ private async onAgentSessionStart(agent: { id?: string; ctx?: Context } | undefined): Promise<void> {
220
+ if (agent === undefined || agent.id === undefined || agent.ctx === undefined) return
221
+ if (this.toolScopedAgents.has(agent)) return
222
+ let cardId = this.sessionCards.get(agent.id)
223
+ if (cardId === undefined) cardId = await this.lookupCardOfSession(agent.id)
224
+ if (cardId === undefined) return
225
+ this.sessionCards.set(agent.id, cardId)
226
+ try {
227
+ registerKanbanTools(agent.ctx, this.toolResolver())
228
+ this.toolScopedAgents.add(agent)
229
+ } catch (error) {
230
+ console.error('[task-kanban] register kanban tools for ' + agent.id + ' failed:', String(error))
231
+ }
232
+ }
233
+
160
234
  // ── Card operations (RPC entry points) ────────────────────────────────────
161
235
 
162
236
  async listCards(workspacePath: string): Promise<KanbanCard[]> {
@@ -287,6 +361,26 @@ export class KanbanRunner implements KanbanToolResolver {
287
361
  }
288
362
 
289
363
  async pump(): Promise<void> {
364
+ // Recovery and session indexing need the workspace registry, which may
365
+ // not be populated when the plugin first applies (the old code silently
366
+ // no-opped and left interrupted cards stuck in refining). Retry on the
367
+ // pump tick until the first workspace appears.
368
+ if (!this.recovered) {
369
+ const ws = await this.workspaceList()
370
+ if (ws.length > 0) {
371
+ this.recovered = true
372
+ try {
373
+ await this.recoverInterrupted()
374
+ } catch (error) {
375
+ console.error('[task-kanban] recoverInterrupted failed:', String(error))
376
+ }
377
+ try {
378
+ await this.indexSessions()
379
+ } catch (error) {
380
+ console.error('[task-kanban] indexSessions failed:', String(error))
381
+ }
382
+ }
383
+ }
290
384
  if (this.slots >= this.maxParallel()) return
291
385
  const workspaces = await this.workspaceList()
292
386
  for (const ws of workspaces) {
@@ -331,7 +425,7 @@ export class KanbanRunner implements KanbanToolResolver {
331
425
  private async runRefinement(cardId: string, wsPath: string): Promise<void> {
332
426
  const card = await this.store.get(wsPath, cardId)
333
427
  if (card === undefined) return
334
- const route = await this.modelRoute(cardId, wsPath)
428
+ const route = await this.modelRoute(cardId, wsPath, 'refine')
335
429
  const sessionId = this.newSessionId()
336
430
  this.sessionCards.set(sessionId, cardId)
337
431
  await this.store.mutate(cardId, (c) => {
@@ -340,7 +434,7 @@ export class KanbanRunner implements KanbanToolResolver {
340
434
  c.sessions.refinement.push(sessionId)
341
435
  }, wsPath)
342
436
  try {
343
- const handle = await this.createAgent(sessionId, wsPath, route, this.toolResolver())
437
+ const handle = await this.createAgent(sessionId, wsPath, route, 'refine')
344
438
  this.keepHandle(cardId, handle)
345
439
  await this.attachRefinementSession(wsPath, sessionId)
346
440
  // The agent scope is the layer where preset skill providers live
@@ -462,30 +556,70 @@ export class KanbanRunner implements KanbanToolResolver {
462
556
  return { promise, dispose }
463
557
  }
464
558
 
559
+ /**
560
+ * Create one kanban session agent. `kind` decides the per-session preset:
561
+ * refine/phase use their settings default (falling back to the roster's
562
+ * default preset when unset), merge keeps the historical 'standard' preset.
563
+ * When the kind defaults configure a reasoning effort, an
564
+ * `installModelSelection` ref couples the provider/model + effort into every
565
+ * prompt assembly and model request. Without a configured effort no
566
+ * selection is installed: `agentOptions` carries the route and the loop
567
+ * materializes the model's own default effort — the historical behavior.
568
+ */
465
569
  private async createAgent(
466
570
  sessionId: SessionId,
467
571
  cwd: string,
468
- route: { provider?: string; model?: string },
469
- resolver: KanbanToolResolver,
572
+ route: { provider?: string; model?: string; reasoningEffort?: string },
573
+ kind: KanbanSessionKind,
470
574
  ) {
471
575
  const agentOptions =
472
576
  route.provider !== undefined && route.model !== undefined && route.model !== ''
473
577
  ? { provider: route.provider, model: route.model }
474
578
  : undefined
579
+ const selectionRef: ModelSelectionRef = {
580
+ current:
581
+ route.provider !== undefined && route.model !== undefined && route.model !== ''
582
+ ? {
583
+ provider: route.provider,
584
+ model: route.model,
585
+ ...(route.reasoningEffort !== undefined && route.reasoningEffort !== ''
586
+ ? { reasoningEffort: ReasoningEffortId(route.reasoningEffort) }
587
+ : {}),
588
+ }
589
+ : undefined,
590
+ assembled: undefined,
591
+ }
592
+ // Resolve the preset id BEFORE creation so the session header records the
593
+ // exact composition it runs under (a resumed kanban session then rebuilds
594
+ // the same preset instead of the deployment default).
595
+ let presetId: string | undefined
596
+ const presets = this.ctx.get('agentPresets') as
597
+ | { resolve(id?: string): Promise<{ id: string }> }
598
+ | undefined
599
+ if (presets !== undefined) {
600
+ const requested = kind === 'merge' ? 'standard' : this.settings.sessionDefaults(kind).preset
601
+ const resolved = await presets.resolve(requested === '' ? undefined : requested)
602
+ presetId = resolved.id
603
+ }
475
604
  return this.ctx.agents.create({
476
605
  sessionId,
477
- meta: { cwd },
606
+ meta: { cwd, ...(presetId !== undefined ? { agentPreset: presetId } : {}) },
478
607
  ...(agentOptions !== undefined ? { agentOptions } : {}),
608
+ // Kanban tools are NOT registered here: `agent/session-start` (see
609
+ // start()) fires for both create and resume, keeping them available
610
+ // even when the host re-opens a persisted kanban session.
479
611
  setup: async (agentCtx) => {
480
612
  const agentPresets = agentCtx.get('agentPresets') as
481
613
  | { mount(agentCtx: unknown, id?: string): Promise<unknown> }
482
614
  | undefined
483
615
  if (agentPresets !== undefined) {
484
- await agentPresets.mount(agentCtx, 'standard')
616
+ await agentPresets.mount(agentCtx, presetId)
485
617
  } else {
486
618
  throw new Error('@fonlan/dsh-task-kanban: agent-presets service is not mounted')
487
619
  }
488
- registerKanbanTools(agentCtx, resolver)
620
+ if (selectionRef.current !== undefined && selectionRef.current.reasoningEffort !== undefined) {
621
+ installModelSelection(agentCtx, selectionRef)
622
+ }
489
623
  },
490
624
  })
491
625
  }
@@ -524,8 +658,19 @@ export class KanbanRunner implements KanbanToolResolver {
524
658
  return undefined
525
659
  }
526
660
 
527
- /** Resolve the provider+model route for an agent (both are required). */
528
- private async modelRoute(cardId: string, wsPath: string): Promise<{ provider?: string; model?: string }> {
661
+ /**
662
+ * Resolve the provider+model route for an agent (both are required), plus
663
+ * the per-kind reasoning effort. Precedence per kind:
664
+ * 1. the card's explicit model (chosen at creation) — wins for every kind;
665
+ * 2. the session-kind defaults (refine/phase settings);
666
+ * 3. the host `agent-default-model` route.
667
+ * Merge sessions skip step 2 (no per-type defaults for them).
668
+ */
669
+ private async modelRoute(
670
+ cardId: string,
671
+ wsPath: string,
672
+ kind: KanbanSessionKind,
673
+ ): Promise<{ provider?: string; model?: string; reasoningEffort?: string }> {
529
674
  const card = await this.store.get(wsPath, cardId)
530
675
  if (card === undefined) return {}
531
676
  if (card.model !== '' && card.provider !== undefined && card.provider !== '') {
@@ -535,10 +680,13 @@ export class KanbanRunner implements KanbanToolResolver {
535
680
  const fallback = this.settings.defaultModelRoute()
536
681
  return fallback.provider !== undefined ? { provider: fallback.provider, model: card.model } : { model: card.model }
537
682
  }
538
- const userDefault = this.settings.get().defaultModel
539
- if (userDefault !== '') {
540
- const fallback = this.settings.defaultModelRoute()
541
- return fallback.provider !== undefined ? { provider: fallback.provider, model: userDefault } : { model: userDefault }
683
+ const kindDefaults = this.settings.sessionDefaults(kind)
684
+ if (kindDefaults.model !== '') {
685
+ return {
686
+ ...(kindDefaults.provider !== '' ? { provider: kindDefaults.provider } : {}),
687
+ model: kindDefaults.model,
688
+ ...(kindDefaults.reasoningEffort !== '' ? { reasoningEffort: kindDefaults.reasoningEffort } : {}),
689
+ }
542
690
  }
543
691
  return this.settings.defaultModelRoute()
544
692
  }
@@ -636,9 +784,9 @@ export class KanbanRunner implements KanbanToolResolver {
636
784
  c.error = undefined
637
785
  c.sessions.phases.push({ phaseIndex, sessionId, startedAt: Date.now() })
638
786
  }, wsPath)
639
- const route = await this.modelRoute(cardId, wsPath)
787
+ const route = await this.modelRoute(cardId, wsPath, 'phase')
640
788
  try {
641
- const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver())
789
+ const handle = await this.createAgent(sessionId, workdir, route, 'phase')
642
790
  this.keepHandle(cardId, handle)
643
791
  entry.cancel = () => handle.agent.cancel({ kind: 'user' })
644
792
  handle.agent.followup(this.asUserMessage(phasePrompt({ ...(await this.store.get(wsPath, cardId)) ?? { plan } as KanbanCard, plan } as KanbanCard, phaseIndex, workdir)))
@@ -889,9 +1037,9 @@ export class KanbanRunner implements KanbanToolResolver {
889
1037
  const sessionId = this.newSessionId()
890
1038
  this.sessionCards.set(sessionId, cardId)
891
1039
  await this.store.mutate(cardId, (c) => { c.sessions.merge.push(sessionId) }, wsPath)
892
- const route = await this.modelRoute(cardId, wsPath)
1040
+ const route = await this.modelRoute(cardId, wsPath, 'merge')
893
1041
  try {
894
- const handle = await this.createAgent(sessionId, workdir, route, this.toolResolver())
1042
+ const handle = await this.createAgent(sessionId, workdir, route, 'merge')
895
1043
  this.keepHandle(cardId, handle)
896
1044
  handle.agent.followup(this.asUserMessage(mergePrompt(workdir, conflicts)))
897
1045
  await handle.agent.whenIdle()
@@ -1,26 +1,48 @@
1
1
  /**
2
- * User-facing plugin settings (global parallel worker count, default model),
3
- * persisted through the settings service namespace `task-kanban`. The DSH
4
- * settings service requires a lowercase kebab-case namespace
5
- * (/^[a-z][a-z0-9-]*$/), so the scoped package name cannot be used here.
2
+ * User-facing plugin settings (global parallel worker count, per-session-type
3
+ * model/effort/preset defaults), persisted through the settings service
4
+ * namespace `task-kanban`. The DSH settings service requires a lowercase
5
+ * kebab-case namespace (/^[a-z][a-z0-9-]*$/), so the scoped package name
6
+ * cannot be used here.
6
7
  */
7
8
  import type { Context } from '@deepseek-ai/cordis'
8
9
  import z from '@deepseek-ai/schemastery'
9
10
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
10
- import type { KanbanSettingsShape } from '../shared/card.js'
11
+ import type { KanbanSessionKind, KanbanSettingsShape } from '../shared/card.js'
11
12
 
12
13
  export const KANBAN_SETTINGS_NS = settingsNamespace('task-kanban')
13
14
 
14
15
  export const KanbanSettingsSchema = z.object({
15
16
  maxParallelWorkers: z.natural().min(1).default(1),
16
- defaultModel: z.string().default(''),
17
+ refinementModel: z.string().default(''),
18
+ refinementProvider: z.string().default(''),
19
+ refinementReasoningEffort: z.string().default(''),
20
+ refinementPreset: z.string().default(''),
21
+ phaseModel: z.string().default(''),
22
+ phaseProvider: z.string().default(''),
23
+ phaseReasoningEffort: z.string().default(''),
24
+ phasePreset: z.string().default(''),
17
25
  })
18
26
 
27
+ /** The per-session-type default slice a session kind resolves. */
28
+ export interface KanbanSessionDefaults {
29
+ model: string
30
+ provider: string
31
+ reasoningEffort: string
32
+ preset: string
33
+ }
34
+
19
35
  export interface KanbanSettingsFace {
20
36
  get(): KanbanSettingsShape
21
37
  update(patch: Partial<KanbanSettingsShape>): Promise<void>
22
38
  /** Default provider/model route from the agent-default-model settings namespace. */
23
39
  defaultModelRoute(): { provider?: string; model?: string }
40
+ /**
41
+ * The configured model/effort/preset defaults for one session kind.
42
+ * `merge` sessions deliberately carry no per-type defaults: they keep the
43
+ * legacy resolution chain (card model → host default).
44
+ */
45
+ sessionDefaults(kind: KanbanSessionKind): KanbanSessionDefaults
24
46
  }
25
47
 
26
48
  /** Structural settings-service subset; the real service resolves the schema. */
@@ -29,8 +51,20 @@ interface SettingsServiceLike {
29
51
  update(ns: unknown, patch: Record<string, unknown>, expectedRevision?: number): Promise<unknown>
30
52
  }
31
53
 
54
+ const EMPTY_DEFAULTS: KanbanSessionDefaults = { model: '', provider: '', reasoningEffort: '', preset: '' }
55
+
32
56
  export function registerSettings(ctx: Context): KanbanSettingsFace {
33
- let current: KanbanSettingsShape = { maxParallelWorkers: 1, defaultModel: '' }
57
+ let current: KanbanSettingsShape = {
58
+ maxParallelWorkers: 1,
59
+ refinementModel: '',
60
+ refinementProvider: '',
61
+ refinementReasoningEffort: '',
62
+ refinementPreset: '',
63
+ phaseModel: '',
64
+ phaseProvider: '',
65
+ phaseReasoningEffort: '',
66
+ phasePreset: '',
67
+ }
34
68
  let service: SettingsServiceLike | undefined
35
69
  ctx.inject(['settings'], (sctx) => {
36
70
  service = sctx.settings as unknown as SettingsServiceLike
@@ -43,6 +77,17 @@ export function registerSettings(ctx: Context): KanbanSettingsFace {
43
77
  })
44
78
  return {
45
79
  get: () => current,
80
+ sessionDefaults: (kind) => {
81
+ const s = current
82
+ switch (kind) {
83
+ case 'refine':
84
+ return { model: s.refinementModel, provider: s.refinementProvider, reasoningEffort: s.refinementReasoningEffort, preset: s.refinementPreset }
85
+ case 'phase':
86
+ return { model: s.phaseModel, provider: s.phaseProvider, reasoningEffort: s.phaseReasoningEffort, preset: s.phasePreset }
87
+ case 'merge':
88
+ return EMPTY_DEFAULTS
89
+ }
90
+ },
46
91
  defaultModelRoute: () => {
47
92
  if (service === undefined) return {}
48
93
  try {
@@ -111,7 +111,19 @@ export interface ModelOption {
111
111
  name?: string
112
112
  }
113
113
 
114
+ /** Which kanban session kind a per-type default applies to. */
115
+ export type KanbanSessionKind = 'refine' | 'phase' | 'merge'
116
+
114
117
  export interface KanbanSettingsShape {
115
118
  maxParallelWorkers: number
116
- defaultModel: string
119
+ /** Requirement-refinement session defaults. */
120
+ refinementModel: string
121
+ refinementProvider: string
122
+ refinementReasoningEffort: string
123
+ refinementPreset: string
124
+ /** Phase implementation session defaults. */
125
+ phaseModel: string
126
+ phaseProvider: string
127
+ phaseReasoningEffort: string
128
+ phasePreset: string
117
129
  }