@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43

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 (72) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  6. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  7. package/assets/stageDist/index.html +12 -12
  8. package/cli/a2a.mjs +24 -4
  9. package/lib/canvas-store.js +249 -17
  10. package/lib/client.js +62 -72
  11. package/lib/client.js.map +1 -1
  12. package/lib/config.js +1 -0
  13. package/lib/faces/admin.js +103 -63
  14. package/lib/faces/wire.js +3 -1
  15. package/lib/index.js +116 -22
  16. package/lib/origin-claim.js +4 -0
  17. package/lib/skill/SKILL.md +117 -117
  18. package/lib/skill/references/collaboration.md +428 -428
  19. package/lib/skill/references/discovery.md +78 -78
  20. package/lib/skill/references/intake.md +107 -107
  21. package/lib/skill/references/membership.md +73 -73
  22. package/lib/skill/references/pitfalls.md +51 -51
  23. package/lib/skill/references/router.md +96 -96
  24. package/lib/skill/references/routing.md +65 -65
  25. package/lib/types/canvas-store.d.ts +48 -1
  26. package/lib/types/config.d.ts +2 -0
  27. package/lib/types/faces/admin.d.ts +2 -0
  28. package/lib/types/origin-claim.d.ts +2 -0
  29. package/nexus-stage/package.json +21 -21
  30. package/nexus-stage/scripts/mockhost.py +261 -261
  31. package/nexus-stage/src/canvas-ops.ts +191 -191
  32. package/nexus-stage/src/canvas-wire.ts +164 -164
  33. package/nexus-stage/src/cli-home-picker.ts +42 -42
  34. package/nexus-stage/src/fault.ts +56 -56
  35. package/nexus-stage/src/federation-overlay.ts +103 -103
  36. package/nexus-stage/src/federation.ts +241 -241
  37. package/nexus-stage/src/layout-doc.ts +145 -145
  38. package/nexus-stage/src/main.ts +520 -513
  39. package/nexus-stage/src/planning-card-text.ts +73 -73
  40. package/nexus-stage/src/planning-card.ts +63 -63
  41. package/nexus-stage/src/planning-edges.ts +26 -26
  42. package/nexus-stage/src/planning-fit.ts +45 -45
  43. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  44. package/nexus-stage/src/planning-legend.ts +39 -39
  45. package/nexus-stage/src/planning-member-handles.ts +61 -61
  46. package/nexus-stage/src/planning-menus.ts +422 -422
  47. package/nexus-stage/src/planning-mirror.ts +75 -75
  48. package/nexus-stage/src/planning-nodelist.ts +294 -294
  49. package/nexus-stage/src/planning-overlap.ts +159 -159
  50. package/nexus-stage/src/planning-reachability.ts +28 -28
  51. package/nexus-stage/src/planning-relayout.ts +121 -121
  52. package/nexus-stage/src/planning-roster.ts +15 -15
  53. package/nexus-stage/src/planning-search.ts +168 -168
  54. package/nexus-stage/src/planning-types.ts +115 -115
  55. package/nexus-stage/src/planning-undo.ts +68 -68
  56. package/nexus-stage/src/planning-view.ts +1499 -1499
  57. package/nexus-stage/src/planning.css +340 -340
  58. package/nexus-stage/src/seat-name-editor.ts +31 -31
  59. package/nexus-stage/src/session-drag.ts +64 -64
  60. package/nexus-stage/src/viewport.ts +106 -106
  61. package/nexus-stage/src/wb-chat.ts +684 -684
  62. package/nexus-stage/src/wb-directory.ts +237 -237
  63. package/nexus-stage/src/wb-ledger.ts +279 -279
  64. package/nexus-stage/src/wb-names.ts +43 -43
  65. package/nexus-stage/src/wb-rail.ts +147 -147
  66. package/nexus-stage/src/workbench.css +388 -388
  67. package/nexus-stage/src/workbench.ts +1294 -1272
  68. package/nexus-stage/src/world.ts +509 -509
  69. package/nexus-stage/tsconfig.json +24 -24
  70. package/nexus-stage/vite.config.ts +15 -15
  71. package/package.json +1 -1
  72. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
