@pingagent/sdk 0.1.13 → 0.1.15

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.
@@ -1,29 +1,39 @@
1
1
  import {
2
+ CollaborationEventManager,
3
+ CollaborationProjectionOutboxManager,
2
4
  ContactManager,
3
5
  LocalStore,
6
+ OperatorSeenStateManager,
4
7
  PingAgentClient,
5
8
  TrustPolicyAuditManager,
6
9
  TrustRecommendationManager,
10
+ buildDeliveryTimeline,
11
+ buildProjectionPreview,
7
12
  decideContactPolicy,
8
13
  decideTaskPolicy,
9
14
  defaultTrustPolicyDoc,
15
+ deriveTransportHealth,
10
16
  ensureTokenValid,
11
17
  getActiveSessionFilePath,
12
18
  getSessionBindingAlertsFilePath,
13
19
  getSessionMapFilePath,
14
20
  getTrustRecommendationActionLabel,
21
+ listPendingDecisionViews,
15
22
  loadIdentity,
16
23
  normalizeTrustPolicyDoc,
17
24
  readCurrentActiveSessionKey,
18
25
  readIngressRuntimeStatus,
19
26
  readSessionBindingAlerts,
20
27
  readSessionBindings,
28
+ readTransportPreference,
21
29
  removeSessionBinding,
22
30
  setSessionBinding,
31
+ summarizeSinceLastSeen,
23
32
  summarizeTrustPolicyAudit,
33
+ switchTransportPreference,
24
34
  updateStoredToken,
25
35
  upsertTrustPolicyRecommendation
26
- } from "./chunk-N2GCIMAW.js";
36
+ } from "./chunk-BCYHGKQE.js";
27
37
 
28
38
  // src/web-server.ts
29
39
  import * as fs from "fs";
