@nelsonlongxiang/dsh-open-a2a-net 0.6.24 → 0.6.26

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 (38) hide show
  1. package/README.md +15 -11
  2. package/README_zh.md +3 -2
  3. package/assets/nexusDist/assets/index-DszoLz8i.js +5 -0
  4. package/assets/nexusDist/assets/index-uQf7NwjF.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +300 -0
  7. package/lib/card.js +17 -0
  8. package/lib/cli-deposit.js +141 -0
  9. package/lib/exposure-store.js +82 -0
  10. package/lib/index.js +904 -57
  11. package/lib/owner-settings.js +100 -0
  12. package/lib/peer-store.js +65 -9
  13. package/lib/task-ledger.js +44 -3
  14. package/lib/types/cli-deposit.d.ts +53 -0
  15. package/lib/types/exposure-store.d.ts +45 -0
  16. package/lib/types/index.d.ts +5 -0
  17. package/lib/types/owner-settings.d.ts +19 -0
  18. package/lib/types/peer-store.d.ts +22 -3
  19. package/lib/types/task-ledger.d.ts +18 -0
  20. package/lib/types/types.d.ts +10 -0
  21. package/nexus-stage/src/canvas-ops.ts +2 -2
  22. package/nexus-stage/src/federation.ts +6 -0
  23. package/nexus-stage/src/main.ts +97 -4
  24. package/nexus-stage/src/planning-menus.ts +16 -4
  25. package/nexus-stage/src/planning-types.ts +3 -0
  26. package/nexus-stage/src/planning-view.ts +208 -18
  27. package/nexus-stage/src/planning.css +4 -0
  28. package/nexus-stage/src/wb-chat.ts +670 -0
  29. package/nexus-stage/src/wb-directory.ts +222 -0
  30. package/nexus-stage/src/wb-ledger.ts +266 -0
  31. package/nexus-stage/src/wb-rail.ts +147 -0
  32. package/nexus-stage/src/workbench.css +190 -84
  33. package/nexus-stage/src/workbench.ts +385 -721
  34. package/nexus-stage/src/world.ts +5 -1
  35. package/package.json +18 -22
  36. package/assets/nexusDist/assets/index-034h1cNc.js +0 -3
  37. package/assets/nexusDist/assets/index-PG9EieGX.css +0 -1
  38. package/lib/types/client.d.ts +0 -73
@@ -19,10 +19,52 @@ const app = document.getElementById('app')!
19
19
  // gh#9 §10: the host injects the control-plane key into the shell when
20
20
  // apiKey is configured — stamp it on every /__dsh_a2a write so the GUI
21
21
  // survives a keyed host (same-origin disclosure only, see the serve site).
22
- const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? ''
22
+ // gh#40: a REMOTE face (LAN-bound test host, tunnel) serves the shell
23
+ // WITHOUT the injected key by design — the credential must not leak to any
24
+ // network fetcher. The operator still needs the panel there, so the key
25
+ // can be typed once: sessionStorage is browser-local (never transmitted in
26
+ // the page), stamped on the same X-API-Key header thereafter.
27
+ const storedKey = (() => { try { return window.sessionStorage.getItem('dsh_a2a_key') ?? '' } catch { return '' } })()
28
+ const CONTROL_KEY = (window as unknown as { __DSH_A2A_KEY__?: string }).__DSH_A2A_KEY__ ?? storedKey
23
29
  const controlFetch = (path: string, init: RequestInit = {}): Promise<Response> =>
24
30
  fetch(path, { ...init, headers: { ...(init.headers as Record<string, string> | undefined ?? {}), ...(CONTROL_KEY !== '' ? { 'X-API-Key': CONTROL_KEY } : {}) } })
25
31
 
