@nelsonlongxiang/dsh-open-a2a-net 0.6.32 → 0.6.34

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.
Files changed (64) hide show
  1. package/README.md +12 -3
  2. package/README_zh.md +12 -3
  3. package/assets/nexusDist/assets/index-D2BGWOeU.js +9 -0
  4. package/assets/nexusDist/assets/index-z33lHtLw.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +5 -2
  7. package/lib/a2a-client.js +9 -2
  8. package/lib/auth-observer.js +1 -1
  9. package/lib/canvas-store.js +50 -6
  10. package/lib/card.js +2 -1
  11. package/lib/cli-home.js +44 -0
  12. package/lib/client/A2aControl.js +1 -1
  13. package/lib/client.js +65 -65
  14. package/lib/client.js.map +1 -1
  15. package/lib/direct-forward.js +61 -0
  16. package/lib/faces/admin.js +84 -9
  17. package/lib/faces/wire.js +57 -16
  18. package/lib/index.js +118 -67
  19. package/lib/layout-store.js +3 -2
  20. package/lib/outbound-scope.js +25 -0
  21. package/lib/queue-guard.js +37 -0
  22. package/lib/roster-op.js +104 -0
  23. package/lib/skill/references/cli.md +6 -5
  24. package/lib/skill/references/pitfalls.md +1 -4
  25. package/lib/skill/references/router.md +1 -1
  26. package/lib/task-auth.js +52 -3
  27. package/lib/task-ledger.js +4 -1
  28. package/lib/team-store.js +49 -1
  29. package/lib/tools/info.js +19 -3
  30. package/lib/types/a2a-client.d.ts +1 -1
  31. package/lib/types/auth-observer.d.ts +2 -2
  32. package/lib/types/canvas-store.d.ts +14 -1
  33. package/lib/types/cli-home.d.ts +24 -0
  34. package/lib/types/client/A2aControl.d.ts +1 -1
  35. package/lib/types/direct-forward.d.ts +32 -0
  36. package/lib/types/faces/admin.d.ts +9 -1
  37. package/lib/types/faces/wire.d.ts +13 -4
  38. package/lib/types/index.d.ts +4 -0
  39. package/lib/types/outbound-scope.d.ts +21 -0
  40. package/lib/types/queue-guard.d.ts +32 -0
  41. package/lib/types/roster-op.d.ts +21 -0
  42. package/lib/types/runtime.d.ts +1 -1
  43. package/lib/types/task-auth.d.ts +17 -2
  44. package/lib/types/task-ledger.d.ts +6 -1
  45. package/lib/types/team-store.d.ts +7 -0
  46. package/lib/types/types.d.ts +8 -0
  47. package/nexus-stage/src/canvas-ops.ts +2 -1
  48. package/nexus-stage/src/cli-home-picker.ts +42 -0
  49. package/nexus-stage/src/layout-doc.ts +6 -4
  50. package/nexus-stage/src/main.ts +36 -4
  51. package/nexus-stage/src/planning-card.ts +61 -0
  52. package/nexus-stage/src/planning-frame-hue.ts +13 -0
  53. package/nexus-stage/src/planning-legend.ts +1 -0
  54. package/nexus-stage/src/planning-menus.ts +63 -12
  55. package/nexus-stage/src/planning-roster.ts +15 -0
  56. package/nexus-stage/src/planning-types.ts +1 -1
  57. package/nexus-stage/src/planning-view.ts +43 -59
  58. package/nexus-stage/src/planning.css +19 -0
  59. package/nexus-stage/src/workbench.css +4 -0
  60. package/nexus-stage/src/workbench.ts +24 -5
  61. package/nexus-stage/src/world.ts +5 -1
  62. package/package.json +4 -2
  63. package/assets/nexusDist/assets/index-D5H8R8de.js +0 -9
  64. package/assets/nexusDist/assets/index-D9GYZyGZ.css +0 -1
@@ -41,7 +41,8 @@ import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt
41
41
  import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
