agent-comm-hub 0.7.0 → 0.8.0

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.
package/assets/admin.html CHANGED
@@ -135,11 +135,82 @@
135
135
  .status[data-state="err"] { color: var(--danger); }
136
136
  .status[data-state="err"] .dot { background: var(--danger); }
137
137
  @keyframes breathe { 50% { opacity: .45; } }
138
+
139
+ /* mode chip: local / lan / remote — tells the operator which trust model is live */
140
+ .mode-chip {
141
+ display: inline-flex; align-items: center; gap: 6px;
142
+ font-size: 11px; font-weight: 600; letter-spacing: .02em;
143
+ border: 1px solid var(--line-strong); border-radius: 999px;
144
+ padding: 2px 10px; color: var(--text-dim); background: var(--surface-2);
145
+ white-space: nowrap;
146
+ }
147
+ .mode-chip[data-mode="local"] { color: var(--ok); border-color: var(--ok-line); background: var(--ok-wash); }
148
+ .mode-chip[data-mode="remote"] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-wash); }
149
+ .mode-chip[data-mode="limited"] { color: var(--warn); border-color: var(--warn-line); background: var(--warn-wash); }
150
+ .mode-chip[data-mode="offline"] { color: var(--danger); border-color: var(--danger-line); background: var(--danger-wash); }
151
+
152
+ /* identity / multi-user onboarding panel */
153
+ .id-map {
154
+ display: grid; gap: 8px;
155
+ padding: 12px 16px; border-bottom: 1px solid var(--line);
156
+ background: var(--surface-2);
157
+ }
158
+ .id-map .flow {
159
+ display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; gap: 8px; align-items: center;
160
+ font-size: 11.5px; color: var(--text-dim);
161
+ }
162
+ .id-map .node {
163
+ border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
164
+ background: var(--bg); padding: 8px 10px; text-align: center; min-width: 0;
165
+ }
166
+ .id-map .node b { display: block; color: var(--text); font-size: 12px; font-weight: 600; }
167
+ .id-map .node span { color: var(--text-faint); font-size: 10.5px; }
168
+ .id-map .arrow { color: var(--text-faint); font-size: 14px; text-align: center; }
169
+ .id-map .note { font-size: 11px; color: var(--text-faint); line-height: 1.5; }
170
+ .id-map .note b { color: var(--text-dim); font-weight: 600; }
171
+ .id-map .note code {
172
+ font-family: var(--font-mono); font-size: 10.5px;
173
+ background: var(--surface-3); border-radius: 3px; padding: 0 4px;
174
+ color: var(--text-dim);
175
+ }
176
+ /* tighten identity flow on mid widths */
177
+ @media (max-width: 1100px) {
178
+ .id-map .flow { grid-template-columns: 1fr; gap: 6px; }
179
+ .id-map .arrow { transform: rotate(90deg); }
180
+ }
181
+
182
+ .invite-grid {
183
+ display: grid; grid-template-columns: minmax(120px, 1fr) minmax(160px, 1.4fr) minmax(90px, 120px) auto; gap: 8px;
184
+ padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface-2);
185
+ }
186
+ @media (max-width: 980px) { .invite-grid { grid-template-columns: 1fr 1fr; } }
187
+ .invite-grid input, .invite-grid select { width: 100%; }
188
+
189
+ .snippet {
190
+ margin: 0; padding: 12px 16px;
191
+ background: var(--bg); border-top: 1px solid var(--line);
192
+ font: 11px/1.55 var(--font-mono); color: var(--text-dim);
193
+ white-space: pre-wrap; word-break: break-all; max-height: 220px; overflow: auto;
194
+ }
195
+ .snippet.hidden { display: none; }
196
+ .snippet-bar {
197
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
198
+ padding: 8px 16px; border-top: 1px solid var(--line); background: var(--surface-2);
199
+ }
200
+ .snippet-bar.hidden { display: none; }
201
+ .snippet-bar .hint { font-size: 11px; color: var(--text-faint); flex: 1; min-width: 0; }
202
+
203
+ .ops-strip {
204
+ display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline;
205
+ padding: 8px 16px; border-bottom: 1px solid var(--line);
206
+ font-size: 11px; color: var(--text-faint);
207
+ }
208
+ .ops-strip b { color: var(--text-dim); font-weight: 600; font-variant-numeric: tabular-nums; }
138
209
  .topbar .field { display: flex; align-items: center; gap: 6px; }
139
210
  .topbar .field label { font-size: 11px; color: var(--text-faint); }
140
211
  .topbar .field input { min-height: 28px; padding: 4px 8px; font-size: 12px; }
141
- #token { width: 240px; }
142
- #mpath { width: 84px; }
212
+ #token { width: 220px; }
213
+ #mpath { width: min(320px, 42vw); }
143
214
  .spacer { flex: 1; }
144
215
 
145
216
  /* ── stats strip: plain numbers + labels, quiet hover (anchors to panels) ── */
@@ -326,6 +397,56 @@
326
397
  .toast.warn { border-left-color: var(--warn); }
327
398
  @keyframes slide-in { from { opacity: 0; transform: translateX(14px); } }
328
399
 
