dsh-taskboard 0.6.1 → 0.6.3

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.
@@ -25,6 +25,7 @@ import {
25
25
  checklistFromTexts,
26
26
  defaultIsolationOf,
27
27
  defaultPermissionOf,
28
+ isValidRelRepoPath,
28
29
  newCommentId,
29
30
  newTaskId,
30
31
  normalizeBody,
@@ -41,7 +42,9 @@ import {
41
42
  type TaskRecord,
42
43
  } from '../shared/protocol.ts'
43
44
  import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
44
- import type { CatalogModelItem, CatalogPresetItem, TaskTemplate } from '../shared/api.ts'
45
+ import { removeMirror, repoMainPath } from './isolation.ts'
46
+ import { createRepoScanner, type RepoScanner } from './repos.ts'
47
+ import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
45
48
  import type { TemplateStore } from './templates.ts'
46
49
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
47
50
  import type { TaskStore } from './store.ts'
@@ -81,6 +84,8 @@ export interface TaskboardRoutesOptions {
81
84
  modelProviders?: () => string[] | undefined
82
85
  /** Git face for worktree actions + workspace git detection; absent → 501 on git actions. */
83
86
  git?: GitFace
87
+ /** Nested-repo scanner for mirror removal; absent → a default one is built on first use. */
88
+ scanner?: RepoScanner
84
89
  /** Task-template store (0.4.0); absent → 501 on template actions. */
85
90
  templates?: TemplateStore
86
91
  /** Prompt completions face (0.5.5; dynamically discovers skills & commands). */
@@ -257,7 +262,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
257
262
 
258
263
  // Workspace git detection, TTL-cached and fail-soft (false on any error):
259
264
  // feeds the create-form isolation toggle and the diagnostics panel.
260
- const gitCache = new Map<string, { value: boolean; at: number }>()
265
+ const gitCache = new Map<string, { value: boolean; at: number; repoCount?: number }>()
261
266
  const gitHinted = new Set<string>()
262
267
 
263
268
  /** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
@@ -274,24 +279,60 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
274
279
  }
275
280
  }
276
281
 
277
- const gitAvailable = async (path: string): Promise<boolean> => {
278
- if (options.git === undefined) return false
282
+ // One scanner for every route-side discovery (the injected one or a
283
+ // shared real-IO fallback whose TTL cache actually helps — 0.6.3).
284
+ const sharedScanner: RepoScanner = options.scanner ?? createRepoScanner()
285
+
286
+ /**
287
+ * Whether the workspace root itself is a git repo (the .gitignore-suggestion
288
+ * gate — a plain container has no repo that could ignore anything).
289
+ */
290
+ const rootIsRepo = async (path: string): Promise<boolean> => {
291
+ try {
292
+ return (await options.git?.detect(path)) === true
293
+ } catch {
294
+ return false // fail-soft
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Workspace repo facts for the form (0.6.3): `gitAvailable` gates the
300
+ * worktree option, `repoCount` feeds the mirror badge. Availability now
301
+ * covers PARALLEL MULTI-REPO workspaces too: a root repo qualifies as
302
+ * before, and a workspace whose root is NOT a repo still qualifies when
303
+ * the scanner finds nested repos — prepareMirror isolates exactly that
304
+ * container shape (mirror root = plain dir, one worktree per nested repo),
305
+ * so the form must not lock the capability away.
306
+ */
307
+ const workspaceRepos = async (path: string): Promise<{ gitAvailable: boolean; repoCount: number }> => {
308
+ if (options.git === undefined) return { gitAvailable: false, repoCount: 0 }
279
309
  const hit = gitCache.get(path)
280
- if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value
281
- let value = false
310
+ if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS) {
311
+ return { gitAvailable: hit.value, repoCount: hit.repoCount ?? (hit.value ? 1 : 0) }
312
+ }
313
+ let rootRepo = false
282
314
  try {
283
- value = await options.git.detect(path)
315
+ rootRepo = await options.git.detect(path)
284
316
  } catch { /* fail-soft → false */ }
285
- gitCache.set(path, { value, at: options.now() })
286
317
  // gitignore 建议 (plan §3.2): suggest (never write) ignoring our
287
- // worktree directory, once per workspace per host run.
288
- if (value && !gitHinted.has(path)) {
318
+ // worktree directory, once per workspace per host run. Root repos only.
319
+ if (rootRepo && !gitHinted.has(path)) {
289
320
  gitHinted.add(path)
290
321
  if (await gitignoreMissing(path)) {
291
322
  console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
292
323
  }
293
324
  }
294
- return value
325
+ // The nested scan always runs: repoCount needs it even when the root
326
+ // is a repo (root repo + nested repos = the mirror shape). The scanner's
327
+ // own TTL cache keeps repeated workspace polls cheap.
328
+ let nestedCount = 0
329
+ try {
330
+ nestedCount = (await sharedScanner.findNestedRepos(path)).length
331
+ } catch { /* fail-soft → 0 */ }
332
+ const value = rootRepo || nestedCount > 0
333
+ const repoCount = (rootRepo ? 1 : 0) + nestedCount
334
+ gitCache.set(path, { value, at: options.now(), repoCount })
335
+ return { gitAvailable: value, repoCount }
295
336
  }
296
337
 
297
338
  /** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
@@ -315,7 +356,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
315
356
  const listGitignoreSuggestions = async (): Promise<Array<{ workspaceId: string; workspacePath: string }>> => {
316
357
  const suggestions: Array<{ workspaceId: string; workspacePath: string }> = []
317
358
  for (const ws of workspaces.list()) {
318
- if (!(await gitAvailable(ws.path))) continue
359
+ // Root repos only: a plain container has no .gitignore any repo reads.
360
+ if (!(await rootIsRepo(ws.path))) continue
319
361
  if (await gitignoreMissing(ws.path)) suggestions.push({ workspaceId: ws.id, workspacePath: ws.path })
320
362
  }
321
363
  return suggestions
@@ -335,10 +377,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
335
377
  }
336
378
  if (pathname === `${ROUTE_PREFIX}/workspaces`) {
337
379
  const list = workspaces.list()
338
- const flags = await Promise.all(list.map(ws => gitAvailable(ws.path)))
380
+ const info = await Promise.all(list.map(ws => workspaceRepos(ws.path)))
339
381
  json(res, {
340
382
  ok: true,
341
- value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable: flags[i] })),
383
+ value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable: info[i]!.gitAvailable, repoCount: info[i]!.repoCount })),
342
384
  })
343
385
  return
344
386
  }
@@ -379,17 +421,29 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
379
421
  const filePath = url.searchParams.get('path')
380
422
  const ws = workspaces.get(task.workspaceId)
381
423
  if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
382
- const cwd = execution.worktreePath ?? ws.path
424
+ // 0.6.3: `?repo=` picks one repo of a multi-repo mirror ('' = the
425
+ // root); without it the legacy flat fields decide, byte-identical.
426
+ const repoParam = url.searchParams.get('repo')
427
+ let cwd = execution.worktreePath ?? ws.path
428
+ let mainRepo = ws.path
429
+ let baseCommit = execution.baseCommit
430
+ if (repoParam !== null) {
431
+ const entry = execution.repos?.find(r => r.repo === repoParam)
432
+ if (entry === undefined) throw new Error('Error: invalid_input: 该执行没有此仓库的镜像记录')
433
+ cwd = entry.worktreePath
434
+ mainRepo = repoMainPath(ws.path, entry.repo)
435
+ baseCommit = entry.baseCommit
436
+ }
383
437
  let result = commit !== null
384
438
  ? await options.git.showCommit(cwd, commit)
385
- : filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : undefined
439
+ : filePath !== null ? await options.git.showPathDiff(cwd, filePath, baseCommit) : undefined
386
440
  // Fallback: the worktree may be gone — commits and committed
387
441
  // ranges still resolve in the main repo.
388
- if (result === undefined && execution.worktreePath !== undefined && cwd !== ws.path) {
442
+ if (result === undefined && cwd !== mainRepo) {
389
443
  result = commit !== null
390
- ? await options.git.showCommit(ws.path, commit)
391
- : filePath !== null && execution.baseCommit !== undefined
392
- ? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit)
444
+ ? await options.git.showCommit(mainRepo, commit)
445
+ : filePath !== null && baseCommit !== undefined
446
+ ? await options.git.showPathDiff(mainRepo, filePath, baseCommit)
393
447
  : undefined
394
448
  }
395
449
  if (result === undefined) {
@@ -702,25 +756,37 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
702
756
  throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
703
757
  }
704
758
  try {
705
- // S3: 'unregistered' (an orphaned dir git forgot) is a
706
- // structured outcome, not a parsed stderr message.
707
- if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
708
- // An unregistered leftover dir: plain fs removal.
709
- await rm(path, { recursive: true, force: true })
710
- }
759
+ // 0.6.3: the whole mirror (every repo worktree) goes
760
+ // through the aggregated children-first removal one
761
+ // dirty repo refuses BEFORE anything is deleted.
762
+ await removeMirror(
763
+ { git: options.git, scanner: options.scanner ?? createRepoScanner() },
764
+ { workspacePath: ws.path, taskId: id },
765
+ )
766
+ // Leftovers unknown to git ('unregistered' worktrees, or a
767
+ // plain mirror dir when the workspace root is no repo).
768
+ await rm(path, { recursive: true, force: true })
711
769
  } catch (error) {
712
770
  const message = error instanceof Error ? error.message : String(error)
713
771
  // Structured classification (review P2): git tags its dirty
714
772
  // rejections with a code; the keyword stays as a fallback.
715
- const dirty = (error as { code?: string }).code === 'dirty-worktree' || message.includes('未提交修改')
773
+ const dirty = (error as { code?: string }).code === 'dirty-worktree'
774
+ || (error as { code?: string }).code === 'dirty-mirror'
775
+ || message.includes('未提交修改')
716
776
  if (dirty) {
717
777
  throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
718
778
  }
719
779
  throw new Error(`Error: invalid_input: ${message}`)
720
780
  }
721
- if (task.branch !== undefined) {
781
+ // Branches die with the task (best effort, per repo).
782
+ const branchTargets: Array<{ repo: string; branch: string }> = []
783
+ if (task.branches !== undefined) {
784
+ for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({ repo, branch })
785
+ }
786
+ if (task.branch !== undefined && !branchTargets.some(t => t.repo === '')) branchTargets.push({ repo: '', branch: task.branch })
787
+ for (const target of branchTargets) {
722
788
  try {
723
- await options.git.deleteBranch(ws.path, task.branch)
789
+ await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch)
724
790
  } catch { /* best effort: the branch may outlive the task */ }
725
791
  }
726
792
  }
@@ -786,47 +852,105 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
786
852
  return
787
853
  }
