@fonlan/dsh-task-kanban 0.2.6 → 0.2.8

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.
@@ -6,9 +6,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
6
6
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
7
7
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
8
8
  import { BoardRoot } from './board'
9
- import { KanbanFooterButton, KanbanSettingsSection } from './sections'
9
+ import { KanbanPanelIcon, KanbanSettingsSection } from './sections'
10
10
  import { LOCALE_NS, zh, en } from './locales'
11
- import { setClient, setBoardRoot, bindSessionNavigation } from './kanban-state'
11
+ import { KANBAN_PANEL_ID, setClient } from './kanban-state'
12
12
 
13
13
  /** The settings sidebar entry this plugin's page owns (must stay stable). */
14
14
  const SETTINGS_SECTION_ID = 'task-kanban'
@@ -19,9 +19,9 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale']
19
19
  /** Client plugin body. */
20
20
  export function apply(ctx: ClientContext): void {
21
21
  setClient(ctx)
22
- setBoardRoot(BoardRoot as unknown as (props: Record<string, unknown>) => React.ReactNode)
23
- // Selecting a session while the board is open must leave the board first.
24
- bindSessionNavigation(ctx)
22
+ // Session opening goes through the shell's own view owner
23
+ // (`ctx.uiWorkspace.openSession`, see kanban-state.ts), which already returns
24
+ // the center column to the Conversation — no wrapper to install here.
25
25
 
26
26
  // Live translate bound to the active locale (labels re-read it per call).
27
27
  const t = ctx.locale.bind(LOCALE_NS)
@@ -30,14 +30,30 @@ export function apply(ctx: ClientContext): void {
30
30
  return () => off()
31
31
  }, 'task-kanban: dictionaries')
32
32
 
33
- ctx.slots.inject('sidebar.footer.action', () =>
33
+ // The board is a GLOBAL MAIN PANEL: the shell renders one `main` entry per
34
+ // selected panel id, so keying it (instead of replacing `main.conversation`)
35
+ // keeps the Conversation mounted and selectable beside the board, and the
36
+ // sidebar's own panel row owns the button that selects it (the
37
+ // `sidebar.panellist` entry below). Nothing of this plugin sits in the
38
+ // sidebar foot any more, so the Settings trigger can never be squeezed.
39
+ ctx.slots.inject('main', () =>
34
40
  ctx.slots.register({
35
- name: 'sidebar.footer.action',
36
- id: 'task-kanban',
37
- order: 60,
41
+ name: 'main',
42
+ key: KANBAN_PANEL_ID,
43
+ locale: LOCALE_NS,
44
+ } as never, BoardRoot as never),
45
+ )
46
+
47
+ // Sidebar panel row: the shell renders the row (label, tooltip, active state)
48
+ // and calls `layout.selectPanel(id)` on click; the entry supplies the glyph.
49
+ ctx.slots.inject('sidebar.panellist', () =>
50
+ ctx.slots.register({
51
+ name: 'sidebar.panellist',
52
+ id: KANBAN_PANEL_ID,
53
+ order: 20,
38
54
  label: () => t('kanban'),
39
55
  locale: LOCALE_NS,
40
- }, KanbanFooterButton as never),
56
+ }, KanbanPanelIcon as never),
41
57
  )
42
58
 
43
59
  // The plugin's own settings page (设置 -> 侧栏「任务看板」) rides the
@@ -53,4 +69,4 @@ export function apply(ctx: ClientContext): void {
53
69
  locale: LOCALE_NS,
54
70
  }, KanbanSettingsSection as never),
55
71
  )
56
- }
72
+ }
@@ -1,44 +1,51 @@
1
- import { useSyncExternalStore } from 'react'
2
1
  import type { Context } from '@deepseek-ai/cordis'
3
2
  type ClientContext = Context
4
3
 