400
+ /* ── inline send box (replaces window.prompt) ── */
401
+ .send-box {
402
+ flex: 1 1 100%;
403
+ display: flex; gap: 8px; align-items: flex-end;
404
+ padding: 10px 12px; margin-top: 6px;
405
+ background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
406
+ animation: row-in .16s ease-out;
407
+ }
408
+ .send-box textarea {
409
+ flex: 1; min-width: 0; min-height: 64px; resize: vertical;
410
+ background: var(--bg); color: var(--text);
411
+ border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
412
+ padding: 8px 10px; font: 12px/1.5 var(--font-ui);
413
+ }
414
+ .send-box textarea:focus {
415
+ outline: none; border-color: var(--accent);
416
+ box-shadow: 0 0 0 2px var(--accent-wash);
417
+ }
418
+ .send-box .col { display: flex; flex-direction: column; gap: 6px; }
419
+
420
+ /* ── confirm dialog (replaces window.prompt for destructive / input) ── */
421
+ .dialog-backdrop {
422
+ position: fixed; inset: 0; z-index: 40;
423
+ background: rgba(0, 0, 0, .55);
424
+ display: grid; place-items: center;
425
+ animation: fade-in .12s ease-out;
426
+ }
427
+ .dialog-backdrop.hidden { display: none; }
428
+ @keyframes fade-in { from { opacity: 0; } }
429
+ .dialog {
430
+ width: min(420px, calc(100vw - 32px));
431
+ background: var(--surface); border: 1px solid var(--line-strong);
432
+ border-radius: var(--radius); box-shadow: var(--shadow-toast);
433
+ padding: 16px; display: grid; gap: 12px;
434
+ animation: slide-in .16s ease-out;
435
+ }
436
+ .dialog h3 { margin: 0; font-size: 13px; font-weight: 600; }
437
+ .dialog p { margin: 0; font-size: 12px; color: var(--text-dim); line-height: 1.5; }
438
+ .dialog input, .dialog textarea {
439
+ width: 100%; background: var(--bg); color: var(--text);
440
+ border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
441
+ padding: 8px 10px; font: 12px/1.5 var(--font-ui);
442
+ }
443
+ .dialog textarea { min-height: 96px; resize: vertical; }
444
+ .dialog input:focus, .dialog textarea:focus {
445
+ outline: none; border-color: var(--accent);
446
+ box-shadow: 0 0 0 2px var(--accent-wash);
447
+ }
448
+ .dialog .row { display: flex; gap: 8px; justify-content: flex-end; }
449
+
329
450
  /* ── responsive / motion ────────────────────────────────────────────── */