@@ -1,1272 +1,1294 @@
1
- /**
2
- * The A2A workbench: one DOM client UI for human AI researchers and agents
3
- * to OPERATE the network. Since 0.6.2 the 2D spatial planning board and the
4
- * operational panels are MERGED into one responsive shell — the board is the
5
- * stage (canvas mode, default while the host serves the canvas face) and the
6
- * directory/ledger/activity/plan panels ride a collapsible side rail; when
7
- * the canvas face is down the panels become the main stack (data mode).
8
- * Narrow screens turn the rail into a drawer and the inspector into a
9
- * bottom sheet (CSS media queries only — the DOM contract is stable).
10
- *
11
- * Discipline (inherited from the planning view it absorbed):
12
- * - Pure DOM, no WebGL, no three, no framework: every row is a textContent
13
- * write, so attacker-shaped labels stay text.
14
- * - The workbench never fabricates capability: dead-letter rows act through
15
- * the real abandon seam (deps), dispatch only DRAFTS a pasteable brief.
16
- * - update() re-renders data regions wholesale but never touches the
17
- * dispatch textarea (user input survives polls); rail collapse, section
18
- * state, and the active mode are view state kept across updates.
19
- * @module nexus-stage/workbench
20
- */
21
- import { createChatDrawer, type ChatTarget } from './wb-chat'
22
- import { createSeatNameEditor } from './seat-name-editor'
23
- import { createCliHomePicker, type CliHomeOptions } from './cli-home-picker'
24
- import { createRailTabs } from './wb-rail'
25
- import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
26
- import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
27
- import { nameInto, teamLabel } from './wb-names'
28
-
29
- /** One pending ledger row from /state `tasks`. */
30
- export interface PendingRow {
31
- readonly taskId: string
32
- readonly team: string
33
- readonly peer?: string
34
- readonly startedAt: number
35
- readonly status: string
36
- readonly direction?: string
37
- readonly receiptExpected?: boolean
38
- readonly settleable?: boolean
39
- /**
40
- * First line of the dispatched message. A row without it is unreadable:
41
- * `direct-8f3a1c2b · dsh · 3h` says nothing about whether to nudge,
42
- * abandon, or go read the receipt — the operator's whole question is
43
- * WHAT is owed. Absent on rows persisted before the field existed.
44
- */
45
- readonly brief?: string
46
- }
47
-
48
- /** One dead-lettered row from /state `tasksDead`. */
49
- export interface DeadRow {
50
- readonly taskId: string
51
- readonly team: string
52
- readonly peer?: string
53
- readonly startedAt: number
54
- readonly deadAt: number
55
- readonly reason?: string
56
- /** First line of the dispatched message (see {@link PendingRow.brief}). */
57
- readonly brief?: string
58
- /** gh#31: which side of the wire this row served (absent on old hosts). */
59
- readonly direction?: 'inbound' | 'outbound'
60
- }
61
-
62
- /** gh#31: one archived row from /state `archived` (newest first) — the
63
- * per-task outcome for a task that left the ledger. Fields past `summary`
64
- * are absent on hosts that predate them. */
65
- export interface ArchivedRow {
66
- readonly taskId: string
67
- readonly team: string
68
- readonly resolvedAt: number
69
- readonly outcome: string
70
- readonly summary?: string
71
- readonly peer?: string
72
- readonly startedAt?: number
73
- /** How the row left the book: a receipt, a content echo, or the caller
74
- * (abandon / cancel — both archive with outcome 'abandoned'). */
75
- readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
76
- /** A receipt arrived after the caller had already ended the row. */
77
- readonly late?: boolean
78
- /** gh#68: present (false) only when the row never expected a receipt — a
79
- * fire-forget send; absent on receipt-expected rows and older hosts. */
80
- readonly receiptExpected?: false
81
- readonly direction?: 'inbound' | 'outbound'
82
- readonly brief?: string
83
- }
84
-
85
- /** gh#41: one tracked peer's published canvas organization (full-card
86
- * canvasOrg) — the board renders REMOTE team frames from these rows. */
87
- export interface PeerOrgRow {
88
- readonly url: string
89
- readonly unreachable?: boolean
90
- readonly org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }>
91
- }
92
-
93
- /** One session row from /state `sessions` (live or cold joined). */
94
- export interface SessionRow {
95
- readonly id: string
96
- readonly label: string
97
- readonly team: string
98
- readonly name?: string
99
- readonly seatName?: string
100
- readonly description?: string
101
- readonly workspace?: string
102
- readonly joined?: boolean
103
- readonly live?: boolean
104
- /** Final scoped assembly lacks at least one registered node section (#174). */
105
- readonly nodePromptMissing?: boolean
106
- readonly teams?: readonly string[]
107
- /** Canvas teams this session is a member of (0.6.6 roster annotation). */
108
- readonly canvasTeams?: readonly string[]
109
- /** @deprecated 0.6.6 — display-only taxonomy superseded by canvas teams. */
110
- readonly group?: string
111
- }
112
-
113
- /** One remote team row from /state `remote`. */
114
- export interface RemoteRow {
115
- readonly team: string
116
- readonly unreachable?: boolean
117
- readonly name?: string
118
- readonly origin?: string
119
- readonly via?: string
120
- readonly workspace?: string
121
- readonly legacy?: boolean
122
- readonly teams?: readonly string[]
123
- /** Canvas teams on this host that carry this remote handle as a member. */
124
- readonly canvasTeams?: readonly string[]
125
- }
126
-
127
- /** One canvas member: a local bare session id or a zone-qualified remote
128
- * handle (`zone/id8`) — remote members are routed by the directory, never
129
- * woken locally (0.6.6 unified team model). */
130
- export interface CanvasMemberRow {
131
- readonly id: string
132
- readonly team?: string
133
- readonly joined?: boolean
134
- readonly live?: boolean
135
- readonly remote?: boolean
136
- /** Present only for CLI refs: null means the linked home is ambiguous or unknown. */
137
- readonly cliHome?: string | null
138
- readonly cliHeld?: boolean
139
- readonly cliHomeCleared?: boolean
140
- }
141
-
142
- /** One canvas team from /state `canvas.teams`. */
143
- export interface CanvasTeamRow {
144
- readonly name: string
145
- readonly team: string
146
- readonly members: readonly CanvasMemberRow[]
147
- }
148
-
149
- /** One activity-ring entry. */
150
- export interface ActivityRow {
151
- readonly ts: number
152
- readonly dir: 'in' | 'out' | 'fwd'
153
- readonly team: string
154
- readonly peer: string
155
- readonly ok: boolean
156
- }
157
-
158
- /** The /state subset the workbench consumes (additive fields ignored). */
159
- export interface StateBody {
160
- readonly zone?: string
161
- readonly version?: string
162
- readonly sessions?: readonly SessionRow[]
163
- readonly canvas?: { teams?: readonly CanvasTeamRow[] }
164
- /** gh#20 W-A/W-C: the peer row carries the link badge source, the host
165
- * rail's label/version/last-contact facts (absent on older hosts). */
166
- readonly peers?: ReadonlyArray<{
167
- url: string
168
- score?: number
169
- link?: 'linked' | 'stranger'
170
- verified?: boolean
171
- name?: string
172
- version?: string
173
- lastContactAt?: number
174
- /** gh#24: whether the last contact attempt succeeded. */
175
- lastContactOk?: boolean
176
- }>
177
- readonly remote?: readonly RemoteRow[]
178
- readonly activity?: readonly ActivityRow[]
179
- readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
180
- readonly tasks?: readonly PendingRow[]
181
- readonly tasksStaleCount?: number
182
- readonly tasksDead?: readonly DeadRow[]
183
- /** gh#31: per-task outcomes for rows that left the ledger (bounded). */
184
- readonly archived?: readonly ArchivedRow[]
185
- /** gh#41 fleet planning: each tracked peer's published canvas org. */
186
- readonly peerOrg?: readonly PeerOrgRow[]
187
- readonly registry?: { teams?: ReadonlyArray<{ team: string; members: readonly string[]; live?: number }> }
188
- readonly archivedCount?: number
189
- /** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
190
- readonly archivedSplit?: Readonly<Record<string, number>>
191
- readonly idempotency?: {
192
- window?: number
193
- pending?: number
194
- settled?: number
195
- claimsFresh?: number
196
- replays?: number
197
- conflicts?: number
198
- }
199
- readonly reconcile?: {
200
- state?: string
201
- woken?: number
202
- rows?: ReadonlyArray<{ id: string; error: string; attempts: number; needsRepair?: boolean }>
203
- }
204
- }
205
-
206
- /** What the workbench can DO — the seams tests drive. */
207
- export interface WorkbenchDeps {
208
- now(): number
209
- /** gh#41 fleet planning: each tracked peer's published canvas org. */
210
- peerOrg?: readonly PeerOrgRow[]
211
- /** gh#34: read the owner three-key panel state (GET /__dsh_a2a/owner-settings). */
212
- ownerSettingsRead?(): Promise<{ ok: boolean; seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean; announceRestartPending?: boolean; error?: string }>
213
- /** gh#34: write owner panel changes; true on success (POST the same face). */
214
- ownerSettingsWrite?(changes: { seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean }): Promise<{ ok: boolean; restartNeeded?: boolean; error?: string }>
215
- /** gh#41 fleet planning: regulate a REMOTE peer's canvas team (this host
216
- * signs the envelope and forwards; the peer's link gate authorizes). */
217
- fleetCanvasOp?(peer: string, op: 'create' | 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<{ ok: boolean; members?: string[]; error?: string }>
218
- /** Abandon one dead-lettered task via the control route. True on success. */
219
- abandon(taskId: string, reason: string): Promise<boolean>
220
- /** Canvas team member change via the control route (0.6.6): `id` is a
221
- * local session id or a zone-qualified remote handle. True on success. */
222
- canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string): Promise<boolean>
223
- cliHomeOptions?(name: string, id: string): Promise<CliHomeOptions>
224
- /** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
225
- * one message to a routing team, async delivery, receipt lands in the
226
- * ledger. Optional — without it the 快派 form hides. */
227
- dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
228
- /** Inline transcript (0.6.11): the tail of a LOCAL session's own
229
- * conversation log, read-only. Optional — without it the block hides. */
230
- sessionLog?(id: string): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string }>
231
- /** 0.6.12: list every local session holding a log artifact (newest first) —
232
- * the reading face is host-scoped, so archived and never-joined sessions
233
- * appear here even when the network directory shows no local rows. */
234
- sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }>
235
- /** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
236
- * resolves the publishing peer through the directory, ?peer=<base-url>
237
- * reads one tracked peer directly. Without id the peer answers its
238
- * readable-session listing. Optional — without it remote/peer chat
239
- * shows the honest where-the-records-live note. */
240
- remoteSessionLog?(query: { team?: string; peer?: string; id?: string; limit?: number }): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string; via?: string }>
241
- /** Copy text to the clipboard; drafts are pasteable task briefs. */
242
- copy(text: string): void
243
- // 0.6.10: openLocal/openRemote (0.6.7/0.6.8 jump affordances) removed by
244
- // ruling — 禁止任何跳转,全部使用响应式布局. dispatchQuick is the only
245
- // reach primitive and it stays on this page.
246
- /** The spatial planning board. Optional: without it the shell stays in
247
- * data mode permanently. `available()` mirrors the host's canvas face
248
- * (state.canvas !== undefined) and gates canvas mode. */
249
- board?: {
250
- readonly root: HTMLElement
251
- activate(): void
252
- deactivate(): void
253
- available(): boolean
254
- }
255
- }
256
-
257
- /** Display name for one node: session title, then team, then label. */
258
- function displayName(node: { name?: string; team?: string; label?: string }): string {
259
- return node.name ?? node.team ?? node.label ?? ''
260
- }
261
-
262
- function ageText(ms: number, now: number): string {
263
- const s = Math.max(0, Math.round((now - ms) / 1000))
264
- if (s < 90) return `${s}s`
265
- const m = Math.round(s / 60)
266
- if (m < 90) return `${m}m`
267
- const h = Math.round(m / 60)
268
- if (h < 48) return `${h}h`
269
- return `${Math.round(h / 24)}d`
270
- }
271
-
272
- function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
273
- const node = document.createElement(tag)
274
- if (className !== undefined) node.className = className
275
- return node
276
- }
277
-
278
- function textRow(parent: HTMLElement, label: string, value: string): void {
279
- const row = el('div', 'wb-kv')
280
- row.appendChild(el('span', 'wb-kv-k')).textContent = label
281
- row.appendChild(el('span', 'wb-kv-v')).textContent = value
282
- parent.appendChild(row)
283
- }
284
-
285
- type Selection =
286
- | { kind: 'canvas'; row: CanvasTeamRow }
287
- | undefined
288
-
289
- /** The rail sections. */
290
- export type WorkbenchSection = 'directory' | 'hosts' | 'ledger' | 'activity' | 'plan'
291
-
292
-
293
- /** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
294
- export type WorkbenchTab = 'board' | WorkbenchSection
295
-
296
- /** Which face fills the stage. */
297
- export type WorkbenchMode = 'canvas' | 'data'
298
-
299
- export interface Workbench {
300
- readonly root: HTMLElement
301
- /** Feed one /state poll (or null while the first fetch is out). */
302
- update(state: StateBody | null): void
303
- /** Current stage mode: the board while the canvas face is up, else data. */
304
- readonly mode: () => WorkbenchMode
305
- /** Focus a target: 'board' converges on canvas mode (no-op while the
306
- * canvas face is down); a section un-collapses its rail panel. */
307
- selectTab(key: WorkbenchTab): void
308
- /** A node was picked on the planning board (0.6.13): resolve it against
309
- * the latest state and open the right-side chat drawer for it. */
310
- openNodeChat(id: string): void
311
- }
312
-
313
- /** Bare host:port of a peer url (workbench-local twin of federation's
314
- * helper — this module keeps a zero-import posture). */
315
- function peerHostOf(url: string): string {
316
- try {
317
- return new URL(url).host
318
- } catch {
319
- return url
320
- }
321
- }
322
-
323
- /** Rail tabs (2026-10-23): 主机 moved up next to 目录 — host health was the
324
- * last accordion section and sat below the fold. */
325
- const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
326
- { key: 'directory', label: '目录' },
327
- { key: 'hosts', label: '主机' },
328
- { key: 'ledger', label: '台账' },
329
- { key: 'activity', label: '活动' },
330
- { key: 'plan', label: '规划' },
331
- ]
332
-
333
- export function createWorkbench(deps: WorkbenchDeps): Workbench {
334
- const board = deps.board
335
- let mode: WorkbenchMode = 'data'
336
- let selection: Selection
337
- let last: StateBody | null = null
338
- // Directory view state (survives polls): unified search + category filter.
339
- let searchText = ''
340
- let dirFilter: DirFilter = 'all'
341
- // Canvas add-member form cache (static DOM re-mounted across polls; keyed
342
- // by team name — same trick as draftForm so inputs survive the 3s poll).
343
- let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
344
- const cliHomeForms = new Map<string, HTMLFormElement>()
345
- // Quick-dispatch form (0.6.9), cached per target routing team the same
346
- // draftForm way: a textarea's draft must survive the 3s poll remount.
347
- let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
348
- // 0.6.12: readable local logs (host-scope list, newest first). Refreshed
349
- // opportunistically per poll; rows render in the directory's 本机日志 section.
350
- let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
351
- /** How many logs the host holds (the list face caps at 50); from the listing's count. */
352
- let logTotal: number | undefined
353
- /** Learned 本机日志 titles: the first user line of a transcript, captured
354
- * when the chat drawer loads it (the list face carries no titles). */
355
- const logTitles = loadLogTitles()
356
- let logRowsBusy = false
357
-
358
- const root = el('div', 'wb')
359
- root.setAttribute('data-testid', 'wb-root')
360
-
361
- // ── Static skeleton: built once, update() only rewrites data regions. ──
362
- const header = el('header', 'wb-header')
363
- const menuBtn = el('button', 'wb-icon-btn wb-menu')
364
- menuBtn.type = 'button'
365
- menuBtn.dataset.testid = 'wb-menu'
366
- menuBtn.setAttribute('aria-label', '打开目录抽屉')
367
- menuBtn.textContent = '☰'
368
- menuBtn.addEventListener('click', () => root.classList.toggle('is-rail-open'))
369
- header.appendChild(menuBtn)
370
- const title = el('div', 'wb-title')
371
- title.appendChild(el('span', 'wb-title-main')).textContent = 'A2A 工作台'
372
- const versionBadge = el('span', 'wb-badge')
373
- title.appendChild(versionBadge)
374
- header.appendChild(title)
375
- // gh#34 owner three-key panel: the header gear opens the inspector panel
376
- // (teamJoinAllowlist / teamScopeInfo / announce) — 远程建联三件不再翻配置文件。
377
- const ownerBtn = el('button', 'wb-icon-btn wb-owner-btn')
378
- ownerBtn.type = 'button'
379
- ownerBtn.dataset.testid = 'wb-owner-btn'
380
- ownerBtn.setAttribute('aria-label', '主人设置')
381
- ownerBtn.title = '主人设置:席位名 / 入网白名单 / 可见性 / 发布'
382
- ownerBtn.textContent = '⚙'
383
- ownerBtn.addEventListener('click', () => { ownerPanelOpen = !ownerPanelOpen; renderOwnerPanel() })
384
- header.appendChild(ownerBtn)
385
- const headerStats = el('div', 'wb-header-stats')
386
- header.appendChild(headerStats)
387
- const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
388
- railToggle.type = 'button'
389
- railToggle.dataset.testid = 'wb-rail-toggle'
390
- railToggle.setAttribute('aria-label', '折叠/展开侧栏')
391
- railToggle.textContent = '◧'
392
- railToggle.addEventListener('click', () => {
393
- root.classList.toggle('is-rail-collapsed')
394
- // The stage width changed while the board fills it → re-sync its view
395
- // size (idempotent; focus side effect is harmless in a shell toggle).
396
- if (mode === 'canvas' && board !== undefined) board.activate()
397
- })
398
- header.appendChild(railToggle)
399
-
400
- const body = el('div', 'wb-body')
401
- // Rail tabs (2026-10-23, wb-rail): one panel per pane, each with its own
402
- // scroll. Panes are created once and re-parented between tab panels
403
- // (canvas mode) and the stage stack (data mode).
404
- const railTabs = createRailTabs(SECTION_DEFS)
405
- const rail = railTabs.root
406
-
407
- const directoryBody = el('div', 'wb-directory')
408
- // Directory toolbar: unified search + category filter chips (0.6.6). Built
409
- // once above the re-rendered list so the input keeps focus and its text
410
- // across polls; filtering is pure frontend over /state.
411
- const directoryTools = el('div', 'wb-dir-tools')
412
- const searchInput = document.createElement('input')
413
- searchInput.className = 'wb-search'
414
- searchInput.type = 'search'
415
- searchInput.placeholder = '搜索:名称 / 队 / 会话 / 节点'
416
- searchInput.setAttribute('aria-label', '统一搜索目录')
417
- searchInput.addEventListener('input', () => {
418
- searchText = searchInput.value.trim().toLowerCase()
419
- renderSidebar()
420
- })
421
- directoryTools.appendChild(searchInput)
422
- const filterRow = el('div', 'wb-filters')
423
- for (const def of DIRECTORY_FILTERS) {
424
- const chip = el('button', 'wb-filter')
425
- chip.type = 'button'
426
- chip.textContent = def.label
427
- chip.dataset.filter = def.key
428
- chip.title = def.hint
429
- chip.addEventListener('click', () => {
430
- dirFilter = def.key
431
- renderSidebar()
432
- })
433
- filterRow.appendChild(chip)
434
- }
435
- directoryTools.appendChild(filterRow)
436
- railTabs.panel('directory').appendChild(directoryTools)
437
- railTabs.panel('directory').appendChild(directoryBody)
438
-
439
- const ledgerPane = el('section', 'wb-pane')
440
- const activityPane = el('section', 'wb-pane')
441
- const planPane = el('section', 'wb-pane')
442
- // gh#20 W-C: the host rail pane (one row per tracked peer).
443
- const hostsPane = el('section', 'wb-pane')
444
- const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
445
- ['ledger', ledgerPane],
446
- ['activity', activityPane],
447
- ['plan', planPane],
448
- ['hosts', hostsPane],
449
- ]
450
-
451
- const stage = el('main', 'wb-stage')
452
- const boardPane = el('section', 'wb-stage-board')
453
- if (board !== undefined) {
454
- boardPane.appendChild(board.root)
455
- stage.appendChild(boardPane)
456
- }
457
- const dataStack = el('div', 'wb-data')
458
-
459
- const inspector = el('aside', 'wb-inspector')
460
- const inspectorHead = el('div', 'wb-inspector-head')
461
- inspectorHead.appendChild(el('span', 'wb-inspector-title')).textContent = '详情'
462
- const inspectorClose = el('button', 'wb-icon-btn')
463
- inspectorClose.type = 'button'
464
- inspectorClose.setAttribute('aria-label', '关闭详情')
465
- inspectorClose.textContent = '✕'
466
- inspectorClose.addEventListener('click', () => select(undefined))
467
- inspectorHead.appendChild(inspectorClose)
468
- inspector.appendChild(inspectorHead)
469
- const inspectorBody = el('div', 'wb-inspector-body')
470
- inspector.appendChild(inspectorBody)
471
-
472
- // ── Node chat drawer (0.6.13; rewritten 2026-10-23 as wb-chat) ──
473
- // Clicking a node — on the planning board or a directory row — opens the
474
- // conversation as a right-side chat: thread + composer + receipt tracking.
475
- const chat = createChatDrawer({
476
- now: deps.now,
477
- sessionLog: deps.sessionLog,
478
- remoteSessionLog: deps.remoteSessionLog,
479
- dispatchQuick: deps.dispatchQuick,
480
- onTranscript: (target, messages) => {
481
- if (target.kind !== 'log' && target.kind !== 'session') return
482
- if (learnLogTitle(logTitles, target.id, messages)) renderSidebar()
483
- },
484
- })
485
-
486
- stage.appendChild(dataStack)
487
- body.appendChild(rail)
488
- body.appendChild(stage)
489
- body.appendChild(inspector)
490
- body.appendChild(chat.root)
491
- root.appendChild(header)
492
- root.appendChild(body)
493
-
494
- /** Move the data panes to their mode home: rail sections while the board
495
- * owns the stage, the stage stack when the board is out. */
496
- function placePanes(m: WorkbenchMode): void {
497
- for (const [key, pane] of dataPanes) {
498
- const home = m === 'canvas' ? railTabs.panel(key) : dataStack
499
- // Data mode: the pane lives on the stage, so its tab steps aside.
500
- railTabs.setHidden(key, m !== 'canvas')
501
- if (pane.parentElement !== home) home.appendChild(pane)
502
- }
503
- }
504
-
505
- function syncMode(): void {
506
- const canvasUp = board !== undefined && board.available()
507
- const next: WorkbenchMode = canvasUp ? 'canvas' : 'data'
508
- root.dataset.mode = next
509
- boardPane.classList.toggle('is-hidden', next !== 'canvas')
510
- dataStack.classList.toggle('is-hidden', next === 'canvas')
511
- placePanes(next)
512
- if (next !== mode) {
513
- if (board !== undefined) {
514
- if (next === 'canvas') board.activate()
515
- else board.deactivate()
516
- }
517
- mode = next
518
- }
519
- }
520
-
521
- function clear(node: HTMLElement): HTMLElement {
522
- while (node.firstChild !== null) node.removeChild(node.firstChild)
523
- return node
524
- }
525
-
526
- function select(next: Selection): void {
527
- selection = next
528
- // Canvas add-member form is cached per team name (draftForm trick):
529
- // drop it whenever the selection moves off its team.
530
- if (next?.kind !== 'canvas' || canvasForm?.name !== next.row.name) canvasForm = null
531
- // Same for the quick-dispatch form: it is cached per target team
532
- // (0.6.15: remote dispatch moved to the chat drawer's own form).
533
- if (next?.kind !== 'canvas') dispatchForm = null
534
- renderInspector()
535
- // Narrow screens: picking from the drawer closes it so the stage shows.
536
- root.classList.remove('is-rail-open')
537
- }
538
-
539
- // ── Directory section ──
540
- function renderSidebar(): void {
541
- // Filter chips reflect the live view state (built once, toggled here).
542
- for (let i = 0; i < filterRow.children.length; i++) {
543
- const node = filterRow.children[i] as HTMLElement
544
- node.classList.toggle('is-active', node.dataset.filter === dirFilter)
545
- }
546
- if (last === null) {
547
- directoryBody.replaceChildren(el('div', 'wb-muted'))
548
- directoryBody.firstElementChild!.textContent = '载入中…'
549
- railTabs.setBadge('directory', '')
550
- return
551
- }
552
- const badge = renderDirectory(directoryBody, {
553
- state: last,
554
- logs: logRows,
555
- logTotal,
556
- titles: logTitles,
557
- search: searchText,
558
- filter: dirFilter,
559
- now: deps.now(),
560
- openChat,
561
- selectCanvas: (row) => select({ kind: 'canvas', row }),
562
- })
563
- railTabs.setBadge('directory', badge)
564
- }
565
-
566
- /**
567
- * gh#20 W-C: the host rail — one row per tracked peer host. Each row is a
568
- * HOST card: operator-facing label (the peer's published card name, host
569
- * fallback), the W-A tri-state badge (link class + verified mark), a
570
- * health lamp driven by the last-contact recency (green ≤60s, amber
571
- * ≤5min, gray older/never — tooltip carries the exact timestamp), and the
572
- * peer's published version chip. Clicking drills down: the peer chat
573
- * drawer (remote log entry) rides the existing face.
574
- */
575
- function renderHosts(): void {
576
- const pane = clear(hostsPane)
577
- if (last === null) {
578
- railTabs.setBadge('hosts', '')
579
- return
580
- }
581
- const now = deps.now()
582
- const peers = [...(last.peers ?? [])].sort((a, b) => {
583
- const rank = (row: { link?: string }): number => row.link === 'linked' ? 0 : 1
584
- return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0)
585
- })
586
- railTabs.setBadge('hosts', peers.length > 0 ? `${peers.filter(row => row.link === 'linked').length}/${peers.length}` : '')
587
- if (peers.length === 0) {
588
- pane.appendChild(el('div', 'wb-nodelist-empty')).textContent = '暂无 tracked peer——配 peers 种子或等 referral 到达'
589
- return
590
- }
591
- for (const row of peers) {
592
- const host = peerHostOf(row.url)
593
- const lineEl = el('div', 'wb-host-row')
594
- // Health lamp (gh#24 semantics): recency alone lied — a FAILED fresh
595
- // contact glowed green. Tiers by age: ≤60s live / ≤5min warm /
596
- // beyond cold, overridden by the OUTCOME: a recent failure (≤5min)
597
- // is red regardless of the live/warm band, and a never-contacted
598
- // peer is the hollow unknown state.
599
- const lamp = el('span', 'wb-host-lamp')
600
- const last = row.lastContactAt
601
- const age = last === undefined ? undefined : now - last
602
- const ok = row.lastContactOk
603
- if (age === undefined) lamp.classList.add('is-unknown')
604
- else if (ok === false && age <= 5 * 60_000) lamp.classList.add('is-fail')
605
- else if (age <= 60_000) lamp.classList.add('is-live')
606
- else if (age <= 5 * 60_000) lamp.classList.add('is-warm')
607
- else lamp.classList.add('is-cold')
608
- lamp.title = last === undefined
609
- ? '尚无接触记录'
610
- : `最近接触 ${ageText(last, now)}${ok === false ? '(失败)' : ok === true ? '(成功)' : ''}`
611
- lineEl.appendChild(lamp)
612
- // Label: the peer's published name, host fallback.
613
- lineEl.appendChild(el('span', 'wb-host-name')).textContent = row.name !== undefined && row.name !== '' ? row.name : host
614
- // W-A badge: link class + verified mark.
615
- const badge = el('span', `wb-host-badge ${row.link === 'linked' ? 'is-linked' : 'is-stranger'}`)
616
- badge.textContent = `${row.link === 'linked' ? 'linked' : 'stranger'} ${row.verified === true ? '✓' : '✗'}`
617
- // The tooltip follows the verified mark (gh#24): a linked ✗ must not
618
- // claim a valid signature.
619
- badge.title = row.link === 'linked'
620
- ? `已建立运营者链接(互种或 canvas 引用);${row.verified === true ? '验签有效' : '尚未持有验签通过的卡片'}`
621
- : '无运营者关系(验签 ≠ 关系)'
622
- lineEl.appendChild(badge)
623
- // Version chip from the warm card.
624
- if (row.version !== undefined && row.version !== '') {
625
- lineEl.appendChild(el('span', 'wb-host-ver')).textContent = row.version
626
- }
627
- lineEl.appendChild(el('span', 'wb-host-url mono')).textContent = host
628
- lineEl.setAttribute('role', 'button')
629
- lineEl.tabIndex = 0
630
- lineEl.title = `${row.url} · score ${String(row.score ?? 0)}`
631
- const drill = (): void => { openChat({ kind: 'peer', id: `peer-${host}`, title: row.name !== undefined && row.name !== '' ? row.name : host, sub: host, url: row.url }) }
632
- lineEl.addEventListener('click', drill)
633
- lineEl.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); drill() } })
634
- pane.appendChild(lineEl)
635
- }
636
- }
637
-
638
- // ── Ledger pane (wb-ledger) ──
639
- function renderLedger(): void {
640
- if (last === null) { ledgerPane.replaceChildren(); railTabs.setBadge('ledger', ''); return }
641
- const state = last
642
- const resolve = (team: string): ChatTarget | undefined => {
643
- const session = (state.sessions ?? []).find(row => row.team === team)
644
- if (session !== undefined) return { kind: 'session', id: session.id, title: displayName(session), sub: session.team, team: session.team, live: session.live }
645
- const remote = (state.remote ?? []).find(row => row.team === team)
646
- if (remote !== undefined) return { kind: 'remote', team: remote.team, title: displayName(remote), sub: remote.team, row: remote }
647
- return undefined
648
- }
649
- const out = paintLedger(ledgerPane, {
650
- state,
651
- now: deps.now(),
652
- abandon: (taskId, reason) => deps.abandon(taskId, reason),
653
- canOpenTeam: (team) => resolve(team) !== undefined,
654
- openTeam: (team) => {
655
- const target = resolve(team)
656
- if (target === undefined) return false
657
- openChat(target)
658
- return true
659
- },
660
- })
661
- railTabs.setBadge('ledger', out.badge, out.tone)
662
- }
663
-
664
- // ── Activity pane ──
665
- /** A team cell: the name when known (wb-names), else the id. */
666
- function teamCell(line: HTMLElement, state: StateBody, team: string): void {
667
- nameInto(line.appendChild(el('span', 'wb-row-team')), state, team)
668
- }
669
-
670
- function renderActivity(): void {
671
- const pane = clear(activityPane)
672
- if (last === null) return
673
- const now = deps.now()
674
- const flights = last.inFlight ?? []
675
- railTabs.setBadge('activity', flights.length > 0 ? String(flights.length) : '', flights.length > 0 ? 'accent' : undefined)
676
- const head = el('div', 'wb-pane-head')
677
- head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
678
- pane.appendChild(head)
679
- if (flights.length > 0) {
680
- const sub = el('div', 'wb-pane-subhead')
681
- sub.textContent = '在途路由'
682
- pane.appendChild(sub)
683
- for (const row of flights) {
684
- const line = el('div', 'wb-row')
685
- line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
686
- teamCell(line, last, row.team)
687
- line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
688
- pane.appendChild(line)
689
- }
690
- }
691
- const ringHead = el('div', 'wb-pane-subhead')
692
- ringHead.textContent = '活动环(最新在前)'
693
- pane.appendChild(ringHead)
694
- const ring = last.activity ?? []
695
- if (ring.length === 0) {
696
- pane.appendChild(el('div', 'wb-muted')).textContent = '暂无活动'
697
- return
698
- }
699
- for (const row of ring) {
700
- const line = el('div', 'wb-row')
701
- const dir = el('span', 'wb-row-dir')
702
- dir.textContent = row.dir === 'fwd' ? '⇢' : row.dir === 'out' ? '→' : '←'
703
- if (row.ok !== true) dir.classList.add('is-fail')
704
- line.appendChild(dir)
705
- teamCell(line, last, row.team)
706
- line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
707
- line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
708
- pane.appendChild(line)
709
- }
710
- }
711
-
712
- // ── Plan pane: derived actions + dispatch drafting ──
713
- function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
714
- const now = deps.now()
715
- const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
716
- // One definition with the 台账 tab: fire-forget expiries are bookkeeping,
717
- // not dead letters (review L1), and only OUR stale sends are nudgeable.
718
- const facts = ledgerFacts(state, now)
719
- if (facts.deadLetters.length > 0) {
720
- actions.push({ severity: 'high', text: `清账 ${facts.deadLetters.length} 条死信(目标早已离开或死信化,无回执可期)` })
721
- }
722
- // (The old `age > 1h && settleable === true` never fired against a real
723
- // host — the server's settleable means receipt-expected AND age < 1h.)
724
- for (const row of facts.stale) {
725
- // Quote the original ask: a nudge that only repeats a task id makes
726
- // the target re-derive WHAT was owed, and the target is exactly the
727
- // session that already failed to answer once.
728
- const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
729
- actions.push({
730
- severity: 'normal',
731
- text: `催办 ${row.taskId} → ${teamLabel(state, row.team)}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
732
- brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
733
- })
734
- }
735
- // Cold sessions are one action, not one per session: they share the same
736
- // remedy (open them in the GUI), and a list of identical lines buried the
737
- // actions that differ.
738
- const cold = (state.sessions ?? []).filter(row => row.live !== true).map(row => displayName(row))
739
- if (cold.length > 0) {
740
- const names = cold.slice(0, 3).join('、') + (cold.length > 3 ? ` 等 ${cold.length} 个` : '')
741
- actions.push({ severity: 'normal', text: `冷会话 ${cold.length} 个(${names}):在 GUI 打开即可唤醒加入面` })
742
- }
743
- for (const row of state.reconcile?.rows ?? []) {
744
- if (row.needsRepair === true) {
745
- // Name the session; the id stays for the operator who greps logs.
746
- const s = (state.sessions ?? []).find(x => x.id === row.id)
747
- const who = s !== undefined ? `${displayName(s)}(${row.id})` : row.id
748
- actions.push({ severity: 'high', text: `reconcile 需修复:${who} ${row.error}` })
749
- }
750
- }
751
- const conflicts = state.idempotency?.conflicts ?? 0
752
- if (conflicts > 0) {
753
- actions.push({ severity: 'high', text: `幂等冲突 ${conflicts} 例:人工核查 UNIQUE-ization 流量` })
754
- }
755
- return actions
756
- }
757
-
758
- function renderPlan(): void {
759
- const pane = clear(planPane)
760
- if (last === null) return
761
- const actions = planActions(last)
762
- railTabs.setBadge('plan', actions.length > 0 ? String(actions.length) : '', actions.some(a => a.severity === 'high') ? 'danger' : undefined)
763
- const head = el('div', 'wb-pane-head')
764
- head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
765
- pane.appendChild(head)
766
- for (const action of actions) {
767
- const line = el('div', `wb-action${action.severity === 'high' ? ' wb-action-high' : ''}`)
768
- line.appendChild(el('span', 'wb-action-text')).textContent = action.text
769
- if (action.brief !== undefined) {
770
- const btn = el('button', 'wb-btn')
771
- btn.type = 'button'
772
- btn.textContent = '复制催办'
773
- btn.addEventListener('click', () => deps.copy(action.brief ?? ''))
774
- line.appendChild(btn)
775
- }
776
- pane.appendChild(line)
777
- }
778
- // Dispatch drafting — static inner form so textarea survives updates.
779
- const draftHead = el('div', 'wb-pane-subhead')
780
- draftHead.textContent = '派单起草(复制后在任一会话粘贴执行)'
781
- pane.appendChild(draftHead)
782
- pane.appendChild(draftForm)
783
- }
784
-
785
- // Dispatch draft form: created ONCE; renderPlan re-attaches the same node.
786
- const draftForm = el('div', 'wb-draft')
787
- const draftTarget = document.createElement('select')
788
- draftTarget.className = 'wb-select'
789
- const draftInput = document.createElement('textarea')
790
- draftInput.className = 'wb-textarea'
791
- draftInput.rows = 3
792
- draftInput.placeholder = '任务正文:目标、验收、期限…'
793
- const draftBtn = el('button', 'wb-btn wb-btn-primary')
794
- draftBtn.type = 'button'
795
- draftBtn.textContent = '复制任务书'
796
- draftBtn.addEventListener('click', () => {
797
- const target = draftTarget.value
798
- const message = draftInput.value.trim()
799
- if (target === '' || message === '') return
800
- deps.copy(`a2a_route 派单 → ${target}:\n${message}`)
801
- })
802
- draftForm.appendChild(draftTarget)
803
- draftForm.appendChild(draftInput)
804
- draftForm.appendChild(draftBtn)
805
-
806
- function renderDraftTargets(): void {
807
- const previous = draftTarget.value
808
- clear(draftTarget)
809
- const empty = document.createElement('option')
810
- empty.value = ''
811
- empty.textContent = '选择目标队…'
812
- draftTarget.appendChild(empty)
813
- if (last === null) return
814
- const state = last
815
- const targets = new Map<string, string>()
816
- for (const row of state.sessions ?? []) {
817
- if (row.team !== undefined && row.team !== '') targets.set(row.team, `${displayName(row)}(会话队)`)
818
- }
819
- for (const row of state.canvas?.teams ?? []) targets.set(row.team, `${row.name}(canvas 队)`)
820
- for (const row of state.remote ?? []) targets.set(row.team, `${displayName(row)}(远端)`)
821
- for (const [team, label] of targets) {
822
- const opt = document.createElement('option')
823
- opt.value = team
824
- opt.textContent = `${label} · ${team}`
825
- draftTarget.appendChild(opt)
826
- }
827
- if (previous !== '' && targets.has(previous)) draftTarget.value = previous
828
- }
829
-
830
- // ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
831
- // gh#34 owner panel state + render (the gear toggles; the inspector hosts).
832
- let seatEditor: HTMLElement | undefined
833
- let ownerPanelOpen = false
834
- let ownerState: { seatNames: Record<string, string>; teamJoinAllowlist: string[]; teamScopeInfo: boolean; announce: boolean; announceRestartPending: boolean } | null = null
835
- function renderOwnerPanel(): void {
836
- renderInspector()
837
- if (!ownerPanelOpen) return
838
- void deps.ownerSettingsRead?.().then(result => {
839
- if (!ownerPanelOpen) return
840
- if (result.ok !== true) { ownerState = null; renderInspector(); return }
841
- seatEditor = undefined
842
- ownerState = {
843
- seatNames: result.seatNames ?? {},
844
- teamJoinAllowlist: result.teamJoinAllowlist ?? [],
845
- teamScopeInfo: result.teamScopeInfo !== false,
846
- announce: result.announce === true,
847
- announceRestartPending: result.announceRestartPending === true,
848
- }
849
- renderInspector()
850
- })
851
- }
852
- function renderOwnerPanelInto(box: HTMLElement): void {
853
- const card = el('div', 'wb-card')
854
- const head = el('div', 'wb-inspector-head')
855
- head.appendChild(el('span', 'wb-inspector-title')).textContent = '主人设置'
856
- const closeBtn = el('button', 'wb-icon-btn')
857
- closeBtn.type = 'button'
858
- closeBtn.setAttribute('aria-label', '关闭主人设置')
859
- closeBtn.textContent = '✕'
860
- closeBtn.addEventListener('click', () => { ownerPanelOpen = false; renderInspector() })
861
- head.appendChild(closeBtn)
862
- card.appendChild(head)
863
- if (deps.ownerSettingsRead === undefined) {
864
- const note = el('div', 'wb-hint')
865
- note.textContent = '此宿主未提供主人设置面(旧版本)——升级后可用。'
866
- card.appendChild(note)
867
- box.appendChild(card)
868
- return
869
- }
870
- if (ownerState === null) {
871
- const note = el('div', 'wb-hint')
872
- note.textContent = '读取中…'
873
- card.appendChild(note)
874
- box.appendChild(card)
875
- return
876
- }
877
- const st = ownerState
878
- seatEditor ??= createSeatNameEditor(last?.sessions ?? [], st.seatNames, async seatNames => {
879
- const result = await deps.ownerSettingsWrite?.({ seatNames })
880
- if (result?.ok) { st.seatNames = seatNames; return { ok: true } }
881
- return { ok: false, error: result?.error ?? '保存失败' }
882
- })
883
- card.appendChild(seatEditor)
884
- // ① 入网白名单(精确团队名增删)
885
- const alHead = el('div', 'wb-side-head')
886
- alHead.textContent = '入网白名单(精确团队名)'
887
- card.appendChild(alHead)
888
- for (const name of st.teamJoinAllowlist) {
889
- const row = el('div', 'wb-member-row')
890
- row.appendChild(el('span', 'wb-member-label')).textContent = name
891
- const del = el('button', 'wb-member-remove')
892
- del.type = 'button'
893
- del.textContent = '移除'
894
- del.addEventListener('click', () => {
895
- void deps.ownerSettingsWrite?.({ teamJoinAllowlist: st.teamJoinAllowlist.filter(n => n !== name) }).then(() => renderOwnerPanel())
896
- })
897
- row.appendChild(del)
898
- card.appendChild(row)
899
- }
900
- const addForm = el('div', 'wb-member-add')
901
- const input = el('input', 'wb-member-manual')
902
- input.type = 'text'
903
- input.placeholder = 'dsh/团队名(精确,无通配)'
904
- input.setAttribute('aria-label', '新增白名单团队名')
905
- const addBtn = el('button', 'wb-member-submit')
906
- addBtn.type = 'button'
907
- addBtn.textContent = '加入'
908
- const doAdd = (): void => {
909
- const v = input.value.trim()
910
- if (v === '' || st.teamJoinAllowlist.includes(v)) return
911
- void deps.ownerSettingsWrite?.({ teamJoinAllowlist: [...st.teamJoinAllowlist, v] }).then(() => renderOwnerPanel())
912
- }
913
- addBtn.addEventListener('click', doAdd)
914
- input.addEventListener('keydown', ev => { if (ev.key === 'Enter') doAdd() })
915
- addForm.append(input, addBtn)
916
- card.appendChild(addForm)
917
- // ② 可见性开关 + 精度固化文案
918
- const tsHead = el('div', 'wb-side-head')
919
- tsHead.textContent = '目录可见性(teamScopeInfo)'
920
- card.appendChild(tsHead)
921
- const tsRow = el('div', 'wb-member-row')
922
- const tsBtn = el('button', 'wb-member-submit')
923
- tsBtn.type = 'button'
924
- tsBtn.textContent = st.teamScopeInfo ? '已开:仅显示已加入团队(点击放开为全舰队可见)' : '已关:全舰队可见(点击收窄为已加入团队)'
925
- tsBtn.addEventListener('click', () => {
926
- void deps.ownerSettingsWrite?.({ teamScopeInfo: !st.teamScopeInfo }).then(() => renderOwnerPanel())
927
- })
928
- tsRow.appendChild(tsBtn)
929
- card.appendChild(tsRow)
930
- const tsNote = el('div', 'wb-hint')
931
- tsNote.textContent = '精度说明:teamScopeInfo 只管「目录显示范围」。关上它不会解锁任何通信准入——会话入网(S3)与目标级路由(F5)规则原样生效。'
932
- card.appendChild(tsNote)
933
- // ③ 发布开关(重启生效)
934
- const anHead = el('div', 'wb-side-head')
935
- anHead.textContent = '节点发布(announce)'
936
- card.appendChild(anHead)
937
- const anRow = el('div', 'wb-member-row')
938
- const anBtn = el('button', 'wb-member-submit')
939
- anBtn.type = 'button'
940
- anBtn.textContent = st.announce ? '已开:本节点发布签名卡(点击关闭)' : '已关:本节点不发布(点击开启——重启宿主后生效)'
941
- anBtn.addEventListener('click', () => {
942
- void deps.ownerSettingsWrite?.({ announce: !st.announce }).then(() => renderOwnerPanel())
943
- })
944
- anRow.appendChild(anBtn)
945
- card.appendChild(anRow)
946
- if (st.announceRestartPending) {
947
- const anNote = el('div', 'wb-hint')
948
- anNote.textContent = '有 announce 变更待生效:重启宿主后按新配置发布。'
949
- card.appendChild(anNote)
950
- }
951
- box.appendChild(card)
952
- }
953
-
954
- function renderInspector(): void {
955
- const box = clear(inspectorBody)
956
- // gh#34: the owner panel owns the inspector when open (selection cards
957
- // yield; the gear closes it back).
958
- if (ownerPanelOpen) { inspector.classList.add('is-open'); renderOwnerPanelInto(box); return }
959
- inspector.classList.toggle('is-open', selection !== undefined)
960
- if (selection === undefined) return
961
- const sel = selection
962
- const card = el('div', 'wb-card')
963
- // 0.6.13: session/log/peer faces moved to the chat drawer; 0.6.15 took
964
- // the remote rows with them (their metadata rides the chat head) — the
965
- // inspector keeps the canvas team card.
966
- if (sel.kind === 'canvas') {
967
- // Re-resolve the team from the freshest state: membership changes here
968
- // must be visible on the next render even though the click-time row is
969
- // a stale snapshot.
970
- const fresh = (last?.canvas?.teams ?? []).find(t => t.name === sel.row.name) ?? sel.row
971
- textRow(card, 'Canvas 队', fresh.name)
972
- textRow(card, '路由队', fresh.team)
973
- const head = el('div', 'wb-side-head')
974
- head.textContent = `成员 · ${fresh.members.length}`
975
- card.appendChild(head)
976
- if (fresh.members.length === 0) {
977
- const empty = el('div', 'wb-muted')
978
- empty.textContent = '空队——用下方表单添加成员'
979
- card.appendChild(empty)
980
- }
981
- for (const member of fresh.members) {
982
- const row = el('div', 'wb-member-row')
983
- const dot = member.cliHeld === true ? '本机持有,无需指定' : member.remote === true ? (member.cliHome === null ? '◇ 远端 · home 未确定' : '◇ 远端') : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
984
- row.appendChild(el('span', 'wb-member-dot')).textContent = dot
985
- const label = el('span', 'wb-member-label')
986
- const session = (last?.sessions ?? []).find(s => s.id === member.id)
987
- label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
988
- label.title = member.cliHeld === true ? '本机持有,无需指定' : member.cliHome !== undefined ? (member.cliHome ?? 'home 未确定') : (member.team ?? member.id)
989
- row.appendChild(label)
990
- const remove = el('button', 'wb-member-remove')
991
- remove.type = 'button'
992
- remove.textContent = '移除'
993
- remove.addEventListener('click', async () => {
994
- remove.disabled = true
995
- if (!(await deps.canvasOp('remove-member', fresh.name, member.id))) remove.disabled = false
996
- })
997
- row.appendChild(remove)
998
- card.appendChild(row)
999
- if (member.cliHomeCleared === true) {
1000
- const notice = el('div', 'wb-hint')
1001
- notice.textContent = '本机落户已清除旧 home 钉;户口离线后也不会恢复旧钉。'
1002
- card.appendChild(notice)
1003
- }
1004
- if (member.cliHome === null && member.cliHeld !== true && deps.cliHomeOptions !== undefined) {
1005
- const key = JSON.stringify([fresh.name, member.id])
1006
- let form = cliHomeForms.get(key)
1007
- if (form === undefined) {
1008
- form = createCliHomePicker(() => deps.cliHomeOptions!(fresh.name, member.id), home => deps.canvasOp('add-member', fresh.name, member.id, home))
1009
- cliHomeForms.set(key, form)
1010
- }
1011
- card.appendChild(form)
1012
- }
1013
- }
1014
- // Add-member form: static DOM re-mounted per poll (draftForm trick) —
1015
- // rebuilt only when the inspected team changes.
1016
- if (canvasForm?.name !== fresh.name) {
1017
- const form = el('form', 'wb-member-add')
1018
- const pick = document.createElement('select')
1019
- pick.className = 'wb-member-pick'
1020
- pick.setAttribute('aria-label', '选择成员')
1021
- const manual = document.createElement('input')
1022
- manual.type = 'text'
1023
- manual.className = 'wb-member-manual'
1024
- manual.placeholder = '或输入远端句柄 zone/id8'
1025
- manual.setAttribute('aria-label', '远端成员句柄')
1026
- const submit = el('button', 'wb-member-submit')
1027
- submit.type = 'submit'
1028
- submit.textContent = '添加'
1029
- form.appendChild(pick)
1030
- form.appendChild(manual)
1031
- form.appendChild(submit)
1032
- form.addEventListener('submit', async ev => {
1033
- ev.preventDefault()
1034
- const handle = manual.value.trim()
1035
- const id = handle !== '' ? handle : pick.value
1036
- if (id === '') return
1037
- submit.disabled = true
1038
- const ok = await deps.canvasOp('add-member', fresh.name, id)
1039
- submit.disabled = false
1040
- if (ok) manual.value = ''
1041
- })
1042
- canvasForm = { name: fresh.name, form, pick, manual }
1043
- }
1044
- // Refresh the picker's options from the freshest state, preserving the
1045
- // current choice (sessions/remote rows churn across polls).
1046
- const pick = canvasForm.pick
1047
- const keep = pick.value
1048
- clear(pick)
1049
- pick.appendChild(new Option('本机会话(现场/冷启动)…', '', true, true))
1050
- const memberIds = new Set(fresh.members.map(member => member.id))
1051
- for (const session of last?.sessions ?? []) {
1052
- if (memberIds.has(session.id)) continue
1053
- pick.appendChild(new Option(`${displayName(session)} — 本机`, session.id))
1054
- }
1055
- let remoteLabeled = false
1056
- for (const remote of last?.remote ?? []) {
1057
- // gh#37: only zone/id8 HANDLES are addable members — a bare zone row
1058
- // (the peer's own team card, e.g. 'dsh') fails the backend shape
1059
- // gate and the old picker offered it anyway: the owner clicked 远端,
1060
- // got refused, and read the feature as broken. Rows without a '/'
1061
- // are skipped; when the directory carries no handles at all the
1062
- // hint explains why (unlinked peers serve the minimal card, which
1063
- // carries no session teams) instead of offering dead options.
1064
- if (!remote.team.includes('/')) continue
1065
- if (memberIds.has(remote.team)) continue
1066
- if (!remoteLabeled) {
1067
- pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
1068
- remoteLabeled = true
1069
- }
1070
- pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
1071
- }
1072
- if (!remoteLabeled) {
1073
- pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
1074
- }
1075
- if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
1076
- card.appendChild(canvasForm.form)
1077
- const hint = el('div', 'wb-hint')
1078
- hint.textContent =
1079
- '成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
1080
- card.appendChild(hint)
1081
- // Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
1082
- // first, then the remote members in order — the seam's own priority.
1083
- if (deps.dispatchQuick !== undefined) card.appendChild(dispatchCard(fresh.team))
1084
- }
1085
- box.appendChild(card)
1086
- }
1087
-
1088
- // ── Quick dispatch (0.6.9) ──
1089
- // One human-typed message to a routing team, no session opened. The form
1090
- // is cached per target team (draftForm trick) so the textarea draft and
1091
- // the result note survive the 3s poll remount.
1092
- function buildDispatchForm(target: string): { form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } {
1093
- const form = el('form', 'wb-dispatch-form')
1094
- const text = document.createElement('textarea')
1095
- text.className = 'wb-dispatch-text'
1096
- text.placeholder = '一句话任务…(≤4000 字符,异步送达,回执进台账)'
1097
- text.setAttribute('aria-label', '快派消息')
1098
- const submit = el('button', 'wb-dispatch-submit')
1099
- submit.type = 'submit'
1100
- submit.textContent = '发送'
1101
- const note = el('div', 'wb-dispatch-note')
1102
- form.appendChild(text)
1103
- form.appendChild(submit)
1104
- form.appendChild(note)
1105
- // 0.6.17 chat-UI conventions (deepseek-harness composer): Enter sends,
1106
- // Shift+Enter is a soft newline, IME composition is never hijacked.
1107
- text.addEventListener('keydown', ev => {
1108
- if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
1109
- if (ev.isComposing) return
1110
- ev.preventDefault()
1111
- if (!submit.disabled) form.requestSubmit()
1112
- })
1113
- form.addEventListener('submit', async ev => {
1114
- ev.preventDefault()
1115
- const message = text.value.trim()
1116
- if (message === '' || submit.disabled) return
1117
- submit.disabled = true
1118
- note.textContent = '派发中…'
1119
- let outcome: { ok: boolean; task_id?: string; reply?: string; error?: string }
1120
- try {
1121
- outcome = await (deps.dispatchQuick?.(target, message) ?? Promise.resolve({ ok: false, error: 'dispatch unavailable' }))
1122
- } catch (error) {
1123
- outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
1124
- }
1125
- submit.disabled = false
1126
- if (outcome.ok === true) {
1127
- // Sent: clear the composer like every chat surface and hand the
1128
- // caret back — the submit round-trip must not strand focus.
1129
- text.value = ''
1130
- }
1131
- text.focus({ preventScroll: true })
1132
- note.textContent = outcome.ok === true
1133
- ? `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账(待回执)`
1134
- : `失败:${outcome.error ?? '未知原因'}`
1135
- })
1136
- return { form, text, note }
1137
- }
1138
-
1139
- function dispatchCard(target: string): HTMLElement {
1140
- const wrap = el('div', 'wb-dispatch')
1141
- const head = el('div', 'wb-side-head')
1142
- head.textContent = '快派'
1143
- wrap.appendChild(head)
1144
- if (dispatchForm?.team !== target) dispatchForm = { team: target, ...buildDispatchForm(target) }
1145
- wrap.appendChild(dispatchForm.form)
1146
- return wrap
1147
- }
1148
-
1149
- function openChat(target: ChatTarget): void {
1150
- chat.open(target)
1151
- root.classList.remove('is-rail-open')
1152
- }
1153
-
1154
- /** Board picks (0.6.13): resolve a node id against the latest state. */
1155
- function openNodeChat(id: string): void {
1156
- if (last === null) return
1157
- const session = (last.sessions ?? []).find(row => row.id === id)
1158
- if (session !== undefined) {
1159
- openChat({ kind: 'session', id, title: displayName(session), sub: session.team, team: session.team, live: session.live })
1160
- return
1161
- }
1162
- if (id.startsWith('peer-')) {
1163
- const host = id.slice('peer-'.length)
1164
- const peer = (last.peers ?? []).find(row => row.url.includes(host))
1165
- openChat({ kind: 'peer', id, title: peer !== undefined ? peer.url : host, sub: host, url: peer?.url })
1166
- return
1167
- }
1168
- const log = logRows.find(row => row.id === id)
1169
- if (log !== undefined) openChat({ kind: 'log', id, title: `…${id.slice(-12)}`, sub: '档案' })
1170
- }
1171
-
1172
- // ── Header ──
1173
- // Results first (owner signal, 0.6.24): the header carries the verdict —
1174
- // exceptions only, one pill each; a healthy host says so quietly. The
1175
- // forensic counters that used to sit here (idempotency claims/replays,
1176
- // reconcile state) read on the ledger's 取证 line instead.
1177
- function renderHeader(): void {
1178
- // An unknown version says nothing: hide the badge rather than print 'v?'.
1179
- versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : ''
1180
- versionBadge.hidden = last?.version === undefined
1181
- const box = clear(headerStats)
1182
- if (last === null) return
1183
- // Each pill is a way in: it opens the tab (and ledger group) that holds
1184
- // the rows behind its count.
1185
- const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string; tab: 'ledger' | 'plan' | 'activity'; group?: string }> = []
1186
- // Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
1187
- // expiries are not dead letters, receiptless rows are never stale.
1188
- const facts = ledgerFacts(last, deps.now())
1189
- const dead = facts.deadLetters.length
1190
- if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账', tab: 'ledger', group: '死信' })
1191
- const stale = facts.stale.length
1192
- if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)', tab: 'ledger', group: '陈旧' })
1193
- const owed = facts.owedOverdue.length
1194
- if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时', tab: 'ledger', group: '待本机回执' })
1195
- const conflicts = last.idempotency?.conflicts ?? 0
1196
- if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查', tab: 'ledger', group: '取证' })
1197
- const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
1198
- if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划', tab: 'plan' })
1199
- if (alerts.length === 0) {
1200
- box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
1201
- return
1202
- }
1203
- for (const a of alerts) {
1204
- const pill = el('button', `wb-alert wb-alert-${a.tone}`)
1205
- pill.type = 'button'
1206
- pill.textContent = a.text
1207
- pill.title = `${a.title}(点击查看)`
1208
- pill.addEventListener('click', () => openAlert(a.tab, a.group))
1209
- box.appendChild(pill)
1210
- }
1211
- }
1212
-
1213
- /** Open the pane behind a header pill. With the board up the panes are
1214
- * rail tabs (on a narrow screen the rail slides in first); without it they
1215
- * sit on the stage. A ledger group (or the 取证 fold) scrolls into view. */
1216
- function openAlert(tab: 'ledger' | 'plan' | 'activity', group?: string): void {
1217
- const pane = dataPanes.find(([k]) => k === tab)![1]
1218
- if (mode === 'canvas') {
1219
- root.classList.add('is-rail-open')
1220
- railTabs.select(tab, { focus: false })
1221
- }
1222
- const target = group === undefined ? pane
1223
- : group === '取证' ? pane.querySelector<HTMLDetailsElement>('details.wb-led-forensics')
1224
- : Array.from(pane.querySelectorAll<HTMLElement>('.wb-led-group')).find(g => g.dataset.group === group) ?? null
1225
- if (target === null) return
1226
- if (target instanceof HTMLDetailsElement) target.open = true
1227
- target.scrollIntoView?.({ block: 'start', behavior: 'smooth' })
1228
- }
1229
-
1230
- function update(state: StateBody | null): void {
1231
- const unknown = new Set((state?.canvas?.teams ?? []).flatMap(team => team.members.filter(member => member.cliHome === null && member.cliHeld !== true).map(member => JSON.stringify([team.name, member.id]))))
1232
- for (const key of cliHomeForms.keys()) if (!unknown.has(key)) cliHomeForms.delete(key)
1233
- last = state
1234
- // 0.6.12: refresh the readable-log list opportunistically (single flight;
1235
- // the sidebar re-renders when rows land, mid-poll remount is idempotent).
1236
- if (deps.sessionLogList !== undefined && !logRowsBusy) {
1237
- logRowsBusy = true
1238
- deps.sessionLogList().then(result => {
1239
- logRows = result.ok && result.sessions !== undefined ? result.sessions : []
1240
- logTotal = result.ok ? result.count : undefined
1241
- }).catch(() => {}).finally(() => {
1242
- logRowsBusy = false
1243
- renderSidebar()
1244
- })
1245
- }
1246
- renderHeader()
1247
- renderSidebar()
1248
- renderDraftTargets()
1249
- renderHosts()
1250
- renderLedger()
1251
- // The open chat drawer refreshes in place (head + receipt statuses); its
1252
- // thread and composer are never rebuilt by the poll.
1253
- chat.update(state)
1254
- renderActivity()
1255
- renderPlan()
1256
- renderInspector()
1257
- // Mode is availability-derived every poll: canvas face down → data mode,
1258
- // the board deactivates and the panes return to the stage stack.
1259
- syncMode()
1260
- }
1261
-
1262
- function selectTab(key: WorkbenchTab): void {
1263
- if (key === 'board') {
1264
- syncMode()
1265
- return
1266
- }
1267
- railTabs.select(key)
1268
- }
1269
-
1270
- update(null)
1271
- return { root, update, mode: () => mode, selectTab, openNodeChat }
1272
- }
1
+ /**
2
+ * The A2A workbench: one DOM client UI for human AI researchers and agents
3
+ * to OPERATE the network. Since 0.6.2 the 2D spatial planning board and the
4
+ * operational panels are MERGED into one responsive shell — the board is the
5
+ * stage (canvas mode, default while the host serves the canvas face) and the
6
+ * directory/ledger/activity/plan panels ride a collapsible side rail; when
7
+ * the canvas face is down the panels become the main stack (data mode).
8
+ * Narrow screens turn the rail into a drawer and the inspector into a
9
+ * bottom sheet (CSS media queries only — the DOM contract is stable).
10
+ *
11
+ * Discipline (inherited from the planning view it absorbed):
12
+ * - Pure DOM, no WebGL, no three, no framework: every row is a textContent
13
+ * write, so attacker-shaped labels stay text.
14
+ * - The workbench never fabricates capability: dead-letter rows act through
15
+ * the real abandon seam (deps), dispatch only DRAFTS a pasteable brief.
16
+ * - update() re-renders data regions wholesale but never touches the
17
+ * dispatch textarea (user input survives polls); rail collapse, section
18
+ * state, and the active mode are view state kept across updates.
19
+ * @module nexus-stage/workbench
20
+ */
21
+ import { createChatDrawer, type ChatTarget } from './wb-chat'
22
+ import { createSeatNameEditor } from './seat-name-editor'
23
+ import { createCliHomePicker, type CliHomeOptions } from './cli-home-picker'
24
+ import { createRailTabs } from './wb-rail'
25
+ import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
26
+ import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
27
+ import { nameInto, teamLabel } from './wb-names'
28
+
29
+ /** One pending ledger row from /state `tasks`. */
30
+ export interface PendingRow {
31
+ readonly taskId: string
32
+ readonly team: string
33
+ readonly peer?: string
34
+ readonly startedAt: number
35
+ readonly status: string
36
+ readonly direction?: string
37
+ readonly receiptExpected?: boolean
38
+ readonly settleable?: boolean
39
+ /**
40
+ * First line of the dispatched message. A row without it is unreadable:
41
+ * `direct-8f3a1c2b · dsh · 3h` says nothing about whether to nudge,
42
+ * abandon, or go read the receipt — the operator's whole question is
43
+ * WHAT is owed. Absent on rows persisted before the field existed.
44
+ */
45
+ readonly brief?: string
46
+ }
47
+
48
+ /** One dead-lettered row from /state `tasksDead`. */
49
+ export interface DeadRow {
50
+ readonly taskId: string
51
+ readonly team: string
52
+ readonly peer?: string
53
+ readonly startedAt: number
54
+ readonly deadAt: number
55
+ readonly reason?: string
56
+ /** First line of the dispatched message (see {@link PendingRow.brief}). */
57
+ readonly brief?: string
58
+ /** gh#31: which side of the wire this row served (absent on old hosts). */
59
+ readonly direction?: 'inbound' | 'outbound'
60
+ }
61
+
62
+ /** gh#31: one archived row from /state `archived` (newest first) — the
63
+ * per-task outcome for a task that left the ledger. Fields past `summary`
64
+ * are absent on hosts that predate them. */
65
+ export interface ArchivedRow {
66
+ readonly taskId: string
67
+ readonly team: string
68
+ readonly resolvedAt: number
69
+ readonly outcome: string
70
+ readonly summary?: string
71
+ readonly peer?: string
72
+ readonly startedAt?: number
73
+ /** How the row left the book: a receipt, a content echo, or the caller
74
+ * (abandon / cancel — both archive with outcome 'abandoned'). */
75
+ readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
76
+ /** A receipt arrived after the caller had already ended the row. */
77
+ readonly late?: boolean
78
+ /** gh#68: present (false) only when the row never expected a receipt — a
79
+ * fire-forget send; absent on receipt-expected rows and older hosts. */
80
+ readonly receiptExpected?: false
81
+ readonly direction?: 'inbound' | 'outbound'
82
+ readonly brief?: string
83
+ }
84
+
85
+ /** gh#41: one tracked peer's published canvas organization (full-card
86
+ * canvasOrg) — the board renders REMOTE team frames from these rows. */
87
+ export interface PeerOrgRow {
88
+ readonly url: string
89
+ readonly unreachable?: boolean
90
+ readonly org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }>
91
+ }
92
+
93
+ /** One session row from /state `sessions` (live or cold joined). */
94
+ export interface SessionRow {
95
+ readonly id: string
96
+ readonly label: string
97
+ readonly team: string
98
+ readonly name?: string
99
+ readonly seatName?: string
100
+ readonly description?: string
101
+ readonly workspace?: string
102
+ readonly joined?: boolean
103
+ readonly live?: boolean
104
+ /** Final scoped assembly lacks at least one registered node section (#174). */
105
+ readonly nodePromptMissing?: boolean
106
+ readonly teams?: readonly string[]
107
+ /** Canvas teams this session is a member of (0.6.6 roster annotation). */
108
+ readonly canvasTeams?: readonly string[]
109
+ /** @deprecated 0.6.6 — display-only taxonomy superseded by canvas teams. */
110
+ readonly group?: string
111
+ }
112
+
113
+ /** One remote team row from /state `remote`. */
114
+ export interface RemoteRow {
115
+ readonly team: string
116
+ readonly unreachable?: boolean
117
+ readonly name?: string
118
+ readonly origin?: string
119
+ readonly via?: string
120
+ readonly workspace?: string
121
+ readonly legacy?: boolean
122
+ readonly teams?: readonly string[]
123
+ /** Canvas teams on this host that carry this remote handle as a member. */
124
+ readonly canvasTeams?: readonly string[]
125
+ }
126
+
127
+ /** One canvas member: a local bare session id or a zone-qualified remote
128
+ * handle (`zone/id8`) — remote members are routed by the directory, never
129
+ * woken locally (0.6.6 unified team model). */
130
+ export interface CanvasMemberRow {
131
+ readonly provenance?: 'legacy-inferred' | 'unknown'
132
+ readonly id: string
133
+ readonly team?: string
134
+ readonly joined?: boolean
135
+ readonly live?: boolean
136
+ readonly remote?: boolean
137
+ /** Present only for CLI refs: null means the linked home is ambiguous or unknown. */
138
+ readonly cliHome?: string | null
139
+ readonly cliHeld?: boolean
140
+ readonly cliHomeCleared?: boolean
141
+ }
142
+
143
+ /** One canvas team from /state `canvas.teams`. */
144
+ export interface CanvasTeamRow {
145
+ readonly name: string
146
+ readonly team: string
147
+ readonly members: readonly CanvasMemberRow[]
148
+ }
149
+
150
+ /** One activity-ring entry. */
151
+ export interface ActivityRow {
152
+ readonly ts: number
153
+ readonly dir: 'in' | 'out' | 'fwd'
154
+ readonly team: string
155
+ readonly peer: string
156
+ readonly ok: boolean
157
+ }
158
+
159
+ /** The /state subset the workbench consumes (additive fields ignored). */
160
+ export interface StateBody {
161
+ readonly zone?: string
162
+ readonly version?: string
163
+ readonly sessions?: readonly SessionRow[]
164
+ readonly canvas?: { teams?: readonly CanvasTeamRow[]; revision?: number; retainedMembers?: ReadonlyArray<{ team: string; member: string; suspended: boolean; records: readonly unknown[] }> }
165
+ /** gh#20 W-A/W-C: the peer row carries the link badge source, the host
166
+ * rail's label/version/last-contact facts (absent on older hosts). */
167
+ readonly peers?: ReadonlyArray<{
168
+ url: string
169
+ score?: number
170
+ link?: 'linked' | 'stranger'
171
+ verified?: boolean
172
+ name?: string
173
+ version?: string
174
+ lastContactAt?: number
175
+ /** gh#24: whether the last contact attempt succeeded. */
176
+ lastContactOk?: boolean
177
+ }>
178
+ readonly remote?: readonly RemoteRow[]
179
+ readonly activity?: readonly ActivityRow[]
180
+ readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
181
+ readonly tasks?: readonly PendingRow[]
182
+ readonly tasksStaleCount?: number
183
+ readonly tasksDead?: readonly DeadRow[]
184
+ /** gh#31: per-task outcomes for rows that left the ledger (bounded). */
185
+ readonly archived?: readonly ArchivedRow[]
186
+ /** gh#41 fleet planning: each tracked peer's published canvas org. */
187
+ readonly peerOrg?: readonly PeerOrgRow[]
188
+ readonly registry?: { teams?: ReadonlyArray<{ team: string; members: readonly string[]; live?: number }> }
189
+ readonly archivedCount?: number
190
+ /** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
191
+ readonly archivedSplit?: Readonly<Record<string, number>>
192
+ readonly idempotency?: {
193
+ window?: number
194
+ pending?: number
195
+ settled?: number
196
+ claimsFresh?: number
197
+ replays?: number
198
+ conflicts?: number
199
+ }
200
+ readonly reconcile?: {
201
+ state?: string
202
+ woken?: number
203
+ rows?: ReadonlyArray<{ id: string; error: string; attempts: number; needsRepair?: boolean }>
204
+ }
205
+ }
206
+
207
+ /** What the workbench can DO — the seams tests drive. */
208
+ export interface WorkbenchDeps {
209
+ now(): number
210
+ /** gh#41 fleet planning: each tracked peer's published canvas org. */
211
+ peerOrg?: readonly PeerOrgRow[]
212
+ /** gh#34: read the owner three-key panel state (GET /__dsh_a2a/owner-settings). */
213
+ ownerSettingsRead?(): Promise<{ ok: boolean; seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean; announceRestartPending?: boolean; error?: string }>
214
+ /** gh#34: write owner panel changes; true on success (POST the same face). */
215
+ ownerSettingsWrite?(changes: { seatNames?: Record<string, string>; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean }): Promise<{ ok: boolean; restartNeeded?: boolean; error?: string }>
216
+ /** gh#41 fleet planning: regulate a REMOTE peer's canvas team (this host
217
+ * signs the envelope and forwards; the peer's link gate authorizes). */
218
+ fleetCanvasOp?(peer: string, op: 'create' | 'add-member' | 'remove-member' | 'remove', name: string, id?: string): Promise<{ ok: boolean; members?: string[]; error?: string }>
219
+ /** Abandon one dead-lettered task via the control route. True on success. */
220
+ abandon(taskId: string, reason: string): Promise<boolean>
221
+ /** Canvas team member change via the control route (0.6.6): `id` is a
222
+ * local session id or a zone-qualified remote handle. True on success. */
223
+ canvasOp(action: 'add-member' | 'remove-member', name: string, id: string, home?: string): Promise<boolean>
224
+ cliHomeOptions?(name: string, id: string): Promise<CliHomeOptions>
225
+ regulateSuspended?(action: 'restore-suspended' | 'delete-suspended', name: string, id: string, revision: number, expected: readonly unknown[]): Promise<{ ok: boolean; error?: string }>
226
+ /** Quick dispatch (0.6.9): POST /__dsh_a2a/dispatch as a human gesture —
227
+ * one message to a routing team, async delivery, receipt lands in the
228
+ * ledger. Optional — without it the 快派 form hides. */
229
+ dispatchQuick?(team: string, message: string): Promise<{ ok: boolean; task_id?: string; reply?: string; error?: string }>
230
+ /** Inline transcript (0.6.11): the tail of a LOCAL session's own
231
+ * conversation log, read-only. Optional — without it the block hides. */
232
+ sessionLog?(id: string): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string }>
233
+ /** 0.6.12: list every local session holding a log artifact (newest first) —
234
+ * the reading face is host-scoped, so archived and never-joined sessions
235
+ * appear here even when the network directory shows no local rows. */
236
+ sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }>
237
+ /** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
238
+ * resolves the publishing peer through the directory, ?peer=<base-url>
239
+ * reads one tracked peer directly. Without id the peer answers its
240
+ * readable-session listing. Optional — without it remote/peer chat
241
+ * shows the honest where-the-records-live note. */
242
+ remoteSessionLog?(query: { team?: string; peer?: string; id?: string; limit?: number }): Promise<{ ok: boolean; messages?: Array<{ role: string; text: string }>; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string; via?: string }>
243
+ /** Copy text to the clipboard; drafts are pasteable task briefs. */
244
+ copy(text: string): void
245
+ // 0.6.10: openLocal/openRemote (0.6.7/0.6.8 jump affordances) removed by
246
+ // ruling — 禁止任何跳转,全部使用响应式布局. dispatchQuick is the only
247
+ // reach primitive and it stays on this page.
248
+ /** The spatial planning board. Optional: without it the shell stays in
249
+ * data mode permanently. `available()` mirrors the host's canvas face
250
+ * (state.canvas !== undefined) and gates canvas mode. */
251
+ board?: {
252
+ readonly root: HTMLElement
253
+ activate(): void
254
+ deactivate(): void
255
+ available(): boolean
256
+ }
257
+ }
258
+
259
+ /** Display name for one node: session title, then team, then label. */
260
+ function displayName(node: { name?: string; team?: string; label?: string }): string {
261
+ return node.name ?? node.team ?? node.label ?? ''
262
+ }
263
+
264
+ function ageText(ms: number, now: number): string {
265
+ const s = Math.max(0, Math.round((now - ms) / 1000))
266
+ if (s < 90) return `${s}s`
267
+ const m = Math.round(s / 60)
268
+ if (m < 90) return `${m}m`
269
+ const h = Math.round(m / 60)
270
+ if (h < 48) return `${h}h`
271
+ return `${Math.round(h / 24)}d`
272
+ }
273
+
274
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string): HTMLElementTagNameMap[K] {
275
+ const node = document.createElement(tag)
276
+ if (className !== undefined) node.className = className
277
+ return node
278
+ }
279
+
280
+ function textRow(parent: HTMLElement, label: string, value: string): void {
281
+ const row = el('div', 'wb-kv')
282
+ row.appendChild(el('span', 'wb-kv-k')).textContent = label
283
+ row.appendChild(el('span', 'wb-kv-v')).textContent = value
284
+ parent.appendChild(row)
285
+ }
286
+
287
+ type Selection =
288
+ | { kind: 'canvas'; row: CanvasTeamRow }
289
+ | undefined
290
+
291
+ /** The rail sections. */
292
+ export type WorkbenchSection = 'directory' | 'hosts' | 'ledger' | 'activity' | 'plan'
293
+
294
+
295
+ /** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
296
+ export type WorkbenchTab = 'board' | WorkbenchSection
297
+
298
+ /** Which face fills the stage. */
299
+ export type WorkbenchMode = 'canvas' | 'data'
300
+
301
+ export interface Workbench {
302
+ readonly root: HTMLElement
303
+ /** Feed one /state poll (or null while the first fetch is out). */
304
+ update(state: StateBody | null): void
305
+ /** Current stage mode: the board while the canvas face is up, else data. */
306
+ readonly mode: () => WorkbenchMode
307
+ /** Focus a target: 'board' converges on canvas mode (no-op while the
308
+ * canvas face is down); a section un-collapses its rail panel. */
309
+ selectTab(key: WorkbenchTab): void
310
+ /** A node was picked on the planning board (0.6.13): resolve it against
311
+ * the latest state and open the right-side chat drawer for it. */
312
+ openNodeChat(id: string): void
313
+ }
314
+
315
+ /** Bare host:port of a peer url (workbench-local twin of federation's
316
+ * helper — this module keeps a zero-import posture). */
317
+ function peerHostOf(url: string): string {
318
+ try {
319
+ return new URL(url).host
320
+ } catch {
321
+ return url
322
+ }
323
+ }
324
+
325
+ /** Rail tabs (2026-10-23): 主机 moved up next to 目录 — host health was the
326
+ * last accordion section and sat below the fold. */
327
+ const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
328
+ { key: 'directory', label: '目录' },
329
+ { key: 'hosts', label: '主机' },
330
+ { key: 'ledger', label: '台账' },
331
+ { key: 'activity', label: '活动' },
332
+ { key: 'plan', label: '规划' },
333
+ ]
334
+
335
+ export function createWorkbench(deps: WorkbenchDeps): Workbench {
336
+ const board = deps.board
337
+ let mode: WorkbenchMode = 'data'
338
+ let selection: Selection
339
+ let last: StateBody | null = null
340
+ // Directory view state (survives polls): unified search + category filter.
341
+ let searchText = ''
342
+ let dirFilter: DirFilter = 'all'
343
+ // Canvas add-member form cache (static DOM re-mounted across polls; keyed
344
+ // by team name — same trick as draftForm so inputs survive the 3s poll).
345
+ let canvasForm: { name: string; form: HTMLFormElement; pick: HTMLSelectElement; manual: HTMLInputElement } | null = null
346
+ const cliHomeForms = new Map<string, HTMLFormElement>()
347
+ // Quick-dispatch form (0.6.9), cached per target routing team the same
348
+ // draftForm way: a textarea's draft must survive the 3s poll remount.
349
+ let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
350
+ // 0.6.12: readable local logs (host-scope list, newest first). Refreshed
351
+ // opportunistically per poll; rows render in the directory's 本机日志 section.
352
+ let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
353
+ /** How many logs the host holds (the list face caps at 50); from the listing's count. */
354
+ let logTotal: number | undefined
355
+ /** Learned 本机日志 titles: the first user line of a transcript, captured
356
+ * when the chat drawer loads it (the list face carries no titles). */
357
+ const logTitles = loadLogTitles()
358
+ let logRowsBusy = false
359
+
360
+ const root = el('div', 'wb')
361
+ root.setAttribute('data-testid', 'wb-root')
362
+
363
+ // ── Static skeleton: built once, update() only rewrites data regions. ──
364
+ const header = el('header', 'wb-header')
365
+ const menuBtn = el('button', 'wb-icon-btn wb-menu')
366
+ menuBtn.type = 'button'
367
+ menuBtn.dataset.testid = 'wb-menu'
368
+ menuBtn.setAttribute('aria-label', '打开目录抽屉')
369
+ menuBtn.textContent = '☰'
370
+ menuBtn.addEventListener('click', () => root.classList.toggle('is-rail-open'))
371
+ header.appendChild(menuBtn)
372
+ const title = el('div', 'wb-title')
373
+ title.appendChild(el('span', 'wb-title-main')).textContent = 'A2A 工作台'
374
+ const versionBadge = el('span', 'wb-badge')
375
+ title.appendChild(versionBadge)
376
+ header.appendChild(title)
377
+ // gh#34 owner three-key panel: the header gear opens the inspector panel
378
+ // (teamJoinAllowlist / teamScopeInfo / announce) — 远程建联三件不再翻配置文件。
379
+ const ownerBtn = el('button', 'wb-icon-btn wb-owner-btn')
380
+ ownerBtn.type = 'button'
381
+ ownerBtn.dataset.testid = 'wb-owner-btn'
382
+ ownerBtn.setAttribute('aria-label', '主人设置')
383
+ ownerBtn.title = '主人设置:席位名 / 入网白名单 / 可见性 / 发布'
384
+ ownerBtn.textContent = '⚙'
385
+ ownerBtn.addEventListener('click', () => { ownerPanelOpen = !ownerPanelOpen; renderOwnerPanel() })
386
+ header.appendChild(ownerBtn)
387
+ const headerStats = el('div', 'wb-header-stats')
388
+ header.appendChild(headerStats)
389
+ const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
390
+ railToggle.type = 'button'
391
+ railToggle.dataset.testid = 'wb-rail-toggle'
392
+ railToggle.setAttribute('aria-label', '折叠/展开侧栏')
393
+ railToggle.textContent = '◧'
394
+ railToggle.addEventListener('click', () => {
395
+ root.classList.toggle('is-rail-collapsed')
396
+ // The stage width changed while the board fills it → re-sync its view
397
+ // size (idempotent; focus side effect is harmless in a shell toggle).
398
+ if (mode === 'canvas' && board !== undefined) board.activate()
399
+ })
400
+ header.appendChild(railToggle)
401
+
402
+ const body = el('div', 'wb-body')
403
+ // Rail tabs (2026-10-23, wb-rail): one panel per pane, each with its own
404
+ // scroll. Panes are created once and re-parented between tab panels
405
+ // (canvas mode) and the stage stack (data mode).
406
+ const railTabs = createRailTabs(SECTION_DEFS)
407
+ const rail = railTabs.root
408
+
409
+ const directoryBody = el('div', 'wb-directory')
410
+ // Directory toolbar: unified search + category filter chips (0.6.6). Built
411
+ // once above the re-rendered list so the input keeps focus and its text
412
+ // across polls; filtering is pure frontend over /state.
413
+ const directoryTools = el('div', 'wb-dir-tools')
414
+ const searchInput = document.createElement('input')
415
+ searchInput.className = 'wb-search'
416
+ searchInput.type = 'search'
417
+ searchInput.placeholder = '搜索:名称 / 队 / 会话 / 节点'
418
+ searchInput.setAttribute('aria-label', '统一搜索目录')
419
+ searchInput.addEventListener('input', () => {
420
+ searchText = searchInput.value.trim().toLowerCase()
421
+ renderSidebar()
422
+ })
423
+ directoryTools.appendChild(searchInput)
424
+ const filterRow = el('div', 'wb-filters')
425
+ for (const def of DIRECTORY_FILTERS) {
426
+ const chip = el('button', 'wb-filter')
427
+ chip.type = 'button'
428
+ chip.textContent = def.label
429
+ chip.dataset.filter = def.key
430
+ chip.title = def.hint
431
+ chip.addEventListener('click', () => {
432
+ dirFilter = def.key
433
+ renderSidebar()
434
+ })
435
+ filterRow.appendChild(chip)
436
+ }
437
+ directoryTools.appendChild(filterRow)
438
+ railTabs.panel('directory').appendChild(directoryTools)
439
+ railTabs.panel('directory').appendChild(directoryBody)
440
+
441
+ const ledgerPane = el('section', 'wb-pane')
442
+ const activityPane = el('section', 'wb-pane')
443
+ const planPane = el('section', 'wb-pane')
444
+ // gh#20 W-C: the host rail pane (one row per tracked peer).
445
+ const hostsPane = el('section', 'wb-pane')
446
+ const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
447
+ ['ledger', ledgerPane],
448
+ ['activity', activityPane],
449
+ ['plan', planPane],
450
+ ['hosts', hostsPane],
451
+ ]
452
+
453
+ const stage = el('main', 'wb-stage')
454
+ const boardPane = el('section', 'wb-stage-board')
455
+ if (board !== undefined) {
456
+ boardPane.appendChild(board.root)
457
+ stage.appendChild(boardPane)
458
+ }
459
+ const dataStack = el('div', 'wb-data')
460
+
461
+ const inspector = el('aside', 'wb-inspector')
462
+ const inspectorHead = el('div', 'wb-inspector-head')
463
+ inspectorHead.appendChild(el('span', 'wb-inspector-title')).textContent = '详情'
464
+ const inspectorClose = el('button', 'wb-icon-btn')
465
+ inspectorClose.type = 'button'
466
+ inspectorClose.setAttribute('aria-label', '关闭详情')
467
+ inspectorClose.textContent = '✕'
468
+ inspectorClose.addEventListener('click', () => select(undefined))
469
+ inspectorHead.appendChild(inspectorClose)
470
+ inspector.appendChild(inspectorHead)
471
+ const inspectorBody = el('div', 'wb-inspector-body')
472
+ inspector.appendChild(inspectorBody)
473
+
474
+ // ── Node chat drawer (0.6.13; rewritten 2026-10-23 as wb-chat) ──
475
+ // Clicking a node — on the planning board or a directory row — opens the
476
+ // conversation as a right-side chat: thread + composer + receipt tracking.
477
+ const chat = createChatDrawer({
478
+ now: deps.now,
479
+ sessionLog: deps.sessionLog,
480
+ remoteSessionLog: deps.remoteSessionLog,
481
+ dispatchQuick: deps.dispatchQuick,
482
+ onTranscript: (target, messages) => {
483
+ if (target.kind !== 'log' && target.kind !== 'session') return
484
+ if (learnLogTitle(logTitles, target.id, messages)) renderSidebar()
485
+ },
486
+ })
487
+
488
+ stage.appendChild(dataStack)
489
+ body.appendChild(rail)
490
+ body.appendChild(stage)
491
+ body.appendChild(inspector)
492
+ body.appendChild(chat.root)
493
+ root.appendChild(header)
494
+ root.appendChild(body)
495
+
496
+ /** Move the data panes to their mode home: rail sections while the board
497
+ * owns the stage, the stage stack when the board is out. */
498
+ function placePanes(m: WorkbenchMode): void {
499
+ for (const [key, pane] of dataPanes) {
500
+ const home = m === 'canvas' ? railTabs.panel(key) : dataStack
501
+ // Data mode: the pane lives on the stage, so its tab steps aside.
502
+ railTabs.setHidden(key, m !== 'canvas')
503
+ if (pane.parentElement !== home) home.appendChild(pane)
504
+ }
505
+ }
506
+
507
+ function syncMode(): void {
508
+ const canvasUp = board !== undefined && board.available()
509
+ const next: WorkbenchMode = canvasUp ? 'canvas' : 'data'
510
+ root.dataset.mode = next
511
+ boardPane.classList.toggle('is-hidden', next !== 'canvas')
512
+ dataStack.classList.toggle('is-hidden', next === 'canvas')
513
+ placePanes(next)
514
+ if (next !== mode) {
515
+ if (board !== undefined) {
516
+ if (next === 'canvas') board.activate()
517
+ else board.deactivate()
518
+ }
519
+ mode = next
520
+ }
521
+ }
522
+
523
+ function clear(node: HTMLElement): HTMLElement {
524
+ while (node.firstChild !== null) node.removeChild(node.firstChild)
525
+ return node
526
+ }
527
+
528
+ function select(next: Selection): void {
529
+ selection = next
530
+ // Canvas add-member form is cached per team name (draftForm trick):
531
+ // drop it whenever the selection moves off its team.
532
+ if (next?.kind !== 'canvas' || canvasForm?.name !== next.row.name) canvasForm = null
533
+ // Same for the quick-dispatch form: it is cached per target team
534
+ // (0.6.15: remote dispatch moved to the chat drawer's own form).
535
+ if (next?.kind !== 'canvas') dispatchForm = null
536
+ renderInspector()
537
+ // Narrow screens: picking from the drawer closes it so the stage shows.
538
+ root.classList.remove('is-rail-open')
539
+ }
540
+
541
+ // ── Directory section ──
542
+ function renderSidebar(): void {
543
+ // Filter chips reflect the live view state (built once, toggled here).
544
+ for (let i = 0; i < filterRow.children.length; i++) {
545
+ const node = filterRow.children[i] as HTMLElement
546
+ node.classList.toggle('is-active', node.dataset.filter === dirFilter)
547
+ }
548
+ if (last === null) {
549
+ directoryBody.replaceChildren(el('div', 'wb-muted'))
550
+ directoryBody.firstElementChild!.textContent = '载入中…'
551
+ railTabs.setBadge('directory', '')
552
+ return
553
+ }
554
+ const badge = renderDirectory(directoryBody, {
555
+ state: last,
556
+ logs: logRows,
557
+ logTotal,
558
+ titles: logTitles,
559
+ search: searchText,
560
+ filter: dirFilter,
561
+ now: deps.now(),
562
+ openChat,
563
+ selectCanvas: (row) => select({ kind: 'canvas', row }),
564
+ })
565
+ railTabs.setBadge('directory', badge)
566
+ }
567
+
568
+ /**
569
+ * gh#20 W-C: the host rail — one row per tracked peer host. Each row is a
570
+ * HOST card: operator-facing label (the peer's published card name, host
571
+ * fallback), the W-A tri-state badge (link class + verified mark), a
572
+ * health lamp driven by the last-contact recency (green ≤60s, amber
573
+ * ≤5min, gray older/never — tooltip carries the exact timestamp), and the
574
+ * peer's published version chip. Clicking drills down: the peer chat
575
+ * drawer (remote log entry) rides the existing face.
576
+ */
577
+ function renderHosts(): void {
578
+ const pane = clear(hostsPane)
579
+ if (last === null) {
580
+ railTabs.setBadge('hosts', '')
581
+ return
582
+ }
583
+ const now = deps.now()
584
+ const peers = [...(last.peers ?? [])].sort((a, b) => {
585
+ const rank = (row: { link?: string }): number => row.link === 'linked' ? 0 : 1
586
+ return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0)
587
+ })
588
+ railTabs.setBadge('hosts', peers.length > 0 ? `${peers.filter(row => row.link === 'linked').length}/${peers.length}` : '')
589
+ if (peers.length === 0) {
590
+ pane.appendChild(el('div', 'wb-nodelist-empty')).textContent = '暂无 tracked peer——配 peers 种子或等 referral 到达'
591
+ return
592
+ }
593
+ for (const row of peers) {
594
+ const host = peerHostOf(row.url)
595
+ const lineEl = el('div', 'wb-host-row')
596
+ // Health lamp (gh#24 semantics): recency alone lied — a FAILED fresh
597
+ // contact glowed green. Tiers by age: ≤60s live / ≤5min warm /
598
+ // beyond cold, overridden by the OUTCOME: a recent failure (≤5min)
599
+ // is red regardless of the live/warm band, and a never-contacted
600
+ // peer is the hollow unknown state.
601
+ const lamp = el('span', 'wb-host-lamp')
602
+ const last = row.lastContactAt
603
+ const age = last === undefined ? undefined : now - last
604
+ const ok = row.lastContactOk
605
+ if (age === undefined) lamp.classList.add('is-unknown')
606
+ else if (ok === false && age <= 5 * 60_000) lamp.classList.add('is-fail')
607
+ else if (age <= 60_000) lamp.classList.add('is-live')
608
+ else if (age <= 5 * 60_000) lamp.classList.add('is-warm')
609
+ else lamp.classList.add('is-cold')
610
+ lamp.title = last === undefined
611
+ ? '尚无接触记录'
612
+ : `最近接触 ${ageText(last, now)}${ok === false ? '(失败)' : ok === true ? '(成功)' : ''}`
613
+ lineEl.appendChild(lamp)
614
+ // Label: the peer's published name, host fallback.
615
+ lineEl.appendChild(el('span', 'wb-host-name')).textContent = row.name !== undefined && row.name !== '' ? row.name : host
616
+ // W-A badge: link class + verified mark.
617
+ const badge = el('span', `wb-host-badge ${row.link === 'linked' ? 'is-linked' : 'is-stranger'}`)
618
+ badge.textContent = `${row.link === 'linked' ? 'linked' : 'stranger'} ${row.verified === true ? '✓' : '✗'}`
619
+ // The tooltip follows the verified mark (gh#24): a linked ✗ must not
620
+ // claim a valid signature.
621
+ badge.title = row.link === 'linked'
622
+ ? `已建立运营者链接(互种或 canvas 引用);${row.verified === true ? '验签有效' : '尚未持有验签通过的卡片'}`
623
+ : '无运营者关系(验签 ≠ 关系)'
624
+ lineEl.appendChild(badge)
625
+ // Version chip from the warm card.
626
+ if (row.version !== undefined && row.version !== '') {
627
+ lineEl.appendChild(el('span', 'wb-host-ver')).textContent = row.version
628
+ }
629
+ lineEl.appendChild(el('span', 'wb-host-url mono')).textContent = host
630
+ lineEl.setAttribute('role', 'button')
631
+ lineEl.tabIndex = 0
632
+ lineEl.title = `${row.url} · score ${String(row.score ?? 0)}`
633
+ const drill = (): void => { openChat({ kind: 'peer', id: `peer-${host}`, title: row.name !== undefined && row.name !== '' ? row.name : host, sub: host, url: row.url }) }
634
+ lineEl.addEventListener('click', drill)
635
+ lineEl.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); drill() } })
636
+ pane.appendChild(lineEl)
637
+ }
638
+ }
639
+
640
+ // ── Ledger pane (wb-ledger) ──
641
+ function renderLedger(): void {
642
+ if (last === null) { ledgerPane.replaceChildren(); railTabs.setBadge('ledger', ''); return }
643
+ const state = last
644
+ const resolve = (team: string): ChatTarget | undefined => {
645
+ const session = (state.sessions ?? []).find(row => row.team === team)
646
+ if (session !== undefined) return { kind: 'session', id: session.id, title: displayName(session), sub: session.team, team: session.team, live: session.live }
647
+ const remote = (state.remote ?? []).find(row => row.team === team)
648
+ if (remote !== undefined) return { kind: 'remote', team: remote.team, title: displayName(remote), sub: remote.team, row: remote }
649
+ return undefined
650
+ }
651
+ const out = paintLedger(ledgerPane, {
652
+ state,
653
+ now: deps.now(),
654
+ abandon: (taskId, reason) => deps.abandon(taskId, reason),
655
+ canOpenTeam: (team) => resolve(team) !== undefined,
656
+ openTeam: (team) => {
657
+ const target = resolve(team)
658
+ if (target === undefined) return false
659
+ openChat(target)
660
+ return true
661
+ },
662
+ })
663
+ railTabs.setBadge('ledger', out.badge, out.tone)
664
+ }
665
+
666
+ // ── Activity pane ──
667
+ /** A team cell: the name when known (wb-names), else the id. */
668
+ function teamCell(line: HTMLElement, state: StateBody, team: string): void {
669
+ nameInto(line.appendChild(el('span', 'wb-row-team')), state, team)
670
+ }
671
+
672
+ function renderActivity(): void {
673
+ const pane = clear(activityPane)
674
+ if (last === null) return
675
+ const now = deps.now()
676
+ const flights = last.inFlight ?? []
677
+ railTabs.setBadge('activity', flights.length > 0 ? String(flights.length) : '', flights.length > 0 ? 'accent' : undefined)
678
+ const head = el('div', 'wb-pane-head')
679
+ head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
680
+ pane.appendChild(head)
681
+ if (flights.length > 0) {
682
+ const sub = el('div', 'wb-pane-subhead')
683
+ sub.textContent = '在途路由'
684
+ pane.appendChild(sub)
685
+ for (const row of flights) {
686
+ const line = el('div', 'wb-row')
687
+ line.appendChild(el('span', 'wb-row-dir')).textContent = '→'
688
+ teamCell(line, last, row.team)
689
+ line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.startedAt, now)}`
690
+ pane.appendChild(line)
691
+ }
692
+ }
693
+ const ringHead = el('div', 'wb-pane-subhead')
694
+ ringHead.textContent = '活动环(最新在前)'
695
+ pane.appendChild(ringHead)
696
+ const ring = last.activity ?? []
697
+ if (ring.length === 0) {
698
+ pane.appendChild(el('div', 'wb-muted')).textContent = '暂无活动'
699
+ return
700
+ }
701
+ for (const row of ring) {
702
+ const line = el('div', 'wb-row')
703
+ const dir = el('span', 'wb-row-dir')
704
+ dir.textContent = row.dir === 'fwd' ? '⇢' : row.dir === 'out' ? '→' : '←'
705
+ if (row.ok !== true) dir.classList.add('is-fail')
706
+ line.appendChild(dir)
707
+ teamCell(line, last, row.team)
708
+ line.appendChild(el('span', 'wb-row-meta')).textContent = `${row.peer} · ${ageText(row.ts, now)}`
709
+ line.appendChild(el('span', 'wb-row-badges')).textContent = row.ok === true ? '' : '失败'
710
+ pane.appendChild(line)
711
+ }
712
+ }
713
+
714
+ // ── Plan pane: derived actions + dispatch drafting ──
715
+ function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
716
+ const now = deps.now()
717
+ const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
718
+ // One definition with the 台账 tab: fire-forget expiries are bookkeeping,
719
+ // not dead letters (review L1), and only OUR stale sends are nudgeable.
720
+ const facts = ledgerFacts(state, now)
721
+ if (facts.deadLetters.length > 0) {
722
+ actions.push({ severity: 'high', text: `清账 ${facts.deadLetters.length} 条死信(目标早已离开或死信化,无回执可期)` })
723
+ }
724
+ // (The old `age > 1h && settleable === true` never fired against a real
725
+ // host — the server's settleable means receipt-expected AND age < 1h.)
726
+ for (const row of facts.stale) {
727
+ // Quote the original ask: a nudge that only repeats a task id makes
728
+ // the target re-derive WHAT was owed, and the target is exactly the
729
+ // session that already failed to answer once.
730
+ const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
731
+ actions.push({
732
+ severity: 'normal',
733
+ text: `催办 ${row.taskId} → ${teamLabel(state, row.team)}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
734
+ brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
735
+ })
736
+ }
737
+ // Cold sessions are one action, not one per session: they share the same
738
+ // remedy (open them in the GUI), and a list of identical lines buried the
739
+ // actions that differ.
740
+ const cold = (state.sessions ?? []).filter(row => row.live !== true).map(row => displayName(row))
741
+ if (cold.length > 0) {
742
+ const names = cold.slice(0, 3).join('、') + (cold.length > 3 ? ` 等 ${cold.length} 个` : '')
743
+ actions.push({ severity: 'normal', text: `冷会话 ${cold.length} 个(${names}):在 GUI 打开即可唤醒加入面` })
744
+ }
745
+ for (const row of state.reconcile?.rows ?? []) {
746
+ if (row.needsRepair === true) {
747
+ // Name the session; the id stays for the operator who greps logs.
748
+ const s = (state.sessions ?? []).find(x => x.id === row.id)
749
+ const who = s !== undefined ? `${displayName(s)}(${row.id})` : row.id
750
+ actions.push({ severity: 'high', text: `reconcile 需修复:${who} ${row.error}` })
751
+ }
752
+ }
753
+ const conflicts = state.idempotency?.conflicts ?? 0
754
+ if (conflicts > 0) {
755
+ actions.push({ severity: 'high', text: `幂等冲突 ${conflicts} 例:人工核查 UNIQUE-ization 流量` })
756
+ }
757
+ return actions
758
+ }
759
+
760
+ function renderPlan(): void {
761
+ const pane = clear(planPane)
762
+ if (last === null) return
763
+ const actions = planActions(last)
764
+ railTabs.setBadge('plan', actions.length > 0 ? String(actions.length) : '', actions.some(a => a.severity === 'high') ? 'danger' : undefined)
765
+ const head = el('div', 'wb-pane-head')
766
+ head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
767
+ pane.appendChild(head)
768
+ for (const action of actions) {
769
+ const line = el('div', `wb-action${action.severity === 'high' ? ' wb-action-high' : ''}`)
770
+ line.appendChild(el('span', 'wb-action-text')).textContent = action.text
771
+ if (action.brief !== undefined) {
772
+ const btn = el('button', 'wb-btn')
773
+ btn.type = 'button'
774
+ btn.textContent = '复制催办'
775
+ btn.addEventListener('click', () => deps.copy(action.brief ?? ''))
776
+ line.appendChild(btn)
777
+ }
778
+ pane.appendChild(line)
779
+ }
780
+ // Dispatch drafting — static inner form so textarea survives updates.
781
+ const draftHead = el('div', 'wb-pane-subhead')
782
+ draftHead.textContent = '派单起草(复制后在任一会话粘贴执行)'
783
+ pane.appendChild(draftHead)
784
+ pane.appendChild(draftForm)
785
+ }
786
+
787
+ // Dispatch draft form: created ONCE; renderPlan re-attaches the same node.
788
+ const draftForm = el('div', 'wb-draft')
789
+ const draftTarget = document.createElement('select')
790
+ draftTarget.className = 'wb-select'
791
+ const draftInput = document.createElement('textarea')
792
+ draftInput.className = 'wb-textarea'
793
+ draftInput.rows = 3
794
+ draftInput.placeholder = '任务正文:目标、验收、期限…'
795
+ const draftBtn = el('button', 'wb-btn wb-btn-primary')
796
+ draftBtn.type = 'button'
797
+ draftBtn.textContent = '复制任务书'
798
+ draftBtn.addEventListener('click', () => {
799
+ const target = draftTarget.value
800
+ const message = draftInput.value.trim()
801
+ if (target === '' || message === '') return
802
+ deps.copy(`a2a_route 派单 → ${target}:\n${message}`)
803
+ })
804
+ draftForm.appendChild(draftTarget)
805
+ draftForm.appendChild(draftInput)
806
+ draftForm.appendChild(draftBtn)
807
+
808
+ function renderDraftTargets(): void {
809
+ const previous = draftTarget.value
810
+ clear(draftTarget)
811
+ const empty = document.createElement('option')
812
+ empty.value = ''
813
+ empty.textContent = '选择目标队…'
814
+ draftTarget.appendChild(empty)
815
+ if (last === null) return
816
+ const state = last
817
+ const targets = new Map<string, string>()
818
+ for (const row of state.sessions ?? []) {
819
+ if (row.team !== undefined && row.team !== '') targets.set(row.team, `${displayName(row)}(会话队)`)
820
+ }
821
+ for (const row of state.canvas?.teams ?? []) targets.set(row.team, `${row.name}(canvas 队)`)
822
+ for (const row of state.remote ?? []) targets.set(row.team, `${displayName(row)}(远端)`)
823
+ for (const [team, label] of targets) {
824
+ const opt = document.createElement('option')
825
+ opt.value = team
826
+ opt.textContent = `${label} · ${team}`
827
+ draftTarget.appendChild(opt)
828
+ }
829
+ if (previous !== '' && targets.has(previous)) draftTarget.value = previous
830
+ }
831
+
832
+ // ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
833
+ // gh#34 owner panel state + render (the gear toggles; the inspector hosts).
834
+ let seatEditor: HTMLElement | undefined
835
+ let ownerPanelOpen = false
836
+ let ownerState: { seatNames: Record<string, string>; teamJoinAllowlist: string[]; teamScopeInfo: boolean; announce: boolean; announceRestartPending: boolean } | null = null
837
+ function renderOwnerPanel(): void {
838
+ renderInspector()
839
+ if (!ownerPanelOpen) return
840
+ void deps.ownerSettingsRead?.().then(result => {
841
+ if (!ownerPanelOpen) return
842
+ if (result.ok !== true) { ownerState = null; renderInspector(); return }
843
+ seatEditor = undefined
844
+ ownerState = {
845
+ seatNames: result.seatNames ?? {},
846
+ teamJoinAllowlist: result.teamJoinAllowlist ?? [],
847
+ teamScopeInfo: result.teamScopeInfo !== false,
848
+ announce: result.announce === true,
849
+ announceRestartPending: result.announceRestartPending === true,
850
+ }
851
+ renderInspector()
852
+ })
853
+ }
854
+ function renderOwnerPanelInto(box: HTMLElement): void {
855
+ const card = el('div', 'wb-card')
856
+ const head = el('div', 'wb-inspector-head')
857
+ head.appendChild(el('span', 'wb-inspector-title')).textContent = '主人设置'
858
+ const closeBtn = el('button', 'wb-icon-btn')
859
+ closeBtn.type = 'button'
860
+ closeBtn.setAttribute('aria-label', '关闭主人设置')
861
+ closeBtn.textContent = '✕'
862
+ closeBtn.addEventListener('click', () => { ownerPanelOpen = false; renderInspector() })
863
+ head.appendChild(closeBtn)
864
+ card.appendChild(head)
865
+ if (deps.ownerSettingsRead === undefined) {
866
+ const note = el('div', 'wb-hint')
867
+ note.textContent = '此宿主未提供主人设置面(旧版本)——升级后可用。'
868
+ card.appendChild(note)
869
+ box.appendChild(card)
870
+ return
871
+ }
872
+ if (ownerState === null) {
873
+ const note = el('div', 'wb-hint')
874
+ note.textContent = '读取中…'
875
+ card.appendChild(note)
876
+ box.appendChild(card)
877
+ return
878
+ }
879
+ const st = ownerState
880
+ seatEditor ??= createSeatNameEditor(last?.sessions ?? [], st.seatNames, async seatNames => {
881
+ const result = await deps.ownerSettingsWrite?.({ seatNames })
882
+ if (result?.ok) { st.seatNames = seatNames; return { ok: true } }
883
+ return { ok: false, error: result?.error ?? '保存失败' }
884
+ })
885
+ card.appendChild(seatEditor)
886
+ // ① 入网白名单(精确团队名增删)
887
+ const alHead = el('div', 'wb-side-head')
888
+ alHead.textContent = '入网白名单(精确团队名)'
889
+ card.appendChild(alHead)
890
+ for (const name of st.teamJoinAllowlist) {
891
+ const row = el('div', 'wb-member-row')
892
+ row.appendChild(el('span', 'wb-member-label')).textContent = name
893
+ const del = el('button', 'wb-member-remove')
894
+ del.type = 'button'
895
+ del.textContent = '移除'
896
+ del.addEventListener('click', () => {
897
+ void deps.ownerSettingsWrite?.({ teamJoinAllowlist: st.teamJoinAllowlist.filter(n => n !== name) }).then(() => renderOwnerPanel())
898
+ })
899
+ row.appendChild(del)
900
+ card.appendChild(row)
901
+ }
902
+ const addForm = el('div', 'wb-member-add')
903
+ const input = el('input', 'wb-member-manual')
904
+ input.type = 'text'
905
+ input.placeholder = 'dsh/团队名(精确,无通配)'
906
+ input.setAttribute('aria-label', '新增白名单团队名')
907
+ const addBtn = el('button', 'wb-member-submit')
908
+ addBtn.type = 'button'
909
+ addBtn.textContent = '加入'
910
+ const doAdd = (): void => {
911
+ const v = input.value.trim()
912
+ if (v === '' || st.teamJoinAllowlist.includes(v)) return
913
+ void deps.ownerSettingsWrite?.({ teamJoinAllowlist: [...st.teamJoinAllowlist, v] }).then(() => renderOwnerPanel())
914
+ }
915
+ addBtn.addEventListener('click', doAdd)
916
+ input.addEventListener('keydown', ev => { if (ev.key === 'Enter') doAdd() })
917
+ addForm.append(input, addBtn)
918
+ card.appendChild(addForm)
919
+ // ② 可见性开关 + 精度固化文案
920
+ const tsHead = el('div', 'wb-side-head')
921
+ tsHead.textContent = '目录可见性(teamScopeInfo)'
922
+ card.appendChild(tsHead)
923
+ const tsRow = el('div', 'wb-member-row')
924
+ const tsBtn = el('button', 'wb-member-submit')
925
+ tsBtn.type = 'button'
926
+ tsBtn.textContent = st.teamScopeInfo ? '已开:仅显示已加入团队(点击放开为全舰队可见)' : '已关:全舰队可见(点击收窄为已加入团队)'
927
+ tsBtn.addEventListener('click', () => {
928
+ void deps.ownerSettingsWrite?.({ teamScopeInfo: !st.teamScopeInfo }).then(() => renderOwnerPanel())
929
+ })
930
+ tsRow.appendChild(tsBtn)
931
+ card.appendChild(tsRow)
932
+ const tsNote = el('div', 'wb-hint')
933
+ tsNote.textContent = '精度说明:teamScopeInfo 只管「目录显示范围」。关上它不会解锁任何通信准入——会话入网(S3)与目标级路由(F5)规则原样生效。'
934
+ card.appendChild(tsNote)
935
+ // ③ 发布开关(重启生效)
936
+ const anHead = el('div', 'wb-side-head')
937
+ anHead.textContent = '节点发布(announce)'
938
+ card.appendChild(anHead)
939
+ const anRow = el('div', 'wb-member-row')
940
+ const anBtn = el('button', 'wb-member-submit')
941
+ anBtn.type = 'button'
942
+ anBtn.textContent = st.announce ? '已开:本节点发布签名卡(点击关闭)' : '已关:本节点不发布(点击开启——重启宿主后生效)'
943
+ anBtn.addEventListener('click', () => {
944
+ void deps.ownerSettingsWrite?.({ announce: !st.announce }).then(() => renderOwnerPanel())
945
+ })
946
+ anRow.appendChild(anBtn)
947
+ card.appendChild(anRow)
948
+ if (st.announceRestartPending) {
949
+ const anNote = el('div', 'wb-hint')
950
+ anNote.textContent = '有 announce 变更待生效:重启宿主后按新配置发布。'
951
+ card.appendChild(anNote)
952
+ }
953
+ box.appendChild(card)
954
+ }
955
+
956
+ function renderInspector(): void {
957
+ const box = clear(inspectorBody)
958
+ // gh#34: the owner panel owns the inspector when open (selection cards
959
+ // yield; the gear closes it back).
960
+ if (ownerPanelOpen) { inspector.classList.add('is-open'); renderOwnerPanelInto(box); return }
961
+ inspector.classList.toggle('is-open', selection !== undefined)
962
+ if (selection === undefined) return
963
+ const sel = selection
964
+ const card = el('div', 'wb-card')
965
+ // 0.6.13: session/log/peer faces moved to the chat drawer; 0.6.15 took
966
+ // the remote rows with them (their metadata rides the chat head) — the
967
+ // inspector keeps the canvas team card.
968
+ if (sel.kind === 'canvas') {
969
+ // Re-resolve the team from the freshest state: membership changes here
970
+ // must be visible on the next render even though the click-time row is
971
+ // a stale snapshot.
972
+ const fresh = (last?.canvas?.teams ?? []).find(t => t.name === sel.row.name) ?? sel.row
973
+ textRow(card, 'Canvas 队', fresh.name)
974
+ textRow(card, '路由队', fresh.team)
975
+ const head = el('div', 'wb-side-head')
976
+ head.textContent = `成员 · ${fresh.members.length}`
977
+ card.appendChild(head)
978
+ if (fresh.members.length === 0) {
979
+ const empty = el('div', 'wb-muted')
980
+ empty.textContent = '空队——用下方表单添加成员'
981
+ card.appendChild(empty)
982
+ }
983
+ for (const member of fresh.members) {
984
+ const row = el('div', 'wb-member-row')
985
+ const dot = member.cliHeld === true ? '本机持有,无需指定' : member.remote === true ? (member.cliHome === null ? '◇ 远端 · home 未确定' : '◇ 远端') : member.live === true ? '● 现场' : member.joined === true ? '○ 冷启' : '· 未入网'
986
+ row.appendChild(el('span', 'wb-member-dot')).textContent = dot + (member.provenance ? ' · 来源未确认' + (member.provenance === 'legacy-inferred' ? '(兼容推断)' : '') : '')
987
+ const label = el('span', 'wb-member-label')
988
+ const session = (last?.sessions ?? []).find(s => s.id === member.id)
989
+ label.textContent = member.remote === true ? member.id : (session !== undefined ? displayName(session) : member.id)
990
+ label.title = member.cliHeld === true ? '本机持有,无需指定' : member.cliHome !== undefined ? (member.cliHome ?? 'home 未确定') : (member.team ?? member.id)
991
+ row.appendChild(label)
992
+ const remove = el('button', 'wb-member-remove')
993
+ remove.type = 'button'
994
+ remove.textContent = '移除'
995
+ remove.addEventListener('click', async () => {
996
+ remove.disabled = true
997
+ if (!(await deps.canvasOp('remove-member', fresh.name, member.id))) remove.disabled = false
998
+ })
999
+ row.appendChild(remove)
1000
+ card.appendChild(row)
1001
+ if (member.cliHomeCleared === true) {
1002
+ const notice = el('div', 'wb-hint')
1003
+ notice.textContent = '本机落户已清除旧 home 钉;户口离线后也不会恢复旧钉。'
1004
+ card.appendChild(notice)
1005
+ }
1006
+ if (member.cliHome === null && member.cliHeld !== true && deps.cliHomeOptions !== undefined) {
1007
+ const key = JSON.stringify([fresh.name, member.id])
1008
+ let form = cliHomeForms.get(key)
1009
+ if (form === undefined) {
1010
+ form = createCliHomePicker(() => deps.cliHomeOptions!(fresh.name, member.id), home => deps.canvasOp('add-member', fresh.name, member.id, home))
1011
+ cliHomeForms.set(key, form)
1012
+ }
1013
+ card.appendChild(form)
1014
+ }
1015
+ }
1016
+ const retained = (last?.canvas?.retainedMembers ?? []).filter(r => r.team === fresh.name)
1017
+ for (const slot of retained) {
1018
+ const row = el('div', 'wb-member-row')
1019
+ row.appendChild(el('span', 'wb-member-dot')).textContent = slot.suspended ? '已暂停 · 不参与路由' : '来源未确认 · 窗口关闭'
1020
+ row.appendChild(el('span', 'wb-member-label')).textContent = slot.member
1021
+ if (slot.suspended && deps.regulateSuspended && last?.canvas?.revision !== undefined) {
1022
+ for (const action of ['restore-suspended', 'delete-suspended'] as const) {
1023
+ const button = el('button', 'wb-member-remove'); button.type = 'button'
1024
+ button.textContent = action === 'restore-suspended' ? '显式恢复' : '删除暂停位'
1025
+ const revision = last.canvas.revision
1026
+ button.addEventListener('click', async () => {
1027
+ button.disabled = true
1028
+ const result = await deps.regulateSuspended!(action, fresh.name, slot.member, revision, slot.records)
1029
+ if (!result.ok) { button.disabled = false; button.title = result.error ?? '状态已变,请刷新后重试' }
1030
+ })
1031
+ row.appendChild(button)
1032
+ }
1033
+ }
1034
+ card.appendChild(row)
1035
+ }
1036
+ // Add-member form: static DOM re-mounted per poll (draftForm trick) —
1037
+ // rebuilt only when the inspected team changes.
1038
+ if (canvasForm?.name !== fresh.name) {
1039
+ const form = el('form', 'wb-member-add')
1040
+ const pick = document.createElement('select')
1041
+ pick.className = 'wb-member-pick'
1042
+ pick.setAttribute('aria-label', '选择成员')
1043
+ const manual = document.createElement('input')
1044
+ manual.type = 'text'
1045
+ manual.className = 'wb-member-manual'
1046
+ manual.placeholder = '或输入远端句柄 zone/id8'
1047
+ manual.setAttribute('aria-label', '远端成员句柄')
1048
+ const submit = el('button', 'wb-member-submit')
1049
+ submit.type = 'submit'
1050
+ submit.textContent = '添加'
1051
+ form.appendChild(pick)
1052
+ form.appendChild(manual)
1053
+ form.appendChild(submit)
1054
+ form.addEventListener('submit', async ev => {
1055
+ ev.preventDefault()
1056
+ const handle = manual.value.trim()
1057
+ const id = handle !== '' ? handle : pick.value
1058
+ if (id === '') return
1059
+ submit.disabled = true
1060
+ const ok = await deps.canvasOp('add-member', fresh.name, id)
1061
+ submit.disabled = false
1062
+ if (ok) manual.value = ''
1063
+ })
1064
+ canvasForm = { name: fresh.name, form, pick, manual }
1065
+ }
1066
+ // Refresh the picker's options from the freshest state, preserving the
1067
+ // current choice (sessions/remote rows churn across polls).
1068
+ const pick = canvasForm.pick
1069
+ const keep = pick.value
1070
+ clear(pick)
1071
+ pick.appendChild(new Option('本机会话(现场/冷启动)…', '', true, true))
1072
+ const memberIds = new Set(fresh.members.map(member => member.id))
1073
+ for (const session of last?.sessions ?? []) {
1074
+ if (memberIds.has(session.id)) continue
1075
+ pick.appendChild(new Option(`${displayName(session)} — 本机`, session.id))
1076
+ }
1077
+ let remoteLabeled = false
1078
+ for (const remote of last?.remote ?? []) {
1079
+ // gh#37: only zone/id8 HANDLES are addable members — a bare zone row
1080
+ // (the peer's own team card, e.g. 'dsh') fails the backend shape
1081
+ // gate and the old picker offered it anyway: the owner clicked 远端,
1082
+ // got refused, and read the feature as broken. Rows without a '/'
1083
+ // are skipped; when the directory carries no handles at all the
1084
+ // hint explains why (unlinked peers serve the minimal card, which
1085
+ // carries no session teams) instead of offering dead options.
1086
+ if (!remote.team.includes('/')) continue
1087
+ if (memberIds.has(remote.team)) continue
1088
+ if (!remoteLabeled) {
1089
+ pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
1090
+ remoteLabeled = true
1091
+ }
1092
+ pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
1093
+ }
1094
+ if (!remoteLabeled) {
1095
+ pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
1096
+ }
1097
+ if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
1098
+ card.appendChild(canvasForm.form)
1099
+ const hint = el('div', 'wb-hint')
1100
+ hint.textContent =
1101
+ '成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
1102
+ card.appendChild(hint)
1103
+ // Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
1104
+ // first, then the remote members in order — the seam's own priority.
1105
+ if (deps.dispatchQuick !== undefined) card.appendChild(dispatchCard(fresh.team))
1106
+ }
1107
+ box.appendChild(card)
1108
+ }
1109
+
1110
+ // ── Quick dispatch (0.6.9) ──
1111
+ // One human-typed message to a routing team, no session opened. The form
1112
+ // is cached per target team (draftForm trick) so the textarea draft and
1113
+ // the result note survive the 3s poll remount.
1114
+ function buildDispatchForm(target: string): { form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } {
1115
+ const form = el('form', 'wb-dispatch-form')
1116
+ const text = document.createElement('textarea')
1117
+ text.className = 'wb-dispatch-text'
1118
+ text.placeholder = '一句话任务…(≤4000 字符,异步送达,回执进台账)'
1119
+ text.setAttribute('aria-label', '快派消息')
1120
+ const submit = el('button', 'wb-dispatch-submit')
1121
+ submit.type = 'submit'
1122
+ submit.textContent = '发送'
1123
+ const note = el('div', 'wb-dispatch-note')
1124
+ form.appendChild(text)
1125
+ form.appendChild(submit)
1126
+ form.appendChild(note)
1127
+ // 0.6.17 chat-UI conventions (deepseek-harness composer): Enter sends,
1128
+ // Shift+Enter is a soft newline, IME composition is never hijacked.
1129
+ text.addEventListener('keydown', ev => {
1130
+ if (ev.key !== 'Enter' || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return
1131
+ if (ev.isComposing) return
1132
+ ev.preventDefault()
1133
+ if (!submit.disabled) form.requestSubmit()
1134
+ })
1135
+ form.addEventListener('submit', async ev => {
1136
+ ev.preventDefault()
1137
+ const message = text.value.trim()
1138
+ if (message === '' || submit.disabled) return
1139
+ submit.disabled = true
1140
+ note.textContent = '派发中…'
1141
+ let outcome: { ok: boolean; task_id?: string; reply?: string; error?: string }
1142
+ try {
1143
+ outcome = await (deps.dispatchQuick?.(target, message) ?? Promise.resolve({ ok: false, error: 'dispatch unavailable' }))
1144
+ } catch (error) {
1145
+ outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
1146
+ }
1147
+ submit.disabled = false
1148
+ if (outcome.ok === true) {
1149
+ // Sent: clear the composer like every chat surface and hand the
1150
+ // caret back — the submit round-trip must not strand focus.
1151
+ text.value = ''
1152
+ }
1153
+ text.focus({ preventScroll: true })
1154
+ note.textContent = outcome.ok === true
1155
+ ? `已派发${outcome.task_id !== undefined ? ` · ${outcome.task_id}` : ''} · 回执将入台账(待回执)`
1156
+ : `失败:${outcome.error ?? '未知原因'}`
1157
+ })
1158
+ return { form, text, note }
1159
+ }
1160
+
1161
+ function dispatchCard(target: string): HTMLElement {
1162
+ const wrap = el('div', 'wb-dispatch')
1163
+ const head = el('div', 'wb-side-head')
1164
+ head.textContent = '快派'
1165
+ wrap.appendChild(head)
1166
+ if (dispatchForm?.team !== target) dispatchForm = { team: target, ...buildDispatchForm(target) }
1167
+ wrap.appendChild(dispatchForm.form)
1168
+ return wrap
1169
+ }
1170
+
1171
+ function openChat(target: ChatTarget): void {
1172
+ chat.open(target)
1173
+ root.classList.remove('is-rail-open')
1174
+ }
1175
+
1176
+ /** Board picks (0.6.13): resolve a node id against the latest state. */
1177
+ function openNodeChat(id: string): void {
1178
+ if (last === null) return
1179
+ const session = (last.sessions ?? []).find(row => row.id === id)
1180
+ if (session !== undefined) {
1181
+ openChat({ kind: 'session', id, title: displayName(session), sub: session.team, team: session.team, live: session.live })
1182
+ return
1183
+ }
1184
+ if (id.startsWith('peer-')) {
1185
+ const host = id.slice('peer-'.length)
1186
+ const peer = (last.peers ?? []).find(row => row.url.includes(host))
1187
+ openChat({ kind: 'peer', id, title: peer !== undefined ? peer.url : host, sub: host, url: peer?.url })
1188
+ return
1189
+ }
1190
+ const log = logRows.find(row => row.id === id)
1191
+ if (log !== undefined) openChat({ kind: 'log', id, title: `…${id.slice(-12)}`, sub: '档案' })
1192
+ }
1193
+
1194
+ // ── Header ──
1195
+ // Results first (owner signal, 0.6.24): the header carries the verdict —
1196
+ // exceptions only, one pill each; a healthy host says so quietly. The
1197
+ // forensic counters that used to sit here (idempotency claims/replays,
1198
+ // reconcile state) read on the ledger's 取证 line instead.
1199
+ function renderHeader(): void {
1200
+ // An unknown version says nothing: hide the badge rather than print 'v?'.
1201
+ versionBadge.textContent = last?.version !== undefined ? `v${last.version}` : ''
1202
+ versionBadge.hidden = last?.version === undefined
1203
+ const box = clear(headerStats)
1204
+ if (last === null) return
1205
+ // Each pill is a way in: it opens the tab (and ledger group) that holds
1206
+ // the rows behind its count.
1207
+ const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string; tab: 'ledger' | 'plan' | 'activity'; group?: string }> = []
1208
+ // Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
1209
+ // expiries are not dead letters, receiptless rows are never stale.
1210
+ const facts = ledgerFacts(last, deps.now())
1211
+ const dead = facts.deadLetters.length
1212
+ if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账', tab: 'ledger', group: '死信' })
1213
+ const stale = facts.stale.length
1214
+ if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)', tab: 'ledger', group: '陈旧' })
1215
+ const owed = facts.owedOverdue.length
1216
+ if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时', tab: 'ledger', group: '待本机回执' })
1217
+ const conflicts = last.idempotency?.conflicts ?? 0
1218
+ if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查', tab: 'ledger', group: '取证' })
1219
+ const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
1220
+ if (repair > 0) alerts.push({ text: `需修复 ${repair}`, tone: 'danger', title: 'reconcile 无法自动恢复的会话,见行动计划', tab: 'plan' })
1221
+ if (alerts.length === 0) {
1222
+ box.appendChild(el('span', 'wb-header-ok')).textContent = '● 无异常'
1223
+ return
1224
+ }
1225
+ for (const a of alerts) {
1226
+ const pill = el('button', `wb-alert wb-alert-${a.tone}`)
1227
+ pill.type = 'button'
1228
+ pill.textContent = a.text
1229
+ pill.title = `${a.title}(点击查看)`
1230
+ pill.addEventListener('click', () => openAlert(a.tab, a.group))
1231
+ box.appendChild(pill)
1232
+ }
1233
+ }
1234
+
1235
+ /** Open the pane behind a header pill. With the board up the panes are
1236
+ * rail tabs (on a narrow screen the rail slides in first); without it they
1237
+ * sit on the stage. A ledger group (or the 取证 fold) scrolls into view. */
1238
+ function openAlert(tab: 'ledger' | 'plan' | 'activity', group?: string): void {
1239
+ const pane = dataPanes.find(([k]) => k === tab)![1]
1240
+ if (mode === 'canvas') {
1241
+ root.classList.add('is-rail-open')
1242
+ railTabs.select(tab, { focus: false })
1243
+ }
1244
+ const target = group === undefined ? pane
1245
+ : group === '取证' ? pane.querySelector<HTMLDetailsElement>('details.wb-led-forensics')
1246
+ : Array.from(pane.querySelectorAll<HTMLElement>('.wb-led-group')).find(g => g.dataset.group === group) ?? null
1247
+ if (target === null) return
1248
+ if (target instanceof HTMLDetailsElement) target.open = true
1249
+ target.scrollIntoView?.({ block: 'start', behavior: 'smooth' })
1250
+ }
1251
+
1252
+ function update(state: StateBody | null): void {
1253
+ const unknown = new Set((state?.canvas?.teams ?? []).flatMap(team => team.members.filter(member => member.cliHome === null && member.cliHeld !== true).map(member => JSON.stringify([team.name, member.id]))))
1254
+ for (const key of cliHomeForms.keys()) if (!unknown.has(key)) cliHomeForms.delete(key)
1255
+ last = state
1256
+ // 0.6.12: refresh the readable-log list opportunistically (single flight;
1257
+ // the sidebar re-renders when rows land, mid-poll remount is idempotent).
1258
+ if (deps.sessionLogList !== undefined && !logRowsBusy) {
1259
+ logRowsBusy = true
1260
+ deps.sessionLogList().then(result => {
1261
+ logRows = result.ok && result.sessions !== undefined ? result.sessions : []
1262
+ logTotal = result.ok ? result.count : undefined
1263
+ }).catch(() => {}).finally(() => {
1264
+ logRowsBusy = false
1265
+ renderSidebar()
1266
+ })
1267
+ }
1268
+ renderHeader()
1269
+ renderSidebar()
1270
+ renderDraftTargets()
1271
+ renderHosts()
1272
+ renderLedger()
1273
+ // The open chat drawer refreshes in place (head + receipt statuses); its
1274
+ // thread and composer are never rebuilt by the poll.
1275
+ chat.update(state)
1276
+ renderActivity()
1277
+ renderPlan()
1278
+ renderInspector()
1279
+ // Mode is availability-derived every poll: canvas face down → data mode,
1280
+ // the board deactivates and the panes return to the stage stack.
1281
+ syncMode()
1282
+ }
1283
+
1284
+ function selectTab(key: WorkbenchTab): void {
1285
+ if (key === 'board') {
1286
+ syncMode()
1287
+ return
1288
+ }
1289
+ railTabs.select(key)
1290
+ }
1291
+
1292
+ update(null)
1293
+ return { root, update, mode: () => mode, selectTab, openNodeChat }
1294
+ }