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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +15 -11
  2. package/README_zh.md +3 -2
  3. package/assets/nexusDist/assets/index-DszoLz8i.js +5 -0
  4. package/assets/nexusDist/assets/index-uQf7NwjF.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +300 -0
  7. package/lib/card.js +17 -0
  8. package/lib/cli-deposit.js +141 -0
  9. package/lib/exposure-store.js +82 -0
  10. package/lib/index.js +904 -57
  11. package/lib/owner-settings.js +100 -0
  12. package/lib/peer-store.js +65 -9
  13. package/lib/task-ledger.js +44 -3
  14. package/lib/types/cli-deposit.d.ts +53 -0
  15. package/lib/types/exposure-store.d.ts +45 -0
  16. package/lib/types/index.d.ts +5 -0
  17. package/lib/types/owner-settings.d.ts +19 -0
  18. package/lib/types/peer-store.d.ts +22 -3
  19. package/lib/types/task-ledger.d.ts +18 -0
  20. package/lib/types/types.d.ts +10 -0
  21. package/nexus-stage/src/canvas-ops.ts +2 -2
  22. package/nexus-stage/src/federation.ts +6 -0
  23. package/nexus-stage/src/main.ts +97 -4
  24. package/nexus-stage/src/planning-menus.ts +16 -4
  25. package/nexus-stage/src/planning-types.ts +3 -0
  26. package/nexus-stage/src/planning-view.ts +208 -18
  27. package/nexus-stage/src/planning.css +4 -0
  28. package/nexus-stage/src/wb-chat.ts +670 -0
  29. package/nexus-stage/src/wb-directory.ts +222 -0
  30. package/nexus-stage/src/wb-ledger.ts +266 -0
  31. package/nexus-stage/src/wb-rail.ts +147 -0
  32. package/nexus-stage/src/workbench.css +190 -84
  33. package/nexus-stage/src/workbench.ts +385 -721
  34. package/nexus-stage/src/world.ts +5 -1
  35. package/package.json +18 -22
  36. package/assets/nexusDist/assets/index-034h1cNc.js +0 -3
  37. package/assets/nexusDist/assets/index-PG9EieGX.css +0 -1
  38. package/lib/types/client.d.ts +0 -73
@@ -18,6 +18,10 @@
18
18
  * state, and the active mode are view state kept across updates.
19
19
  * @module nexus-stage/workbench
20
20
  */
21
+ import { createChatDrawer, type ChatTarget } from './wb-chat'
22
+ import { createRailTabs } from './wb-rail'
23
+ import { DIRECTORY_FILTERS, learnLogTitle, loadLogTitles, renderDirectory, type DirFilter } from './wb-directory'
24
+ import { ledgerFacts, renderLedger as paintLedger } from './wb-ledger'
21
25
 
22
26
  /** One pending ledger row from /state `tasks`. */
23
27
  export interface PendingRow {
@@ -48,6 +52,35 @@ export interface DeadRow {
48
52
  readonly reason?: string
49
53
  /** First line of the dispatched message (see {@link PendingRow.brief}). */
50
54
  readonly brief?: string
55
+ /** gh#31: which side of the wire this row served (absent on old hosts). */
56
+ readonly direction?: 'inbound' | 'outbound'
57
+ }
58
+
59
+ /** gh#31: one archived row from /state `archived` (newest first) — the
60
+ * per-task outcome for a task that left the ledger. Fields past `summary`
61
+ * are absent on hosts that predate them. */
62
+ export interface ArchivedRow {
63
+ readonly taskId: string
64
+ readonly team: string
65
+ readonly resolvedAt: number
66
+ readonly outcome: string
67
+ readonly summary?: string
68
+ readonly peer?: string
69
+ readonly startedAt?: number
70
+ /** How the row left the book: a receipt, a content echo, or the caller
71
+ * (abandon / cancel — both archive with outcome 'abandoned'). */
72
+ readonly endedBy?: 'receipt' | 'echo' | 'abandoned' | 'cancelled'
73
+ /** A receipt arrived after the caller had already ended the row. */
74
+ readonly late?: boolean
75
+ readonly direction?: 'inbound' | 'outbound'
76
+ readonly brief?: string
77
+ }
78
+
79
+ /** gh#41: one tracked peer's published canvas organization (full-card
80
+ * canvasOrg) — the board renders REMOTE team frames from these rows. */
81
+ export interface PeerOrgRow {
82
+ readonly url: string
83
+ readonly org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }>
51
84
  }
52
85
 
53
86
  /** One session row from /state `sessions` (live or cold joined). */
