@pingagent/sdk 0.1.10 → 0.1.12

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.
@@ -11,9 +11,11 @@ import {
11
11
  getActiveSessionFilePath,
12
12
  getSessionBindingAlertsFilePath,
13
13
  getSessionMapFilePath,
14
+ getTrustRecommendationActionLabel,
14
15
  loadIdentity,
15
16
  normalizeTrustPolicyDoc,
16
17
  readCurrentActiveSessionKey,
18
+ readIngressRuntimeStatus,
17
19
  readSessionBindingAlerts,
18
20
  readSessionBindings,
19
21
  removeSessionBinding,
@@ -21,12 +23,13 @@ import {
21
23
  summarizeTrustPolicyAudit,
22
24
  updateStoredToken,
23
25
  upsertTrustPolicyRecommendation
24
- } from "./chunk-2Y6YRKTO.js";
26
+ } from "./chunk-BLHMTUID.js";
25
27
 
26
28
  // src/web-server.ts
27
29
  import * as fs from "fs";
28
30
  import * as http from "http";
29
31
  import * as path from "path";
32
+ import { spawnSync } from "child_process";
30
33
  import { SCHEMA_TEXT } from "@pingagent/schemas";
31
34
 
32
35
  // src/host-panel-html.ts
@@ -75,6 +78,30 @@ function getHostPanelHtml() {
75
78
  padding: 16px;
76
79
  box-shadow: 0 14px 40px rgba(0, 0, 0, 0.22);
77
80
  }