788
854
  if (action === 'merge') {
789
- // ⇥ 合并 (detail page, user-only): merge the task branch into the
790
- // main worktree with --no-ff; conflicts are reported verbatim.
855
+ // ⇥ 合并 (detail page, user-only): merge the task branch(es) into
856
+ // the main worktree(s) with --no-ff; conflicts are reported
857
+ // verbatim. 0.6.3: multi-repo mirror tasks merge PER REPO —
858
+ // sequentially, a failed repo never blocking the others.
791
859
  if (options.git === undefined) {
792
860
  const f = fail('invalid_input', 'git integration unavailable')
793
861
  json(res, f.res, 501)
794
862
  return
795
863
  }
796
- if (task.branch === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
797
864
  if (task.status === 'in_progress') throw new Error('Error: invalid_input: 任务执行中,不能合并')
798
865
  if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能合并')
799
866
  const ws = workspaces.get(task.workspaceId)
800
867
  if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
801
- // No-op detection (0.3.1): a branch with no commits over HEAD
802
- // merges as "already up to date" — report that instead of landing
803
- // a bogus 已合并 comment.
804
- let noop = false
805
- try {
806
- noop = await options.git.isAncestor(ws.path, task.branch)
807
- } catch { /* fail-soft: proceed to the real merge */ }
808
- if (noop) {
809
- json(res, { ok: true, value: { merged: false, noop: true, branch: task.branch } })
810
- return
868
+ const targets: Array<{ repo: string; branch: string }> = []
869
+ if (task.branches !== undefined) {
870
+ for (const [repo, branch] of Object.entries(task.branches)) targets.push({ repo, branch })
811
871
  }
812
- try {
813
- await options.git.merge(ws.path, task.branch)
814
- } catch (error) {
815
- throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
872
+ if (task.branch !== undefined && !targets.some(t => t.repo === '')) targets.unshift({ repo: '', branch: task.branch })
873
+ if (targets.length === 0) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
874
+ const multi = task.branches !== undefined
875
+ const results: MergeRepoResult[] = []
876
+ for (const target of targets) {
877
+ if (!isValidRelRepoPath(target.repo)) throw new Error('Error: invalid_input: 非法的仓库路径')
878
+ const repoRoot = repoMainPath(ws.path, target.repo)
879
+ // No-op detection (0.3.1): a branch with no commits over HEAD
880
+ // merges as "already up to date" — report that instead of landing
881
+ // a bogus 已合并 comment.
882
+ let noop = false
883
+ try {
884
+ noop = await options.git.isAncestor(repoRoot, target.branch)
885
+ } catch { /* fail-soft: proceed to the real merge */ }
886
+ if (noop) {
887
+ results.push({ repo: target.repo, branch: target.branch, outcome: 'noop' })
888
+ continue
889
+ }
890
+ try {
891
+ // 0.6.3 review fix: parallel repos are self-governing — their
892
+ // content AND their gitlink entries in the ROOT main checkout
893
+ // (e.g. a container repo tracking `dsh-taskboard` as an embedded
894
+ // repo) don't gate the root repo's merge. Child repos merge with
895
+ // no extra exemption (their own .dsh-worktrees rule suffices).
896
+ const exempt = target.repo === ''
897
+ ? await (options.scanner ?? createRepoScanner()).findNestedRepos(ws.path).then(rs => rs.map(r => r.relPath))
898
+ : undefined
899
+ await options.git.merge(repoRoot, target.branch, exempt)
900
+ results.push({ repo: target.repo, branch: target.branch, outcome: 'merged' })
901
+ } catch (error) {
902
+ results.push({
903
+ repo: target.repo,
904
+ branch: target.branch,
905
+ outcome: 'failed',
906
+ error: error instanceof Error ? error.message : String(error),
907
+ })
908
+ }
816
909
  }
817
- const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }
818
- // R1: the git merge above is slow — re-find the FRESH task inside
910
+ // R1: the git merges above are slow re-find the FRESH task inside
819
911
  // the mutation so a concurrent comment is never overwritten.
820
- await store.mutate('comment-added', ledger => {
821
- const { index, task: fresh } = liveTaskAt(ledger, id)
822
- const next = structuredClone(fresh)
823
- next.comments.push(mergedComment)
824
- next.version = fresh.version + 1
825
- next.updatedAt = options.now()
826
- ledger.tasks[index] = next
827
- return [next]
912
+ const pushComment = (body: string): Promise<void> =>
913
+ store.mutate('comment-added', ledger => {
914
+ const { index, task: fresh } = liveTaskAt(ledger, id)
915
+ const next = structuredClone(fresh)
916
+ next.comments.push({ id: newCommentId(), body: normalizeBody(body), version: 1, createdAt: options.now() })
917
+ next.version = fresh.version + 1
918
+ next.updatedAt = options.now()
919
+ ledger.tasks[index] = next
920
+ return [next]
921
+ }).then(() => undefined)
922
+ if (!multi) {
923
+ // Legacy single-repo shape, byte-identical to 0.3.x–0.6.x.
924
+ const root = results.find(r => r.repo === '')
925
+ if (root === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
926
+ if (root.outcome === 'noop') {
927
+ json(res, { ok: true, value: { merged: false, noop: true, branch: root.branch } })
928
+ return
929
+ }
930
+ if (root.outcome === 'failed') {
931
+ throw new Error(`Error: invalid_input: ${root.error ?? '合并失败'}`)
932
+ }
933
+ await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`)
934
+ json(res, { ok: true, value: { merged: true, branch: root.branch } })
935
+ return
936
+ }
937
+ const labelOf = (repo: string): string => repo === '' ? '根仓库' : repo
938
+ const mergedCount = results.filter(r => r.outcome === 'merged').length
939
+ const failedCount = results.filter(r => r.outcome === 'failed').length
940
+ const summary = results
941
+ .map(r => r.outcome === 'merged'
942
+ ? `${labelOf(r.repo)} ✓ 已合并`
943
+ : r.outcome === 'noop' ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? '合并失败').slice(0, 150)}`)
944
+ .join(';')
945
+ await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`)
946
+ json(res, {
947
+ ok: true,
948
+ value: {
949
+ merged: mergedCount > 0,
950
+ ...(mergedCount === 0 && failedCount === 0 ? { noop: true } : {}),
951
+ results,
952
+ },
828
953
  })
829
- json(res, { ok: true, value: { merged: true, branch: task.branch } })
830
954
  return
831
955
  }
832
956
  if (action === 'worktree-remove') {
@@ -845,23 +969,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
845
969
  throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
846
970
  }
847
971
  try {
848
- // S3: an unregistered leftover at the task's own path is
849
- // removed from the filesystem directly.
850
- if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
851
- await rm(path, { recursive: true, force: true })
852
- }
972
+ // 0.6.3: aggregated children-first mirror removal (see purge).
973
+ await removeMirror(
974
+ { git: options.git, scanner: options.scanner ?? createRepoScanner() },
975
+ { workspacePath: ws.path, taskId: id },
976
+ )
977
+ await rm(path, { recursive: true, force: true })
853
978
  } catch (error) {
854
979
  throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
855
980
  }
856
981
  let branchDeleted = false
857
982
  let branchError: string | undefined
858
- if (body.deleteBranch === true && task.branch !== undefined) {
859
- try {
860
- await options.git.deleteBranch(ws.path, task.branch)
861
- branchDeleted = true
862
- } catch (error) {
863
- branchError = error instanceof Error ? error.message : String(error)
983
+ if (body.deleteBranch === true) {
984
+ const branchTargets: Array<{ repo: string; branch: string }> = []
985
+ if (task.branches !== undefined) {
986
+ for (const [repo, branch] of Object.entries(task.branches)) branchTargets.push({ repo, branch })
987
+ }
988
+ if (task.branch !== undefined && !branchTargets.some(t => t.repo === '')) branchTargets.push({ repo: '', branch: task.branch })
989
+ let failures = 0
990
+ for (const target of branchTargets) {
991
+ try {
992
+ await options.git.deleteBranch(repoMainPath(ws.path, target.repo), target.branch)
993
+ } catch (error) {
994
+ failures += 1
995
+ const label = target.repo === '' ? '根仓库' : target.repo
996
+ branchError = `${label}:${error instanceof Error ? error.message : String(error)}`
997
+ }
864
998
  }
999
+ branchDeleted = failures === 0
865
1000
  }
866
1001
  json(res, { ok: true, value: { removed: true, branchDeleted, ...(branchError !== undefined ? { branchError } : {}) } })
867
1002
  return
@@ -898,11 +1033,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
898
1033
  throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
899
1034
  }
900
1035
  try {
901
- // S3: 'unregistered' = git no longer knows this worktree; remove
902
- // the leftover dir directly (scope-verified above).
903
- if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
904
- await rm(path, { recursive: true, force: true })
905
- }
1036
+ // 0.6.3: an orphan mirror may hold several repo worktrees
1037
+ // aggregated children-first removal, then the leftover fs rm
1038
+ // (scope-verified above).
1039
+ await removeMirror(
1040
+ { git: options.git, scanner: options.scanner ?? createRepoScanner() },
1041
+ { workspacePath: ws.path, taskId },
1042
+ )
1043
+ await rm(path, { recursive: true, force: true })
906
1044
  } catch (error) {
907
1045
  throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
908
1046
  }
package/src/host/tools.ts CHANGED
@@ -86,7 +86,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
86
86
  `任务 ${t.id} 「${t.title}」`,
87
87
  `状态: ${t.status} (v${t.version}) · 紧急度: ${t.urgency} · 项目: ${t.workspaceId}${t.blocked ? ' · 受阻' : ''}`,
88
88
  `执行方式: ${t.execution.mode}${t.execution.cron !== undefined ? ` cron=${t.execution.cron}` : ''}`,
89
- `隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}`,
89
+ `隔离: ${t.isolation === 'none' ? '关闭(原目录执行)' : 'Git Worktree'}${t.branch !== undefined ? `(分支 ${t.branch})` : ''}${t.branches !== undefined ? `(多仓库镜像 ${Object.keys(t.branches).length + (t.branch !== undefined ? 1 : 0)} 个仓库)` : ''}`,
90
90
  ]
91
91
  const holder = isClaimedBy(t)
92
92
  if (holder !== undefined) lines.push(`认领: agent ${String(holder).slice(0, 24)}(持有期间其他会话不可移动)`)
@@ -925,4 +925,4 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
925
925
  })) as () => void)
926
926
 
927
927
  return disposers
928
- }
928
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import type {} from '@deepseek-ai/dsh-agent'
22
22
  import { PROTOCOL_SECTION_NAME, PROTOCOL_SECTION_ORDER, TASKBOARD_PROTOCOL } from './host/protocol-text.ts'
23
23
  import { DEFAULT_MAX_CONCURRENT, ExecutionService, type EventsFace } from './host/execution.ts'
24
24
  import { createGitFace } from './host/git.ts'
25
+ import { createRepoScanner } from './host/repos.ts'
25
26
  import { registerTaskboardRoutes } from './host/routes.ts'
26
27
  import { SchedulerService } from './host/scheduler.ts'
27
28
  import { dshHomePath } from './host/sdk.ts'
@@ -124,8 +125,10 @@ export function apply(ctx: Context): void {
124
125
  disposers.push(() => sessionSync.dispose())
125
126
 
126
127
  // The narrow git face shared by execution (worktree isolation) and the
127
- // routes (merge / remove / workspace detection).
128
+ // routes (merge / remove / workspace detection), plus the shared
129
+ // nested-repo scanner for multi-repo mirrors (0.6.3).
128
130
  const git = createGitFace()
131
+ const scanner = createRepoScanner()
129
132
 
130
133
  wsCtx.inject(['agents'], (agentCtx: Context) => {
131
134
  agentSessions = agentCtx.get('sessions') as { get?: (id: string) => unknown; list?: () => unknown[] } | undefined
@@ -144,6 +147,7 @@ export function apply(ctx: Context): void {
144
147
  events,
145
148
  now,
146
149
  git,
150
+ scanner,
147
151
  // Preset composition (0.3.3): mirror apiproxy's composeAgent — resolve
148
152
  // the id BEFORE creation (the session header snapshots meta), mount
149
153
  // inside the factory's setup callback. No roster service → undefined
@@ -201,6 +205,7 @@ export function apply(ctx: Context): void {
201
205
  cancel: (taskId: string) => execution.cancel(taskId),
202
206
  modelProviders,
203
207
  git,
208
+ scanner,
204
209
  templates,
205
210
  promptCompletions: async () => {
206
211
  try {
package/src/shared/api.ts CHANGED
@@ -40,8 +40,12 @@ export type ApiResult<T> = ApiOk<T> | ApiFail
40
40
  /** Full-state response (the reconnect baseline after an SSE gap). */
41
41
  export type StateResponse = TaskLedger
42
42
 
43
- /** Workspace listing for the UI pickers. */
44
- export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean }
43
+ /**
44
+ * Workspace listing for the UI pickers. `repoCount` (0.6.3): how many repos a
45
+ * task mirror of this workspace would cover (root repo + nested) — the form's
46
+ * worktree option shows the mirror badge when it exceeds 1.
47
+ */
48
+ export type WorkspaceView = { id: string; path: string; title: string; sessionCount: number; gitAvailable?: boolean; repoCount?: number }
45
49
 
46
50
  /** Create-task request body (actor is always the GUI user). */
47
51
  export type CreateTaskBody = {
@@ -103,8 +107,28 @@ export type DeleteTaskBody = { ifVersion?: number; purge?: boolean }
103
107
  /** Run request body; `reuse: true` = 续跑 (keep a live worktree as-is). */
104
108
  export type RunTaskBody = { reuse?: boolean }
105
109
 
106
- /** Merge outcome: `noop: true` = the branch had no commits over HEAD (nothing merged). */
107
- export type MergeBranchResponse = { merged: boolean; noop?: boolean; branch: string }
110
+ /** One repo's merge outcome in a multi-repo merge (0.6.3; `repo: ''` = the workspace root repo). */
111
+ export type MergeRepoResult = {
112
+ repo: string
113
+ branch: string
114
+ outcome: 'merged' | 'noop' | 'failed'
115
+ /** Failure reason (verbatim git message) when outcome = 'failed'. */
116
+ error?: string
117
+ }
118
+
119
+ /**
120
+ * Merge outcome. Legacy single-repo tasks keep the flat shape; multi-repo
121
+ * mirror tasks (0.6.3) additionally return per-repo results — merges run
122
+ * sequentially and a failed repo does not block the others (plan §4.5).
123
+ */
124
+ export type MergeBranchResponse = {
125
+ merged: boolean
126
+ noop?: boolean
127
+ /** The merged task branch (legacy single-repo shape; multi-repo responses omit it). */
128
+ branch?: string
129
+ /** Present only on multi-repo mirror merges (0.6.3). */
130
+ results?: MergeRepoResult[]
131
+ }
108
132
 
109
133
  /** Remove a task's worktree; optionally delete its branch too. */
110
134
  export type WorktreeRemoveBody = { deleteBranch?: boolean }
@@ -246,4 +270,4 @@ export type ChangeEvent = {
246
270
  revision: number
247
271
  kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'
248
272
  tasks: TaskSummary[]
249
- }
273
+ }