330
451
  @media (max-width: 980px) {
331
452
  .workspace { grid-template-columns: 1fr; }
@@ -354,14 +475,15 @@
354
475
  </span>
355
476
  </div>
356
477
  <span class="status" id="conn" data-state="off" aria-live="polite"><span class="dot"></span><span id="conn-text" data-i18n="statusOff">未连接</span></span>
478
+ <span class="mode-chip" id="mode-chip" data-mode="unknown" title="运行形态:本机 / 远程鉴权 / 无管理权限">—</span>
357
479
  <span class="spacer"></span>
358
480
  <div class="field">
359
481
  <label for="token" data-i18n="fieldToken">token</label>
360
482
  <input id="token" type="password" placeholder="manager token" autocomplete="off">
361
483
  </div>
362
484
  <div class="field">
363
- <label for="mpath" data-i18n="fieldMcp">mcp</label>
364
- <input id="mpath" value="/mcp" spellcheck="false" aria-label="MCP path">
485
+ <label for="mpath" data-i18n="fieldMcp">endpoint</label>
486
+ <input id="mpath" value="/mcp" spellcheck="false" aria-label="MCP endpoint" data-i18n-ph="fieldMcpPh" placeholder="/mcp 或 http://127.0.0.1:18764/mcp">
365
487
  </div>
366
488
  <button id="lang" class="ghost" data-i18n-title="langTitle" title="切换语言">中 / EN</button>
367
489
  <button id="reload" class="primary" data-i18n="btnReload">重新连接</button>
@@ -383,6 +505,44 @@
383
505
  </nav>
384
506
 
385
507
  <main class="workspace">
508
+ <!-- identity: multi-user same-agent model + invite → MCP config snippet -->
509
+ <section class="panel" id="panel-identity">
510
+ <header>
511
+ <h2 data-i18n="panelIdentity">身份与接入</h2>
512
+ <span class="hint" id="id-hint" data-i18n="identityHint">一人一 token = 一个 peer,同名 agent 也互不串台</span>
513
+ <span class="spacer"></span>
514
+ <span class="hint" id="ops-health"></span>
515
+ </header>
516
+ <div class="id-map" aria-label="identity mapping">
517
+ <div class="flow">
518
+ <div class="node"><b data-i18n="idPerson">人 / 机器</b><span>A · B · C</span></div>
519
+ <div class="arrow" aria-hidden="true">→</div>
520
+ <div class="node"><b data-i18n="idToken">专属 token</b><span>zhangsan / lisi / wangwu</span></div>
521
+ <div class="arrow" aria-hidden="true">→</div>
522
+ <div class="node"><b data-i18n="idPeer">独立 peer</b><span>kimi-code × 3 · 信箱隔离</span></div>
523
+ </div>
524
+ <div class="note" data-i18n-html="identityNote">
525
+ <b>规则:</b>token 决定 peer,不是 clientInfo 名。A 发给 B 时 C 收不到;全员用 <code>to:"all"</code>,小圈子用群组。
526
+ 同一人多 agent 建议 <code>人.agent</code>(如 <code>zhangsan.kimi</code>)。
527
+ </div>
528
+ </div>
529
+ <form class="invite-grid" id="invite-form">
530
+ <input name="owner" data-i18n-ph="inviteOwner" placeholder="归属人(张三)" required spellcheck="false" aria-label="owner">
531
+ <input name="peer" data-i18n-ph="invitePeer" placeholder="peer id(zhangsan 或 zhangsan.kimi)" required spellcheck="false" aria-label="peer id">
532
+ <select name="role" aria-label="role">
533
+ <option value="agent">agent</option>
534
+ <option value="manager">manager</option>
535
+ </select>
536
+ <button class="primary" type="submit" data-i18n="inviteIssue">签发并生成配置</button>
537
+ </form>
538
+ <div class="snippet-bar hidden" id="snippet-bar">
539
+ <span class="hint" id="snippet-hint"></span>
540
+ <button class="ghost" id="snippet-copy" data-i18n="snippetCopy">复制 MCP 配置</button>
541
+ <button class="ghost" id="snippet-hide" data-i18n="snippetHide">收起</button>
542
+ </div>
543
+ <pre class="snippet hidden" id="snippet" tabindex="0"></pre>
544
+ </section>
545
+
386
546
  <!-- main: members & tokens -->
387
547
  <section class="panel" id="panel-tokens">
388
548
  <header>
@@ -473,6 +633,19 @@
473
633
 
474
634
  <div class="toasts" id="toasts" aria-live="polite"></div>
475
635
 
636
+ <!-- in-page dialog (replaces native prompt/confirm) -->
637
+ <div class="dialog-backdrop hidden" id="dialog-backdrop" role="presentation">
638
+ <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
639
+ <h3 id="dialog-title"></h3>
640
+ <p id="dialog-body" hidden></p>
641
+ <textarea id="dialog-input" hidden rows="4"></textarea>
642
+ <div class="row">
643
+ <button type="button" id="dialog-cancel"></button>
644
+ <button type="button" class="primary" id="dialog-ok"></button>
645
+ </div>
646
+ </div>
647
+ </div>
648
+
476
649
  <script>
477
650
  'use strict'
478
651
  /* ── i18n: tiny dictionary, zh/en; language persisted in localStorage ── */
@@ -485,7 +658,8 @@ const I18N = {
485
658
  statusOn: '已连接',
486
659
  statusErr: '连接异常',
487
660
  fieldToken: 'token',
488
- fieldMcp: 'mcp',
661
+ fieldMcp: 'endpoint',
662
+ fieldMcpPh: '/mcp 或 http://127.0.0.1:18764/mcp',
489
663
  btnReload: '重新连接',
490
664
  btnReveal: '明/打码',
491
665
  btnRevealTitle: '切换 明文/打码 显示',
@@ -498,8 +672,29 @@ const I18N = {
498
672
  statTokens: '已签发令牌',
499
673
  statGroups: '群组',
500
674
  statLog: '消息尾迹(30 条)',
675
+ panelIdentity: '身份与接入',
501
676
  panelTokens: '成员与令牌',
502
677
  panelRoster: '花名册',
678
+ identityHint: '一人一 token = 一个 peer,同名 agent 也互不串台',
679
+ identityNote: '<b>规则:</b>token 决定 peer,不是 clientInfo 名。A 发给 B 时 C 收不到;全员用 <code>to:"all"</code>,小圈子用群组。同一人多 agent 建议 <code>人.agent</code>(如 <code>zhangsan.kimi</code>)。',
680
+ idPerson: '人 / 机器',
681
+ idToken: '专属 token',
682
+ idPeer: '独立 peer',
683
+ inviteOwner: '归属人(张三)',
684
+ invitePeer: 'peer id(zhangsan 或 zhangsan.kimi)',
685
+ inviteIssue: '签发并生成配置',
686
+ snippetCopy: '复制 MCP 配置',
687
+ snippetHide: '收起',
688
+ snippetHint: '把这段配置发给 {peer};token 只显示这一次',
689
+ snippetCopied: '已复制 {peer} 的 MCP 配置',
690
+ modeLocal: '本机',
691
+ modeRemote: '远程鉴权',
692
+ modeLimited: '只读(无 manager)',
693
+ modeUnknown: '检测中…',
694
+ modeOffline: '未连接',
695
+ errEndpoint: '无法访问 MCP 端点 {url} · 若本页是静态打开,请填 hub 完整地址(默认 http://127.0.0.1:18764/mcp)',
696
+ errHttp: 'HTTP {status} · {url}',
697
+ opsHealth: '队列上限 {queue} · 历史 {hist} · peer {n}',
503
698
  panelGroups: '群组',
504
699
  panelLog: '消息尾迹',
505
700
  groupsHint: '频道内互发 · 离线成员从历史补读',
@@ -558,8 +753,11 @@ const I18N = {
558
753
  groupDeleted: '已删除 #{name}',
559
754
  groupDelNote: '删除群 <b>{name}</b>?(消息历史保留)',
560
755
  groupDelYes: '确认删除',
561
- groupSend: '发到 #{name}:',
756
+ groupSend: '发到 #{name}',
562
757
  groupSent: '已发送到 #{name}',
758
+ groupSendPh: '输入要发到该群组的消息…',
759
+ dialogOk: '确定',
760
+ dialogCancel: '取消',
563
761
  groupRemoved: '已移除 {peer}',
564
762
  groupRemoveTitle: '移除 {peer}',
565
763
  groupAdded: '已添加 {peer} → #{group}',
@@ -582,7 +780,8 @@ const I18N = {
582
780
  statusOn: 'connected',
583
781
  statusErr: 'connection error',
584
782
  fieldToken: 'token',
585
- fieldMcp: 'mcp',
783
+ fieldMcp: 'endpoint',
784
+ fieldMcpPh: '/mcp or http://127.0.0.1:18764/mcp',
586
785
  btnReload: 'Reconnect',
587
786
  btnReveal: 'show/hide',
588
787
  btnRevealTitle: 'Toggle plain/masked tokens',
@@ -595,8 +794,29 @@ const I18N = {
595
794
  statTokens: 'Tokens issued',
596
795
  statGroups: 'Groups',
597
796
  statLog: 'Message tail (30)',
797
+ panelIdentity: 'Identity & Onboarding',
598
798
  panelTokens: 'Members & Tokens',
599
799
  panelRoster: 'Roster',
800
+ identityHint: 'One person = one token = one peer · same client name stays isolated',
801
+ identityNote: '<b>Rule:</b> the token decides the peer, not clientInfo. A→B never lands in C\'s mailbox; use <code>to:"all"</code> for everyone, groups for a subset. Multi-agent per person: <code>person.agent</code> (e.g. <code>zhangsan.kimi</code>).',
802
+ idPerson: 'Person / machine',
803
+ idToken: 'Dedicated token',
804
+ idPeer: 'Isolated peer',
805
+ inviteOwner: 'Owner (Zhang San)',
806
+ invitePeer: 'peer id (zhangsan or zhangsan.kimi)',
807
+ inviteIssue: 'Issue + config',
808
+ snippetCopy: 'Copy MCP config',
809
+ snippetHide: 'Hide',
810
+ snippetHint: 'Send this config to {peer}; the token is shown only once',
811
+ snippetCopied: 'copied MCP config for {peer}',
812
+ modeLocal: 'Local',
813
+ modeRemote: 'Remote auth',
814
+ modeLimited: 'Read-only (no manager)',
815
+ modeUnknown: 'detecting…',
816
+ modeOffline: 'Offline',
817
+ errEndpoint: 'Cannot reach MCP endpoint {url} · if this page was opened as a static file, set the full hub URL (default http://127.0.0.1:18764/mcp)',
818
+ errHttp: 'HTTP {status} · {url}',
819
+ opsHealth: 'queue {queue} · history {hist} · peers {n}',
600
820
  panelGroups: 'Groups',
601
821
  panelLog: 'Message Tail',
602
822
  groupsHint: 'Channel broadcast · offline members read back from history',
@@ -655,8 +875,11 @@ const I18N = {
655
875
  groupDeleted: 'deleted #{name}',
656
876
  groupDelNote: 'Delete group <b>{name}</b>? (message history is kept)',
657
877
  groupDelYes: 'Delete',
658
- groupSend: 'Send to #{name}: ',
878
+ groupSend: 'Send to #{name}',
659
879
  groupSent: 'sent to #{name}',
880
+ groupSendPh: 'Message to this group…',
881
+ dialogOk: 'OK',
882
+ dialogCancel: 'Cancel',
660
883
  groupRemoved: 'removed {peer}',
661
884
  groupRemoveTitle: 'Remove {peer}',
662
885
  groupAdded: 'added {peer} → #{group}',
@@ -683,10 +906,12 @@ const t = (key, vars) => {
683
906
  if (vars) for (const [k, v] of Object.entries(vars)) s = s.split('{' + k + '}').join(String(v))
684
907
  return s
685
908
  }
686
- /* Apply keys to static DOM: [data-i18n] text, [data-i18n-ph] placeholder. */
909
+ /* Apply keys to static DOM: [data-i18n] text, [data-i18n-html] rich text,
910
+ [data-i18n-ph] placeholder. Rich keys are trusted UI copy (no user input). */
687
911
  function applyI18n() {
688
912
  document.title = t('title')
689
913
  document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.dataset.i18n) })
914
+ document.querySelectorAll('[data-i18n-html]').forEach((el) => { el.innerHTML = t(el.dataset.i18nHtml) })
690
915
  document.querySelectorAll('[data-i18n-ph]').forEach((el) => { el.placeholder = t(el.dataset.i18nPh) })
691
916
  document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = t(el.dataset.i18nTitle) })
692
917
  }
@@ -694,7 +919,35 @@ document.querySelector('#lang').textContent = t('langToggle')
694
919
 
695
920
  const $ = (id) => document.getElementById(id)
696
921
  const token = () => $('token').value.trim()
697
- const endpoint = () => location.origin + ($('mpath').value.trim() || '/mcp')
922
+
923
+ /** True when this page was opened as a static file (assets/admin.html on
924
+ * Live Server / file://) rather than served by the hub at /admin. In that
925
+ * case location.origin is NOT the hub — default to loopback:18764. */
926
+ function pageLooksStatic() {
927
+ const p = location.pathname
928
+ return /assets\//i.test(p) || /\.html?$/i.test(p)
929
+ }
930
+
931
+ const DEFAULT_HUB = 'http://127.0.0.1:18764'
932
+
933
+ function endpoint() {
934
+ const raw = ($('mpath').value.trim() || '/mcp')
935
+ // Absolute URL wins.
936
+ if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '')
937
+ // host[:port][/path] without a leading slash → treat as host root + path.
938
+ if (/^[a-z0-9._-]+(:\d+)?(\/|$)/i.test(raw) && !raw.startsWith('/')) {
939
+ const scheme = location.protocol === 'https:' && !pageLooksStatic() ? 'https://' : 'http://'
940
+ return (scheme + raw).replace(/\/+$/, '')
941
+ }
942
+ const path = raw.startsWith('/') ? raw : '/' + raw
943
+ if (pageLooksStatic()) return DEFAULT_HUB + path
944
+ return location.origin + path
945
+ }
946
+
947
+ /** Base for /admin/api/* (strip the /mcp suffix from the MCP endpoint). */
948
+ function apiBase() {
949
+ return endpoint().replace(/\/mcp\/?$/i, '')
950
+ }
698
951
 
699
952
  let sid = null
700
953
  async function rpc(method, params) {
@@ -706,13 +959,21 @@ async function rpc(method, params) {
706
959
  const isNotification = method.startsWith('notifications/')
707
960
  const body = { jsonrpc: '2.0', method, params }
708
961
  if (!isNotification) body.id = Date.now() % 1e6
709
- const res = await fetch(endpoint(), { method: 'POST', headers, body: JSON.stringify(body) })
962
+ let res
963
+ try {
964
+ res = await fetch(endpoint(), { method: 'POST', headers, body: JSON.stringify(body) })
965
+ } catch {
966
+ throw new Error(t('errEndpoint', { url: endpoint() }))
967
+ }
710
968
  if (res.status === 401) throw new Error(t('errUnauth'))
969
+ // Static file servers (Live Server :5500, python -m http.server) answer
970
+ // POST /mcp with 405 — surface the real hub URL instead of a bare code.
971
+ if (res.status === 405) throw new Error(t('errHttp', { status: 405, url: endpoint() }))
711
972
  const json = isNotification ? null : await res.json().catch(() => null)
712
973
  const newSid = res.headers.get('mcp-session-id')
713
974
  if (newSid) sid = newSid
714
975
  if (isNotification) return {}
715
- if (!json || json.error) throw new Error(json?.error?.message ?? ('HTTP ' + res.status))
976
+ if (!json || json.error) throw new Error(json?.error?.message ?? t('errHttp', { status: res.status, url: endpoint() }))
716
977
  const parsed = JSON.parse(json.result?.content?.[0]?.text ?? '{}')
717
978
  if (json.result?.isError) throw new Error(parsed.error ?? 'tool error')
718
979
  return parsed
@@ -727,6 +988,46 @@ async function ensureSession() {
727
988
  }
728
989
  async function call(tool, args) { await ensureSession(); return rpc('tools/call', { name: tool, arguments: args ?? {} }) }
729
990
 
991
+ /* ── in-page prompt/confirm (never window.prompt — native dialogs break
992
+ the console's layout and block the event loop) ── */
993
+ function promptDialog({ title, body, placeholder, multiline = true, okLabel, cancelLabel }) {
994
+ return new Promise(resolve => {
995
+ const backdrop = $('dialog-backdrop')
996
+ const titleEl = $('dialog-title')
997
+ const bodyEl = $('dialog-body')
998
+ const input = $('dialog-input')
999
+ const ok = $('dialog-ok')
1000
+ const cancel = $('dialog-cancel')
1001
+ titleEl.textContent = title ?? ''
1002
+ if (body) { bodyEl.hidden = false; bodyEl.textContent = body } else { bodyEl.hidden = true; bodyEl.textContent = '' }
1003
+ input.hidden = false
1004
+ input.value = ''
1005
+ input.placeholder = placeholder ?? ''
1006
+ ok.textContent = okLabel ?? t('dialogOk')
1007
+ cancel.textContent = cancelLabel ?? t('dialogCancel')
1008
+ backdrop.classList.remove('hidden')
1009
+ const close = (value) => {
1010
+ backdrop.classList.add('hidden')
1011
+ ok.onclick = null
1012
+ cancel.onclick = null
1013
+ input.onkeydown = null
1014
+ backdrop.onclick = null
1015
+ resolve(value)
1016
+ }
1017
+ ok.onclick = () => close(input.value.trim())
1018
+ cancel.onclick = () => close(null)
1019
+ backdrop.onclick = (e) => { if (e.target === backdrop) close(null) }
1020
+ input.onkeydown = (e) => {
1021
+ if (e.key === 'Enter' && (multiline === false || e.metaKey || e.ctrlKey)) {
1022
+ e.preventDefault()
1023
+ close(input.value.trim())
1024
+ }
1025
+ if (e.key === 'Escape') { e.preventDefault(); close(null) }
1026
+ }
1027
+ queueMicrotask(() => input.focus())
1028
+ })
1029
+ }
1030
+
730
1031
  /* ── toast feedback: four states (info/ok/warn/err), aria-live broadcast ── */
731
1032
  function toast(msg, kind = '') {
732
1033
  const el = document.createElement('div')
@@ -749,7 +1050,7 @@ async function adminApi(method, path, body) {
749
1050
  const headers = {}
750
1051
  if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json'
751
1052
  if (token() !== '') headers.Authorization = 'Bearer ' + token()
752
- const res = await fetch(endpoint().replace(/\/mcp$/, '') + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
1053
+ const res = await fetch(apiBase() + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
753
1054
  if (res.status === 401 || res.status === 403) throw new Error(t('errNeedManager'))
754
1055
  const json = await res.json().catch(() => null)
755
1056
  if (!json || json.error) throw new Error(json?.error ?? ('HTTP ' + res.status))
@@ -838,9 +1139,184 @@ $('tok-form').onsubmit = async (ev) => {
838
1139
  revealTokens = true // reveal after issuing so the new token can be copied
839
1140
  loadTokens()
840
1141
  toast(t('tokenIssued', { peer: res.created.peer, role: res.created.role, token: res.created.token }), 'ok')
1142
+ showSnippet(res.created)
841
1143
  ev.target.reset()
842
1144
  } catch (e) { toast(String(e.message ?? e), 'err') }
843
1145
  }
1146
+
1147
+ /* ── mode + identity onboarding ────────────────────────────────────────
1148
+ Local hub (no --auth-tokens): tokens API is absent / 403 → local mode,
1149
+ invite form stays visible but issuing fails with a clear toast.
1150
+ Remote hub with manager token: full invite → one-shot MCP config.
1151
+ Remote hub with agent token: limited (read-only console). */
1152
+ let hubMode = 'unknown' // local | remote | limited | offline | unknown
1153
+ function setMode(mode) {
1154
+ hubMode = mode
1155
+ const chip = $('mode-chip')
1156
+ chip.dataset.mode = mode
1157
+ chip.textContent = mode === 'local' ? t('modeLocal')
1158
+ : mode === 'remote' ? t('modeRemote')
1159
+ : mode === 'limited' ? t('modeLimited')
1160
+ : mode === 'offline' ? t('modeOffline')
1161
+ : t('modeUnknown')
1162
+ chip.title = mode === 'local'
1163
+ ? 'Loopback trust model — no token table. Multi-user needs --auth-tokens on a server/LAN hub.'
1164
+ : mode === 'remote'
1165
+ ? 'Remote auth active — each person/agent holds a dedicated token → dedicated peer mailbox.'
1166
+ : mode === 'limited'
1167
+ ? 'Connected, but this token is not a manager — token issue/revoke is unavailable.'
1168
+ : mode === 'offline'
1169
+ ? 'Hub unreachable — check the endpoint field (full URL if this page is static-hosted).'
1170
+ : 'Detecting…'
1171
+ }
1172
+
1173
+ function mcpBaseUrl() {
1174
+ // Same origin as the admin page; path from the mcp field.
1175
+ return location.origin + ($('mpath').value.trim() || '/mcp')
1176
+ }
1177
+
1178
+ function buildMcpConfig(identity) {
1179
+ const url = mcpBaseUrl()
1180
+ const headers = { Authorization: 'Bearer ' + identity.token }
1181
+ // Multi-shape snippet: covers the majority of MCP clients in one paste.
1182
+ return [
1183
+ '# agent-comm-hub · peer: ' + identity.peer + (identity.owner ? ' (' + identity.owner + ')' : ''),
1184
+ '# 一人一枚 token;不要多人共用(共用=共享信箱)',
1185
+ '',
1186
+ '## Claude Code / generic HTTP MCP (.mcp.json)',
1187
+ JSON.stringify({
1188
+ mcpServers: {
1189
+ 'agent-hub': {
1190
+ type: 'http',
1191
+ url,
1192
+ headers,
1193
+ },
1194
+ },
1195
+ }, null, 2),
1196
+ '',
1197
+ '## opencode (opencode.json → mcp)',
1198
+ JSON.stringify({
1199
+ mcp: {
1200
+ 'agent-hub': {
1201
+ type: 'remote',
1202
+ url,
1203
+ headers,
1204
+ },
1205
+ },
1206
+ }, null, 2),
1207
+ '',
1208
+ '## curl 探测',
1209
+ 'curl -sS -X POST ' + url + ' \\',
1210
+ " -H 'Content-Type: application/json' \\",
1211
+ " -H 'Accept: application/json, text/event-stream' \\",
1212
+ " -H 'Authorization: Bearer " + identity.token + "' \\",
1213
+ " -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"probe\",\"version\":\"1\"}}}'",
1214
+ ].join('\n')
1215
+ }
1216
+
1217
+ let lastSnippetIdentity = null
1218
+ function showSnippet(identity) {
1219
+ lastSnippetIdentity = identity
1220
+ const bar = $('snippet-bar')
1221
+ const pre = $('snippet')
1222
+ bar.classList.remove('hidden')
1223
+ pre.classList.remove('hidden')
1224
+ $('snippet-hint').textContent = t('snippetHint', { peer: identity.peer })
1225
+ pre.textContent = buildMcpConfig(identity)
1226
+ pre.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
1227
+ }
1228
+
1229
+ $('snippet-copy').onclick = async () => {
1230
+ if (!lastSnippetIdentity) return
1231
+ try {
1232
+ await navigator.clipboard.writeText(buildMcpConfig(lastSnippetIdentity))
1233
+ toast(t('snippetCopied', { peer: lastSnippetIdentity.peer }), 'ok')
1234
+ } catch (e) { toast(t('copyFail', { msg: String(e.message ?? e) }), 'err') }
1235
+ }
1236
+ $('snippet-hide').onclick = () => {
1237
+ $('snippet-bar').classList.add('hidden')
1238
+ $('snippet').classList.add('hidden')
1239
+ }
1240
+
1241
+ /* Owner types a human name → suggest a peer slug (editable). */
1242
+ const inviteOwner = document.querySelector('#invite-form [name=owner]')
1243
+ const invitePeer = document.querySelector('#invite-form [name=peer]')
1244
+ let peerTouched = false
1245
+ invitePeer?.addEventListener('input', () => { peerTouched = true })
1246
+ inviteOwner?.addEventListener('input', () => {
1247
+ if (peerTouched) return
1248
+ const slug = String(inviteOwner.value || '')
1249
+ .trim()
1250
+ .toLowerCase()
1251
+ .replace(/\s+/g, '-')
1252
+ .replace(/[^a-z0-9._:-]+/g, '')
1253
+ invitePeer.value = slug
1254
+ })
1255
+
1256
+ $('invite-form').onsubmit = async (ev) => {
1257
+ ev.preventDefault()
1258
+ const f = new FormData(ev.target)
1259
+ const peer = String(f.get('peer') || '').trim()
1260
+ const owner = String(f.get('owner') || '').trim()
1261
+ const role = String(f.get('role') || 'agent')
1262
+ if (!peer) return
1263
+ try {
1264
+ const res = await adminApi('POST', '/admin/api/tokens/add', {
1265
+ peer,
1266
+ role,
1267
+ ...(owner !== '' ? { owner } : {}),
1268
+ })
1269
+ revealTokens = true
1270
+ peerTouched = false
1271
+ loadTokens()
1272
+ toast(t('tokenIssued', { peer: res.created.peer, role: res.created.role, token: res.created.token }), 'ok')
1273
+ showSnippet(res.created)
1274
+ ev.target.reset()
1275
+ peerTouched = false
1276
+ } catch (e) {
1277
+ // Local mode / non-manager: surface a precise next step instead of a raw 403.
1278
+ const msg = String(e.message ?? e)
1279
+ if (hubMode === 'local' || /manager token required|401|403/i.test(msg)) {
1280
+ toast(t('emptyTokensRemote'), 'warn')
1281
+ } else {
1282
+ toast(msg, 'err')
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ async function detectMode() {
1288
+ try {
1289
+ await adminApi('GET', '/admin/api/tokens?reveal=0', undefined)
1290
+ setMode('remote')
1291
+ return
1292
+ } catch (e) {
1293
+ const msg = String(e?.message ?? e)
1294
+ // Network / wrong origin: never claim "limited" when the hub is unreachable.
1295
+ if (/Cannot reach|HTTP 405|Failed to fetch|NetworkError|ECONNREFUSED/i.test(msg)) {
1296
+ setMode('offline')
1297
+ return
1298
+ }
1299
+ if (/manager token required|unauthorized|401|403/i.test(msg)) {
1300
+ // Token present but not a manager, or remote with empty token.
1301
+ try {
1302
+ await call('bridge_status')
1303
+ setMode('limited')
1304
+ } catch (e2) {
1305
+ const m2 = String(e2?.message ?? e2)
1306
+ setMode(/Cannot reach|HTTP 405|Failed to fetch/i.test(m2) ? 'offline' : 'limited')
1307
+ }
1308
+ return
1309
+ }
1310
+ // API not present (local hub without --auth-tokens): status still works.
1311
+ try {
1312
+ await call('bridge_status')
1313
+ setMode('local')
1314
+ } catch (e2) {
1315
+ const m2 = String(e2?.message ?? e2)
1316
+ setMode(/Cannot reach|HTTP 405|Failed to fetch/i.test(m2) ? 'offline' : 'limited')
1317
+ }
1318
+ }
1319
+ }
844
1320
  $('tok-reveal').onclick = () => { revealTokens = !revealTokens; loadTokens() }
845
1321
  $('tok-refresh').onclick = () => { pendingRevoke = null; loadTokens() }
846
1322
 
@@ -883,7 +1359,9 @@ function rosterRow(p) {
883
1359
  const actions = document.createElement('div')
884
1360
  actions.className = 'actions'
885
1361
  actions.append(mkBtn(t('actionRename'), 'ghost', () => { editingId = p.id; confirmingId = null; renderRoster(lastPeers) }))
886
- if (p.connected) actions.append(mkBtn(t('actionKick'), 'ghost danger', () => { confirmingId = p.id; editingId = null; renderRoster(lastPeers) }))
1362
+ // Offline peers stay registered until kicked or idle-GC'd — always offer
1363
+ // remove so a stuck offline identity can be cleared from the roster.
1364
+ actions.append(mkBtn(t('actionKick'), 'ghost danger', () => { confirmingId = p.id; editingId = null; renderRoster(lastPeers) }))
887
1365
 
888
1366
  row.append(presence, who, chip, state, badge, actions)
889
1367
  return row
@@ -1031,7 +1509,11 @@ function groupRow(g) {
1031
1509
  renderGroups(lastGroups)
1032
1510
  }))
1033
1511
  actions.append(mkBtn(t('actionSend'), 'ghost', async () => {
1034
- const text = prompt(t('groupSend', { name: g.name ?? g.id }))
1512
+ const text = await promptDialog({
1513
+ title: t('groupSend', { name: g.name ?? g.id }),
1514
+ placeholder: t('groupSendPh'),
1515
+ multiline: true,
1516
+ })
1035
1517
  if (!text) return
1036
1518
  try { await call('bridge_group_send', { group: g.id, message: text }); toast(t('groupSent', { name: g.name ?? g.id }), 'ok') }
1037
1519
  catch (e) { toast(String(e.message ?? e), 'err') }
@@ -1166,10 +1648,19 @@ async function refresh() {
1166
1648
  const status = await call('bridge_status')
1167
1649
  lastStatus = status
1168
1650
  setConn('ok', t('statusOn'))
1651
+ // Recovered from a failed tick: re-probe tokens API for the real mode.
1652
+ if (hubMode === 'offline' || hubMode === 'unknown') void detectMode()
1169
1653
  const bar = $('bar')
1170
1654
  bar.className = ''
1171
1655
  bar.textContent = t('stateRefreshEvery', { time: new Date().toLocaleTimeString(LANG === 'zh' ? 'zh-CN' : 'en-GB', { hour12: false }) })
1172
1656
  if (!editingId && !confirmingId) renderRoster(status.peers ?? [])
1657
+ // Ops strip: capacity knobs the server operator actually needs at a glance.
1658
+ const n = (status.peers ?? []).length
1659
+ $('ops-health').textContent = t('opsHealth', {
1660
+ queue: status.maxQueue ?? '—',
1661
+ hist: status.historyLimit ?? '—',
1662
+ n,
1663
+ })
1173
1664
  const groups = await call('bridge_group_list')
1174
1665
  if (!pendingGroupDelete && expandedGroup === null) renderGroups(groups.groups ?? [])
1175
1666
  const tail = await call('bridge_history', { peer: 'all', limit: 30 })
@@ -1177,11 +1668,16 @@ async function refresh() {
1177
1668
  loadTokens()
1178
1669
  } catch (e) {
1179
1670
  setConn('err', t('statusErr'))
1671
+ setMode('offline')
1180
1672
  const bar = $('bar')
1181
1673
  bar.className = 'err'
1182
1674
  // A hub restart invalidates the old session: next tick re-runs initialize.
1183
1675
  sid = null
1184
1676
  bar.textContent = String(e.message ?? e)
1677
+ // Don't leave the roster stuck on "connecting…" when the hub is down.
1678
+ if ($('roster').querySelector('.loading')) {
1679
+ $('roster').innerHTML = `<div class="empty"><b>${escapeHtml(t('statusErr'))}</b>${escapeHtml(String(e.message ?? e))}</div>`
1680
+ }
1185
1681
  }
1186
1682
  }
1187
1683
 
@@ -1197,10 +1693,22 @@ $('g-create').onsubmit = async (ev) => {
1197
1693
  refresh()
1198
1694
  }
1199
1695
  $('refresh').onclick = () => { editingId = null; confirmingId = null; refresh() }
1200
- $('reload').onclick = () => { sid = null; localStorage.setItem('hub-admin-token', token()); refresh() }
1696
+ $('reload').onclick = () => { sid = null; localStorage.setItem('hub-admin-token', token()); void detectMode(); refresh() }
1201
1697
  $('lang').onclick = () => { localStorage.setItem('hub-admin-lang', LANG === 'zh' ? 'en' : 'zh'); location.reload() }
1202
1698
  $('token').addEventListener('change', () => localStorage.setItem('hub-admin-token', token()))
1203
1699
  $('token').value = localStorage.getItem('hub-admin-token') ?? ''
1700
+ // Static-hosted pages: seed the endpoint with the default hub URL once.
1701
+ if (pageLooksStatic() && !localStorage.getItem('hub-admin-endpoint')) {
1702
+ $('mpath').value = DEFAULT_HUB + '/mcp'
1703
+ localStorage.setItem('hub-admin-endpoint', $('mpath').value)
1704
+ } else {
1705
+ $('mpath').value = localStorage.getItem('hub-admin-endpoint') || $('mpath').value
1706
+ }
1707
+ $('mpath').addEventListener('change', () => {
1708
+ localStorage.setItem('hub-admin-endpoint', $('mpath').value.trim())
1709
+ sid = null
1710
+ void detectMode()
1711
+ })
1204
1712
 
1205
1713
  /* ── material feedback: topbar gets heavier past the content edge
1206
1714
  (rAF-throttled, interruptible transition) ── */
@@ -1227,7 +1735,9 @@ document.addEventListener('keydown', (e) => {
1227
1735
  })
1228
1736
 
1229
1737
  applyI18n()
1738
+ setMode('unknown')
1230
1739
  setInterval(refresh, 3000)
1740
+ void detectMode()
1231
1741
  refresh()
1232
1742
  </script>
1233
1743
  </body>