81
+ .status-strip {
82
+ display: flex;
83
+ justify-content: space-between;
84
+ gap: 16px;
85
+ align-items: flex-start;
86
+ flex-wrap: wrap;
87
+ }
88
+ .status-main { display: grid; gap: 8px; }
89
+ .status-main h2 { margin: 0; font-size: 18px; }
90
+ .status-state {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ gap: 8px;
94
+ width: fit-content;
95
+ padding: 6px 12px;
96
+ border-radius: 999px;
97
+ font-size: 12px;
98
+ letter-spacing: 0.08em;
99
+ text-transform: uppercase;
100
+ border: 1px solid #334155;
101
+ }
102
+ .status-state.ready { background: rgba(34, 197, 94, 0.12); color: #86efac; border-color: rgba(34, 197, 94, 0.35); }
103
+ .status-state.degraded { background: rgba(248, 113, 113, 0.14); color: #fecaca; border-color: rgba(248, 113, 113, 0.45); }
104
+ .quickstart-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
78
105
  .card h2, .card h3 { margin: 0 0 12px; font-size: 16px; }
79
106
  .stats .value { font-size: 28px; font-weight: 700; margin-top: 6px; }
80
107
  .runtime-layout { grid-template-columns: minmax(260px, 340px) minmax(0, 1fr); align-items: start; }
@@ -126,6 +153,20 @@ function getHostPanelHtml() {
126
153
  }
127
154
  .empty { color: #94a3b8; font-size: 13px; }
128
155
  .link-row { display: flex; gap: 10px; margin-top: 20px; }
156
+ .toolbar-row { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
157
+ .toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
158
+ .mode-toggle { display: inline-flex; gap: 6px; padding: 4px; border-radius: 999px; border: 1px solid #334155; background: #020617; }
159
+ .mode-toggle button {
160
+ border: 0;
161
+ background: transparent;
162
+ color: #94a3b8;
163
+ border-radius: 999px;
164
+ padding: 6px 10px;
165
+ cursor: pointer;
166
+ }
167
+ .mode-toggle button.active { background: #0f766e; color: #ecfeff; }
168
+ .summary-pills { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
169
+ .summary-pills .pill { font-size: 11px; }
129
170
  @media (max-width: 1000px) {
130
171
  .layout { grid-template-columns: 1fr; }
131
172
  .sidebar { border-right: none; border-bottom: 1px solid #1e293b; }
@@ -155,15 +196,31 @@ function getHostPanelHtml() {
155
196
  </div>
156
197
  <div class="header-actions">
157
198
  <span class="pill" id="runtimeModePill">runtime_mode=bridge</span>
199
+ <span class="pill" id="receiveModePill">receive_mode=webhook</span>
158
200
  <span class="pill" id="policyPathPill">policy=\u2026</span>
201
+ <button class="secondary-btn" id="fixHooksBtn" style="width:auto">Fix OpenClaw Hooks</button>
202
+ <button class="secondary-btn" id="publicLinkBtn" style="width:auto">Public Link</button>
203
+ <button class="secondary-btn" id="contactCardBtn" style="width:auto">Contact Card</button>
204
+ <a class="secondary-btn" href="/a/demo" target="_blank" rel="noreferrer" style="width:auto">Try Demo Agent</a>
159
205
  </div>
160
206
  </div>
161
207
 
162
208
  <section id="runtimePanel" class="panel active">
209
+ <div class="card" id="activationCard" style="margin-bottom:16px"></div>
163
210
  <div class="grid stats" id="statsGrid"></div>
164
211
  <div class="grid runtime-layout" style="margin-top:16px">
165
212
  <div class="card">
166
- <h2>Recent Sessions</h2>
213
+ <div class="toolbar-row">
214
+ <h2 style="margin:0">Recent Sessions</h2>
215
+ <div class="toolbar-actions">
216
+ <button class="secondary-btn" id="toggleUnreadBtn" style="width:auto">Unread only: off</button>
217
+ <button class="secondary-btn" id="nextUnreadBtn" style="width:auto">Next unread</button>
218
+ <div class="mode-toggle" aria-label="Runtime detail mode">
219
+ <button id="detailModeBasicBtn" class="active" type="button">Basic</button>
220
+ <button id="detailModeAdvancedBtn" type="button">Advanced</button>
221
+ </div>
222
+ </div>
223
+ </div>
167
224
  <div class="sessions" id="sessionList"></div>
168
225
  </div>
169
226
  <div class="grid">
@@ -253,19 +310,67 @@ function getHostPanelHtml() {
253
310
  <div class="audit-list" id="policyAuditList"></div>
254
311
  </div>
255
312
  </div>
313
+
314
+ <div class="card" style="margin-top:16px">
315
+ <h2>Profile + Capability Card</h2>
316
+ <div class="form-grid">
317
+ <label class="label">Display name</label>
318
+ <input id="profileDisplayName" placeholder="Agent name">
319
+ <label class="label">Bio</label>
320
+ <textarea id="profileBio" placeholder="What this agent does"></textarea>
321
+ <label class="label">Tags (comma separated)</label>
322
+ <input id="profileTags" placeholder="coding, devops">
323
+ <label class="label">Legacy capabilities (comma separated)</label>
324
+ <input id="profileCapabilities" placeholder="coding, testing">
325
+ <label class="label">Capability summary</label>
326
+ <textarea id="capabilityCardSummary" placeholder="Short machine-readable summary"></textarea>
327
+ <label class="label">Accepts new work</label>
328
+ <select id="capabilityCardAcceptsNewWork">
329
+ <option value="">(unspecified)</option>
330
+ <option value="true">true</option>
331
+ <option value="false">false</option>
332
+ </select>
333
+ <label class="label">Preferred contact mode</label>
334
+ <select id="capabilityCardContactMode">
335
+ <option value="">(unspecified)</option>
336
+ <option value="dm">dm</option>
337
+ <option value="task">task</option>
338
+ <option value="either">either</option>
339
+ </select>
340
+ <label class="label">Capability entries JSON</label>
341
+ <textarea id="capabilityCardItems" placeholder='[{"id":"coding","label":"Coding","accepts_tasks":true}]'></textarea>
342
+ <div class="row-actions">
343
+ <button class="action-btn" id="saveProfileBtn">Save Profile</button>
344
+ </div>
345
+ </div>
346
+ </div>
256
347
  </section>
257
348
  </main>
258
349
  </div>
259
350
 
260
351
  <script>
352
+ const initialQuery = (function () {
353
+ const params = new URLSearchParams(window.location.search);
354
+ const profile = params.get('profile');
355
+ const sessionKey = params.get('session_key');
356
+ const view = params.get('view');
357
+ return {
358
+ profile: profile && profile.trim() ? profile.trim() : null,
359
+ sessionKey: sessionKey && sessionKey.trim() ? sessionKey.trim() : null,
360
+ view: view === 'policy' ? 'policy' : 'runtime',
361
+ };
362
+ })();
363
+
261
364
  const state = {
262
- selectedProfile: sessionStorage.getItem('pingagent_host_panel_profile') || null,
263
- currentTab: 'runtime',
365
+ selectedProfile: initialQuery.profile || sessionStorage.getItem('pingagent_host_panel_profile') || null,
366
+ currentTab: initialQuery.view,
264
367
  profiles: [],
265
368
  overview: null,
266
369
  session: null,
267
370
  policy: null,
268
- selectedSessionKey: null,
371
+ selectedSessionKey: initialQuery.sessionKey || null,
372
+ detailMode: sessionStorage.getItem('pingagent_host_panel_detail_mode') || 'basic',
373
+ showUnreadOnly: false,
269
374
  };
270
375
 
271
376
  function esc(value) {
@@ -277,11 +382,57 @@ function getHostPanelHtml() {
277
382
  .replace(/'/g, '&#39;');
278
383
  }
279
384
 
385
+ function parseCsvList(value) {
386
+ return String(value == null ? '' : value)
387
+ .split(',')
388
+ .map(function (item) { return item.trim(); })
389
+ .filter(Boolean);
390
+ }
391
+
280
392
  function fmtTs(value) {
281
393
  if (!value) return '-';
282
394
  try { return new Date(value).toLocaleString(); } catch { return String(value); }
283
395
  }
284
396
 
397
+ function syncUrlState() {
398
+ const url = new URL(window.location.href);
399
+ if (state.selectedProfile) url.searchParams.set('profile', state.selectedProfile);
400
+ else url.searchParams.delete('profile');
401
+ if (state.selectedSessionKey) url.searchParams.set('session_key', state.selectedSessionKey);
402
+ else url.searchParams.delete('session_key');
403
+ url.searchParams.set('view', state.currentTab === 'policy' ? 'policy' : 'runtime');
404
+ history.replaceState(null, '', url.pathname + (url.search ? url.search : ''));
405
+ }
406
+
407
+ function setDetailMode(mode) {
408
+ state.detailMode = mode === 'advanced' ? 'advanced' : 'basic';
409
+ sessionStorage.setItem('pingagent_host_panel_detail_mode', state.detailMode);
410
+ document.getElementById('detailModeBasicBtn').classList.toggle('active', state.detailMode === 'basic');
411
+ document.getElementById('detailModeAdvancedBtn').classList.toggle('active', state.detailMode === 'advanced');
412
+ if (state.overview) renderOverview();
413
+ if (state.session) renderSession();
414
+ }
415
+
416
+ function buildSessionLink(sessionKey) {
417
+ const url = new URL(window.location.href);
418
+ if (state.selectedProfile) url.searchParams.set('profile', state.selectedProfile);
419
+ else url.searchParams.delete('profile');
420
+ if (sessionKey) url.searchParams.set('session_key', sessionKey);
421
+ else url.searchParams.delete('session_key');
422
+ url.searchParams.set('view', 'runtime');
423
+ return url.toString();
424
+ }
425
+
426
+ async function copyText(text, fallbackLabel) {
427
+ try {
428
+ await navigator.clipboard.writeText(text);
429
+ window.alert((fallbackLabel || 'Copied') + ':
430
+ ' + text);
431
+ } catch {
432
+ window.prompt(fallbackLabel || 'Copy', text);
433
+ }
434
+ }
435
+
285
436
  async function api(path, opts) {
286
437
  let url = path;
287
438
  if (state.selectedProfile) {
@@ -331,6 +482,7 @@ function getHostPanelHtml() {
331
482
  document.getElementById('navPolicy').classList.toggle('active', tab === 'policy');
332
483
  document.getElementById('runtimePanel').classList.toggle('active', tab === 'runtime');
333
484
  document.getElementById('policyPanel').classList.toggle('active', tab === 'policy');
485
+ syncUrlState();
334
486
  }
335
487
 
336
488
  function renderHeader() {
@@ -338,20 +490,148 @@ function getHostPanelHtml() {
338
490
  const profileLabel = state.selectedProfile ? 'profile=' + state.selectedProfile : 'Select profile';
339
491
  const title = overview ? ('Host Panel \xB7 ' + overview.did) : 'PingAgent Host Panel';
340
492
  const tier = overview && overview.subscription ? overview.subscription.tier : null;
493
+ const receiveMode = overview && overview.ingressRuntime ? overview.ingressRuntime.receive_mode : 'webhook';
341
494
  document.getElementById('headerTitle').textContent = title;
342
495
  document.getElementById('headerSubtitle').textContent = overview
343
496
  ? (profileLabel + ' \xB7 ' + overview.serverUrl + (tier ? (' \xB7 tier=' + tier) : '') + ' \xB7 sessions=' + overview.sessionsTotal + ' \xB7 unread=' + overview.unreadTotal)
344
497
  : profileLabel;
345
498
  document.getElementById('runtimeModePill').textContent = overview ? ('runtime_mode=' + overview.runtimeMode) : 'runtime_mode=\u2026';
499
+ document.getElementById('receiveModePill').textContent = 'receive_mode=' + receiveMode;
346
500
  document.getElementById('policyPathPill').textContent = overview ? ('policy=' + overview.trustPolicyPath) : 'policy=\u2026';
501
+ document.getElementById('fixHooksBtn').textContent = receiveMode === 'polling_degraded' || (overview && overview.ingressRuntime && overview.ingressRuntime.hooks_last_error) ? 'Fix now' : 'Fix OpenClaw Hooks';
502
+ }
503
+
504
+ function ingressStatusModel(overview) {
505
+ const ingress = overview && overview.ingressRuntime ? overview.ingressRuntime : null;
506
+ const degraded = !ingress || ingress.receive_mode === 'polling_degraded' || !!ingress.hooks_last_error;
507
+ return {
508
+ degraded: degraded,
509
+ label: degraded ? 'Degraded' : 'Ready',
510
+ className: degraded ? 'degraded' : 'ready',
511
+ detail: degraded
512
+ ? (ingress && ingress.hooks_last_error ? ingress.hooks_last_error : 'Webhook ingress is degraded. Fix hooks or keep running on polling fallback.')
513
+ : 'Webhook ingress is healthy. New inbound messages land on the main runtime path.',
514
+ };
515
+ }
516
+
517
+ function recommendationActionLabel(item) {
518
+ if (!item) return 'Apply Recommendation';
519
+ if (item.status === 'dismissed' || item.status === 'superseded') return 'Reopen';
520
+ if (item.status === 'applied') return 'Applied';
521
+ if (item.primary_action_label) return item.primary_action_label;
522
+ if (item.policy === 'contact' && item.action === 'approve') return 'Approve + remember sender';
523
+ if (item.policy === 'contact' && item.action === 'manual') return 'Keep contact manual';
524
+ if (item.policy === 'contact' && item.action === 'reject') return 'Block this sender';
525
+ if (item.policy === 'task' && item.action === 'bridge') return 'Keep tasks manual';
526
+ if (item.policy === 'task' && item.action === 'execute') return 'Allow tasks from this sender';
527
+ if (item.policy === 'task' && item.action === 'deny') return 'Block tasks from this sender';
528
+ return 'Apply Recommendation';
529
+ }
530
+
531
+ function formatCapabilityCardEditor(card) {
532
+ if (!card) return '';
533
+ try {
534
+ return JSON.stringify(Array.isArray(card.capabilities) ? card.capabilities : [], null, 2);
535
+ } catch {
536
+ return '';
537
+ }
538
+ }
539
+
540
+ function renderSummaryBlock(summary) {
541
+ if (!summary) {
542
+ return '<div class="empty">No carry-forward summary yet. Save one below to make delegation and session continuation cheaper.</div>';
543
+ }
544
+ const parts = [
545
+ summary.objective ? '<div><span class="label">Objective</span><div style="margin-top:6px">' + esc(summary.objective) + '</div></div>' : '',
546
+ summary.context ? '<div><span class="label">Context</span><div style="margin-top:6px">' + esc(summary.context) + '</div></div>' : '',
547
+ summary.constraints ? '<div><span class="label">Constraints</span><div style="margin-top:6px">' + esc(summary.constraints) + '</div></div>' : '',
548
+ summary.decisions ? '<div><span class="label">Decisions</span><div style="margin-top:6px">' + esc(summary.decisions) + '</div></div>' : '',
549
+ summary.open_questions ? '<div><span class="label">Open Questions</span><div style="margin-top:6px">' + esc(summary.open_questions) + '</div></div>' : '',
550
+ summary.next_action ? '<div><span class="label">Next Action</span><div style="margin-top:6px">' + esc(summary.next_action) + '</div></div>' : '',
551
+ summary.handoff_ready_text ? '<div><span class="label">Handoff Ready</span><pre style="margin-top:6px">' + esc(summary.handoff_ready_text) + '</pre></div>' : '',
552
+ ].filter(Boolean);
553
+ parts.push('<div class="muted small">updated=' + esc(fmtTs(summary.updated_at)) + '</div>');
554
+ return parts.join('');
555
+ }
556
+
557
+ function renderHandoffBlock(handoff) {
558
+ if (!handoff) return '';
559
+ return '' +
560
+ '<div style="margin-top:8px;padding:10px 12px;border:1px solid #1d4ed8;border-radius:10px;background:rgba(30,64,175,0.18)">' +
561
+ '<div class="label">Handoff</div>' +
562
+ (handoff.objective ? '<div class="muted small" style="margin-top:6px">objective=' + esc(handoff.objective) + '</div>' : '') +
563
+ (handoff.priority ? '<div class="muted small">priority=' + esc(handoff.priority) + '</div>' : '') +
564
+ (handoff.success_criteria ? '<div class="muted small">success=' + esc(handoff.success_criteria) + '</div>' : '') +
565
+ (handoff.callback_session_key ? '<div class="muted small">callback=' + esc(handoff.callback_session_key) + '</div>' : '') +
566
+ (handoff.delegated_by || handoff.delegated_to
567
+ ? '<div class="muted small">delegated_by=' + esc(handoff.delegated_by || '(unknown)') + ' \xB7 delegated_to=' + esc(handoff.delegated_to || '(unknown)') + '</div>'
568
+ : '') +
569
+ (handoff.carry_forward_summary
570
+ ? '<pre style="margin-top:8px">' + esc(handoff.carry_forward_summary) + '</pre>'
571
+ : '') +
572
+ '</div>';
573
+ }
574
+
575
+ function getOverviewSessions() {
576
+ return state.overview && Array.isArray(state.overview.sessions) ? state.overview.sessions : [];
577
+ }
578
+
579
+ function getVisibleSessions() {
580
+ const sessions = getOverviewSessions();
581
+ return state.showUnreadOnly
582
+ ? sessions.filter(function (session) { return Number(session.unread_count || 0) > 0; })
583
+ : sessions;
584
+ }
585
+
586
+ function syncSelectedSessionFromOverview() {
587
+ const allSessions = getOverviewSessions();
588
+ const visibleSessions = getVisibleSessions();
589
+ if (!allSessions.length) {
590
+ state.selectedSessionKey = null;
591
+ state.session = null;
592
+ return;
593
+ }
594
+ const hasSelected = allSessions.some(function (session) { return session.session_key === state.selectedSessionKey; });
595
+ if (!hasSelected) {
596
+ state.selectedSessionKey = visibleSessions.length ? visibleSessions[0].session_key : allSessions[0].session_key;
597
+ return;
598
+ }
599
+ if (state.showUnreadOnly) {
600
+ const stillVisible = visibleSessions.some(function (session) { return session.session_key === state.selectedSessionKey; });
601
+ if (!stillVisible) state.selectedSessionKey = visibleSessions.length ? visibleSessions[0].session_key : null;
602
+ }
347
603
  }
348
604
 
349
605
  function renderOverview() {
350
606
  const overview = state.overview;
351
607
  if (!overview) return;
608
+ syncSelectedSessionFromOverview();
609
+ const ingressState = ingressStatusModel(overview);
610
+ document.getElementById('activationCard').innerHTML =
611
+ '<div class="status-strip">' +
612
+ '<div class="status-main">' +
613
+ '<h2>Activation</h2>' +
614
+ '<div class="status-state ' + ingressState.className + '">' + esc(ingressState.label) + '</div>' +
615
+ '<div class="muted small">' + esc(ingressState.detail) + '</div>' +
616
+ '<div class="muted small">Public link: ' + esc(overview.publicSelf && overview.publicSelf.public_url ? overview.publicSelf.public_url : '(not ready yet)') + '</div>' +
617
+ '</div>' +
618
+ '<div style="min-width:320px">' +
619
+ '<div class="label">Quick Start</div>' +
620
+ '<div class="quickstart-row">' +
621
+ '<button class="action-btn demo-preset-btn" data-preset="hello">Demo: hello</button>' +
622
+ '<button class="secondary-btn demo-preset-btn" data-preset="delegate">Demo: delegate</button>' +
623
+ '<button class="secondary-btn demo-preset-btn" data-preset="trust">Demo: trust</button>' +
624
+ '</div>' +
625
+ '<div class="quickstart-row">' +
626
+ '<button class="secondary-btn" id="copyPublicLinkBtn">Copy Public Link</button>' +
627
+ '<button class="secondary-btn" id="makeContactCardBtn">Share Contact Card</button>' +
628
+ '</div>' +
629
+ '</div>' +
630
+ '</div>';
352
631
  const subscription = overview.subscription || null;
353
632
  const stats = [
354
633
  { label: 'Plan', value: subscription ? subscription.tier : 'ghost', sub: subscription ? subscription.summary : 'subscription unavailable' },
634
+ { label: 'Ingress', value: overview.ingressRuntime ? overview.ingressRuntime.receive_mode : 'webhook', sub: overview.ingressRuntime && overview.ingressRuntime.reason ? overview.ingressRuntime.reason : 'OpenClaw ingress receive mode' },
355
635
  { label: 'Relay', value: subscription ? (subscription.usage.relay_today + '/' + subscription.usage.relay_limit) : '-', sub: subscription ? ('retention=' + subscription.retention_label) : 'daily relay usage' },
356
636
  { label: 'Alias', value: subscription ? (subscription.usage.alias_count + '/' + subscription.usage.alias_limit) : '-', sub: subscription ? (subscription.audit_export_allowed ? 'audit export enabled' : 'audit export off on this plan') : 'identity limits' },
357
637
  { label: 'Sessions', value: overview.sessionsTotal, sub: JSON.stringify(overview.trustCounts || {}) },
@@ -359,29 +639,33 @@ function getHostPanelHtml() {
359
639
  { label: 'Tasks', value: overview.tasksTotal, sub: 'recent local task threads' },
360
640
  { label: 'Audit', value: overview.auditSummary.total_events, sub: 'policy / runtime audit events' },
361
641
  { label: 'Recommendations', value: overview.recommendationSummary ? overview.recommendationSummary.total : overview.recommendations.length, sub: overview.recommendationSummary ? JSON.stringify(overview.recommendationSummary.by_status || {}) : 'learned policy suggestions' },
642
+ { 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' },
362
643
  ];
363
644
  document.getElementById('statsGrid').innerHTML = stats.map(function (item) {
364
645
  return '<div class="card"><div class="label">' + esc(item.label) + '</div><div class="value">' + esc(item.value) + '</div><div class="muted small">' + esc(item.sub) + '</div></div>';
365
646
  }).join('');
366
647
 
367
- const sessions = Array.isArray(overview.sessions) ? overview.sessions : [];
648
+ const toggleUnreadBtn = document.getElementById('toggleUnreadBtn');
649
+ if (toggleUnreadBtn) toggleUnreadBtn.textContent = 'Unread only: ' + (state.showUnreadOnly ? 'on' : 'off');
650
+ const sessions = getVisibleSessions();
368
651
  if (!sessions.length) {
369
- document.getElementById('sessionList').innerHTML = '<div class="empty">No sessions yet.</div>';
652
+ document.getElementById('sessionList').innerHTML = '<div class="empty">' + (state.showUnreadOnly ? 'No unread sessions.' : 'No sessions yet.') + '</div>';
370
653
  } else {
371
- if (!state.selectedSessionKey) state.selectedSessionKey = sessions[0].session_key;
372
654
  document.getElementById('sessionList').innerHTML = sessions.map(function (session) {
373
655
  const active = session.session_key === state.selectedSessionKey ? ' active' : '';
374
656
  const badges = [
375
657
  '<span class="badge ' + esc(session.trust_state) + '">' + esc(session.trust_state) + '</span>',
376
658
  session.binding_alert
377
- ? '<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="' + esc(session.binding_alert.message || 'Rebind this conversation to the current chat session') + '">Needs rebind</button>'
659
+ ? '<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>'
378
660
  : '',
379
661
  ].filter(Boolean).join('');
380
662
  return '<div class="session-row' + active + '" data-session="' + esc(session.session_key) + '">' +
381
- '<div class="top"><strong>' + esc(session.remote_did || session.conversation_id || 'unknown') + '</strong><div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + badges + '</div></div>' +
382
- '<div class="muted small" style="margin-top:6px">conversation=' + esc(session.conversation_id || '(none)') + '</div>' +
663
+ '<div class="top"><strong>' + esc(session.remote_did || session.session_key || 'unknown') + '</strong><div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + badges + '</div></div>' +
383
664
  '<div class="muted small">unread=' + esc(session.unread_count) + ' \xB7 last=' + esc(fmtTs(session.last_remote_activity_at || session.updated_at)) + '</div>' +
384
- '<div class="muted small">work_session=' + esc(session.mapped_work_session || '(unbound)') + (session.is_active_work_session ? ' \xB7 active_chat=true' : '') + '</div>' +
665
+ (state.detailMode === 'advanced'
666
+ ? '<div class="muted small" style="margin-top:6px">conversation=' + esc(session.conversation_id || '(none)') + '</div>' +
667
+ '<div class="muted small">work_session=' + esc(session.mapped_work_session || '(unbound)') + (session.is_active_work_session ? ' \xB7 active_chat=true' : '') + '</div>'
668
+ : '') +
385
669
  '<div class="muted small" style="margin-top:6px">' + esc(session.last_message_preview || '(no preview)') + '</div>' +
386
670
  '</div>';
387
671
  }).join('');
@@ -416,6 +700,7 @@ function getHostPanelHtml() {
416
700
  '<div class="muted small">updated=' + esc(fmtTs(task.updated_at)) + '</div>' +
417
701
  (task.result_summary ? '<div style="margin-top:8px">' + esc(task.result_summary) + '</div>' : '') +
418
702
  (task.error_message ? '<div style="margin-top:8px;color:#fca5a5">' + esc(task.error_message) + '</div>' : '') +
703
+ renderHandoffBlock(task.handoff) +
419
704
  '</div>';
420
705
  }).join('')
421
706
  : '<div class="empty">No recent task threads.</div>';
@@ -433,6 +718,56 @@ function getHostPanelHtml() {
433
718
  : '') +
434
719
  '</div>' + document.getElementById('taskList').innerHTML;
435
720
  }
721
+
722
+ document.querySelectorAll('.demo-preset-btn').forEach(function (btn) {
723
+ btn.addEventListener('click', async function () {
724
+ const preset = btn.getAttribute('data-preset');
725
+ const result = await api('/api/runtime/demo', {
726
+ method: 'POST',
727
+ headers: { 'Content-Type': 'application/json' },
728
+ body: JSON.stringify({ preset: preset }),
729
+ });
730
+ window.alert(result.sent
731
+ ? ('Demo agent messaged.
732
+
733
+ conversation=' + result.conversation_id)
734
+ : ('Demo agent ready.
735
+
736
+ conversation=' + result.conversation_id));
737
+ await refreshAll();
738
+ });
739
+ });
740
+ const copyPublicLinkBtn = document.getElementById('copyPublicLinkBtn');
741
+ if (copyPublicLinkBtn) {
742
+ copyPublicLinkBtn.addEventListener('click', async function () {
743
+ const url = overview.publicSelf && overview.publicSelf.public_url ? overview.publicSelf.public_url : '';
744
+ if (!url) {
745
+ window.alert('No public link yet. Use Public Link or refresh after hosted auto-create.');
746
+ return;
747
+ }
748
+ try {
749
+ await navigator.clipboard.writeText(url);
750
+ window.alert('Copied public link:
751
+ ' + url);
752
+ } catch {
753
+ window.prompt('Copy public link', url);
754
+ }
755
+ });
756
+ }
757
+ const makeContactCardBtn = document.getElementById('makeContactCardBtn');
758
+ if (makeContactCardBtn) {
759
+ makeContactCardBtn.addEventListener('click', async function () {
760
+ const result = await api('/api/public/contact-card', {
761
+ method: 'POST',
762
+ headers: { 'Content-Type': 'application/json' },
763
+ body: JSON.stringify({
764
+ target_did: overview && overview.did ? overview.did : undefined,
765
+ }),
766
+ });
767
+ window.alert('Contact card ready:
768
+ ' + (result.share_url || '(missing share_url)'));
769
+ });
770
+ }
436
771
  }
437
772
 
438
773
  function renderSession() {
@@ -449,32 +784,99 @@ function getHostPanelHtml() {
449
784
  const messages = Array.isArray(detail.messages) ? detail.messages : [];
450
785
  const auditEvents = Array.isArray(detail.auditEvents) ? detail.auditEvents : [];
451
786
  const recommendations = Array.isArray(detail.recommendations) ? detail.recommendations : [];
787
+ const openRecommendation = recommendations.find(function (item) { return item.status === 'open'; }) || null;
788
+ const reopenRecommendation = recommendations.find(function (item) { return item.status === 'dismissed' || item.status === 'superseded'; }) || null;
452
789
  const binding = detail.binding || null;
453
790
  const bindingAlert = detail.bindingAlert || null;
454
791
  const activeWorkSession = detail.activeWorkSession || null;
792
+ const summary = detail.sessionSummary || null;
793
+ const isAdvanced = state.detailMode === 'advanced';
794
+ const sessionLink = buildSessionLink(session.session_key);
795
+ const summaryPills = [];
796
+ if (contact && contact.action) summaryPills.push('<span class="pill">contact=' + esc(contact.action) + '</span>');
797
+ if (task && task.action) summaryPills.push('<span class="pill">task=' + esc(task.action) + '</span>');
798
+ if (session.trust_state) summaryPills.push('<span class="pill">trust=' + esc(session.trust_state) + '</span>');
799
+ if (Number(session.unread_count || 0) > 0) summaryPills.push('<span class="pill">unread=' + esc(session.unread_count) + '</span>');
800
+ if (binding && binding.session_key) summaryPills.push('<span class="pill">chat link attached</span>');
801
+ else if (activeWorkSession) summaryPills.push('<span class="pill">current OpenClaw chat available</span>');
802
+ const policyBlock = isAdvanced
803
+ ? '<pre style="margin-top:8px">[Contact]\\naction=' + esc(contact.action) + '\\nsource=' + esc(contact.source) + (contact.matched_rule ? '\\nmatched_rule=' + esc(contact.matched_rule) : '') + '\\n' + esc(contact.explanation) + '\\n\\n[Task]\\naction=' + esc(task.action) + '\\nsource=' + esc(task.source) + (task.matched_rule ? '\\nmatched_rule=' + esc(task.matched_rule) : '') + '\\n' + esc(task.explanation) + '</pre>'
804
+ : '<div class="summary-pills" style="margin-top:8px">' + summaryPills.join('') + '</div>' +
805
+ '<div class="muted small" style="margin-top:10px">' + esc(contact.explanation || '(no contact explanation)') + '</div>' +
806
+ '<div class="muted small" style="margin-top:6px">' + esc(task.explanation || '(no task explanation)') + '</div>';
455
807
 
456
808
  el.innerHTML = '' +
457
809
  '<div class="two-col">' +
458
810
  '<div>' +
459
811
  '<div class="label">Session</div>' +
460
812
  '<div style="margin-top:8px"><strong>' + esc(session.remote_did || '(unknown)') + '</strong></div>' +
461
- '<div class="muted small">session=' + esc(session.session_key) + '</div>' +
462
- '<div class="muted small">conversation=' + esc(session.conversation_id || '(none)') + '</div>' +
463
813
  '<div class="muted small">trust=' + esc(session.trust_state) + ' \xB7 unread=' + esc(session.unread_count) + '</div>' +
464
814
  '<div class="muted small">last activity=' + esc(fmtTs(session.last_remote_activity_at || session.updated_at)) + '</div>' +
465
- '<div class="muted small" style="margin-top:8px">active_chat_session=' + esc(activeWorkSession || '(none)') + '</div>' +
466
- '<div class="muted small">binding=' + esc(binding ? binding.session_key : '(unbound)') + '</div>' +
815
+ (isAdvanced
816
+ ? '<div style="margin-top:8px">' +
817
+ '<div class="muted small">session=' + esc(session.session_key) + '</div>' +
818
+ '<div class="muted small">conversation=' + esc(session.conversation_id || '(none)') + '</div>' +
819
+ '<div class="muted small">active_chat_session=' + esc(activeWorkSession || '(none)') + '</div>' +
820
+ '<div class="muted small">binding=' + esc(binding ? binding.session_key : '(unbound)') + '</div>' +
821
+ '</div>'
822
+ : '') +
823
+ (openRecommendation
824
+ ? '<div class="muted small" style="margin-top:8px">trust_action=' + esc(recommendationActionLabel(openRecommendation)) + '</div>'
825
+ : (reopenRecommendation ? '<div class="muted small" style="margin-top:8px">trust_action=' + esc(recommendationActionLabel(reopenRecommendation)) + '</div>' : '')) +
826
+ (summaryPills.length ? '<div class="summary-pills">' + summaryPills.join('') + '</div>' : '') +
467
827
  (bindingAlert
468
- ? '<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 rebind</strong><div class="small" style="margin-top:6px">' + esc(bindingAlert.message || 'Bound work session is missing. Rebind this PingAgent conversation to the current chat session.') + '</div></div>'
828
+ ? '<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>'
469
829
  : '') +
470
830
  '<div class="row-actions">' +
471
- '<button class="action-btn bind-current-btn" data-conversation="' + esc(session.conversation_id || '') + '">Bind Current Chat</button>' +
472
- '<button class="danger-btn clear-binding-btn" data-conversation="' + esc(session.conversation_id || '') + '">Clear Binding</button>' +
831
+ (session.trust_state === 'pending'
832
+ ? '<button class="action-btn approve-session-btn" data-session="' + esc(session.session_key) + '">Approve Contact</button>'
833
+ : '') +
834
+ (openRecommendation
835
+ ? '<button class="action-btn apply-session-recommendation-btn" data-session="' + esc(session.session_key) + '">' + esc(recommendationActionLabel(openRecommendation)) + '</button>'
836
+ : '') +
837
+ (openRecommendation
838
+ ? '<button class="danger-btn dismiss-session-recommendation-btn" data-session="' + esc(session.session_key) + '">Dismiss</button>'
839
+ : '') +
840
+ (!openRecommendation && reopenRecommendation
841
+ ? '<button class="secondary-btn reopen-session-recommendation-btn" data-session="' + esc(session.session_key) + '">Reopen</button>'
842
+ : '') +
843
+ '<button class="action-btn bind-current-btn" data-conversation="' + esc(session.conversation_id || '') + '">Attach to Current Chat</button>' +
844
+ '<button class="secondary-btn mark-read-btn" data-session="' + esc(session.session_key) + '">Mark read</button>' +
845
+ '<button class="secondary-btn copy-session-link-btn" data-session="' + esc(session.session_key) + '">Copy Session Link</button>' +
846
+ '<button class="danger-btn clear-binding-btn" data-conversation="' + esc(session.conversation_id || '') + '">Detach Chat Link</button>' +
847
+ '</div>' +
848
+ '<div class="form-grid" style="margin-top:16px">' +
849
+ '<label class="label">Reply in this session</label>' +
850
+ '<textarea id="sessionReplyInput" placeholder="Send a text reply in this session"></textarea>' +
851
+ '<div class="row-actions"><button class="action-btn" id="sendSessionReplyBtn">Send Reply</button></div>' +
473
852
  '</div>' +
474
853
  '</div>' +
475
854
  '<div>' +
476
855
  '<div class="label">Policy Decisions</div>' +
477
- '<pre style="margin-top:8px">[Contact]\\naction=' + esc(contact.action) + '\\nsource=' + esc(contact.source) + (contact.matched_rule ? '\\nmatched_rule=' + esc(contact.matched_rule) : '') + '\\n' + esc(contact.explanation) + '\\n\\n[Task]\\naction=' + esc(task.action) + '\\nsource=' + esc(task.source) + (task.matched_rule ? '\\nmatched_rule=' + esc(task.matched_rule) : '') + '\\n' + esc(task.explanation) + '</pre>' +
856
+ policyBlock +
857
+ (isAdvanced && recommendations.length
858
+ ? '<pre style="margin-top:12px">recommendations_debug=' + esc(JSON.stringify(recommendations.map(function (item) { return { id: item.id, status: item.status, policy: item.policy, action: item.action, current_action: item.current_action, match: item.match, confidence: item.confidence }; }), null, 2)) + '</pre>'
859
+ : '') +
860
+ (isAdvanced ? '<div class="muted small" style="margin-top:10px">permalink=' + esc(sessionLink) + '</div>' : '') +
861
+ '</div>' +
862
+ '</div>' +
863
+ '<div class="grid two-col" style="margin-top:16px">' +
864
+ '<div>' +
865
+ '<div class="label">Carry-Forward Summary</div>' +
866
+ '<div style="margin-top:8px">' + renderSummaryBlock(summary) + '</div>' +
867
+ '</div>' +
868
+ '<div>' +
869
+ '<div class="label">Update Summary</div>' +
870
+ '<div class="form-grid" style="margin-top:8px">' +
871
+ '<input id="sessionSummaryObjective" placeholder="Objective" value="' + esc(summary && summary.objective ? summary.objective : '') + '">' +
872
+ '<textarea id="sessionSummaryContext" placeholder="Context">' + esc(summary && summary.context ? summary.context : '') + '</textarea>' +
873
+ '<textarea id="sessionSummaryConstraints" placeholder="Constraints">' + esc(summary && summary.constraints ? summary.constraints : '') + '</textarea>' +
874
+ '<textarea id="sessionSummaryDecisions" placeholder="Decisions">' + esc(summary && summary.decisions ? summary.decisions : '') + '</textarea>' +
875
+ '<textarea id="sessionSummaryOpenQuestions" placeholder="Open questions">' + esc(summary && summary.open_questions ? summary.open_questions : '') + '</textarea>' +
876
+ '<textarea id="sessionSummaryNextAction" placeholder="Next action">' + esc(summary && summary.next_action ? summary.next_action : '') + '</textarea>' +
877
+ '<textarea id="sessionSummaryHandoff" placeholder="Handoff-ready summary">' + esc(summary && summary.handoff_ready_text ? summary.handoff_ready_text : '') + '</textarea>' +
878
+ '<div class="row-actions"><button class="action-btn" id="saveSessionSummaryBtn">Save Summary</button></div>' +
879
+ '</div>' +
478
880
  '</div>' +
479
881
  '</div>' +
480
882
  '<div class="grid two-col" style="margin-top:16px">' +
@@ -484,15 +886,26 @@ function getHostPanelHtml() {
484
886
  '<div class="muted small">updated=' + esc(fmtTs(taskItem.updated_at)) + '</div>' +
485
887
  (taskItem.result_summary ? '<div style="margin-top:8px">' + esc(taskItem.result_summary) + '</div>' : '') +
486
888
  (taskItem.error_message ? '<div style="margin-top:8px;color:#fca5a5">' + esc(taskItem.error_message) + '</div>' : '') +
889
+ renderHandoffBlock(taskItem.handoff) +
487
890
  '</div>';
488
891
  }).join('') : '<div class="empty">No tasks in this session.</div>') +
489
892
  '</div></div>' +
490
893
  '<div><div class="label">Learned Recommendations</div><div class="recommendation-list" style="margin-top:8px">' +
491
894
  (recommendations.length ? recommendations.map(function (item) {
895
+ const actionButton = item.status === 'open'
896
+ ? '<button class="action-btn apply-session-recommendation-btn" data-session="' + esc(session.session_key) + '">' + esc(recommendationActionLabel(item)) + '</button>'
897
+ : '';
898
+ const dismissButton = item.status === 'open'
899
+ ? '<button class="danger-btn dismiss-session-recommendation-btn" data-session="' + esc(session.session_key) + '">Dismiss</button>'
900
+ : '';
901
+ const reopenButton = (item.status === 'dismissed' || item.status === 'superseded')
902
+ ? '<button class="secondary-btn reopen-session-recommendation-btn" data-session="' + esc(session.session_key) + '">Reopen</button>'
903
+ : '';
492
904
  return '<div class="recommendation-row"><div class="top"><strong>' + esc(item.policy) + '</strong><span class="badge">' + esc(item.status + ' \xB7 ' + item.action) + '</span></div>' +
493
- '<div class="muted small">current=' + esc(item.current_action) + ' \xB7 confidence=' + esc(item.confidence) + '</div>' +
494
- '<div class="muted small">match=' + esc(item.match) + '</div>' +
905
+ (isAdvanced ? '<div class="muted small">current=' + esc(item.current_action) + ' \xB7 confidence=' + esc(item.confidence) + '</div>' : '') +
906
+ (isAdvanced ? '<div class="muted small">match=' + esc(item.match) + '</div>' : '') +
495
907
  '<div style="margin-top:8px">' + esc(item.reason) + '</div>' +
908
+ '<div class="row-actions">' + actionButton + dismissButton + reopenButton + '</div>' +
496
909
  '</div>';
497
910
  }).join('') : '<div class="empty">No learned recommendation for this session.</div>') +
498
911
  '</div></div>' +
@@ -500,10 +913,10 @@ function getHostPanelHtml() {
500
913
  '<div class="grid two-col" style="margin-top:16px">' +
501
914
  '<div><div class="label">Recent Messages</div><div class="message-list" style="margin-top:8px">' +
502
915
  (messages.length ? messages.map(function (msg) {
503
- const summary = msg.schema === 'pingagent.text@1' && msg.payload && msg.payload.text
916
+ const messageSummary = msg.schema === 'pingagent.text@1' && msg.payload && msg.payload.text
504
917
  ? msg.payload.text
505
918
  : JSON.stringify(msg.payload || {});
506
- 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(summary) + '</div></div>';
919
+ 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>';
507
920
  }).join('') : '<div class="empty">No local message history yet.</div>') +
508
921
  '</div></div>' +
509
922
  '<div><div class="label">Policy Audit</div><div class="audit-list" style="margin-top:8px">' +
@@ -516,6 +929,24 @@ function getHostPanelHtml() {
516
929
  '</div></div>' +
517
930
  '</div>';
518
931
 
932
+ el.querySelectorAll('.approve-session-btn').forEach(function (btn) {
933
+ btn.addEventListener('click', async function () {
934
+ const sessionKey = btn.getAttribute('data-session');
935
+ const result = await api('/api/runtime/session/approve', {
936
+ method: 'POST',
937
+ headers: { 'Content-Type': 'application/json' },
938
+ body: JSON.stringify({ session_key: sessionKey }),
939
+ });
940
+ await refreshAll();
941
+ const promoted = result && result.dm_conversation_id
942
+ ? getOverviewSessions().find(function (item) { return item.conversation_id === result.dm_conversation_id; })
943
+ : null;
944
+ state.selectedSessionKey = promoted ? promoted.session_key : sessionKey;
945
+ renderOverview();
946
+ if (state.selectedSessionKey) await loadSession(state.selectedSessionKey);
947
+ setTab('runtime');
948
+ });
949
+ });
519
950
  el.querySelectorAll('.bind-current-btn').forEach(function (btn) {
520
951
  btn.addEventListener('click', async function () {
521
952
  const conversationId = btn.getAttribute('data-conversation');
@@ -523,6 +954,22 @@ function getHostPanelHtml() {
523
954
  await promptBindCurrentChat(conversationId);
524
955
  });
525
956
  });
957
+ el.querySelectorAll('.mark-read-btn').forEach(function (btn) {
958
+ btn.addEventListener('click', async function () {
959
+ await api('/api/runtime/session/mark-read', {
960
+ method: 'POST',
961
+ headers: { 'Content-Type': 'application/json' },
962
+ body: JSON.stringify({ session_key: btn.getAttribute('data-session') }),
963
+ });
964
+ await refreshAll();
965
+ setTab('runtime');
966
+ });
967
+ });
968
+ el.querySelectorAll('.copy-session-link-btn').forEach(function (btn) {
969
+ btn.addEventListener('click', async function () {
970
+ await copyText(buildSessionLink(btn.getAttribute('data-session')), 'Session link');
971
+ });
972
+ });
526
973
  el.querySelectorAll('.clear-binding-btn').forEach(function (btn) {
527
974
  btn.addEventListener('click', async function () {
528
975
  await api('/api/runtime/session-bindings/clear', {
@@ -534,6 +981,79 @@ function getHostPanelHtml() {
534
981
  setTab('runtime');
535
982
  });
536
983
  });
984
+ el.querySelectorAll('.apply-session-recommendation-btn').forEach(function (btn) {
985
+ btn.addEventListener('click', async function () {
986
+ await api('/api/runtime/policy/recommendations/apply-session', {
987
+ method: 'POST',
988
+ headers: { 'Content-Type': 'application/json' },
989
+ body: JSON.stringify({ session_key: btn.getAttribute('data-session') }),
990
+ });
991
+ await refreshAll();
992
+ setTab('runtime');
993
+ });
994
+ });
995
+ el.querySelectorAll('.dismiss-session-recommendation-btn').forEach(function (btn) {
996
+ btn.addEventListener('click', async function () {
997
+ await api('/api/runtime/policy/recommendations/dismiss-session', {
998
+ method: 'POST',
999
+ headers: { 'Content-Type': 'application/json' },
1000
+ body: JSON.stringify({ session_key: btn.getAttribute('data-session') }),
1001
+ });
1002
+ await refreshAll();
1003
+ setTab('runtime');
1004
+ });
1005
+ });
1006
+ el.querySelectorAll('.reopen-session-recommendation-btn').forEach(function (btn) {
1007
+ btn.addEventListener('click', async function () {
1008
+ await api('/api/runtime/policy/recommendations/reopen-session', {
1009
+ method: 'POST',
1010
+ headers: { 'Content-Type': 'application/json' },
1011
+ body: JSON.stringify({ session_key: btn.getAttribute('data-session') }),
1012
+ });
1013
+ await refreshAll();
1014
+ setTab('runtime');
1015
+ });
1016
+ });
1017
+ const sendSessionReplyBtn = document.getElementById('sendSessionReplyBtn');
1018
+ if (sendSessionReplyBtn) {
1019
+ sendSessionReplyBtn.addEventListener('click', async function () {
1020
+ const input = document.getElementById('sessionReplyInput');
1021
+ const message = input.value.trim();
1022
+ if (!message) {
1023
+ window.alert('Reply text is required.');
1024
+ return;
1025
+ }
1026
+ await api('/api/runtime/session/reply', {
1027
+ method: 'POST',
1028
+ headers: { 'Content-Type': 'application/json' },
1029
+ body: JSON.stringify({ session_key: session.session_key, message: message }),
1030
+ });
1031
+ input.value = '';
1032
+ await refreshAll();
1033
+ setTab('runtime');
1034
+ });
1035
+ }
1036
+ const saveSessionSummaryBtn = document.getElementById('saveSessionSummaryBtn');
1037
+ if (saveSessionSummaryBtn) {
1038
+ saveSessionSummaryBtn.addEventListener('click', async function () {
1039
+ await api('/api/runtime/session-summary', {
1040
+ method: 'POST',
1041
+ headers: { 'Content-Type': 'application/json' },
1042
+ body: JSON.stringify({
1043
+ session_key: session.session_key,
1044
+ objective: document.getElementById('sessionSummaryObjective').value.trim(),
1045
+ context: document.getElementById('sessionSummaryContext').value.trim(),
1046
+ constraints: document.getElementById('sessionSummaryConstraints').value.trim(),
1047
+ decisions: document.getElementById('sessionSummaryDecisions').value.trim(),
1048
+ open_questions: document.getElementById('sessionSummaryOpenQuestions').value.trim(),
1049
+ next_action: document.getElementById('sessionSummaryNextAction').value.trim(),
1050
+ handoff_ready_text: document.getElementById('sessionSummaryHandoff').value.trim(),
1051
+ }),
1052
+ });
1053
+ await refreshAll();
1054
+ setTab('runtime');
1055
+ });
1056
+ }
537
1057
  }
538
1058
 
539
1059
  async function promptBindCurrentChat(conversationId, previousBinding, remoteDid) {
@@ -543,7 +1063,7 @@ function getHostPanelHtml() {
543
1063
  const previous = previousBinding || (state.session && state.session.binding ? state.session.binding.session_key : null) || '(unbound)';
544
1064
  const targetRemoteDid = remoteDid || (state.session && state.session.session ? state.session.session.remote_did : null) || '(unknown)';
545
1065
  const confirmed = window.confirm(
546
- 'Rebind this PingAgent conversation to the current chat session?' +
1066
+ 'Attach this PingAgent session to the current OpenClaw chat?' +
547
1067
  '
548
1068
 
549
1069
  Conversation: ' + conversationId +
@@ -551,9 +1071,9 @@ Conversation: ' + conversationId +
551
1071
  Remote DID: ' + targetRemoteDid +
552
1072
  '
553
1073
 
554
- Current chat: ' + (current || '(none)') +
1074
+ Current OpenClaw chat: ' + (current || '(none)') +
555
1075
  '
556
- Previous binding: ' + previous
1076
+ Previous chat link: ' + previous
557
1077
  );
558
1078
  if (!confirmed) return;
559
1079
  await api('/api/runtime/session-bindings/bind-current', {
@@ -568,8 +1088,23 @@ Previous binding: ' + previous
568
1088
  function renderPolicy() {
569
1089
  const policy = state.policy;
570
1090
  if (!policy) return;
1091
+ const profile = state.overview && state.overview.profile ? state.overview.profile : null;
571
1092
  document.getElementById('contactDefault').value = policy.doc.contact_policy.default_action;
572
1093
  document.getElementById('taskDefault').value = policy.doc.task_policy.default_action;
1094
+ document.getElementById('profileDisplayName').value = profile && profile.display_name ? profile.display_name : '';
1095
+ document.getElementById('profileBio').value = profile && profile.bio ? profile.bio : '';
1096
+ document.getElementById('profileTags').value = profile && Array.isArray(profile.tags) ? profile.tags.join(', ') : '';
1097
+ document.getElementById('profileCapabilities').value = profile && Array.isArray(profile.capabilities) ? profile.capabilities.join(', ') : '';
1098
+ document.getElementById('capabilityCardSummary').value = profile && profile.capability_card && profile.capability_card.summary ? profile.capability_card.summary : '';
1099
+ document.getElementById('capabilityCardAcceptsNewWork').value =
1100
+ profile && profile.capability_card && typeof profile.capability_card.accepts_new_work === 'boolean'
1101
+ ? String(profile.capability_card.accepts_new_work)
1102
+ : '';
1103
+ document.getElementById('capabilityCardContactMode').value =
1104
+ profile && profile.capability_card && profile.capability_card.preferred_contact_mode
1105
+ ? profile.capability_card.preferred_contact_mode
1106
+ : '';
1107
+ document.getElementById('capabilityCardItems').value = formatCapabilityCardEditor(profile && profile.capability_card ? profile.capability_card : null);
573
1108
 
574
1109
  const rules = [];
575
1110
  policy.doc.contact_policy.rules.forEach(function (rule) {
@@ -610,7 +1145,7 @@ Previous binding: ' + previous
610
1145
  if (!list.length) return '';
611
1146
  return '<div><div class="label" style="margin-bottom:8px">' + esc(status) + '</div>' + list.map(function (item) {
612
1147
  const applyButton = status !== 'applied'
613
- ? '<button class="action-btn apply-recommendation-btn" data-recommendation-id="' + esc(item.id) + '">Apply</button>'
1148
+ ? '<button class="action-btn apply-recommendation-btn" data-recommendation-id="' + esc(item.id) + '">' + esc(recommendationActionLabel(item)) + '</button>'
614
1149
  : '';
615
1150
  const dismissButton = status === 'open'
616
1151
  ? '<button class="danger-btn dismiss-recommendation-btn" data-recommendation-id="' + esc(item.id) + '">Dismiss</button>'
@@ -672,6 +1207,45 @@ Previous binding: ' + previous
672
1207
  : '<div class="empty">No audit events yet.</div>';
673
1208
 
674
1209
  updateRuleActionOptions();
1210
+
1211
+ const saveProfileBtn = document.getElementById('saveProfileBtn');
1212
+ if (saveProfileBtn) {
1213
+ saveProfileBtn.onclick = async function () {
1214
+ let capabilityItems = [];
1215
+ const rawItems = document.getElementById('capabilityCardItems').value.trim();
1216
+ if (rawItems) {
1217
+ try {
1218
+ const parsed = JSON.parse(rawItems);
1219
+ if (!Array.isArray(parsed)) throw new Error('Capability entries JSON must be an array.');
1220
+ capabilityItems = parsed;
1221
+ } catch (err) {
1222
+ window.alert(err && err.message ? err.message : 'Invalid capability entries JSON');
1223
+ return;
1224
+ }
1225
+ }
1226
+ const acceptsNewWorkValue = document.getElementById('capabilityCardAcceptsNewWork').value;
1227
+ const capabilityCard = {
1228
+ version: '1',
1229
+ summary: document.getElementById('capabilityCardSummary').value.trim() || undefined,
1230
+ accepts_new_work: acceptsNewWorkValue === '' ? undefined : acceptsNewWorkValue === 'true',
1231
+ preferred_contact_mode: document.getElementById('capabilityCardContactMode').value || undefined,
1232
+ capabilities: capabilityItems,
1233
+ };
1234
+ await api('/api/profile', {
1235
+ method: 'POST',
1236
+ headers: { 'Content-Type': 'application/json' },
1237
+ body: JSON.stringify({
1238
+ display_name: document.getElementById('profileDisplayName').value.trim() || undefined,
1239
+ bio: document.getElementById('profileBio').value.trim() || undefined,
1240
+ tags: parseCsvList(document.getElementById('profileTags').value),
1241
+ capabilities: parseCsvList(document.getElementById('profileCapabilities').value),
1242
+ capability_card: capabilityCard,
1243
+ }),
1244
+ });
1245
+ await refreshAll();
1246
+ setTab('policy');
1247
+ };
1248
+ }
675
1249
  }
676
1250
 
677
1251
  function updateRuleActionOptions() {
@@ -683,14 +1257,15 @@ Previous binding: ' + previous
683
1257
 
684
1258
  async function loadOverview() {
685
1259
  state.overview = await api('/api/runtime/overview');
1260
+ syncSelectedSessionFromOverview();
686
1261
  renderHeader();
687
1262
  renderOverview();
688
- const sessions = state.overview && Array.isArray(state.overview.sessions) ? state.overview.sessions : [];
689
- if (!state.selectedSessionKey && sessions.length) {
690
- state.selectedSessionKey = sessions[0].session_key;
691
- }
692
1263
  if (state.selectedSessionKey) {
693
1264
  await loadSession(state.selectedSessionKey);
1265
+ } else {
1266
+ state.session = null;
1267
+ renderSession();
1268
+ syncUrlState();
694
1269
  }
695
1270
  }
696
1271
 
@@ -698,6 +1273,7 @@ Previous binding: ' + previous
698
1273
  if (!sessionKey) return;
699
1274
  state.selectedSessionKey = sessionKey;
700
1275
  state.session = await api('/api/runtime/session?session_key=' + encodeURIComponent(sessionKey));
1276
+ syncUrlState();
701
1277
  renderSession();
702
1278
  }
703
1279
 
@@ -718,6 +1294,30 @@ Previous binding: ' + previous
718
1294
 
719
1295
  document.getElementById('navRuntime').addEventListener('click', function () { setTab('runtime'); });
720
1296
  document.getElementById('navPolicy').addEventListener('click', function () { setTab('policy'); });
1297
+ document.getElementById('toggleUnreadBtn').addEventListener('click', async function () {
1298
+ state.showUnreadOnly = !state.showUnreadOnly;
1299
+ syncSelectedSessionFromOverview();
1300
+ renderOverview();
1301
+ if (state.selectedSessionKey) await loadSession(state.selectedSessionKey);
1302
+ else {
1303
+ syncUrlState();
1304
+ renderSession();
1305
+ }
1306
+ });
1307
+ document.getElementById('nextUnreadBtn').addEventListener('click', async function () {
1308
+ const unreadSessions = getOverviewSessions().filter(function (session) { return Number(session.unread_count || 0) > 0; });
1309
+ if (!unreadSessions.length) {
1310
+ window.alert('No unread sessions.');
1311
+ return;
1312
+ }
1313
+ const currentIndex = unreadSessions.findIndex(function (session) { return session.session_key === state.selectedSessionKey; });
1314
+ const next = unreadSessions[(currentIndex + 1 + unreadSessions.length) % unreadSessions.length];
1315
+ state.selectedSessionKey = next.session_key;
1316
+ renderOverview();
1317
+ await loadSession(state.selectedSessionKey);
1318
+ });
1319
+ document.getElementById('detailModeBasicBtn').addEventListener('click', function () { setDetailMode('basic'); });
1320
+ document.getElementById('detailModeAdvancedBtn').addEventListener('click', function () { setDetailMode('advanced'); });
721
1321
  document.getElementById('rulePolicy').addEventListener('change', updateRuleActionOptions);
722
1322
  document.getElementById('saveDefaultsBtn').addEventListener('click', async function () {
723
1323
  await api('/api/runtime/policy/defaults', {
@@ -758,11 +1358,54 @@ Previous binding: ' + previous
758
1358
  document.getElementById('simulateOutput').textContent = JSON.stringify(result, null, 2);
759
1359
  setTab('policy');
760
1360
  });
1361
+ document.getElementById('fixHooksBtn').addEventListener('click', async function () {
1362
+ const confirmed = window.confirm('Repair OpenClaw hooks config now? A timestamped backup of openclaw.json will be written first.');
1363
+ if (!confirmed) return;
1364
+ const result = await api('/api/runtime/openclaw/fix-hooks', { method: 'POST' });
1365
+ window.alert((result.ok ? 'Hooks repaired.
1366
+
1367
+ ' : 'Hooks repair reported an error.
1368
+
1369
+ ') + (result.stdout || result.stderr || 'No output'));
1370
+ await refreshAll();
1371
+ });
1372
+ document.getElementById('publicLinkBtn').addEventListener('click', async function () {
1373
+ const suggested = state.overview && state.overview.publicSelf && state.overview.publicSelf.public_slug
1374
+ ? state.overview.publicSelf.public_slug
1375
+ : '';
1376
+ const slug = window.prompt('Public share slug (leave empty to use the recommended value):', suggested);
1377
+ if (slug == null) return;
1378
+ const result = await api('/api/public/link', {
1379
+ method: 'POST',
1380
+ headers: { 'Content-Type': 'application/json' },
1381
+ body: JSON.stringify({ slug: slug.trim() || undefined, enabled: true }),
1382
+ });
1383
+ window.alert('Public link ready:
1384
+ ' + (result.public_url || '(missing public_url)'));
1385
+ await refreshAll();
1386
+ });
1387
+ document.getElementById('contactCardBtn').addEventListener('click', async function () {
1388
+ const intro = window.prompt('Optional intro note for this contact card:', '');
1389
+ if (intro == null) return;
1390
+ const result = await api('/api/public/contact-card', {
1391
+ method: 'POST',
1392
+ headers: { 'Content-Type': 'application/json' },
1393
+ body: JSON.stringify({
1394
+ target_did: state.overview && state.overview.did ? state.overview.did : undefined,
1395
+ intro_note: intro.trim() || undefined,
1396
+ }),
1397
+ });
1398
+ window.alert('Contact card ready:
1399
+ ' + (result.share_url || '(missing share_url)'));
1400
+ });
761
1401
 
762
1402
  async function init() {
1403
+ setTab(state.currentTab);
1404
+ setDetailMode(state.detailMode);
763
1405
  await loadProfiles();
764
1406
  updateRuleActionOptions();
765
1407
  await refreshAll();
1408
+ syncUrlState();
766
1409
  }
767
1410
 
768
1411
  init().catch(function (error) {
@@ -781,6 +1424,29 @@ function resolvePath(p) {
781
1424
  if (!p || !p.startsWith("~")) return p;
782
1425
  return path.join(process.env.HOME || process.env.USERPROFILE || "", p.slice(1));
783
1426
  }
1427
+ function findOpenClawInstallScript() {
1428
+ const explicit = process.env.PINGAGENT_OPENCLAW_INSTALL_BIN?.trim();
1429
+ if (explicit) return { kind: "script", cmd: process.execPath, args: [path.resolve(explicit)] };
1430
+ const repoScript = path.resolve(process.cwd(), "packages", "openclaw-install", "install.mjs");
1431
+ if (fs.existsSync(repoScript)) return { kind: "script", cmd: process.execPath, args: [repoScript] };
1432
+ return null;
1433
+ }
1434
+ function runOpenClawInstall(args) {
1435
+ const resolved = findOpenClawInstallScript();
1436
+ if (!resolved) {
1437
+ return { ok: false, stdout: "", stderr: "OpenClaw installer script not found locally. Set PINGAGENT_OPENCLAW_INSTALL_BIN." };
1438
+ }
1439
+ const result = spawnSync(resolved.cmd, [...resolved.args, ...args], {
1440
+ encoding: "utf-8",
1441
+ env: process.env
1442
+ });
1443
+ return {
1444
+ ok: result.status === 0,
1445
+ stdout: String(result.stdout ?? ""),
1446
+ stderr: String(result.stderr ?? ""),
1447
+ status: result.status ?? 1
1448
+ };
1449
+ }
784
1450
  function listProfiles(rootDir) {
785
1451
  const root = resolvePath(rootDir);
786
1452
  const profiles = [];
@@ -839,6 +1505,30 @@ function listProfiles(rootDir) {
839
1505
  return profiles;
840
1506
  }
841
1507
  var DEFAULT_SERVER_URL = "https://pingagent.chat";
1508
+ var OFFICIAL_HOSTED_ORIGIN = new URL(DEFAULT_SERVER_URL).origin;
1509
+ var autoPublicLinkAttempts = /* @__PURE__ */ new Set();
1510
+ function normalizeOrigin(input) {
1511
+ try {
1512
+ return new URL(String(input ?? "")).origin;
1513
+ } catch {
1514
+ return null;
1515
+ }
1516
+ }
1517
+ function isOfficialHostedServer(serverUrl) {
1518
+ return normalizeOrigin(serverUrl) === OFFICIAL_HOSTED_ORIGIN;
1519
+ }
1520
+ async function maybeEnsureHostedPublicLink(ctx) {
1521
+ if (!isOfficialHostedServer(ctx.serverUrl)) return;
1522
+ const key = `${ctx.identityPath}:${normalizeOrigin(ctx.serverUrl)}`;
1523
+ if (autoPublicLinkAttempts.has(key)) return;
1524
+ autoPublicLinkAttempts.add(key);
1525
+ try {
1526
+ const current = await ctx.client.getPublicSelf().catch(() => ({ ok: false }));
1527
+ if (current.ok && current.data?.public_slug) return;
1528
+ await ctx.client.createPublicLink({ enabled: true }).catch(() => ({ ok: false }));
1529
+ } catch {
1530
+ }
1531
+ }
842
1532
  async function getContextForProfile(profile, defaultServerUrl) {
843
1533
  const identity = loadIdentity(profile.identityPath);
844
1534
  const serverUrl = identity.serverUrl ?? defaultServerUrl ?? DEFAULT_SERVER_URL;
@@ -1044,11 +1734,14 @@ function describeHostedTier(tier) {
1044
1734
  }
1045
1735
  async function buildRuntimeOverviewPayload(ctx) {
1046
1736
  const client = ctx.client;
1737
+ await maybeEnsureHostedPublicLink(ctx);
1047
1738
  await client.listConversations({ type: "dm" });
1048
1739
  const sessionManager = client.getSessionManager();
1740
+ const sessionSummaryManager = client.getSessionSummaryManager();
1049
1741
  const taskManager = client.getTaskThreadManager();
1742
+ const taskHandoffManager = client.getTaskHandoffManager();
1050
1743
  const historyManager = client.getHistoryManager();
1051
- if (!sessionManager || !taskManager || !historyManager) {
1744
+ if (!sessionManager || !sessionSummaryManager || !taskManager || !taskHandoffManager || !historyManager) {
1052
1745
  throw new Error("Runtime overview requires a writable local store");
1053
1746
  }
1054
1747
  const sessions = sessionManager.listRecentSessions(24);
@@ -1071,6 +1764,10 @@ async function buildRuntimeOverviewPayload(ctx) {
1071
1764
  const runtimeMode = getRuntimeMode();
1072
1765
  const subRes = await client.getSubscription().catch(() => ({ ok: false }));
1073
1766
  const subscription = subRes.ok && subRes.data ? subRes.data : null;
1767
+ const publicSelfRes = await client.getPublicSelf().catch(() => ({ ok: false }));
1768
+ const publicSelf = publicSelfRes.ok && publicSelfRes.data ? publicSelfRes.data : null;
1769
+ const profileRes = await client.getProfile().catch(() => ({ ok: false }));
1770
+ const profile = profileRes.ok && profileRes.data ? profileRes.data : null;
1074
1771
  const recommendationState = syncTrustRecommendations(ctx.storePath, {
1075
1772
  policyDoc: policy,
1076
1773
  sessions,
@@ -1082,6 +1779,7 @@ async function buildRuntimeOverviewPayload(ctx) {
1082
1779
  const unreadTotal = sessions.reduce((sum, session) => sum + session.unread_count, 0);
1083
1780
  const sessionBindings = readSessionBindings();
1084
1781
  const sessionBindingAlerts = readSessionBindingAlerts();
1782
+ const ingressRuntime = readIngressRuntimeStatus();
1085
1783
  const activeWorkSession = readCurrentActiveSessionKey();
1086
1784
  const bindingByConversation = new Map(sessionBindings.map((row) => [row.conversation_id, row.session_key]));
1087
1785
  const bindingAlertByConversation = new Map(sessionBindingAlerts.map((row) => [row.conversation_id, row]));
@@ -1096,6 +1794,7 @@ async function buildRuntimeOverviewPayload(ctx) {
1096
1794
  trustPolicyPath: getTrustPolicyPath(ctx.identityPath),
1097
1795
  activeWorkSessionFile: getActiveSessionFilePath(),
1098
1796
  activeWorkSession,
1797
+ ingressRuntime,
1099
1798
  sessionMapPath: getSessionMapFilePath(),
1100
1799
  sessionBindingAlertsPath: getSessionBindingAlertsFilePath(),
1101
1800
  sessionBindings,
@@ -1111,6 +1810,8 @@ async function buildRuntimeOverviewPayload(ctx) {
1111
1810
  retention_label: formatRetentionLabel(subscription.limits.store_forward_ttl_ms),
1112
1811
  audit_export_allowed: !!subscription.limits.audit_export_allowed
1113
1812
  } : null,
1813
+ publicSelf,
1814
+ profile,
1114
1815
  policyDefaults: {
1115
1816
  contact: policy.contact_policy.enabled ? policy.contact_policy.default_action : "disabled",
1116
1817
  task: policy.task_policy.enabled ? policy.task_policy.default_action : "disabled"
@@ -1122,22 +1823,31 @@ async function buildRuntimeOverviewPayload(ctx) {
1122
1823
  recommendationSummary: recommendationState.summary,
1123
1824
  sessions: sessions.map((session) => ({
1124
1825
  ...session,
1826
+ session_summary: sessionSummaryManager.get(session.session_key),
1125
1827
  mapped_work_session: session.conversation_id ? bindingByConversation.get(session.conversation_id) ?? null : null,
1126
1828
  binding_alert: session.conversation_id ? bindingAlertByConversation.get(session.conversation_id) ?? null : null,
1127
1829
  is_active_work_session: session.session_key === activeWorkSession,
1128
1830
  latest_messages: session.conversation_id ? historyManager.listRecent(session.conversation_id, 3) : []
1129
1831
  })),
1130
- tasks: refreshedTasks,
1832
+ tasks: refreshedTasks.map((task) => ({
1833
+ ...task,
1834
+ handoff: taskHandoffManager.get(task.task_id)
1835
+ })),
1131
1836
  auditSummary,
1132
- recommendations: recommendationState.recommendations
1837
+ recommendations: recommendationState.recommendations.map((item) => ({
1838
+ ...item,
1839
+ primary_action_label: getTrustRecommendationActionLabel(item)
1840
+ }))
1133
1841
  };
1134
1842
  }
1135
1843
  async function buildSessionOverviewPayload(ctx, sessionKey) {
1136
1844
  const client = ctx.client;
1137
1845
  const sessionManager = client.getSessionManager();
1846
+ const sessionSummaryManager = client.getSessionSummaryManager();
1138
1847
  const taskManager = client.getTaskThreadManager();
1848
+ const taskHandoffManager = client.getTaskHandoffManager();
1139
1849
  const historyManager = client.getHistoryManager();
1140
- if (!sessionManager || !taskManager || !historyManager) {
1850
+ if (!sessionManager || !sessionSummaryManager || !taskManager || !taskHandoffManager || !historyManager) {
1141
1851
  throw new Error("Session overview requires a writable local store");
1142
1852
  }
1143
1853
  const session = sessionKey ? sessionManager.get(sessionKey) : sessionManager.getActiveSession() ?? sessionManager.listRecentSessions(1)[0] ?? null;
@@ -1173,6 +1883,8 @@ async function buildSessionOverviewPayload(ctx, sessionKey) {
1173
1883
  });
1174
1884
  return {
1175
1885
  session,
1886
+ sessionSummary: sessionSummaryManager.get(session.session_key),
1887
+ ingressRuntime: readIngressRuntimeStatus(),
1176
1888
  binding,
1177
1889
  bindingAlert,
1178
1890
  activeWorkSession,
@@ -1180,12 +1892,30 @@ async function buildSessionOverviewPayload(ctx, sessionKey) {
1180
1892
  sessionMapPath: getSessionMapFilePath(),
1181
1893
  sessionBindingAlertsPath: getSessionBindingAlertsFilePath(),
1182
1894
  policyExplain: buildPolicyDecisionShape(ctx.identityPath, session.remote_did, { runtimeMode: getRuntimeMode() }),
1183
- tasks,
1895
+ tasks: tasks.map((task) => ({
1896
+ ...task,
1897
+ handoff: taskHandoffManager.get(task.task_id)
1898
+ })),
1184
1899
  messages,
1185
1900
  auditEvents,
1186
- recommendations: recommendationState.recommendations.filter((item) => item.remote_did === session.remote_did)
1901
+ recommendations: recommendationState.recommendations.filter((item) => item.remote_did === session.remote_did).map((item) => ({
1902
+ ...item,
1903
+ primary_action_label: getTrustRecommendationActionLabel(item)
1904
+ }))
1187
1905
  };
1188
1906
  }
1907
+ function resolveSessionForInput(sessionManager, input) {
1908
+ if (!sessionManager) return null;
1909
+ const sessionKey = String(input.session_key ?? "").trim();
1910
+ const conversationId = String(input.conversation_id ?? "").trim();
1911
+ const remoteDid = String(input.remote_did ?? "").trim();
1912
+ let session = sessionKey ? sessionManager.get(sessionKey) : null;
1913
+ if (!session && conversationId) session = sessionManager.getByConversationId(conversationId);
1914
+ if (!session && remoteDid) {
1915
+ session = sessionManager.listRecentSessions(100).find((item) => item.remote_did === remoteDid) ?? null;
1916
+ }
1917
+ return session ?? sessionManager.getActiveSession() ?? sessionManager.listRecentSessions(1)[0] ?? null;
1918
+ }
1189
1919
  async function handleApi(pathname, req, ctx) {
1190
1920
  const client = ctx.client;
1191
1921
  const contactManager = ctx.contactManager;
@@ -1196,10 +1926,139 @@ async function handleApi(pathname, req, ctx) {
1196
1926
  return { did: myDid, serverUrl };
1197
1927
  }
1198
1928
  if (parts[0] === "runtime") {
1929
+ if (parts[1] === "session-summary") {
1930
+ const sessionManager = client.getSessionManager();
1931
+ const sessionSummaryManager = client.getSessionSummaryManager();
1932
+ if (!sessionManager || !sessionSummaryManager) throw new Error("Session summary requires a writable local store");
1933
+ const url = new URL(req.url || "", "http://x");
1934
+ const body = req.method === "POST" ? await readBody(req) : null;
1935
+ const sessionKey = String(
1936
+ body?.session_key ?? url.searchParams.get("session_key") ?? ""
1937
+ ).trim();
1938
+ const conversationId = String(
1939
+ body?.conversation_id ?? url.searchParams.get("conversation_id") ?? ""
1940
+ ).trim();
1941
+ const remoteDid = String(
1942
+ body?.remote_did ?? url.searchParams.get("remote_did") ?? ""
1943
+ ).trim();
1944
+ const session = sessionKey ? sessionManager.get(sessionKey) : conversationId ? sessionManager.getByConversationId(conversationId) : remoteDid ? sessionManager.listRecentSessions(100).find((item) => item.remote_did === remoteDid) ?? null : sessionManager.getActiveSession() ?? sessionManager.listRecentSessions(1)[0] ?? null;
1945
+ if (!session) throw new Error("No session available");
1946
+ if (req.method === "GET") {
1947
+ return { session, summary: sessionSummaryManager.get(session.session_key) };
1948
+ }
1949
+ const summary = sessionSummaryManager.upsert({
1950
+ session_key: session.session_key,
1951
+ objective: typeof body?.objective === "string" ? body.objective : void 0,
1952
+ context: typeof body?.context === "string" ? body.context : void 0,
1953
+ constraints: typeof body?.constraints === "string" ? body.constraints : void 0,
1954
+ decisions: typeof body?.decisions === "string" ? body.decisions : void 0,
1955
+ open_questions: typeof body?.open_questions === "string" ? body.open_questions : void 0,
1956
+ next_action: typeof body?.next_action === "string" ? body.next_action : void 0,
1957
+ handoff_ready_text: typeof body?.handoff_ready_text === "string" ? body.handoff_ready_text : void 0
1958
+ });
1959
+ return { ok: true, session, summary };
1960
+ }
1199
1961
  if (!parts[1] || parts[1] === "overview") {
1200
1962
  return buildRuntimeOverviewPayload(ctx);
1201
1963
  }
1964
+ if (parts[1] === "receive-mode" && req.method === "GET") {
1965
+ return {
1966
+ runtimeStatusPath: path.resolve(process.env.IM_INGRESS_RUNTIME_STATUS_FILE || ""),
1967
+ status: readIngressRuntimeStatus()
1968
+ };
1969
+ }
1970
+ if (parts[1] === "demo" && req.method === "POST") {
1971
+ const body = await readBody(req);
1972
+ const preset = typeof body?.preset === "string" ? body.preset.trim().toLowerCase() : "";
1973
+ const presetMessages = {
1974
+ hello: "Hello",
1975
+ delegate: "Please show me how task delegation works in PingAgent.",
1976
+ trust: "Show me how trust decisions and recommendations work."
1977
+ };
1978
+ const resolved = await client.resolveAlias("pingagent/demo");
1979
+ if (!resolved.ok || !resolved.data?.did) {
1980
+ throw new Error(resolved.error?.message ?? "Failed to resolve demo agent");
1981
+ }
1982
+ const convo = await client.openConversation(resolved.data.did);
1983
+ if (!convo.ok || !convo.data?.conversation_id) {
1984
+ throw new Error(convo.error?.message ?? "Failed to open demo conversation");
1985
+ }
1986
+ const message = typeof body?.message === "string" && body.message.trim() ? body.message.trim() : presetMessages[preset] ?? "";
1987
+ if (!message) {
1988
+ return {
1989
+ ok: true,
1990
+ did: resolved.data.did,
1991
+ conversation_id: convo.data.conversation_id,
1992
+ preset: preset || null,
1993
+ sent: false
1994
+ };
1995
+ }
1996
+ const sendRes = await client.sendMessage(convo.data.conversation_id, SCHEMA_TEXT, { text: message });
1997
+ if (!sendRes.ok) {
1998
+ throw new Error(sendRes.error?.message ?? "Failed to send demo message");
1999
+ }
2000
+ return {
2001
+ ok: true,
2002
+ did: resolved.data.did,
2003
+ conversation_id: convo.data.conversation_id,
2004
+ preset: preset || null,
2005
+ sent: true,
2006
+ message_id: sendRes.data?.message_id ?? null
2007
+ };
2008
+ }
2009
+ if (parts[1] === "openclaw" && parts[2] === "fix-hooks" && req.method === "POST") {
2010
+ const result = runOpenClawInstall(["fix-hooks"]);
2011
+ return {
2012
+ ok: result.ok,
2013
+ stdout: result.stdout,
2014
+ stderr: result.stderr,
2015
+ status: result.status,
2016
+ receiveMode: readIngressRuntimeStatus()
2017
+ };
2018
+ }
1202
2019
  if (parts[1] === "session") {
2020
+ const sessionManager = ctx.client.getSessionManager();
2021
+ if (!sessionManager) throw new Error("Session actions require a writable local store");
2022
+ if (parts[2] === "reply" && req.method === "POST") {
2023
+ const body = await readBody(req);
2024
+ const session = resolveSessionForInput(sessionManager, body);
2025
+ if (!session?.conversation_id) throw new Error("No session selected");
2026
+ const text = String(body?.message ?? "").trim();
2027
+ if (!text) throw new Error("Missing message");
2028
+ const sendRes = await client.sendMessage(session.conversation_id, SCHEMA_TEXT, { text });
2029
+ if (!sendRes.ok) throw new Error(sendRes.error?.message ?? "Failed to send");
2030
+ sessionManager.focusSession(session.session_key);
2031
+ return {
2032
+ ok: true,
2033
+ session: sessionManager.get(session.session_key),
2034
+ message_id: sendRes.data?.message_id ?? null
2035
+ };
2036
+ }
2037
+ if (parts[2] === "approve" && req.method === "POST") {
2038
+ const body = await readBody(req);
2039
+ const session = resolveSessionForInput(sessionManager, body);
2040
+ if (!session?.conversation_id) throw new Error("No session selected");
2041
+ const approveRes = await client.approveContact(session.conversation_id);
2042
+ if (!approveRes.ok) throw new Error(approveRes.error?.message ?? "Failed to approve contact");
2043
+ sessionManager.focusSession(session.session_key);
2044
+ return {
2045
+ ok: true,
2046
+ session: sessionManager.get(session.session_key),
2047
+ trusted: approveRes.data?.trusted ?? true,
2048
+ dm_conversation_id: approveRes.data?.dm_conversation_id ?? session.conversation_id
2049
+ };
2050
+ }
2051
+ if (parts[2] === "mark-read" && req.method === "POST") {
2052
+ const body = await readBody(req);
2053
+ const session = resolveSessionForInput(sessionManager, body);
2054
+ if (!session?.session_key) throw new Error("No session selected");
2055
+ const updated = sessionManager.markRead(session.session_key);
2056
+ if (!updated) throw new Error("Failed to mark session as read");
2057
+ return {
2058
+ ok: true,
2059
+ session: updated
2060
+ };
2061
+ }
1203
2062
  const url = new URL(req.url || "", "http://x");
1204
2063
  const sessionKey = url.searchParams.get("session_key");
1205
2064
  return buildSessionOverviewPayload(ctx, sessionKey);
@@ -1290,7 +2149,10 @@ async function handleApi(pathname, req, ctx) {
1290
2149
  auditSummary: summarizeTrustPolicyAudit(auditEvents),
1291
2150
  auditEvents,
1292
2151
  recommendationSummary: recommendationState.summary,
1293
- recommendations: recommendationState.recommendations
2152
+ recommendations: recommendationState.recommendations.map((item) => ({
2153
+ ...item,
2154
+ primary_action_label: getTrustRecommendationActionLabel(item)
2155
+ }))
1294
2156
  };
1295
2157
  } finally {
1296
2158
  auditStore.close();
@@ -1488,6 +2350,141 @@ async function handleApi(pathname, req, ctx) {
1488
2350
  }
1489
2351
  };
1490
2352
  }
2353
+ if (parts[2] === "recommendations" && parts[3] === "apply-session" && req.method === "POST") {
2354
+ const body = await readBody(req);
2355
+ const sessionKey = typeof body?.session_key === "string" ? body.session_key : ctx.client.getSessionManager()?.getActiveSession()?.session_key ?? "";
2356
+ if (!sessionKey) throw new Error("session_key is required");
2357
+ const session = ctx.client.getSessionManager()?.get(sessionKey);
2358
+ if (!session?.remote_did) throw new Error("No active session recommendation target");
2359
+ const doc = readTrustPolicyDoc(ctx.identityPath);
2360
+ const sharedStore = new LocalStore(ctx.storePath);
2361
+ try {
2362
+ const auditManager = new TrustPolicyAuditManager(sharedStore);
2363
+ const recommendationManager = new TrustRecommendationManager(sharedStore);
2364
+ const auditEvents = auditManager.listRecent(200);
2365
+ recommendationManager.sync({
2366
+ policyDoc: doc,
2367
+ sessions: sessionManager.listRecentSessions(100),
2368
+ tasks: taskManager.listRecent(100),
2369
+ auditEvents,
2370
+ runtimeMode,
2371
+ limit: 50
2372
+ });
2373
+ const recommendation = recommendationManager.list({ remoteDid: session.remote_did, status: "open", limit: 1 })[0];
2374
+ if (!recommendation) throw new Error("No open recommendation for this session");
2375
+ const nextDoc = upsertTrustPolicyRecommendation(doc, recommendation);
2376
+ const savedPath = writeTrustPolicyDoc(ctx.identityPath, nextDoc);
2377
+ const stored = recommendationManager.apply(recommendation.id) ?? recommendation;
2378
+ auditManager.record({
2379
+ event_type: "recommendation_applied",
2380
+ policy_scope: recommendation.policy,
2381
+ remote_did: recommendation.remote_did,
2382
+ action: String(recommendation.action),
2383
+ outcome: "recommendation_applied",
2384
+ explanation: recommendation.reason,
2385
+ matched_rule: recommendation.match,
2386
+ detail: { recommendation_id: recommendation.id, session_key: sessionKey }
2387
+ });
2388
+ return { ok: true, path: savedPath, recommendation: stored, doc: nextDoc };
2389
+ } finally {
2390
+ sharedStore.close();
2391
+ }
2392
+ }
2393
+ if (parts[2] === "recommendations" && parts[3] === "dismiss-session" && req.method === "POST") {
2394
+ const body = await readBody(req);
2395
+ const sessionKey = typeof body?.session_key === "string" ? body.session_key : ctx.client.getSessionManager()?.getActiveSession()?.session_key ?? "";
2396
+ if (!sessionKey) throw new Error("session_key is required");
2397
+ const session = ctx.client.getSessionManager()?.get(sessionKey);
2398
+ if (!session?.remote_did) throw new Error("No active session recommendation target");
2399
+ const sharedStore = new LocalStore(ctx.storePath);
2400
+ try {
2401
+ const recommendationManager = new TrustRecommendationManager(sharedStore);
2402
+ const recommendation = recommendationManager.list({ remoteDid: session.remote_did, status: "open", limit: 1 })[0];
2403
+ if (!recommendation) throw new Error("No open recommendation for this session");
2404
+ const stored = recommendationManager.dismiss(recommendation.id) ?? recommendation;
2405
+ new TrustPolicyAuditManager(sharedStore).record({
2406
+ event_type: "recommendation_dismissed",
2407
+ policy_scope: recommendation.policy,
2408
+ remote_did: recommendation.remote_did,
2409
+ action: String(recommendation.action),
2410
+ outcome: "recommendation_dismissed",
2411
+ explanation: recommendation.reason,
2412
+ matched_rule: recommendation.match,
2413
+ detail: { recommendation_id: recommendation.id, session_key: sessionKey }
2414
+ });
2415
+ return { ok: true, recommendation: stored };
2416
+ } finally {
2417
+ sharedStore.close();
2418
+ }
2419
+ }
2420
+ if (parts[2] === "recommendations" && parts[3] === "reopen-session" && req.method === "POST") {
2421
+ const body = await readBody(req);
2422
+ const sessionKey = typeof body?.session_key === "string" ? body.session_key : ctx.client.getSessionManager()?.getActiveSession()?.session_key ?? "";
2423
+ if (!sessionKey) throw new Error("session_key is required");
2424
+ const session = ctx.client.getSessionManager()?.get(sessionKey);
2425
+ if (!session?.remote_did) throw new Error("No active session recommendation target");
2426
+ const sharedStore = new LocalStore(ctx.storePath);
2427
+ try {
2428
+ const recommendationManager = new TrustRecommendationManager(sharedStore);
2429
+ const recommendation = recommendationManager.list({ remoteDid: session.remote_did, status: ["dismissed", "superseded"], limit: 1 })[0];
2430
+ if (!recommendation) throw new Error("No dismissed or superseded recommendation for this session");
2431
+ const stored = recommendationManager.reopen(recommendation.id) ?? recommendation;
2432
+ new TrustPolicyAuditManager(sharedStore).record({
2433
+ event_type: "recommendation_reopened",
2434
+ policy_scope: recommendation.policy,
2435
+ remote_did: recommendation.remote_did,
2436
+ action: String(recommendation.action),
2437
+ outcome: "recommendation_reopened",
2438
+ explanation: recommendation.reason,
2439
+ matched_rule: recommendation.match,
2440
+ detail: { recommendation_id: recommendation.id, session_key: sessionKey }
2441
+ });
2442
+ return { ok: true, recommendation: stored };
2443
+ } finally {
2444
+ sharedStore.close();
2445
+ }
2446
+ }
2447
+ }
2448
+ }
2449
+ if (parts[0] === "public") {
2450
+ if ((!parts[1] || parts[1] === "self") && req.method === "GET") {
2451
+ await maybeEnsureHostedPublicLink(ctx);
2452
+ const res = await client.getPublicSelf();
2453
+ if (!res.ok) throw new Error(res.error?.message ?? "Failed to get public link state");
2454
+ return res.data;
2455
+ }
2456
+ if (parts[1] === "link" && req.method === "POST") {
2457
+ const body = await readBody(req);
2458
+ const res = await client.createPublicLink({
2459
+ slug: typeof body?.slug === "string" ? body.slug : void 0,
2460
+ enabled: typeof body?.enabled === "boolean" ? body.enabled : void 0
2461
+ });
2462
+ if (!res.ok) throw new Error(res.error?.message ?? "Failed to create public link");
2463
+ return res.data;
2464
+ }
2465
+ if (parts[1] === "contact-card" && req.method === "POST") {
2466
+ const body = await readBody(req);
2467
+ const res = await client.createContactCard({
2468
+ target_did: typeof body?.target_did === "string" ? body.target_did : void 0,
2469
+ referrer_did: typeof body?.referrer_did === "string" ? body.referrer_did : void 0,
2470
+ intro_note: typeof body?.intro_note === "string" ? body.intro_note : void 0,
2471
+ message_template: typeof body?.message_template === "string" ? body.message_template : void 0
2472
+ });
2473
+ if (!res.ok) throw new Error(res.error?.message ?? "Failed to create contact card");
2474
+ return res.data;
2475
+ }
2476
+ if (parts[1] === "task-share" && req.method === "POST") {
2477
+ const body = await readBody(req);
2478
+ const res = await client.createTaskShare({
2479
+ task_id: typeof body?.task_id === "string" ? body.task_id : void 0,
2480
+ title: typeof body?.title === "string" ? body.title : void 0,
2481
+ status: typeof body?.status === "string" ? body.status : void 0,
2482
+ summary: String(body?.summary ?? "").trim(),
2483
+ conversation_id: typeof body?.conversation_id === "string" ? body.conversation_id : void 0,
2484
+ redacted_metadata: body?.redacted_metadata && typeof body.redacted_metadata === "object" ? body.redacted_metadata : void 0
2485
+ });
2486
+ if (!res.ok) throw new Error(res.error?.message ?? "Failed to create task share");
2487
+ return res.data;
1491
2488
  }
1492
2489
  }
1493
2490
  if (parts[0] === "profile") {
@@ -1499,11 +2496,12 @@ async function handleApi(pathname, req, ctx) {
1499
2496
  if (req.method === "POST") {
1500
2497
  const body = await readBody(req);
1501
2498
  const profile = {};
1502
- if (body?.display_name != null) profile.display_name = body.display_name;
1503
- if (body?.bio != null) profile.bio = body.bio;
1504
- if (Array.isArray(body?.capabilities)) profile.capabilities = body.capabilities;
2499
+ if (typeof body?.display_name === "string") profile.display_name = body.display_name;
2500
+ if (typeof body?.bio === "string") profile.bio = body.bio;
2501
+ if (Array.isArray(body?.capabilities)) profile.capabilities = body.capabilities.map((value) => String(value ?? "").trim()).filter(Boolean);
1505
2502
  else if (typeof body?.capabilities === "string") profile.capabilities = body.capabilities.split(",").map((s) => s.trim()).filter(Boolean);
1506
- if (Array.isArray(body?.tags)) profile.tags = body.tags;
2503
+ if (body?.capability_card && typeof body.capability_card === "object") profile.capability_card = body.capability_card;
2504
+ if (Array.isArray(body?.tags)) profile.tags = body.tags.map((value) => String(value ?? "").trim()).filter(Boolean);
1507
2505
  else if (typeof body?.tags === "string") profile.tags = body.tags.split(",").map((s) => s.trim()).filter(Boolean);
1508
2506
  if (typeof body?.discoverable === "boolean") profile.discoverable = body.discoverable;
1509
2507
  const res = await client.updateProfile(profile);