5
4
  /**
6
- * HMR-safe shared state store keyed on the global symbol registry.
5
+ * Panel id shared by this plugin's main-panel key (`main`) and its sidebar
6
+ * entry (`sidebar.panellist`): the shell selects the panel by this id, so the
7
+ * two registrations must agree.
8
+ */
9
+ export const KANBAN_PANEL_ID = 'task-kanban'
10
+
11
+ /**
12
+ * Structural view of the client shell's panel-navigation face (`ctx.layout`).
13
+ * Declared locally (no value import) so the plugin keeps working on a runtime
14
+ * whose layout types differ.
15
+ */
16
+ interface LayoutLike {
17
+ selectPanel(panelId: string | null): void
18
+ }
19
+
20
+ /**
21
+ * Structural view of the client shell's workspace-navigation face
22
+ * (`ctx.uiWorkspace`, dsh-client-ui-workspace). Declared locally (no value
23
+ * import) so the plugin keeps working on a runtime whose workspace types differ.
24
+ */
25
+ interface UiWorkspaceLike {
26
+ /** Select a Session (id, or a direct-parent subagent address) and show it. */
27
+ openSession(target: string): void
28
+ }
29
+
30
+ /**
31
+ * HMR-safe owner context, keyed on the global symbol registry.
7
32
  *
8
33
  * The client-hmr hot swap re-evaluates this module (a fresh `lib/client.js`
9
- * bundle) WITHOUT reloading the page. A module-level singleton would then be
10
- * duplicated per evaluation: the session-navigation wrapper installed by the
11
- * OLD module copy keeps calling the OLD copy's `exitBoard`, which reads the
12
- * OLD copy's `boardOpen`/`boardDisposer` — while a board opened after the swap
13
- * writes the NEW copy's state cell. The wrapper would then no-op and clicking
14
- * a sidebar session would never leave the board.
15
- *
16
- * Keeping every field on one `globalThis`-backed record (via `Symbol.for`, so
17
- * all bundle copies resolve the same key) makes every copy read/write the same
18
- * state cell, so exit works regardless of which module copy performed the
19
- * enter.
34
+ * bundle) WITHOUT reloading the page; a module-level cell would then be
35
+ * duplicated per evaluation and an old copy's helper would read a stale owner.
36
+ * `Symbol.for` makes every bundle copy share one cell.
20
37
  */
21
38
  const STATE_KEY = Symbol.for('@fonlan/dsh-task-kanban/state')
22
39
 
23
40
  interface KanbanState {
24
41
  client: ClientContext | null
25
- boardRoot: ((props: Record<string, unknown>) => React.ReactNode) | null
26
- boardDisposer: (() => void) | null
27
- boardOpen: boolean
28
- listeners: Set<() => void>
29
42
  }
30
43
 