@@ -182,6 +192,7 @@ function getHostPanelHtml() {
182
192
  <div class="profile-list" id="profileList"></div>
183
193
  <div class="nav">
184
194
  <button id="navRuntime" class="active">Runtime</button>
195
+ <button id="navDecisions">Decisions</button>
185
196
  <button id="navPolicy">Policy</button>
186
197
  </div>
187
198
  <div class="link-row">
@@ -207,6 +218,10 @@ function getHostPanelHtml() {
207
218
 
208
219
  <section id="runtimePanel" class="panel active">
209
220
  <div class="card" id="activationCard" style="margin-bottom:16px"></div>
221
+ <div class="grid two-col" style="margin-bottom:16px">
222
+ <div class="card" id="transportHealthCard"></div>
223
+ <div class="card" id="sinceLastSeenCard"></div>
224
+ </div>
210
225
  <div class="grid stats" id="statsGrid"></div>
211
226
  <div class="grid runtime-layout" style="margin-top:16px">
212
227
  <div class="card">
@@ -236,8 +251,40 @@ function getHostPanelHtml() {
236
251
  </div>
237
252
  </section>
238
253
 
254
+ <section id="decisionsPanel" class="panel">
255
+ <div class="grid two-col">
256
+ <div class="card">
257
+ <h2>Decision Inbox</h2>
258
+ <div id="decisionInboxSummary" class="muted small" style="margin-bottom:12px">Loading pending decisions\u2026</div>
259
+ <div class="audit-list" id="decisionInboxList"></div>
260
+ </div>
261
+ <div class="card">
262
+ <h2>Projection Delivery</h2>
263
+ <div id="projectionOutboxSummary" class="muted small" style="margin-bottom:12px">Loading projection delivery state\u2026</div>
264
+ <div class="audit-list" id="projectionOutboxList"></div>
265
+ </div>
266
+ </div>
267
+ </section>
268
+
239
269
  <section id="policyPanel" class="panel">
240
270
  <div class="grid policy-grid">
271
+ <div class="card">
272
+ <h2>Projection Policy</h2>
273
+ <div class="form-grid">
274
+ <label class="label">Preset</label>
275
+ <select id="projectionPreset">
276
+ <option value="quiet">quiet</option>
277
+ <option value="balanced">balanced</option>
278
+ <option value="strict">strict</option>
279
+ </select>
280
+ <div class="muted small">Balanced is the default: key conclusions, handoffs, repairs, and required decisions are pushed to the human thread; ordinary progress stays summary-first.</div>
281
+ <div class="row-actions">
282
+ <button class="action-btn" id="saveProjectionPresetBtn">Save projection preset</button>
283
+ </div>
284
+ <div id="projectionPolicyPreview" class="muted small" style="margin-top:12px"></div>
285
+ </div>
286
+ </div>
287
+
241
288
  <div class="card">
242
289
  <h2>Policy Defaults</h2>
243
290
  <div class="form-grid">
@@ -357,7 +404,7 @@ function getHostPanelHtml() {
357
404
  return {
358
405
  profile: profile && profile.trim() ? profile.trim() : null,
359
406
  sessionKey: sessionKey && sessionKey.trim() ? sessionKey.trim() : null,
360
- view: view === 'policy' ? 'policy' : 'runtime',
407
+ view: view === 'policy' ? 'policy' : (view === 'decisions' ? 'decisions' : 'runtime'),
361
408
  };
362
409
  })();
363
410
 
@@ -367,11 +414,16 @@ function getHostPanelHtml() {
367
414
  profiles: [],
368
415
  overview: null,
369
416
  session: null,
417
+ decisions: null,
370
418
  policy: null,
371
419
  selectedSessionKey: initialQuery.sessionKey || null,
372
420
  detailMode: sessionStorage.getItem('pingagent_host_panel_detail_mode') || 'basic',
373
421
  showUnreadOnly: false,
374
422
  };
423
+ const seenMarkers = {
424
+ globalTab: null,
425
+ sessionKey: null,
426
+ };
375
427
 
376
428
  function esc(value) {
377
429
  return String(value == null ? '' : value)
@@ -394,13 +446,137 @@ function getHostPanelHtml() {
394
446
  try { return new Date(value).toLocaleString(); } catch { return String(value); }
395
447
  }
396
448
 
449
+ function countSinceLastSeen(summary) {
450
+ if (!summary) return 0;
451
+ return Number(summary.new_external_messages || 0)
452
+ + Number(summary.new_conclusions || 0)
453
+ + Number(summary.new_handoffs || 0)
454
+ + Number(summary.new_decisions || 0)
455
+ + Number(summary.new_failures || 0)
456
+ + Number(summary.new_repairs || 0)
457
+ + Number(summary.new_projection_failures || 0);
458
+ }
459
+
460
+ function renderSinceLastSeenSummary(summary) {
461
+ if (!summary) return '<div class="empty">No seen-state yet.</div>';
462
+ const count = countSinceLastSeen(summary);
463
+ return '' +
464
+ '<div class="status-main">' +
465
+ '<h2>Since Last Seen</h2>' +
466
+ '<div class="muted small">Per operator view. Opening runtime, detail, or decisions resets the matching anchor instead of forcing every refresh to zero out the feed.</div>' +
467
+ '<div class="summary-pills">' +
468
+ '<span class="pill">new=' + esc(count) + '</span>' +
469
+ '<span class="pill">external=' + esc(summary.new_external_messages || 0) + '</span>' +
470
+ '<span class="pill">conclusions=' + esc(summary.new_conclusions || 0) + '</span>' +
471
+ '<span class="pill">handoffs=' + esc(summary.new_handoffs || 0) + '</span>' +
472
+ '<span class="pill">decisions=' + esc(summary.new_decisions || 0) + '</span>' +
473
+ '<span class="pill">failures=' + esc(summary.new_failures || 0) + '</span>' +
474
+ '<span class="pill">repairs=' + esc(summary.new_repairs || 0) + '</span>' +
475
+ '<span class="pill">projection=' + esc(summary.new_projection_failures || 0) + '</span>' +
476
+ '</div>' +
477
+ '<div class="muted small" style="margin-top:8px">latest=' + esc(fmtTs(summary.latest_ts)) + '</div>' +
478
+ '</div>';
479
+ }
480
+
481
+ function renderTransportHealthCard(transportHealth) {
482
+ const health = transportHealth || {};
483
+ const stateLabel = health.state || 'Ready';
484
+ const stateClass = stateLabel === 'Degraded' || stateLabel === 'Switching Recommended' ? 'degraded' : 'ready';
485
+ const current = health.transport_mode || 'bridge';
486
+ const preferred = health.preferred_transport_mode || current;
487
+ return '' +
488
+ '<div class="status-strip">' +
489
+ '<div class="status-main">' +
490
+ '<h2>Transport Health</h2>' +
491
+ '<div class="status-state ' + esc(stateClass) + '">' + esc(stateLabel) + '</div>' +
492
+ '<div class="summary-pills">' +
493
+ '<span class="pill">current=' + esc(current) + '</span>' +
494
+ '<span class="pill">preferred=' + esc(preferred) + '</span>' +
495
+ '<span class="pill">retry_queue=' + esc(health.retry_queue_length || 0) + '</span>' +
496
+ '<span class="pill">failures=' + esc(health.consecutive_failures || 0) + '</span>' +
497
+ '</div>' +
498
+ '<div class="muted small" style="margin-top:8px">last inbound=' + esc(fmtTs(health.last_inbound_at)) + ' \xB7 last outbound=' + esc(fmtTs(health.last_outbound_at)) + '</div>' +
499
+ '<div class="muted small">last degraded=' + esc(fmtTs(health.last_degraded_at)) + ' \xB7 last repaired=' + esc(fmtTs(health.last_repaired_at)) + '</div>' +
500
+ (health.last_error ? '<div style="margin-top:8px;color:#fecaca">' + esc(health.last_error) + '</div>' : '<div class="muted small" style="margin-top:8px">No recent transport errors.</div>') +
501
+ '</div>' +
502
+ '<div style="min-width:240px">' +
503
+ '<div class="label">Actions</div>' +
504
+ '<div class="row-actions" style="margin-top:8px">' +
505
+ '<button class="secondary-btn transport-switch-btn" data-mode="bridge" style="width:auto">Switch to bridge</button>' +
506
+ '<button class="secondary-btn transport-switch-btn" data-mode="channel" style="width:auto">Switch to channel</button>' +
507
+ '</div>' +
508
+ (health.state === 'Switching Recommended'
509
+ ? '<div class="muted small" style="margin-top:10px">Assist Then Switch is recommending a move back to bridge. The preference file already captures the desired fallback path.</div>'
510
+ : '<div class="muted small" style="margin-top:10px">Managed restart keeps the transport layer swappable without changing the human-facing model.</div>') +
511
+ '</div>' +
512
+ '</div>';
513
+ }
514
+
515
+ function renderProjectionPreviewBlock(preview) {
516
+ if (!preview) return '<div class="empty">No projection preview available for the selected session yet.</div>';
517
+ const detailOnly = Array.isArray(preview.description && preview.description.detail_only) ? preview.description.detail_only : [];
518
+ const immediate = Array.isArray(preview.description && preview.description.immediate) ? preview.description.immediate : [];
519
+ return '' +
520
+ '<div class="muted small">Preset <strong>' + esc(preview.preset) + '</strong> projects <code>' + esc(immediate.join(', ') || 'none') + '</code> immediately and keeps <code>' + esc(detailOnly.join(', ') || 'none') + '</code> in detail-only mode.</div>' +
521
+ '<div class="audit-list" style="margin-top:10px">' +
522
+ (Array.isArray(preview.recent) && preview.recent.length
523
+ ? preview.recent.map(function (entry) {
524
+ return '<div class="audit-row"><div class="top"><strong>' + esc(entry.event_type) + '</strong><span class="badge">' + esc(entry.disposition) + '</span></div>' +
525
+ '<div class="muted small">' + esc(entry.reason || '(no reason)') + '</div>' +
526
+ '</div>';
527
+ }).join('')
528
+ : '<div class="empty">No recent collaboration events to preview.</div>') +
529
+ '</div>';
530
+ }
531
+
532
+ function renderDeliveryTimeline(timeline) {
533
+ if (!Array.isArray(timeline) || !timeline.length) {
534
+ return '<div class="empty">No delivery timeline yet.</div>';
535
+ }
536
+ return timeline.map(function (entry) {
537
+ return '<div class="audit-row"><div class="top"><strong>' + esc(entry.kind || 'event') + '</strong><span class="badge">' + esc(fmtTs(entry.ts_ms)) + '</span></div>' +
538
+ '<div style="margin-top:8px">' + esc(entry.summary || '(no summary)') + '</div>' +
539
+ '</div>';
540
+ }).join('');
541
+ }
542
+
543
+ async function markSeen(scopeType, scopeKey) {
544
+ return api('/api/runtime/seen', {
545
+ method: 'POST',
546
+ headers: { 'Content-Type': 'application/json' },
547
+ body: JSON.stringify({
548
+ operator_id: 'host_panel',
549
+ scope_type: scopeType,
550
+ scope_key: scopeType === 'session' ? scopeKey : null,
551
+ }),
552
+ });
553
+ }
554
+
555
+ async function maybeMarkGlobalSeen(tab, force) {
556
+ if (tab !== 'runtime' && tab !== 'decisions') return;
557
+ if (!force && seenMarkers.globalTab === tab) return;
558
+ try {
559
+ await markSeen('global', null);
560
+ seenMarkers.globalTab = tab;
561
+ } catch {}
562
+ }
563
+
564
+ async function maybeMarkSessionSeen(sessionKey, force) {
565
+ if (!sessionKey) return;
566
+ if (!force && seenMarkers.sessionKey === sessionKey) return;
567
+ try {
568
+ await markSeen('session', sessionKey);
569
+ seenMarkers.sessionKey = sessionKey;
570
+ } catch {}
571
+ }
572
+
397
573
  function syncUrlState() {
398
574
  const url = new URL(window.location.href);
399
575
  if (state.selectedProfile) url.searchParams.set('profile', state.selectedProfile);
400
576
  else url.searchParams.delete('profile');
401
577
  if (state.selectedSessionKey) url.searchParams.set('session_key', state.selectedSessionKey);
402
578
  else url.searchParams.delete('session_key');
403
- url.searchParams.set('view', state.currentTab === 'policy' ? 'policy' : 'runtime');
579
+ url.searchParams.set('view', state.currentTab === 'policy' ? 'policy' : (state.currentTab === 'decisions' ? 'decisions' : 'runtime'));
404
580
  history.replaceState(null, '', url.pathname + (url.search ? url.search : ''));
405
581
  }
406
582
 
@@ -477,12 +653,24 @@ function getHostPanelHtml() {
477
653
  }
478
654
 
479
655
  function setTab(tab) {
656
+ const previous = state.currentTab;
480
657
  state.currentTab = tab;
481
658
  document.getElementById('navRuntime').classList.toggle('active', tab === 'runtime');
659
+ document.getElementById('navDecisions').classList.toggle('active', tab === 'decisions');
482
660
  document.getElementById('navPolicy').classList.toggle('active', tab === 'policy');
483
661
  document.getElementById('runtimePanel').classList.toggle('active', tab === 'runtime');
662
+ document.getElementById('decisionsPanel').classList.toggle('active', tab === 'decisions');
484
663
  document.getElementById('policyPanel').classList.toggle('active', tab === 'policy');
664
+ if (previous !== tab && tab !== 'runtime') {
665
+ seenMarkers.sessionKey = null;
666
+ }
485
667
  syncUrlState();
668
+ if (previous !== tab) {
669
+ void maybeMarkGlobalSeen(tab, true);
670
+ if (tab === 'runtime' && state.selectedSessionKey) {
671
+ void maybeMarkSessionSeen(state.selectedSessionKey, true);
672
+ }
673
+ }
486
674
  }
487
675
 
488
676
  function renderHeader() {
@@ -572,6 +760,112 @@ function getHostPanelHtml() {
572
760
  '</div>';
573
761
  }
574
762
 
763
+ function renderCollaborationEventsBlock(events, isAdvanced) {
764
+ if (!events || !events.length) {
765
+ return '<div class="empty">No collaboration events recorded yet. New external updates, runtime shifts, and decision points will appear here.</div>';
766
+ }
767
+ return events.map(function (event) {
768
+ const detail = event && event.detail ? event.detail : null;
769
+ const resolution = detail && detail.approval_resolution ? detail.approval_resolution : null;
770
+ const badges = [
771
+ '<span class="badge">' + esc(event.severity || 'info') + '</span>',
772
+ event.approval_required
773
+ ? '<span class="badge">' + esc(event.approval_status === 'pending' ? 'review' : event.approval_status) + '</span>'
774
+ : '',
775
+ event.target_human_session ? '<span class="badge">human thread</span>' : '',
776
+ ].filter(Boolean).join('');
777
+ const actions = event.approval_required && event.approval_status === 'pending'
778
+ ? '<div class="row-actions" style="margin-top:10px">' +
779
+ '<button class="action-btn collaboration-decision-btn" data-event-id="' + esc(event.id) + '" data-decision="approved">Approve</button>' +
780
+ '<button class="danger-btn collaboration-decision-btn" data-event-id="' + esc(event.id) + '" data-decision="rejected">Reject</button>' +
781
+ '</div>'
782
+ : '';
783
+ const resolutionLine = resolution
784
+ ? '<div class="muted small" style="margin-top:8px">resolution=' + esc(resolution.approval_status || event.approval_status || '(unknown)') +
785
+ ' \xB7 resolved_at=' + esc(fmtTs(resolution.resolved_at)) +
786
+ (resolution.resolved_by ? ' \xB7 resolved_by=' + esc(resolution.resolved_by) : '') +
787
+ (resolution.note ? ' \xB7 note=' + esc(resolution.note) : '') +
788
+ '</div>'
789
+ : '';
790
+ return '<div class="audit-row"><div class="top"><strong>' + esc(event.event_type) + '</strong>' + badges + '</div>' +
791
+ '<div class="muted small">' + esc(fmtTs(event.ts_ms)) + '</div>' +
792
+ '<div style="margin-top:8px">' + esc(event.summary || '(no summary)') + '</div>' +
793
+ '<div class="muted small" style="margin-top:8px">detail_ref=' + esc(detail && detail.detail_ref && detail.detail_ref.session_key ? detail.detail_ref.session_key : (event.session_key || '(none)')) + ' \xB7 conversation=' + esc(event.conversation_id || '(none)') + '</div>' +
794
+ resolutionLine +
795
+ actions +
796
+ (isAdvanced && detail ? '<pre style="margin-top:8px">' + esc(JSON.stringify(detail, null, 2)) + '</pre>' : '') +
797
+ '</div>';
798
+ }).join('');
799
+ }
800
+
801
+ function renderDecisionInbox() {
802
+ const data = state.decisions;
803
+ const pendingDecisions = data && Array.isArray(data.pendingDecisions) ? data.pendingDecisions : [];
804
+ const overdueDecisions = pendingDecisions.filter(function (event) { return !!event.overdue; });
805
+ const pendingOutbox = data && Array.isArray(data.projectionOutboxPending) ? data.projectionOutboxPending : [];
806
+ const failedOutbox = data && Array.isArray(data.projectionOutboxFailed) ? data.projectionOutboxFailed : [];
807
+ document.getElementById('decisionInboxSummary').textContent =
808
+ 'pending=' + pendingDecisions.length + ' \xB7 overdue=' + overdueDecisions.length + ' \xB7 projection_pending=' + pendingOutbox.length + ' \xB7 projection_failed=' + failedOutbox.length;
809
+ document.getElementById('decisionInboxList').innerHTML = pendingDecisions.length
810
+ ? pendingDecisions.map(function (event) {
811
+ return '<div class="audit-row"><div class="top"><strong>' + esc(event.summary || '(no summary)') + '</strong>' +
812
+ '<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end"><span class="badge">' + esc(event.severity || 'warning') + '</span>' +
813
+ (event.overdue ? '<span class="badge alert">overdue</span>' : '') + '</div></div>' +
814
+ '<div class="muted small">' + esc(fmtTs(event.ts_ms)) + ' \xB7 session=' + esc(event.session_key || '(none)') + ' \xB7 target=' + esc(event.target_human_session || '(none)') +
815
+ (event.overdue ? ' \xB7 overdue_by=' + esc(Math.ceil((event.overdue_by_ms || 0) / 60000)) + 'm' : '') + '</div>' +
816
+ '<div class="muted small" style="margin-top:8px">detail_ref=' + esc(event.conversation_id || '(none)') + '</div>' +
817
+ '<div class="row-actions">' +
818
+ '<button class="action-btn inbox-decision-btn" data-event-id="' + esc(event.id) + '" data-decision="approved">Approve</button>' +
819
+ '<button class="danger-btn inbox-decision-btn" data-event-id="' + esc(event.id) + '" data-decision="rejected">Reject</button>' +
820
+ '<button class="secondary-btn inbox-open-detail-btn" data-session-key="' + esc(event.session_key || '') + '">Open Detail</button>' +
821
+ '</div>' +
822
+ '</div>';
823
+ }).join('')
824
+ : '<div class="empty">No pending collaboration decisions.</div>';
825
+
826
+ const combinedOutbox = pendingOutbox.concat(failedOutbox);
827
+ document.getElementById('projectionOutboxSummary').textContent =
828
+ combinedOutbox.length
829
+ ? 'Human-thread projection uses a stable outbox. Failed rows stay visible until delivery recovers.'
830
+ : 'No undelivered human-thread projections.';
831
+ document.getElementById('projectionOutboxList').innerHTML = combinedOutbox.length
832
+ ? combinedOutbox.map(function (entry) {
833
+ return '<div class="audit-row"><div class="top"><strong>' + esc(entry.summary || '(no summary)') + '</strong>' +
834
+ '<span class="badge">' + esc(entry.status || 'pending') + '</span></div>' +
835
+ '<div class="muted small">' + esc(fmtTs(entry.created_at)) + ' \xB7 kind=' + esc(entry.projection_kind || 'collaboration_update') + ' \xB7 attempts=' + esc(entry.attempt_count || 0) + '</div>' +
836
+ '<div class="muted small" style="margin-top:8px">target=' + esc(entry.target_human_session || '(missing)') + ' \xB7 session=' + esc(entry.session_key || '(none)') + '</div>' +
837
+ (entry.last_error ? '<div style="margin-top:8px">' + esc(entry.last_error) + '</div>' : '') +
838
+ '</div>';
839
+ }).join('')
840
+ : '<div class="empty">No pending or failed projection deliveries.</div>';
841
+
842
+ document.querySelectorAll('.inbox-decision-btn').forEach(function (btn) {
843
+ btn.addEventListener('click', async function () {
844
+ const eventId = Number(btn.getAttribute('data-event-id'));
845
+ const decision = btn.getAttribute('data-decision');
846
+ if (!Number.isInteger(eventId) || !decision) return;
847
+ await api('/api/runtime/collaboration-decisions/resolve', {
848
+ method: 'POST',
849
+ headers: { 'Content-Type': 'application/json' },
850
+ body: JSON.stringify({ event_id: eventId, decision: decision }),
851
+ });
852
+ await refreshAll();
853
+ setTab('decisions');
854
+ });
855
+ });
856
+
857
+ document.querySelectorAll('.inbox-open-detail-btn').forEach(function (btn) {
858
+ btn.addEventListener('click', async function () {
859
+ const sessionKey = btn.getAttribute('data-session-key');
860
+ if (!sessionKey) return;
861
+ state.selectedSessionKey = sessionKey;
862
+ await loadSession(sessionKey);
863
+ renderOverview();
864
+ setTab('runtime');
865
+ });
866
+ });
867
+ }
868
+
575
869
  function getOverviewSessions() {
576
870
  return state.overview && Array.isArray(state.overview.sessions) ? state.overview.sessions : [];
577
871
  }
@@ -607,6 +901,11 @@ function getHostPanelHtml() {
607
901
  if (!overview) return;
608
902
  syncSelectedSessionFromOverview();
609
903
  const ingressState = ingressStatusModel(overview);
904
+ const transportHealth = overview.transportHealth || null;
905
+ const sinceLastSeen = overview.sinceLastSeen || null;
906
+ const overdueDecisions = Array.isArray(overview.pendingDecisions)
907
+ ? overview.pendingDecisions.filter(function (event) { return !!event.overdue; })
908
+ : [];
610
909
  document.getElementById('activationCard').innerHTML =
611
910
  '<div class="status-strip">' +
612
911
  '<div class="status-main">' +
@@ -614,6 +913,11 @@ function getHostPanelHtml() {
614
913
  '<div class="status-state ' + ingressState.className + '">' + esc(ingressState.label) + '</div>' +
615
914
  '<div class="muted small">' + esc(ingressState.detail) + '</div>' +
616
915
  '<div class="muted small">Public link: ' + esc(overview.publicSelf && overview.publicSelf.public_url ? overview.publicSelf.public_url : '(not ready yet)') + '</div>' +
916
+ '<div class="summary-pills">' +
917
+ '<span class="pill">pending_decisions=' + esc((overview.pendingDecisions || []).length) + '</span>' +
918
+ '<span class="pill">overdue=' + esc(overdueDecisions.length) + '</span>' +
919
+ '<span class="pill">projection=' + esc(overview.collaborationProjection && overview.collaborationProjection.preset ? overview.collaborationProjection.preset : 'balanced') + '</span>' +
920
+ '</div>' +
617
921
  '</div>' +
618
922
  '<div style="min-width:320px">' +
619
923
  '<div class="label">Quick Start</div>' +
@@ -628,6 +932,8 @@ function getHostPanelHtml() {
628
932
  '</div>' +
629
933
  '</div>' +
630
934
  '</div>';
935
+ document.getElementById('transportHealthCard').innerHTML = renderTransportHealthCard(transportHealth);
936
+ document.getElementById('sinceLastSeenCard').innerHTML = renderSinceLastSeenSummary(sinceLastSeen);
631
937
  const subscription = overview.subscription || null;
632
938
  const stats = [
633
939
  { label: 'Plan', value: subscription ? subscription.tier : 'ghost', sub: subscription ? subscription.summary : 'subscription unavailable' },
@@ -638,6 +944,7 @@ function getHostPanelHtml() {
638
944
  { label: 'Unread', value: overview.unreadTotal, sub: 'session-first inbox state' },
639
945
  { label: 'Tasks', value: overview.tasksTotal, sub: 'recent local task threads' },
640
946
  { label: 'Audit', value: overview.auditSummary.total_events, sub: 'policy / runtime audit events' },
947
+ { label: 'Collaboration', value: overview.collaborationSummary ? overview.collaborationSummary.total_events : 0, sub: overview.collaborationSummary ? ('pending_review=' + overview.collaborationSummary.pending_approvals) : 'projected external collaboration events' },
641
948
  { label: 'Recommendations', value: overview.recommendationSummary ? overview.recommendationSummary.total : overview.recommendations.length, sub: overview.recommendationSummary ? JSON.stringify(overview.recommendationSummary.by_status || {}) : 'learned policy suggestions' },
642
949
  { label: 'Public Link', value: overview.publicSelf && overview.publicSelf.public_url ? 'ready' : 'disabled', sub: overview.publicSelf && overview.publicSelf.public_url ? overview.publicSelf.public_url : 'create a hosted shareable profile link' },
643
950
  ];
@@ -655,6 +962,9 @@ function getHostPanelHtml() {
655
962
  const active = session.session_key === state.selectedSessionKey ? ' active' : '';
656
963
  const badges = [
657
964
  '<span class="badge ' + esc(session.trust_state) + '">' + esc(session.trust_state) + '</span>',
965
+ (countSinceLastSeen(session.since_last_seen) > 0
966
+ ? '<span class="badge alert">new ' + esc(countSinceLastSeen(session.since_last_seen)) + '</span>'
967
+ : ''),
658
968
  session.binding_alert
659
969
  ? '<button type="button" class="badge alert rebind-badge-btn" data-session="' + esc(session.session_key) + '" data-conversation="' + esc(session.conversation_id || '') + '" data-bound-session="' + esc(session.mapped_work_session || '') + '" data-remote-did="' + esc(session.remote_did || '') + '" title="OpenClaw chat link needs attention">Needs reconnect</button>'
660
970
  : '',
@@ -692,6 +1002,25 @@ function getHostPanelHtml() {
692
1002
  });
693
1003
  }
694
1004
 
1005
+ document.querySelectorAll('.transport-switch-btn').forEach(function (btn) {
1006
+ btn.addEventListener('click', async function () {
1007
+ const mode = btn.getAttribute('data-mode');
1008
+ if (mode !== 'bridge' && mode !== 'channel') return;
1009
+ const confirmed = window.confirm('Switch preferred transport to ' + mode + ' and request a managed restart?');
1010
+ if (!confirmed) return;
1011
+ const result = await api('/api/runtime/transport-switch', {
1012
+ method: 'POST',
1013
+ headers: { 'Content-Type': 'application/json' },
1014
+ body: JSON.stringify({ mode: mode }),
1015
+ });
1016
+ window.alert(result && result.result && result.result.restarted
1017
+ ? ('Preferred transport switched to ' + mode + '. Managed restart was attempted.')
1018
+ : ('Preferred transport switched to ' + mode + '. Restart is still required.'));
1019
+ await refreshAll();
1020
+ setTab('runtime');
1021
+ });
1022
+ });
1023
+
695
1024
  const tasks = Array.isArray(overview.tasks) ? overview.tasks : [];
696
1025
  document.getElementById('taskList').innerHTML = tasks.length
697
1026
  ? tasks.map(function (task) {
@@ -783,6 +1112,8 @@ conversation=' + result.conversation_id));
783
1112
  const tasks = Array.isArray(detail.tasks) ? detail.tasks : [];
784
1113
  const messages = Array.isArray(detail.messages) ? detail.messages : [];
785
1114
  const auditEvents = Array.isArray(detail.auditEvents) ? detail.auditEvents : [];
1115
+ const collaborationEvents = Array.isArray(detail.collaborationEvents) ? detail.collaborationEvents : [];
1116
+ const pendingCollaborationEvents = Array.isArray(detail.pendingCollaborationEvents) ? detail.pendingCollaborationEvents : [];
786
1117
  const recommendations = Array.isArray(detail.recommendations) ? detail.recommendations : [];
787
1118
  const openRecommendation = recommendations.find(function (item) { return item.status === 'open'; }) || null;
788
1119
  const reopenRecommendation = recommendations.find(function (item) { return item.status === 'dismissed' || item.status === 'superseded'; }) || null;
@@ -790,6 +1121,14 @@ conversation=' + result.conversation_id));
790
1121
  const bindingAlert = detail.bindingAlert || null;
791
1122
  const activeWorkSession = detail.activeWorkSession || null;
792
1123
  const summary = detail.sessionSummary || null;
1124
+ const projectionPreset = detail.collaborationProjection && detail.collaborationProjection.preset
1125
+ ? detail.collaborationProjection.preset
1126
+ : 'balanced';
1127
+ const projectionOutbox = Array.isArray(detail.projectionOutbox) ? detail.projectionOutbox : [];
1128
+ const sinceLastSeen = detail.sinceLastSeen || null;
1129
+ const deliveryTimeline = Array.isArray(detail.deliveryTimeline) ? detail.deliveryTimeline : [];
1130
+ const projectionPreview = detail.projectionPreview || null;
1131
+ const transportHealth = detail.transportHealth || null;
793
1132
  const isAdvanced = state.detailMode === 'advanced';
794
1133
  const sessionLink = buildSessionLink(session.session_key);
795
1134
  const summaryPills = [];
@@ -824,6 +1163,10 @@ conversation=' + result.conversation_id));
824
1163
  ? '<div class="muted small" style="margin-top:8px">trust_action=' + esc(recommendationActionLabel(openRecommendation)) + '</div>'
825
1164
  : (reopenRecommendation ? '<div class="muted small" style="margin-top:8px">trust_action=' + esc(recommendationActionLabel(reopenRecommendation)) + '</div>' : '')) +
826
1165
  (summaryPills.length ? '<div class="summary-pills">' + summaryPills.join('') + '</div>' : '') +
1166
+ (pendingCollaborationEvents.length
1167
+ ? '<div style="margin-top:10px;padding:10px 12px;border:1px solid #f59e0b;border-radius:10px;background:rgba(120,53,15,0.25);color:#fde68a"><strong>Decision pending</strong><div class="small" style="margin-top:6px">A collaboration update needs review before the human thread can be treated as fully current. Resolve it in the Collaboration Feed below.' +
1168
+ (pendingCollaborationEvents[0].overdue ? ' This item is overdue.' : '') + '</div></div>'
1169
+ : '') +
827
1170
  (bindingAlert
828
1171
  ? '<div style="margin-top:10px;padding:10px 12px;border:1px solid #ef4444;border-radius:10px;background:rgba(127,29,29,0.25);color:#fecaca"><strong>Needs reconnect</strong><div class="small" style="margin-top:6px">' + esc(isAdvanced ? (bindingAlert.message || 'Bound work session is missing. Rebind this PingAgent conversation to the current chat session.') : 'OpenClaw chat link is stale. Attach this PingAgent session to the current OpenClaw chat.') + '</div></div>'
829
1172
  : '') +
@@ -919,6 +1262,11 @@ conversation=' + result.conversation_id));
919
1262
  return '<div class="message-row"><div class="muted small">' + esc(fmtTs(msg.ts_ms)) + ' \xB7 ' + esc(msg.direction) + ' \xB7 ' + esc(msg.schema) + '</div><div style="margin-top:8px">' + esc(messageSummary) + '</div></div>';
920
1263
  }).join('') : '<div class="empty">No local message history yet.</div>') +
921
1264
  '</div></div>' +
1265
+ '<div><div class="label">Collaboration Feed</div><div class="audit-list" style="margin-top:8px">' +
1266
+ renderCollaborationEventsBlock(collaborationEvents, isAdvanced) +
1267
+ '</div></div>' +
1268
+ '</div>' +
1269
+ '<div class="grid two-col" style="margin-top:16px">' +
922
1270
  '<div><div class="label">Policy Audit</div><div class="audit-list" style="margin-top:8px">' +
923
1271
  (auditEvents.length ? auditEvents.map(function (event) {
924
1272
  return '<div class="audit-row"><div class="top"><strong>' + esc(event.event_type) + '</strong><span class="badge">' + esc(event.action || event.outcome || '-') + '</span></div>' +
@@ -927,6 +1275,36 @@ conversation=' + result.conversation_id));
927
1275
  '</div>';
928
1276
  }).join('') : '<div class="empty">No audit events for this session.</div>') +
929
1277
  '</div></div>' +
1278
+ '<div><div class="label">Human Thread Posture</div><div class="audit-list" style="margin-top:8px">' +
1279
+ '<div class="audit-row"><div class="top"><strong>Projection policy</strong><span class="badge">' + esc(projectionPreset) + '</span></div>' +
1280
+ '<div class="muted small">The collaboration session keeps the raw transcript. The human thread receives concise updates, risk signals, decisions, and approval results through the projection outbox.</div>' +
1281
+ '<div style="margin-top:8px">Use this detail view to inspect every raw message, task, handoff, audit event, and runtime change before taking action.</div>' +
1282
+ (sinceLastSeen
1283
+ ? '<div class="summary-pills" style="margin-top:8px">' +
1284
+ '<span class="pill">new=' + esc(countSinceLastSeen(sinceLastSeen)) + '</span>' +
1285
+ '<span class="pill">decisions=' + esc(sinceLastSeen.new_decisions || 0) + '</span>' +
1286
+ '<span class="pill">failures=' + esc(sinceLastSeen.new_failures || 0) + '</span>' +
1287
+ '</div>'
1288
+ : '') +
1289
+ (transportHealth
1290
+ ? '<div class="muted small" style="margin-top:8px">transport=' + esc(transportHealth.transport_mode || 'bridge') + ' \xB7 state=' + esc(transportHealth.state || 'Ready') + ' \xB7 retry_queue=' + esc(transportHealth.retry_queue_length || 0) + '</div>'
1291
+ : '') +
1292
+ (projectionOutbox.length
1293
+ ? '<div class="muted small" style="margin-top:8px">outbox=' + esc(projectionOutbox[0].status || 'pending') + ' \xB7 target=' + esc(projectionOutbox[0].target_human_session || '(missing)') + '</div>'
1294
+ : '<div class="muted small" style="margin-top:8px">outbox=clear</div>') +
1295
+ '<div style="margin-top:12px"><div class="label">Projection Preview</div><div style="margin-top:8px">' + renderProjectionPreviewBlock(projectionPreview) + '</div></div>' +
1296
+ '</div>' +
1297
+ '</div></div>' +
1298
+ '</div>';
1299
+
1300
+ el.innerHTML +=
1301
+ '<div class="grid two-col" style="margin-top:16px">' +
1302
+ '<div><div class="label">Since Last Seen</div><div class="audit-list" style="margin-top:8px">' +
1303
+ '<div class="audit-row">' + renderSinceLastSeenSummary(sinceLastSeen) + '</div>' +
1304
+ '</div></div>' +
1305
+ '<div><div class="label">Delivery Timeline</div><div class="audit-list" style="margin-top:8px">' +
1306
+ renderDeliveryTimeline(deliveryTimeline) +
1307
+ '</div></div>' +
930
1308
  '</div>';
931
1309
 
932
1310
  el.querySelectorAll('.approve-session-btn').forEach(function (btn) {
@@ -1014,6 +1392,21 @@ conversation=' + result.conversation_id));
1014
1392
  setTab('runtime');
1015
1393
  });
1016
1394
  });
1395
+ el.querySelectorAll('.collaboration-decision-btn').forEach(function (btn) {
1396
+ btn.addEventListener('click', async function () {
1397
+ const eventId = Number(btn.getAttribute('data-event-id'));
1398
+ const decision = btn.getAttribute('data-decision');
1399
+ if (!Number.isInteger(eventId) || !decision) return;
1400
+ await api('/api/runtime/session/collaboration-decision', {
1401
+ method: 'POST',
1402
+ headers: { 'Content-Type': 'application/json' },
1403
+ body: JSON.stringify({ event_id: eventId, decision: decision }),
1404
+ });
1405
+ await refreshAll();
1406
+ if (state.selectedSessionKey) await loadSession(state.selectedSessionKey);
1407
+ setTab('runtime');
1408
+ });
1409
+ });
1017
1410
  const sendSessionReplyBtn = document.getElementById('sendSessionReplyBtn');
1018
1411
  if (sendSessionReplyBtn) {
1019
1412
  sendSessionReplyBtn.addEventListener('click', async function () {
@@ -1089,6 +1482,11 @@ Previous chat link: ' + previous
1089
1482
  const policy = state.policy;
1090
1483
  if (!policy) return;
1091
1484
  const profile = state.overview && state.overview.profile ? state.overview.profile : null;
1485
+ document.getElementById('projectionPreset').value =
1486
+ policy.doc && policy.doc.collaboration_projection && policy.doc.collaboration_projection.preset
1487
+ ? policy.doc.collaboration_projection.preset
1488
+ : 'balanced';
1489
+ document.getElementById('projectionPolicyPreview').innerHTML = renderProjectionPreviewBlock(state.session && state.session.projectionPreview ? state.session.projectionPreview : null);
1092
1490
  document.getElementById('contactDefault').value = policy.doc.contact_policy.default_action;
1093
1491
  document.getElementById('taskDefault').value = policy.doc.task_policy.default_action;
1094
1492
  document.getElementById('profileDisplayName').value = profile && profile.display_name ? profile.display_name : '';
@@ -1260,6 +1658,7 @@ Previous chat link: ' + previous
1260
1658
  syncSelectedSessionFromOverview();
1261
1659
  renderHeader();
1262
1660
  renderOverview();
1661
+ if (state.currentTab === 'runtime') await maybeMarkGlobalSeen('runtime');
1263
1662
  if (state.selectedSessionKey) {
1264
1663
  await loadSession(state.selectedSessionKey);
1265
1664
  } else {
@@ -1271,10 +1670,14 @@ Previous chat link: ' + previous
1271
1670
 
1272
1671
  async function loadSession(sessionKey) {
1273
1672
  if (!sessionKey) return;
1673
+ const previousSessionKey = state.selectedSessionKey;
1274
1674
  state.selectedSessionKey = sessionKey;
1275
1675
  state.session = await api('/api/runtime/session?session_key=' + encodeURIComponent(sessionKey));
1276
1676
  syncUrlState();
1277
1677
  renderSession();
1678
+ if (state.currentTab === 'runtime') {
1679
+ await maybeMarkSessionSeen(sessionKey, previousSessionKey !== sessionKey);
1680
+ }
1278
1681
  }
1279
1682
 
1280
1683
  async function loadPolicy() {
@@ -1282,17 +1685,25 @@ Previous chat link: ' + previous
1282
1685
  renderPolicy();
1283
1686
  }
1284
1687
 
1688
+ async function loadDecisions() {
1689
+ state.decisions = await api('/api/runtime/collaboration-decisions');
1690
+ renderDecisionInbox();
1691
+ if (state.currentTab === 'decisions') await maybeMarkGlobalSeen('decisions');
1692
+ }
1693
+
1285
1694
  async function refreshAll() {
1286
1695
  if (!state.selectedProfile && state.profiles.length > 1) {
1287
1696
  renderHeader();
1288
1697
  return;
1289
1698
  }
1290
1699
  await loadOverview();
1700
+ await loadDecisions();
1291
1701
  await loadPolicy();
1292
1702
  renderHeader();
1293
1703
  }
1294
1704
 
1295
1705
  document.getElementById('navRuntime').addEventListener('click', function () { setTab('runtime'); });
1706
+ document.getElementById('navDecisions').addEventListener('click', function () { setTab('decisions'); });
1296
1707
  document.getElementById('navPolicy').addEventListener('click', function () { setTab('policy'); });
1297
1708
  document.getElementById('toggleUnreadBtn').addEventListener('click', async function () {
1298
1709
  state.showUnreadOnly = !state.showUnreadOnly;
@@ -1331,6 +1742,15 @@ Previous chat link: ' + previous
1331
1742
  await refreshAll();
1332
1743
  setTab('policy');
1333
1744
  });
1745
+ document.getElementById('saveProjectionPresetBtn').addEventListener('click', async function () {
1746
+ await api('/api/runtime/policy/projection', {
1747
+ method: 'POST',
1748
+ headers: { 'Content-Type': 'application/json' },
1749
+ body: JSON.stringify({ preset: document.getElementById('projectionPreset').value }),
1750
+ });
1751
+ await refreshAll();
1752
+ setTab('policy');
1753
+ });
1334
1754
  document.getElementById('addRuleBtn').addEventListener('click', async function () {
1335
1755
  await api('/api/runtime/policy/rules', {
1336
1756
  method: 'POST',
@@ -1678,6 +2098,62 @@ function writeTrustPolicyDoc(identityPath, doc) {
1678
2098
  fs.writeFileSync(policyPath, JSON.stringify(normalizeTrustPolicyDoc(doc), null, 2), "utf-8");
1679
2099
  return policyPath;
1680
2100
  }
2101
+ function normalizeProjectionPreset(value) {
2102
+ if (value === "quiet" || value === "strict") return value;
2103
+ return "balanced";
2104
+ }
2105
+ function readProjectionOutboxState(storePath, limit = 20) {
2106
+ const store = new LocalStore(storePath);
2107
+ try {
2108
+ const manager = new CollaborationProjectionOutboxManager(store);
2109
+ const pending = manager.listByStatus("pending", limit);
2110
+ const failed = manager.listByStatus("failed", limit);
2111
+ return {
2112
+ pending,
2113
+ failed,
2114
+ total_pending: pending.length,
2115
+ total_failed: failed.length
2116
+ };
2117
+ } finally {
2118
+ store.close();
2119
+ }
2120
+ }
2121
+ function readTransportHealthState(storePath) {
2122
+ const store = new LocalStore(storePath);
2123
+ try {
2124
+ const eventManager = new CollaborationEventManager(store);
2125
+ const outboxManager = new CollaborationProjectionOutboxManager(store);
2126
+ const runtimeStatus = readIngressRuntimeStatus();
2127
+ const preference = readTransportPreference();
2128
+ const normalizedStatus = runtimeStatus ? {
2129
+ ...runtimeStatus,
2130
+ preferred_transport_mode: preference?.preferred_mode ?? runtimeStatus.preferred_transport_mode ?? "bridge"
2131
+ } : {
2132
+ receive_mode: "webhook",
2133
+ transport_mode: preference?.preferred_mode ?? "bridge",
2134
+ preferred_transport_mode: preference?.preferred_mode ?? "bridge"
2135
+ };
2136
+ return deriveTransportHealth({
2137
+ runtime_status: normalizedStatus,
2138
+ recent_events: eventManager.listRecent(30),
2139
+ projection_outbox_failed: outboxManager.listByStatus("failed", 20)
2140
+ });
2141
+ } finally {
2142
+ store.close();
2143
+ }
2144
+ }
2145
+ function readSinceLastSeenState(storePath, operatorId, scopeType, scopeKey) {
2146
+ const store = new LocalStore(storePath);
2147
+ try {
2148
+ return summarizeSinceLastSeen(store, {
2149
+ operator_id: operatorId,
2150
+ scope_type: scopeType,
2151
+ scope_key: scopeKey
2152
+ });
2153
+ } finally {
2154
+ store.close();
2155
+ }
2156
+ }
1681
2157
  function buildPolicyDecisionShape(identityPath, remoteDid, opts) {
1682
2158
  const policy = readTrustPolicyDoc(identityPath);
1683
2159
  const runtimeMode = opts?.runtimeMode ?? getRuntimeMode();
@@ -1741,7 +2217,8 @@ async function buildRuntimeOverviewPayload(ctx) {
1741
2217
  const taskManager = client.getTaskThreadManager();
1742
2218
  const taskHandoffManager = client.getTaskHandoffManager();
1743
2219
  const historyManager = client.getHistoryManager();
1744
- if (!sessionManager || !sessionSummaryManager || !taskManager || !taskHandoffManager || !historyManager) {
2220
+ const collaborationEventManager = client.getCollaborationEventManager();
2221
+ if (!sessionManager || !sessionSummaryManager || !taskManager || !taskHandoffManager || !historyManager || !collaborationEventManager) {
1745
2222
  throw new Error("Runtime overview requires a writable local store");
1746
2223
  }
1747
2224
  const sessions = sessionManager.listRecentSessions(24);
@@ -1787,6 +2264,17 @@ async function buildRuntimeOverviewPayload(ctx) {
1787
2264
  acc[session.trust_state] = (acc[session.trust_state] ?? 0) + 1;
1788
2265
  return acc;
1789
2266
  }, {});
2267
+ const collaborationSummary = collaborationEventManager.summarize(200);
2268
+ const projectionOutbox = readProjectionOutboxState(ctx.storePath, 20);
2269
+ const transportHealth = readTransportHealthState(ctx.storePath);
2270
+ const sinceLastSeen = readSinceLastSeenState(ctx.storePath, "host_panel", "global");
2271
+ const decisionStore = new LocalStore(ctx.storePath);
2272
+ let decisionViews = [];
2273
+ try {
2274
+ decisionViews = listPendingDecisionViews(decisionStore, 100);
2275
+ } finally {
2276
+ decisionStore.close();
2277
+ }
1790
2278
  return {
1791
2279
  did: ctx.myDid,
1792
2280
  serverUrl: ctx.serverUrl,
@@ -1816,18 +2304,27 @@ async function buildRuntimeOverviewPayload(ctx) {
1816
2304
  contact: policy.contact_policy.enabled ? policy.contact_policy.default_action : "disabled",
1817
2305
  task: policy.task_policy.enabled ? policy.task_policy.default_action : "disabled"
1818
2306
  },
2307
+ collaborationProjection: policy.collaboration_projection,
1819
2308
  sessionsTotal: sessions.length,
1820
2309
  tasksTotal: refreshedTasks.length,
1821
2310
  unreadTotal,
1822
2311
  trustCounts,
1823
2312
  recommendationSummary: recommendationState.summary,
2313
+ collaborationSummary,
2314
+ projectionOutbox,
2315
+ transportHealth,
2316
+ sinceLastSeen,
2317
+ pendingDecisions: decisionViews,
2318
+ recentCollaborationEvents: collaborationEventManager.listRecent(20),
1824
2319
  sessions: sessions.map((session) => ({
1825
2320
  ...session,
1826
2321
  session_summary: sessionSummaryManager.get(session.session_key),
1827
2322
  mapped_work_session: session.conversation_id ? bindingByConversation.get(session.conversation_id) ?? null : null,
1828
2323
  binding_alert: session.conversation_id ? bindingAlertByConversation.get(session.conversation_id) ?? null : null,
1829
2324
  is_active_work_session: session.session_key === activeWorkSession,
1830
- latest_messages: session.conversation_id ? historyManager.listRecent(session.conversation_id, 3) : []
2325
+ latest_messages: session.conversation_id ? historyManager.listRecent(session.conversation_id, 3) : [],
2326
+ collaboration_events: collaborationEventManager.listBySession(session.session_key, 3),
2327
+ since_last_seen: readSinceLastSeenState(ctx.storePath, "host_panel", "session", session.session_key)
1831
2328
  })),
1832
2329
  tasks: refreshedTasks.map((task) => ({
1833
2330
  ...task,
@@ -1847,7 +2344,8 @@ async function buildSessionOverviewPayload(ctx, sessionKey) {
1847
2344
  const taskManager = client.getTaskThreadManager();
1848
2345
  const taskHandoffManager = client.getTaskHandoffManager();
1849
2346
  const historyManager = client.getHistoryManager();
1850
- if (!sessionManager || !sessionSummaryManager || !taskManager || !taskHandoffManager || !historyManager) {
2347
+ const collaborationEventManager = client.getCollaborationEventManager();
2348
+ if (!sessionManager || !sessionSummaryManager || !taskManager || !taskHandoffManager || !historyManager || !collaborationEventManager) {
1851
2349
  throw new Error("Session overview requires a writable local store");
1852
2350
  }
1853
2351
  const session = sessionKey ? sessionManager.get(sessionKey) : sessionManager.getActiveSession() ?? sessionManager.listRecentSessions(1)[0] ?? null;
@@ -1881,16 +2379,49 @@ async function buildSessionOverviewPayload(ctx, sessionKey) {
1881
2379
  runtimeMode: getRuntimeMode(),
1882
2380
  limit: 20
1883
2381
  });
2382
+ const outboxStore = new LocalStore(ctx.storePath);
2383
+ let projectionOutbox = [];
2384
+ let sinceLastSeen = null;
2385
+ let deliveryTimeline = [];
2386
+ let projectionPreview = null;
2387
+ let pendingDecisionViews = [];
2388
+ try {
2389
+ projectionOutbox = new CollaborationProjectionOutboxManager(outboxStore).listBySession(session.session_key, 20);
2390
+ sinceLastSeen = summarizeSinceLastSeen(outboxStore, {
2391
+ operator_id: "host_panel",
2392
+ scope_type: "session",
2393
+ scope_key: session.session_key
2394
+ });
2395
+ deliveryTimeline = buildDeliveryTimeline(outboxStore, session.session_key, 40);
2396
+ projectionPreview = buildProjectionPreview(
2397
+ outboxStore,
2398
+ session.session_key,
2399
+ policy.collaboration_projection.preset,
2400
+ 5
2401
+ );
2402
+ pendingDecisionViews = listPendingDecisionViews(outboxStore, 100).filter((event) => event.session_key === session.session_key).slice(0, 20);
2403
+ } finally {
2404
+ outboxStore.close();
2405
+ }
1884
2406
  return {
1885
2407
  session,
1886
2408
  sessionSummary: sessionSummaryManager.get(session.session_key),
2409
+ collaborationEvents: collaborationEventManager.listBySession(session.session_key, 40),
2410
+ pendingCollaborationEvents: pendingDecisionViews,
2411
+ collaborationSummary: collaborationEventManager.summarize(200),
1887
2412
  ingressRuntime: readIngressRuntimeStatus(),
2413
+ transportHealth: readTransportHealthState(ctx.storePath),
1888
2414
  binding,
1889
2415
  bindingAlert,
1890
2416
  activeWorkSession,
1891
2417
  activeWorkSessionFile: getActiveSessionFilePath(),
1892
2418
  sessionMapPath: getSessionMapFilePath(),
1893
2419
  sessionBindingAlertsPath: getSessionBindingAlertsFilePath(),
2420
+ collaborationProjection: policy.collaboration_projection,
2421
+ projectionOutbox,
2422
+ sinceLastSeen,
2423
+ deliveryTimeline,
2424
+ projectionPreview,
1894
2425
  policyExplain: buildPolicyDecisionShape(ctx.identityPath, session.remote_did, { runtimeMode: getRuntimeMode() }),
1895
2426
  tasks: tasks.map((task) => ({
1896
2427
  ...task,
@@ -1967,6 +2498,70 @@ async function handleApi(pathname, req, ctx) {
1967
2498
  status: readIngressRuntimeStatus()
1968
2499
  };
1969
2500
  }
2501
+ if (parts[1] === "transport-health" && req.method === "GET") {
2502
+ return {
2503
+ transportHealth: readTransportHealthState(ctx.storePath),
2504
+ transportPreference: readTransportPreference()
2505
+ };
2506
+ }
2507
+ if (parts[1] === "transport-switch" && req.method === "POST") {
2508
+ const body = await readBody(req);
2509
+ const mode = String(body?.mode ?? "").trim().toLowerCase();
2510
+ if (mode !== "bridge" && mode !== "channel") throw new Error("mode must be bridge or channel");
2511
+ const result = switchTransportPreference(mode, {
2512
+ updated_by: "host_panel"
2513
+ });
2514
+ const store = new LocalStore(ctx.storePath);
2515
+ try {
2516
+ new CollaborationEventManager(store).record({
2517
+ event_type: "transport_switched",
2518
+ severity: result.restarted ? "notice" : "warning",
2519
+ summary: result.restarted ? `Preferred transport switched to ${mode}. A managed restart was attempted.` : `Preferred transport switched to ${mode}. Restart is still required.`,
2520
+ detail: {
2521
+ preferred_mode: result.preferred_mode,
2522
+ restart_required: result.restart_required,
2523
+ restart_method: result.restart_method ?? null,
2524
+ restart_error: result.restart_error ?? null,
2525
+ preference_path: result.preference_path
2526
+ }
2527
+ });
2528
+ } finally {
2529
+ store.close();
2530
+ }
2531
+ return {
2532
+ ok: true,
2533
+ result,
2534
+ transportHealth: readTransportHealthState(ctx.storePath)
2535
+ };
2536
+ }
2537
+ if (parts[1] === "seen" && req.method === "POST") {
2538
+ const body = await readBody(req);
2539
+ const operatorId = String(body?.operator_id ?? "host_panel").trim();
2540
+ const scopeType = String(body?.scope_type ?? "global").trim();
2541
+ const scopeKey = typeof body?.scope_key === "string" ? body.scope_key.trim() : void 0;
2542
+ if (!["host_panel", "tui", "mcp"].includes(operatorId)) throw new Error("operator_id must be host_panel, tui, or mcp");
2543
+ if (scopeType !== "global" && scopeType !== "session") throw new Error("scope_type must be global or session");
2544
+ const store = new LocalStore(ctx.storePath);
2545
+ try {
2546
+ const seen = new OperatorSeenStateManager(store).markSeen({
2547
+ operator_id: operatorId,
2548
+ scope_type: scopeType,
2549
+ scope_key: scopeType === "session" ? scopeKey : null,
2550
+ last_seen_ts: Date.now()
2551
+ });
2552
+ return {
2553
+ ok: true,
2554
+ seen,
2555
+ summary: summarizeSinceLastSeen(store, {
2556
+ operator_id: operatorId,
2557
+ scope_type: scopeType,
2558
+ scope_key: scopeType === "session" ? scopeKey : null
2559
+ })
2560
+ };
2561
+ } finally {
2562
+ store.close();
2563
+ }
2564
+ }
1970
2565
  if (parts[1] === "demo" && req.method === "POST") {
1971
2566
  const body = await readBody(req);
1972
2567
  const preset = typeof body?.preset === "string" ? body.preset.trim().toLowerCase() : "";
@@ -2016,9 +2611,48 @@ async function handleApi(pathname, req, ctx) {
2016
2611
  receiveMode: readIngressRuntimeStatus()
2017
2612
  };
2018
2613
  }
2614
+ if (parts[1] === "collaboration-decisions") {
2615
+ const store = new LocalStore(ctx.storePath);
2616
+ try {
2617
+ const manager = new CollaborationEventManager(store);
2618
+ const outboxManager = new CollaborationProjectionOutboxManager(store);
2619
+ if (req.method === "GET") {
2620
+ const pendingDecisions = listPendingDecisionViews(store, 100);
2621
+ return {
2622
+ pendingDecisions,
2623
+ projectionOutboxPending: outboxManager.listByStatus("pending", 50),
2624
+ projectionOutboxFailed: outboxManager.listByStatus("failed", 50)
2625
+ };
2626
+ }
2627
+ if (parts[2] === "resolve" && req.method === "POST") {
2628
+ const body = await readBody(req);
2629
+ const eventId = Number(body?.event_id);
2630
+ const decision = String(body?.decision ?? "").trim().toLowerCase();
2631
+ if (!Number.isInteger(eventId) || eventId <= 0) throw new Error("event_id is required");
2632
+ if (decision !== "approved" && decision !== "rejected") {
2633
+ throw new Error("decision must be approved or rejected");
2634
+ }
2635
+ const result = manager.resolveApproval(eventId, decision, {
2636
+ resolved_by: "host_panel",
2637
+ note: typeof body?.note === "string" ? body.note.trim() || void 0 : void 0
2638
+ });
2639
+ if (!result) throw new Error(`Collaboration event ${eventId} not found`);
2640
+ return {
2641
+ ok: true,
2642
+ event: result.updated,
2643
+ resolutionEvent: result.resolution_event,
2644
+ projectionOutbox: result.projection_outbox
2645
+ };
2646
+ }
2647
+ } finally {
2648
+ store.close();
2649
+ }
2650
+ }
2019
2651
  if (parts[1] === "session") {
2020
2652
  const sessionManager = ctx.client.getSessionManager();
2653
+ const collaborationEventManager = ctx.client.getCollaborationEventManager();
2021
2654
  if (!sessionManager) throw new Error("Session actions require a writable local store");
2655
+ if (!collaborationEventManager) throw new Error("Collaboration actions require a writable local store");
2022
2656
  if (parts[2] === "reply" && req.method === "POST") {
2023
2657
  const body = await readBody(req);
2024
2658
  const session = resolveSessionForInput(sessionManager, body);
@@ -2059,6 +2693,28 @@ async function handleApi(pathname, req, ctx) {
2059
2693
  session: updated
2060
2694
  };
2061
2695
  }
2696
+ if (parts[2] === "collaboration-decision" && req.method === "POST") {
2697
+ const body = await readBody(req);
2698
+ const eventId = Number(body?.event_id);
2699
+ const decision = String(body?.decision ?? "").trim().toLowerCase();
2700
+ if (!Number.isInteger(eventId) || eventId <= 0) throw new Error("event_id is required");
2701
+ if (decision !== "approved" && decision !== "rejected") {
2702
+ throw new Error("decision must be approved or rejected");
2703
+ }
2704
+ const result = collaborationEventManager.resolveApproval(eventId, decision, {
2705
+ resolved_by: "host_panel",
2706
+ note: typeof body?.note === "string" ? body.note.trim() || void 0 : void 0
2707
+ });
2708
+ if (!result) throw new Error(`Collaboration event ${eventId} not found`);
2709
+ const session = result.updated.session_key ? sessionManager.get(result.updated.session_key) : null;
2710
+ return {
2711
+ ok: true,
2712
+ event: result.updated,
2713
+ resolutionEvent: result.resolution_event,
2714
+ projectionOutbox: result.projection_outbox,
2715
+ session
2716
+ };
2717
+ }
2062
2718
  const url = new URL(req.url || "", "http://x");
2063
2719
  const sessionKey = url.searchParams.get("session_key");
2064
2720
  return buildSessionOverviewPayload(ctx, sessionKey);
@@ -2186,6 +2842,39 @@ async function handleApi(pathname, req, ctx) {
2186
2842
  }
2187
2843
  return { ok: true, path: savedPath, doc };
2188
2844
  }
2845
+ if (parts[2] === "projection") {
2846
+ if (req.method === "GET") {
2847
+ const doc = readTrustPolicyDoc(ctx.identityPath);
2848
+ return {
2849
+ path: policyPath,
2850
+ projection: doc.collaboration_projection
2851
+ };
2852
+ }
2853
+ if (req.method === "POST") {
2854
+ const body = await readBody(req);
2855
+ const doc = readTrustPolicyDoc(ctx.identityPath);
2856
+ doc.collaboration_projection = {
2857
+ preset: normalizeProjectionPreset(body?.preset)
2858
+ };
2859
+ const savedPath = writeTrustPolicyDoc(ctx.identityPath, doc);
2860
+ const auditStore = new LocalStore(ctx.storePath);
2861
+ try {
2862
+ new TrustPolicyAuditManager(auditStore).record({
2863
+ event_type: "policy_default_updated",
2864
+ policy_scope: "session",
2865
+ action: "collaboration_projection_updated",
2866
+ outcome: "saved",
2867
+ explanation: `Updated projection preset=${doc.collaboration_projection.preset}`,
2868
+ detail: {
2869
+ preset: doc.collaboration_projection.preset
2870
+ }
2871
+ });
2872
+ } finally {
2873
+ auditStore.close();
2874
+ }
2875
+ return { ok: true, path: savedPath, projection: doc.collaboration_projection, doc };
2876
+ }
2877
+ }
2189
2878
  if (parts[2] === "rules" && req.method === "POST") {
2190
2879
  const body = await readBody(req);
2191
2880
  const doc = readTrustPolicyDoc(ctx.identityPath);