32
+ // gh#40: key-entry affordance — mounts BEFORE the panel when the shell came
33
+ // keyless (remote face) and the control plane answers 401. One input, one
34
+ // submit; the stored key survives until the tab closes.
35
+ if (CONTROL_KEY === '') {
36
+ void controlFetch('/__dsh_a2a/owner-settings').then(res => {
37
+ if (res.status !== 401) return
38
+ const gate = document.createElement('div')
39
+ gate.className = 'nexus-keygate'
40
+ gate.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:#fff;z-index:9999;font:14px/1.5 system-ui,sans-serif;color:#000'
41
+ const label = document.createElement('div')
42
+ label.textContent = '此面板经远程面打开(密钥不随页面下发)——输入控制密钥'
43
+ const input = document.createElement('input')
44
+ input.type = 'password'
45
+ input.placeholder = 'X-API-Key'
46
+ input.setAttribute('aria-label', '控制密钥')
47
+ const submit = document.createElement('button')
48
+ submit.type = 'button'
49
+ submit.textContent = '进入面板'
50
+ const err = document.createElement('div')
51
+ err.style.color = '#b00'
52
+ const tryKey = (): void => {
53
+ const v = input.value.trim()
54
+ if (v === '') return
55
+ void fetch('/__dsh_a2a/owner-settings', { headers: { 'X-API-Key': v } }).then(r => {
56
+ if (r.status === 401) { err.textContent = '密钥不对,再试一次'; return }
57
+ try { window.sessionStorage.setItem('dsh_a2a_key', v) } catch { /* blocked site data: keep in-page */ }
58
+ window.location.reload()
59
+ })
60
+ }
61
+ submit.addEventListener('click', tryKey)
62
+ input.addEventListener('keydown', ev => { if (ev.key === 'Enter') tryKey() })
63
+ gate.append(label, input, submit, err)
64
+ document.body.appendChild(gate)
65
+ })
66
+ }
67
+
26
68
  const faultBadge = document.createElement('div')
27
69
  faultBadge.className = 'nexus-fault'
28
70
  app.appendChild(faultBadge)
@@ -113,6 +155,23 @@ const canvasWire = createCanvasWire({
113
155
  })
114
156
 