@@ -112,15 +145,31 @@ export interface StateBody {
112
145
  readonly version?: string
113
146
  readonly sessions?: readonly SessionRow[]
114
147
  readonly canvas?: { teams?: readonly CanvasTeamRow[] }
115
- readonly peers?: ReadonlyArray<{ url: string; score?: number }>
148
+ /** gh#20 W-A/W-C: the peer row carries the link badge source, the host
149
+ * rail's label/version/last-contact facts (absent on older hosts). */
150
+ readonly peers?: ReadonlyArray<{
151
+ url: string
152
+ score?: number
153
+ link?: 'linked' | 'stranger'
154
+ verified?: boolean
155
+ name?: string
156
+ version?: string
157
+ lastContactAt?: number
158
+ /** gh#24: whether the last contact attempt succeeded. */
159
+ lastContactOk?: boolean
160
+ }>
116
161
  readonly remote?: readonly RemoteRow[]
117
162
  readonly activity?: readonly ActivityRow[]
118
163
  readonly inFlight?: ReadonlyArray<{ team: string; peer: string; startedAt: number }>
119
164
  readonly tasks?: readonly PendingRow[]
120
165
  readonly tasksStaleCount?: number
121
166
  readonly tasksDead?: readonly DeadRow[]
167
+ /** gh#31: per-task outcomes for rows that left the ledger (bounded). */
168
+ readonly archived?: readonly ArchivedRow[]
169
+ /** gh#41 fleet planning: each tracked peer's published canvas org. */
170
+ readonly peerOrg?: readonly PeerOrgRow[]
122
171
  readonly archivedCount?: number
123
- /** Settled-archive split by verdict ('completed' | 'declined' | … | 'abandoned' | 'unclassified'). */
172
+ /** Settled-archive split by verdict (the receipt outcome vocabulary — 'completed' | 'rejected' | 'failed' | … | 'abandoned' — or 'unclassified'). */
124
173
  readonly archivedSplit?: Readonly<Record<string, number>>
125
174
  readonly idempotency?: {
126
175
  window?: number
@@ -140,6 +189,15 @@ export interface StateBody {
140
189
  /** What the workbench can DO — the seams tests drive. */
141
190
  export interface WorkbenchDeps {
142
191
  now(): number
192
+ /** gh#41 fleet planning: each tracked peer's published canvas org. */
193
+ peerOrg?: readonly PeerOrgRow[]
194
+ /** gh#34: read the owner three-key panel state (GET /__dsh_a2a/owner-settings). */
195
+ ownerSettingsRead?(): Promise<{ ok: boolean; teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean; announceRestartPending?: boolean; error?: string }>
196
+ /** gh#34: write owner panel changes; true on success (POST the same face). */
197
+ ownerSettingsWrite?(changes: { teamJoinAllowlist?: string[]; teamScopeInfo?: boolean; announce?: boolean }): Promise<{ ok: boolean; restartNeeded?: boolean; error?: string }>
198
+ /** gh#41 fleet planning: regulate a REMOTE peer's canvas team (this host
199
+ * signs the envelope and forwards; the peer's link gate authorizes). */
200
+ fleetCanvasOp?(peer: string, op: 'create' | 'add-member' | 'remove-member', name: string, id?: string): Promise<{ ok: boolean; members?: string[]; error?: string }>
143
201
  /** Abandon one dead-lettered task via the control route. True on success. */
144
202
  abandon(taskId: string, reason: string): Promise<boolean>
145
203
  /** Canvas team member change via the control route (0.6.6): `id` is a
@@ -155,7 +213,7 @@ export interface WorkbenchDeps {
155
213
  /** 0.6.12: list every local session holding a log artifact (newest first) —
156
214
  * the reading face is host-scoped, so archived and never-joined sessions
157
215
  * appear here even when the network directory shows no local rows. */
158
- sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; error?: string }>
216
+ sessionLogList?(): Promise<{ ok: boolean; sessions?: Array<{ id: string; mtimeMs: number; sizeBytes: number }>; count?: number; error?: string }>
159
217
  /** 0.6.15: remote transcript via the local proxy — ?team=<zone/id8>
160
218
  * resolves the publishing peer through the directory, ?peer=<base-url>
161
219
  * reads one tracked peer directly. Without id the peer answers its
@@ -199,30 +257,6 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string):
199
257
  return node
200
258
  }
201
259
 
202
- /** One labelled counter in a pane's stat strip; `title` carries the definition
203
- * (native tooltip — the definition lives with the number it explains). */
204
- function statChip(label: string, value: number, hint: string, tone?: 'warn' | 'danger'): HTMLElement {
205
- const chip = el('span', 'wb-stat')
206
- if (tone !== undefined) chip.classList.add(`wb-stat-${tone}`)
207
- chip.setAttribute('title', hint)
208
- chip.appendChild(el('span', 'wb-stat-k')).textContent = label
209
- chip.appendChild(el('span', 'wb-stat-v')).textContent = String(value)
210
- return chip
211
- }
212
-
213
- /** One proportional bar: label · track · count. Cheap density for a histogram. */
214
- function barRow(parent: HTMLElement, label: string, count: number, max: number): void {
215
- const row = el('div', 'wb-bar-row')
216
- row.appendChild(el('span', 'wb-bar-k')).textContent = label
217
- const track = el('span', 'wb-bar-track')
218
- const fill = el('span', 'wb-bar-fill')
219
- fill.style.width = `${max > 0 ? Math.max(count > 0 ? 2 : 0, Math.round((count / max) * 100)) : 0}%`
220
- track.appendChild(fill)
221
- row.appendChild(track)
222
- row.appendChild(el('span', 'wb-bar-v')).textContent = String(count)
223
- parent.appendChild(row)
224
- }
225
-
226
260
  function textRow(parent: HTMLElement, label: string, value: string): void {
227
261
  const row = el('div', 'wb-kv')
228
262
  row.appendChild(el('span', 'wb-kv-k')).textContent = label
@@ -234,40 +268,9 @@ type Selection =
234
268
  | { kind: 'canvas'; row: CanvasTeamRow }
235
269
  | undefined
236
270
 
237
- /** Chat drawer target (0.6.13, remote 0.6.15): a node picked on the board or in the
238
- * directory — session/log get the local transcript tail, remote rows and peers
239
- * read through the /a2a/log proxy. Reading, not jumping. */
240
- type ChatTarget =
241
- | { kind: 'session'; id: string; title: string; sub: string; team?: string; live?: boolean }
242
- | { kind: 'log'; id: string; title: string; sub: string }
243
- | { kind: 'peer'; id: string; title: string; sub: string; url?: string; picked?: string }
244
- | { kind: 'remote'; team: string; title: string; sub: string; row?: RemoteRow }
245
-
246
- /** The four rail sections. */
247
- export type WorkbenchSection = 'directory' | 'ledger' | 'activity' | 'plan'
248
-
249
- /** Directory category filter (0.6.6): one chip per directory section group. */
250
- type DirFilter = 'all' | 'local' | 'canvas' | 'peers' | 'remote'
271
+ /** The rail sections. */
272
+ export type WorkbenchSection = 'directory' | 'hosts' | 'ledger' | 'activity' | 'plan'
251
273
 
252
- const DIRECTORY_FILTERS: ReadonlyArray<{ key: DirFilter; label: string; hint: string }> = [
253
- { key: 'all', label: '全部', hint: '不过滤:目录全部条目' },
254
- { key: 'local', label: '本机', hint: '本机会话节点(现场 + 冷启动)' },
255
- { key: 'canvas', label: '队', hint: '画布队(人类手工分配的组队)' },
256
- { key: 'peers', label: 'Peers', hint: '对等节点(peer 池)' },
257
- { key: 'remote', label: '远端', hint: '远端会话队(来自对端卡片)' },
258
- ]
259
-
260
- /** Lowercased haystack for the unified search: name/team/label/id/url/teams. */
261
- function rowHaystack(...parts: ReadonlyArray<string | readonly string[] | undefined>): string {
262
- const flat: string[] = []
263
- for (const part of parts) {
264
- if (part === undefined) continue
265
- if (typeof part === 'string') {
266
- if (part !== '') flat.push(part)
267
- } else for (const entry of part) if (entry !== '') flat.push(entry)
268
- }
269
- return flat.join(' ').toLowerCase()
270
- }
271
274
 
272
275
  /** Legacy deep-link targets: 'board' focuses the stage, sections open a rail section. */
273
276
  export type WorkbenchTab = 'board' | WorkbenchSection
@@ -289,8 +292,21 @@ export interface Workbench {
289
292
  openNodeChat(id: string): void
290
293
  }
291
294
 
295
+ /** Bare host:port of a peer url (workbench-local twin of federation's
296
+ * helper — this module keeps a zero-import posture). */
297
+ function peerHostOf(url: string): string {
298
+ try {
299
+ return new URL(url).host
300
+ } catch {
301
+ return url
302
+ }
303
+ }
304
+
305
+ /** Rail tabs (2026-10-23): 主机 moved up next to 目录 — host health was the
306
+ * last accordion section and sat below the fold. */
292
307
  const SECTION_DEFS: ReadonlyArray<{ key: WorkbenchSection; label: string }> = [
293
308
  { key: 'directory', label: '目录' },
309
+ { key: 'hosts', label: '主机' },
294
310
  { key: 'ledger', label: '台账' },
295
311
  { key: 'activity', label: '活动' },
296
312
  { key: 'plan', label: '规划' },
@@ -301,7 +317,6 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
301
317
  let mode: WorkbenchMode = 'data'
302
318
  let selection: Selection
303
319
  let last: StateBody | null = null
304
- const collapsed = new Set<WorkbenchSection>(['activity', 'plan'])
305
320
  // Directory view state (survives polls): unified search + category filter.
306
321
  let searchText = ''
307
322
  let dirFilter: DirFilter = 'all'
@@ -311,27 +326,14 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
311
326
  // Quick-dispatch form (0.6.9), cached per target routing team the same
312
327
  // draftForm way: a textarea's draft must survive the 3s poll remount.
313
328
  let dispatchForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
314
- // Chat drawer (0.6.13): the picked node + its own dispatch form cache
315
- // (separate from the inspector's so both faces can hold different teams).
316
- let chatTarget: ChatTarget | null = null
317
- /** 0.6.17: one-shot composer focus on drawer open (consumed by renderChat). */
318
- let chatFocusPending = false
319
- let chatForm: { team: string; form: HTMLFormElement; text: HTMLTextAreaElement; note: HTMLElement } | null = null
320
- // 0.6.11: inline transcript cache — one fetch per session id, refreshed on
321
- // demand. renderInspector rebuilds every poll, so the cache keeps the
322
- // transcript block from refetching on each 3s tick.
323
- const transcriptCache = new Map<string, { ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string } | undefined>()
324
- /** 0.6.17 chat-UI convention: reading position per transcript key — the
325
- * 3s poll rebuilds the body, the scroll restores instead of jumping top. */
326
- const transcriptScroll = new Map<string, number>()
327
- /** Owner signal (2026-10-21): transcripts default to a collapsed detail —
328
- * the seats' forensics face, not the owner's. Expansion persists per key. */
329
- const expandedTranscripts = new Set<string>()
330
- /** 0.6.15: peer-host session listings, cached per peer url. */
331
- const peerListCache = new Map<string, { ok: boolean; sessions?: Array<{ id: string; sizeBytes: number }>; error?: string } | undefined>()
332
329
  // 0.6.12: readable local logs (host-scope list, newest first). Refreshed
333
330
  // opportunistically per poll; rows render in the directory's 本机日志 section.
334
331
  let logRows: Array<{ id: string; mtimeMs: number; sizeBytes: number }> = []
332
+ /** How many logs the host holds (the list face caps at 50); from the listing's count. */
333
+ let logTotal: number | undefined
334
+ /** Learned 本机日志 titles: the first user line of a transcript, captured
335
+ * when the chat drawer loads it (the list face carries no titles). */
336
+ const logTitles = loadLogTitles()
335
337
  let logRowsBusy = false
336
338
 
337
339
  const root = el('div', 'wb')
@@ -351,6 +353,16 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
351
353
  const versionBadge = el('span', 'wb-badge')
352
354
  title.appendChild(versionBadge)
353
355
  header.appendChild(title)
356
+ // gh#34 owner three-key panel: the header gear opens the inspector panel
357
+ // (teamJoinAllowlist / teamScopeInfo / announce) — 远程建联三件不再翻配置文件。
358
+ const ownerBtn = el('button', 'wb-icon-btn wb-owner-btn')
359
+ ownerBtn.type = 'button'
360
+ ownerBtn.dataset.testid = 'wb-owner-btn'
361
+ ownerBtn.setAttribute('aria-label', '主人设置(三键面板)')
362
+ ownerBtn.title = '主人设置:入网白名单 / 可见性 / 发布'
363
+ ownerBtn.textContent = '⚙'
364
+ ownerBtn.addEventListener('click', () => { ownerPanelOpen = !ownerPanelOpen; renderOwnerPanel() })
365
+ header.appendChild(ownerBtn)
354
366
  const headerStats = el('div', 'wb-header-stats')
355
367
  header.appendChild(headerStats)
356
368
  const railToggle = el('button', 'wb-icon-btn wb-rail-toggle')
@@ -367,32 +379,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
367
379
  header.appendChild(railToggle)
368
380
 
369
381
  const body = el('div', 'wb-body')
370
- const rail = el('aside', 'wb-rail')
371
-
372
- // Rail sections (accordion). Panes are created once and re-parented
373
- // between section bodies (canvas mode) and the stage stack (data mode).
374
- const sectionBodies = new Map<WorkbenchSection, HTMLElement>()
375
- const sectionCounts = new Map<WorkbenchSection, HTMLElement>()
376
- for (const def of SECTION_DEFS) {
377
- const sec = el('section', 'wb-sec')
378
- sec.dataset.sec = def.key
379
- const head = el('button', 'wb-sec-head')
380
- head.type = 'button'
381
- head.appendChild(el('span', 'wb-sec-label')).textContent = def.label
382
- const count = el('span', 'wb-sec-count')
383
- head.appendChild(count)
384
- head.addEventListener('click', () => {
385
- if (collapsed.has(def.key)) collapsed.delete(def.key)
386
- else collapsed.add(def.key)
387
- syncSections()
388
- })
389
- sec.appendChild(head)
390
- const secBody = el('div', 'wb-sec-body')
391
- sec.appendChild(secBody)
392
- rail.appendChild(sec)
393
- sectionBodies.set(def.key, secBody)
394
- sectionCounts.set(def.key, count)
395
- }
382
+ // Rail tabs (2026-10-23, wb-rail): one panel per pane, each with its own
383
+ // scroll. Panes are created once and re-parented between tab panels
384
+ // (canvas mode) and the stage stack (data mode).
385
+ const railTabs = createRailTabs(SECTION_DEFS)
386
+ const rail = railTabs.root
396
387
 
397
388
  const directoryBody = el('div', 'wb-directory')
398
389
  // Directory toolbar: unified search + category filter chips (0.6.6). Built
@@ -423,16 +414,19 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
423
414
  filterRow.appendChild(chip)
424
415
  }
425
416
  directoryTools.appendChild(filterRow)
426
- sectionBodies.get('directory')!.appendChild(directoryTools)
427
- sectionBodies.get('directory')!.appendChild(directoryBody)
417
+ railTabs.panel('directory').appendChild(directoryTools)
418
+ railTabs.panel('directory').appendChild(directoryBody)
428
419
 
429
420
  const ledgerPane = el('section', 'wb-pane')
430
421
  const activityPane = el('section', 'wb-pane')
431
422
  const planPane = el('section', 'wb-pane')
423
+ // gh#20 W-C: the host rail pane (one row per tracked peer).
424
+ const hostsPane = el('section', 'wb-pane')
432
425
  const dataPanes: ReadonlyArray<[WorkbenchSection, HTMLElement]> = [
433
426
  ['ledger', ledgerPane],
434
427
  ['activity', activityPane],
435
428
  ['plan', planPane],
429
+ ['hosts', hostsPane],
436
430
  ]
437
431
 
438
432
  const stage = el('main', 'wb-stage')
@@ -456,64 +450,35 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
456
450
  const inspectorBody = el('div', 'wb-inspector-body')
457
451
  inspector.appendChild(inspectorBody)
458
452
 
459
- // ── Node chat drawer (0.6.13) ──
460
- // Clicking a node — on the planning board or a directory session/log/peer
461
- // row — opens the conversation reading face as a right-side chat UI. The
462
- // transcript tail is local-only; peers get the honest cross-host note.
463
- const chat = el('aside', 'wb-chat')
464
- const chatHead = el('div', 'wb-chat-head')
465
- const chatDot = el('span', 'wb-chat-dot')
466
- const chatTitle = el('span', 'wb-chat-title')
467
- const chatSub = el('span', 'wb-chat-sub')
468
- const chatClose = el('button', 'wb-icon-btn')
469
- chatClose.type = 'button'
470
- chatClose.setAttribute('aria-label', '关闭对话')
471
- chatClose.textContent = '✕'
472
- chatClose.addEventListener('click', () => closeChat())
473
- chatHead.appendChild(chatDot)
474
- chatHead.appendChild(chatTitle)
475
- chatHead.appendChild(chatSub)
476
- chatHead.appendChild(chatClose)
477
- const chatBody = el('div', 'wb-chat-body')
478
- chat.appendChild(chatHead)
479
- chat.appendChild(chatBody)
480
- // 0.6.17 chat-UI convention: Escape closes the drawer. Document-scoped
481
- // (a reading drawer often holds no focusable element), but it never
482
- // steals Escape from another surface's text field or from the planning
483
- // board's own deselect gesture.
484
- document.addEventListener('keydown', ev => {
485
- if (ev.key !== 'Escape' || !chat.classList.contains('is-open')) return
486
- const target = ev.target
487
- if (!(target instanceof HTMLElement)) return
488
- const insideChat = chat.contains(target)
489
- if (!insideChat) {
490
- const typingElsewhere = target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement || target.isContentEditable
491
- const onBoard = target.closest('.wb-stage-board') !== null
492
- if (typingElsewhere || onBoard) return
493
- }
494
- closeChat()
453
+ // ── Node chat drawer (0.6.13; rewritten 2026-10-23 as wb-chat) ──
454
+ // Clicking a node — on the planning board or a directory row — opens the
455
+ // conversation as a right-side chat: thread + composer + receipt tracking.
456
+ const chat = createChatDrawer({
457
+ now: deps.now,
458
+ sessionLog: deps.sessionLog,
459
+ remoteSessionLog: deps.remoteSessionLog,
460
+ dispatchQuick: deps.dispatchQuick,
461
+ onTranscript: (target, messages) => {
462
+ if (target.kind !== 'log' && target.kind !== 'session') return
463
+ if (learnLogTitle(logTitles, target.id, messages)) renderSidebar()
464
+ },
495
465
  })
496
466
 
497
467
  stage.appendChild(dataStack)
498
468
  body.appendChild(rail)
499
469
  body.appendChild(stage)
500
470
  body.appendChild(inspector)
501
- body.appendChild(chat)
471
+ body.appendChild(chat.root)
502
472
  root.appendChild(header)
503
473
  root.appendChild(body)
504
474
 
505
- function syncSections(): void {
506
- for (const def of SECTION_DEFS) {
507
- const sec = sectionBodies.get(def.key)!.parentElement!
508
- sec.classList.toggle('is-collapsed', collapsed.has(def.key))
509
- }
510
- }
511
-
512
475
  /** Move the data panes to their mode home: rail sections while the board
513
476
  * owns the stage, the stage stack when the board is out. */
514
477
  function placePanes(m: WorkbenchMode): void {
515
478
  for (const [key, pane] of dataPanes) {
516
- const home = m === 'canvas' ? sectionBodies.get(key)! : dataStack
479
+ const home = m === 'canvas' ? railTabs.panel(key) : dataStack
480
+ // Data mode: the pane lives on the stage, so its tab steps aside.
481
+ railTabs.setHidden(key, m !== 'canvas')
517
482
  if (pane.parentElement !== home) home.appendChild(pane)
518
483
  }
519
484
  }
@@ -554,284 +519,127 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
554
519
 
555
520
  // ── Directory section ──
556
521
  function renderSidebar(): void {
557
- const box = clear(directoryBody)
558
522
  // Filter chips reflect the live view state (built once, toggled here).
559
523
  for (let i = 0; i < filterRow.children.length; i++) {
560
524
  const node = filterRow.children[i] as HTMLElement
561
525
  node.classList.toggle('is-active', node.dataset.filter === dirFilter)
562
526
  }
563
527
  if (last === null) {
564
- box.appendChild(el('div', 'wb-muted')).textContent = '载入中…'
565
- sectionCounts.get('directory')!.textContent = ''
528
+ directoryBody.replaceChildren(el('div', 'wb-muted'))
529
+ directoryBody.firstElementChild!.textContent = '载入中…'
530
+ railTabs.setBadge('directory', '')
566
531
  return
567
532
  }
568
- const state = last
569
- const filtering = searchText !== '' || dirFilter !== 'all'
570
- /** One directory row factory with the shared search+filter gate. */
571
- const admit = (cat: DirFilter, haystack: string): boolean =>
572
- (dirFilter === 'all' || dirFilter === cat) && (searchText === '' || haystack.includes(searchText))
573
- /** Canvas-team badge for roster annotation (▤ + short name). */
574
- const badges = (item: HTMLElement, teams: readonly string[] | undefined): void => {
575
- if (teams === undefined || teams.length === 0) return
576
- const chip = el('span', 'wb-item-badge')
577
- const names = teams.map(t => t.slice(t.lastIndexOf('/') + 1))
578
- chip.textContent = `▤ ${names.slice(0, 3).join(' ')}${names.length > 3 ? ` +${names.length - 3}` : ''}`
579
- chip.title = `画布队:${teams.join(' · ')}`
580
- item.appendChild(chip)
581
- }
582
- const section = (label: string, count: number): HTMLElement => {
583
- const head = el('div', 'wb-side-head')
584
- head.textContent = `${label} · ${count}`
585
- box.appendChild(head)
586
- return box
587
- }
588
- const sessions = state.sessions ?? []
589
- const live = sessions.filter(row => row.live === true && admit('local', rowHaystack(displayName(row), row.team, row.label, row.id, row.canvasTeams)))
590
- const cold = sessions.filter(row => row.live !== true && admit('local', rowHaystack(displayName(row), row.team, row.label, row.id, row.canvasTeams)))
591
- sectionCounts.get('directory')!.textContent = `${live.length}/${cold.length}${logRows.length > 0 ? ` · 档 ${logRows.length}` : ''}`
592
- if (live.length > 0) {
593
- section('本机 · 现场', live.length)
594
- for (const row of live) {
595
- const item = el('button', 'wb-item')
596
- item.type = 'button'
597
- item.appendChild(el('span', 'wb-dot wb-dot-live')).textContent = '●'
598
- item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
599
- badges(item, row.canvasTeams)
600
- item.addEventListener('click', () => openChat({ kind: 'session', id: row.id, title: displayName(row), sub: row.team, team: row.team, live: row.live }))
601
- box.appendChild(item)
602
- }
603
- }
604
- if (cold.length > 0) {
605
- section('本机 · 冷启动', cold.length)
606
- for (const row of cold) {
607
- const item = el('button', 'wb-item')
608
- item.type = 'button'
609
- item.appendChild(el('span', 'wb-dot wb-dot-cold')).textContent = '○'
610
- item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
611
- badges(item, row.canvasTeams)
612
- item.addEventListener('click', () => openChat({ kind: 'session', id: row.id, title: displayName(row), sub: row.team, team: row.team, live: row.live }))
613
- box.appendChild(item)
614
- }
615
- }
616
- const canvases = (state.canvas?.teams ?? []).filter(row =>
617
- admit('canvas', rowHaystack(row.name, row.team, row.members.map(member => member.team ?? member.id))))
618
- if (canvases.length > 0) {
619
- section('Canvas 队', canvases.length)
620
- for (const row of canvases) {
621
- const item = el('button', 'wb-item')
622
- item.type = 'button'
623
- item.appendChild(el('span', 'wb-dot wb-dot-canvas')).textContent = '▤'
624
- const remote = row.members.filter(member => member.remote === true).length
625
- item.appendChild(el('span', 'wb-item-label')).textContent =
626
- `${row.name} · ${row.members.length}${remote > 0 ? `(远端 ${remote})` : ''}`
627
- item.addEventListener('click', () => select({ kind: 'canvas', row }))
628
- box.appendChild(item)
629
- }
630
- }
631
- const peers = (state.peers ?? []).filter(row => admit('peers', rowHaystack(row.url)))
632
- if (peers.length > 0) {
633
- section('Peers', peers.length)
634
- for (const row of peers) {
635
- const item = el('button', 'wb-item')
636
- item.type = 'button'
637
- item.appendChild(el('span', 'wb-dot wb-dot-peer')).textContent = '⇄'
638
- item.appendChild(el('span', 'wb-item-label')).textContent =
639
- row.url.replace(/^https?:\/\//, '') + (row.score !== undefined ? ` · ${row.score}` : '')
640
- item.addEventListener('click', () => {
641
- const host = row.url.replace(/^https?:\/\//, '').replace(/\/.*$/, '')
642
- openChat({ kind: 'peer', id: `peer-${host}`, title: row.url, sub: host, url: row.url })
643
- })
644
- box.appendChild(item)
645
- }
646
- }
647
- const remote = (state.remote ?? []).filter(row => admit('remote', rowHaystack(displayName(row), row.team, row.origin, row.workspace, row.canvasTeams)))
648
- if (remote.length > 0) {
649
- section('远端节点', remote.length)
650
- for (const row of remote) {
651
- const item = el('button', 'wb-item')
652
- item.type = 'button'
653
- item.appendChild(el('span', 'wb-dot wb-dot-remote')).textContent = '◇'
654
- item.appendChild(el('span', 'wb-item-label')).textContent = displayName(row)
655
- badges(item, row.canvasTeams)
656
- // 0.6.15: remote rows open the chat drawer — the transcript now
657
- // reads through the proxy instead of the where-the-records-live note.
658
- item.addEventListener('click', () => openChat({ kind: 'remote', team: row.team, title: displayName(row), sub: row.team, row }))
659
- box.appendChild(item)
660
- }
661
- }
662
- // 0.6.12: 本机日志 — every local session artifact EXCEPT ids already shown
663
- // as network rows (those open the same transcript from their own cards).
664
- // This is the reachability fix for hosts whose cold rows are all archived:
665
- // reading is host-scoped, the network directory is not the reading gate.
666
- const networkIds = new Set(sessions.map(row => row.id))
667
- const fmtSize = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
668
- const logs = logRows.filter(row => !networkIds.has(row.id) && admit('local', rowHaystack(row.id)))
669
- if (logs.length > 0) {
670
- section('本机日志', logs.length)
671
- for (const row of logs) {
672
- const item = el('button', 'wb-item')
673
- item.type = 'button'
674
- item.appendChild(el('span', 'wb-dot')).textContent = '❐'
675
- const label = el('span', 'wb-item-label')
676
- label.textContent = `…${row.id.slice(-12)} · ${fmtSize(row.sizeBytes)}`
677
- item.title = `${row.id}\n${fmtSize(row.sizeBytes)} · ${new Date(row.mtimeMs).toLocaleString()}`
678
- item.appendChild(label)
679
- item.addEventListener('click', () => openChat({ kind: 'log', id: row.id, title: `…${row.id.slice(-12)}`, sub: '档案' }))
680
- box.appendChild(item)
681
- }
682
- }
683
- if (filtering && live.length + cold.length + canvases.length + peers.length + remote.length + logs.length === 0) {
684
- box.appendChild(el('div', 'wb-muted')).textContent = '无匹配条目'
685
- }
533
+ const badge = renderDirectory(directoryBody, {
534
+ state: last,
535
+ logs: logRows,
536
+ logTotal,
537
+ titles: logTitles,
538
+ search: searchText,
539
+ filter: dirFilter,
540
+ now: deps.now(),
541
+ openChat,
542
+ selectCanvas: (row) => select({ kind: 'canvas', row }),
543
+ })
544
+ railTabs.setBadge('directory', badge)
686
545
  }
687
546
 
688
- // ── Ledger pane ──
689
- /** Age buckets for debt: what the ledger is actually made of, at a glance.
690
- * Boundaries mirror the C-ruling split (1h = settleable→conversational). */
691
- const AGE_BUCKETS: ReadonlyArray<{ label: string; maxMs: number }> = [
692
- { label: '<10m', maxMs: 10 * 60_000 },
693
- { label: '10m-1h', maxMs: 60 * 60_000 },
694
- { label: '1-6h', maxMs: 6 * 60 * 60_000 },
695
- { label: '6-24h', maxMs: 24 * 60 * 60_000 },
696
- { label: '>24h', maxMs: Number.POSITIVE_INFINITY },
697
- ]
698
-
699
- /** Group pending rows by age bucket; returns buckets in age order with counts. */
700
- function ageHistogram(rows: readonly PendingRow[], now: number): number[] {
701
- const counts = AGE_BUCKETS.map(() => 0)
702
- for (const row of rows) {
703
- const age = now - row.startedAt
704
- const index = AGE_BUCKETS.findIndex(bucket => age < bucket.maxMs)
705
- counts[index === -1 ? counts.length - 1 : index]! += 1
547
+ /**
548
+ * gh#20 W-C: the host rail — one row per tracked peer host. Each row is a
549
+ * HOST card: operator-facing label (the peer's published card name, host
550
+ * fallback), the W-A tri-state badge (link class + verified mark), a
551
+ * health lamp driven by the last-contact recency (green ≤60s, amber
552
+ * ≤5min, gray older/never — tooltip carries the exact timestamp), and the
553
+ * peer's published version chip. Clicking drills down: the peer chat
554
+ * drawer (remote log entry) rides the existing face.
555
+ */
556
+ function renderHosts(): void {
557
+ const pane = clear(hostsPane)
558
+ if (last === null) {
559
+ railTabs.setBadge('hosts', '')
560
+ return
706
561
  }
707
- return counts
708
- }
709
-
710
- /** Top teams by pending-row count, most loaded first (bounded to 5). */
711
- function topTeams(rows: readonly PendingRow[]): Array<[string, number]> {
712
- const counts = new Map<string, number>()
713
- for (const row of rows) counts.set(row.team, (counts.get(row.team) ?? 0) + 1)
714
- return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
715
- }
716
-
717
- function renderLedger(): void {
718
- const pane = clear(ledgerPane)
719
- if (last === null) return
720
562
  const now = deps.now()
721
- const pending = last.tasks ?? []
722
- const dead = last.tasksDead ?? []
723
- const stale = last.tasksStaleCount ?? 0
724
- const archived = last.archivedCount ?? 0
725
- sectionCounts.get('ledger')!.textContent = `待 ${pending.length} · 死 ${dead.length}`
726
-
727
- // Stat strip: the four headline numbers, each carrying its definition.
728
- const stats = el('div', 'wb-stats')
729
- stats.appendChild(statChip('待回执', pending.length,
730
- '等待回执的任务(pending)。其中「陈旧」= 超过 1 小时仍未回执,或本就无回执债(fire-and-forget)'))
731
- stats.appendChild(statChip('陈旧', stale,
732
- '陈旧尾:≥1h 无回执的旧账 + receiptExpected:false 的免回执行。它们到 24h TTL 自动转死信,不算可催办债务',
733
- stale > 0 ? 'warn' : undefined))
734
- stats.appendChild(statChip('死信', dead.length,
735
- '超过 24h TTL 仍未结算,或目标早已离开。需清账(abandon)才能移出',
736
- dead.length > 0 ? 'danger' : undefined))
737
- stats.appendChild(statChip('已归档', archived,
738
- '已结算并留档的任务(回执已关联)。用于审计,不再占待回执名额'))
739
- pane.appendChild(stats)
740
-
741
- // Archive verdict split: how those settled rows actually ended.
742
- const split = last.archivedSplit
743
- if (archived > 0 && split !== undefined) {
744
- const entries = Object.entries(split).sort((a, b) => b[1] - a[1])
745
- if (entries.length > 0) {
746
- const line = el('div', 'wb-pane-head')
747
- line.textContent = `归档结局 · ${entries.map(([key, count]) => `${key} ${count}`).join(' · ')}`
748
- pane.appendChild(line)
749
- }
750
- }
751
-
752
- // Forensics (moved off the header): the plumbing counters a seat reads
753
- // when something looks off — muted, never the owner's first line.
754
- const idem = last.idempotency
755
- const reconcileState = last.reconcile?.state
756
- if (idem !== undefined || reconcileState !== undefined) {
757
- const parts: string[] = []
758
- if (idem !== undefined) parts.push(`幂等 claims ${idem.claimsFresh ?? 0} · replays ${idem.replays ?? 0} · conflicts ${idem.conflicts ?? 0}`)
759
- if (reconcileState !== undefined) parts.push(`reconcile ${reconcileState}`)
760
- const line = el('div', 'wb-pane-head wb-forensics')
761
- line.textContent = `取证 · ${parts.join(' · ')}`
762
- pane.appendChild(line)
563
+ const peers = [...(last.peers ?? [])].sort((a, b) => {
564
+ const rank = (row: { link?: string }): number => row.link === 'linked' ? 0 : 1
565
+ return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0)
566
+ })
567
+ railTabs.setBadge('hosts', peers.length > 0 ? `${peers.filter(row => row.link === 'linked').length}/${peers.length}` : '')
568
+ if (peers.length === 0) {
569
+ pane.appendChild(el('div', 'wb-nodelist-empty')).textContent = '暂无 tracked peer——配 peers 种子或等 referral 到达'
570
+ return
763
571
  }
764
-
765
- if (pending.length > 0) {
766
- // Age histogram + team load: WHERE the debt sits and how old it is.
767
- const counts = ageHistogram(pending, now)
768
- const max = Math.max(...counts, 1)
769
- const hist = el('div', 'wb-hist')
770
- for (let i = 0; i < AGE_BUCKETS.length; i += 1) {
771
- barRow(hist, AGE_BUCKETS[i]!.label, counts[i]!, max)
572
+ for (const row of peers) {
573
+ const host = peerHostOf(row.url)
574
+ const lineEl = el('div', 'wb-host-row')
575
+ // Health lamp (gh#24 semantics): recency alone lied — a FAILED fresh
576
+ // contact glowed green. Tiers by age: ≤60s live / ≤5min warm /
577
+ // beyond cold, overridden by the OUTCOME: a recent failure (≤5min)
578
+ // is red regardless of the live/warm band, and a never-contacted
579
+ // peer is the hollow unknown state.
580
+ const lamp = el('span', 'wb-host-lamp')
581
+ const last = row.lastContactAt
582
+ const age = last === undefined ? undefined : now - last
583
+ const ok = row.lastContactOk
584
+ if (age === undefined) lamp.classList.add('is-unknown')
585
+ else if (ok === false && age <= 5 * 60_000) lamp.classList.add('is-fail')
586
+ else if (age <= 60_000) lamp.classList.add('is-live')
587
+ else if (age <= 5 * 60_000) lamp.classList.add('is-warm')
588
+ else lamp.classList.add('is-cold')
589
+ lamp.title = last === undefined
590
+ ? '尚无接触记录'
591
+ : `最近接触 ${ageText(last, now)}${ok === false ? '(失败)' : ok === true ? '(成功)' : ''}`
592
+ lineEl.appendChild(lamp)
593
+ // Label: the peer's published name, host fallback.
594
+ lineEl.appendChild(el('span', 'wb-host-name')).textContent = row.name !== undefined && row.name !== '' ? row.name : host
595
+ // W-A badge: link class + verified mark.
596
+ const badge = el('span', `wb-host-badge ${row.link === 'linked' ? 'is-linked' : 'is-stranger'}`)
597
+ badge.textContent = `${row.link === 'linked' ? 'linked' : 'stranger'} ${row.verified === true ? '✓' : '✗'}`
598
+ // The tooltip follows the verified mark (gh#24): a linked ✗ must not
599
+ // claim a valid signature.
600
+ badge.title = row.link === 'linked'
601
+ ? `已建立运营者链接(互种或 canvas 引用);${row.verified === true ? '验签有效' : '尚未持有验签通过的卡片'}`
602
+ : '无运营者关系(验签 ≠ 关系)'
603
+ lineEl.appendChild(badge)
604
+ // Version chip from the warm card.
605
+ if (row.version !== undefined && row.version !== '') {
606
+ lineEl.appendChild(el('span', 'wb-host-ver')).textContent = row.version
772
607
  }
773
- pane.appendChild(hist)
774
- const teams = topTeams(pending)
775
- const teamLine = el('div', 'wb-pane-head')
776
- teamLine.textContent = `债务队 Top${teams.length} · ${teams.map(([team, count]) => `${team} ${count}`).join(' · ')}`
777
- pane.appendChild(teamLine)
608
+ lineEl.appendChild(el('span', 'wb-host-url mono')).textContent = host
609
+ lineEl.setAttribute('role', 'button')
610
+ lineEl.tabIndex = 0
611
+ lineEl.title = `${row.url} · score ${String(row.score ?? 0)}`
612
+ const drill = (): void => { openChat({ kind: 'peer', id: `peer-${host}`, title: row.name !== undefined && row.name !== '' ? row.name : host, sub: host, url: row.url }) }
613
+ lineEl.addEventListener('click', drill)
614
+ lineEl.addEventListener('keydown', ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); drill() } })
615
+ pane.appendChild(lineEl)
778
616
  }
617
+ }
779
618
 
780
- const table = el('div', 'wb-table')
781
- for (const row of pending) {
782
- // Owner signal (2026-10-21): the ledger is a RESULTS face — the brief
783
- // is the primary copy, ids/teams/ages are muted forensics underneath.
784
- // Exception-driven: only overdue (>1h) rows raise their voice; normal
785
- // rows stay quiet so the debt that needs eyes stands out.
786
- const overdue = now - row.startedAt > 60 * 60_000
787
- const line = el('div', `wb-row wb-row-result${overdue ? ' wb-row-alert' : ''}`)
788
- const briefText = row.brief ?? '(无摘要——派发未带 brief)'
789
- const brief = el('span', 'wb-row-head')
790
- brief.textContent = briefText
791
- brief.title = briefText
792
- line.appendChild(brief)
793
- const badges: string[] = []
794
- if (row.settleable === true) badges.push('可结算')
795
- if (row.receiptExpected === false) badges.push('无回执债')
796
- if (row.direction === 'inbound') badges.push('入站')
797
- const meta = el('span', 'wb-row-meta-line')
798
- meta.textContent = `${row.taskId} · ${row.team} · ${ageText(row.startedAt, now)}${badges.length > 0 ? ` · ${badges.join(' ')}` : ''}`
799
- line.appendChild(meta)
800
- table.appendChild(line)
801
- }
802
- pane.appendChild(table)
803
- if (dead.length > 0) {
804
- const deadHead = el('div', 'wb-pane-subhead')
805
- deadHead.textContent = '死信(可清账)'
806
- pane.appendChild(deadHead)
807
- const deadTable = el('div', 'wb-table wb-table-dead')
808
- for (const row of dead) {
809
- // Same results-first shape, always exception-colored: dead letters
810
- // ARE the alarm.
811
- const line = el('div', 'wb-row wb-row-result wb-row-alert')
812
- const deadBriefText = row.brief ?? '(无摘要)'
813
- const deadBrief = el('span', 'wb-row-head')
814
- deadBrief.textContent = deadBriefText
815
- deadBrief.title = deadBriefText
816
- line.appendChild(deadBrief)
817
- const meta = el('span', 'wb-row-meta-line')
818
- meta.textContent = `${row.taskId} · ${row.team} · ${ageText(row.deadAt, now)}${row.reason !== undefined && row.reason !== '' ? ` · ${row.reason}` : ''}`
819
- line.appendChild(meta)
820
- const btn = el('button', 'wb-btn')
821
- btn.type = 'button'
822
- btn.textContent = '清账'
823
- btn.addEventListener('click', () => {
824
- btn.disabled = true
825
- void deps.abandon(row.taskId, 'workbench dead-letter cleanup').then(ok => {
826
- btn.disabled = false
827
- btn.textContent = ok ? '已清' : '失败'
828
- })
829
- })
830
- line.appendChild(btn)
831
- deadTable.appendChild(line)
832
- }
833
- pane.appendChild(deadTable)
619
+ // ── Ledger pane (wb-ledger) ──
620
+ function renderLedger(): void {
621
+ if (last === null) { ledgerPane.replaceChildren(); railTabs.setBadge('ledger', ''); return }
622
+ const state = last
623
+ const resolve = (team: string): ChatTarget | undefined => {
624
+ const session = (state.sessions ?? []).find(row => row.team === team)
625
+ if (session !== undefined) return { kind: 'session', id: session.id, title: displayName(session), sub: session.team, team: session.team, live: session.live }
626
+ const remote = (state.remote ?? []).find(row => row.team === team)
627
+ if (remote !== undefined) return { kind: 'remote', team: remote.team, title: displayName(remote), sub: remote.team, row: remote }
628
+ return undefined
834
629
  }
630
+ const out = paintLedger(ledgerPane, {
631
+ state,
632
+ now: deps.now(),
633
+ abandon: (taskId, reason) => deps.abandon(taskId, reason),
634
+ canOpenTeam: (team) => resolve(team) !== undefined,
635
+ openTeam: (team) => {
636
+ const target = resolve(team)
637
+ if (target === undefined) return false
638
+ openChat(target)
639
+ return true
640
+ },
641
+ })
642
+ railTabs.setBadge('ledger', out.badge, out.tone)
835
643
  }
836
644
 
837
645
  // ── Activity pane ──
@@ -840,7 +648,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
840
648
  if (last === null) return
841
649
  const now = deps.now()
842
650
  const flights = last.inFlight ?? []
843
- sectionCounts.get('activity')!.textContent = `${flights.length}+${last.activity?.length ?? 0}`
651
+ railTabs.setBadge('activity', flights.length > 0 ? String(flights.length) : '', flights.length > 0 ? 'accent' : undefined)
844
652
  const head = el('div', 'wb-pane-head')
845
653
  head.textContent = `在途 ${flights.length} · 活动 ${last.activity?.length ?? 0}`
846
654
  pane.appendChild(head)
@@ -881,23 +689,24 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
881
689
  function planActions(state: StateBody): Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> {
882
690
  const now = deps.now()
883
691
  const actions: Array<{ severity: 'high' | 'normal'; text: string; brief?: string }> = []
884
- const dead = state.tasksDead ?? []
885
- if (dead.length > 0) {
886
- actions.push({ severity: 'high', text: `清账 ${dead.length} 条死信(目标早已离开或死信化,无回执可期)` })
692
+ // One definition with the 台账 tab: fire-forget expiries are bookkeeping,
693
+ // not dead letters (review L1), and only OUR stale sends are nudgeable.
694
+ const facts = ledgerFacts(state, now)
695
+ if (facts.deadLetters.length > 0) {
696
+ actions.push({ severity: 'high', text: `清账 ${facts.deadLetters.length} 条死信(目标早已离开或死信化,无回执可期)` })
887
697
  }
888
- for (const row of state.tasks ?? []) {
889
- const age = now - row.startedAt
890
- if (age > 60 * 60_000 && row.settleable === true) {
891
- // Quote the original ask: a nudge that only repeats a task id makes
892
- // the target re-derive WHAT was owed, and the target is exactly the
893
- // session that already failed to answer once.
894
- const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
895
- actions.push({
896
- severity: 'normal',
897
- text: `催办 ${row.taskId} → ${row.team}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
898
- brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
899
- })
900
- }
698
+ // (The old `age > 1h && settleable === true` never fired against a real
699
+ // host — the server's settleable means receipt-expected AND age < 1h.)
700
+ for (const row of facts.stale) {
701
+ // Quote the original ask: a nudge that only repeats a task id makes
702
+ // the target re-derive WHAT was owed, and the target is exactly the
703
+ // session that already failed to answer once.
704
+ const subject = row.brief === undefined || row.brief === '' ? '' : `「${row.brief}」`
705
+ actions.push({
706
+ severity: 'normal',
707
+ text: `催办 ${row.taskId} → ${row.team}(等待 ${ageText(row.startedAt, now)} 无回执)${subject}`,
708
+ brief: `a2a_route 催收回执:task ${row.taskId}(team ${row.team})${subject === '' ? '' : `原任务「${row.brief}」`}已派发 ${ageText(row.startedAt, now)},请回报结果或回执。`,
709
+ })
901
710
  }
902
711
  for (const row of state.sessions ?? []) {
903
712
  if (row.live !== true) {
@@ -920,7 +729,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
920
729
  const pane = clear(planPane)
921
730
  if (last === null) return
922
731
  const actions = planActions(last)
923
- sectionCounts.get('plan')!.textContent = `${actions.length}`
732
+ railTabs.setBadge('plan', actions.length > 0 ? String(actions.length) : '', actions.some(a => a.severity === 'high') ? 'danger' : undefined)
924
733
  const head = el('div', 'wb-pane-head')
925
734
  head.textContent = actions.length === 0 ? '网络健康——无待办行动' : `待办行动 ${actions.length}`
926
735
  pane.appendChild(head)
@@ -989,8 +798,125 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
989
798
  }
990
799
 
991
800
  // ── Inspector (overlay card over the stage; bottom sheet on narrow) ──
801
+ // gh#34 owner panel state + render (the gear toggles; the inspector hosts).
802
+ let ownerPanelOpen = false
803
+ let ownerState: { teamJoinAllowlist: string[]; teamScopeInfo: boolean; announce: boolean; announceRestartPending: boolean } | null = null
804
+ function renderOwnerPanel(): void {
805
+ renderInspector()
806
+ if (!ownerPanelOpen) return
807
+ void deps.ownerSettingsRead?.().then(result => {
808
+ if (!ownerPanelOpen) return
809
+ if (result.ok !== true) { ownerState = null; renderInspector(); return }
810
+ ownerState = {
811
+ teamJoinAllowlist: result.teamJoinAllowlist ?? [],
812
+ teamScopeInfo: result.teamScopeInfo !== false,
813
+ announce: result.announce === true,
814
+ announceRestartPending: result.announceRestartPending === true,
815
+ }
816
+ renderInspector()
817
+ })
818
+ }
819
+ function renderOwnerPanelInto(box: HTMLElement): void {
820
+ const card = el('div', 'wb-card')
821
+ const head = el('div', 'wb-inspector-head')
822
+ head.appendChild(el('span', 'wb-inspector-title')).textContent = '主人设置'
823
+ const closeBtn = el('button', 'wb-icon-btn')
824
+ closeBtn.type = 'button'
825
+ closeBtn.setAttribute('aria-label', '关闭主人设置')
826
+ closeBtn.textContent = '✕'
827
+ closeBtn.addEventListener('click', () => { ownerPanelOpen = false; renderInspector() })
828
+ head.appendChild(closeBtn)
829
+ card.appendChild(head)
830
+ if (deps.ownerSettingsRead === undefined) {
831
+ const note = el('div', 'wb-hint')
832
+ note.textContent = '此宿主未提供主人设置面(旧版本)——升级后可用。'
833
+ card.appendChild(note)
834
+ box.appendChild(card)
835
+ return
836
+ }
837
+ if (ownerState === null) {
838
+ const note = el('div', 'wb-hint')
839
+ note.textContent = '读取中…'
840
+ card.appendChild(note)
841
+ box.appendChild(card)
842
+ return
843
+ }
844
+ const st = ownerState
845
+ // ① 入网白名单(精确团队名增删)
846
+ const alHead = el('div', 'wb-side-head')
847
+ alHead.textContent = '入网白名单(精确团队名)'
848
+ card.appendChild(alHead)
849
+ for (const name of st.teamJoinAllowlist) {
850
+ const row = el('div', 'wb-member-row')
851
+ row.appendChild(el('span', 'wb-member-label')).textContent = name
852
+ const del = el('button', 'wb-member-remove')
853
+ del.type = 'button'
854
+ del.textContent = '移除'
855
+ del.addEventListener('click', () => {
856
+ void deps.ownerSettingsWrite?.({ teamJoinAllowlist: st.teamJoinAllowlist.filter(n => n !== name) }).then(() => renderOwnerPanel())
857
+ })
858
+ row.appendChild(del)
859
+ card.appendChild(row)
860
+ }
861
+ const addForm = el('div', 'wb-member-add')
862
+ const input = el('input', 'wb-member-manual')
863
+ input.type = 'text'
864
+ input.placeholder = 'dsh/团队名(精确,无通配)'
865
+ input.setAttribute('aria-label', '新增白名单团队名')
866
+ const addBtn = el('button', 'wb-member-submit')
867
+ addBtn.type = 'button'
868
+ addBtn.textContent = '加入'
869
+ const doAdd = (): void => {
870
+ const v = input.value.trim()
871
+ if (v === '' || st.teamJoinAllowlist.includes(v)) return
872
+ void deps.ownerSettingsWrite?.({ teamJoinAllowlist: [...st.teamJoinAllowlist, v] }).then(() => renderOwnerPanel())
873
+ }
874
+ addBtn.addEventListener('click', doAdd)
875
+ input.addEventListener('keydown', ev => { if (ev.key === 'Enter') doAdd() })
876
+ addForm.append(input, addBtn)
877
+ card.appendChild(addForm)
878
+ // ② 可见性开关 + 精度固化文案
879
+ const tsHead = el('div', 'wb-side-head')
880
+ tsHead.textContent = '目录可见性(teamScopeInfo)'
881
+ card.appendChild(tsHead)
882
+ const tsRow = el('div', 'wb-member-row')
883
+ const tsBtn = el('button', 'wb-member-submit')
884
+ tsBtn.type = 'button'
885
+ tsBtn.textContent = st.teamScopeInfo ? '已开:仅显示已加入团队(点击放开为全舰队可见)' : '已关:全舰队可见(点击收窄为已加入团队)'
886
+ tsBtn.addEventListener('click', () => {
887
+ void deps.ownerSettingsWrite?.({ teamScopeInfo: !st.teamScopeInfo }).then(() => renderOwnerPanel())
888
+ })
889
+ tsRow.appendChild(tsBtn)
890
+ card.appendChild(tsRow)
891
+ const tsNote = el('div', 'wb-hint')
892
+ tsNote.textContent = '精度说明:teamScopeInfo 只管「目录显示范围」。关上它不会解锁任何通信准入——会话入网(S3)与目标级路由(F5)规则原样生效。'
893
+ card.appendChild(tsNote)
894
+ // ③ 发布开关(重启生效)
895
+ const anHead = el('div', 'wb-side-head')
896
+ anHead.textContent = '节点发布(announce)'
897
+ card.appendChild(anHead)
898
+ const anRow = el('div', 'wb-member-row')
899
+ const anBtn = el('button', 'wb-member-submit')
900
+ anBtn.type = 'button'
901
+ anBtn.textContent = st.announce ? '已开:本节点发布签名卡(点击关闭)' : '已关:本节点不发布(点击开启——重启宿主后生效)'
902
+ anBtn.addEventListener('click', () => {
903
+ void deps.ownerSettingsWrite?.({ announce: !st.announce }).then(() => renderOwnerPanel())
904
+ })
905
+ anRow.appendChild(anBtn)
906
+ card.appendChild(anRow)
907
+ if (st.announceRestartPending) {
908
+ const anNote = el('div', 'wb-hint')
909
+ anNote.textContent = '有 announce 变更待生效:重启宿主后按新配置发布。'
910
+ card.appendChild(anNote)
911
+ }
912
+ box.appendChild(card)
913
+ }
914
+
992
915
  function renderInspector(): void {
993
916
  const box = clear(inspectorBody)
917
+ // gh#34: the owner panel owns the inspector when open (selection cards
918
+ // yield; the gear closes it back).
919
+ if (ownerPanelOpen) { inspector.classList.add('is-open'); renderOwnerPanelInto(box); return }
994
920
  inspector.classList.toggle('is-open', selection !== undefined)
995
921
  if (selection === undefined) return
996
922
  const sel = selection
@@ -1075,6 +1001,14 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1075
1001
  }
1076
1002
  let remoteLabeled = false
1077
1003
  for (const remote of last?.remote ?? []) {
1004
+ // gh#37: only zone/id8 HANDLES are addable members — a bare zone row
1005
+ // (the peer's own team card, e.g. 'dsh') fails the backend shape
1006
+ // gate and the old picker offered it anyway: the owner clicked 远端,
1007
+ // got refused, and read the feature as broken. Rows without a '/'
1008
+ // are skipped; when the directory carries no handles at all the
1009
+ // hint explains why (unlinked peers serve the minimal card, which
1010
+ // carries no session teams) instead of offering dead options.
1011
+ if (!remote.team.includes('/')) continue
1078
1012
  if (memberIds.has(remote.team)) continue
1079
1013
  if (!remoteLabeled) {
1080
1014
  pick.appendChild(new Option('远端会话队(目录卡片)…', '', true, false))
@@ -1082,11 +1016,14 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1082
1016
  }
1083
1017
  pick.appendChild(new Option(`${displayName(remote)} — 远端`, remote.team))
1084
1018
  }
1019
+ if (!remoteLabeled) {
1020
+ pick.appendChild(new Option('(暂无可拉入的远端会话队——见下方说明)', '', true, false))
1021
+ }
1085
1022
  if (keep !== '' && Array.from(pick.options).some(option => option.value === keep)) pick.value = keep
1086
1023
  card.appendChild(canvasForm.form)
1087
1024
  const hint = el('div', 'wb-hint')
1088
1025
  hint.textContent =
1089
- '成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。'
1026
+ '成员由人类在此手工分配(agent 不可自组队)。远端成员走对端目录路由,不做本机冷唤醒;地址由目录在派发时重解析。远端下拉只列 zone/id8 会话句柄——若为空,说明目录里还没有已链接对端的会话队(未链接的对端只给最小卡);此时可在输入框手输句柄(如 dsh/xxxxxxxx)先把人拉进来,句柄本身即链接手势。'
1090
1027
  card.appendChild(hint)
1091
1028
  // Quick dispatch targets the canvas ROUTING team (0.6.9): local tiers
1092
1029
  // first, then the remote members in order — the seam's own priority.
@@ -1156,289 +1093,11 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1156
1093
  return wrap
1157
1094
  }
1158
1095
 
1159
- // ── Inline transcript (0.6.11, remote 0.6.15) ──
1160
- // The tail of a session's conversation log, read-only, on this page —
1161
- // local sessions read the host log face; remote sessions read through
1162
- // the proxy. Cached per cache key across the 3s poll remounts; the
1163
- // refresh button forces one re-read. Reading — not jumping — is the
1164
- // interaction.
1165
- type TranscriptState = { ok: boolean; messages?: Array<{ role: string; text: string }>; error?: string } | undefined
1166
-
1167
- function transcriptBlock(cacheKey: string, load: () => Promise<TranscriptState>): HTMLElement {
1168
- const block = el('div', 'wb-transcript')
1169
- const head = el('div', 'wb-side-head')
1170
- // Owner signal (2026-10-21): log reading is the SEAT's forensics face —
1171
- // it defaults to a collapsed detail (展开 once per transcript; the
1172
- // expansion persists across the 3s poll remounts). The owner reads
1173
- // results in the ledger; seats click 展开 when they need the record.
1174
- const expand = el('button', 'wb-transcript-refresh')
1175
- expand.type = 'button'
1176
- const paintExpand = (): void => {
1177
- expand.textContent = expandedTranscripts.has(cacheKey) ? '收起取证' : '展开取证'
1178
- }
1179
- expand.addEventListener('click', () => {
1180
- if (expandedTranscripts.has(cacheKey)) expandedTranscripts.delete(cacheKey)
1181
- else expandedTranscripts.add(cacheKey)
1182
- paintExpand()
1183
- body.classList.toggle('is-folded', !expandedTranscripts.has(cacheKey))
1184
- if (expandedTranscripts.has(cacheKey)) settle()
1185
- })
1186
- paintExpand()
1187
- head.appendChild(expand)
1188
- head.appendChild(el('span', 'wb-transcript-title')).textContent = '最近对话'
1189
- const refresh = el('button', 'wb-transcript-refresh')
1190
- refresh.type = 'button'
1191
- refresh.textContent = '刷新'
1192
- head.appendChild(refresh)
1193
- block.appendChild(head)
1194
- const body = el('div', 'wb-transcript-body')
1195
- if (!expandedTranscripts.has(cacheKey)) body.classList.add('is-folded')
1196
- block.appendChild(body)
1197
- // 0.6.17 chat-UI conventions: the reading position survives the 3s poll
1198
- // remount (scroll captured on scroll, restored after every paint), and a
1199
- // FIRST successful load lands on the newest turn (bottom) like every
1200
- // chat surface. Refresh explicitly re-lands on the bottom.
1201
- let landed = transcriptScroll.has(cacheKey)
1202
- let landOnBottom = !landed
1203
- const settle = (): void => {
1204
- if (landOnBottom) {
1205
- body.scrollTop = body.scrollHeight
1206
- landOnBottom = false
1207
- landed = true
1208
- } else if (transcriptScroll.has(cacheKey)) {
1209
- body.scrollTop = transcriptScroll.get(cacheKey) ?? 0
1210
- }
1211
- }
1212
- body.addEventListener('scroll', () => {
1213
- if (body.scrollTop > 0 || landed) transcriptScroll.set(cacheKey, body.scrollTop)
1214
- })
1215
- const paint = (state: TranscriptState): void => {
1216
- body.replaceChildren()
1217
- if (state === undefined) {
1218
- body.appendChild(el('div', 'wb-muted')).textContent = '读取中…'
1219
- return
1220
- }
1221
- if (state.ok !== true || state.messages === undefined) {
1222
- body.appendChild(el('div', 'wb-muted')).textContent = `无法读取(${state.error ?? '未知错误'})`
1223
- return
1224
- }
1225
- if (state.messages.length === 0) {
1226
- body.appendChild(el('div', 'wb-muted')).textContent = '暂无对话记录'
1227
- return
1228
- }
1229
- for (const message of state.messages) {
1230
- const row = el('div', `wb-transcript-row wb-transcript-${message.role === 'user' ? 'user' : 'assistant'}`)
1231
- row.appendChild(el('span', 'wb-transcript-role')).textContent = message.role === 'user' ? '问' : '答'
1232
- row.appendChild(el('span', 'wb-transcript-text')).textContent = message.text
1233
- body.appendChild(row)
1234
- }
1235
- // Scroll writes only stick on ATTACHED elements: the synchronous
1236
- // cache-hit path paints before the block joins the DOM, so the
1237
- // settle must run on the next frame, after append + layout.
1238
- requestAnimationFrame(() => {
1239
- if (body.isConnected) settle()
1240
- })
1241
- }
1242
- const run = (force: boolean): void => {
1243
- if (force) landOnBottom = true
1244
- if (!force && transcriptCache.has(cacheKey)) {
1245
- paint(transcriptCache.get(cacheKey))
1246
- return
1247
- }
1248
- transcriptCache.set(cacheKey, undefined)
1249
- paint(undefined)
1250
- void load().then(result => {
1251
- transcriptCache.set(cacheKey, result)
1252
- // The poll may have remounted the drawer since; only the live
1253
- // body repaints, the cache carries the result to the next mount.
1254
- if (body.isConnected) paint(result)
1255
- })
1256
- }
1257
- refresh.addEventListener('click', () => run(true))
1258
- run(false)
1259
- return block
1260
- }
1261
-
1262
- function localTranscript(id: string): HTMLElement {
1263
- return transcriptBlock(`local:${id}`, async () => await deps.sessionLog?.(id) ?? { ok: false, error: 'sessionLog unavailable' })
1264
- }
1265
-
1266
- function remoteTranscript(cacheKey: string, query: { team?: string; peer?: string; id?: string }): HTMLElement {
1267
- return transcriptBlock(cacheKey, async () => await deps.remoteSessionLog?.(query) ?? { ok: false, error: 'remoteSessionLog unavailable' })
1268
- }
1269
-
1270
- /** Peer-host session listing (0.6.15): the list face of /a2a/log through
1271
- * the proxy — recent readable sessions on that peer, click to drill. */
1272
- function peerLogList(url: string): HTMLElement {
1273
- const block = el('div', 'wb-transcript')
1274
- const head = el('div', 'wb-side-head')
1275
- head.textContent = '对端最近会话'
1276
- block.appendChild(head)
1277
- const body = el('div', 'wb-transcript-body')
1278
- block.appendChild(body)
1279
- const cacheKey = `peerlist:${url}`
1280
- const paint = (state: { ok: boolean; sessions?: Array<{ id: string; sizeBytes: number }>; error?: string } | undefined): void => {
1281
- body.replaceChildren()
1282
- if (state === undefined) {
1283
- body.appendChild(el('div', 'wb-muted')).textContent = '读取中…'
1284
- return
1285
- }
1286
- if (state.ok !== true || state.sessions === undefined) {
1287
- body.appendChild(el('div', 'wb-muted')).textContent = `无法读取(${state.error ?? '未知错误'})`
1288
- return
1289
- }
1290
- if (state.sessions.length === 0) {
1291
- body.appendChild(el('div', 'wb-muted')).textContent = '对端没有可读会话'
1292
- return
1293
- }
1294
- const fmtSize = (n: number): string => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} MB` : n >= 1_000 ? `${Math.round(n / 1_000)} KB` : `${n} B`
1295
- for (const row of state.sessions) {
1296
- const item = el('button', 'wb-member-row')
1297
- item.type = 'button'
1298
- item.appendChild(el('span', 'wb-chat-dot is-cold')).textContent = '❐'
1299
- item.appendChild(el('span', 'wb-item-label')).textContent = `…${row.id.slice(-12)} · ${fmtSize(row.sizeBytes)}`
1300
- item.addEventListener('click', () => {
1301
- if (chatTarget?.kind === 'peer') chatTarget.picked = row.id
1302
- renderChat()
1303
- })
1304
- body.appendChild(item)
1305
- }
1306
- }
1307
- if (peerListCache.has(cacheKey)) paint(peerListCache.get(cacheKey))
1308
- else {
1309
- paint(undefined)
1310
- void deps.remoteSessionLog?.({ peer: url }).then(result => {
1311
- peerListCache.set(cacheKey, result)
1312
- if (body.isConnected) paint(result)
1313
- })
1314
- }
1315
- return block
1316
- }
1317
-
1318
- // ── Node chat drawer (0.6.13) ──
1319
- // The right-side reading face. The transcript block is cached per id, so
1320
- // re-rendering across the 3s poll is cheap; the dispatch form has its own
1321
- // cache slot so the inspector's team and the chat's can coexist.
1322
- function closeChat(): void {
1323
- chatTarget = null
1324
- chatForm = null
1325
- chat.classList.remove('is-open')
1326
- }
1327
-
1328
1096
  function openChat(target: ChatTarget): void {
1329
- chatTarget = target
1330
- // Keep the chat form only when it belongs to the same session team.
1331
- const team = target.kind === 'session' || target.kind === 'remote' ? target.team : undefined
1332
- if (chatForm !== null && chatForm.team !== team) chatForm = null
1333
- // 0.6.17: focus the composer ONCE per open — never on poll remounts,
1334
- // which would steal focus every 3s (the focus bug the owner hit).
1335
- chatFocusPending = true
1336
- renderChat()
1337
- chat.classList.add('is-open')
1097
+ chat.open(target)
1338
1098
  root.classList.remove('is-rail-open')
1339
1099
  }
1340
1100
 
1341
- /** 0.6.17 focus law: the 3s poll rebuilds the drawer, and MOVING a
1342
- * focused node (re-append) blurs it in every browser — the composer
1343
- * would lose the caret mid-typing. Capture focus + selection around the
1344
- * face render and restore it on the same node after (cached nodes come
1345
- * back; throwaway nodes just lose focus as before). */
1346
- function renderChat(): void {
1347
- const activeBefore = document.activeElement
1348
- const focusWasInside = activeBefore instanceof HTMLElement && chatBody.contains(activeBefore)
1349
- const selection = activeBefore instanceof HTMLTextAreaElement || activeBefore instanceof HTMLInputElement
1350
- ? { start: activeBefore.selectionStart, end: activeBefore.selectionEnd }
1351
- : undefined
1352
- renderChatFace()
1353
- if (!focusWasInside || !(activeBefore instanceof HTMLElement) || !chatBody.contains(activeBefore)) return
1354
- activeBefore.focus({ preventScroll: true })
1355
- if (selection !== undefined && activeBefore instanceof HTMLTextAreaElement) {
1356
- activeBefore.setSelectionRange(selection.start, selection.end)
1357
- }
1358
- }
1359
-
1360
- function renderChatFace(): void {
1361
- const t = chatTarget
1362
- if (t === null) return
1363
- chatDot.className = `wb-chat-dot ${t.kind === 'peer' ? 'is-peer' : t.kind === 'log' || t.kind === 'remote' || t.live === false ? 'is-cold' : 'is-live'}`
1364
- chatTitle.textContent = t.title
1365
- chatSub.textContent = t.sub
1366
- clear(chatBody)
1367
- if (t.kind === 'peer') {
1368
- // 0.6.15: the peer host is readable through the proxy — list its
1369
- // recent sessions, then drill into one. Without the proxy dep the
1370
- // honest where-the-records-live note stays.
1371
- const url = t.url ?? (t.title.startsWith('http') ? t.title : '')
1372
- if (url === '' || deps.remoteSessionLog === undefined) {
1373
- const note = el('div', 'wb-chat-note')
1374
- note.textContent = `对端主机节点(${t.sub})—— 会话记录存放在对端机器上,本页只读本机会话日志。`
1375
- chatBody.appendChild(note)
1376
- return
1377
- }
1378
- const note = el('div', 'wb-chat-note')
1379
- note.textContent = '对端主机节点 —— 以下会话列表经本机代理从对端 /a2a/log 读取。'
1380
- chatBody.appendChild(note)
1381
- if (t.picked !== undefined) {
1382
- const back = el('button', 'wb-transcript-refresh')
1383
- back.type = 'button'
1384
- back.textContent = '← 返回会话列表'
1385
- back.addEventListener('click', () => {
1386
- if (chatTarget?.kind === 'peer') chatTarget.picked = undefined
1387
- renderChat()
1388
- })
1389
- chatBody.appendChild(back)
1390
- chatBody.appendChild(remoteTranscript(`peer:${url}:${t.picked}`, { peer: url, id: t.picked }))
1391
- } else {
1392
- chatBody.appendChild(peerLogList(url))
1393
- }
1394
- return
1395
- }
1396
- if (t.kind === 'remote') {
1397
- // Card metadata the old inspector face carried (origin/via/workspace),
1398
- // kept readable in the drawer head area.
1399
- const meta = el('div')
1400
- if (t.row?.origin !== undefined) textRow(meta, '来源', t.row.origin)
1401
- if (t.row?.via !== undefined) textRow(meta, '经', t.row.via)
1402
- if (t.row?.workspace !== undefined) textRow(meta, '工作区', t.row.workspace)
1403
- if (meta.childElementCount > 0) chatBody.appendChild(meta)
1404
- if (deps.remoteSessionLog !== undefined) {
1405
- const note = el('div', 'wb-chat-note')
1406
- note.textContent = '远端会话节点 —— 经本机代理从对端 /a2a/log 读取最近对话。'
1407
- chatBody.appendChild(note)
1408
- chatBody.appendChild(remoteTranscript(`remote:${t.team}`, { team: t.team }))
1409
- } else {
1410
- const note = el('div', 'wb-chat-note')
1411
- note.textContent = '远端节点的对话记录在对端机器上;本页的代理读取面未启用。'
1412
- chatBody.appendChild(note)
1413
- }
1414
- } else {
1415
- if (t.kind === 'log') {
1416
- const note = el('div', 'wb-chat-note')
1417
- note.textContent = '只读日志 —— 此会话当前不在网络目录(可能已归档或未入网)。'
1418
- chatBody.appendChild(note)
1419
- }
1420
- if (deps.sessionLog !== undefined) chatBody.appendChild(localTranscript(t.id))
1421
- }
1422
- const dispatchTeam = t.kind === 'session' || t.kind === 'remote' ? t.team : undefined
1423
- if (dispatchTeam !== undefined && dispatchTeam !== '' && deps.dispatchQuick !== undefined) {
1424
- const wrap = el('div', 'wb-dispatch')
1425
- const head = el('div', 'wb-side-head')
1426
- head.textContent = '发送'
1427
- wrap.appendChild(head)
1428
- if (chatForm === null) chatForm = { team: dispatchTeam, ...buildDispatchForm(dispatchTeam) }
1429
- wrap.appendChild(chatForm.form)
1430
- chatBody.appendChild(wrap)
1431
- if (chatFocusPending) {
1432
- chatFocusPending = false
1433
- // preventScroll: focusing must not fight the transcript's own
1434
- // landing scroll (bottom on first read).
1435
- chatForm.text.focus({ preventScroll: true })
1436
- }
1437
- } else {
1438
- chatFocusPending = false
1439
- }
1440
- }
1441
-
1442
1101
  /** Board picks (0.6.13): resolve a node id against the latest state. */
1443
1102
  function openNodeChat(id: string): void {
1444
1103
  if (last === null) return
@@ -1467,10 +1126,15 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1467
1126
  const box = clear(headerStats)
1468
1127
  if (last === null) return
1469
1128
  const alerts: Array<{ text: string; tone: 'warn' | 'danger'; title: string }> = []
1470
- const dead = last.tasksDead?.length ?? 0
1471
- if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务,需在台账清账' })
1472
- const stale = last.tasksStaleCount ?? 0
1473
- if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '≥1h 无回执的旧账(含免回执行),到 24h TTL 自动转死信' })
1129
+ // Same definitions as the 台账 tab (wb-ledger ledgerFacts): fire-forget
1130
+ // expiries are not dead letters, receiptless rows are never stale.
1131
+ const facts = ledgerFacts(last, deps.now())
1132
+ const dead = facts.deadLetters.length
1133
+ if (dead > 0) alerts.push({ text: `死信 ${dead}`, tone: 'danger', title: '超过 24h TTL 仍未结算的任务(unconsumed),需在台账清账' })
1134
+ const stale = facts.stale.length
1135
+ if (stale > 0) alerts.push({ text: `陈旧 ${stale}`, tone: 'warn', title: '我方派发、要回执、已等待 ≥1h 仍未回执(可催办;免回执行不算)' })
1136
+ const owed = facts.owedOverdue.length
1137
+ if (owed > 0) alerts.push({ text: `欠回执 ${owed}`, tone: 'warn', title: '对方投递给本机、已等待本机回执超过 1 小时' })
1474
1138
  const conflicts = last.idempotency?.conflicts ?? 0
1475
1139
  if (conflicts > 0) alerts.push({ text: `幂等冲突 ${conflicts}`, tone: 'danger', title: '同一幂等键对应了不同载荷,需人工核查' })
1476
1140
  const repair = (last.reconcile?.rows ?? []).filter(row => row.needsRepair === true).length
@@ -1495,6 +1159,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1495
1159
  logRowsBusy = true
1496
1160
  deps.sessionLogList().then(result => {
1497
1161
  logRows = result.ok && result.sessions !== undefined ? result.sessions : []
1162
+ logTotal = result.ok ? result.count : undefined
1498
1163
  }).catch(() => {}).finally(() => {
1499
1164
  logRowsBusy = false
1500
1165
  renderSidebar()
@@ -1503,17 +1168,17 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1503
1168
  renderHeader()
1504
1169
  renderSidebar()
1505
1170
  renderDraftTargets()
1171
+ renderHosts()
1506
1172
  renderLedger()
1507
- // 0.6.13: an open chat drawer re-renders against the fresh state (title
1508
- // freshness + the transcript block's per-id cache answers instantly).
1509
- if (chatTarget !== null) renderChat()
1173
+ // The open chat drawer refreshes in place (head + receipt statuses); its
1174
+ // thread and composer are never rebuilt by the poll.
1175
+ chat.update(state)
1510
1176
  renderActivity()
1511
1177
  renderPlan()
1512
1178
  renderInspector()
1513
1179
  // Mode is availability-derived every poll: canvas face down → data mode,
1514
1180
  // the board deactivates and the panes return to the stage stack.
1515
1181
  syncMode()
1516
- syncSections()
1517
1182
  }
1518
1183
 
1519
1184
  function selectTab(key: WorkbenchTab): void {
@@ -1521,8 +1186,7 @@ export function createWorkbench(deps: WorkbenchDeps): Workbench {
1521
1186
  syncMode()
1522
1187
  return
1523
1188
  }
1524
- collapsed.delete(key)
1525
- syncSections()
1189
+ railTabs.select(key)
1526
1190
  }
1527
1191
 
1528
1192
  update(null)