@nelsonlongxiang/dsh-open-a2a-net 0.6.31 → 0.6.33

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 (98) hide show
  1. package/README.md +11 -2
  2. package/README_zh.md +10 -1
  3. package/assets/nexusDist/assets/index-BACVZe0j.css +1 -0
  4. package/assets/nexusDist/assets/index-Bbxnpf2p.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +450 -59
  7. package/lib/a2a-client.js +9 -4
  8. package/lib/auth-observer.js +254 -0
  9. package/lib/blob-store.js +210 -0
  10. package/lib/canvas-store.js +6 -0
  11. package/lib/card.js +4 -2
  12. package/lib/cli-deposit.js +122 -7
  13. package/lib/client.js +64 -64
  14. package/lib/client.js.map +1 -1
  15. package/lib/faces/admin.js +176 -47
  16. package/lib/faces/canvas-notify.js +12 -3
  17. package/lib/faces/files.js +155 -0
  18. package/lib/faces/nodes.js +171 -0
  19. package/lib/faces/session-log.js +162 -0
  20. package/lib/faces/wire.js +212 -43
  21. package/lib/index.js +819 -1725
  22. package/lib/joined-store.js +23 -0
  23. package/lib/layout-store.js +3 -2
  24. package/lib/ledger-conservation.js +97 -0
  25. package/lib/legacy/teams-bridge-inbound.js +156 -0
  26. package/lib/node-bind.js +534 -0
  27. package/lib/node-github.js +72 -0
  28. package/lib/node-self.js +453 -0
  29. package/lib/node-store.js +484 -0
  30. package/lib/node-tasks.js +342 -0
  31. package/lib/origin-claim.js +246 -0
  32. package/lib/peer-directory.js +134 -0
  33. package/lib/receipt.js +26 -1
  34. package/lib/roster-op.js +104 -0
  35. package/lib/skill/SKILL.md +3 -1
  36. package/lib/skill/references/cli.md +83 -4
  37. package/lib/skill/references/collaboration.md +126 -74
  38. package/lib/skill/references/discovery.md +29 -0
  39. package/lib/skill/references/intake.md +107 -0
  40. package/lib/skill/references/router.md +96 -0
  41. package/lib/skill/references/setup.md +2 -1
  42. package/lib/task-auth.js +483 -0
  43. package/lib/task-ledger.js +324 -12
  44. package/lib/team-store.js +73 -2
  45. package/lib/tools/act.js +383 -0
  46. package/lib/tools/files.js +201 -0
  47. package/lib/tools/info.js +684 -0
  48. package/lib/types/a2a-client.d.ts +10 -4
  49. package/lib/types/auth-observer.d.ts +110 -0
  50. package/lib/types/blob-store.d.ts +83 -0
  51. package/lib/types/canvas-store.d.ts +4 -0
  52. package/lib/types/cli-deposit.d.ts +38 -0
  53. package/lib/types/faces/admin.d.ts +15 -2
  54. package/lib/types/faces/canvas-notify.d.ts +4 -1
  55. package/lib/types/faces/files.d.ts +53 -0
  56. package/lib/types/faces/nodes.d.ts +69 -0
  57. package/lib/types/faces/session-log.d.ts +44 -0
  58. package/lib/types/faces/wire.d.ts +51 -5
  59. package/lib/types/index.d.ts +27 -0
  60. package/lib/types/joined-store.d.ts +9 -0
  61. package/lib/types/ledger-conservation.d.ts +77 -0
  62. package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
  63. package/lib/types/node-bind.d.ts +220 -0
  64. package/lib/types/node-github.d.ts +35 -0
  65. package/lib/types/node-self.d.ts +178 -0
  66. package/lib/types/node-store.d.ts +180 -0
  67. package/lib/types/node-tasks.d.ts +121 -0
  68. package/lib/types/origin-claim.d.ts +119 -0
  69. package/lib/types/peer-directory.d.ts +39 -0
  70. package/lib/types/receipt.d.ts +12 -0
  71. package/lib/types/roster-op.d.ts +21 -0
  72. package/lib/types/task-auth.d.ts +271 -0
  73. package/lib/types/task-ledger.d.ts +143 -6
  74. package/lib/types/team-store.d.ts +16 -0
  75. package/lib/types/tools/act.d.ts +52 -0
  76. package/lib/types/tools/files.d.ts +38 -0
  77. package/lib/types/tools/info.d.ts +102 -0
  78. package/lib/types/types.d.ts +6 -0
  79. package/nexus-stage/src/canvas-ops.ts +3 -2
  80. package/nexus-stage/src/federation.ts +1 -0
  81. package/nexus-stage/src/layout-doc.ts +6 -4
  82. package/nexus-stage/src/main.ts +68 -20
  83. package/nexus-stage/src/planning-frame-hue.ts +13 -0
  84. package/nexus-stage/src/planning-legend.ts +1 -0
  85. package/nexus-stage/src/planning-member-handles.ts +34 -0
  86. package/nexus-stage/src/planning-menus.ts +28 -12
  87. package/nexus-stage/src/planning-mirror.ts +47 -3
  88. package/nexus-stage/src/planning-reachability.ts +28 -0
  89. package/nexus-stage/src/planning-roster.ts +15 -0
  90. package/nexus-stage/src/planning-types.ts +2 -2
  91. package/nexus-stage/src/planning-view.ts +57 -52
  92. package/nexus-stage/src/planning.css +6 -0
  93. package/nexus-stage/src/wb-directory.ts +13 -0
  94. package/nexus-stage/src/workbench.css +3 -0
  95. package/nexus-stage/src/workbench.ts +7 -1
  96. package/package.json +3 -2
  97. package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
  98. package/assets/nexusDist/assets/index-jm-cFAhB.js +0 -9
