@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30

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 (69) hide show
  1. package/README.md +87 -14
  2. package/README_zh.md +83 -7
  3. package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
  4. package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +0 -0
  7. package/cordis.patch.yml +21 -21
  8. package/lib/canvas-store.js +21 -1
  9. package/lib/client.js +64 -64
  10. package/lib/client.js.map +1 -1
  11. package/lib/faces/admin.js +755 -0
  12. package/lib/faces/canvas-notify.js +138 -0
  13. package/lib/faces/wire.js +669 -0
  14. package/lib/flood-guard.js +106 -0
  15. package/lib/index.js +200 -1392
  16. package/lib/recursion-guard.js +78 -0
  17. package/lib/runtime.js +1 -0
  18. package/lib/skill/SKILL.md +35 -9
  19. package/lib/skill/references/cli.md +24 -7
  20. package/lib/skill/references/collaboration.md +355 -0
  21. package/lib/skill/references/discovery.md +5 -2
  22. package/lib/skill/references/pitfalls.md +2 -1
  23. package/lib/skill/references/routing.md +18 -8
  24. package/lib/skill/references/setup.md +152 -0
  25. package/lib/task-ledger.js +40 -4
  26. package/lib/types/canvas-store.d.ts +10 -1
  27. package/lib/types/faces/admin.d.ts +133 -0
  28. package/lib/types/faces/canvas-notify.d.ts +74 -0
  29. package/lib/types/faces/wire.d.ts +171 -0
  30. package/lib/types/flood-guard.d.ts +29 -0
  31. package/lib/types/index.d.ts +11 -3
  32. package/lib/types/recursion-guard.d.ts +56 -0
  33. package/lib/types/runtime.d.ts +35 -0
  34. package/lib/types/task-ledger.d.ts +15 -1
  35. package/nexus-stage/package-lock.json +2262 -2262
  36. package/nexus-stage/src/canvas-ops.ts +175 -175
  37. package/nexus-stage/src/canvas-wire.ts +156 -156
  38. package/nexus-stage/src/edges.ts +72 -72
  39. package/nexus-stage/src/federation-overlay.ts +103 -103
  40. package/nexus-stage/src/federation.ts +240 -240
  41. package/nexus-stage/src/layout-doc.ts +143 -143
  42. package/nexus-stage/src/layout-wire.ts +158 -158
  43. package/nexus-stage/src/main.ts +5 -1
  44. package/nexus-stage/src/planning-card-text.ts +73 -0
  45. package/nexus-stage/src/planning-edges.ts +26 -0
  46. package/nexus-stage/src/planning-fit.ts +45 -0
  47. package/nexus-stage/src/planning-legend.ts +38 -0
  48. package/nexus-stage/src/planning-mirror.ts +31 -0
  49. package/nexus-stage/src/planning-overlap.ts +159 -0
  50. package/nexus-stage/src/planning-relayout.ts +121 -0
  51. package/nexus-stage/src/planning-search.ts +168 -0
  52. package/nexus-stage/src/planning-types.ts +1 -0
  53. package/nexus-stage/src/planning-undo.ts +68 -0
  54. package/nexus-stage/src/planning-view.ts +120 -121
  55. package/nexus-stage/src/planning.css +41 -8
  56. package/nexus-stage/src/seat.ts +35 -35
  57. package/nexus-stage/src/stage-name.ts +13 -13
  58. package/nexus-stage/src/tokens.ts +31 -31
  59. package/nexus-stage/src/viewport.ts +24 -1
  60. package/nexus-stage/src/wb-chat.ts +20 -6
  61. package/nexus-stage/src/wb-ledger.ts +20 -7
  62. package/nexus-stage/src/wb-names.ts +43 -0
  63. package/nexus-stage/src/workbench.css +8 -2
  64. package/nexus-stage/src/workbench.ts +55 -17
  65. package/nexus-stage/src/world.ts +13 -21
  66. package/package.json +216 -214
  67. package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
  68. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  69. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