42
42
  import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
43
43
  import { applyAction, settleAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
44
- import { FRAME_HUES } from './tokens'
44
+ import { frameHue } from './planning-frame-hue'
45
+ import { rosterFrameTitle } from './planning-roster'
45
46
  import { createPlanningMenus } from './planning-menus'
46
47
  import { createNodeFlyer, mountBoardSearch } from './planning-search'
47
48
  import { applyBoardRelayout } from './planning-relayout'
@@ -50,6 +51,7 @@ import { settleBoard, tidyMembers } from './planning-overlap'
50
51
  import { createLayoutUndo, type LayoutSnap } from './planning-undo'
51
52
  import { createNodeList } from './planning-nodelist'
52
53
  import { cardText } from './planning-card-text'
54
+ import { createCardElement, paintPrio, workLabel } from './planning-card'
53
55
  import { createLegend } from './planning-legend'
54
56
  import { UNREACHABLE_HINT, showFrameReachability, unreachableRefs } from './planning-reachability'
55
57
  import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
@@ -58,28 +60,18 @@ import './planning.css'
58
60
  export { viewportBucket } from './planning-types'
59
61
  export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
60
62
 
61
- /** Team-name hue, same 31-polynomial family as the 3D scene's getHue. */
62
- function frameHue(name: string): { line: string; bg: string } {
63
- let h = 0
64
- for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
65
- const rgb = FRAME_HUES[h % FRAME_HUES.length]!
66
- const line = '#' + rgb.toString(16).padStart(6, '0')
67
- // 5% alpha fill over the dark base, per the design system.
68
- const r = (rgb >> 16) & 0xff
69
- const g = (rgb >> 8) & 0xff
70
- const b = rgb & 0xff
71
- return { line, bg: `rgba(${r},${g},${b},0.05)` }
72
- }
73
-
74
63
 
75
64
  type Gesture =
76
65
  | { readonly kind: 'none' }
77
- | { readonly kind: 'node'; ids: readonly string[]; clicked: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
66
+ | { readonly kind: 'node'; ids: readonly string[]; clicked: string; rosterOrigin?: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
78
67
  | { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
79
68
  | { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
80
69
  | { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
81
70
  | { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
82
71
 
72
+ // The card skeleton, its 工作中/空闲 chip and the right-hand chip live in planning-card (module-size cap).
73
+ export { workLabel } from './planning-card'
74
+
83
75
  export function createPlanningView(deps: PlanningDeps): PlanningView {
84
76
  const model = new WorldModel()
85
77
  let layoutDoc: LayoutDoc | undefined
@@ -372,7 +364,10 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
372
364
  const title = el.querySelector<HTMLElement>('.ttl')!
373
365
  const cnt = el.querySelector<HTMLElement>('.cnt')!
374
366
  const route = el.querySelector<HTMLElement>('.route')!
375
- if (title.textContent !== name) title.textContent = name
367
+ const isRoster = rosterFramesRef.has(name)
368
+ const shownName = isRoster ? rosterFrameTitle(name) : name
369
+ if (title.textContent !== shownName) title.textContent = shownName
370
+ el.classList.toggle('is-roster', isRoster)
376
371
  let members = 0
377
372
  let cross = 0
378
373
  for (const m of model.allNodes()) {
@@ -385,7 +380,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
385
380
  if (cnt.textContent !== cntText) cnt.textContent = cntText
386
381
  const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
387
382
  const unavailable = mirror !== undefined && reachability.hosts.has(peerHostOf(remoteFramesRef.get(name)!))
388
- const routeText0 = mirror?.text ?? routeText(name)
383
+ const routeText0 = mirror?.text ?? (lastTeams.find(x => x.name === name)?.team ?? name)
389
384
  if (route.textContent !== routeText0) route.textContent = routeText0
390
385
  route.title = `${mirror?.title ?? ''}${unavailable ? ` · ${UNREACHABLE_HINT}` : ''}`
391
386
  showFrameReachability(el, unavailable)
@@ -400,12 +395,6 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
400
395
  }
401
396
  }
402
397
 
403
- /** The mono route line `<host>/canvas/<name>` when the payload carries it. */
404
- function routeText(name: string): string {
405
- const t = lastTeams.find(x => x.name === name)
406
- return t?.team ?? name
407
- }
408
-
409
398
  // ── keyed diff: nodes ──
410
399
  function renderNodes(): void {
411
400
  const seen = new Set<string>()
@@ -413,22 +402,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
413
402
  seen.add(n.id)
414
403
  let el = nodeEls.get(n.id)
415
404
  if (el === undefined) {
416
- el = document.createElement('div')
417
- el.className = 'p-node'
418
- el.dataset.id = n.id
419
- el.tabIndex = 0
420
- const nm = document.createElement('div')
421
- nm.className = 'nm'
422
- const dot = document.createElement('span')
423
- dot.className = 'dot'
424
- const name = document.createElement('span')
425
- name.className = 'nm-text'
426
- nm.append(dot, name)
427
- const sub = document.createElement('div')
428
- sub.className = 'sub mono'
429
- const prio = document.createElement('span')
430
- prio.className = 'prio mono'
431
- el.append(nm, sub, prio)
405
+ el = createCardElement(n.id)
432
406
  nodeLayer.appendChild(el)
433
407
  nodeEls.set(n.id, el)
434
408
  }
@@ -437,6 +411,12 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
437
411
  el.style.top = `${r.y}px`
438
412
  el.classList.toggle('cold', !n.live)
439
413
  el.classList.toggle('remote', n.remote === true)
414
+ // 工作中/空闲: live local sessions only; the host reports nothing for cold or remote nodes.
415
+ const work = n.live && n.remote !== true ? lastWork.get(n.id) : undefined
416
+ el.classList.toggle('is-working', work?.status === 'running')
417
+ const workText = workLabel(work)
418
+ const workEl = el.querySelector<HTMLElement>('.work')!
419
+ if (workEl.textContent !== workText) workEl.textContent = workText
440
420
  el.classList.toggle('selected', model.isSelected(n.id))
441
421
  el.setAttribute('aria-selected', String(model.isSelected(n.id)))
442
422
  const host = n.id.slice('peer-'.length)
@@ -451,22 +431,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
451
431
  if (subEl.textContent !== subText) subEl.textContent = subText
452
432
  const tip = unavailable ? `${txt.tip} · ${UNREACHABLE_HINT}` : txt.tip
453
433
  if (el.title !== tip) el.title = tip
454
- const prioEl = el.querySelector<HTMLElement>('.prio')!
455
- if (n.remote === true) {
456
- // gh#20 W-A: the peer badge — link class colors the chip (linked
457
- // green / stranger gray), the verified tick/cross rides after it.
458
- // An absent link field (older host) renders the neutral 'peer'.
459
- const info = peerInfoByHost.get(host)
460
- const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
461
- if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
462
- const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
463
- const badge = (info?.link ?? 'peer') + mark
464
- if (prioEl.textContent !== badge) prioEl.textContent = badge
465
- } else {
466
- if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
467
- const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
468
- if (prioEl.textContent !== prioText) prioEl.textContent = prioText
469
- }
434
+ paintPrio(el.querySelector<HTMLElement>('.prio')!, n, n.remote === true ? peerInfoByHost.get(host) : undefined)
470
435
  }
471
436
  for (const [id, el] of [...nodeEls]) {
472
437
  if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
@@ -485,6 +450,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
485
450
  const federation = createFederationOverlay(svg, model)
486
451
  let lastPeers: ReadonlyArray<PeerRow> = []
487
452
  let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
453
+ let lastWork: ReadonlyMap<string, Pick<PlanningSession, 'status' | 'queued'>> = new Map()
488
454
  const peerScoreByText = new Map<string, number | undefined>()
489
455
  /** gh#20 W-A: per-host link/verified info for the peer badges. */
490
456
  const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
@@ -534,10 +500,17 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
534
500
  // gh#41: remote-frame registry (frame name → peer URL), refreshed by
535
501
  // reconcile and read by emitAction's serverAction to fork remote drops.
536
502
  let remoteFramesRef = new Map<string, string>()
503
+ let rosterFramesRef = new Set<string>()
537
504
  let reachability = unreachableRefs({})
538
505
  const dissolving = createDissolveGuard(deps.now)
539
506
 
540
507
  function emitAction(a: CanvasAction): void {
508
+ const roster = (a.type === 'create-team' && a.roster === true) || rosterFramesRef.has(actionTeam(a))
509
+ if (roster && a.type === 'remove-team' && !window.confirm(`解散网络队「${rosterFrameTitle(a.name)}」并撤回成员与白名单?`)) return
510
+ if (roster && a.type === 'reorder') { notice('error', 'roster 队不支持置顶路由'); return }
511
+ if (roster && (a.type === 'create-team' || a.type === 'add-member') && a.ids.some(id => model.getNode(id)?.remote === true || !lastSessions.some(s => s.id === id && s.joined === true))) {
512
+ notice('error', 'roster 只接受本机已入网会话,远端或未入网成员未写入'); return
513
+ }
541
514
  // gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
542
515
  // keeps its peer-<host> node id — the canvas member contract is
543
516
  // session-id-or-remote-ref. Translation happens here, one boundary;
@@ -590,14 +563,16 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
590
563
  })
591
564
  }
592
565
  }
566
+ if (roster && a.type === 'create-team') rosterFramesRef.add(a.name)
593
567
  const undo = applyAction(model, a)
594
568
  const team = actionTeam(a)
595
569
  pendingAdd(team)
596
570
  render()
597
- const sent = serverAction(a)
571
+ const sent = serverAction(roster ? { ...a, roster: true } : a)
598
572
  void deps.onCanvasAction(sent).then(
599
573
  ok => {
600
574
  pendingRelease(team)
575
+ if (!ok && a.type === 'create-team' && roster) rosterFramesRef.delete(team)
601
576
  layoutDoc = settleAction(model, a, ok, undo, layoutDoc); markDirty(); dissolving.settled(sent, ok)
602
577
  render()
603
578
  },
@@ -606,6 +581,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
606
581
  // team's guard (and its optimistic state) forever — and per §3.4
607
582
  // 不吞错, the rollback surfaces to the user.
608
583
  pendingRelease(team)
584
+ if (a.type === 'create-team' && roster) rosterFramesRef.delete(team)
609
585
  layoutDoc = settleAction(model, a, false, undo, layoutDoc); markDirty(); dissolving.settled(sent, false)
610
586
  notice('error', '操作通道异常,已回滚')
611
587
  render()
@@ -628,6 +604,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
628
604
  const menus = createPlanningMenus({
629
605
  root, netBtn, signal, model,
630
606
  teams: () => lastTeams,
607
+ joined: id => lastSessions.some(s => s.id === id && s.joined === true),
608
+ isRoster: name => rosterFramesRef.has(name),
631
609
  anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
632
610
  emit: emitAction,
633
611
  notice,
@@ -698,8 +676,11 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
698
676
  }
699
677
  }
700
678
  }
701
- // Blank drop: a position move only. No roster writes — the explicit
702
- // context-menu 离队 (and the node drawer) remain the removal paths.
679
+ // Canvas blank drops move only; leaving a roster frame explicitly retracts those declarations.
680
+ if (gesture.rosterOrigin !== undefined && f !== gesture.rosterOrigin) {
681
+ const leaving = gesture.ids.filter(id => model.teamMemberIds(gesture.rosterOrigin!).includes(id))
682
+ if (leaving.length > 0) emitAction({ type: 'remove-member', team: gesture.rosterOrigin, ids: leaving })
683
+ }
703
684
  }
704
685
 
705
686
  function pointerDown(ev: SeamPointer): void {
@@ -768,7 +749,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
768
749
  const already = model.isSelected(id)
769
750
  if (!already && !ev.shiftKey) model.setSelection([id])
770
751
  const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
771
- gesture = { kind: 'node', clicked: id, ids, last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
752
+ const origin = innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y))
753
+ gesture = { kind: 'node', clicked: id, ids, ...(origin && rosterFramesRef.has(origin) && model.teamMemberIds(origin).includes(id) ? { rosterOrigin: origin } : {}), last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
772
754
  nodeEl.classList.add('dragging')
773
755
  } else if (headEl !== null) {
774
756
  if (ev.button !== 0) return
@@ -1126,6 +1108,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1126
1108
  function reconcile(input: PlanningInput): void {
1127
1109
  // gh#41: remote-frame registry shared with serverAction (drop fork).
1128
1110
  lastTeams = input.teams
1111
+ rosterFramesRef = new Set([...input.teams.filter(t => t.kind === 'roster').map(t => t.name), ...[...rosterFramesRef].filter(n => pendingTeams.has(n))])
1129
1112
  lastPeers = input.peers ?? []
1130
1113
  federation.setInFlight(input.inFlight ?? [])
1131
1114
  // Unjoined sessions have no node on the canvas — the panel-slim
@@ -1219,6 +1202,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1219
1202
  // excluded from bounds, so the column cannot feed back into itself).
1220
1203
  const peerSeen = new Set<string>()
1221
1204
  lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
1205
+ lastWork = new Map(input.sessions.map(s => [s.id, { status: s.status, queued: s.queued }]))
1222
1206
  reachability = unreachableRefs(input)
1223
1207
  peerScoreByText.clear()
1224
1208
  for (const p of input.peers ?? []) {
@@ -83,6 +83,8 @@
83
83
  #dsh-plan .p-frame-head .cnt{color:var(--ink-muted); font-weight:400}
84
84
  #dsh-plan .p-frame-head .route{font-size:10.5px; color:var(--ink-muted)}
85
85
  #dsh-plan .p-frame.is-mirror{border-style:dashed}
86
+ #dsh-plan .p-frame.is-roster{border-style:double;border-width:3px}
87
+ #dsh-plan .p-frame.is-roster .p-frame-head{border-style:dashed}
86
88
  #dsh-plan .p-frame.is-syncing .p-frame-head .route{color:var(--accent)}
87
89
  #dsh-plan .p-frame.is-unreachable{opacity:.76;border-style:dashed}
88
90
  #dsh-plan .p-frame-unreachable{position:absolute;left:8px;top:20px;max-width:calc(100% - 16px);color:var(--uupm-error);font-size:11px;pointer-events:none}
@@ -114,6 +116,14 @@
114
116
  #dsh-plan .p-node.remote .nm-text{overflow-wrap:anywhere}
115
117
  #dsh-plan .p-node .dot{width:8px; height:8px; border-radius:50%; background:var(--edge-live); flex:none}
116
118
  #dsh-plan .p-node.cold .dot{background:transparent; border:1.5px solid var(--edge-cold)}
119
+ #dsh-plan .p-node .sub-row{display:flex; align-items:baseline; gap:6px; margin-top:2px; min-width:0}
120
+ #dsh-plan .p-node .sub-row .sub{flex:1 1 auto; min-width:0; margin-top:0}
121
+ #dsh-plan .p-node .work{flex:none; font-size:10.5px; font-weight:500; color:var(--ink-muted); white-space:nowrap}
122
+ #dsh-plan .p-node .work:empty{display:none}
123
+ #dsh-plan .p-node.is-working .work{color:var(--edge-live)}
124
+ #dsh-plan .p-node.is-working .dot{animation:p-working 1.4s ease-in-out infinite}
125
+ @keyframes p-working{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--edge-live) 55%,transparent)}50%{box-shadow:0 0 0 4px transparent}}
126
+ @media (prefers-reduced-motion: reduce){#dsh-plan .p-node.is-working .dot{animation:none}}
117
127
  #dsh-plan .p-node .sub{font-size:11px; color:var(--ink-muted); margin-top:2px;
118
128
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
119
129
  /* W1: a picked search hit pulses its card for 1.2s (class removed on timer). */
@@ -250,6 +260,15 @@
250
260
  #dsh-plan .p-dialog-input:hover{border-color:color-mix(in srgb,var(--uupm-ink-1) 55%,transparent)}
251
261
  #dsh-plan .p-dialog-input:focus-visible{outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent)}
252
262
  #dsh-plan .p-dialog-err{min-height:1.2em; font-size:11px; color:var(--uupm-error)}
263
+ #dsh-plan .p-dialog-kind{display:grid; grid-template-columns:1fr 1fr; gap:4px; padding:3px;
264
+ background:var(--uupm-bg-0); border:1px solid var(--card-line); border-radius:8px}
265
+ #dsh-plan .p-dialog-kind button{font:inherit; color:var(--ink-muted); background:transparent; border:1px solid transparent;
266
+ border-radius:6px; padding:6px 10px; cursor:pointer}
267
+ #dsh-plan .p-dialog-kind button:hover{color:var(--ink); background:var(--uupm-wash-8)}
268
+ #dsh-plan .p-dialog-kind button[aria-checked=true]{color:var(--ink); font-weight:600; background:var(--uupm-wash-15);
269
+ border-color:var(--accent)}
270
+ #dsh-plan .p-dialog-kind button:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
271
+ #dsh-plan .p-dialog-hint{font-size:11px; color:var(--ink-muted); max-width:340px}
253
272
  #dsh-plan .p-dialog-row{display:flex; gap:8px; justify-content:flex-end}
254
273
  #dsh-plan .p-dialog-row button{background:transparent; color:var(--ink); border:1px solid var(--card-line);
255
274
  border-radius:6px; padding:5px 12px; font:inherit; cursor:pointer}
@@ -93,6 +93,10 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
93
93
  .wb-member-remove:hover:not(:disabled),.wb-member-submit:hover:not(:disabled){border-color:var(--n-danger)}
94
94
  .wb-member-submit:hover:not(:disabled){border-color:var(--n-accent)}
95
95
  .wb-member-add{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
96
+ .wb-cli-home{display:flex;flex-wrap:wrap;gap:6px;padding:8px;margin:4px 0 10px;border:1px solid var(--n-border);border-radius:6px}
97
+ .wb-cli-home [hidden]{display:none}
98
+ .wb-cli-home .wb-hint{flex-basis:100%;margin-top:0}
99
+ .wb-cli-home .wb-member-pick{min-width:0;max-width:100%}
96
100
  .wb-member-pick,.wb-member-manual{background:var(--n-panel);border:1px solid var(--n-border);color:var(--n-ink);
97
101
  border-radius:6px;padding:3px 8px;font:inherit;font-size:12px;min-width:0}
98
102
  .wb-member-pick{flex:1 1 150px}
@@ -19,6 +19,7 @@
19
19
  * @module nexus-stage/workbench
20
20
  */
21
21
  import { createChatDrawer, type ChatTarget } from './wb-chat'
22
+ import { createCliHomePicker, type CliHomeOptions } from './cli-home-picker'
22
23
  import { createRailTabs } from './wb-rail'
23
24
  import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
24
25
  import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
@@ -130,6 +131,9 @@ export interface CanvasMemberRow {
130
131
  readonly joined?: boolean
131
132
  readonly live?: boolean
132
133
  readonly remote?: boolean
134
+ /** Present only for CLI refs: null means the linked home is ambiguous or unknown. */
135
+ readonly cliHome?: string | null
136
+ readonly cliHeld?: boolean
133
137
  }
134
138
 
135
139
  /** One canvas team from /state `canvas.teams`. */
@@ -142,7 +146,7 @@ export interface CanvasTeamRow {
142
146
  /** One activity-ring entry. */
143
147
  export interface ActivityRow {
144
148
  readonly ts: number
145
- readonly dir: 'in' | 'out'
149
+ readonly dir: 'in' | 'out' | 'fwd'
146
150
  readonly team: string
147
151
  readonly peer: string
148
152
  readonly ok: boolean
@@ -150,6 +154,7 @@ export interface ActivityRow {
150
154
 
151
155
  /** The /state subset the workbench consumes (additive fields ignored). */
152
156
  export interface StateBody {
157
+ readonly zone?: string
153
158
  readonly version?: string
154
159
  readonly sessions?: readonly SessionRow[]
155
160
  readonly canvas?: { teams?: readonly CanvasTeamRow[] }
@@ -176,6 +181,7 @@ export interface StateBody {
176
181
  readonly archived?: readonly ArchivedRow[]
177
182
  /** gh#41 fleet planning: each tracked peer's published canvas org. */
178
183
  readonly peerOrg?: readonly PeerOrgRow[]
184
+ readonly registry?: { teams?: ReadonlyArray<{ team: string; members: readonly string[]; live?: number }> }
179
185
  readonly archivedCount?: number
180
186
  /** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
181
187
  readonly archivedSplit?: Readonly<Record<string, number>>
@@ -210,7 +216,8 @@ export interface WorkbenchDeps {
210
216
  abandon(taskId: string, reason: string): Promise<boolean>
211
217
  /** Canvas team member change via the control route (0.6.6): `id` is a
212
218
  * local session id or a zone-qualified remote handle. True on success. */
213
- canvasOp(action: 'add-member' | 'remove-member', name: string, id: string): Promise<boolean>
219
+ canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string): Promise<boolean>
220
+ cliHomeOptions?(name: string, id: string): Promise<CliHomeOptions>
214
221
  /** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
215
222
  * one message to a routing team, async delivery, receipt lands in the
216
223
  * ledger. Optional — without it the 快派 form hides. */
@@ -331,6 +338,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
331
338
  // Canvas add-member form cache (static DOM re-mounted across polls; keyed
332
339
  // by team name — same trick as draftForm so inputs survive the 3s poll).
333
340
  let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
341
+ const cliHomeForms = new Map<string, HTMLFormElement>()
334
342
  // Quick-dispatch form (0.6.9), cached per target routing team the same
335
343
  // draftForm way: a textarea's draft must survive the 3s poll remount.
336
344
  let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
@@ -688,7 +696,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
688
696
  for (const row of ring) {
689
697
  const line = el('div', 'wb-row')
690
698
  const dir = el('span', 'wb-row-dir')
691
- dir.textContent = row.dir === 'out' ? '→' : '←'
699
+ dir.textContent = row.dir === 'fwd' ? '⇢' : row.dir === 'out' ? '→' : '←'
692
700
  if (row.ok !== true) dir.classList.add('is-fail')
693
701
  line.appendChild(dir)
694
702
  teamCell(line, last, row.team)
@@ -960,12 +968,12 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
960
968
  }
961
969
  for (const member of fresh.members) {
962
970
  const row = el('div', 'wb-member-row')
963
- const dot = member.remote === true ? '◇ 远端' : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
971
+ const dot = member.cliHeld === true ? '本机持有,无需指定' : member.remote === true ? (member.cliHome === null ? '◇ 远端 · home 未确定' : '◇ 远端') : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
964
972
  row.appendChild(el('span', 'wb-member-dot')).textContent = dot
965
973
  const label = el('span', 'wb-member-label')
966
974
  const session = (last?.sessions ?? []).find(s => s.id === member.id)
967
975
  label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
968
- label.title = member.team ?? member.id
976
+ label.title = member.cliHeld === true ? '本机持有,无需指定' : member.cliHome !== undefined ? (member.cliHome ?? 'home 未确定') : (member.team ?? member.id)
969
977
  row.appendChild(label)
970
978
  const remove = el('button', 'wb-member-remove')
971
979
  remove.type = 'button'
@@ -976,6 +984,15 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
976
984
  })
977
985
  row.appendChild(remove)
978
986
  card.appendChild(row)
987
+ if (member.cliHome === null && member.cliHeld !== true && deps.cliHomeOptions !== undefined) {
988
+ const key = JSON.stringify([fresh.name, member.id])
989
+ let form = cliHomeForms.get(key)
990
+ if (form === undefined) {
991
+ form = createCliHomePicker(() => deps.cliHomeOptions!(fresh.name, member.id), home => deps.canvasOp('add-member', fresh.name, member.id, home))
992
+ cliHomeForms.set(key, form)
993
+ }
994
+ card.appendChild(form)
995
+ }
979
996
  }
980
997
  // Add-member form: static DOM re-mounted per poll (draftForm trick) —
981
998
  // rebuilt only when the inspected team changes.
@@ -1194,6 +1211,8 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1194
1211
  }
1195
1212
 
1196
1213
  function update(state: StateBody | null): void {
1214
+ const unknown = new Set((state?.canvas?.teams ?? []).flatMap(team => team.members.filter(member => member.cliHome === null && member.cliHeld !== true).map(member => JSON.stringify([team.name, member.id]))))
1215
+ for (const key of cliHomeForms.keys()) if (!unknown.has(key)) cliHomeForms.delete(key)
1197
1216
  last = state
1198
1217
  // 0.6.12: refresh the readable-log list opportunistically (single flight;
1199
1218
  // the sidebar re-renders when rows land, mid-poll remount is idempotent).
@@ -153,7 +153,11 @@ export function planSeatClear(sid: string, occupied: ReadonlyArray<{ readonly x:
153
153
  }
154
154
 
155
155
  /** Minimal session row shape the model needs (topology.SessionRow satisfies it). */
156
- export interface SessionLite { id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[] }
156
+ export interface SessionLite {
157
+ id: string; label: string; team: string; name?: string; description?: string; joined: boolean; live?: boolean; teams?: readonly string[]
158
+ /** Live local sessions only: mid-turn ('running') or idle, and how many receipt-owing tasks wait on it. */
159
+ status?: 'running' | 'idle'; queued?: number
160
+ }
157
161
 
158
162
  /** Minimal team shape (topology.CanvasTeam satisfies it). `team` is the
159
163
  * mono route alias `<host>/canvas/<name>` when the payload carries it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nelsonlongxiang/dsh-open-a2a-net",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
4
4
  "description": "Open A2A network plugin for DeepSeek Harness: signed agent cards, decentralized peer/zone discovery, direct routing model tools, and joinable session nodes with a sidebar control",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -84,8 +84,10 @@
84
84
  "test:mesh": "vitest run tests/mesh",
85
85
  "test:live": "vitest run tests/live",
86
86
  "test:build-layout": "vitest run tests/gates/build-layout.spec.ts",
87
+ "verify:docs": "node scripts/doc-drift.mjs",
88
+ "reanchor:arch": "node scripts/reanchor-architecture.mjs",
87
89
  "verify:nexus": "node scripts/verify-nexus-ci.mjs && npm --prefix nexus-stage run build && npm --prefix nexus-stage run test && git diff --exit-code -- assets/nexusDist/assets && git diff --ignore-cr-at-eol --exit-code -- assets/nexusDist/index.html",
88
- "verify": "pnpm typecheck && pnpm build && pnpm test && pnpm test:build-layout && pnpm verify:nexus"
90
+ "verify": "pnpm typecheck && pnpm verify:docs && pnpm build && pnpm test && pnpm test:build-layout && pnpm verify:nexus"
89
91
  },
90
92
  "peerDependencies": {
91
93
  "@deepseek-ai/cordis": ">=4.0.1",