@@ -0,0 +1,102 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { Agent } from '@deepseek-ai/dsh-agent';
3
+ import type { Config } from '../index.ts';
4
+ import type { CardCacheEntry, InFlightRoute, RouteActivityEntry } from '../runtime.ts';
5
+ import type { A2aPeerCard } from '../types.ts';
6
+ import type { ZoneCardFetch } from '../zone.ts';
7
+ import type { A2aClient, CardFetchOutcome } from '../a2a-client.ts';
8
+ import type { CanvasStore } from '../canvas-store.ts';
9
+ import type { ExposureStore } from '../exposure-store.ts';
10
+ import type { IdempotencyStore } from '../idempotency-store.ts';
11
+ import type { JoinedSessions } from '../joined-store.ts';
12
+ import type { PeerStore } from '../peer-store.ts';
13
+ import type { TaskLedger } from '../task-ledger.ts';
14
+ import type { TeamMembershipStore } from '../team-store.ts';
15
+ /** One directory row shared with listDirectoryTeams (which stays in index.ts). */
16
+ export type DirectoryTeamRow = {
17
+ team: string;
18
+ session: string;
19
+ name: string;
20
+ description: string;
21
+ local?: boolean;
22
+ origin?: string;
23
+ workspace?: string;
24
+ via?: string;
25
+ legacy?: boolean; /** gh#106: the peer's last card fetch missed — this row is its kept card, not a live answer. */
26
+ unreachable?: boolean;
27
+ teams?: string[]; /** Internal S5 scope hint, stripped before the tool's public output. */
28
+ canvasMembers?: readonly string[];
29
+ };
30
+ /** Everything the information-plane tools capture from apply(). */
31
+ export interface InfoToolsDeps {
32
+ readonly ctx: Context;
33
+ readonly config: Config;
34
+ readonly logger: ReturnType<Context['logger']>;
35
+ readonly session: string;
36
+ readonly lanIp: string;
37
+ readonly ownCardUrl: string;
38
+ readonly peerStore: PeerStore;
39
+ readonly taskLedger: TaskLedger;
40
+ readonly joinedSessions: JoinedSessions;
41
+ readonly teamMemberships: TeamMembershipStore;
42
+ readonly canvasStore: CanvasStore;
43
+ readonly exposureStore: ExposureStore;
44
+ readonly idempotencyStore: IdempotencyStore;
45
+ readonly cardCache: Map<string, CardCacheEntry>;
46
+ readonly inFlightRoutes: Map<number, InFlightRoute>;
47
+ readonly recentActivity: RouteActivityEntry[];
48
+ readonly teamAuditRing: {
49
+ at: number;
50
+ action: 'join' | 'leave' | 'archive-prune' | 'agent-less';
51
+ team: string;
52
+ id: string;
53
+ ok: boolean;
54
+ reason?: string;
55
+ }[];
56
+ readonly reportRing: {
57
+ at: number;
58
+ level: 'info' | 'warning' | 'error';
59
+ message: string;
60
+ session: string;
61
+ task?: string;
62
+ }[];
63
+ readonly sessionNodes: Map<string, Agent>;
64
+ readonly ownerAllowlist: readonly string[];
65
+ readonly ownerTeamScopeInfo: boolean;
66
+ readonly id8: (id: string) => string;
67
+ readonly sessionTeamOf: (agent: Agent) => string;
68
+ readonly nodeMetadataOf: (agent: Agent) => {
69
+ name: string;
70
+ description: string;
71
+ workspace?: string;
72
+ };
73
+ readonly canvasTeamOf: (name: string) => string;
74
+ /** gh#152: the session's canvas teams read through the local lens — a handle-stored membership of this session counts. */
75
+ readonly canvasTeamsOfSession: (id: string) => string[];
76
+ /** Resolve a handle against the current local/seeded-peer collision snapshot. */
77
+ readonly localHandleResolver: () => (ref: string) => string | undefined;
78
+ readonly memoizedCardFetch: () => ZoneCardFetch;
79
+ readonly mapBounded: <T, R>(items: readonly T[], limit: number, task: (item: T) => Promise<R>) => Promise<R[]>;
80
+ readonly SWEEP_CONCURRENCY: number;
81
+ readonly a2aJoinGateRefusal: (exec: {
82
+ agent?: {
83
+ id: unknown;
84
+ };
85
+ } | undefined) => void;
86
+ readonly observeAuthorization: (cause: string) => void;
87
+ readonly settlePeerCard: (peer: string, outcome: CardFetchOutcome) => A2aPeerCard | undefined;
88
+ readonly client: A2aClient;
89
+ readonly listDirectoryTeams: (expand: boolean, includeCanvas?: boolean) => Promise<DirectoryTeamRow[]>;
90
+ }
91
+ /**
92
+ * Build the five registrars. Call createInfoTools after its deps exist, then
93
+ * invoke each registrar at the tool's original registration position — the
94
+ * registry order the model sees must not change.
95
+ */
96
+ export declare function createInfoTools(deps: InfoToolsDeps): {
97
+ registerWhoami: () => void;
98
+ registerTeams: () => void;
99
+ registerStatus: () => void;
100
+ registerTasks: () => void;
101
+ registerProbe: () => void;
102
+ };
@@ -129,6 +129,12 @@ export interface A2aRouteOk {
129
129
  * receipt-owed (`trackOwedTask` skips it).
130
130
  */
131
131
  readonly bridge?: 'native-teams';
132
+ /**
133
+ * gh#141: the peer refused the settlement the delivered message carried
134
+ * (its enforce verdict, `settlement: {status: 'refused'}` on the wire,
135
+ * #139) — the delivery landed, the debt it named stays owed there.
136
+ */
137
+ readonly settlementRefused?: true;
132
138
  }