31
44
  function getState(): KanbanState {
32
45
  const g = globalThis as unknown as Record<symbol, KanbanState | undefined>
33
46
  let state = g[STATE_KEY]
34
47
  if (state === undefined) {
35
- state = {
36
- client: null,
37
- boardRoot: null,
38
- boardDisposer: null,
39
- boardOpen: false,
40
- listeners: new Set(),
41
- }
48
+ state = { client: null }
42
49
  g[STATE_KEY] = state
43
50
  }
44
51
  return state
@@ -51,102 +58,67 @@ export function getClient(): ClientContext | null {
51
58
  return getState().client
52
59
  }
53
60
 
54
- export function setBoardRoot(component: (props: Record<string, unknown>) => React.ReactNode): void {
55
- getState().boardRoot = component
56
- }
57
- export function getBoardRoot(): ((props: Record<string, unknown>) => React.ReactNode) | null {
58
- return getState().boardRoot
59
- }
60
-
61
- function notify(): void {
62
- for (const listener of getState().listeners) listener()
63
- }
64
- export function isBoardOpen(): boolean {
65
- return getState().boardOpen
66
- }
67
- function subscribe(cb: () => void): () => void {
68
- const listeners = getState().listeners
69
- listeners.add(cb)
70
- return () => { listeners.delete(cb) }
71
- }
72
- export function useBoardOpen(): boolean {
73
- return useSyncExternalStore(subscribe, isBoardOpen)
61
+ /** The shell's panel face, when this runtime provides it. */
62
+ function layoutOf(ctx: ClientContext | null): LayoutLike | null {
63
+ if (ctx === null) return null
64
+ try {
65
+ const layout = (ctx as unknown as { layout?: LayoutLike }).layout
66
+ return typeof layout?.selectPanel === 'function' ? layout : null
67
+ } catch {
68
+ return null
69
+ }
74
70
  }
75
71
 
76
- export function enterBoard(): void {
77
- const state = getState()
78
- const ctx = state.client
79
- const root = state.boardRoot
80
- if (ctx === null || root === null || state.boardDisposer !== null) return
72
+ /** Select the kanban panel. The sidebar's own entry does this; kept for in-app callers. */
73
+ export function showKanbanPanel(ctx: ClientContext | null = getClient()): void {
81
74
  try {
82
- // DSH 0.1.5 moved the replaceable shell from the legacy `conversation`
83
- // seat to `main.conversation`. Register through `inject` so the board
84
- // waits for that owner if the client plugin load order is reversed.
85
- state.boardDisposer = ctx.slots.inject('main.conversation', () =>
86
- ctx.slots.register({ name: 'main.conversation', priority: -1, locale: 'task-kanban' } as never, root as never),
87
- )
88
- state.boardOpen = true
89
- notify()
75
+ layoutOf(ctx)?.selectPanel(KANBAN_PANEL_ID)
90
76
  } catch (error) {
91
- console.error('[@fonlan/dsh-task-kanban] cannot open the board:', error)
77
+ console.error('[@fonlan/dsh-task-kanban] cannot select the kanban panel:', error)
92
78
  }
93
79
  }
94
80
 
95
- export function exitBoard(): void {
96
- const state = getState()
97
- if (state.boardDisposer !== null) {
98
- state.boardDisposer()
99
- state.boardDisposer = null
100
- }
101
- if (state.boardOpen) {
102
- state.boardOpen = false
103
- notify()
81
+ /** Hand the center column back to the Conversation. */
82
+ export function leaveKanbanPanel(ctx: ClientContext | null = getClient()): void {
83
+ try {
84
+ layoutOf(ctx)?.selectPanel(null)
85
+ } catch (error) {
86
+ console.error('[@fonlan/dsh-task-kanban] cannot leave the kanban panel:', error)
104
87
  }
105
88
  }
106
89
 
107
- export function toggleBoard(): void {
108
- if (isBoardOpen()) exitBoard()
109
- else enterBoard()
110
- }
111
-
112
90
  /**
113
- * Route session navigation through the board: opening any session while the
114
- * board is open leaves the board first, so the conversation view can render
115
- * for the newly selected session.
91
+ * Open one session — or a durable direct-parent subagent address — in the
92
+ * center column, leaving the kanban panel.
116
93
  *
117
- * Every sidebar path that selects a session funnels through `sessions.open`
118
- * (session rows, search results, fork results, New Session) or
119
- * `sessions.openSubagent` (catalog children) — including re-clicking the
120
- * already-current session, which never changes `list.current` and therefore
121
- * cannot be caught by a list-store subscription. Wrapping the two entry
122
- * points covers all of them in one place.
94
+ * Navigation is a view-owner concern, not a session-controller one: `ctx.sessions`
95
+ * (`ISessions`) exposes retention (`retain`/`using`/`retainInfo`), creation,
96
+ * fork, search and catalog reads, and has no `open`/`openSubagent` at all
97
+ * ("navigation belongs to view owners"). The view owner the shell itself uses is
98
+ * `ctx.uiWorkspace.openSession(target)` (dsh-client-ui-workspace): it retains the
99
+ * session AND reveals the Conversation panel as ONE navigation action. Routing
100
+ * every open through it therefore replaces the old wrapper around
101
+ * `sessions.open`/`openSubagent`, which existed only to hand the center column
102
+ * back when a sidebar click selected a session while the board was showing.
123
103
  *
124
- * The wrapper is idempotent: a `kbBound` marker on the wrapper prevents a
125
- * second bind (plugin re-apply / HMR) from stacking another layer. Because the
126
- * wrapper's exit goes through the shared state store, a wrapper left over from
127
- * a previous (HMR-replaced) module copy still closes the board correctly.
104
+ * @param target - session id (or subagent address) to show.
105
+ * @returns whether the runtime provided the navigation face; `false` means the
106
+ * session could not be shown (a client without ui-workspace).
128
107
  */
129
- export function bindSessionNavigation(ctx: ClientContext): void {
130
- const sessions = ctx.sessions
131
- if (sessions === undefined) return
132
-
133
- const open = sessions.open
134
- if (typeof open === 'function' && (open as unknown as { kbBound?: boolean }).kbBound !== true) {
135
- const bound = ((id: Parameters<typeof open>[0]) => {
136
- exitBoard()
137
- return open.call(sessions, id)
138
- }) as typeof open
139
- ;(bound as unknown as { kbBound?: boolean }).kbBound = true
140
- sessions.open = bound
108
+ export function openSessionView(target: string, ctx: ClientContext | null = getClient()): boolean {
109
+ if (ctx === null) return false
110
+ let uiWorkspace: UiWorkspaceLike | undefined
111
+ try {
112
+ uiWorkspace = (ctx as unknown as { uiWorkspace?: UiWorkspaceLike }).uiWorkspace
113
+ } catch {
114
+ return false
141
115
  }
142
-
143
- const openSubagent = sessions.openSubagent
144
- if (typeof openSubagent === 'function' && (openSubagent as unknown as { kbBound?: boolean }).kbBound !== true) {
145
- const bound = ((address: Parameters<typeof openSubagent>[0]) => {
146
- exitBoard()
147
- return openSubagent.call(sessions, address)
148
- }) as typeof openSubagent
149
- ;(bound as unknown as { kbBound?: boolean }).kbBound = true
150
- sessions.openSubagent = bound
116
+ if (typeof uiWorkspace?.openSession !== 'function') return false
117
+ try {
118
+ uiWorkspace.openSession(target)
119
+ return true
120
+ } catch (error) {
121
+ console.error('[@fonlan/dsh-task-kanban] cannot open the session view:', error)
122
+ return false
151
123
  }
152
- }
124
+ }
@@ -1,143 +1,17 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
2
+ import { ChecklistIcon } from './icons'
3
3
  import { api, type PresetOption, type ReasoningOptions } from './api'
4
- import { toggleBoard, useBoardOpen } from './kanban-state'
5
4
  import type { KanbanSettingsShape, ModelOption } from '../shared/card'
6
5
 
7
- interface FooterActionProps {
8
- wide?: boolean
9
- t?: (key: string) => string
10
- }
11
-
12
- /** Sidebar footer entry: toggles the kanban board main view.
13
- * The shell renders footer actions on their own row ABOVE the settings row,
14
- * so the button is reparented into the settings area to sit on the same
15
- * line, right of the Settings trigger (see board.css .kb-settings-row). */
16
- export function KanbanFooterButton({ wide, t }: FooterActionProps): JSX.Element {
17
- const open = useBoardOpen()
18
- const ref = useRef<HTMLButtonElement | null>(null)
19
- const label = t !== undefined ? t(open ? 'closeBoard' : 'openBoard') : 'Task Kanban'
20
-
21
- useEffect(() => {
22
- const btn = ref.current
23
- if (btn === null) return
24
- let foot: Element | null = null
25
- let disposed = false
26
-
27
- // Locate the sidebar foot area WITHOUT moving any DOM: it is the ancestor
28
- // of the button whose LAST child contains the settings trigger and which
29
- // is itself the last child of its parent (root > footArea > settingsArea).
30
- // The slot machinery wraps entries in an anonymous div, so the search
31
- // tolerates extra wrapper levels. Styling is pure CSS (see board.css
32
- // .kb-foot-row), so a failed lookup only leaves the button in its natural
33
- // slot row — it can never vanish.
34
- const findFoot = (): Element | null => {
35
- let el: Element | null = btn.parentElement
36
- while (el !== null && el !== document.body && el.parentElement !== null) {
37
- const last = el.lastElementChild
38
- const hasTrigger =
39
- last !== null &&
40
- (last.querySelector?.('button[aria-haspopup="dialog"]') !== null ||
41
- last.querySelector?.('button[aria-haspopup="menu"]') !== null)
42
- const isLastChild = el.parentElement.lastElementChild === el
43
- if (hasTrigger && isLastChild) return el
44
- el = el.parentElement
45
- }
46
- return null
47
- }
48
-
49
- const tryApply = (): boolean => {
50
- if (foot === null) {
51
- try {
52
- foot = findFoot()
53
- } catch {
54
- foot = null
55
- }
56
- }
57
- if (foot === null) return false
58
- try {
59
- foot.classList.add('kb-foot-row')
60
- foot.classList.toggle('kb-foot-rail', wide !== true)
61
- } catch {
62
- // element already gone; retry will re-locate
63
- }
64
- return true
65
- }
66
-
67
- let applied = false
68
- try {
69
- applied = tryApply()
70
- } catch (error) {
71
- console.error('[@fonlan/dsh-task-kanban] foot placement error:', error)
72
- }
73
- if (!applied) {
74
- // The settings entry may mount later than this entry (plugin load
75
- // order); retry on DOM mutations AND on a short interval until the
76
- // foot area is found (give up after 15s).
77
- const observer = new MutationObserver(() => {
78
- if (disposed) return
79
- if (tryApply()) observer.disconnect()
80
- })
81
- observer.observe(document.body, { childList: true, subtree: true })
82
- const timer = window.setInterval(() => {
83
- if (disposed) {
84
- window.clearInterval(timer)
85
- return
86
- }
87
- if (tryApply()) {
88
- observer.disconnect()
89
- window.clearInterval(timer)
90
- }
91
- }, 1000)
92
- window.setTimeout(() => {
93
- observer.disconnect()
94
- window.clearInterval(timer)
95
- if (foot === null) {
96
- // report the DOM chain so the next report carries evidence
97
- const chain: string[] = []
98
- let el: Element | null = btn.parentElement
99
- while (el !== null && chain.length < 8) {
100
- chain.push(el.tagName + '.' + String(el.className).slice(0, 60))
101
- el = el.parentElement
102
- }
103
- console.warn('[@fonlan/dsh-task-kanban] could not locate the sidebar foot area:', chain)
104
- }
105
- }, 15000)
106
- return () => {
107
- disposed = true
108
- observer.disconnect()
109
- window.clearInterval(timer)
110
- try {
111
- foot?.classList.remove('kb-foot-row')
112
- foot?.classList.remove('kb-foot-rail')
113
- } catch {
114
- // already gone
115
- }
116
- }
117
- }
118
- return () => {
119
- try {
120
- foot?.classList.remove('kb-foot-row')
121
- foot?.classList.remove('kb-foot-rail')
122
- } catch {
123
- // already gone
124
- }
125
- }
126
- }, [wide])
127
-
128
- return (
129
- <button
130
- ref={ref}
131
- type="button"
132
- className={'kb-footer-action' + (wide === true ? ' kb-footer-action-wide' : '') + (open ? ' kb-footer-action-active' : '')}
133
- aria-label={label}
134
- title={label}
135
- onClick={() => toggleBoard()}
136
- >
137
- <IconChecklistOutline14 size={wide === true ? 16 : 18} />
138
- {wide === true && <span className="kb-footer-label">{t !== undefined ? t('kanban') : 'Kanban'}</span>}
139
- </button>
140
- )
6
+ /**
7
+ * Sidebar panel-row glyph for the board (`sidebar.panellist`).
8
+ *
9
+ * The shell owns the row: it renders the button, its label, tooltip and active
10
+ * state, and calls `layout.selectPanel(id)` on click. The entry contributes
11
+ * only the glyph, so this component must stay purely presentational.
12
+ */
13
+ export function KanbanPanelIcon({ size }: { size: number }): JSX.Element {
14
+ return <ChecklistIcon size={size} />
141
15
  }
142
16
 
143
17
  interface SettingsSectionProps {
package/src/index.ts CHANGED
@@ -7,20 +7,22 @@
7
7
  * serves the fenced JSON API the board UI calls.
8
8
  */
9
9
  import type { Context } from '@deepseek-ai/cordis'
10
- import z from '@deepseek-ai/schemastery'
11
- import { registerSettings } from './server/settings.js'
10
+ import { KanbanSettingsSchema, registerSettings } from './server/settings.js'
12
11
  import { TaskStore } from './server/task-store.js'
13
12
  import { KanbanRunner } from './server/runner.js'
14
13
  import { registerApiRoutes } from './server/rpc.js'
14
+ import type { KanbanSettingsShape } from './shared/card.js'
15
15
 
16
16
  export const name = '@fonlan/dsh-task-kanban'
17
17
 
18
18
  export const inject = ['webServer', 'agents', 'skills']
19
19
 
20
- export const Config = z.object({})
20
+ // The entry config is the settings document (worker count + per-session-type
21
+ // defaults); a changed config restarts this entry.
22
+ export const Config = KanbanSettingsSchema
21
23
 
22
- export function apply(ctx: Context, _config: unknown): void {
23
- const settings = registerSettings(ctx)
24
+ export function apply(ctx: Context, config: KanbanSettingsShape): void {
25
+ const settings = registerSettings(ctx, config)
24
26
  const store = new TaskStore()
25
27
  const runner = new KanbanRunner(ctx, store, settings)
26
28
 
@@ -19,7 +19,14 @@ import { currentBranch, detectBaseRef, hasStashMessage, isGitRepo, isTreeDirty,
19
19
  import { interactiveRefinementPrompt, mergePrompt, phasePrompt, refinementPrompt } from './prompts.js'
20
20
  import { loadCardSkill, parseSkillGesture, skillInvocationMessage } from './skills.js'
21
21
  import { registerKanbanTools, type KanbanToolResolver } from './tools.js'
22
- import type {} from '@deepseek-ai/dsh-agent-presets'
22
+ // Declaration-merge only (erased at runtime): loads the `agentPresets` service
23
+ // augmentation for `ctx.get('agentPresets')` below. The 0.1.x name
24
+ // `@deepseek-ai/dsh-agent-presets` stopped shipping after 0.1.5-rc.3 and does
25
+ // not exist at 0.2.0-rc.1; `@deepseek-ai/dsh-agent-preset` ships on both live
26
+ // runtimes and carries the same `agentPresets` Context augmentation (it depends
27
+ // on `@deepseek-ai/dsh-agent-preset-registry`, which owns the service and the
28
+ // augmentation).
29
+ import type {} from '@deepseek-ai/dsh-agent-preset'
23
30
 
24
31
  const WORKTREES_REL = ['.dsh', 'worktrees']
25
32
 
@@ -59,7 +66,7 @@ export class KanbanRunner implements KanbanToolResolver {
59
66
  private recovered = false
60
67
  /** Agents that already got their scoped kanban tools (event idempotency). */
61
68
  private toolScopedAgents = new WeakSet<object>()
62
- private agentStartOff: (() => void) | undefined
69
+ private agentCreatedOff: (() => void) | undefined
63
70
 
64
71
  constructor(ctx: Context, store: TaskStore, settings: KanbanSettingsFace) {
65
72
  this.ctx = ctx
@@ -73,21 +80,28 @@ export class KanbanRunner implements KanbanToolResolver {
73
80
 
74
81
  start(): void {
75
82
  this.pumpTimer = setInterval(() => { void this.pump() }, 2500)
76
- // `agent/session-start` fires for BOTH agents.create (startup) and
77
- // agents.resume (host re-open after a restart). Kanban tools are scoped
78
- // per agent, so a resumed refinement/phase/merge session would otherwise
79
- // lose them — register there instead of only in createAgent's setup.
80
- this.agentStartOff = this.ctx.on('agent/session-start', (payload: { agent?: { id?: string; ctx?: Context } }) => {
81
- void this.onAgentSessionStart(payload.agent)
83
+ // `agent/created` is the lifecycle edge for an agent that entered the
84
+ // registry — dsh announces every creation path through it, including
85
+ // `agents.resume` (the factory re-opens a persisted session's agent after a
86
+ // host restart), and its `source` records which path that was. The previous
87
+ // subscription used `agent/session-start`, which existed in dsh 0.1.5 only
88
+ // and was already gone by 0.1.7 — so resumed refinement/phase/merge
89
+ // sessions silently lost their scoped kanban tools. `agent/created` exists
90
+ // in 0.1.7-rc.2 and 0.2.0-rc.1 with the same `{ agent, source }` payload.
91
+ // It dispatches as a serial listener, so this callback must not reject:
92
+ // tool registration is best-effort and a lookup failure must not fail the
93
+ // host's agent creation (hence the floating promise).
94
+ this.agentCreatedOff = this.ctx.on('agent/created', (payload: { agent?: { id?: string; ctx?: Context } }): undefined => {
95
+ void this.onAgentCreated(payload.agent)
82
96
  })
83
97
  }
84
98
 
85
99
  stop(): void {
86
100
  if (this.pumpTimer !== undefined) clearInterval(this.pumpTimer)
87
101
  this.pumpTimer = undefined
88
- if (this.agentStartOff !== undefined) {
89
- this.agentStartOff()
90
- this.agentStartOff = undefined
102
+ if (this.agentCreatedOff !== undefined) {
103
+ this.agentCreatedOff()
104
+ this.agentCreatedOff = undefined
91
105
  }
92
106
  }
93
107
 
@@ -217,12 +231,13 @@ export class KanbanRunner implements KanbanToolResolver {
217
231
  }
218
232
 
219
233
  /**
220
- * The single place kanban tools get registered. `agent/session-start` fires
221
- * for BOTH `agents.create` (startup) and `agents.resume` (host re-open after
222
- * a restart), so a resumed refinement/phase/merge session keeps its scoped
234
+ * The single place kanban tools get registered. Subscribed to
235
+ * `agent/created`, which fires for BOTH `agents.create` (startup) and
236
+ * `agents.resume` (host re-open after a restart) and carries the path in its
237
+ * `source`, so a resumed refinement/phase/merge session keeps its scoped
223
238
  * tools. Idempotent per agent object; non-kanban sessions are skipped.
224
239
  */
225
- private async onAgentSessionStart(agent: { id?: string; ctx?: Context } | undefined): Promise<void> {
240
+ private async onAgentCreated(agent: { id?: string; ctx?: Context } | undefined): Promise<void> {
226
241
  if (agent === undefined || agent.id === undefined || agent.ctx === undefined) return
227
242
  if (this.toolScopedAgents.has(agent)) return
228
243
  let cardId = this.sessionCards.get(agent.id)
@@ -611,9 +626,9 @@ export class KanbanRunner implements KanbanToolResolver {
611
626
  sessionId,
612
627
  meta: { cwd, ...(presetId !== undefined ? { agentPreset: presetId } : {}) },
613
628
  ...(agentOptions !== undefined ? { agentOptions } : {}),
614
- // Kanban tools are NOT registered here: `agent/session-start` (see
615
- // start()) fires for both create and resume, keeping them available
616
- // even when the host re-opens a persisted kanban session.
629
+ // Kanban tools are NOT registered here: `agent/created` (see start())
630
+ // fires for both create and resume, keeping them available even when the
631
+ // host re-opens a persisted kanban session.
617
632
  setup: async (agentCtx) => {
618
633
  const agentPresets = agentCtx.get('agentPresets') as
619
634
  | { mount(agentCtx: unknown, id?: string): Promise<unknown> }