@goodandready/dsh-kanban 0.2.21 → 0.2.23

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.
@@ -8,8 +8,8 @@
8
8
  export function createGitProvider({ gitea, github, getConfig }) {
9
9
  async function resolveActiveProvider(hint) {
10
10
  if (hint && typeof hint === 'object') {
11
- if (hint.provider === 'github') return github
12
- if (hint.provider === 'gitea') return gitea
11
+ if (hint.forge === 'github' || hint.provider === 'github') return github
12
+ if (hint.forge === 'gitea' || hint.provider === 'gitea') return gitea
13
13
  if (typeof hint.issueUrl === 'string' && hint.issueUrl.includes('github.com')) {
14
14
  return github
15
15
  }
@@ -58,6 +58,15 @@ export function createGitProvider({ gitea, github, getConfig }) {
58
58
  return p.me()
59
59
  },
60
60
 
61
+ /** Create repository. */
62
+ async createRepo(options) {
63
+ const p = forTarget(options?.provider) || await resolveActiveProvider(options)
64
+ if (typeof p?.createRepo !== 'function') {
65
+ throw Object.assign(new Error('Repository creation not supported by provider'), { code: 'not-supported' })
66
+ }
67
+ return p.createRepo(options)
68
+ },
69
+
61
70
  /** List issues. */
62
71
  async listIssues(options) {
63
72
  const p = forTarget(options?.provider) || await resolveActiveProvider(options)
package/lib/github.js CHANGED
@@ -188,6 +188,16 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
188
188
  return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
189
189
  },
190
190
 
191
+ /** Create private, empty repository in organization or user account. */
192
+ async createRepo({ owner, name, description = '' }) {
193
+ const safe = safeSegment(name)
194
+ if (safe === undefined) throw Object.assign(new Error('invalid name'), { code: 'bad-segment' })
195
+ const path = owner ? `/orgs/${safeSegment(owner)}/repos` : '/user/repos'
196
+ return request('POST', path, {
197
+ name: safe, description, private: true, auto_init: false,
198
+ })
199
+ },
200
+
191
201
  /** Create new issue. */
192
202
  async createIssue({ owner, repo, title, body }) {
193
203
  const name = String(title ?? '').trim()
package/lib/import.js CHANGED
@@ -152,24 +152,24 @@ export async function giteaState(gitea) {
152
152
  }
153
153
 
154
154
  /** List open issues for import dialog, marking already-imported ones. */
155
- export async function listImportable({ gitea, store, owner, repo, board = 'main' }) {
155
+ export async function listImportable({ gitea, store, owner, repo, board = 'main', provider, forge }) {
156
156
  const state = await giteaState(gitea)
157
157
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
158
- const issues = await gitea.listIssues({ owner, repo, state: 'open' })
158
+ const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
159
159
  const existing = store.listTasks({ board })
160
160
  return { issues: markImported(issues, existing, { owner, repo }) }
161
161
  }
162
162
 
163
163
  /** Create a task from an issue. */
164
- export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog' }) {
164
+ export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog', provider, forge }) {
165
165
  const state = await giteaState(gitea)
166
166
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
167
167
  if (typeof issueNumber !== 'number') return { error: 'issue-number-required', status: 400 }
168
168
  // Simple board is for non-issue tasks. Importing here would bring branches/PRs into columns unable to display them.
169
169
  if (store.boardKind(board) !== 'project') return { error: 'board-not-for-issues', status: 400 }
170
- const issue = await gitea.getIssue({ owner, repo, index: issueNumber })
170
+ const issue = await gitea.getIssue({ owner, repo, index: issueNumber, provider: forge || provider })
171
171
  if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
172
- return { task: store.createTask(issueToTask(issue, { board, column, owner, repo })) }
172
+ return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge: forge || provider })) }
173
173
  }
174
174
 
175
175
  /** Re-read issue and update task, preserving local state. */