133
139
  /** Canonical `a2a_route` failure value. */
134
140
  export interface A2aRouteError {
@@ -19,16 +19,17 @@ import type { LayoutDoc } from './layout-doc'
19
19
  export type RosterOp = { op: 'remove' | 'add'; id: string }
20
20
 
21
21
  /** The five user-facing write actions (one per design.md §3.3 row), plus the two network join/leave actions the panel-slim migration moved into the planning view. */
22
- export type CanvasAction =
22
+ export type CanvasAction = (
23
23
  | { type: 'create-team'; name: string; ids: readonly string[]; /** true when the model had no such team before this action; NOT server creation provenance and never authority to delete on failure. */ created?: boolean }
24
24
  | { type: 'add-member'; team: string; ids: readonly string[]; /** gh#41: set when the target frame is a REMOTE peer's team — the wire routes the op through the host fleet relay. */ fleetPeer?: string }
25
25
  | { type: 'remove-member'; team: string; ids: readonly string[]; /** gh#41: see add-member. */ fleetPeer?: string }
26
- | { type: 'remove-team'; name: string }
26
+ | { type: 'remove-team'; name: string; /** set when the frame is a REMOTE peer's team: the owner dissolves it, through the relay. */ fleetPeer?: string }
27
27
  /** `ops` comes from reorderOps() at emit time; the wire stays model-free. */
28
28
  | { type: 'reorder'; team: string; ops: ReadonlyArray<RosterOp> }
29
29
  /** Network-level join/leave (the session joins the A2A mesh) — distinct from canvas-team membership. */
30
30
  | { type: 'join-network'; id: string }
31
31
  | { type: 'leave-network'; id: string }
32
+ ) & { roster?: boolean }
32
33
 
33
34
  /** Reverts exactly what one applyAction did (team-scoped). */
34
35
  export type Undo = () => void
@@ -70,6 +70,7 @@ export function peerHostOf(url: string): string {
70
70
  /** One remote team row as the directory reports it (state.remote). */
71
71
  export interface RemoteTeamRow {
72
72
  team: string
73
+ unreachable?: boolean
73
74
  name?: string
74
75
  via?: string
75
76
  workspace?: string
@@ -66,11 +66,13 @@ function clampRect(raw: unknown): LayoutRect | undefined {
66
66
  * Normalize an untrusted layout document exactly as the host does:
67
67
  * version!==1 rejects the whole doc; non-finite coordinates drop the
68
68
  * point; coordinates round and clamp to ±COORD_LIMIT; scale clamps to
69
- * [SCALE_MIN, SCALE_MAX]; node keys cap at 128 chars; frame keys mirror
70
- * canvas team names (no '/', cap 40); rects need w>0 and h>0; caps are
69
+ * [SCALE_MIN, SCALE_MAX]; node keys cap at 128 chars; canvas frame names cap at 40,
70
+ * roster:<name> reserves seven extra characters; rects need w>0 and h>0; caps are
71
71
  * first-wins in insertion order - entries beyond are silently dropped,
72
72
  * never refused (an overflow renders, it just does not persist).
73
73
  */
74
+ const validFrame = (name: string): boolean => name !== '' && !name.includes('/') && (name.startsWith('roster:') ? name.length > 7 && name.length <= 47 : name.length <= 40)
75
+
74
76
  export function clampDoc(raw: unknown): LayoutDoc | undefined {
75
77
  if (raw === null || typeof raw !== 'object') return undefined
76
78
  const rec = raw as Record<string, unknown>
@@ -96,7 +98,7 @@ export function clampDoc(raw: unknown): LayoutDoc | undefined {
96
98
  const frames: Record<string, LayoutRect> = {}
97
99
  if (rec.frames !== null && typeof rec.frames === 'object') {
98
100
  for (const [name, rect] of Object.entries(rec.frames as Record<string, unknown>)) {
99
- if (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40) continue
101
+ if (!validFrame(name)) continue
100
102
  const r = clampRect(rect)
101
103
  if (r !== undefined) frames[name] = r
102
104
  if (Object.keys(frames).length >= LAYOUT_FRAME_CAP) break
@@ -127,7 +129,7 @@ export function buildLayoutDoc(
127
129
  }
128
130
  const docFrames: Record<string, LayoutRect> = {}
129
131
  for (const [name, r] of frames) {
130
- if (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40) continue
132
+ if (!validFrame(name)) continue
131
133
  const w = clamp(r.w)
132
134
  const h = clamp(r.h)
133
135
  if (w <= 0 || h <= 0) continue
@@ -9,9 +9,10 @@
9
9
  import { createFaultReporter } from './fault'
10
10
  import { createWorkbench, type StateBody } from './workbench'
11
11
  import { createPlanningView } from './planning-view'
12
+ import { rosterFrames, rosterNameOf } from './planning-roster'
12
13
  import { createSaveLoop, type LampState } from './layout-wire'
13
14
  import { createCanvasWire } from './canvas-wire'
14
- import type { CanvasAction } from './canvas-ops'
15
+ import { actionTeam, type CanvasAction } from './canvas-ops'
15
16
  import './workbench.css'
16
17
 
17
18
  const app = document.getElementById('app')!
@@ -154,28 +155,58 @@ const canvasWire = createCanvasWire({
154
155
  onNotice: (kind, text) => planning.notice(kind, text),
155
156
  })
156
157
 
158
+ /** One relayed op on a REMOTE peer's team. THIS host signs the envelope
159
+ * and forwards; the peer's link gate authorizes the write. */
160
+ async function fleetOp(peer: string, op: 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<boolean> {
161
+ const r = await controlFetch('/__dsh_a2a/fleet-canvas-op', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ peer, op, name, ...(id !== undefined ? { id } : {}) }),
165
+ cache: 'no-store',
166
+ }).catch(() => undefined)
167
+ const body = r !== undefined ? await r.json().catch(() => undefined) : undefined
168
+ if (body?.ok === true && op === 'remove' && body.removed === false) planning.notice('info', `属主那边已没有「${name}」,无需解散`)
169
+ if (body?.ok === true) return true
170
+ // Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
171
+ planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
172
+ return false
173
+ }
174
+
175
+ async function rosterAction(a: CanvasAction, name: string): Promise<boolean> {
176
+ const post = async (body: Record<string, unknown>): Promise<boolean> => {
177
+ const reply = await controlFetch('/__dsh_a2a/roster-op', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ...body }) }).catch(() => undefined)
178
+ const result = reply !== undefined ? await reply.json().catch(() => undefined) as { ok?: boolean; error?: string } | undefined : undefined
179
+ if (reply?.ok && result?.ok === true) return true
180
+ planning.notice('error', `roster 操作失败:${String(result?.error ?? (reply ? `HTTP ${reply.status}` : '无响应'))}`)
181
+ return false
182
+ }
183
+ if (a.type === 'create-team') return post({ op: 'create', ids: a.ids })
184
+ if (a.type === 'remove-team') return post({ op: 'dissolve' })
185
+ if (a.type === 'add-member' || a.type === 'remove-member') {
186
+ for (const id of a.ids) if (!(await post({ op: a.type, id }))) return false
187
+ return true
188
+ }
189
+ planning.notice('error', 'roster 不支持该操作')
190
+ return false
191
+ }
192
+
157
193
  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.
194
+ // gh#41: a remote-frame drop forks to the fleet relay; so does dissolving
195
+ // a mirror frame (single owner + mirror: the owner's store is the truth).
160
196
  if ((a.type === 'add-member' || a.type === 'remove-member') && a.fleetPeer !== undefined) {
197
+ const peer = a.fleetPeer
161
198
  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) {
171
- // Say WHY the peer refused (not linked, untrusted origin, unknown member…), not just that it did.
172
- planning.notice('error', `跨主机团队操作失败:${String(body?.error ?? '对端无响应')}`)
173
- return false
174
- }
175
- }
199
+ for (const id of a.ids) if (!(await fleetOp(peer, a.type, a.team, id))) return false
176
200
  return true
177
201
  })()
178
202
  }
