@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.
package/bin/pingagent.js CHANGED
@@ -24,12 +24,22 @@ import {
24
24
  TaskThreadManager,
25
25
  TaskHandoffManager,
26
26
  TrustPolicyAuditManager,
27
+ CollaborationEventManager,
28
+ CollaborationProjectionOutboxManager,
29
+ OperatorSeenStateManager,
27
30
  TrustRecommendationManager,
28
31
  getTrustRecommendationActionLabel,
29
32
  formatCapabilityCardSummary,
30
33
  defaultTrustPolicyDoc,
31
34
  normalizeTrustPolicyDoc,
32
35
  upsertTrustPolicyRecommendation,
36
+ summarizeSinceLastSeen,
37
+ buildDeliveryTimeline,
38
+ buildProjectionPreview,
39
+ listPendingDecisionViews,
40
+ deriveTransportHealth,
41
+ readTransportPreference,
42
+ switchTransportPreference,
33
43
  getActiveSessionFilePath,
34
44
  getSessionMapFilePath,
35
45
  getSessionBindingAlertsFilePath,
@@ -242,7 +252,16 @@ function formatSessionRow(session, selected) {
242
252
  const trust = session.trust_state || 'unknown';
243
253
  const unread = session.unread_count ?? 0;
244
254
  const who = truncateLine(session.remote_did || session.conversation_id || 'unknown', 40);
245
- return `${marker} ${who} [${trust}] unread=${unread}${reconnect}`;
255
+ const seen = session.since_last_seen || {};
256
+ const sinceCount = (seen.new_external_messages ?? 0)
257
+ + (seen.new_conclusions ?? 0)
258
+ + (seen.new_handoffs ?? 0)
259
+ + (seen.new_decisions ?? 0)
260
+ + (seen.new_failures ?? 0)
261
+ + (seen.new_repairs ?? 0)
262
+ + (seen.new_projection_failures ?? 0);
263
+ const sinceBadge = sinceCount > 0 ? ` new=${sinceCount}` : '';
264
+ return `${marker} ${who} [${trust}] unread=${unread}${sinceBadge}${reconnect}`;
246
265
  }
247
266
 
248
267
  function formatMessageRow(message) {
@@ -337,6 +356,7 @@ function buildHostState(identityPath, selectedSessionKey = null, historyPageInde
337
356
  const taskHandoffManager = new TaskHandoffManager(store);
338
357
  const historyManager = new HistoryManager(store);
339
358
  const auditManager = new TrustPolicyAuditManager(store);
359
+ const collaborationEventManager = new CollaborationEventManager(store);
340
360
  const sessions = sessionManager.listRecentSessions(50);
341
361
  const bindings = readSessionBindings();
342
362
  const alerts = readSessionBindingAlerts();
@@ -344,6 +364,21 @@ function buildHostState(identityPath, selectedSessionKey = null, historyPageInde
344
364
  const alertByConversation = new Map(alerts.map((row) => [row.conversation_id, row]));
345
365
  const activeChatSession = readCurrentActiveSessionKey();
346
366
  const ingressRuntime = readIngressRuntimeStatus();
367
+ const transportPreference = readTransportPreference();
368
+ const transportHealth = deriveTransportHealth({
369
+ runtime_status: ingressRuntime
370
+ ? {
371
+ ...ingressRuntime,
372
+ preferred_transport_mode: transportPreference?.preferred_mode ?? ingressRuntime.preferred_transport_mode ?? 'bridge',
373
+ }
374
+ : {
375
+ receive_mode: 'webhook',
376
+ transport_mode: transportPreference?.preferred_mode ?? 'bridge',
377
+ preferred_transport_mode: transportPreference?.preferred_mode ?? 'bridge',
378
+ },
379
+ recent_events: collaborationEventManager.listRecent(30),
380
+ projection_outbox_failed: new CollaborationProjectionOutboxManager(store).listByStatus('failed', 20),
381
+ });
347
382
  const desiredSelectedSessionKey =
348
383
  (selectedSessionKey && sessions.some((session) => session.session_key === selectedSessionKey) ? selectedSessionKey : null) ??
349
384
  sessionManager.getActiveSession()?.session_key ??
@@ -370,6 +405,13 @@ function buildHostState(identityPath, selectedSessionKey = null, historyPageInde
370
405
  const selectedAuditEvents = selectedSession
371
406
  ? auditManager.listBySession(selectedSession.session_key, 12)
372
407
  : [];
408
+ const selectedCollaborationEvents = selectedSession
409
+ ? collaborationEventManager.listBySession(selectedSession.session_key, 12)
410
+ : [];
411
+ const selectedPendingCollaborationEvents = selectedSession
412
+ ? listPendingDecisionViews(store, 100).filter((event) => event.session_key === selectedSession.session_key).slice(0, 12)
413
+ : [];
414
+ const pendingCollaborationEventsGlobal = listPendingDecisionViews(store, 50);
373
415
  const selectedMessages = selectedSession?.conversation_id
374
416
  ? historyManager.listRecent(selectedSession.conversation_id, 12)
375
417
  : [];
@@ -393,26 +435,54 @@ function buildHostState(identityPath, selectedSessionKey = null, historyPageInde
393
435
  : [];
394
436
  const unreadTotal = sessionsWithMeta.reduce((sum, session) => sum + (session.unread_count ?? 0), 0);
395
437
  const alertSessions = sessionsWithMeta.filter((session) => !!session.binding_alert).length;
438
+ const sinceLastSeenGlobal = summarizeSinceLastSeen(store, {
439
+ operator_id: 'tui',
440
+ scope_type: 'global',
441
+ });
442
+ const sessionsWithSeen = sessionsWithMeta.map((session) => ({
443
+ ...session,
444
+ since_last_seen: summarizeSinceLastSeen(store, {
445
+ operator_id: 'tui',
446
+ scope_type: 'session',
447
+ scope_key: session.session_key,
448
+ }),
449
+ }));
450
+ const selectedDeliveryTimeline = selectedSession
451
+ ? buildDeliveryTimeline(store, selectedSession.session_key, 30)
452
+ : [];
453
+ const selectedProjectionPreview = selectedSession
454
+ ? buildProjectionPreview(store, selectedSession.session_key, policy.doc.collaboration_projection?.preset || 'balanced', 5)
455
+ : null;
396
456
  return {
397
457
  identity,
398
458
  runtimeMode,
399
459
  activeChatSession,
400
460
  ingressRuntime,
461
+ transportPreference,
462
+ transportHealth,
401
463
  activeChatSessionFile: getActiveSessionFilePath(),
402
464
  sessionMapPath: getSessionMapFilePath(),
403
465
  sessionBindingAlertsPath: getSessionBindingAlertsFilePath(),
404
466
  policyPath: policy.path,
405
467
  policyDoc: policy.doc,
406
- sessions: sessionsWithMeta,
468
+ projectionPreset: policy.doc.collaboration_projection?.preset || 'balanced',
469
+ sinceLastSeenGlobal,
470
+ sessions: sessionsWithSeen,
407
471
  tasks: taskManager.listRecent(30).map((task) => ({
408
472
  ...task,
409
473
  handoff: taskHandoffManager.get(task.task_id),
410
474
  })),
411
475
  auditEvents: auditManager.listRecent(40),
476
+ collaborationEvents: collaborationEventManager.listRecent(40),
412
477
  selectedSession,
413
478
  selectedSessionSummary,
414
479
  selectedTasks,
415
480
  selectedAuditEvents,
481
+ selectedCollaborationEvents,
482
+ selectedPendingCollaborationEvents,
483
+ selectedDeliveryTimeline,
484
+ selectedProjectionPreview,
485
+ pendingCollaborationEventsGlobal,
416
486
  selectedMessages,
417
487
  selectedHistoryPage,
418
488
  selectedHistorySearchQuery: historySearchQuery.trim(),
@@ -432,7 +502,12 @@ function renderHostTuiScreen(hostState, uiState) {
432
502
  const selectedSummary = hostState.selectedSessionSummary || null;
433
503
  const tasks = hostState.selectedTasks || [];
434
504
  const auditEvents = hostState.selectedAuditEvents || [];
505
+ const collaborationEvents = hostState.selectedCollaborationEvents || [];
506
+ const pendingCollaborationEvents = hostState.selectedPendingCollaborationEvents || [];
507
+ const pendingCollaborationEventsGlobal = hostState.pendingCollaborationEventsGlobal || [];
435
508
  const messages = hostState.selectedMessages || [];
509
+ const deliveryTimeline = hostState.selectedDeliveryTimeline || [];
510
+ const projectionPreview = hostState.selectedProjectionPreview || null;
436
511
  const recommendations = hostState.selectedRecommendations || [];
437
512
  const openRecommendation = recommendations.find((item) => item.status === 'open') || null;
438
513
  const reopenRecommendation = recommendations.find((item) => item.status === 'dismissed' || item.status === 'superseded') || null;
@@ -442,6 +517,8 @@ function renderHostTuiScreen(hostState, uiState) {
442
517
  const view = uiState?.view || 'overview';
443
518
  const selectedTaskIndex = Math.max(0, Math.min(uiState?.selectedTaskIndex || 0, Math.max(0, tasks.length - 1)));
444
519
  const selectedTask = tasks[selectedTaskIndex] || null;
520
+ const selectedDecisionIndex = Math.max(0, Math.min(uiState?.selectedDecisionIndex || 0, Math.max(0, pendingCollaborationEventsGlobal.length - 1)));
521
+ const selectedDecision = pendingCollaborationEventsGlobal[selectedDecisionIndex] || null;
445
522
  const statusCountdown = uiState?.statusExpiresAt
446
523
  ? ` (${Math.max(0, Math.ceil((uiState.statusExpiresAt - Date.now()) / 1000))}s)`
447
524
  : '';
@@ -450,14 +527,18 @@ function renderHostTuiScreen(hostState, uiState) {
450
527
  || hostState.ingressRuntime.receive_mode === 'polling_degraded'
451
528
  || !!hostState.ingressRuntime.hooks_last_error;
452
529
  const ingressLabel = degraded ? 'Degraded' : 'Ready';
530
+ const transportHealth = hostState.transportHealth || { state: 'Ready', transport_mode: 'bridge', preferred_transport_mode: 'bridge', retry_queue_length: 0, consecutive_failures: 0 };
531
+ const sinceLastSeenGlobal = hostState.sinceLastSeenGlobal || {};
453
532
  const lines = [
454
533
  'PingAgent Host TUI',
455
534
  `DID: ${hostState.identity.did}`,
456
535
  `status=${formatStatusLine(uiState?.statusLevel || 'info', uiState?.statusMessage || '(ready)')}${statusTs}${statusCountdown}`,
457
536
  `runtime_mode=${hostState.runtimeMode} receive_mode=${hostState.ingressRuntime?.receive_mode || 'webhook'} current_openclaw_chat=${hostState.activeChatSession || '(none)'}`,
458
537
  `ingress=${ingressLabel}${degraded ? ' action=[f] fix-now' : ''}`,
538
+ `transport=${transportHealth.transport_mode} preferred=${transportHealth.preferred_transport_mode} state=${transportHealth.state} retry_queue=${transportHealth.retry_queue_length} failures=${transportHealth.consecutive_failures}`,
459
539
  uiState?.publicLinkUrl ? `public_link=${uiState.publicLinkUrl}` : null,
460
- `sessions=${sessions.length} unread_total=${hostState.unreadTotal ?? 0} alert_sessions=${hostState.alertSessions ?? 0} view=${view}`,
540
+ `sessions=${sessions.length} unread_total=${hostState.unreadTotal ?? 0} alert_sessions=${hostState.alertSessions ?? 0} view=${view} projection=${hostState.projectionPreset || 'balanced'}`,
541
+ `since_last_seen external=${sinceLastSeenGlobal.new_external_messages ?? 0} conclusions=${sinceLastSeenGlobal.new_conclusions ?? 0} decisions=${sinceLastSeenGlobal.new_decisions ?? 0} failures=${sinceLastSeenGlobal.new_failures ?? 0}`,
461
542
  `policy=${hostState.policyPath}`,
462
543
  `chat_link_map=${hostState.sessionMapPath}`,
463
544
  `chat_link_alerts=${hostState.sessionBindingAlertsPath}`,
@@ -475,6 +556,7 @@ function renderHostTuiScreen(hostState, uiState) {
475
556
  lines.push('- a: approve selected pending contact');
476
557
  lines.push('- m: mark selected session as read');
477
558
  lines.push('- t: open task list view for selected session');
559
+ lines.push('- i: open global decision inbox');
478
560
  lines.push('- x: cancel selected task (in task views)');
479
561
  lines.push('- p: multiline reply prompt (detail view)');
480
562
  lines.push('- S: edit carry-forward summary (detail view)');
@@ -483,12 +565,16 @@ function renderHostTuiScreen(hostState, uiState) {
483
565
  lines.push('- n / p: older / newer history page (history view)');
484
566
  lines.push('- s or /: search local history (history view)');
485
567
  lines.push('- y: dump task detail to stdout (task-detail view, choose json/plain)');
568
+ lines.push('- u: approve the selected collaboration decision');
569
+ lines.push('- U: reject the selected collaboration decision');
570
+ lines.push('- b: switch preferred transport to bridge');
571
+ lines.push('- c: switch preferred transport to channel');
486
572
  lines.push('- f: repair OpenClaw hooks config');
487
573
  lines.push('- A: apply first open trust recommendation for selected session');
488
574
  lines.push('- D: dismiss current open recommendation');
489
575
  lines.push('- R: reopen dismissed/superseded recommendation');
490
- lines.push('- b: attach selected session to the current OpenClaw chat');
491
- lines.push('- c: detach the selected chat link');
576
+ lines.push('- B: attach selected session to the current OpenClaw chat');
577
+ lines.push('- C: detach the selected chat link');
492
578
  lines.push('- q: quit');
493
579
  } else if (view === 'history') {
494
580
  lines.push('Conversation History');
@@ -546,6 +632,17 @@ function renderHostTuiScreen(hostState, uiState) {
546
632
  lines.push('Tasks In Session');
547
633
  lines.push(...tasks.map((task, idx) => `${idx === selectedTaskIndex ? '>' : ' '} ${task.task_id} [${task.status}] ${truncateLine(task.title || task.result_summary || '', 70)}`));
548
634
  }
635
+ } else if (view === 'decisions') {
636
+ lines.push('Decision Inbox');
637
+ lines.push(`pending=${pendingCollaborationEventsGlobal.length}`);
638
+ lines.push('actions=[u] approve [U] reject [Enter/l] open-session [j/k] select [h/Esc] back');
639
+ lines.push('');
640
+ lines.push(...(pendingCollaborationEventsGlobal.length
641
+ ? pendingCollaborationEventsGlobal.map((event, idx) => {
642
+ const marker = idx === selectedDecisionIndex ? '>' : ' ';
643
+ return `${marker} ${event.id} [${event.severity}${event.overdue ? ':overdue' : ''}] ${truncateLine(event.summary || '(none)', 88)} session=${event.session_key || '(none)'}`;
644
+ })
645
+ : ['- none']));
549
646
  } else if (view === 'tasks') {
550
647
  lines.push('Task Detail');
551
648
  if (!selected) {
@@ -606,6 +703,15 @@ function renderHostTuiScreen(hostState, uiState) {
606
703
  } else {
607
704
  lines.push('summary_objective=(none)');
608
705
  }
706
+ if (pendingCollaborationEvents.length > 0) {
707
+ lines.push(`pending_collaboration_decisions=${pendingCollaborationEvents.length}`);
708
+ lines.push(`next_decision=${truncateLine(pendingCollaborationEvents[0].summary || '(none)', 100)}`);
709
+ if (pendingCollaborationEvents[0].overdue) {
710
+ lines.push(`next_decision_overdue=${Math.ceil((pendingCollaborationEvents[0].overdue_by_ms || 0) / 60000)}m`);
711
+ }
712
+ }
713
+ const sessionSeen = selected.since_last_seen || {};
714
+ lines.push(`since_last_seen external=${sessionSeen.new_external_messages ?? 0} conclusions=${sessionSeen.new_conclusions ?? 0} decisions=${sessionSeen.new_decisions ?? 0} failures=${sessionSeen.new_failures ?? 0}`);
609
715
  const actionBar = [
610
716
  selected.trust_state === 'pending' ? '[a] approve' : null,
611
717
  '[A] apply-rec',
@@ -615,10 +721,12 @@ function renderHostTuiScreen(hostState, uiState) {
615
721
  '[d] demo',
616
722
  '[p] reply',
617
723
  '[S] summary',
724
+ pendingCollaborationEvents.length ? '[u] approve-decision' : null,
725
+ pendingCollaborationEvents.length ? '[U] reject-decision' : null,
618
726
  '[o] history',
619
727
  '[t] tasks',
620
- '[b] attach-chat',
621
- '[c] detach-chat',
728
+ '[B] attach-chat',
729
+ '[C] detach-chat',
622
730
  ].filter(Boolean).join(' ');
623
731
  lines.push(`actions=${actionBar}`);
624
732
  lines.push('');
@@ -645,6 +753,25 @@ function renderHostTuiScreen(hostState, uiState) {
645
753
  lines.push(...(messages.length ? messages.map((message) => formatMessageRow(message)) : ['- none']));
646
754
  }
647
755
  lines.push('');
756
+ lines.push('Collaboration Feed');
757
+ lines.push(...(collaborationEvents.length
758
+ ? collaborationEvents.map((event) => `- ${event.event_type} [${event.severity}${event.approval_required ? `:${event.approval_status}` : ''}] ${truncateLine(event.summary || '', 90)}`)
759
+ : ['- none']));
760
+ if (projectionPreview) {
761
+ lines.push('');
762
+ lines.push(`Projection Preview [preset=${projectionPreview.preset}]`);
763
+ lines.push(...(projectionPreview.recent.length
764
+ ? projectionPreview.recent.map((entry) => `- ${entry.event_type} => ${entry.disposition} (${truncateLine(entry.reason, 70)})`)
765
+ : ['- none']));
766
+ }
767
+ if (view === 'detail') {
768
+ lines.push('');
769
+ lines.push('Delivery Timeline');
770
+ lines.push(...(deliveryTimeline.length
771
+ ? deliveryTimeline.slice(0, 10).map((entry) => `- ${formatTs(entry.ts_ms, false)} ${entry.kind} ${truncateLine(entry.summary || '', 72)}`)
772
+ : ['- none']));
773
+ }
774
+ lines.push('');
648
775
  lines.push('Audit');
649
776
  lines.push(...(auditEvents.length
650
777
  ? auditEvents.map((event) => `- ${event.event_type} ${event.action || event.outcome || ''} ${truncateLine(event.explanation || '', 80)}`)
@@ -655,7 +782,7 @@ function renderHostTuiScreen(hostState, uiState) {
655
782
  }
656
783
 
657
784
  lines.push('');
658
- lines.push('Keys: ↑/↓ or j/k select Enter/l open Esc/h back g/G jump r refresh a approve A apply-rec D dismiss-rec R reopen-rec d demo m read p reply o history s search t tasks x cancel-task y dump f fix-hooks b attach-chat c detach-chat ? help q quit');
785
+ lines.push('Keys: ↑/↓ or j/k select Enter/l open Esc/h back g/G jump r refresh a approve u/U decide A apply-rec D dismiss-rec R reopen-rec d demo m read p reply o history s search t tasks x cancel-task y dump f fix-hooks b transport->bridge c transport->channel B attach-chat C detach-chat ? help q quit');
659
786
  return lines.join('\n');
660
787
  }
661
788
 
@@ -667,6 +794,7 @@ async function runHostTui(identityPath, opts) {
667
794
  selectedSessionKey: null,
668
795
  view: 'overview',
669
796
  selectedTaskIndex: 0,
797
+ selectedDecisionIndex: 0,
670
798
  statusMessage: '(ready)',
671
799
  statusLevel: 'info',
672
800
  statusExpiresAt: 0,
@@ -675,6 +803,10 @@ async function runHostTui(identityPath, opts) {
675
803
  historySearchQuery: '',
676
804
  publicLinkUrl: '',
677
805
  };
806
+ const seenState = {
807
+ globalView: null,
808
+ sessionKey: null,
809
+ };
678
810
 
679
811
  const render = () => {
680
812
  if (uiState.statusExpiresAt && Date.now() >= uiState.statusExpiresAt) {
@@ -717,7 +849,49 @@ async function runHostTui(identityPath, opts) {
717
849
  return rendered.hostState;
718
850
  };
719
851
 
852
+ const markSeen = (scopeType, scopeKey = null) => {
853
+ const store = openStore(identityPath);
854
+ try {
855
+ return new OperatorSeenStateManager(store).markSeen({
856
+ operator_id: 'tui',
857
+ scope_type: scopeType,
858
+ scope_key: scopeType === 'session' ? (scopeKey || null) : null,
859
+ last_seen_ts: Date.now(),
860
+ });
861
+ } finally {
862
+ store.close();
863
+ }
864
+ };
865
+
866
+ const refreshSeenMarkers = (hostState, options = {}) => {
867
+ let changed = false;
868
+ const forceGlobal = !!options.forceGlobal;
869
+ const forceSession = !!options.forceSession;
870
+ if (uiState.view === 'overview' || uiState.view === 'decisions') {
871
+ if (forceGlobal || seenState.globalView !== uiState.view) {
872
+ markSeen('global');
873
+ seenState.globalView = uiState.view;
874
+ changed = true;
875
+ }
876
+ if (uiState.view === 'overview') seenState.sessionKey = null;
877
+ } else if (['detail', 'history', 'tasks', 'task-detail'].includes(uiState.view)) {
878
+ const sessionKey = hostState.selectedSession?.session_key || null;
879
+ if (sessionKey && (forceSession || seenState.sessionKey !== sessionKey)) {
880
+ markSeen('session', sessionKey);
881
+ seenState.sessionKey = sessionKey;
882
+ changed = true;
883
+ }
884
+ }
885
+ return changed;
886
+ };
887
+
888
+ const rerenderAfterSeenUpdate = (hostState, options = {}) => {
889
+ if (!refreshSeenMarkers(hostState, options)) return hostState;
890
+ return redraw();
891
+ };
892
+
720
893
  let latestState = redraw();
894
+ latestState = rerenderAfterSeenUpdate(latestState, { forceGlobal: true });
721
895
  readline.emitKeypressEvents(process.stdin);
722
896
  if (process.stdin.setRawMode) process.stdin.setRawMode(true);
723
897
 
@@ -734,6 +908,50 @@ async function runHostTui(identityPath, opts) {
734
908
  uiState.statusAt = Date.now();
735
909
  };
736
910
 
911
+ const switchTransport = async (mode) => {
912
+ const label = mode === 'channel' ? 'channel' : 'bridge';
913
+ const confirmed = await confirmAction(`Switch preferred transport to ${label} and request a managed restart?`);
914
+ if (!confirmed) {
915
+ setStatus(`Transport switch to ${label} cancelled.`, 'warn');
916
+ latestState = redraw();
917
+ return;
918
+ }
919
+ try {
920
+ const result = switchTransportPreference(mode, {
921
+ updated_by: 'tui',
922
+ });
923
+ const store = openStore(identityPath);
924
+ try {
925
+ new CollaborationEventManager(store).record({
926
+ event_type: 'transport_switched',
927
+ severity: result.restarted ? 'notice' : 'warning',
928
+ summary: result.restarted
929
+ ? `Preferred transport switched to ${label}. A managed restart was attempted.`
930
+ : `Preferred transport switched to ${label}. Restart is still required.`,
931
+ detail: {
932
+ preferred_mode: result.preferred_mode,
933
+ restart_required: result.restart_required,
934
+ restart_method: result.restart_method ?? null,
935
+ restart_error: result.restart_error ?? null,
936
+ preference_path: result.preference_path,
937
+ },
938
+ });
939
+ } finally {
940
+ store.close();
941
+ }
942
+ setStatus(
943
+ result.restarted
944
+ ? `Preferred transport is now ${label}; managed restart attempted via ${result.restart_method || 'unknown method'}.`
945
+ : `Preferred transport saved as ${label}; restart still required.`,
946
+ result.restarted ? 'ok' : 'warn',
947
+ result.restarted ? 7000 : 9000,
948
+ );
949
+ } catch (error) {
950
+ setStatus(`Transport switch failed: ${error?.message || 'unknown error'}`, 'err', 9000);
951
+ }
952
+ latestState = redraw();
953
+ };
954
+
737
955
  const applySessionRecommendation = (selected) => {
738
956
  if (!selected?.remote_did) return { ok: false, message: 'No remote DID for selected session.' };
739
957
  const store = openStore(identityPath);
@@ -922,6 +1140,10 @@ async function runHostTui(identityPath, opts) {
922
1140
  const tasks = latestState.selectedTasks || [];
923
1141
  if (!tasks.length) return;
924
1142
  uiState.selectedTaskIndex = Math.min(tasks.length - 1, Math.max(0, uiState.selectedTaskIndex + delta));
1143
+ } else if (uiState.view === 'decisions') {
1144
+ const decisions = latestState.pendingCollaborationEventsGlobal || [];
1145
+ if (!decisions.length) return;
1146
+ uiState.selectedDecisionIndex = Math.min(decisions.length - 1, Math.max(0, uiState.selectedDecisionIndex + delta));
925
1147
  } else if (uiState.view === 'history' && !uiState.historySearchQuery) {
926
1148
  if (delta > 0 && latestState.selectedHistoryPage?.hasOlder) uiState.selectedHistoryPageIndex += 1;
927
1149
  if (delta < 0 && latestState.selectedHistoryPage?.hasNewer) uiState.selectedHistoryPageIndex = Math.max(0, uiState.selectedHistoryPageIndex - 1);
@@ -936,6 +1158,10 @@ async function runHostTui(identityPath, opts) {
936
1158
  uiState.historySearchQuery = '';
937
1159
  }
938
1160
  latestState = redraw();
1161
+ latestState = rerenderAfterSeenUpdate(latestState, {
1162
+ forceGlobal: uiState.view === 'overview' || uiState.view === 'decisions',
1163
+ forceSession: ['detail', 'history', 'tasks', 'task-detail'].includes(uiState.view),
1164
+ });
939
1165
  };
940
1166
 
941
1167
  const jumpSelection = (target) => {
@@ -943,6 +1169,10 @@ async function runHostTui(identityPath, opts) {
943
1169
  const tasks = latestState.selectedTasks || [];
944
1170
  if (!tasks.length) return;
945
1171
  uiState.selectedTaskIndex = target;
1172
+ } else if (uiState.view === 'decisions') {
1173
+ const decisions = latestState.pendingCollaborationEventsGlobal || [];
1174
+ if (!decisions.length) return;
1175
+ uiState.selectedDecisionIndex = target;
946
1176
  } else if (uiState.view === 'history') {
947
1177
  uiState.selectedHistoryPageIndex = 0;
948
1178
  } else {
@@ -954,6 +1184,10 @@ async function runHostTui(identityPath, opts) {
954
1184
  uiState.historySearchQuery = '';
955
1185
  }
956
1186
  latestState = redraw();
1187
+ latestState = rerenderAfterSeenUpdate(latestState, {
1188
+ forceGlobal: uiState.view === 'overview' || uiState.view === 'decisions',
1189
+ forceSession: ['detail', 'history', 'tasks', 'task-detail'].includes(uiState.view),
1190
+ });
957
1191
  };
958
1192
 
959
1193
  const stopInterval = () => {
@@ -989,23 +1223,34 @@ async function runHostTui(identityPath, opts) {
989
1223
  if (_str === 'G') {
990
1224
  const max = uiState.view === 'tasks' || uiState.view === 'task-detail'
991
1225
  ? Math.max(0, (latestState.selectedTasks || []).length - 1)
992
- : Math.max(0, (latestState.sessions || []).length - 1);
1226
+ : uiState.view === 'decisions'
1227
+ ? Math.max(0, (latestState.pendingCollaborationEventsGlobal || []).length - 1)
1228
+ : Math.max(0, (latestState.sessions || []).length - 1);
993
1229
  return jumpSelection(max);
994
1230
  }
995
1231
  if (key?.name === 'return' || key?.name === 'enter' || key?.name === 'l') {
996
1232
  if (uiState.view === 'tasks') {
997
1233
  uiState.view = 'task-detail';
1234
+ } else if (uiState.view === 'decisions') {
1235
+ const decision = (latestState.pendingCollaborationEventsGlobal || [])[Math.max(0, Math.min(uiState.selectedDecisionIndex || 0, Math.max(0, (latestState.pendingCollaborationEventsGlobal || []).length - 1)))] || null;
1236
+ if (decision?.session_key) {
1237
+ uiState.selectedSessionKey = decision.session_key;
1238
+ uiState.view = 'detail';
1239
+ }
998
1240
  } else {
999
1241
  uiState.view = 'detail';
1000
1242
  }
1001
1243
  latestState = redraw();
1244
+ latestState = rerenderAfterSeenUpdate(latestState, { forceSession: uiState.view !== 'decisions' && uiState.view !== 'overview' });
1002
1245
  return;
1003
1246
  }
1004
1247
  if (key?.name === 'escape' || key?.name === 'h') {
1005
1248
  if (uiState.view === 'task-detail') uiState.view = 'tasks';
1006
1249
  else if (uiState.view === 'history') uiState.view = 'detail';
1250
+ else if (uiState.view === 'decisions') uiState.view = 'overview';
1007
1251
  else uiState.view = 'overview';
1008
1252
  latestState = redraw();
1253
+ latestState = rerenderAfterSeenUpdate(latestState, { forceGlobal: uiState.view === 'overview' || uiState.view === 'decisions' });
1009
1254
  return;
1010
1255
  }
1011
1256
  if (_str === '?') {
@@ -1042,6 +1287,15 @@ async function runHostTui(identityPath, opts) {
1042
1287
  uiState.selectedHistoryPageIndex = 0;
1043
1288
  setStatus('Opened task list view.', 'info');
1044
1289
  latestState = redraw();
1290
+ latestState = rerenderAfterSeenUpdate(latestState, { forceSession: true });
1291
+ return;
1292
+ }
1293
+ if (key?.name === 'i') {
1294
+ uiState.view = 'decisions';
1295
+ uiState.selectedDecisionIndex = 0;
1296
+ setStatus('Opened decision inbox.', 'info');
1297
+ latestState = redraw();
1298
+ latestState = rerenderAfterSeenUpdate(latestState, { forceGlobal: true });
1045
1299
  return;
1046
1300
  }
1047
1301
  if (key?.name === 'r') {
@@ -1083,6 +1337,45 @@ async function runHostTui(identityPath, opts) {
1083
1337
  latestState = redraw();
1084
1338
  return;
1085
1339
  }
1340
+ if (key?.name === 'u' || _str === 'U') {
1341
+ const approvalStatus = _str === 'U' ? 'rejected' : 'approved';
1342
+ const pendingEvent = uiState.view === 'decisions'
1343
+ ? (latestState.pendingCollaborationEventsGlobal || [])[Math.max(0, Math.min(uiState.selectedDecisionIndex || 0, Math.max(0, (latestState.pendingCollaborationEventsGlobal || []).length - 1)))] || null
1344
+ : (latestState.selectedPendingCollaborationEvents || [])[0];
1345
+ const selected = pendingEvent?.session_key
1346
+ ? (latestState.sessions || []).find((session) => session.session_key === pendingEvent.session_key) || latestState.selectedSession
1347
+ : latestState.selectedSession;
1348
+ if (!pendingEvent || !selected) return;
1349
+ const confirmed = await confirmAction(`${approvalStatus === 'approved' ? 'Approve' : 'Reject'} collaboration decision ${pendingEvent.id}\nSession: ${selected.session_key}\nSummary: ${pendingEvent.summary}\nProceed?`);
1350
+ if (!confirmed) {
1351
+ setStatus('Collaboration decision cancelled.', 'warn');
1352
+ latestState = redraw();
1353
+ return;
1354
+ }
1355
+ const store = openStore(identityPath);
1356
+ try {
1357
+ const manager = new CollaborationEventManager(store);
1358
+ const result = manager.resolveApproval(pendingEvent.id, approvalStatus, {
1359
+ resolved_by: 'tui',
1360
+ });
1361
+ if (!result) {
1362
+ setStatus(`Decision ${pendingEvent.id} no longer exists.`, 'err');
1363
+ } else {
1364
+ setStatus(
1365
+ approvalStatus === 'approved'
1366
+ ? `Approved collaboration decision ${pendingEvent.id}${result.projection_outbox ? ` · human-thread projection=${result.projection_outbox.status}` : ''}`
1367
+ : `Rejected collaboration decision ${pendingEvent.id}${result.projection_outbox ? ` · human-thread projection=${result.projection_outbox.status}` : ''}`,
1368
+ 'ok',
1369
+ );
1370
+ }
1371
+ } catch (error) {
1372
+ setStatus(`Collaboration decision failed: ${error?.message || 'unknown error'}`, 'err', 9000);
1373
+ } finally {
1374
+ store.close();
1375
+ }
1376
+ latestState = redraw();
1377
+ return;
1378
+ }
1086
1379
  if (_str === 'A') {
1087
1380
  const selected = latestState.selectedSession;
1088
1381
  if (!selected) return;
@@ -1185,6 +1478,7 @@ async function runHostTui(identityPath, opts) {
1185
1478
  uiState.historySearchQuery = '';
1186
1479
  setStatus('Opened local history view.', 'info');
1187
1480
  latestState = redraw();
1481
+ latestState = rerenderAfterSeenUpdate(latestState, { forceSession: true });
1188
1482
  return;
1189
1483
  }
1190
1484
  if ((_str === '/' || key?.name === 's') && uiState.view === 'history') {
@@ -1197,6 +1491,14 @@ async function runHostTui(identityPath, opts) {
1197
1491
  return;
1198
1492
  }
1199
1493
  if (key?.name === 'b') {
1494
+ await switchTransport('bridge');
1495
+ return;
1496
+ }
1497
+ if (key?.name === 'c') {
1498
+ await switchTransport('channel');
1499
+ return;
1500
+ }
1501
+ if (_str === 'B') {
1200
1502
  const selected = (latestState.sessions || []).find((session) => session.session_key === uiState.selectedSessionKey);
1201
1503
  if (!selected?.conversation_id) return;
1202
1504
  const current = latestState.activeChatSession || '(none)';
@@ -1216,7 +1518,7 @@ async function runHostTui(identityPath, opts) {
1216
1518
  latestState = redraw();
1217
1519
  return;
1218
1520
  }
1219
- if (key?.name === 'c') {
1521
+ if (_str === 'C') {
1220
1522
  const selected = (latestState.sessions || []).find((session) => session.session_key === uiState.selectedSessionKey);
1221
1523
  if (!selected?.conversation_id) return;
1222
1524
  removeSessionBinding(selected.conversation_id);