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

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 (61) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/client.js +64 -64
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +57 -9
  9. package/lib/origin-claim.js +4 -0
  10. package/lib/skill/SKILL.md +117 -117
  11. package/lib/skill/references/collaboration.md +428 -428
  12. package/lib/skill/references/discovery.md +78 -78
  13. package/lib/skill/references/intake.md +107 -107
  14. package/lib/skill/references/membership.md +73 -73
  15. package/lib/skill/references/pitfalls.md +51 -51
  16. package/lib/skill/references/router.md +96 -96
  17. package/lib/skill/references/routing.md +65 -65
  18. package/lib/types/origin-claim.d.ts +2 -0
  19. package/nexus-stage/package.json +21 -21
  20. package/nexus-stage/scripts/mockhost.py +261 -261
  21. package/nexus-stage/src/canvas-ops.ts +191 -191
  22. package/nexus-stage/src/canvas-wire.ts +164 -164
  23. package/nexus-stage/src/cli-home-picker.ts +42 -42
  24. package/nexus-stage/src/fault.ts +56 -56
  25. package/nexus-stage/src/federation-overlay.ts +103 -103
  26. package/nexus-stage/src/federation.ts +241 -241
  27. package/nexus-stage/src/layout-doc.ts +145 -145
  28. package/nexus-stage/src/main.ts +513 -513
  29. package/nexus-stage/src/planning-card-text.ts +73 -73
  30. package/nexus-stage/src/planning-card.ts +63 -63
  31. package/nexus-stage/src/planning-edges.ts +26 -26
  32. package/nexus-stage/src/planning-fit.ts +45 -45
  33. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  34. package/nexus-stage/src/planning-legend.ts +39 -39
  35. package/nexus-stage/src/planning-member-handles.ts +61 -61
  36. package/nexus-stage/src/planning-menus.ts +422 -422
  37. package/nexus-stage/src/planning-mirror.ts +75 -75
  38. package/nexus-stage/src/planning-nodelist.ts +294 -294
  39. package/nexus-stage/src/planning-overlap.ts +159 -159
  40. package/nexus-stage/src/planning-reachability.ts +28 -28
  41. package/nexus-stage/src/planning-relayout.ts +121 -121
  42. package/nexus-stage/src/planning-roster.ts +15 -15
  43. package/nexus-stage/src/planning-search.ts +168 -168
  44. package/nexus-stage/src/planning-types.ts +115 -115
  45. package/nexus-stage/src/planning-undo.ts +68 -68
  46. package/nexus-stage/src/planning-view.ts +1499 -1499
  47. package/nexus-stage/src/planning.css +340 -340
  48. package/nexus-stage/src/seat-name-editor.ts +31 -31
  49. package/nexus-stage/src/session-drag.ts +64 -64
  50. package/nexus-stage/src/viewport.ts +106 -106
  51. package/nexus-stage/src/wb-chat.ts +684 -684
  52. package/nexus-stage/src/wb-directory.ts +237 -237
  53. package/nexus-stage/src/wb-ledger.ts +279 -279
  54. package/nexus-stage/src/wb-names.ts +43 -43
  55. package/nexus-stage/src/wb-rail.ts +147 -147
  56. package/nexus-stage/src/workbench.css +388 -388
  57. package/nexus-stage/src/workbench.ts +1272 -1272
  58. package/nexus-stage/src/world.ts +509 -509
  59. package/nexus-stage/tsconfig.json +24 -24
  60. package/nexus-stage/vite.config.ts +15 -15
  61. package/package.json +1 -1
@@ -1,1272 +1,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 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 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
+ }