@@ -179,7 +179,8 @@ export async function refreshTask({ gitea, store, id }) {
179
179
  const task = store.getTask(id)
180
180
  if (task === undefined) return { error: 'task-not-found', status: 404 }
181
181
  if (typeof task.issueNumber !== 'number') return { error: 'task-has-no-issue', status: 400 }
182
- const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber })
182
+ const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
183
+ const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber, provider: forge, issueUrl: task.issueUrl })
183
184
  if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
184
185
  return { task: store.updateTask(id, refreshPatch(task, issue)) }
185
186
  }
@@ -199,7 +200,7 @@ export async function searchRepos({ gitea, query, limit = 20 }) {
199
200
  * Failure midway avoids leaving orphaned cards.
200
201
  */
201
202
  export async function createProjectTask({
202
- gitea, store, owner, repo, newRepo, title, body, board = 'main',
203
+ gitea, store, owner, repo, newRepo, title, body, board = 'main', provider, forge,
203
204
  }) {
204
205
  const state = await giteaState(gitea)
205
206
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
@@ -214,7 +215,7 @@ export async function createProjectTask({
214
215
  // Validate repository name before API call.
215
216
  if (safeSegment(wanted) === undefined) return { error: 'bad-repo-name', status: 400 }
216
217
  try {
217
- await gitea.createRepo({ owner, name: wanted })
218
+ await gitea.createRepo({ owner, name: wanted, provider: forge || provider })
218
219
  } catch (error) {
219
220
  return { error: 'repo-not-created', status: 502, detail: String(error?.message ?? error) }
220
221
  }
@@ -223,11 +224,11 @@ export async function createProjectTask({
223
224
 
224
225
  let issue
225
226
  try {
226
- issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body })
227
+ issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider: forge || provider })
227
228
  } catch (error) {
228
229
  // Repository may have already been created; avoid deleting on issue creation failure.
229
230
  return { error: 'issue-not-created', status: 502, detail: String(error?.message ?? error) }
230
231
  }
231
232
 
232
- return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo })) }
233
+ return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge: forge || provider })) }
233
234
  }
package/lib/index.js CHANGED
@@ -807,14 +807,14 @@ export function apply(ctx, config) {
807
807
  // Single setting switch controls both board tools.
808
808
  const registerBoardTool = () => {
809
809
  const off = [
810
- ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
811
- ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
812
- ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
810
+ ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig, eventHub }))),
811
+ ctx.tools.register(defineTool(boardPlanDefinition({ store, eventHub }))),
812
+ ctx.tools.register(defineTool(boardChecklistDefinition({ store, eventHub }))),
813
813
  ctx.tools.register(defineTool(boardReportDefinition({ store }))),
814
814
  ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
815
815
  ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
816
816
  ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
817
- ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
817
+ ctx.tools.register(defineTool(boardChecklistItemDefinition({ store, eventHub }))),
818
818
  ]
819
819
  return () => { for (const stop of off) stop?.() }
820
820
  }