@@ -1,35 +1,35 @@
1
- /**
2
- * Deterministic fallback seating: a session id hashes to a stable polar
3
- * seat, so a reload reproduces the same star map even when no layout is
4
- * persisted (saved layouts still win - main.ts checks them first). This
5
- * replaces three Math.random() calls whose only product was a star map
6
- * that reshuffled on every reload, making screenshots and sessions
7
- * incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
8
- * dependencies so the unit test pins stability/bounds without three.
9
- * @module nexus-stage/seat
10
- */
11
-
12
- /** Radial distance bounds, mirroring the historical random-seat envelope. */
13
- export const SEAT_DIST_MIN = 12
14
- export const SEAT_DIST_MAX = 30
15
- /** Vertical jitter bound in world units above/below the grid plane. */
16
- export const SEAT_Y_SPAN = 1
17
-
18
- /** FNV-1a 32-bit - the same determinism family as getHue. */
19
- export function hashId(sid: string): number {
20
- let h = 0x811c9dc5
21
- for (let i = 0; i < sid.length; i++) {
22
- h ^= sid.charCodeAt(i)
23
- h = Math.imul(h, 0x01000193) >>> 0
24
- }
25
- return h >>> 0
26
- }
27
-
28
- /** Deterministic world seat (x, vertical jitter, z) for one session id. */
29
- export function seatFor(sid: string): { x: number; y: number; z: number } {
30
- const h = hashId(sid)
31
- const angle = ((h % 3600) / 3600) * Math.PI * 2
32
- const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
33
- const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
34
- return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
35
- }
1
+ /**
2
+ * Deterministic fallback seating: a session id hashes to a stable polar
3
+ * seat, so a reload reproduces the same star map even when no layout is
4
+ * persisted (saved layouts still win - main.ts checks them first). This
5
+ * replaces three Math.random() calls whose only product was a star map
6
+ * that reshuffled on every reload, making screenshots and sessions
7
+ * incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
8
+ * dependencies so the unit test pins stability/bounds without three.
9
+ * @module nexus-stage/seat
10
+ */
11
+
12
+ /** Radial distance bounds, mirroring the historical random-seat envelope. */
13
+ export const SEAT_DIST_MIN = 12
14
+ export const SEAT_DIST_MAX = 30
15
+ /** Vertical jitter bound in world units above/below the grid plane. */
16
+ export const SEAT_Y_SPAN = 1
17
+
18
+ /** FNV-1a 32-bit - the same determinism family as getHue. */
19
+ export function hashId(sid: string): number {
20
+ let h = 0x811c9dc5
21
+ for (let i = 0; i < sid.length; i++) {
22
+ h ^= sid.charCodeAt(i)
23
+ h = Math.imul(h, 0x01000193) >>> 0
24
+ }
25
+ return h >>> 0
26
+ }
27
+
28
+ /** Deterministic world seat (x, vertical jitter, z) for one session id. */
29
+ export function seatFor(sid: string): { x: number; y: number; z: number } {
30
+ const h = hashId(sid)
31
+ const angle = ((h % 3600) / 3600) * Math.PI * 2
32
+ const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
33
+ const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
34
+ return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
35
+ }
@@ -1,13 +1,13 @@
1
- /**
2
- * Display name for one stage node: the session title when present, then the
3
- * routable team label (`team/<id8>`), then the host fallback label
4
- * (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
5
- * without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
6
- * user report: the planning canvas showed a dozen unreadable label cards).
7
- * @module nexus-stage/stage-name
8
- */
9
-
10
- /** One node's display title. */
11
- export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
12
- return node.name ?? node.team ?? node.label ?? ''
13
- }
1
+ /**
2
+ * Display name for one stage node: the session title when present, then the
3
+ * routable team label (`team/<id8>`), then the host fallback label
4
+ * (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
5
+ * without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
6
+ * user report: the planning canvas showed a dozen unreadable label cards).
7
+ * @module nexus-stage/stage-name
8
+ */
9
+
10
+ /** One node's display title. */
11
+ export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
12
+ return node.name ?? node.team ?? node.label ?? ''
13
+ }
@@ -1,32 +1,32 @@
1
- /**
2
- * A2A Nexus design tokens.
3
- * Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
4
- */
5
-
6
- export const C = {
7
- bg0: '#060a12',
8
- bg1: '#0d1322',
9
- ink: '#dde3f0',
10
- inkMuted: '#7a86a0',
11
- accent: '#8b5cf6',
12
- liveGreen: '#4ade80',
13
- coldGrey: '#4a5568',
14
- lineFederal: '#6366f1',
15
- membershipLine: '#22d3ee',
16
- } as const;
17
-
18
- /**
19
- * Semantic layer: the scene-facing names the viewer actually consumes,
20
- * each pinned to its primitive above. This is the `S` main.ts reached for
21
- * as `T.S.*` — the layer was documented but never exported, so every seat
22
- * attempt threw and cycle()'s empty catch buried it: an eternally empty
23
- * stage behind a healthy-looking render loop.
24
- */
25
- export const S = {
26
- /** Mesh color + emissive for live session nodes (primitive liveGreen). */
27
- nodeLive: 0x4ade80,
28
- /** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
29
- nodeCold: 0x4a5568,
30
- } as const;
31
-
1
+ /**
2
+ * A2A Nexus design tokens.
3
+ * Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
4
+ */
5
+
6
+ export const C = {
7
+ bg0: '#060a12',
8
+ bg1: '#0d1322',
9
+ ink: '#dde3f0',
10
+ inkMuted: '#7a86a0',
11
+ accent: '#8b5cf6',
12
+ liveGreen: '#4ade80',
13
+ coldGrey: '#4a5568',
14
+ lineFederal: '#6366f1',
15
+ membershipLine: '#22d3ee',
16
+ } as const;
17
+
18
+ /**
19
+ * Semantic layer: the scene-facing names the viewer actually consumes,
20
+ * each pinned to its primitive above. This is the `S` main.ts reached for
21
+ * as `T.S.*` — the layer was documented but never exported, so every seat
22
+ * attempt threw and cycle()'s empty catch buried it: an eternally empty
23
+ * stage behind a healthy-looking render loop.
24
+ */
25
+ export const S = {
26
+ /** Mesh color + emissive for live session nodes (primitive liveGreen). */
27
+ nodeLive: 0x4ade80,
28
+ /** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
29
+ nodeCold: 0x4a5568,
30
+ } as const;
31
+
32
32
  export const FRAME_HUES = [0x8b5cf6, 0x3b82f6, 0x14b8a6, 0xf59e0b, 0xec4899, 0x84cc16];
@@ -60,16 +60,39 @@ export function zoomAt(vp: LayoutViewport, factor: number, ax: number, ay: numbe
60
60
  /** Content bounds in world space (null = nothing to fit). */
61
61
  export interface WorldBounds { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
62
62
 
63
+ /** A fit never shrinks past a readable floor: below {@link NARROW_FIT_WIDTH}
64
+ * (the board's phone breakpoint) {@link NARROW_FIT_FLOOR}, at any wider
65
+ * viewport {@link WIDE_FIT_FLOOR}. Fitting a whole board drew 13px card
66
+ * names at ~7px on a phone, and a real 66-card board at 30% on a desktop
67
+ * (review 2026-10-27, real-host check). Past the floor the fit anchors the
68
+ * content's top-left corner instead of its centre; the rest is a pan away. */
69
+ export const NARROW_FIT_WIDTH = 680
70
+ export const NARROW_FIT_FLOOR = 0.8
71
+ export const WIDE_FIT_FLOOR = 0.6
72
+ const FIT_MARGIN = 16
73
+
63
74
  /**
64
75
  * Fit the content bounds into a viewW x viewH viewport at 90% fill,
65
76
  * clamped to the persisted scale envelope; empty bounds reset to identity.
77
+ * The scale is floored (see {@link WIDE_FIT_FLOOR}); an explicit maxScale
78
+ * below the floor still wins.
66
79
  */
67
80
  export function fitView(bounds: WorldBounds | null, viewW: number, viewH: number, maxScale = Number.POSITIVE_INFINITY): LayoutViewport {
68
81
  if (bounds === null || viewW <= 0 || viewH <= 0) return IDENTITY_VIEWPORT
69
82
  const bw = bounds.maxX - bounds.minX
70
83
  const bh = bounds.maxY - bounds.minY
71
84
  if (!(bw > 0) || !(bh > 0)) return IDENTITY_VIEWPORT
72
- const scale = clampScale(Math.min(maxScale, 0.9 * Math.min(viewW / bw, viewH / bh)))
85
+ // The floor is about content that does not FIT at a readable scale, so it
86
+ // is judged on the natural fit — a caller's maxScale cap below the floor
87
+ // must not turn a board that fits into a top-left-anchored one (PR #79 review).
88
+ const natural = 0.9 * Math.min(viewW / bw, viewH / bh)
89
+ const fitted = Math.min(maxScale, natural)
90
+ const floor = viewW < NARROW_FIT_WIDTH ? NARROW_FIT_FLOOR : WIDE_FIT_FLOOR
91
+ if (natural < floor) {
92
+ const scale = clampScale(Math.min(maxScale, floor))
93
+ return { x: bounds.minX - FIT_MARGIN / scale, y: bounds.minY - FIT_MARGIN / scale, scale }
94
+ }
95
+ const scale = clampScale(fitted)
73
96
  return {
74
97
  x: (bounds.minX + bounds.maxX) / 2 - viewW / (2 * scale),
75
98
  y: (bounds.minY + bounds.maxY) / 2 - viewH / (2 * scale),
@@ -19,7 +19,7 @@
19
19
  * @module nexus-stage/wb-chat
20
20
  */
21
21
  import type { ArchivedRow, DeadRow, PendingRow, RemoteRow, StateBody } from './workbench'
22
- import { VERDICTS } from './wb-ledger'
22
+ import { overHour, VERDICTS } from './wb-ledger'
23
23
  import { displayName } from './stage-name'
24
24
 
25
25
  /** Chat target: a node picked on the board or in the directory. Session and
@@ -55,8 +55,6 @@ export interface ChatDrawer {
55
55
 
56
56
  /** The composer's hard limit (dispatch route contract). */
57
57
  const MAX_CHARS = 4000
58
- /** A pending receipt older than this is 陈旧 (same definition as the ledger). */
59
- const STALE_MS = 60 * 60 * 1000
60
58
  /** Within this many px of the bottom, a repaint keeps the thread pinned there. */
61
59
  const STICK_PX = 32
62
60
 
@@ -415,9 +413,14 @@ export function createChatDrawer(deps: ChatDrawerDeps): ChatDrawer {
415
413
  function pendingStatus(row: PendingRow, now: number): SentRow['status'] {
416
414
  const inbound = row.direction === 'inbound'
417
415
  if (row.receiptExpected === false) return { text: inbound ? '来信 · 无需回执' : '已送达 · 无回执', tone: 'muted', hint: '免回执消息:不等回执,24h 后自动归档' }
418
- const stale = now - row.startedAt > STALE_MS
419
- const label = inbound ? '待本机回执' : '待回执'
420
- return { text: `${stale ? '陈旧' : label} · ${row.status}`, tone: stale ? 'warn' : 'wait', hint: `${inbound ? '对方等待本机回执;' : ''}派发于 ${ago(row.startedAt, now)}` }
416
+ // One hour boundary with the 台账 tab (the host's settleable verdict,
417
+ // browser clock only on older hosts). Past it, OUR send is 陈旧 (can be
418
+ // nudged); a peer's delivery is a receipt WE owe — never 陈旧 (#30 P3).
419
+ const late = overHour(row, now)
420
+ const text = inbound ? (late ? '待本机回执 · 超 1 小时' : '待本机回执') : late ? '陈旧' : '待回执'
421
+ // The row's own status only when it says more than 'pending'.
422
+ const extra = row.status !== 'pending' ? ` · ${row.status}` : ''
423
+ return { text: `${text}${extra}`, tone: late ? 'warn' : 'wait', hint: `${inbound ? '对方等待本机回执;' : ''}${inbound ? '收到' : '派发'}于 ${ago(row.startedAt, now)}` }
421
424
  }
422
425
 
423
426
  /** A send that left the ledger, by how it ended (gh#31). `endedBy` says
@@ -427,6 +430,17 @@ export function createChatDrawer(deps: ChatDrawerDeps): ChatDrawer {
427
430
  * ledger's own markers, which are not shown. */
428
431
  function settledStatus(row: ArchivedRow, now: number): SentRow['status'] {
429
432
  const when = `结算于 ${ago(row.resolvedAt, now)}`
433
+ // gh#68: a fire-forget send this host moved out of the ledger (移出 on
434
+ // the 已过期(免回执) card, an abandon) is bookkeeping, not a failure — it
435
+ // never waited on a receipt, so 已放弃 would contradict the button that
436
+ // did it. A cancel stays 已撤回: it is a deliberate recall that also
437
+ // tells the peer to stop (API / tool path; the workbench offers none).
438
+ // A receipt that arrives after the move keeps its 回执迟到 flag.
439
+ if (row.receiptExpected === false && row.endedBy === 'abandoned') {
440
+ return row.late === true
441
+ ? { text: '已移出(免回执) · 回执迟到', tone: 'warn', hint: `${when};免回执消息本机移出后对方回执才到,结局不改写` }
442
+ : { text: '已移出(免回执)', tone: 'muted', hint: `${when};免回执消息,本机移出台账,不是失败` }
443
+ }
430
444
  if (row.endedBy === 'abandoned' || row.endedBy === 'cancelled') {
431
445
  const act = row.endedBy === 'cancelled' ? '撤回' : '放弃'
432
446
  // The peer answered after we ended it: the verdict stays ours (late
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import type { DeadRow, PendingRow, StateBody } from './workbench'
27
27
  import type { RailTone } from './wb-rail'
28
+ import { nameInto } from './wb-names'
28
29
 
29
30
  export interface LedgerContext {
30
31
  readonly state: StateBody
@@ -54,7 +55,7 @@ function senderOf(row: { peer?: string }): string | undefined {
54
55
  * settleable verdict (receiptExpected && age < 1h, computed host-side), else
55
56
  * by ours — a workbench opened from another machine with a skewed clock then
56
57
  * still draws the boundary where the server does (at most one poll late). */
57
- function overHour(r: PendingRow, now: number): boolean {
58
+ export function overHour(r: PendingRow, now: number): boolean {
58
59
  return r.settleable !== undefined ? r.settleable === false : now - r.startedAt >= HOUR
59
60
  }
60
61
 
@@ -155,7 +156,10 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
155
156
  const staleCount = facts.stale.length
156
157
 
157
158
  const tiles = el('div', 'wb-led-tiles')
158
- tile(tiles, '待回执', pending.length, '等待回执的任务(pending),含陈旧与免回执行')
159
+ // 待回执 counts only rows a receipt is owed on — the same rows as the
160
+ // 陈旧 / 待本机回执 / 等待回执 groups below; receiptless rows are their own
161
+ // 免回执 group and owe nothing.
162
+ tile(tiles, '待回执', expected.length, `要回执、仍未结算的任务(含陈旧与本机欠对方的入站行)${receiptless.length > 0 ? `;另有免回执 ${receiptless.length} 条不计` : ''}`)
159
163
  tile(tiles, '陈旧', staleCount, '我方派发、要回执、已等待 ≥1h 仍未回执(可催办);本机欠对方的入站行在「待本机回执」组另计,免回执行不算陈旧', staleCount > 0 ? 'warn' : undefined)
160
164
  tile(tiles, '死信', facts.deadLetters.length, '超过 24h TTL 仍未结算(unconsumed);需清账(abandon)才能移出。免回执到期另列,不算死信', facts.deadLetters.length > 0 ? 'danger' : undefined)
161
165
  tile(tiles, '已归档', archived, '已离开台账并留档的任务(结算、拒绝或放弃),用于审计')
@@ -172,8 +176,13 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
172
176
  head.appendChild(brief)
173
177
  node.appendChild(head)
174
178
  const meta = el('div', 'wb-led-meta')
175
- meta.appendChild(el('span', 'wb-led-team')).textContent = c.inbound ? `来自 ${c.sender ?? '对端'}` : `发往 ${c.team}`
176
- if (c.inbound) meta.appendChild(el('span', 'wb-led-to')).textContent = `至 ${c.team}`
179
+ // Names, not bare ids (wb-names; the id rides each span's tooltip). An
180
+ // inbound row's sender is the peer handle; an unknown one reads 对端.
181
+ const who = meta.appendChild(el('span', 'wb-led-team'))
182
+ if (!c.inbound) nameInto(who, state, c.team, '发往 ')
183
+ else if (c.sender !== undefined) nameInto(who, state, c.sender, '来自 ')
184
+ else who.textContent = '来自 对端'
185
+ if (c.inbound) nameInto(meta.appendChild(el('span', 'wb-led-to')), state, c.team, '至 ')
177
186
  meta.appendChild(el('span')).textContent = c.atText
178
187
  meta.appendChild(el('span', 'wb-led-task')).textContent = c.taskId
179
188
  for (const chip of c.chips) meta.appendChild(el('span', 'wb-led-chip')).textContent = chip
@@ -181,10 +190,13 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
181
190
  node.title = `${c.taskId}\n${c.team}`
182
191
  if (c.dead !== undefined) {
183
192
  const row = c.dead
184
- const btn = el('button', 'wb-btn wb-led-abandon')
193
+ // A fire-forget expiry is normal bookkeeping, not a failure: moving it
194
+ // out is an optional tidy, so it gets a quiet control, not 清账.
195
+ const expiry = row.reason === 'fire-forget'
196
+ const btn = el('button', `wb-btn wb-led-abandon${expiry ? ' is-quiet' : ''}`)
185
197
  btn.type = 'button'
186
- btn.textContent = '清账'
187
- btn.title = '从台账移出这条死信(abandon)'
198
+ btn.textContent = expiry ? '移出' : '清账'
199
+ btn.title = expiry ? '可选:把这条已过期的免回执记录移出台账(不是失败,不处理也无妨)' : '从台账移出这条死信(abandon)'
188
200
  btn.addEventListener('click', ev => {
189
201
  ev.stopPropagation()
190
202
  btn.disabled = true
@@ -209,6 +221,7 @@ export function renderLedger(pane: HTMLElement, ctx: LedgerContext): { badge: st
209
221
  const section = (label: string, rows: number, tone: string, hint: string): HTMLElement | null => {
210
222
  if (rows === 0) return null
211
223
  const g = el('div', `wb-led-group is-${tone}`)
224
+ g.dataset.group = label
212
225
  const head = el('div', 'wb-led-group-head')
213
226
  head.appendChild(el('span')).textContent = `${label} · ${rows}`
214
227
  head.appendChild(el('span', 'wb-led-group-hint')).textContent = hint
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Readable names for routing team ids, shared by the activity and plan
3
+ * panes and the 台账 tab (review 2026-10-27: rows read bare `dsh/1111…` ids;
4
+ * the ledger kept doing so after #72 fixed the other two panes).
5
+ * Names are attacker-shaped data: textContent only.
6
+ * @module nexus-stage/wb-names
7
+ */
8
+ import type { StateBody } from './workbench'
9
+ import { displayName } from './stage-name'
10
+
11
+ /** The name behind a team id — a session, a remote node or a canvas team —
12
+ * or undefined when none matches. An empty name counts as no match:
13
+ * displayName's `??` keeps '', and a peer can publish one. `remote` marks a
14
+ * name the PEER chose — it can collide with a local session's, so callers
15
+ * must show where it is from. */
16
+ export function teamName(state: StateBody, team: string): { name: string; remote: boolean } | undefined {
17
+ const named = (name: string | undefined, remote: boolean) => name !== undefined && name.trim() !== '' ? { name, remote } : undefined
18
+ const s = (state.sessions ?? []).find(r => r.team === team)
19
+ if (s !== undefined) return named(displayName(s), false)
20
+ const r = (state.remote ?? []).find(x => x.team === team)
21
+ if (r !== undefined) return named(displayName(r), true)
22
+ return named((state.canvas?.teams ?? []).find(t => t.team === team)?.name, false)
23
+ }
24
+
25
+ /** A name for plain text: a peer-published one says it is remote. */
26
+ export function teamLabel(state: StateBody, team: string): string {
27
+ const n = teamName(state, team)
28
+ return n === undefined ? team : n.remote ? `${n.name}(远端)` : n.name
29
+ }
30
+
31
+ /** Fill `target` with `prefix` + the name (the id rides the tooltip; an
32
+ * unknown team stays its id); a peer-published name gets a visible 远端 chip. */
33
+ export function nameInto(target: HTMLElement, state: StateBody, team: string, prefix = ''): void {
34
+ const n = teamName(state, team)
35
+ target.textContent = `${prefix}${n?.name ?? team}`
36
+ target.title = team
37
+ if (n?.remote === true) {
38
+ const chip = target.appendChild(document.createElement('span'))
39
+ chip.className = 'wb-row-remote'
40
+ chip.textContent = '远端'
41
+ chip.title = '名字由对端自行发布'
42
+ }
43
+ }
@@ -31,13 +31,17 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
31
31
  .wb-header{display:flex;align-items:center;gap:12px;padding:8px 14px;border-bottom:1px solid var(--n-border);background:var(--n-panel)}
32
32
  .wb-title{display:flex;align-items:center;gap:10px;font-weight:600;font-size:14px;white-space:nowrap}
33
33
  .wb-badge{font-size:11px;font-weight:400;color:var(--n-ink-muted);border:1px solid var(--n-border);border-radius:99px;padding:1px 8px}
34
+ .wb-badge[hidden]{display:none}
35
+ .wb-row-remote{margin-left:6px;padding:0 6px;border:1px solid currentColor;border-radius:99px;font-size:10.5px;color:var(--n-ink-muted);vertical-align:1px}
34
36
  .wb-header-stats{display:flex;gap:16px;font-size:12px;color:var(--n-ink-muted);flex-wrap:wrap;min-width:0}
35
37
  .wb-header-stats .wb-kv{display:flex;gap:6px}
36
38
  .wb-header-stats .wb-kv-k{color:var(--n-ink-muted)}
37
39
  .wb-header-stats .wb-kv-v{color:var(--n-ink)}
38
40
  /* Header verdict: exceptions only (results-first). Quiet when healthy. */
39
41
  .wb-header-ok{color:var(--n-ink-muted)}
40
- .wb-alert{border:1px solid currentColor;border-radius:99px;padding:0 9px;line-height:20px;white-space:nowrap;cursor:help}
42
+ .wb-alert{border:1px solid currentColor;border-radius:99px;padding:0 9px;line-height:20px;white-space:nowrap;cursor:pointer;background:transparent;font:inherit}
43
+ .wb-alert:hover{background:color-mix(in srgb,currentColor 12%,transparent)}
44
+ .wb-alert:focus-visible{outline:2px solid currentColor;outline-offset:2px}
41
45
  .wb-alert-danger{color:var(--n-danger)}
42
46
  .wb-alert-warn{color:var(--n-act)}
43
47
  .wb-forensics{font-family:ui-monospace,Consolas,monospace;font-size:11px}
@@ -69,7 +73,7 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
69
73
  .wb-search:focus{outline:1px solid var(--n-accent)}
70
74
  .wb-filters{display:flex;flex-wrap:wrap;gap:4px}
71
75
  .wb-filter{background:none;border:1px solid var(--n-border);color:var(--n-ink-muted);
72
- border-radius:99px;padding:1px 9px;font:inherit;font-size:11.5px;cursor:pointer}
76
+ border-radius:99px;padding:0 10px;min-height:24px;font:inherit;font-size:11.5px;cursor:pointer}
73
77
  .wb-filter:hover{border-color:var(--n-accent);color:var(--n-ink)}
74
78
  .wb-filter.is-active{border-color:var(--n-accent);color:var(--n-ink);
75
79
  background:color-mix(in srgb,var(--n-accent) 16%,transparent)}
@@ -365,6 +369,8 @@ body{background:var(--n-bg);color:var(--n-ink);font:13px/1.45 system-ui,"Segoe U
365
369
  .wb-led-meta > span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}
366
370
  .wb-led-chip{border:1px solid var(--n-border);border-radius:99px;padding:0 6px;font-family:inherit}
367
371
  .wb-led-abandon{position:absolute;top:6px;right:6px;margin-left:0;padding:1px 9px;font-size:11.5px}
372
+ .wb-led-abandon.is-quiet{border-color:transparent;background:transparent;color:var(--n-ink-muted)}
373
+ .wb-led-abandon.is-quiet:hover{border-color:var(--n-border);color:var(--n-ink)}
368
374
  .wb-led-card:has(.wb-led-abandon) .wb-led-card-head{padding-right:52px}
369
375
  .wb-led-empty{padding:24px 8px;text-align:center;font-size:12px;color:var(--n-ink-muted)}
370
376
  .wb-led-forensics{font-size:11px;color:var(--n-ink-muted);border-top:1px dashed var(--n-border);padding-top:6px;margin-top:4px}
@@ -22,6 +22,7 @@ import { createChatDrawer, type ChatTarget } from './wb-chat'
22
22
  import { createRailTabs } from './wb-rail'
23
23
  import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
24
24
  import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
25
+ import { nameInto, teamLabel } from './wb-names'
25
26
 
26
27
  /** One pending ledger row from /state `tasks`. */
27
28
  export interface PendingRow {
@@ -72,6 +73,9 @@ export interface ArchivedRow {
72
73
  readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
73
74
  /** A receipt arrived after the caller had already ended the row. */
74
75
  readonly late?: boolean
76
+ /** gh#68: present (false) only when the row never expected a receipt — a
77
+ * fire-forget send; absent on receipt-expected rows and older hosts. */
78
+ readonly receiptExpected?: false
75
79
  readonly direction?: 'inbound' | 'outbound'
76
80
  readonly brief?: string
77
81
  }
@@ -643,6 +647,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
643
647
  }
644
648
 
645
649
  // ── Activity pane ──
650
+ /** A team cell: the name when known (wb-names), else the id. */
651
+ function teamCell(line: HTMLElement, state: StateBody, team: string): void {
652
+ nameInto(line.appendChild(el('span', 'wb-row-team')), state, team)
653
+ }
654
+
646
655
  function renderActivity(): void {
647
656
  const pane = clear(activityPane)
648
657
  if (last === null) return
@@ -659,7 +668,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
659
668
  for (const row of flights) {
660
669
  const line = el('div', 'wb-row')
661
670
  line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
662
- line.appendChild(el('span', 'wb-row-team')).textContent = row.team
671
+ teamCell(line, last, row.team)
663
672
  line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
664
673
  pane.appendChild(line)
665
674
  }
@@ -678,7 +687,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
678
687
  dir.textContent = row.dir === 'out' ? '→' : '←'
679
688
  if (row.ok !== true) dir.classList.add('is-fail')
680
689
  line.appendChild(dir)
681
- line.appendChild(el('span', 'wb-row-team')).textContent = row.team
690
+ teamCell(line, last, row.team)
682
691
  line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
683
692
  line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
684
693
  pane.appendChild(line)
@@ -704,18 +713,24 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
704
713
  const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
705
714
  actions.push({
706
715
  severity: 'normal',
707
- text: `催办 ${row.taskId} → ${row.team}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
716
+ text: `催办 ${row.taskId} → ${teamLabel(state, row.team)}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
708
717
  brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
709
718
  })
710
719
  }
711
- for (const row of state.sessions ?? []) {
712
- if (row.live !== true) {
713
- actions.push({ severity: 'normal', text: `冷会话 ${displayName(row)}:在 GUI 打开该会话即可唤醒加入面` })
714
- }
720
+ // Cold sessions are one action, not one per session: they share the same
721
+ // remedy (open them in the GUI), and a list of identical lines buried the
722
+ // actions that differ.
723
+ const cold = (state.sessions ?? []).filter(row => row.live !== true).map(row => displayName(row))
724
+ if (cold.length > 0) {
725
+ const names = cold.slice(0, 3).join('、') + (cold.length > 3 ? ` 等 ${cold.length} 个` : '')
726
+ actions.push({ severity: 'normal', text: `冷会话 ${cold.length} 个(${names}):在 GUI 打开即可唤醒加入面` })
715
727
  }
716
728
  for (const row of state.reconcile?.rows ?? []) {
717
729
  if (row.needsRepair === true) {
718
- actions.push({ severity: 'high', text: `reconcile 需修复:${row.id} ${row.error}` })
730
+ // Name the session; the id stays for the operator who greps logs.
731
+ const s = (state.sessions ?? []).find(x => x.id === row.id)
732
+ const who = s !== undefined ? `${displayName(s)}(${row.id})` : row.id
733
+ actions.push({ severity: 'high', text: `reconcile 需修复:${who} ${row.error}` })
719
734
  }
720
735
  }
721
736
  const conflicts = state.idempotency?.conflicts ?? 0
@@ -1122,35 +1137,58 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1122
1137
  // forensic counters that used to sit here (idempotency claims/replays,
1123
1138
  // reconcile state) read on the ledger's 取证 line instead.
1124
1139
  function renderHeader(): void {
1125
- versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : 'v?'
1140
+ // An unknown version says nothing: hide the badge rather than print 'v?'.
1141
+ versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : ''
1142
+ versionBadge.hidden = last?.version === undefined
1126
1143
  const box = clear(headerStats)
1127
1144
  if (last === null) return
1128
- const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string }> = []
1145
+ // Each pill is a way in: it opens the tab (and ledger group) that holds
1146
+ // the rows behind its count.
1147
+ const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string; tab: 'ledger' | 'plan' | 'activity'; group?: string }> = []
1129
1148
  // Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
1130
1149
  // expiries are not dead letters, receiptless rows are never stale.
1131
1150
  const facts = ledgerFacts(last, deps.now())
1132
1151
  const dead = facts.deadLetters.length
1133
- if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账' })
1152
+ if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账', tab: 'ledger', group: '死信' })
1134
1153
  const stale = facts.stale.length
1135
- if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)' })
1154
+ if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)', tab: 'ledger', group: '陈旧' })
1136
1155
  const owed = facts.owedOverdue.length
1137
- if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时' })
1156
+ if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时', tab: 'ledger', group: '待本机回执' })
1138
1157
  const conflicts = last.idempotency?.conflicts ?? 0
1139
- if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查' })
1158
+ if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查', tab: 'ledger', group: '取证' })
1140
1159
  const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
1141
- if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划' })
1160
+ if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划', tab: 'plan' })
1142
1161
  if (alerts.length === 0) {
1143
1162
  box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
1144
1163
  return
1145
1164
  }
1146
1165
  for (const a of alerts) {
1147
- const pill = el('span', `wb-alert wb-alert-${a.tone}`)
1166
+ const pill = el('button', `wb-alert wb-alert-${a.tone}`)
1167
+ pill.type = 'button'
1148
1168
  pill.textContent = a.text
1149
- pill.title = a.title
1169
+ pill.title = `${a.title}(点击查看)`
1170
+ pill.addEventListener('click', () => openAlert(a.tab, a.group))
1150
1171
  box.appendChild(pill)
1151
1172
  }
1152
1173
  }
1153
1174
 
1175
+ /** Open the pane behind a header pill. With the board up the panes are
1176
+ * rail tabs (on a narrow screen the rail slides in first); without it they
1177
+ * sit on the stage. A ledger group (or the 取证 fold) scrolls into view. */
1178
+ function openAlert(tab: 'ledger' | 'plan' | 'activity', group?: string): void {
1179
+ const pane = dataPanes.find(([k]) => k === tab)![1]
1180
+ if (mode === 'canvas') {
1181
+ root.classList.add('is-rail-open')
1182
+ railTabs.select(tab, { focus: false })
1183
+ }
1184
+ const target = group === undefined ? pane
1185
+ : group === '取证' ? pane.querySelector<HTMLDetailsElement>('details.wb-led-forensics')
1186
+ : Array.from(pane.querySelectorAll<HTMLElement>('.wb-led-group')).find(g => g.dataset.group === group) ?? null
1187
+ if (target === null) return
1188
+ if (target instanceof HTMLDetailsElement) target.open = true
1189
+ target.scrollIntoView?.({ block: 'start', behavior: 'smooth' })
1190
+ }
1191
+
1154
1192
  function update(state: StateBody | null): void {
1155
1193
  last = state
1156
1194
  // 0.6.12: refresh the readable-log list opportunistically (single flight;