@goodandready/dsh-kanban 0.2.34 → 0.2.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,367 @@
1
+ import {
2
+ buildBoard,
3
+ boardMetrics,
4
+ listArchive,
5
+ exportBoard,
6
+ importBoard,
7
+ exportTasksCsv,
8
+ taskBySession,
9
+ } from '../routes.js'
10
+ import { runBatch, resolveModel, liveSessions } from '../launcher.js'
11
+ import { listImportable, importIssue, createProjectTask, searchRepos } from '../import.js'
12
+ import { shouldRelay, relayRequest, testRemoteServer } from '../remote-relay.js'
13
+ import { testRemoteConnection, isRemoteMode } from '../remote-runner.js'
14
+ import { rootOf } from '../config.js'
15
+ import { TASK_TEMPLATES, getTemplateById } from '../templates.js'
16
+ import { verifySignature, detectWebhookProvider, parseEvent } from '../webhook.js'
17
+ import { isWatched } from '../intake.js'
18
+ import { intakeAll, syncAll } from '../sync.js'
19
+ import { SessionId } from '@deepseek-ai/dsh-session'
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
21
+ import { randomUUID } from 'node:crypto'
22
+
23
+ export function createRouteHandlers(deps) {
24
+ const {
25
+ ctx,
26
+ store,
27
+ getConfig,
28
+ git,
29
+ gitea,
30
+ github,
31
+ getGitRunner,
32
+ eventHub,
33
+ syncState,
34
+ runSync,
35
+ liveOf,
36
+ defaultOwner,
37
+ whoAmI,
38
+ isCostMeterAvailable,
39
+ resolveSecret,
40
+ isTrusted,
41
+ urlOf,
42
+ writeJson,
43
+ reply,
44
+ readBody,
45
+ readRaw,
46
+ rejectUntrusted,
47
+ } = deps
48
+
49
+ return {
50
+ async handleProjectTask(req, res) {
51
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
52
+ if (rejectUntrusted(req, res)) return
53
+ try {
54
+ const input = await readBody(req)
55
+ reply(res, await createProjectTask({
56
+ gitea: git, store,
57
+ owner: input.owner || await defaultOwner(),
58
+ repo: input.repo, newRepo: input.newRepo,
59
+ title: input.title, body: input.body,
60
+ board: input.board ?? 'main',
61
+ }))
62
+ } catch (error) {
63
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
64
+ }
65
+ },
66
+
67
+ async handlePresets(req, res) {
68
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
69
+ if (rejectUntrusted(req, res)) return
70
+ const presets = ctx.get('agentPresets')
71
+ const permissions = ctx.get('permissionPresets')
72
+ let agentPresets = []
73
+ try {
74
+ const rows = presets === undefined ? [] : await presets.list()
75
+ agentPresets = rows.filter((row) => row?.broken === undefined).map((row) => ({
76
+ id: row.id, name: row.name || row.id, description: row.description,
77
+ }))
78
+ } catch (error) {
79
+ ctx.logger?.warn?.(`dsh-kanban: agent profiles failed to load: ${error?.message}`)
80
+ }
81
+ let access = []
82
+ let accessDefault = ''
83
+ try {
84
+ if (permissions !== undefined) {
85
+ access = permissions.names.map((name) => permissions.optionOf(name))
86
+ accessDefault = permissions.defaultPreset ?? ''
87
+ }
88
+ } catch (error) {
89
+ ctx.logger?.warn?.(`dsh-kanban: access levels failed to load: ${error?.message}`)
90
+ }
91
+ reply(res, {
92
+ agentPresets,
93
+ agentPresetDefault: presets?.defaultId ?? '',
94
+ access,
95
+ accessDefault,
96
+ })
97
+ },
98
+
99
+ handleSessions(req, res) {
100
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
101
+ if (rejectUntrusted(req, res)) return
102
+ reply(res, { sessions: liveSessions({ store, agents: ctx.agents }) })
103
+ },
104
+
105
+ async handleBatch(req, res) {
106
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
107
+ if (rejectUntrusted(req, res)) return
108
+ try {
109
+ const input = await readBody(req)
110
+ const ids = Array.isArray(input.ids) ? input.ids : []
111
+ const picked = ids.map((id) => store.getTask(id)).filter((task) => task !== undefined)
112
+ const chosen = resolveModel({
113
+ requested: input,
114
+ fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
115
+ })
116
+ if (chosen === undefined) { writeJson(res, 400, { error: 'model-required' }); return }
117
+ reply(res, await runBatch({
118
+ agents: ctx.agents, store, tasks: picked, config: getConfig(),
119
+ provider: chosen.provider, model: chosen.model,
120
+ mintSessionId: () => SessionId(`kanban-batch-${randomUUID()}`),
121
+ createMessage: createUserMessage,
122
+ cwdOf: undefined, logger: ctx.logger, text: input.text,
123
+ agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
124
+ permission: typeof input.permission === 'string' ? input.permission : undefined,
125
+ permissions: ctx.get('permissionPresets'),
126
+ claimAs: await whoAmI(),
127
+ }))
128
+ } catch (error) {
129
+ writeJson(res, error?.status ?? 500, { error: 'batch-failed' })
130
+ }
131
+ },
132
+
133
+ handleArchive(req, res) {
134
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
135
+ if (rejectUntrusted(req, res)) return
136
+ reply(res, listArchive({ store }))
137
+ },
138
+
139
+ async handleSync(req, res) {
140
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
141
+ if (rejectUntrusted(req, res)) return
142
+ try {
143
+ writeJson(res, 200, await runSync())
144
+ } catch (error) {
145
+ writeJson(res, 502, { error: 'sync-failed' })
146
+ }
147
+ },
148
+
149
+ async handleWebhook(req, res) {
150
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
151
+ try {
152
+ const raw = await readRaw(req)
153
+ const isGithub = detectWebhookProvider(req.headers) === 'github'
154
+ const secretRef = isGithub
155
+ ? (getConfig().githubWebhookSecretRef || getConfig().webhookSecretRef)
156
+ : getConfig().webhookSecretRef
157
+ const secret = await resolveSecret(secretRef)
158
+ const sig = isGithub ? req.headers['x-hub-signature-256'] : req.headers['x-gitea-signature']
159
+ if (!verifySignature(secret, raw, sig)) {
160
+ writeJson(res, 401, { error: 'bad-signature' })
161
+ return
162
+ }
163
+ let payload
164
+ try { payload = JSON.parse(raw.toString('utf8')) } catch { payload = undefined }
165
+ const target = parseEvent(payload)
166
+ if (target === undefined) { writeJson(res, 202, { ignored: true }); return }
167
+
168
+ const activeGit = isGithub ? github : gitea
169
+ const owner = isGithub
170
+ ? (String(getConfig().githubOwner ?? '').trim() || target.owner)
171
+ : await defaultOwner()
172
+ const taken = target.owner === owner || isWatched({
173
+ config: getConfig(), owner: target.owner, defaultOwner: owner, repo: target.repo,
174
+ })
175
+ ? await intakeAll({
176
+ gitea: activeGit, store, config: getConfig(), owner, logger: ctx.logger,
177
+ only: (pair) => pair.owner === target.owner && pair.repo === target.repo,
178
+ })
179
+ : { added: 0, skipped: 0, failed: 0 }
180
+
181
+ const out = await syncAll({
182
+ gitea: git, store, logger: ctx.logger,
183
+ only: (t) => t.owner === target.owner && t.repo === target.repo
184
+ && (target.issueNumber === undefined || t.issueNumber === target.issueNumber),
185
+ })
186
+ writeJson(res, 200, { ...out, taken })
187
+ } catch (error) {
188
+ writeJson(res, error?.status ?? 500, { error: 'webhook-failed' })
189
+ }
190
+ },
191
+
192
+ async handleBoard(req, res) {
193
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
194
+ if (rejectUntrusted(req, res)) return
195
+ const cfg = getConfig()
196
+ if (shouldRelay(cfg)) {
197
+ const url = urlOf(req)
198
+ const relayRes = await relayRequest({
199
+ config: cfg,
200
+ urlPath: '/dsh-kanban/board' + (url.search || ''),
201
+ method: 'GET',
202
+ headers: req.headers,
203
+ })
204
+ writeJson(res, relayRes.status, relayRes.body)
205
+ return
206
+ }
207
+ const url = urlOf(req)
208
+ reply(res, buildBoard({
209
+ store,
210
+ config: cfg,
211
+ board: url.searchParams.get('board') || 'main',
212
+ repo: url.searchParams.get('repo') || undefined,
213
+ liveOf,
214
+ sync: syncState.snapshot(),
215
+ projectRoot: rootOf(cfg),
216
+ costMeterAvailable: isCostMeterAvailable(ctx),
217
+ }))
218
+ },
219
+
220
+ async handleRemoteTest(req, res) {
221
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
222
+ if (rejectUntrusted(req, res)) return
223
+ const cfg = getConfig()
224
+ const sshRes = await testRemoteConnection(cfg)
225
+ const serverRes = cfg.remoteServerUrl ? await testRemoteServer(cfg) : { ok: false, error: 'no-server-url' }
226
+ writeJson(res, 200, {
227
+ remoteMode: isRemoteMode(cfg),
228
+ ssh: sshRes,
229
+ server: serverRes,
230
+ })
231
+ },
232
+
233
+ handleMetrics(req, res) {
234
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
235
+ if (rejectUntrusted(req, res)) return
236
+ const url = urlOf(req)
237
+ const days = Number(url.searchParams.get('staleDays'))
238
+ reply(res, boardMetrics({
239
+ store,
240
+ board: url.searchParams.get('board') || 'main',
241
+ staleDays: Number.isFinite(days) && days > 0 ? days : 14,
242
+ }))
243
+ },
244
+
245
+ async handleWhoami(req, res) {
246
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
247
+ if (rejectUntrusted(req, res)) return
248
+ reply(res, { login: await whoAmI() })
249
+ },
250
+
251
+ handleExportCsv(req, res) {
252
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
253
+ if (rejectUntrusted(req, res)) return
254
+ const u = urlOf(req)
255
+ const board = u.searchParams.get('board') || 'main'
256
+ const csv = exportTasksCsv({ store, board })
257
+ res.writeHead(200, {
258
+ 'Content-Type': 'text/csv; charset=utf-8',
259
+ 'Content-Disposition': `attachment; filename="kanban-${board}-${new Date().toISOString().slice(0, 10)}.csv"`,
260
+ 'Cache-Control': 'no-store',
261
+ })
262
+ res.end(csv)
263
+ },
264
+
265
+ handleEvents(req, res) {
266
+ eventHub.handleSseRequest(req, res, { isTrustedRequest: isTrusted })
267
+ },
268
+
269
+ handleTemplates(req, res) {
270
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
271
+ if (rejectUntrusted(req, res)) return
272
+ const u = new URL(req.url, 'http://localhost')
273
+ const tid = u.searchParams.get('id')
274
+ if (tid) {
275
+ const tmpl = getTemplateById(tid)
276
+ if (!tmpl) { writeJson(res, 404, { error: 'template-not-found' }); return }
277
+ reply(res, { template: tmpl })
278
+ return
279
+ }
280
+ reply(res, { templates: TASK_TEMPLATES })
281
+ },
282
+
283
+ async handleSnapshot(req, res) {
284
+ if (rejectUntrusted(req, res)) return
285
+ if (req.method === 'GET') { reply(res, exportBoard({ store })); return }
286
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
287
+ try {
288
+ const input = await readBody(req)
289
+ const impResult = importBoard({ store, input })
290
+ if (impResult?.imported > 0) {
291
+ eventHub.broadcast('task:change', { action: 'imported', count: impResult.imported })
292
+ }
293
+ reply(res, impResult)
294
+ } catch {
295
+ writeJson(res, 400, { error: 'bad-request' })
296
+ }
297
+ },
298
+
299
+ async handleGiteaRepos(req, res) {
300
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
301
+ if (rejectUntrusted(req, res)) return
302
+ const url = urlOf(req)
303
+ reply(res, await searchRepos({ gitea: git, query: url.searchParams.get('q') || '' }))
304
+ },
305
+
306
+ async handleGiteaIssues(req, res) {
307
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
308
+ if (rejectUntrusted(req, res)) return
309
+ const url = urlOf(req)
310
+ reply(res, await listImportable({
311
+ gitea: git,
312
+ store,
313
+ owner: url.searchParams.get('owner') || undefined,
314
+ repo: url.searchParams.get('repo') || undefined,
315
+ board: url.searchParams.get('board') || 'main',
316
+ }))
317
+ },
318
+
319
+ async handleGiteaImport(req, res) {
320
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
321
+ if (rejectUntrusted(req, res)) return
322
+ try {
323
+ const input = await readBody(req)
324
+ reply(res, await importIssue({
325
+ gitea: git, store,
326
+ owner: input.owner, repo: input.repo, issueNumber: input.issueNumber,
327
+ board: input.board ?? 'main', column: input.column ?? 'backlog',
328
+ forge: input.forge || input.provider,
329
+ }))
330
+ } catch (error) {
331
+ writeJson(res, error?.status ?? 500, { error: 'bad-request' })
332
+ }
333
+ },
334
+
335
+ async handleModels(req, res) {
336
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
337
+ if (rejectUntrusted(req, res)) return
338
+ const current = ctx.get('agentDefaultModel')?.currentSelection?.() ?? null
339
+ const llmService = ctx.get('llm')
340
+ let providers = []
341
+ try {
342
+ providers = ((llmService?.listProviders?.() ?? []).map((p) => ({ id: p.id, name: p.name ?? p.id })))
343
+ } catch { providers = [] }
344
+
345
+ const wanted = urlOf(req).searchParams.get('provider') || ''
346
+ if (wanted === '') { writeJson(res, 200, { current, providers, models: [] }); return }
347
+
348
+ try {
349
+ const rows = await llmService?.listModels?.(wanted)
350
+ writeJson(res, 200, {
351
+ current, providers,
352
+ models: (rows ?? []).map((m) => ({ id: m.id, name: m.name ?? m.id })),
353
+ })
354
+ } catch {
355
+ writeJson(res, 200, { current, providers, models: [] })
356
+ }
357
+ },
358
+
359
+ handleSessionTask(req, res) {
360
+ if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
361
+ if (rejectUntrusted(req, res)) return
362
+ const m = /^\/dsh-kanban\/session\/([^/]+)\/task\/?$/.exec(urlOf(req).pathname)
363
+ if (m === null) { writeJson(res, 404, { error: 'not-found' }); return }
364
+ reply(res, taskBySession({ store, sessionId: decodeURIComponent(m[1]), agents: ctx.agents }))
365
+ },
366
+ }
367
+ }
@@ -0,0 +1,225 @@
1
+ import { PowerInhibitor } from '../power.js'
2
+ import { createWatchdogService } from '../watchdog.js'
3
+ import { WorkflowBridge } from '../workflow-bridge.js'
4
+ import { buildPromptSectionText } from '../config.js'
5
+ import { handleSessionEvent } from '../lifecycle.js'
6
+ import { dispatchNextQueuedTask } from '../launcher.js'
7
+ import { SessionId } from '@deepseek-ai/dsh-session'
8
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
9
+ import { randomUUID } from 'node:crypto'
10
+ import { defineTool } from '@deepseek-ai/dsh-tools'
11
+ import {
12
+ boardMoveDefinition,
13
+ boardPlanDefinition,
14
+ boardChecklistDefinition,
15
+ boardReportDefinition,
16
+ boardCommentsDefinition,
17
+ boardCommentAddDefinition,
18
+ boardDecomposeDefinition,
19
+ boardChecklistItemDefinition,
20
+ } from '../board-tool.js'
21
+
22
+ export function registerKanbanServices(ctx, deps) {
23
+ const {
24
+ store,
25
+ getConfig,
26
+ eventHub,
27
+ git,
28
+ rearmers,
29
+ } = deps
30
+
31
+ const watchdog = createWatchdogService()
32
+
33
+ const powerInhibitor = new PowerInhibitor({
34
+ platform: process.platform,
35
+ pid: process.pid,
36
+ })
37
+
38
+ const workflowBridge = new WorkflowBridge({
39
+ get store() { return store },
40
+ get workflowEngine() { return ctx.get('workflowEngine') },
41
+ get agentPresets() { return ctx.get('agentPresets') },
42
+ get agents() { return ctx.agents },
43
+ git,
44
+ getConfig,
45
+ logger: ctx.logger,
46
+ eventHub,
47
+ })
48
+
49
+ const unbindCoreWorkflow = workflowBridge.bindCoreEvents(ctx)
50
+ if (typeof ctx.on === 'function') {
51
+ ctx.on('dispose', () => {
52
+ unbindCoreWorkflow?.()
53
+ })
54
+ }
55
+
56
+ const syncPower = () => {
57
+ try {
58
+ const cfg = getConfig()
59
+ powerInhibitor.setEnabled(cfg.preventIdleSleep === true)
60
+ if (store) {
61
+ const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
62
+ powerInhibitor.setReasons(activeCount)
63
+ }
64
+ } catch { /* ignore */ }
65
+ }
66
+ rearmers.add(syncPower)
67
+ ctx.effect(() => {
68
+ syncPower()
69
+ return () => { powerInhibitor.destroy() }
70
+ }, 'dsh-kanban: power inhibitor')
71
+
72
+ let disposePromptSection
73
+ const syncPromptSection = () => {
74
+ if (disposePromptSection) {
75
+ try { disposePromptSection() } catch { /* cleanup best effort */ }
76
+ disposePromptSection = undefined
77
+ }
78
+ const cfg = getConfig()
79
+ const sp = ctx.get('systemPrompt')
80
+ const promptText = buildPromptSectionText(cfg)
81
+ if (promptText && sp && typeof sp.section === 'function') {
82
+ try {
83
+ disposePromptSection = sp.section({
84
+ name: 'plugin:dsh-kanban',
85
+ order: 200,
86
+ text: promptText,
87
+ })
88
+ } catch (err) {
89
+ ctx.logger?.warn?.(`dsh-kanban: failed to register system prompt section: ${err?.message}`)
90
+ }
91
+ }
92
+ }
93
+ rearmers.add(syncPromptSection)
94
+ ctx.effect(() => {
95
+ syncPromptSection()
96
+ return () => { if (disposePromptSection) { try { disposePromptSection() } catch { /* cleanup best effort */ } } }
97
+ }, 'dsh-kanban: system prompt section')
98
+
99
+ ctx.effect(() => ctx.on('session/event', (session, event) => {
100
+ try {
101
+ const sessionIdStr = String(session?.id ?? '')
102
+ if (
103
+ getConfig().autoSyncSessions === true &&
104
+ session &&
105
+ !session.isSubagent &&
106
+ session.origin !== 'subagent' &&
107
+ session.parentId === undefined &&
108
+ session.meta?.origin !== 'subagent' &&
109
+ !sessionIdStr.startsWith('kanban-') &&
110
+ !sessionIdStr.startsWith('kanban_')
111
+ ) {
112
+ let existingTask = store?.findTaskBySession?.(sessionIdStr)
113
+ if (!existingTask && (event?.type === 'turn/start' || event?.type === 'session/created' || event?.type === 'input/user' || event?.type === 'message')) {
114
+ const userText = (event?.content?.[0]?.text ?? event?.text ?? '').trim()
115
+ const sessionTitle = session.title || session.name || (userText ? userText.slice(0, 50) : `Dialog ${sessionIdStr.slice(0, 8)}`)
116
+ const createdTask = store.createTask({
117
+ title: sessionTitle,
118
+ body: userText || `Automatically created from chat session ${sessionIdStr}`,
119
+ column: 'in-progress',
120
+ board: 'main',
121
+ sessionId: sessionIdStr,
122
+ labels: ['chat-sync'],
123
+ })
124
+ if (createdTask) {
125
+ existingTask = createdTask
126
+ eventHub.broadcast('task:change', { taskId: createdTask.id, action: 'created' })
127
+ }
128
+ } else if (existingTask) {
129
+ if (event?.type === 'turn/end' || event?.type === 'turn/completed') {
130
+ if (existingTask.column === 'in-progress') {
131
+ store.moveTask(existingTask.id, { column: 'review' })
132
+ eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'review' })
133
+ }
134
+ } else if (event?.type === 'turn/error' || event?.type === 'session/error') {
135
+ if (existingTask.column === 'in-progress') {
136
+ store.moveTask(existingTask.id, { column: 'todo' })
137
+ eventHub.broadcast('task:change', { taskId: existingTask.id, action: 'moved', toCol: 'todo' })
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ const text = event?.content?.[0]?.text ?? event?.text ?? ''
144
+ if (existingTask) {
145
+ if (event?.type === 'tool/error' || event?.type === 'turn/error' || event?.type === 'session/error') {
146
+ watchdog.recordError(existingTask.id, event.error || text)
147
+ } else {
148
+ watchdog.recordActivity(existingTask.id, event?.type)
149
+ if (event?.type === 'tool/result' || event?.type === 'turn/completed' || event?.type === 'turn/end') {
150
+ watchdog.clearError(existingTask.id)
151
+ }
152
+ }
153
+ }
154
+ handleSessionEvent({
155
+ store,
156
+ sessionId: String(session?.id ?? ''),
157
+ session,
158
+ type: event?.type,
159
+ text,
160
+ error: event?.error,
161
+ })
162
+ syncPower()
163
+ if (event?.type === 'turn/end' || event?.type === 'turn/completed' || event?.type === 'session/error' || event?.type === 'turn/error') {
164
+ dispatchNextQueuedTask({
165
+ agents: ctx.agents,
166
+ store,
167
+ config: getConfig(),
168
+ mintSessionId: () => SessionId(`kanban-queued-${randomUUID()}`),
169
+ createMessage: createUserMessage,
170
+ logger: ctx.logger,
171
+ permissions: ctx.get('permissionPresets'),
172
+ workspaceRegistry: ctx.get('workspaceRegistry'),
173
+ claimAs: 'queue-dispatcher',
174
+ }).catch(() => {})
175
+ }
176
+ } catch (error) {
177
+ ctx.logger?.warn?.(`dsh-kanban: session event handler failed: ${error?.message}`)
178
+ }
179
+ }), 'dsh-kanban: session events')
180
+
181
+ ctx.effect(() => {
182
+ let unregister
183
+ const arm = () => {
184
+ const wanted = getConfig().boardToolEnabled === true
185
+ if (wanted === (unregister !== undefined)) return
186
+ if (!wanted) { unregister?.(); unregister = undefined; return }
187
+ unregister = registerBoardTool()
188
+ }
189
+
190
+ const registerBoardTool = () => {
191
+ const off = [
192
+ ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig, eventHub }))),
193
+ ctx.tools.register(defineTool(boardPlanDefinition({ store, eventHub }))),
194
+ ctx.tools.register(defineTool(boardChecklistDefinition({ store, eventHub }))),
195
+ ctx.tools.register(defineTool(boardReportDefinition({ store }))),
196
+ ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
197
+ ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
198
+ ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
199
+ ctx.tools.register(defineTool(boardChecklistItemDefinition({ store, eventHub }))),
200
+ ]
201
+ return () => { for (const stop of off) stop?.() }
202
+ }
203
+
204
+ arm()
205
+ rearmers.add(arm)
206
+ return () => { rearmers.delete(arm); unregister?.() }
207
+ }, 'dsh-kanban: board tool')
208
+
209
+ const liveOf = (sessionId) => {
210
+ try {
211
+ const a = ctx.agents?.get?.(sessionId)
212
+ if (!a) return undefined
213
+ return {
214
+ status: a.status || (typeof a.isIdle === 'function' && a.isIdle() ? 'idle' : 'running'),
215
+ state: a.state || a.lifecycleState,
216
+ step: a.step || a.currentStep || a.stateDetail || a.activity || '',
217
+ activity: a.activity || a.statusDetail || '',
218
+ }
219
+ } catch {
220
+ return undefined
221
+ }
222
+ }
223
+
224
+ return { powerInhibitor, syncPower, workflowBridge, liveOf, watchdog }
225
+ }