203
+ if (a.type === 'remove-team' && a.fleetPeer !== undefined) return fleetOp(a.fleetPeer, 'remove', a.name)
204
+ // Only a trusted registry roster frame or an explicit create choice may trigger a local roster write.
205
+ if (a.roster === true) {
206
+ const roster = rosterNameOf(actionTeam(a))
207
+ if (roster !== undefined) return rosterAction(a, roster)
208
+ planning.notice('error', '无效的 roster 框名'); return Promise.resolve(false)
209
+ }
179
210
  switch (a.type) {
180
211
  case 'create-team': return canvasWire.createTeam(a.name, a.ids, a.created)
181
212
  case 'add-member': return canvasWire.addMembers(a.team, a.ids)
@@ -200,6 +231,9 @@ async function postNetworkJoin(action: 'join' | 'leave', id: string): Promise<bo
200
231
  }
201
232
 
202
233
  let lampState: LampState = 'idle'
234
+ // The nodes face reports effective scoped assemblies; a mounted registration alone does not prove visibility.
235
+ let nodePromptMissing = new Set<string>()
236
+ let nodePromptCheckedAt = 0
203
237
  let canvasFace = false
204
238
  let lastLayoutJson: string | undefined
205
239
  /** True once any layout answer (saved doc or an explicit null) has been
@@ -375,13 +409,25 @@ async function poll(): Promise<void> {
375
409
  const res = await controlFetch('/__dsh_a2a/state', { cache: 'no-store' })
376
410
  if (!res.ok) throw new Error(`/__dsh_a2a/state ${res.status}`)
377
411
  const body = (await res.json()) as StateBody
412
+ if (Date.now() - nodePromptCheckedAt >= 15_000) {
413
+ nodePromptCheckedAt = Date.now()
414
+ try {
415
+ const nodes = await controlFetch('/__dsh_a2a/nodes', { cache: 'no-store' })
416
+ if (!nodes.ok) throw new Error(`nodes ${nodes.status}`)
417
+ const view = await nodes.json() as { bindings?: Array<{ session: string; mounted: unknown; effective: { core: boolean; mutable: boolean } | null }> }
418
+ nodePromptMissing = new Set((view.bindings ?? [])
419
+ .filter(b => b.mounted !== null && b.effective !== null && (b.effective.core === false || b.effective.mutable === false))
420
+ .map(b => b.session))
421
+ } catch { nodePromptMissing.clear() } // unavailable is unknown, not confirmed missing
422
+ }
423
+ const shown = { ...body, sessions: (body.sessions ?? []).map(s => ({ ...s, ...(nodePromptMissing.has(s.id) ? { nodePromptMissing: true } : {}) })) }
378
424
  clearFault()
379
425
  // canvasFace first: bench.update reads board.available() to pick the
380
426
  // mode, so assigning it afterwards held the very first paint in data
381
427
  // mode (board display:none, 0px wide) until the next 3s poll — and the
382
428
  // board's first reconcile measured a zero-width view.
383
429
  canvasFace = body.canvas !== undefined
384
- bench.update(body)
430
+ bench.update(shown)
385
431
 
386
432
  // ── The layout answer precedes the first reconcile on purpose ──
387
433
  // The board's first build reads `layoutDoc` for saved positions and
@@ -419,7 +465,7 @@ async function poll(): Promise<void> {
419
465
  // SessionLite needs `joined: boolean` (workbench rows carry `joined?`):
420
466
  // tri-state → boolean without losing the unjoined node-list population.
421
467
  const allSessions = (body.sessions ?? []).map(s => ({ ...s, joined: s.joined === true }))
422
- const teams = body.canvas?.teams ?? []
468
+ const teams = [...(body.canvas?.teams ?? []), ...rosterFrames(body.registry?.teams ?? [], body.zone ?? '')]
423
469
  const peers = (body.peers ?? []).filter(p => p !== null && typeof p === 'object' && typeof (p as { url?: unknown }).url === 'string')
424
470
  const inFlight = (body.inFlight ?? []).filter(r => r !== null && typeof r === 'object' && typeof (r as { team?: unknown }).team === 'string' && typeof (r as { peer?: unknown }).peer === 'string' && Number.isFinite((r as { startedAt?: unknown }).startedAt))
425
471
  const remoteTeams = (body.remote ?? [])
@@ -429,15 +475,17 @@ async function poll(): Promise<void> {
429
475
  name: r.name,
430
476
  via: r.via,
431
477
  workspace: r.workspace,
478
+ ...(r.unreachable === true ? { unreachable: true } : {}),
432
479
  ...(typeof r.origin === 'string' && r.origin !== '' ? { origin: r.origin } : {}),
433
480
  ...(Array.isArray(r.teams) && r.teams.length > 0 ? { teams: r.teams.filter((t): t is string => typeof t === 'string' && t !== '') } : {}),
434
481
  }))
435
482
  // gh#41: the peers' published canvas org rides the board (remote frames).
436
483
  const peerOrg = (body.peerOrg as unknown[] ?? [])
437
- .filter((r): r is { url?: unknown; org?: unknown } => r !== null && typeof r === 'object')
484
+ .filter((r): r is { url?: unknown; org?: unknown; unreachable?: unknown } => r !== null && typeof r === 'object')
438
485
  .filter(r => typeof r.url === 'string' && Array.isArray(r.org))
439
486
  .map(r => ({
440
487
  url: r.url as string,
488
+ ...(r.unreachable === true ? { unreachable: true } : {}),
441
489
  org: (r.org as unknown[])
442
490
  .filter((e): e is { name?: unknown; team?: unknown; members?: unknown } => e !== null && typeof e === 'object')
443
491
  .filter(e => typeof e.team === 'string' && e.team !== '')
@@ -0,0 +1,13 @@
1
+ /** Stable team hue shared by canvas and roster frames. */
2
+ import { FRAME_HUES } from './tokens'
3
+
4
+ export function frameHue(name: string): { line: string; bg: string } {
5
+ let h = 0
6
+ for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
7
+ const rgb = FRAME_HUES[h % FRAME_HUES.length]!
8
+ const line = "#" + rgb.toString(16).padStart(6, "0")
9
+ const r = (rgb >> 16) & 0xff
10
+ const g = (rgb >> 8) & 0xff
11
+ const b = rgb & 0xff
12
+ return { line, bg: `rgba(${r},${g},${b},0.05)` }
13
+ }
@@ -27,6 +27,7 @@ export function createLegend(): HTMLDetailsElement {
27
27
  }