@@ -946,7 +946,11 @@ export function apply(ctx, config) {
946
946
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
947
947
  try {
948
948
  const input = await readBody(req)
949
- reply(res, importBoard({ store, input }))
949
+ const impResult = importBoard({ store, input })
950
+ if (impResult?.imported > 0) {
951
+ eventHub.broadcast('task:change', { action: 'imported', count: impResult.imported })
952
+ }
953
+ reply(res, impResult)
950
954
  } catch {
951
955
  writeJson(res, 400, { error: 'bad-request' })
952
956
  }
@@ -965,7 +969,11 @@ export function apply(ctx, config) {
965
969
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
966
970
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
967
971
  try {
968
- reply(res, createTask({ store, input: await readBody(req) }))
972
+ const resObj = createTask({ store, input: await readBody(req) })
973
+ if (resObj?.task) {
974
+ eventHub.broadcast('task:change', { taskId: resObj.task.id, action: 'created' })
975
+ }
976
+ reply(res, resObj)
969
977
  } catch (error) {
970
978
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
971
979
  }
@@ -987,12 +995,23 @@ export function apply(ctx, config) {
987
995
  return
988
996
  }
989
997
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
990
- if (req.method === 'DELETE' && action === undefined) { reply(res, deleteTask({ store, id })); return }
998
+ if (req.method === 'DELETE' && action === undefined) {
999
+ const resObj = deleteTask({ store, id })
1000
+ if (resObj?.deleted) {
1001
+ eventHub.broadcast('task:change', { taskId: id, action: 'deleted' })
1002
+ }
1003
+ reply(res, resObj)
1004
+ return
1005
+ }
991
1006
  // Card fields update: title, body, labels, priority. Priority is board-local.
992
1007
  if (req.method === 'POST' && action === 'update') {
993
1008
  try {
994
1009
  const input = await readBody(req)
995
- reply(res, updateTask({ store, id, input }))
1010
+ const resObj = updateTask({ store, id, input })
1011
+ if (resObj?.task) {
1012
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1013
+ }
1014
+ reply(res, resObj)
996
1015
  } catch {
997
1016
  writeJson(res, 400, { error: 'bad-request' })
998
1017
  }
@@ -1169,6 +1188,9 @@ export function apply(ctx, config) {
1169
1188
  }
1170
1189
  if (req.method === 'POST' && action === 'checklist-toggle') {
1171
1190
  const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
1191
+ if (resObj?.body?.task) {
1192
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1193
+ }
1172
1194
  writeJson(res, resObj.status, resObj.body)
1173
1195
  return
1174
1196
  }
@@ -1179,6 +1201,9 @@ export function apply(ctx, config) {
1179
1201
  }
1180
1202
  if (req.method === 'POST' && action === 'checklist') {
1181
1203
  const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
1204
+ if (resObj?.body?.task) {
1205
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1206
+ }
1182
1207
  writeJson(res, resObj.status, resObj.body)
1183
1208
  return
1184
1209
  }
package/lib/launcher.js CHANGED
@@ -12,11 +12,38 @@
12
12
 
13
13
  import { isAbsolute, join, resolve, sep } from 'node:path'
14
14
  import { randomUUID } from 'node:crypto'
15
- import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
15
+ import { createTaskWorktree, registerDshWorkspace, isGitRepo } from './worktree.js'
16
16
  import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
17
17
 
18
18
  export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
19
19
 
20
+ const activeReservations = new Set()
21
+
22
+ export function reserveSessionSlot(taskId) {
23
+ if (taskId) activeReservations.add(taskId)
24
+ }
25
+
26
+ export function releaseSessionSlot(taskId) {
27
+ if (taskId) activeReservations.delete(taskId)
28
+ }
29
+
30
+ export function verifyPermissionGate({ task, permission, config }) {
31
+ if (!config?.permissionGateEnabled) return
32
+ const perm = permission || task?.permission || config?.sessionDefaultPermission || 'read-only'
33
+ const defaultPerm = config?.sessionDefaultPermission || 'read-only'
34
+ const permRank = PERMISSION_RANKS[perm] ?? Infinity
35
+ const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
36
+ const confirmedTier = task?.permissionConfirmedTier || (task?.permissionConfirmedAt ? task?.permission : undefined)
37
+ const confirmedRank = confirmedTier ? (PERMISSION_RANKS[confirmedTier] ?? -1) : -1
38
+
39
+ if (permRank > defaultRank && permRank > confirmedRank) {
40
+ const err = new Error('Permission confirmation required')
41
+ err.key = 'permission-unconfirmed'
42
+ err.status = 403
43
+ throw err
44
+ }
45
+ }
46
+
20
47
  /**
21
48
  * Tail instructions for local board tasks.
22
49
  *
@@ -134,20 +161,44 @@ export function applyPermission({ permissions, agent, name }) {
134
161
  export async function obtainAgent({
135
162
  agents, task, config, provider, model, mintSessionId, cwdOf, logger,
136
163
  agentPreset, permission, permissions, gitRunner, workspaceRegistry,
164
+ reasoningEffort,
137
165
  }) {
166
+ const chosenPerm = permission || task?.permission || ''
167
+ const effort = reasoningEffort || task?.reasoningEffort || undefined
168
+ const agentOptions = { provider, model, ...(effort ? { reasoningEffort: effort } : {}) }
169
+
138
170
  const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
139
171
 
140
172
  if (known !== undefined) {
141
173
  // Live agent — reuse active session.
142
174
  let live
143
175
  try { live = agents.get(known) } catch { live = undefined }
144
- if (live !== undefined) return { agent: live, sessionId: known, mode: 'opened' }
176
+ if (live !== undefined) {
177
+ const access = applyPermission({ permissions, agent: live, name: chosenPerm })
178
+ if (access.error !== undefined) {
179
+ const error = new Error('access tier not set')
180
+ error.key = access.error
181
+ error.status = 409
182
+ throw error
183
+ }
184
+ return { agent: live, sessionId: known, mode: 'opened' }
185
+ }
145
186
 
146
187
  // Session suspended; attempt resume.
147
188
  try {
148
- const handle = await agents.resume({ resumeSessionId: known, agentOptions: { provider, model } })
189
+ const handle = await agents.resume({ resumeSessionId: known, agentOptions })
190
+ const access = applyPermission({ permissions, agent: handle.agent, name: chosenPerm })
191
+ if (access.error !== undefined) {
192
+ const error = new Error('access tier not set')
193
+ error.key = access.error
194
+ error.status = 409
195
+ throw error
196
+ }
149
197
  return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'resumed' }
150
198
  } catch (error) {
199
+ if (error?.key === 'permission-refused' || error?.key === 'permission-unavailable' || error?.status === 409) {
200
+ throw error
201
+ }
151
202
  logger?.warn?.(`dsh-kanban: session ${known} resume failed: ${error?.message}`)
152
203
  }
153
204
  }
@@ -159,30 +210,41 @@ export async function obtainAgent({
159
210
  let workspaceId = task?.workspaceId || undefined
160
211
  let worktreeCreated = false
161
212
 
162
- if (config?.worktreeIsolation !== false && task?.repo) {
213
+ const isGit = await isGitRepo(cwd, gitRunner)
214
+ if (config?.worktreeIsolation !== false && isGit) {
215
+ let created
163
216
  try {
164
- const created = await createTaskWorktree({
217
+ created = await createTaskWorktree({
165
218
  task,
166
219
  repoDir: cwd,
167
220
  config,
168
221
  gitRunner,
169
222
  cwdOf,
170
223
  })
171
- if (created) {
172
- worktreePath = created.worktreePath
173
- branchName = created.branchName
174
- worktreeCreated = created.created === true
175
-
176
- if (workspaceRegistry) {
177
- workspaceId = await registerDshWorkspace({
178
- workspaceRegistry,
179
- worktreePath,
180
- title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
181
- })
182
- }
183
- }
184
224
  } catch (err) {
185
- logger?.warn?.(`dsh-kanban: failed to create worktree for ${task.id}: ${err.message}`)
225
+ const error = new Error(`worktree-creation-failed: ${err.message}`)
226
+ error.key = 'worktree-creation-failed'
227
+ error.status = 500
228
+ throw error
229
+ }
230
+
231
+ if (!created?.worktreePath) {
232
+ const error = new Error('worktree-creation-failed')
233
+ error.key = 'worktree-creation-failed'
234
+ error.status = 500
235
+ throw error
236
+ }
237
+
238
+ worktreePath = created.worktreePath
239
+ branchName = created.branchName
240
+ worktreeCreated = created.created === true
241
+
242
+ if (workspaceRegistry) {
243
+ workspaceId = await registerDshWorkspace({
244
+ workspaceRegistry,
245
+ worktreePath,
246
+ title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
247
+ })
186
248
  }
187
249
  }
188
250
 
@@ -197,9 +259,9 @@ export async function obtainAgent({
197
259
  sessionId: mintSessionId(),
198
260
  // Profile is set during session creation.
199
261
  meta,
200
- agentOptions: { provider, model },
262
+ agentOptions,
201
263
  })
202
- const access = applyPermission({ permissions, agent: handle.agent, name: permission })
264
+ const access = applyPermission({ permissions, agent: handle.agent, name: chosenPerm })
203
265
  if (access.error !== undefined) {
204
266
  // Session already initialized; preserve configured permissions.
205
267
  try { handle.dispose?.() } catch { /* cleanup is secondary to rejection */ }
@@ -265,9 +327,11 @@ export async function runTask({
265
327
  agents, store, task, config, provider, model,
266
328
  mintSessionId, createMessage, cwdOf, logger, text,
267
329
  agentPreset, permission, permissions, claimAs,
268
- gitRunner, workspaceRegistry, resume = false,
330
+ gitRunner, workspaceRegistry, resume = false, reasoningEffort,
269
331
  }) {
270
- // Concurrency limit check (#214)
332
+ const effort = reasoningEffort || task?.reasoningEffort || undefined
333
+
334
+ // Concurrency limit check (#214, #330)
271
335
  const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
272
336
  if (maxConcurrent > 0) {
273
337
  const activeCount = countActiveSessions({ store, agents })
@@ -275,7 +339,16 @@ export async function runTask({
275
339
  const isAlreadyRunning = task.sessionId && agents?.get?.(task.sessionId)?.status === 'running'
276
340
  if (!isAlreadyRunning && activeCount >= maxConcurrent) {
277
341
  const now = Date.now()
278
- store.updateTask(task.id, { queuedAt: task.queuedAt > 0 ? task.queuedAt : now })
342
+ store.updateTask(task.id, {
343
+ queuedAt: task.queuedAt > 0 ? task.queuedAt : now,
344
+ ...(provider ? { provider } : {}),
345
+ ...(model ? { model } : {}),
346
+ ...(permission ? { permission } : {}),
347
+ ...(effort ? { reasoningEffort: effort } : {}),
348
+ queuedRequest: JSON.stringify({
349
+ provider, model, permission, reasoningEffort: effort, agentPreset, text,
350
+ }),
351
+ })
279
352
  store.addTransition({
280
353
  taskId: task.id,
281
354
  fromCol: task.column,
@@ -286,28 +359,23 @@ export async function runTask({
286
359
  return { mode: 'queued', queued: true, taskId: task.id, activeCount, maxConcurrent }
287
360
  }
288
361
  }
289
- if (config?.permissionGateEnabled) {
290
- const perm = permission || task.permission || 'read-only'
291
- const defaultPerm = config?.sessionDefaultPermission || 'read-only'
292
- const permRank = PERMISSION_RANKS[perm] ?? Infinity
293
- const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
294
- if (permRank > defaultRank) {
295
- if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
296
- const err = new Error('Permission confirmation required')
297
- err.key = 'permission-unconfirmed'
298
- err.status = 403
299
- throw err
300
- }
301
- }
302
- }
303
362
 
304
- const taken = await obtainAgent({
305
- agents, task, config, provider, model, mintSessionId, cwdOf, logger,
306
- agentPreset, permission, permissions, gitRunner, workspaceRegistry,
307
- })
363
+ verifyPermissionGate({ task, permission, config })
308
364
 
309
- if (task.queuedAt > 0) {
310
- store.updateTask(task.id, { queuedAt: 0 })
365
+ reserveSessionSlot(task.id)
366
+ let taken
367
+ try {
368
+ taken = await obtainAgent({
369
+ agents, task, config, provider, model, mintSessionId, cwdOf, logger,
370
+ agentPreset, permission, permissions, gitRunner, workspaceRegistry,
371
+ reasoningEffort: effort,
372
+ })
373
+ } finally {
374
+ releaseSessionSlot(task.id)
375
+ }
376
+
377
+ if (task.queuedAt > 0 || task.queuedRequest) {
378
+ store.updateTask(task.id, { queuedAt: 0, queuedRequest: '' })
311
379
  }
312
380
 
313
381
  // Record run in task execution history
@@ -462,10 +530,31 @@ export async function runBatch({
462
530
  const list = (tasks ?? []).filter((task) => task !== undefined)
463
531
  if (list.length === 0) return { error: 'nothing-picked', status: 400 }
464
532
 
465
- const taken = await obtainAgent({
466
- agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
467
- agentPreset, permission, permissions,
468
- })
533
+ for (const t of list) {
534
+ verifyPermissionGate({ task: t, permission, config })
535
+ }
536
+
537
+ const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
538
+ if (maxConcurrent > 0) {
539
+ const activeCount = countActiveSessions({ store, agents })
540
+ if (activeCount >= maxConcurrent) {
541
+ const err = new Error('concurrent-limit-reached')
542
+ err.key = 'concurrent-limit-reached'
543
+ err.status = 429
544
+ throw err
545
+ }
546
+ }
547
+
548
+ reserveSessionSlot(list[0].id)
549
+ let taken
550
+ try {
551
+ taken = await obtainAgent({
552
+ agents, task: list[0], config, provider, model, mintSessionId, cwdOf, logger,
553
+ agentPreset, permission, permissions,
554
+ })
555
+ } finally {
556
+ releaseSessionSlot(list[0].id)
557
+ }
469
558
 
470
559
  await taken.agent.whenIdle()
471
560
  const own = typeof text === 'string' ? text.trim() : ''
@@ -563,12 +652,23 @@ export function unqueueTask({ store, task }) {
563
652
  */
564
653
  export function countActiveSessions({ store, agents }) {
565
654
  const sessions = liveSessions({ store, agents })
566
- return sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
655
+ const activeCount = sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
656
+ return activeCount + activeReservations.size
567
657
  }
568
658
 
569
659
  export function liveSessions({ store, agents }) {
570
660
  const byId = new Map()
571
- const all = [...store.listTasks({ board: 'main' }), ...store.listTasks({ board: 'simple' })]
661
+ let all = []
662
+ if (typeof store?.listBoards === 'function' && typeof store?.listTasks === 'function') {
663
+ const boards = store.listBoards() || []
664
+ all = boards.flatMap((b) => store.listTasks({ board: b.id }) || [])
665
+ } else if (typeof store?.listTasks === 'function') {
666
+ all = store.listTasks() || []
667
+ } else if (typeof store?.listInColumn === 'function') {
668
+ all = store.listInColumn('in-progress') || []
669
+ } else if (Array.isArray(store?.tasks)) {
670
+ all = store.tasks
671
+ }
572
672
  for (const task of all) {
573
673
  const id = typeof task.sessionId === 'string' ? task.sessionId : ''
574
674
  if (id === '') continue
@@ -606,19 +706,26 @@ export async function dispatchNextQueuedTask({
606
706
  if (queued.length === 0) return null
607
707
 
608
708
  const nextTask = queued[0]
709
+ let queuedParams = {}
710
+ if (typeof nextTask.queuedRequest === 'string' && nextTask.queuedRequest) {
711
+ try { queuedParams = JSON.parse(nextTask.queuedRequest) } catch { /* ignore */ }
712
+ }
609
713
  try {
610
714
  return await runTask({
611
715
  agents,
612
716
  store,
613
717
  task: nextTask,
614
718
  config,
615
- provider: nextTask.provider || undefined,
616
- model: nextTask.model || undefined,
719
+ provider: queuedParams.provider || nextTask.provider || undefined,
720
+ model: queuedParams.model || nextTask.model || undefined,
721
+ permission: queuedParams.permission || nextTask.permission || undefined,
722
+ reasoningEffort: queuedParams.reasoningEffort || nextTask.reasoningEffort || undefined,
723
+ text: queuedParams.text || undefined,
724
+ agentPreset: queuedParams.agentPreset || agentPreset,
617
725
  mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
618
726
  createMessage: createMessage || ((msg) => msg),
619
727
  cwdOf,
620
728
  logger,
621
- agentPreset,
622
729
  permissions,
623
730
  workspaceRegistry,
624
731
  claimAs: claimAs || 'queue-dispatcher',
package/lib/order.js CHANGED
@@ -1,63 +1,33 @@
1
1
  // Fractional ordering index for cards.
2
2
  //
3
- // Naive approach stores integer index, and dragging card to the top of column
4
- // with ten items requires rewriting all ten. Here order is stored as string,
5
- // and new value is generated BETWEEN two neighbors, so drag-and-drop is always
6
- // a single UPDATE of one row regardless of how many cards are in column.
7
- //
8
- // String-based implementation is intentional. Floating-point numbers fail
9
- // after ~50 insertions into the same position when precision runs out.
10
- //
11
- // Key is read as fraction after implicit '0.': comparison is standard
12
- // lexicographical. Key never ends with first alphabet character to ensure
13
- // subsequent division remains possible.
14
-
15
- /** Alphabet in ascending character code order. */
16
- const DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz'
17
- const FIRST = DIGITS[0]
18
-
19
- /** Value strictly between `a` and `b`; `b === undefined` means unbounded right. */
20
- function midpoint(a, b) {
21
- if (b !== undefined && a >= b) {
22
- throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
23
- }
24
- if (a.endsWith(FIRST) || (b !== undefined && b.endsWith(FIRST))) {
25
- throw new Error('order key cannot end with first alphabet symbol')
26
- }
27
-
28
- if (b !== undefined) {
29
- // Common prefix carried as-is, division continues on remainder
30
- // to avoid inflating key length unnecessarily.
31
- let n = 0
32
- while ((a[n] ?? FIRST) === b[n]) n += 1
33
- if (n > 0) return b.slice(0, n) + midpoint(a.slice(n), b.slice(n))
34
- }
35
-
36
- const from = a === '' ? 0 : DIGITS.indexOf(a[0])
37
- const to = b === undefined ? DIGITS.length : DIGITS.indexOf(b[0])
38
- if (from < 0 || to < 0) throw new Error('order key contains character outside alphabet')
3
+ // String-based implementation using rocicorp/fractional-indexing (CC0-1.0).
4
+ // Generates bounded-length order keys strictly between neighbors, avoiding
5
+ // O(N^2) string inflation on repeated appends/prepends.
39
6
 
40
- if (to - from > 1) {
41
- return DIGITS[Math.round(0.5 * (from + to))]
42
- }
43
- if (b !== undefined && b.length > 1) {
44
- return b.slice(0, 1)
45
- }
46
- // Adjacent symbols: take `a` digit and divide next position.
47
- return DIGITS[from] + midpoint(a.slice(1), undefined)
48
- }
7
+ import { generateKeyBetween, generateNKeysBetween } from './fractional-indexing.js'
49
8
 
50
9
  /**
51
10
  * Fractional order key strictly between two neighbors.
52
11
  *
53
- * Both neighbors are optional: without either returns default initial value;
54
- * without right returns value after `a`; without left returns value before `b`.
55
- *
56
12
  * @param {string} [a] left neighbor order key
57
13
  * @param {string} [b] right neighbor order key
58
14
  * @returns {string} key strictly between them
59
- * @throws if `a` is not less than `b` or key contains invalid character
15
+ * @throws if a >= b or key contains invalid character
60
16
  */
61
17
  export function keyBetween(a, b) {
62
- return midpoint(a ?? '', b ?? undefined)
18
+ if (a !== undefined && a !== null && b !== undefined && b !== null) {
19
+ if (a >= b) {
20
+ throw new Error(`order violated: ${JSON.stringify(a)} not less than ${JSON.stringify(b)}`)
21
+ }
22
+ }
23
+ if (typeof a === 'string' && /[^0-9a-zA-Z]/.test(a)) {
24
+ throw new Error('order key contains character outside alphabet')
25
+ }
26
+ if (typeof b === 'string' && /[^0-9a-zA-Z]/.test(b)) {
27
+ throw new Error('order key contains character outside alphabet')
28
+ }
29
+
30
+ return generateKeyBetween(a ?? null, b ?? null)
63
31
  }
32
+
33
+ export { generateKeyBetween, generateNKeysBetween }