115
157
  function runCanvasAction(a: CanvasAction): Promise<boolean> {
158
+ // gh#41: a remote-frame drop forks to the fleet relay — THIS host signs
159
+ // the envelope and forwards; the peer's link gate authorizes the write.
160
+ if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
161
+ return (async () => {
162
+ for (const id of a.ids) {
163
+ const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify({ peer: a.fleetPeer, op: a.type === 'add-member' ? 'add-member' : 'remove-member', name: a.team, id }),
167
+ cache: 'no-store',
168
+ }).catch(() => undefined)
169
+ const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
170
+ if (body?.ok !== true) return false
171
+ }
172
+ return true
173
+ })()
174
+ }
116
175
  switch (a.type) {
117
176
  case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
118
177
  case 'add-member': return canvasWire.addMembers(a.team, a.ids)
@@ -154,6 +213,27 @@ async function fetchLayout(): Promise<unknown> {
154
213
  // ─── The workbench ───
155
214
  const bench = createWorkbench({
156
215
  now: () => Date.now(),
216
+ // gh#34: the owner three-key panel rides the control face.
217
+ async ownerSettingsRead() {
218
+ try {
219
+ const res = await controlFetch('/__dsh_a2a/owner-settings')
220
+ return await res.json()
221
+ } catch {
222
+ return { ok: false, error: 'unreachable' }
223
+ }
224
+ },
225
+ async ownerSettingsWrite(changes) {
226
+ try {
227
+ const res = await controlFetch('/__dsh_a2a/owner-settings', {
228
+ method: 'POST',
229
+ headers: { 'content-type': 'application/json' },
230
+ body: JSON.stringify(changes),
231
+ })
232
+ return await res.json()
233
+ } catch {
234
+ return { ok: false, error: 'unreachable' }
235
+ }
236
+ },
157
237
  async abandon(taskId, reason) {
158
238
  try {
159
239
  const res = await controlFetch('/__dsh_a2a/tasks/abandon', {
@@ -231,8 +311,10 @@ const bench = createWorkbench({
231
311
  // sessions included; the network directory is not the reading gate.
232
312
  try {
233
313
  const res = await controlFetch('/__dsh_a2a/session-log')
234
- const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string }
235
- return { ok: res.ok && body.ok === true, sessions: body.sessions, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
314
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }
315
+ // count: how many logs the host holds — the list face caps at 50, so the
316
+ // directory can say it is showing 50 of N instead of silently truncating.
317
+ return { ok: res.ok && body.ok === true, sessions: body.sessions, count: typeof body.count === 'number' ? body.count : undefined, error: body.error ?? (res.ok ? undefined : `HTTP ${res.status}`) }
236
318
  } catch (error) {
237
319
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
238
320
  }
@@ -346,7 +428,18 @@ async function poll(): Promise<void> {
346
428
  ...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
347
429
  ...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
348
430
  }))
349
- planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams })
431
+ // gh#41: the peers' published canvas org rides the board (remote frames).
432
+ const peerOrg = (body.peerOrg as unknown[] ?? [])
433
+ .filter((r): r is { url?: unknown; org?: unknown } => r !== null && typeof r === 'object')
434
+ .filter(r => typeof r.url === 'string' && Array.isArray(r.org))
435
+ .map(r => ({
436
+ url: r.url as string,
437
+ org: (r.org as unknown[])
438
+ .filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
439
+ .filter(e => typeof e.team === 'string' && e.team !== '')
440
+ .map(e => ({ name: typeof e.name === 'string' ? e.name : '', team: e.team as string, members: Array.isArray(e.members) ? e.members.filter((m): m is string => typeof m === 'string') : [] })),
441
+ }))
442
+ planning.reconcile({ sessions: allSessions, teams, peerCount: peers.length, peers, inFlight, remoteTeams, peerOrg })
350
443
  } catch (err) {
351
444
  fault(`state 轮询失败:${String(err)}`)
352
445
  }
@@ -57,8 +57,15 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
57
57
 
58
58
  function startCreateFromSelection(): void {
59
59
  if (dialog !== null) return
60
- // Peer cards are not joinable sessions: they never enter 建队 ids.
61
- const ids = selectedByY().filter(id => model.getNode(id)?.remote !== true)
60
+ // gh#20 W-B: a mixed local+remote selection composes a cross-host team —
61
+ // peers ride their zone/id8 handle at the server boundary. A peer
62
+ // without a handle yet (no remote team rows) cannot join; drop it and
63
+ // let the ≥2 floor judge the rest.
64
+ const ids = selectedByY().filter(id => {
65
+ const node = model.getNode(id)
66
+ if (node === undefined) return false
67
+ return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
68
+ })
62
69
  if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
63
70
  openNameDialog(ids)
64
71
  }
@@ -313,9 +320,14 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
313
320
  /** Context menu from a real contextmenu event or the keyboard equivalent. */
314
321
  function contextMenuAt(x: number, y: number, target: Element | null): void {
315
322
  if (dialog !== null) return
316
- // Peer endpoints are read-only entries: no roster menu for them.
323
+ // gh#20 W-B: a peer WITH a remote handle gets the roster menu (加入团队
324
+ // composes a cross-host team through the handle boundary); a peer
325
+ // WITHOUT one stays a read-only entry (nothing the server would accept).
317
326
  const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
318
- if (probeNode !== null && model.getNode(probeNode.dataset.id ?? '')?.remote === true) return
327
+ if (probeNode !== null) {
328
+ const probe = model.getNode(probeNode.dataset.id ?? '')
329
+ if (probe?.remote === true && (probe.remoteHandle === undefined || probe.remoteHandle === '')) return
330
+ }
319
331
  const nodeEl = probeNode
320
332
  const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
321
333
  if (nodeEl !== null) {
@@ -24,6 +24,9 @@ export interface PlanningInput {
24
24
  inFlight?: ReadonlyArray<InFlightRow>
25
25
  /** Remote team directory rows (PR D v1 ruling): grouped under peer cards. */
26
26
  remoteTeams?: ReadonlyArray<RemoteTeamRow>
27
+ /** gh#41 fleet planning: each tracked peer's published canvas org —
28
+ * rendered as REMOTE team frames (read + cross-host regulation). */
29
+ peerOrg?: ReadonlyArray<{ url: string; org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }> }>
27
30
  }
28
31
 
29
32
  /** Screen-width class a viewport is remembered under (window width, not board width — collapsing the rail must not switch buckets). */
@@ -383,22 +383,41 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
383
383
  el.classList.toggle('selected', model.isSelected(n.id))
384
384
  el.setAttribute('aria-selected', String(model.isSelected(n.id)))
385
385
  const host = n.id.slice('peer-'.length)
386
- const nmText = n.remote === true ? host : displayName(n)
386
+ // gh#38: member cards show their display NAME as the title (the whole
387
+ // point is picking a person); peer HOST cards keep the host spelling.
388
+ const nmText = n.id.startsWith('member-')
389
+ ? (n.name !== undefined && n.name !== '' ? n.name : n.team)
390
+ : n.remote === true ? host : displayName(n)
387
391
  const nmEl = el.querySelector<HTMLElement>('.nm-text')!
388
392
  if (nmEl.textContent !== nmText) nmEl.textContent = nmText
389
393
  const subEl = el.querySelector<HTMLElement>('.sub')!
390
394
  const subBase = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
391
395
  // When the title fell back to the team label the sub line would repeat
392
396
  // it verbatim; collapse to the cross-team suffix alone in that case.
393
- const subText = n.remote === true
394
- ? remoteSubText(host)
395
- : (subBase === nmText ? '' : subBase)
396
- + (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
397
- + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
397
+ const subText = n.id.startsWith('member-')
398
+ ? n.team
399
+ : n.remote === true
400
+ ? remoteSubText(host)
401
+ : (subBase === nmText ? '' : subBase)
402
+ + (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
403
+ + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
398
404
  if (subEl.textContent !== subText) subEl.textContent = subText
399
405
  const prioEl = el.querySelector<HTMLElement>('.prio')!
400
- const prioText = n.remote === true ? 'peer' : (n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : '')
401
- if (prioEl.textContent !== prioText) prioEl.textContent = prioText
406
+ if (n.remote === true) {
407
+ // gh#20 W-A: the peer badge — link class colors the chip (linked
408
+ // green / stranger gray), the verified tick/cross rides after it.
409
+ // An absent link field (older host) renders the neutral 'peer'.
410
+ const info = peerInfoByHost.get(host)
411
+ const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
412
+ if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
413
+ const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
414
+ const badge = (info?.link ?? 'peer') + mark
415
+ if (prioEl.textContent !== badge) prioEl.textContent = badge
416
+ } else {
417
+ if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
418
+ const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
419
+ if (prioEl.textContent !== prioText) prioEl.textContent = prioText
420
+ }
402
421
  }
403
422
  for (const [id, el] of [...nodeEls]) {
404
423
  if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
@@ -433,6 +452,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
433
452
  let lastPeers: ReadonlyArray<PeerRow> = []
434
453
  let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
435
454
  const peerScoreByText = new Map<string, number | undefined>()
455
+ /** gh#20 W-A: per-host link/verified info for the peer badges. */
456
+ const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
436
457
  let lastInFlight: ReadonlyArray<InFlightRow> = []
437
458
 
438
459
  function renderFederation(): void {
@@ -524,8 +545,41 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
524
545
  if (n <= 0) pendingTeams.delete(team)
525
546
  else pendingTeams.set(team, n)
526
547
  }
548
+ // gh#41: remote-frame registry (frame name → peer URL), refreshed by
549
+ // reconcile and read by emitAction's serverAction to fork remote drops.
550
+ let remoteFramesRef = new Map<string, string>()
527
551
 
528
552
  function emitAction(a: CanvasAction): void {
553
+ // gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
554
+ // keeps its peer-<host> node id — the canvas member contract is
555
+ // session-id-or-remote-ref. Translation happens here, one boundary;
556
+ // the model action keeps board-local ids so rendering and undo stay
557
+ // node-keyed. A peer without a known handle (no remote team rows yet)
558
+ // is filtered out — the server would refuse the raw peer- id.
559
+ function serverAction(act: CanvasAction): CanvasAction {
560
+ const mapIds = (ids: readonly string[]): string[] => {
561
+ const out: string[] = []
562
+ for (const id of ids) {
563
+ // Both remote card families translate to their zone/id8 handle
564
+ // (gh#38: member- cards join teams like peer- cards do).
565
+ if (!id.startsWith('peer-') && !id.startsWith('member-')) { out.push(id); continue }
566
+ const handle = model.getNode(id)?.remoteHandle
567
+ if (handle !== undefined && handle !== '') out.push(handle)
568
+ }
569
+ return out
570
+ }
571
+ if (act.type === 'add-member' || act.type === 'remove-member' || act.type === 'create-team') {
572
+ const ids = mapIds(act.ids)
573
+ // gh#41: a drop into a REMOTE frame carries the peer URL — main.ts
574
+ // forks it to the fleet relay instead of the local canvas control.
575
+ const fleetPeer = act.type !== 'create-team' ? remoteFramesRef.get(act.team) : undefined
576
+ return ids.length > 0 || fleetPeer !== undefined
577
+ ? { ...act, ids, ...(fleetPeer !== undefined ? { fleetPeer } : {}) }
578
+ : act
579
+ }
580
+ return act
581
+ }
582
+
529
583
  // Optimistic seats: a write may reference a session the teamed gate
530
584
  // keeps OFF the model (no canvas card yet). The write actions read and
531
585
  // write memberships through the node table, so materialize those ids
@@ -534,7 +588,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
534
588
  if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
535
589
  const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
536
590
  for (const id of ids) {
537
- if (id === '' || id.startsWith('peer-')) continue
591
+ if (id === '' || id.startsWith('peer-') || id.startsWith('member-')) continue
538
592
  if (model.getNode(id) !== undefined) continue
539
593
  const s = lastSessions.find(row => row.id === id)
540
594
  if (s === undefined) continue
@@ -551,7 +605,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
551
605
  const team = actionTeam(a)
552
606
  pendingAdd(team)
553
607
  render()
554
- void deps.onCanvasAction(a).then(
608
+ void deps.onCanvasAction(serverAction(a)).then(
555
609
  ok => {
556
610
  pendingRelease(team)
557
611
  if (!ok) undo() // host refused: roll this action's optimistic delta back
@@ -627,10 +681,18 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
627
681
  const p = localXY(ev)
628
682
  const w = screenToWorld(vp, p.x, p.y)
629
683
  const f = innermostFrameAt(new Map(model.allFrames()), w)
630
- // 契约不变量 1:成员必须 joined session——对等卡永远不进名单写面。
631
- const localIds = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
684
+ // gh#20 W-B: peer cards join canvas teams too — a mixed local+remote
685
+ // drop composes a cross-host team; the server boundary translates
686
+ // peer ids to zone/id8 handles (emitAction's serverAction). Reorder
687
+ // stays local-only (roster priority is a local-session fact).
688
+ const memberIds = gesture.ids.filter(id => {
689
+ const node = model.getNode(id)
690
+ if (node === undefined) return false
691
+ if (node.remote !== true) return true
692
+ return node.remoteHandle !== undefined && node.remoteHandle !== ''
693
+ })
632
694
  if (f !== undefined) {
633
- const fresh = localIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
695
+ const fresh = memberIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
634
696
  if (fresh.length > 0) {
635
697
  emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
636
698
  return
@@ -772,7 +834,12 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
772
834
  // Drop-target highlight: the innermost frame under the pointer, but
773
835
  // only when the drop would change something (some id not yet member).
774
836
  const f = innermostFrameAt(new Map(model.allFrames()), w)
775
- const joinables = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
837
+ // W-B: peers with a remote handle are droppable members too.
838
+ const joinables = gesture.ids.filter(id => {
839
+ const node = model.getNode(id)
840
+ if (node === undefined) return false
841
+ return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
842
+ })
776
843
  const relevant = f !== undefined && joinables.length > 0
777
844
  for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
778
845
  render()
@@ -1043,6 +1110,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1043
1110
  let lastTeams: ReadonlyArray<PlanningTeam> = []
1044
1111
 
1045
1112
  function reconcile(input: PlanningInput): void {
1113
+ // gh#41: remote-frame registry shared with serverAction (drop fork).
1046
1114
  lastTeams = input.teams
1047
1115
  lastPeers = input.peers ?? []
1048
1116
  lastInFlight = input.inFlight ?? []
@@ -1127,7 +1195,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1127
1195
  }
1128
1196
  }
1129
1197
  for (const id of model.nodeIds()) {
1130
- if (!seen.has(id) && !id.startsWith('peer-')) model.removeNode(id)
1198
+ // peer- and member- cards are reconciled by their own blocks below.
1199
+ if (!seen.has(id) && !id.startsWith('peer-') && !id.startsWith('member-')) model.removeNode(id)
1131
1200
  }
1132
1201
 
1133
1202
  // Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
@@ -1139,6 +1208,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1139
1208
  peerScoreByText.clear()
1140
1209
  for (const p of input.peers ?? []) {
1141
1210
  peerScoreByText.set(peerHostOf(p.url), p.score)
1211
+ peerInfoByHost.set(peerHostOf(p.url), { link: p.link, verified: p.verified })
1142
1212
  }
1143
1213
  if (input.peers !== undefined) {
1144
1214
  const localBounds = model.contentBounds()
@@ -1153,9 +1223,15 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1153
1223
  : saved !== undefined
1154
1224
  ? { x: saved.x, y: saved.y }
1155
1225
  : { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
1226
+ // gh#20 W-B: the peer's server-side member form — its first remote
1227
+ // team row's zone/id8 handle (canvas remote refs are exact handles
1228
+ // per the D1 ruling). Absent until the first remote team row lands.
1229
+ const host = peerHostOf(p.url)
1230
+ const handle = lastRemoteTeams.get(host)?.[0]?.team
1156
1231
  model.upsertNode({
1157
1232
  id, x: base.x, y: base.y,
1158
1233
  label: p.url, team: '', name: p.url, peerUrl: p.url,
1234
+ remoteHandle: handle,
1159
1235
  live: true, remote: true, memberships: [],
1160
1236
  })
1161
1237
  model.upsertScore(id, p.score)
@@ -1164,6 +1240,118 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1164
1240
  for (const id of model.nodeIds()) {
1165
1241
  if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
1166
1242
  }
1243
+ // gh#38 (owner ruling 「统一到画布里面拖拽」): every remote session-team
1244
+ // HANDLE renders as its own draggable card, stacked beside its host's
1245
+ // peer card — pulling a specific remote member into a local team is a
1246
+ // plain drag (no picker, no hand-typed handle). Host cards stay for
1247
+ // federation context; the member cards carry the actual join gesture.
1248
+ const memberSeen = new Set<string>()
1249
+ for (const [host, rows] of lastRemoteTeams) {
1250
+ const hostNode = model.allNodes().find(n => n.remote === true && peerHostOf(n.peerUrl ?? '') === host && !n.id.startsWith('member-'))
1251
+ const baseX = (hostNode?.x ?? 400) + 190
1252
+ let y = hostNode?.y ?? 0
1253
+ for (const row of rows) {
1254
+ if (!row.team.includes('/')) continue
1255
+ const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
1256
+ if (memberSeen.has(id)) continue
1257
+ memberSeen.add(id)
1258
+ const saved = layoutDoc?.nodes[id]
1259
+ const existing = model.getNode(id)
1260
+ const pos = existing !== undefined ? { x: existing.x, y: existing.y } : saved !== undefined ? { x: saved.x, y: saved.y } : { x: baseX, y }
1261
+ y += 92
1262
+ model.upsertNode({
1263
+ id, x: pos.x, y: pos.y,
1264
+ label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
1265
+ remoteHandle: row.team,
1266
+ live: true, remote: true, memberships: [],
1267
+ })
1268
+ }
1269
+ }
1270
+ for (const id of model.nodeIds()) {
1271
+ if (id.startsWith('member-') && !memberSeen.has(id)) model.removeNode(id)
1272
+ }
1273
+ // gh#42 (owner report: dragged a remote peer into base, no edge): team
1274
+ // edges derive from NODE memberships, but remote cards carried hardcoded
1275
+ // empty memberships — the server truth (base contains dsh/d659a8f9)
1276
+ // never re-attached after a poll or reload. Re-attach here: every local
1277
+ // team's remote member handles resolve onto their cards (member- card
1278
+ // preferred, the peer HOST card as fallback), in-flight teams excluded.
1279
+ const handleToNode = new Map<string, string>()
1280
+ for (const n of model.allNodes()) {
1281
+ if (n.remote !== true || n.remoteHandle === undefined || n.remoteHandle === '') continue
1282
+ if (!handleToNode.has(n.remoteHandle) || n.id.startsWith('member-')) handleToNode.set(n.remoteHandle, n.id)
1283
+ }
1284
+ for (const team of input.teams) {
1285
+ if (pendingTeams.has(team.name)) continue
1286
+ const resolved: string[] = []
1287
+ for (const m of team.members) {
1288
+ const id = handleToNode.get(m.id) ?? m.id
1289
+ if (model.getNode(id) !== undefined) resolved.push(id)
1290
+ }
1291
+ const current = [...model.teamMemberIds(team.name)].sort().join('|')
1292
+ const next = [...resolved].sort().join('|')
1293
+ if (current !== next) model.setTeamMembers(team.name, resolved)
1294
+ }
1295
+ // gh#41 fleet planning: the peers' published canvas org renders as
1296
+ // REMOTE team frames — the owner reads the other side's grouping from
1297
+ // ANY node and regulates it in place. Members attach as board
1298
+ // memberships; drops into a remote frame fork to the host relay (see
1299
+ // serverAction: the action carries fleetPeer, and main.ts routes it to
1300
+ // /__dsh_a2a/fleet-canvas-op instead of the local canvas control).
1301
+ const remoteFrames = new Map<string, string>()
1302
+ const prevRemoteFrames = remoteFramesRef
1303
+ remoteFramesRef = remoteFrames
1304
+ if (input.peerOrg !== undefined) {
1305
+ let slot = 0
1306
+ const localNames = new Set(input.teams.map(t => t.name))
1307
+ for (const row of input.peerOrg) {
1308
+ // The peer's zone comes from its directory rows ('dsh/xxxx' →
1309
+ // 'dsh'); the org's members are stored verbatim — LOCAL members as
1310
+ // session ids, remote members as zone/id8 handles. Both must
1311
+ // resolve onto that host's session cards: id-form members become
1312
+ // `member-<zone>-<id8>` (id8 = strip 'session-' prefix, first 8
1313
+ // chars — the repo's id8 derivation), handle members sanitize
1314
+ // directly. The old sanitize-only pass misresolved every local
1315
+ // member (owner: 数据对不上).
1316
+ const peerZone = input.remoteTeams?.find(r => r.via === row.url)?.team.split('/')[0] ?? 'dsh'
1317
+ const id8of = (id: string): string => id.replace(/^session-/, '').slice(0, 8)
1318
+ // The server now canonicalizes org members to handles (gh#41 wire
1319
+ // form); the id-form branch stays as the defensive path for peers
1320
+ // on an older build that still publish raw session ids.
1321
+ const memberBoardId = (m: string): string =>
1322
+ m.includes('/') ? `member-${m.replace(/[^a-z0-9-]/gi, '-')}` : `member-${peerZone}-${id8of(m)}`
1323
+ for (const team of row.org) {
1324
+ if (team.name === '' || localNames.has(team.name)) continue
1325
+ const memberIds = team.members.map(memberBoardId).filter(id => model.getNode(id) !== undefined)
1326
+ const saved = layoutDoc?.frames?.[team.name]
1327
+ // gh#44 (owner: frame position/size never sticks): the LOCAL-team
1328
+ // path keeps an existing frame's live rect ("live rect wins" — a
1329
+ // poll must not snap a frame the user just dragged, the save loop
1330
+ // persists it a beat later). Remote frames re-set unconditionally
1331
+ // every poll, so every drag snapped back before the save landed.
1332
+ // Same contract here: only place the frame when it is absent.
1333
+ if (model.getFrame(team.name) === undefined) {
1334
+ model.setFrame(team.name, saved !== undefined ? { x: saved.x, y: saved.y, w: saved.w, h: saved.h } : { x: 200 + (slot % 4) * 280, y: 940 + Math.floor(slot / 4) * 160, w: 250, h: 130 })
1335
+ }
1336
+ model.setTeamMembers(team.name, memberIds)
1337
+ remoteFrames.set(team.name, row.url)
1338
+ slot += 1
1339
+ }
1340
+ }
1341
+ }
1342
+ for (const [name, url] of prevRemoteFrames) {
1343
+ if (!remoteFrames.has(name)) {
1344
+ model.removeFrame(name)
1345
+ void url
1346
+ }
1347
+ }
1348
+ // gh#20 W-B: the server echoes cross-host members as zone/id8 handles —
1349
+ // map each handle back onto the peer node that carries it, so the
1350
+ // reconcile keeps remote memberships instead of dropping them.
1351
+ const handleToPeer = new Map<string, string>()
1352
+ for (const n of model.allNodes()) {
1353
+ if (n.remote === true && n.remoteHandle !== undefined && n.remoteHandle !== '') handleToPeer.set(n.remoteHandle, n.id)
1354
+ }
1167
1355
  const homes = homeTeams(input.teams)
1168
1356
  for (const team of input.teams) {
1169
1357
  if (model.getFrame(team.name) !== undefined) continue // live rect wins
@@ -1177,17 +1365,19 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1177
1365
  const rects: LayoutRect[] = []
1178
1366
  const all: LayoutRect[] = []
1179
1367
  for (const m of team.members) {
1180
- const n = model.getNode(m.id)
1368
+ const n = model.getNode(handleToPeer.get(m.id) ?? m.id)
1181
1369
  if (n === undefined) continue
1182
1370
  all.push(nodeRect(n))
1183
- if (homes.get(m.id) === team.name) rects.push(nodeRect(n))
1371
+ if (homes.get(n.id) === team.name) rects.push(nodeRect(n))
1184
1372
  }
1185
1373
  const wrap = rects.length > 0 ? rects : all
1186
1374
  if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
1187
1375
  }
1188
1376
  for (const [name] of model.allFrames()) {
1189
1377
  if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
1190
- if (!input.teams.some(t => t.name === name)) model.removeFrame(name)
1378
+ // gh#41: remote org frames are reconciled by the peerOrg block above —
1379
+ // the local-team cleanup must not remove them.
1380
+ if (!input.teams.some(t => t.name === name) && !remoteFrames.has(name)) model.removeFrame(name)
1191
1381
  }
1192
1382
  // An open node panel follows the poll: facts (joins, leaves, team
1193
1383
  // declarations, peer arrivals) land within one cadence.
@@ -176,6 +176,10 @@
176
176
  #dsh-plan .p-node.remote{border-left-color:var(--federal); padding-right:46px}
177
177
  #dsh-plan .p-node.remote .dot{background:var(--federal)}
178
178
  #dsh-plan .p-node.remote .prio{color:var(--ink)}
179
+ /* gh#20 W-A link badge: linked green / stranger gray (fleet reserved);
180
+ the ✓/✗ verified mark rides inside the same chip. */
181
+ #dsh-plan .p-node .prio.lnk-linked{color:var(--live, #2e9e5b); border-color:var(--live, #2e9e5b)}
182
+ #dsh-plan .p-node .prio.lnk-stranger{color:var(--ink-muted); border-color:var(--card-line)}
179
183
  #dsh-plan .e-activity{stroke:var(--accent); stroke-width:2; stroke-dasharray:8 6; stroke-opacity:.9}
180
184
  #dsh-plan .e-federal{stroke:var(--federal); stroke-width:1.5; stroke-dasharray:6 5; stroke-opacity:.5}
181
185
  #dsh-plan .edge-label{font-size:10.5px; fill:var(--ink-muted)}