28
28
  row(false, '成员边(星形,框→成员)')
29
29
  row(true, '跨队多属(虚线)')
30
+ row(true, 'roster 网络队框(双线边框)')
30
31
  row(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
31
32
  row(true, '联邦线(→peer)', 'var(--federal)')
32
33
  legend.append(summary, rows)
@@ -0,0 +1,34 @@
1
+ /** Pure joined-local-handle projection for remote organization members. */
2
+ import type { RemoteTeamRow } from './federation'
3
+ import type { SessionLite } from './world'
4
+
5
+ type HandleSession = Pick<SessionLite, 'id' | 'team' | 'joined'>
6
+
7
+ /** Map only unique joined id8 handles that no remote directory row already owns. */
8
+ export function joinedLocalIdsByHandle(sessions: ReadonlyArray<HandleSession>, publishedHandles: ReadonlySet<string>): Map<string, string> {
9
+ const localIds = new Map<string, string>()
10
+ const ambiguous = new Set<string>()
11
+ for (const session of sessions) {
12
+ if (session.joined !== true) continue
13
+ const slash = session.team.lastIndexOf('/')
14
+ if (slash <= 0 || session.team.slice(slash + 1) !== session.id.replace(/^session-/, '').slice(0, 8)) continue
15
+ const handle = session.team
16
+ if (publishedHandles.has(handle) || ambiguous.has(handle)) continue
17
+ if (localIds.has(handle)) {
18
+ localIds.delete(handle)
19
+ ambiguous.add(handle)
20
+ } else {
21
+ localIds.set(handle, session.id)
22
+ }
23
+ }
24
+ return localIds
25
+ }
26
+
27
+ /** Project a remote canvas member to its local card or stable synthetic card id. */
28
+ export function remoteMemberBoardId(member: string, localIdsByHandle: ReadonlyMap<string, string>, remoteTeams: ReadonlyArray<RemoteTeamRow> | undefined, peerUrl: string): string {
29
+ const localId = localIdsByHandle.get(member)
30
+ if (localId !== undefined) return localId
31
+ if (member.includes('/')) return `member-${member.replace(/[^a-z0-9-]/gi, '-')}`
32
+ const peerZone = remoteTeams?.find(row => row.via === peerUrl)?.team.split('/')[0] ?? 'dsh'
33
+ return `member-${peerZone}-${member.replace(/^session-/, '').slice(0, 8)}`
34
+ }
@@ -8,6 +8,7 @@
8
8
  * @module nexus-stage/planning-menus
9
9
  */
10
10
  import { reorderOps, type CanvasAction } from './canvas-ops'
11
+ import { rosterFrameName, rosterFrameTitle } from './planning-roster'
11
12
  import type { PlanningSession, PlanningTeam } from './planning-types'
12
13
  import type { WorldModel } from './world'
13
14
 
@@ -22,6 +23,8 @@ export interface MenusDeps {
22
23
  readonly model: WorldModel
23
24
  /** Canvas teams from the latest poll. */
24
25
  teams(): ReadonlyArray<PlanningTeam>
26
+ joined(id: string): boolean
27
+ isRoster(frame: string): boolean
25
28
  /** The card or frame-head element a menu re-focuses on close. */
26
29
  anchorFor(target: MenuTarget): HTMLElement | null
27
30
  emit(a: CanvasAction): void
@@ -45,6 +48,7 @@ export interface PlanningMenus {
45
48
 
46
49
  export function createPlanningMenus(d: MenusDeps): PlanningMenus {
47
50
  const { root, netBtn, signal, model } = d
51
+ const titleOf = (frame: string): string => d.isRoster(frame) ? rosterFrameTitle(frame) : frame
48
52
 
49
53
  // ── 建队:selection → 命名对话框 → create-team ──
50
54
  function selectedByY(): string[] {
@@ -87,6 +91,12 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
87
91
  title.id = 'p-dialog-title'
88
92
  title.className = 'p-dialog-title'
89
93
  title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
94
+ const kind = document.createElement('select')
95
+ kind.className = 'p-dialog-kind'
96
+ kind.setAttribute('aria-label', '团队类型')
97
+ for (const [value, label] of [['canvas', 'canvas 队'], ['roster', 'roster 网络队']]) {
98
+ const option = document.createElement('option'); option.value = value; option.textContent = label; kind.appendChild(option)
99
+ }
90
100
  const input = document.createElement('input')
91
101
  input.className = 'p-dialog-input'
92
102
  input.maxLength = 40
@@ -103,7 +113,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
103
113
  cancelBtn.type = 'button'
104
114
  cancelBtn.textContent = '取消'
105
115
  row.append(okBtn, cancelBtn)
106
- panel.append(title, input, err, row)
116
+ panel.append(title, kind, input, err, row)
107
117
  wrap.appendChild(panel)
108
118
  root.appendChild(wrap)
109
119
 
@@ -115,25 +125,27 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
115
125
  }
116
126
  const nameError = (): string | null => {
117
127
  const name = input.value.trim()
118
- if (name === '' || name.length > 40 || name.includes('/') || /^\d+$/.test(name)) {
119
- return '队名需 1..40 字,不含“/”,不为纯数字'
120
- }
128
+ if (name === '' || name.length > 40 || name.includes('/') || name.startsWith('roster:') || (kind.value === 'roster' && name.includes('*')) || /^\d+$/.test(name)) return '队名需 1..40 字,不含“/”、通配符或保留前缀,不为纯数字'
129
+ if (kind.value === 'roster' && model.getFrame(name) !== undefined) return '同名 canvas 队已存在,不能创建 roster 队'
130
+ if (kind.value === 'roster' && model.getFrame(rosterFrameName(name)) !== undefined && !d.isRoster(rosterFrameName(name))) return 'roster 框名已被 canvas 或镜像队占用'
131
+ if (kind.value === 'canvas' && model.getFrame(rosterFrameName(name)) !== undefined) return '同名 roster 队已存在,不能创建 canvas 队'
132
+ if (kind.value === 'roster' && ids.some(id => model.getNode(id)?.remote === true || !d.joined(id))) return 'roster 仅接受本机已入网的会话'
121
133
  return null
122
134
  }
123
135
  const confirm = (): void => {
124
136
  const problem = nameError()
125
137
  if (problem !== null) { err.textContent = problem; input.focus(); return }
126
- const name = input.value.trim()
138
+ const name = kind.value === 'roster' ? rosterFrameName(input.value.trim()) : input.value.trim()
127
139
  close()
128
140
  if (model.getFrame(name) !== undefined) {
129
141
  // Duplicate name: host create is idempotent-ok, but the optimistic
130
142
  // create-team would WIPE the existing roster and overwrite its saved
131
143
  // frame rect. Join instead — same wire result, nothing destroyed.
132
- d.notice('info', `已加入现有团队「${name}」`)
144
+ d.notice('info', `已加入现有团队「${kind.value === 'roster' ? rosterFrameTitle(name) : name}」`)
133
145
  d.emit({ type: 'add-member', team: name, ids })
134
146
  return
135
147
  }
136
- d.emit({ type: 'create-team', name, ids, created: true })
148
+ d.emit({ type: 'create-team', name, ids, created: true, ...(kind.value === 'roster' ? { roster: true } : {}) })
137
149
  }
138
150
  okBtn.addEventListener('click', confirm)
139
151
  cancelBtn.addEventListener('click', close)
@@ -149,7 +161,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
149
161
  if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
150
162
  if (e.key !== 'Tab') return
151
163
  e.preventDefault()
152
- const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, button'))
164
+ const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button'))
153
165
  const idx = focusables.indexOf(document.activeElement as HTMLElement)
154
166
  const next = e.shiftKey
155
167
  ? focusables[(idx - 1 + focusables.length) % focusables.length]
@@ -252,7 +264,7 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
252
264
  el.setAttribute('aria-label', '团队操作')
253
265
  let items: HTMLButtonElement[] = []
254
266
  if (target.kind === 'frame') {
255
- items = [menuItem(`解散团队「${target.name}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
267
+ items = [menuItem(`解散团队「${titleOf(target.name)}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
256
268
  } else {
257
269
  const id = target.id
258
270
  if (level === 'root') {
@@ -278,13 +290,17 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
278
290
  for (const t of d.teams()) {
279
291
  if (level === 'join') {
280
292
  const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
281
- items.push(menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => d.emit({ type: 'add-member', team: t.name, ids: [id] })))
293
+ const allowed = !d.isRoster(t.name) || (d.joined(id) && model.getNode(id)?.remote !== true)
294
+ const item = menuItem(already ? `${titleOf(t.name)}(已加入)` : titleOf(t.name), !already && allowed, () => d.emit({ type: 'add-member', team: t.name, ids: [id] }))
295
+ if (!allowed) item.title = 'roster 只接受本机已入网会话'
296
+ items.push(item)
282
297
  } else if (level === 'leave') {
283
298
  const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
284
- items.push(menuItem(t.name, member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
299
+ items.push(menuItem(titleOf(t.name), member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
285
300
  } else {
286
301
  const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
287
- items.push(menuItem(t.name, member, () => {
302
+ if (d.isRoster(t.name)) continue
303
+ items.push(menuItem(titleOf(t.name), member, () => {
288
304
  const current = model.teamMemberIds(t.name)
289
305
  const desired = [id, ...current.filter(x => x !== id)]
290
306
  d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
@@ -4,9 +4,9 @@
4
4
  * other host renders it as a MIRROR of that host's published card and edits
5
5
  * it through the fleet relay. The frame head says so: 「镜像 · <owner host>」,
6
6
  * and 「同步中…」 from the moment a relayed edit is sent until the owner
7
- * answers (the relay refreshes the owner's card before replying, so the next
8
- * poll already shows the result). Local frames carry no marker — this host
9
- * is their owner.
7
+ * answers (the relay then re-reads the owner's card in the background, and
8
+ * the owner's own change ping backs it up). Local frames carry no marker —
9
+ * this host is their owner.
10
10
  * @module nexus-stage/planning-mirror
11
11
  */
12
12
  import { peerHostOf } from './federation'
@@ -29,3 +29,47 @@ export function mirrorRoute(ownerUrl: string | undefined, syncing: boolean): Mir
29
29
  syncing,
30
30
  }
31
31
  }
32
+
33
+ type MirrorOrg = ReadonlyArray<{ readonly url: string; readonly org: ReadonlyArray<{ readonly name: string }> }>
34
+
35
+ /** After the owner accepted a dissolve, hide its team at most this long: a
36
+ * peer's late add-member re-creates the team on the owner, and then it is
37
+ * real again even though the owner's org never stopped listing it. */
38
+ export const DISSOLVE_HIDE_MS = 10_000
39
+
40
+ /**
41
+ * 解散 on a mirror frame: the frame stays gone from the moment the dissolve
42
+ * is sent until the owner's published org stops listing the team (or
43
+ * DISSOLVE_HIDE_MS after the owner accepted). Without this, a poll that lands
44
+ * while the relay is in flight — or before the owner's card is re-read —
45
+ * draws the frame again (and keeps its saved rect for a later team of the
46
+ * same name). Keyed by owner AND name: two owners may both have a team X.
47
+ */
48
+ export function createDissolveGuard(now: () => number = Date.now) {
49
+ const key = (ownerUrl: string, name: string): string => `${ownerUrl}\u0000${name}`
50
+ const dissolving = new Map<string, { url: string; name: string; acceptedAt?: number }>()
51
+ return {
52
+ /** Mark the dissolve and return the action carrying its owner (the relay fork). */
53
+ fork<A extends { type: 'remove-team'; name: string }>(act: A, ownerUrl: string): A & { fleetPeer: string } {
54
+ dissolving.set(key(ownerUrl, act.name), { url: ownerUrl, name: act.name })
55
+ return { ...act, fleetPeer: ownerUrl }
56
+ },
57
+ /** The owner answered: a refusal (or a failed channel) lets the frame back now, an acceptance starts the clock. */
58
+ settled(act: { type: string; name?: string; fleetPeer?: string }, ok: boolean): void {
59
+ if (act.type !== 'remove-team' || act.name === undefined || act.fleetPeer === undefined) return
60
+ const entry = dissolving.get(key(act.fleetPeer, act.name))
61
+ if (entry === undefined) return
62
+ if (ok) entry.acceptedAt = now()
63
+ else dissolving.delete(key(act.fleetPeer, act.name))
64
+ },
65
+ /** True while this owner's team is being (or has just been) dissolved here. */
66
+ hides(name: string, ownerUrl: string): boolean { return dissolving.has(key(ownerUrl, name)) },
67
+ /** Forget a dissolve once its owner's org no longer lists the team, or once its time is up. */
68
+ sweep(peerOrg: MirrorOrg): void {
69
+ for (const [k, e] of dissolving) {
70
+ const listed = peerOrg.find(row => row.url === e.url)?.org.some(t => t.name === e.name) === true
71
+ if (!listed || (e.acceptedAt !== undefined && now() - e.acceptedAt > DISSOLVE_HIDE_MS)) dissolving.delete(k)
72
+ }
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,28 @@
1
+ /** Project the peer's last missed card read onto existing remote cards and frames. */
2
+ import { peerHostOf, type RemoteTeamRow } from './federation'
3
+
4
+ export const UNREACHABLE_HINT = '对端未应答(显示的是最后已知状态)'
5
+
6
+ export function unreachableRefs(input: {
7
+ remoteTeams?: ReadonlyArray<RemoteTeamRow>
8
+ peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean }>
9
+ }): { hosts: ReadonlySet<string>; members: ReadonlySet<string> } {
10
+ const hosts = new Set<string>()
11
+ for (const row of input.remoteTeams ?? []) if (row.unreachable === true && row.via) hosts.add(peerHostOf(row.via))
12
+ for (const row of input.peerOrg ?? []) if (row.unreachable === true) hosts.add(peerHostOf(row.url))
13
+ const members = new Set<string>()
14
+ for (const row of input.remoteTeams ?? []) if (row.via && hosts.has(peerHostOf(row.via))) members.add(`member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`)
15
+ return { hosts, members }
16
+ }
17
+
18
+ /** A mirror keeps its last known contents visible but warns until its owner responds again. */
19
+ export function showFrameReachability(frame: HTMLElement, lost: boolean): void {
20
+ frame.classList.toggle('is-unreachable', lost)
21
+ const existing = frame.querySelector('.p-frame-unreachable')
22
+ if (lost && existing === null) {
23
+ const hint = document.createElement('span')
24
+ hint.className = 'p-frame-unreachable'
25
+ hint.textContent = UNREACHABLE_HINT
26
+ frame.appendChild(hint)
27
+ } else if (!lost) existing?.remove()
28
+ }
@@ -0,0 +1,15 @@
1
+ /** Roster frames use an independent layout namespace beside canvas frames. */
2
+ import type { PlanningTeam } from './planning-types'
3
+
4
+ export const ROSTER_PREFIX = 'roster:'
5
+ export const rosterNameOf = (frame: string): string | undefined => frame.startsWith(ROSTER_PREFIX) ? frame.slice(ROSTER_PREFIX.length) : undefined
6
+ export const rosterFrameName = (name: string): string => ROSTER_PREFIX + name
7
+ export const rosterFrameTitle = (frame: string): string => { const name = rosterNameOf(frame); return name === undefined ? frame : `网络队 · roster · ${name}` }
8
+ export function rosterFrames(rows: readonly { team: string; members: readonly string[] }[], zone: string): PlanningTeam[] {
9
+ return rows.flatMap(row => {
10
+ if (zone === '' || !row.team.startsWith(zone + '/') || !Array.isArray(row.members)) return []
11
+ const name = row.team.slice(zone.length + 1)
12
+ if (name === '' || name.length > 40 || name.includes('/') || name.startsWith(ROSTER_PREFIX) || name.includes('*')) return []
13
+ return [{ name: rosterFrameName(name), team: row.team, kind: 'roster' as const, members: row.members.filter(id => typeof id === 'string' && id !== '').map(id => ({ id })) }]
14
+ })
15
+ }
@@ -12,7 +12,7 @@ import type { SessionLite, TeamLite } from './world'
12
12
 
13
13
  /** Minimal session/team shapes consumed here (topology rows satisfy them). */
14
14
  export interface PlanningSession extends SessionLite {}
15
- export interface PlanningTeam extends TeamLite {}
15
+ export interface PlanningTeam extends TeamLite { kind?: 'roster' }
16
16
 
17
17
  export interface PlanningInput {
18
18
  sessions: ReadonlyArray<PlanningSession>
@@ -26,7 +26,7 @@ export interface PlanningInput {
26
26
  remoteTeams?: ReadonlyArray<RemoteTeamRow>
27
27
  /** gh#41 fleet planning: each tracked peer's published canvas org —
28
28
  * rendered as REMOTE team frames (read + cross-host regulation). */
29
- peerOrg?: ReadonlyArray<{ url: string; org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }> }>
29
+ peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean; org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }> }>
30
30
  }
31
31
 
32
32
  /** Screen-width class a viewport is remembered under (window width, not board width — collapsing the rail must not switch buckets). */