The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 81afd7789b27Commit detailsView latest

apps/dashboard/src/main.tsx 3213 lines · 112.2 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import {
4
  applyPreviewRequest,
5
  createPreviewData,
6
  defaultPreviewSettings,
7
  disablePreview,
8
  enablePreview,
9
  isPreviewActive,
10
  previewSession,
11
} from './preview.js';
12
import './styles.css';
13
14
declare const __AUTH0_DOMAIN__: string;
15
declare const __AUTH0_CLIENT_ID__: string;
16
declare const __AUTH0_AUDIENCE__: string;
17
18
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
19
type AuthSession = { user: Auth0User };
20
type AuthTransaction = { state: string; nonce: string; verifier: string };
21
type Theme = 'dark' | 'light';
22
23
const authTransactionKey = 'openclasp.auth0.transaction';
24
const themeKey = 'openclasp.theme.v1';
25
26
type DashboardData = {
27
  agents: Record<string, any>[];
28
  projects: Record<string, any>[];
29
  installations: Record<string, any>[];
30
  setupRequests: Record<string, any>[];
31
  publications: Record<string, any>[];
32
  interactions: Record<string, any>[];
33
  federatedInteractions: Record<string, any>[];
34
  liveSessions: Record<string, any>[];
35
  hostedThreads: Record<string, any>[];
36
  events: Record<string, any>[];
37
  conflicts: Record<string, any>[];
38
  receipts: Record<string, any>[];
39
  profiles: Record<string, any>[];
40
  counterpartyBriefs: Record<string, any>[];
41
  completionReports: Record<string, any>[];
42
  feedbackRequests: Record<string, any>[];
43
  interactionFeedback: Record<string, any>[];
44
  interactionConclusions: Record<string, any>[];
45
  learningEligibility: Record<string, any>[];
46
  profileDeltas: Record<string, any>[];
47
  runtimes: Record<string, any>[];
48
  accessTokens: Record<string, any>[];
49
};
50
51
type Settings = {
52
  displayName: string;
53
  contributionEnabled: boolean;
54
  retentionDays: number;
55
  evidenceSharing: 'never' | 'ask' | 'contract_only';
56
  rawConversationsStored: false;
57
};
58
59
const emptyData: DashboardData = {
60
  agents: [],
61
  projects: [],
62
  installations: [],
63
  setupRequests: [],
64
  publications: [],
65
  interactions: [],
66
  federatedInteractions: [],
67
  liveSessions: [],
68
  hostedThreads: [],
69
  events: [],
70
  conflicts: [],
71
  receipts: [],
72
  profiles: [],
73
  counterpartyBriefs: [],
74
  completionReports: [],
75
  feedbackRequests: [],
76
  interactionFeedback: [],
77
  interactionConclusions: [],
78
  learningEligibility: [],
79
  profileDeltas: [],
80
  runtimes: [],
81
  accessTokens: [],
82
};
83
const defaultSettings: Settings = {
84
  displayName: '',
85
  contributionEnabled: false,
86
  retentionDays: 30,
87
  evidenceSharing: 'ask',
88
  rawConversationsStored: false,
89
};
90
91
function normalizeDashboard(value: unknown): DashboardData {
92
  return { ...emptyData, ...(value as Partial<DashboardData>) };
93
}
94
const pages = [
95
  'dashboard',
96
  'conversations',
97
  'history',
98
  'agents',
99
  'insights',
100
  'connect',
101
  'settings',
102
] as const;
103
type Page = (typeof pages)[number];
104
const pageMeta: Record<Page, { label: string; title: string; lede: string; eyebrow: string }> = {
105
  dashboard: {
106
    label: 'Overview',
107
    title: 'Overview',
108
    lede: 'Agent readiness, signed outcomes, and anything waiting on you.',
109
    eyebrow: 'Network',
110
  },
111
  history: {
112
    label: 'History',
113
    title: 'History',
114
    lede: 'Structured events, receipts, and shared contracts — never raw messages.',
115
    eyebrow: 'Audit',
116
  },
117
  conversations: {
118
    label: 'Conversations',
119
    title: 'Temporary conversations',
120
    lede: 'Hosted history for temporary chat identities. Direct runtime messages never appear here.',
121
    eyebrow: 'Inbox',
122
  },
123
  agents: {
124
    label: 'Agents',
125
    title: 'Agents',
126
    lede: 'Identities, runtimes, and the automation each agent is allowed to do.',
127
    eyebrow: 'Registry',
128
  },
129
  insights: {
130
    label: 'Insights',
131
    title: 'Insights',
132
    lede: 'Task-specific reliability from signed outcomes. No universal score.',
133
    eyebrow: 'Context',
134
  },
135
  connect: {
136
    label: 'Connect',
137
    title: 'Connect',
138
    lede: 'Add an agent once. Approve its identity, then OpenClasp handles the routine work.',
139
    eyebrow: 'Setup',
140
  },
141
  settings: {
142
    label: 'Settings',
143
    title: 'Settings',
144
    lede: 'Privacy, retention, and what this account may contribute to the network.',
145
    eyebrow: 'Account',
146
  },
147
};
148
149
function base64Url(bytes: Uint8Array) {
150
  let binary = '';
151
  for (const byte of bytes) binary += String.fromCharCode(byte);
152
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
153
}
154
155
function randomValue(size = 32) {
156
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
157
}
158
159
async function beginAuth(provider: 'google' | 'github') {
160
  const verifier = randomValue(48);
161
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
162
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
163
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
164
  const parameters = new URLSearchParams({
165
    client_id: __AUTH0_CLIENT_ID__,
166
    redirect_uri: `${new URL(__AUTH0_AUDIENCE__).origin}/sso-callback`,
167
    response_type: 'code',
168
    scope: 'openid profile email',
169
    audience: __AUTH0_AUDIENCE__,
170
    state: transaction.state,
171
    nonce: transaction.nonce,
172
    code_challenge: base64Url(new Uint8Array(digest)),
173
    code_challenge_method: 'S256',
174
    connection: provider === 'google' ? 'google-oauth2' : 'github',
175
  });
176
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
177
}
178
179
async function signOut(preview: boolean) {
180
  if (preview) {
181
    disablePreview();
182
    location.replace('/login');
183
    return;
184
  }
185
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
186
  const parameters = new URLSearchParams({
187
    client_id: __AUTH0_CLIENT_ID__,
188
    returnTo: `${new URL(__AUTH0_AUDIENCE__).origin}/login`,
189
  });
190
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
191
}
192
193
async function remoteApi(path: string, init?: RequestInit) {
194
  const headers = new Headers(init?.headers);
195
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
196
  return fetch(path, {
197
    ...init,
198
    credentials: 'same-origin',
199
    headers,
200
  }).then(async (response) => {
201
    if (!response.ok)
202
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
203
    return response.json();
204
  });
205
}
206
207
function route(): Page {
208
  const value = location.pathname.slice(1) || 'dashboard';
209
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
210
}
211
212
function initialTheme(): Theme {
213
  const saved = localStorage.getItem(themeKey);
214
  if (saved === 'dark' || saved === 'light') return saved;
215
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
216
}
217
218
function App() {
219
  const [session, setSession] = useState<AuthSession | null>();
220
  const [preview, setPreview] = useState(false);
221
  const [theme, setTheme] = useState<Theme>(initialTheme);
222
  const [page, setPage] = useState<Page>(route());
223
  const [data, setData] = useState<DashboardData>(emptyData);
224
  const [settings, setSettings] = useState<Settings>(defaultSettings);
225
  const [loading, setLoading] = useState(true);
226
  const [error, setError] = useState('');
227
  const api = useCallback(
228
    async (path: string, init?: RequestInit) => {
229
      if (preview) {
230
        const next = applyPreviewRequest(data, settings, path, init);
231
        setData(next.data);
232
        setSettings(next.settings);
233
        return next.result;
234
      }
235
      return remoteApi(path, init);
236
    },
237
    [data, preview, settings],
238
  );
239
  const refreshDashboard = useCallback(async () => {
240
    if (preview) return;
241
    setData(normalizeDashboard(await remoteApi('/v0.1/dashboard')));
242
  }, [preview]);
243
244
  useEffect(() => {
245
    document.title = session
246
      ? `${pageMeta[page].label} · OpenClasp`
247
      : session === null
248
        ? 'Sign in · OpenClasp'
249
        : 'OpenClasp';
250
  }, [page, session]);
251
252
  useEffect(() => {
253
    let cancelled = false;
254
    const boot = async () => {
255
      if (import.meta.env.DEV && isPreviewActive()) {
256
        if (cancelled) return;
257
        setPreview(true);
258
        setSession(previewSession);
259
        setData(createPreviewData());
260
        setSettings(defaultPreviewSettings);
261
        setLoading(false);
262
        return;
263
      }
264
      try {
265
        const response = await fetch('/api/session', { credentials: 'same-origin' });
266
        if (!response.ok) {
267
          if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
268
          return;
269
        }
270
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) {
271
          setSession((await response.json()) as AuthSession);
272
        }
273
      } catch {
274
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
275
      }
276
    };
277
    void boot();
278
    return () => {
279
      cancelled = true;
280
    };
281
  }, []);
282
283
  useEffect(() => {
284
    document.documentElement.dataset.theme = theme;
285
    localStorage.setItem(themeKey, theme);
286
    const meta = document.querySelector('meta[name="theme-color"]');
287
    if (meta) meta.setAttribute('content', theme === 'light' ? '#f4f6f1' : '#0b0d0b');
288
  }, [theme]);
289
290
  useEffect(() => {
291
    const onPopState = () => setPage(route());
292
    addEventListener('popstate', onPopState);
293
    return () => removeEventListener('popstate', onPopState);
294
  }, []);
295
296
  useEffect(() => {
297
    if (session === undefined) return;
298
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
299
    if (!session) return;
300
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
301
    setPage(route());
302
    if (preview) {
303
      setLoading(false);
304
      return;
305
    }
306
    Promise.all([remoteApi('/v0.1/dashboard'), remoteApi('/v0.1/settings')])
307
      .then(([dashboard, accountSettings]) => {
308
        setData(normalizeDashboard(dashboard));
309
        setSettings(accountSettings as Settings);
310
      })
311
      .catch((reason: unknown) =>
312
        setError(reason instanceof Error ? reason.message : 'Load failed'),
313
      )
314
      .finally(() => setLoading(false));
315
  }, [preview, session]);
316
317
  useEffect(() => {
318
    if (!session || preview) return;
319
    const refresh = () => {
320
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
321
    };
322
    const timer = window.setInterval(refresh, 10_000);
323
    document.addEventListener('visibilitychange', refresh);
324
    return () => {
325
      window.clearInterval(timer);
326
      document.removeEventListener('visibilitychange', refresh);
327
    };
328
  }, [preview, refreshDashboard, session]);
329
330
  const navigate = (next: Page) => {
331
    history.pushState({}, '', `/${next}`);
332
    setPage(next);
333
  };
334
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
335
  const pendingInvites = data.federatedInteractions.filter(
336
    (interaction) => interaction.status === 'pending',
337
  ).length;
338
  const unreadThreads = data.hostedThreads.reduce(
339
    (total, thread) => total + Number(thread.unreadCount ?? 0),
340
    0,
341
  );
342
343
  if (session === undefined) return <Loading />;
344
  if (!session)
345
    return import.meta.env.DEV ? (
346
      <Login
347
        onPreview={() => {
348
          enablePreview();
349
          setPreview(true);
350
          setSession(previewSession);
351
          setData(createPreviewData());
352
          setSettings(defaultPreviewSettings);
353
          setLoading(false);
354
          history.replaceState({}, '', '/dashboard');
355
          setPage('dashboard');
356
        }}
357
      />
358
    ) : (
359
      <Login />
360
    );
361
362
  return (
363
    <div className="appShell">
364
      <a className="skipLink" href="#main">
365
        Skip to content
366
      </a>
367
      <aside>
368
        <div className="brand">
369
          <div className="mark">OC</div>
370
          <div>
371
            <strong>OpenClasp</strong>
372
            <small>ASSURANCE NETWORK</small>
373
          </div>
374
        </div>
375
        <nav aria-label="Dashboard">
376
          <Nav
377
            page="dashboard"
378
            active={page}
379
            onClick={navigate}
380
            label="Overview"
381
            icon="home"
382
            badge={pendingInvites || pendingSetup ? pendingInvites + pendingSetup : 0}
383
          />
384
          <Nav page="history" active={page} onClick={navigate} label="History" icon="history" />
385
          <Nav
386
            page="conversations"
387
            active={page}
388
            onClick={navigate}
389
            label="Conversations"
390
            icon="connect"
391
            badge={unreadThreads}
392
          />
393
          <Nav page="agents" active={page} onClick={navigate} label="Agents" icon="agents" />
394
          <Nav page="insights" active={page} onClick={navigate} label="Insights" icon="insights" />
395
          <Nav
396
            page="connect"
397
            active={page}
398
            onClick={navigate}
399
            label="Connect"
400
            icon="connect"
401
            badge={pendingSetup}
402
          />
403
          <Nav page="settings" active={page} onClick={navigate} label="Settings" icon="settings" />
404
        </nav>
405
        <button
406
          className="themeSwitch"
407
          type="button"
408
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
409
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
410
        >
411
          <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
412
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
413
        </button>
414
        <div className="privacyStamp">
415
          <span className="liveDot" />
416
          <div>
417
            <strong>Privacy separated</strong>
418
            <small>Direct messages never stored</small>
419
          </div>
420
        </div>
421
        <button
422
          className="account"
423
          type="button"
424
          onClick={() => void signOut(preview)}
425
          title="Sign out"
426
        >
427
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
428
          <div>
429
            <strong>{session.user.name || 'OpenClasp user'}</strong>
430
            <small>{session.user.email || 'Signed in'}</small>
431
          </div>
432
          <b>Sign out</b>
433
        </button>
434
      </aside>
435
      <div>
436
        <div className="mobileBar">
437
          <div className="brand">
438
            <div className="mark">OC</div>
439
            <div>
440
              <strong>OpenClasp</strong>
441
              <small>ASSURANCE</small>
442
            </div>
443
          </div>
444
          <div className="mobileActions">
445
            <button
446
              className="iconButton"
447
              type="button"
448
              aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
449
              onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
450
            >
451
              <Icon name={theme === 'dark' ? 'sun' : 'moon'} />
452
            </button>
453
            <button
454
              className="iconButton"
455
              type="button"
456
              aria-label="Sign out"
457
              onClick={() => void signOut(preview)}
458
            >
459
              <span>{initials(session.user.name || session.user.email || 'OC')}</span>
460
            </button>
461
          </div>
462
        </div>
463
        <main id="main">
464
          {preview && (
465
            <div className="previewBanner">Local preview with sample data. Changes stay here.</div>
466
          )}
467
          {error && (
468
            <div className="errorBar" role="alert">
469
              {error}
470
            </div>
471
          )}
472
          {loading ? (
473
            <Loading compact />
474
          ) : (
475
            <PageContent
476
              page={page}
477
              data={data}
478
              settings={settings}
479
              setSettings={setSettings}
480
              navigate={navigate}
481
              refreshDashboard={refreshDashboard}
482
              api={api}
483
            />
484
          )}
485
        </main>
486
      </div>
487
    </div>
488
  );
489
}
490
491
function Login({ onPreview }: { onPreview?: () => void }) {
492
  const [error, setError] = useState('');
493
  const continueWith = async (provider: 'google' | 'github') => {
494
    setError('');
495
    try {
496
      await beginAuth(provider);
497
    } catch {
498
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
499
    }
500
  };
501
  return (
502
    <div className="loginPage">
503
      <section className="loginPitch">
504
        <div className="brand">
505
          <div className="mark">OC</div>
506
          <strong>OpenClasp</strong>
507
        </div>
508
        <div>
509
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
510
          <h1>Know who your agent is dealing with.</h1>
511
          <p>
512
            Verified identities, signed outcomes, private warnings, and contextual
513
            reliability—without using message bodies for scoring.
514
          </p>
515
        </div>
516
        <div className="loginProof">
517
          <span>01</span> User-owned history
518
          <span>02</span> No universal trust score
519
          <span>03</span> Explicit network consent
520
        </div>
521
      </section>
522
      <section className="loginCard">
523
        <div>
524
          <p className="eyebrow">SECURE ACCESS</p>
525
          <h2>Sign in to OpenClasp</h2>
526
          <p>
527
            Manage connected agents, review signed history, and control what contributes to the
528
            network.
529
          </p>
530
        </div>
531
        <div className="socialButtons">
532
          <button type="button" onClick={() => void continueWith('google')}>
533
            <GoogleMark /> Continue with Google
534
          </button>
535
          <button type="button" onClick={() => void continueWith('github')}>
536
            <GitHubMark /> Continue with GitHub
537
          </button>
538
          {onPreview && (
539
            <button type="button" onClick={() => void onPreview()}>
540
              Continue with local preview
541
            </button>
542
          )}
543
        </div>
544
        {error && (
545
          <div className="loginError" role="alert">
546
            {error}
547
          </div>
548
        )}
549
        <small>
550
          Google and GitHub handle authentication. OpenClasp never receives your password.
551
        </small>
552
      </section>
553
    </div>
554
  );
555
}
556
557
let callbackStarted = false;
558
function AuthCallback() {
559
  const [error, setError] = useState('');
560
  useEffect(() => {
561
    if (callbackStarted) return;
562
    callbackStarted = true;
563
    const finish = async () => {
564
      const query = new URLSearchParams(location.search);
565
      const oauthError = query.get('error_description') ?? query.get('error');
566
      if (oauthError) throw new Error(oauthError);
567
      const code = query.get('code');
568
      const returnedState = query.get('state');
569
      if (code && returnedState?.startsWith('oc_tx_')) {
570
        const response = await fetch('/api/oauth-callback', {
571
          method: 'POST',
572
          credentials: 'same-origin',
573
          headers: { 'content-type': 'application/json' },
574
          body: JSON.stringify({ code, state: returnedState }),
575
        });
576
        if (!response.ok) throw new Error('MCP authorization failed');
577
        const result = (await response.json()) as { redirectTo?: unknown };
578
        if (typeof result.redirectTo !== 'string')
579
          throw new Error('Invalid MCP authorization response');
580
        location.replace(result.redirectTo);
581
        return;
582
      }
583
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
584
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
585
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
586
      sessionStorage.removeItem(authTransactionKey);
587
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
588
      const response = await fetch('/api/session', {
589
        method: 'POST',
590
        credentials: 'same-origin',
591
        headers: { 'content-type': 'application/json' },
592
        body: JSON.stringify({
593
          code,
594
          codeVerifier: transaction.verifier,
595
        }),
596
      });
597
      if (!response.ok) throw new Error('Auth0 token exchange failed');
598
      location.replace('/dashboard');
599
    };
600
    void finish().catch((reason: unknown) =>
601
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
602
    );
603
  }, []);
604
  if (error)
605
    return (
606
      <div className="loading">
607
        <span className="mark">OC</span>
608
        <p>{error}</p>
609
        <a href="/login">Return to login</a>
610
      </div>
611
    );
612
  return <Loading />;
613
}
614
615
function PageContent({
616
  page,
617
  data,
618
  settings,
619
  setSettings,
620
  navigate,
621
  refreshDashboard,
622
  api,
623
}: {
624
  page: Page;
625
  data: DashboardData;
626
  settings: Settings;
627
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
628
  navigate: (page: Page) => void;
629
  refreshDashboard: () => Promise<void>;
630
  api: (path: string, init?: RequestInit) => Promise<unknown>;
631
}) {
632
  if (page === 'history') return <History data={data} />;
633
  if (page === 'conversations')
634
    return <Conversations data={data} refreshDashboard={refreshDashboard} api={api} />;
635
  if (page === 'agents')
636
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
637
  if (page === 'insights') return <Insights data={data} />;
638
  if (page === 'connect')
639
    return (
640
      <Connect data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
641
    );
642
  if (page === 'settings')
643
    return <SettingsPage settings={settings} setSettings={setSettings} api={api} />;
644
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
645
}
646
647
function Overview({
648
  data,
649
  navigate,
650
  refreshDashboard,
651
  api,
652
}: {
653
  data: DashboardData;
654
  navigate: (page: Page) => void;
655
  refreshDashboard: () => Promise<void>;
656
  api: (path: string, init?: RequestInit) => Promise<unknown>;
657
}) {
658
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
659
  const warnings = data.events.filter((item) =>
660
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
661
  ).length;
662
  const publishedIds = new Set(
663
    data.publications
664
      .filter((publication) => publication.published)
665
      .map((publication) => String(publication.agentId)),
666
  );
667
  const runtimeIds = new Set(
668
    data.runtimes
669
      .filter((runtime) => runtime.status === 'verified')
670
      .map((runtime) => String(runtime.agentId)),
671
  );
672
  const modeOf = (agent: Record<string, any>) =>
673
    agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
674
  const readyAgents = data.agents.filter(
675
    (agent) =>
676
      publishedIds.has(String(agent.agentId)) &&
677
      (modeOf(agent) === 'temporary_chat' || runtimeIds.has(String(agent.agentId))),
678
  ).length;
679
  const activeTemporaryChats = data.agents.filter(
680
    (agent) => modeOf(agent) === 'temporary_chat' && agent.presence?.status === 'online',
681
  ).length;
682
  const pendingInvitations = data.federatedInteractions.filter(
683
    (interaction) => interaction.status === 'pending',
684
  ).length;
685
  const pendingFeedback = data.feedbackRequests.filter(
686
    (request) => request.status === 'pending',
687
  ).length;
688
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
689
  const reviewCount = warnings + pendingInvitations + pendingFeedback;
690
  return (
691
    <>
692
      <PageHead page="dashboard" action="Connect agent" onAction={() => navigate('connect')} />
693
      {pendingSetup > 0 && (
694
        <button className="attentionBanner" type="button" onClick={() => navigate('connect')}>
695
          <div>
696
            <span className="statusOrb">{pendingSetup}</span>
697
            <div>
698
              <strong>
699
                {pendingSetup} setup request{pendingSetup === 1 ? '' : 's'} waiting
700
              </strong>
701
              <small>Approve the proposed identity before the agent can be bound.</small>
702
            </div>
703
          </div>
704
        </button>
705
      )}
706
      <section
707
        className={`readiness ${readyAgents === data.agents.length && readyAgents ? 'ready' : ''}`}
708
      >
709
        <div>
710
          <span className="statusOrb">{readyAgents ? '✓' : '!'}</span>
711
          <div>
712
            <strong>
713
              {readyAgents === data.agents.length && readyAgents
714
                ? 'Your agent network is ready'
715
                : `${readyAgents} of ${data.agents.length} agents can receive A2A work`}
716
            </strong>
717
            <small>
718
              Safe matching requests activate automatically. Everything else waits for approval.
719
            </small>
720
          </div>
721
        </div>
722
        <button className="secondary" type="button" onClick={() => navigate('agents')}>
723
          Manage automation
724
        </button>
725
      </section>
726
      <section className="metrics">
727
        <Metric
728
          label="Active temporary chats"
729
          value={activeTemporaryChats}
730
          note={`${runtimeIds.size} persistent runtimes connected`}
731
        />
732
        <Metric
733
          label="Interactions"
734
          value={data.interactions.length + data.federatedInteractions.length}
735
          note="local and shared"
736
        />
737
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
738
        <Metric
739
          label="Needs review"
740
          value={reviewCount}
741
          note={`${pendingFeedback} feedback · ${pendingInvitations} invitations`}
742
          warn={reviewCount > 0}
743
        />
744
      </section>
745
      <Invitations data={data} refreshDashboard={refreshDashboard} api={api} />
746
      <section className="contentGrid">
747
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
748
          <Timeline events={data.events.slice(-6).reverse()} />
749
          <TextButton onClick={() => navigate('history')}>View complete history</TextButton>
750
        </Panel>
751
        <Panel
752
          title="Reliability context"
753
          subtitle="Task-specific profiles, never a universal score"
754
        >
755
          {data.profiles.length ? (
756
            data.profiles.slice(0, 4).map((profile) => {
757
              const deltas = data.profileDeltas.filter(
758
                (delta) =>
759
                  delta.agentId === profile.agentId &&
760
                  delta.agentVersion === profile.agentVersion &&
761
                  delta.taskCategory === profile.taskCategory,
762
              );
763
              return (
764
                <Profile
765
                  key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
766
                  profile={profile}
767
                  deltas={deltas}
768
                />
769
              );
770
            })
771
          ) : (
772
            <Empty
773
              title="No reliability profile yet"
774
              text="Profiles appear after signed receipts and eligible bilateral feedback."
775
            />
776
          )}
777
        </Panel>
778
      </section>
779
    </>
780
  );
781
}
782
783
function Conversations({
784
  data,
785
  refreshDashboard,
786
  api,
787
}: {
788
  data: DashboardData;
789
  refreshDashboard: () => Promise<void>;
790
  api: (path: string, init?: RequestInit) => Promise<unknown>;
791
}) {
792
  const [selected, setSelected] = useState<Record<string, any> | null>(null);
793
  const [selectedAgentId, setSelectedAgentId] = useState('');
794
  const [draft, setDraft] = useState('');
795
  const [working, setWorking] = useState(false);
796
  const [error, setError] = useState('');
797
  const temporaryAgentFor = (thread: Record<string, any>) =>
798
    data.agents.find(
799
      (agent) =>
800
        thread.participantAgentIds?.includes(agent.agentId) &&
801
        (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
802
          'temporary_chat',
803
    );
804
  const openThread = async (thread: Record<string, any>) => {
805
    const agent = temporaryAgentFor(thread);
806
    if (!agent) return;
807
    setWorking(true);
808
    setError('');
809
    try {
810
      const [detail] = (await Promise.all([
811
        api(
812
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}`,
813
        ),
814
        api(
815
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}/read`,
816
          { method: 'POST' },
817
        ),
818
      ])) as [Record<string, any>, unknown];
819
      setSelected(detail);
820
      setSelectedAgentId(String(agent.agentId));
821
      await refreshDashboard();
822
    } catch (reason) {
823
      setError(reason instanceof Error ? reason.message : 'Could not open conversation');
824
    } finally {
825
      setWorking(false);
826
    }
827
  };
828
  const reply = async () => {
829
    if (!selected || !draft.trim() || !selectedAgentId) return;
830
    setWorking(true);
831
    setError('');
832
    try {
833
      await api(`/v0.1/agents/${encodeURIComponent(selectedAgentId)}/messages`, {
834
        method: 'POST',
835
        body: JSON.stringify({
836
          interactionId: selected.thread.interactionId,
837
          content: draft.trim(),
838
        }),
839
      });
840
      setDraft('');
841
      await openThread(selected.thread);
842
    } catch (reason) {
843
      setError(reason instanceof Error ? reason.message : 'Could not send reply');
844
      setWorking(false);
845
    }
846
  };
847
  return (
848
    <>
849
      <PageHead page="conversations" />
850
      <div className="notice">
851
        <strong>Hosted temporary mode.</strong> OpenClasp processes these messages and encrypts them
852
        at rest for 30 days. Messages between persistent runtimes remain direct and are never shown
853
        here.
854
      </div>
855
      {error ? (
856
        <div className="errorBar" role="alert">
857
          {error}
858
        </div>
859
      ) : null}
860
      <section className="conversationLayout">
861
        <Panel title="Threads" subtitle="Temporary chat identities only">
862
          <div className="threadList">
863
            {data.hostedThreads.length ? (
864
              data.hostedThreads.map((thread) => {
865
                const agent = temporaryAgentFor(thread);
866
                const peer = thread.participantAgentIds?.find(
867
                  (value: string) => value !== agent?.agentId,
868
                );
869
                return (
870
                  <button
871
                    type="button"
872
                    key={thread.threadId}
873
                    className={selected?.thread?.threadId === thread.threadId ? 'active' : ''}
874
                    onClick={() => void openThread(thread)}
875
                    disabled={working}
876
                  >
877
                    <span>
878
                      <strong>{peer ?? 'Counterparty'}</strong>
879
                      <small>{agent?.name ?? agent?.agentId}</small>
880
                    </span>
881
                    <b>{thread.unreadCount ? `${thread.unreadCount} NEW` : thread.status}</b>
882
                  </button>
883
                );
884
              })
885
            ) : (
886
              <Empty
887
                title="No temporary conversations"
888
                text="Messages appear after a temporary chat identity accepts an interaction with a persistent runtime."
889
              />
890
            )}
891
          </div>
892
        </Panel>
893
        <Panel
894
          title={selected ? 'Conversation' : 'Select a thread'}
895
          subtitle="Private insights are attributable and task-specific"
896
        >
897
          {selected ? (
898
            <div className="conversationDetail">
899
              {!!selected.insights?.length && (
900
                <div className="conversationInsights">
901
                  {selected.insights.map((insight: Record<string, any>) => (
902
                    <p key={insight.code}>
903
                      <b>{String(insight.severity).toUpperCase()}</b> {insight.message}
904
                    </p>
905
                  ))}
906
                </div>
907
              )}
908
              <div className="messageList">
909
                {selected.messages.map((message: Record<string, any>) => (
910
                  <article
911
                    key={message.messageId}
912
                    className={message.senderAgentId === selectedAgentId ? 'sent' : 'received'}
913
                  >
914
                    <small>{message.senderAgentId === selectedAgentId ? 'You' : 'Peer'}</small>
915
                    <p>{message.content}</p>
916
                    <span>{new Date(message.createdAt).toLocaleString()}</span>
917
                  </article>
918
                ))}
919
              </div>
920
              <div className="replyBox">
921
                <textarea
922
                  aria-label="Temporary chat reply"
923
                  value={draft}
924
                  onChange={(event) => setDraft(event.target.value)}
925
                  maxLength={20_000}
926
                  placeholder="Reply through the temporary OpenClasp A2A adapter"
927
                />
928
                <button
929
                  className="primary"
930
                  type="button"
931
                  disabled={working || !draft.trim() || selected.thread.status !== 'open'}
932
                  onClick={() => void reply()}
933
                >
934
                  {working ? 'Sending…' : 'Send reply'}
935
                </button>
936
              </div>
937
            </div>
938
          ) : (
939
            <Empty title="Nothing selected" text="Choose a thread to read its hosted history." />
940
          )}
941
        </Panel>
942
      </section>
943
    </>
944
  );
945
}
946
947
function History({ data }: { data: DashboardData }) {
948
  const [query, setQuery] = useState('');
949
  const [status, setStatus] = useState<
950
    'all' | 'pending' | 'active' | 'provisional' | 'finalizing' | 'completed'
951
  >('all');
952
  const journeys = useMemo(() => interactionJourneys(data), [data]);
953
  const filtered = useMemo(() => {
954
    const needle = query.trim().toLowerCase();
955
    return journeys.filter((journey) => {
956
      if (status !== 'all' && journey.status !== status) return false;
957
      if (!needle) return true;
958
      const hay = [
959
        journey.interactionId,
960
        journey.purpose,
961
        journey.taskCategory,
962
        journey.status,
963
        ...journey.participants,
964
      ]
965
        .filter(Boolean)
966
        .join(' ')
967
        .toLowerCase();
968
      return hay.includes(needle);
969
    });
970
  }, [journeys, query, status]);
971
  const [selectedId, setSelectedId] = useState('');
972
  const selected = filtered.find((journey) => journey.interactionId === selectedId) ?? filtered[0];
973
  return (
974
    <>
975
      <PageHead page="history" />
976
      <div className="historyToolbar">
977
        <input
978
          className="searchField"
979
          value={query}
980
          onChange={(event) => setQuery(event.target.value)}
981
          placeholder="Search purpose, category, or agent"
982
          aria-label="Search interaction history"
983
        />
984
        <div className="filterRow" aria-label="Interaction status filters">
985
          {(['all', 'pending', 'active', 'provisional', 'completed'] as const).map((value) => (
986
            <button
987
              key={value}
988
              className={status === value ? 'filterChip active' : 'filterChip'}
989
              type="button"
990
              aria-pressed={status === value}
991
              onClick={() => setStatus(value)}
992
            >
993
              {value}{' '}
994
              {value === 'all'
995
                ? journeys.length
996
                : journeys.filter((item) => item.status === value).length}
997
            </button>
998
          ))}
999
        </div>
1000
      </div>
1001
      {filtered.length ? (
1002
        <section className="journeyLayout">
1003
          <div className="journeyList" aria-label="Interactions">
1004
            {filtered.map((journey) => (
1005
              <button
1006
                key={journey.interactionId}
1007
                className={selected?.interactionId === journey.interactionId ? 'active' : ''}
1008
                type="button"
1009
                onClick={() => {
1010
                  setSelectedId(journey.interactionId);
1011
                  if (matchMedia('(max-width: 980px)').matches)
1012
                    requestAnimationFrame(() =>
1013
                      document
1014
                        .querySelector('.journeyDetail')
1015
                        ?.scrollIntoView({ behavior: 'smooth' }),
1016
                    );
1017
                }}
1018
              >
1019
                <span className="journeyListTop">
1020
                  <b>{journey.taskCategory}</b>
1021
                  <StatusPill value={journey.status} />
1022
                </span>
1023
                <strong>{journey.purpose}</strong>
1024
                <small>{journey.participants.join(' ↔ ') || 'Participants unavailable'}</small>
1025
                <span className="journeyListMeta">
1026
                  {relativeTime(journey.updatedAt)}
1027
                  <i>{journey.stepCount} recorded steps</i>
1028
                </span>
1029
              </button>
1030
            ))}
1031
          </div>
1032
          {selected ? <InteractionJourney journey={selected} /> : null}
1033
        </section>
1034
      ) : (
1035
        <section className="panel">
1036
          <Empty
1037
            title={journeys.length ? 'No matching interactions' : 'No interactions recorded'}
1038
            text={
1039
              journeys.length
1040
                ? 'Clear the search or choose another status.'
1041
                : 'Completed contracts, outcomes, feedback, and learning will appear as one readable journey.'
1042
            }
1043
          />
1044
        </section>
1045
      )}
1046
    </>
1047
  );
1048
}
1049
1050
type InteractionJourneyModel = {
1051
  interactionId: string;
1052
  purpose: string;
1053
  taskCategory: string;
1054
  status: string;
1055
  participants: string[];
1056
  updatedAt: string;
1057
  stepCount: number;
1058
  interaction: Record<string, any>;
1059
  session?: Record<string, any>;
1060
  events: Record<string, any>[];
1061
  briefs: Record<string, any>[];
1062
  reports: Record<string, any>[];
1063
  feedbackRequests: Record<string, any>[];
1064
  feedback: Record<string, any>[];
1065
  conclusion?: Record<string, any>;
1066
  receipt?: Record<string, any>;
1067
  eligibility?: Record<string, any>;
1068
  deltas: Record<string, any>[];
1069
  conflicts: Record<string, any>[];
1070
};
1071
1072
function interactionJourneys(data: DashboardData): InteractionJourneyModel[] {
1073
  const ids = new Set<string>();
1074
  const collections = [
1075
    data.interactions,
1076
    data.federatedInteractions,
1077
    data.liveSessions,
1078
    data.events,
1079
    data.counterpartyBriefs,
1080
    data.completionReports,
1081
    data.feedbackRequests,
1082
    data.interactionFeedback,
1083
    data.interactionConclusions,
1084
    data.receipts,
1085
    data.learningEligibility,
1086
    data.profileDeltas,
1087
    data.conflicts,
1088
  ];
1089
  for (const collection of collections)
1090
    for (const item of collection) if (item.interactionId) ids.add(String(item.interactionId));
1091
  return [...ids]
1092
    .map((interactionId) => {
1093
      const interaction = data.federatedInteractions.find(
1094
        (item) => item.interactionId === interactionId,
1095
      ) ??
1096
        data.interactions.find((item) => item.interactionId === interactionId) ?? { interactionId };
1097
      const session = data.liveSessions.find((item) => item.interactionId === interactionId);
1098
      const events = data.events.filter((item) => item.interactionId === interactionId);
1099
      const briefs = data.counterpartyBriefs.filter((item) => item.interactionId === interactionId);
1100
      const reports = data.completionReports.filter((item) => item.interactionId === interactionId);
1101
      const feedbackRequests = data.feedbackRequests.filter(
1102
        (item) => item.interactionId === interactionId,
1103
      );
1104
      const feedback = data.interactionFeedback.filter(
1105
        (item) => item.interactionId === interactionId,
1106
      );
1107
      const conclusion = data.interactionConclusions.find(
1108
        (item) => item.interactionId === interactionId,
1109
      );
1110
      const receipt = data.receipts.find((item) => item.interactionId === interactionId);
1111
      const eligibility = data.learningEligibility.find(
1112
        (item) => item.interactionId === interactionId,
1113
      );
1114
      const deltas = data.profileDeltas.filter((item) => item.interactionId === interactionId);
1115
      const conflicts = data.conflicts.filter((item) => item.interactionId === interactionId);
1116
      const participants = [
1117
        interaction.initiatorAgentId,
1118
        interaction.responderAgentId,
1119
        interaction.agentId,
1120
        ...reports.flatMap((report) => [report.reportingAgentId, report.counterpartyAgentId]),
1121
      ]
1122
        .filter((value): value is string => typeof value === 'string' && Boolean(value))
1123
        .filter((value, index, values) => values.indexOf(value) === index);
1124
      const dates = [
1125
        interaction.updatedAt,
1126
        interaction.createdAt,
1127
        session?.completedAt,
1128
        session?.activatedAt,
1129
        ...events.map(timestamp),
1130
        ...reports.map(timestamp),
1131
        ...feedback.map(timestamp),
1132
        conclusion?.generatedAt,
1133
        receipt?.completedAt,
1134
        eligibility?.decidedAt,
1135
      ].filter((value): value is string => typeof value === 'string');
1136
      return {
1137
        interactionId,
1138
        purpose: String(
1139
          interaction.contract?.purpose ??
1140
            interaction.contract?.requestedOutcome ??
1141
            conclusion?.summary ??
1142
            'Assured agent interaction',
1143
        ),
1144
        taskCategory: String(interaction.contract?.taskCategory ?? 'general'),
1145
        status: String(
1146
          conclusion || receipt
1147
            ? conclusion?.lifecycle === 'provisional' || receipt?.provisional
1148
              ? 'provisional'
1149
              : 'completed'
1150
            : reports.length > 0
1151
              ? 'finalizing'
1152
              : (session?.status ?? interaction.status ?? 'pending'),
1153
        ),
1154
        participants,
1155
        updatedAt:
1156
          dates.sort((left, right) => Date.parse(right) - Date.parse(left))[0] ??
1157
          new Date(0).toISOString(),
1158
        stepCount:
1159
          1 +
1160
          events.length +
1161
          briefs.length +
1162
          reports.length +
1163
          feedbackRequests.length +
1164
          feedback.length +
1165
          conflicts.length +
1166
          Number(Boolean(conclusion)) +
1167
          Number(Boolean(receipt)) +
1168
          Number(Boolean(eligibility)) +
1169
          deltas.length,
1170
        interaction,
1171
        ...(session ? { session } : {}),
1172
        events,
1173
        briefs,
1174
        reports,
1175
        feedbackRequests,
1176
        feedback,
1177
        ...(conclusion ? { conclusion } : {}),
1178
        ...(receipt ? { receipt } : {}),
1179
        ...(eligibility ? { eligibility } : {}),
1180
        deltas,
1181
        conflicts,
1182
      };
1183
    })
1184
    .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
1185
}
1186
1187
function InteractionJourney({ journey }: { journey: InteractionJourneyModel }) {
1188
  const timeline = interactionTimeline(journey);
1189
  const conclusion = journey.conclusion;
1190
  const checkpoints = journey.events.filter(
1191
    (event) => (event.type ?? event.eventType) === 'progress_checkpoint' && event.checkpoint,
1192
  );
1193
  const latestCheckpoint = checkpoints.at(-1)?.checkpoint;
1194
  const progress = Number(latestCheckpoint?.progress ?? 0);
1195
  const submittedFeedback = new Set(
1196
    journey.feedback.map((item) => item.reviewerAgentId).filter(Boolean),
1197
  ).size;
1198
  return (
1199
    <article className="journeyDetail">
1200
      <header className="journeyHero">
1201
        <div>
1202
          <span className="eyebrow">{journey.taskCategory}</span>
1203
          <h2>{journey.purpose}</h2>
1204
          <p>{journey.participants.join(' ↔ ')}</p>
1205
        </div>
1206
        <StatusPill value={conclusion?.outcome ?? journey.status} />
1207
      </header>
1208
1209
      <div className="journeySummaryGrid">
1210
        <section>
1211
          <span>Progress</span>
1212
          <strong>
1213
            {latestCheckpoint
1214
              ? `${Math.round(progress * 100)}% · ${humanize(latestCheckpoint.state)}`
1215
              : journey.reports.length
1216
                ? 'One side ended'
1217
                : 'Awaiting checkpoint'}
1218
          </strong>
1219
          <small>
1220
            {latestCheckpoint
1221
              ? `${humanize(latestCheckpoint.topicStatus)} · ${Math.round(Number(latestCheckpoint.confidence ?? 0) * 100)}% confidence`
1222
              : 'Agents report compact progress every few exchanges'}
1223
          </small>
1224
        </section>
1225
        <section>
1226
          <span>Outcome</span>
1227
          <strong>
1228
            {conclusion
1229
              ? humanize(conclusion.outcome)
1230
              : `${journey.reports.length}/2 terminal reports`}
1231
          </strong>
1232
          <small>
1233
            {conclusion
1234
              ? conclusion.lifecycle === 'provisional'
1235
                ? `${Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer ${humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()}`
1236
                : humanize(conclusion.consensus)
1237
              : journey.reports.length === 1
1238
                ? 'One agent finished; confirming with the peer'
1239
                : 'Conversation remains active'}
1240
          </small>
1241
        </section>
1242
        <section>
1243
          <span>Sealed feedback</span>
1244
          <strong>
1245
            {journey.feedbackRequests.some((request) => request.status === 'pending')
1246
              ? `${submittedFeedback}/2 sealed`
1247
              : 'Window closed'}
1248
          </strong>
1249
          <small>{feedbackState(journey.feedbackRequests)}</small>
1250
        </section>
1251
        <section>
1252
          <span>Learning</span>
1253
          <strong>
1254
            {journey.eligibility
1255
              ? journey.eligibility.eligible
1256
                ? `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% weight`
1257
                : 'Not eligible'
1258
              : conclusion?.lifecycle === 'provisional'
1259
                ? 'Pending corroboration'
1260
                : 'Not assessed'}
1261
          </strong>
1262
          <small>{humanize(journey.eligibility?.contributionMode ?? 'Local only')}</small>
1263
        </section>
1264
      </div>
1265
1266
      {latestCheckpoint ? (
1267
        <section className="progressCard">
1268
          <div className="progressCardTop">
1269
            <div>
1270
              <span className="eyebrow">LATEST PRIVATE CHECKPOINT</span>
1271
              <h3>{humanize(latestCheckpoint.state)}</h3>
1272
            </div>
1273
            <strong>{Math.round(progress * 100)}%</strong>
1274
          </div>
1275
          <div
1276
            className="progressTrack"
1277
            role="progressbar"
1278
            aria-label="Interaction progress"
1279
            aria-valuemin={0}
1280
            aria-valuemax={100}
1281
            aria-valuenow={Math.round(progress * 100)}
1282
          >
1283
            <i style={{ width: `${Math.round(progress * 100)}%` }} />
1284
          </div>
1285
          <div className="progressFacts">
1286
            <span>{latestCheckpoint.criteriaMet?.length ?? 0} criteria met</span>
1287
            <span>{latestCheckpoint.criteriaRemaining?.length ?? 0} remaining</span>
1288
            <span>{latestCheckpoint.expectedRemainingTurns ?? '—'} expected turns</span>
1289
            <span>
1290
              {latestCheckpoint.needsHuman ? 'Human input needed' : 'No human input needed'}
1291
            </span>
1292
          </div>
1293
          {latestCheckpoint.topicStatus !== 'in_scope' ? (
1294
            <p className="progressWarning">
1295
              Topic {humanize(latestCheckpoint.topicStatus)}. Amend the contract or start a new
1296
              interaction before continuing.
1297
            </p>
1298
          ) : null}
1299
        </section>
1300
      ) : null}
1301
1302
      {conclusion ? (
1303
        <section className="outcomeCard">
1304
          <div>
1305
            <span className="eyebrow">
1306
              {conclusion.lifecycle === 'provisional' ? 'PROVISIONAL INSIGHT' : 'WHAT HAPPENED'}
1307
            </span>
1308
            <h3>{conclusion.summary}</h3>
1309
            {conclusion.lifecycle === 'provisional' ? (
1310
              <p className="progressWarning">
1311
                Based on {conclusion.reportIds?.length ?? 1} participant report. Missing:{' '}
1312
                {(conclusion.missingReportAgentIds ?? []).join(', ') || 'peer report'}. This is
1313
                usable now at reduced confidence and will be revised if the peer responds.
1314
              </p>
1315
            ) : null}
1316
          </div>
1317
          {!!conclusion.criteria?.length && (
1318
            <ul>
1319
              {conclusion.criteria.map((criterion: Record<string, any>) => (
1320
                <li key={criterion.criterion}>
1321
                  <StatusPill value={String(criterion.status)} />
1322
                  <span>{criterion.criterion}</span>
1323
                </li>
1324
              ))}
1325
            </ul>
1326
          )}
1327
          {!!Object.keys(conclusion.averageRatings ?? {}).length && (
1328
            <div className="ratingGrid">
1329
              {Object.entries(conclusion.averageRatings).map(([label, value]) => (
1330
                <Meter key={label} label={humanize(label)} value={Number(value)} />
1331
              ))}
1332
            </div>
1333
          )}
1334
        </section>
1335
      ) : (
1336
        <div className="journeyNotice">
1337
          {journey.reports.length === 1
1338
            ? 'Finalizing: one agent submitted its terminal report. OpenClasp has requested independent confirmation from the peer.'
1339
            : journey.reports.length >= 2
1340
              ? 'Both agents finished. Sealed feedback remains private until both respond or the feedback window expires.'
1341
              : 'Active interaction. Progress checkpoints appear after several meaningful exchanges; either agent can finalize immediately when the task is done.'}
1342
        </div>
1343
      )}
1344
1345
      <section className="journeyTimelineSection">
1346
        <div>
1347
          <h3>Interaction timeline</h3>
1348
          <small>Contract → session → outcome → feedback → learning</small>
1349
        </div>
1350
        <ol className="journeyTimeline">
1351
          {timeline.map((step, index) => (
1352
            <li key={`${step.at}-${step.title}-${index}`}>
1353
              <span className={`journeyNode ${step.tone}`} />
1354
              <div>
1355
                <span>{formatDate(step.at)}</span>
1356
                <strong>{step.title}</strong>
1357
                <p>{step.detail}</p>
1358
              </div>
1359
              <StatusPill value={step.status} />
1360
            </li>
1361
          ))}
1362
        </ol>
1363
      </section>
1364
    </article>
1365
  );
1366
}
1367
1368
function interactionTimeline(journey: InteractionJourneyModel) {
1369
  const steps: { at: string; title: string; detail: string; status: string; tone: string }[] = [
1370
    {
1371
      at: String(journey.interaction.createdAt ?? journey.updatedAt),
1372
      title: 'Contract proposed',
1373
      detail: `${journey.participants.length || 2} participants · ${journey.taskCategory}`,
1374
      status: String(journey.interaction.status ?? 'recorded'),
1375
      tone: 'neutral',
1376
    },
1377
    ...journey.briefs.map((brief) => ({
1378
      at: String(brief.generatedAt ?? journey.updatedAt),
1379
      title: `Private counterparty brief for ${brief.recipientAgentId}`,
1380
      detail: `${brief.relevantSampleSize ?? 0} relevant samples · ${Math.round(Number(brief.historyConfidence ?? 0) * 100)}% history confidence`,
1381
      status: String(brief.decision ?? 'ready'),
1382
      tone: brief.decision === 'DENY' ? 'danger' : brief.decision === 'CHALLENGE' ? 'warn' : 'good',
1383
    })),
1384
    ...Object.values(journey.interaction.acceptances ?? {}).map((acceptance: any) => ({
1385
      at: String(acceptance.acceptedAt ?? journey.interaction.updatedAt ?? journey.updatedAt),
1386
      title: `Contract accepted by ${acceptance.agentId}`,
1387
      detail: `Acceptance method: ${humanize(acceptance.method ?? 'recorded')}`,
1388
      status: 'accepted',
1389
      tone: 'good',
1390
    })),
1391
    ...(journey.session?.activatedAt
1392
      ? [
1393
          {
1394
            at: String(journey.session.activatedAt),
1395
            title: 'A2A session activated',
1396
            detail:
1397
              'Scoped credentials were issued. Persistent runtimes exchange content directly; temporary mode uses the hosted adapter.',
1398
            status: 'active',
1399
            tone: 'good',
1400
          },
1401
        ]
1402
      : []),
1403
    ...journey.events.map((event) => {
1404
      const eventType = String(event.type ?? event.eventType ?? 'structured_event');
1405
      const checkpoint = event.checkpoint;
1406
      return {
1407
        at: timestamp(event),
1408
        title:
1409
          eventType === 'progress_checkpoint'
1410
            ? `${Math.round(Number(checkpoint?.progress ?? 0) * 100)}% progress checkpoint`
1411
            : humanize(eventType),
1412
        detail:
1413
          eventType === 'progress_checkpoint'
1414
            ? `${event.agentId} · ${humanize(checkpoint?.state ?? 'active')} · ${humanize(checkpoint?.topicStatus ?? 'in_scope')}`
1415
            : String(event.agentId ?? 'OpenClasp') + ' submitted structured metadata.',
1416
        status: String(checkpoint?.state ?? event.visibility ?? 'recorded'),
1417
        tone:
1418
          checkpoint?.topicStatus === 'changed' || checkpoint?.state === 'blocked'
1419
            ? 'warn'
1420
            : eventType.includes('violation')
1421
              ? 'danger'
1422
              : eventType.includes('warning')
1423
                ? 'warn'
1424
                : checkpoint
1425
                  ? 'good'
1426
                  : 'neutral',
1427
      };
1428
    }),
1429
    ...journey.reports.map((report) => ({
1430
      at: timestamp(report),
1431
      title: `Completion reported by ${report.reportingAgentId}`,
1432
      detail: String(report.summary ?? 'Structured completion report submitted.'),
1433
      status: String(report.outcome ?? 'reported'),
1434
      tone:
1435
        report.outcome === 'failure' ? 'danger' : report.outcome === 'partial' ? 'warn' : 'good',
1436
    })),
1437
    ...journey.feedback.map((item) => ({
1438
      at: timestamp(item),
1439
      title: `Feedback submitted by ${item.reviewerAgentId}`,
1440
      detail: `Would work again: ${humanize(item.wouldWorkAgain ?? 'unsure')} · private comment concealed`,
1441
      status: 'sealed',
1442
      tone: 'neutral',
1443
    })),
1444
    ...journey.feedbackRequests
1445
      .filter((request) => request.status !== 'submitted')
1446
      .map((request) => ({
1447
        at: String(request.requestedAt ?? journey.updatedAt),
1448
        title: `Feedback request ${humanize(request.status)}`,
1449
        detail:
1450
          request.status === 'pending'
1451
            ? `Response due ${formatDate(String(request.dueAt))}`
1452
            : 'The bilateral conclusion continued without this response.',
1453
        status: String(request.status),
1454
        tone: request.status === 'pending' ? 'warn' : 'neutral',
1455
      })),
1456
    ...journey.conflicts.map((conflict) => ({
1457
      at: timestamp(conflict),
1458
      title: 'Dispute recorded',
1459
      detail: String(conflict.summary ?? 'The interaction has a structured dispute record.'),
1460
      status: String(conflict.status ?? 'open'),
1461
      tone: conflict.status === 'resolved' ? 'good' : 'danger',
1462
    })),
1463
    ...(journey.conclusion
1464
      ? [
1465
          {
1466
            at: String(journey.conclusion.generatedAt),
1467
            title:
1468
              journey.conclusion.lifecycle === 'provisional'
1469
                ? 'Provisional insight generated'
1470
                : 'Conclusion released',
1471
            detail: String(journey.conclusion.summary),
1472
            status: String(journey.conclusion.consensus),
1473
            tone:
1474
              journey.conclusion.lifecycle === 'provisional' ||
1475
              journey.conclusion.consensus === 'conflicting'
1476
                ? 'warn'
1477
                : 'good',
1478
          },
1479
        ]
1480
      : []),
1481
    ...(journey.receipt
1482
      ? [
1483
          {
1484
            at: timestamp(journey.receipt),
1485
            title: journey.receipt.provisional
1486
              ? 'Provisional outcome attested'
1487
              : 'Outcome receipt attested',
1488
            detail: journey.receipt.provisional
1489
              ? 'The available one-sided report was sealed without claiming peer agreement.'
1490
              : 'Contract result, commitment status, and evidence hashes were sealed.',
1491
            status: String(journey.receipt.outcome ?? 'signed'),
1492
            tone: journey.receipt.outcome === 'failure' ? 'danger' : 'good',
1493
          },
1494
        ]
1495
      : []),
1496
    ...(journey.eligibility
1497
      ? [
1498
          {
1499
            at: String(journey.eligibility.decidedAt),
1500
            title: journey.eligibility.eligible
1501
              ? 'Eligible history applied'
1502
              : 'History excluded from profile',
1503
            detail: `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% sample weight · ${humanize(journey.eligibility.contributionMode)}`,
1504
            status: journey.eligibility.eligible ? 'attested' : 'excluded',
1505
            tone: journey.eligibility.eligible ? 'good' : 'warn',
1506
          },
1507
        ]
1508
      : []),
1509
  ];
1510
  return steps.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
1511
}
1512
1513
function Agents({
1514
  data,
1515
  navigate,
1516
  refreshDashboard,
1517
  api,
1518
}: {
1519
  data: DashboardData;
1520
  navigate: (page: Page) => void;
1521
  refreshDashboard: () => Promise<void>;
1522
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1523
}) {
1524
  const [working, setWorking] = useState('');
1525
  const [error, setError] = useState('');
1526
  const saveAutomation = async (
1527
    agentId: string,
1528
    value: {
1529
      autoPublish: boolean;
1530
      autoAcceptPolicy: 'off' | 'safe_matching';
1531
      autoAcceptTaskCategories: string[];
1532
    },
1533
  ) => {
1534
    setWorking(agentId);
1535
    setError('');
1536
    try {
1537
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
1538
        method: 'PUT',
1539
        body: JSON.stringify(value),
1540
      });
1541
      await refreshDashboard();
1542
      return true;
1543
    } catch (reason) {
1544
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
1545
      return false;
1546
    } finally {
1547
      setWorking('');
1548
    }
1549
  };
1550
  const saveRuntime = async (agentId: string, endpoint: string) => {
1551
    setWorking(`runtime:${agentId}`);
1552
    setError('');
1553
    try {
1554
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
1555
        method: 'PUT',
1556
        body: JSON.stringify({ endpoint }),
1557
      });
1558
      await refreshDashboard();
1559
    } catch (reason) {
1560
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
1561
    } finally {
1562
      setWorking('');
1563
    }
1564
  };
1565
  const disableRuntime = async (agentId: string) => {
1566
    setWorking(`runtime:${agentId}`);
1567
    setError('');
1568
    try {
1569
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
1570
      await refreshDashboard();
1571
    } catch (reason) {
1572
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
1573
    } finally {
1574
      setWorking('');
1575
    }
1576
  };
1577
  const revokeAccessToken = async (agentId: string, tokenId: string) => {
1578
    setWorking(`token:${agentId}`);
1579
    setError('');
1580
    try {
1581
      await api(
1582
        `/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens/${encodeURIComponent(tokenId)}`,
1583
        { method: 'DELETE' },
1584
      );
1585
      await refreshDashboard();
1586
    } catch (reason) {
1587
      setError(reason instanceof Error ? reason.message : 'Could not revoke agent access token');
1588
    } finally {
1589
      setWorking('');
1590
    }
1591
  };
1592
  const deleteAgent = async (agent: Record<string, any>) => {
1593
    const agentId = String(agent.agentId);
1594
    if (
1595
      !window.confirm(
1596
        `Delete “${String(agent.name ?? agentId)}”?\n\nIts runtime, publication, presence, MCP binding and hosted temporary messages will be removed. Signed interaction and receipt history will be retained.`,
1597
      )
1598
    )
1599
      return;
1600
    setWorking(`delete:${agentId}`);
1601
    setError('');
1602
    try {
1603
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
1604
      await refreshDashboard();
1605
    } catch (reason) {
1606
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
1607
    } finally {
1608
      setWorking('');
1609
    }
1610
  };
1611
  return (
1612
    <>
1613
      <PageHead page="agents" action="Connect agent" onAction={() => navigate('connect')} />
1614
      <div className="notice">
1615
        <strong>Two explicit modes.</strong> Persistent runtimes talk directly over A2A. Temporary
1616
        chats use an OpenClasp-hosted endpoint with encrypted-at-rest history. There is no offline
1617
        relay for persistent runtimes.
1618
      </div>
1619
      {error && (
1620
        <div className="errorBar" role="alert">
1621
          {error}
1622
        </div>
1623
      )}
1624
      <section className="agentGrid">
1625
        {data.agents.length ? (
1626
          data.agents.map((agent) => (
1627
            <AgentCard
1628
              key={agent.agentId}
1629
              agent={agent}
1630
              projectName={
1631
                data.projects.find((project) => project.projectId === agent.projectId)?.name
1632
              }
1633
              published={data.publications.some(
1634
                (publication) => publication.agentId === agent.agentId && publication.published,
1635
              )}
1636
              working={working === agent.agentId}
1637
              onSave={(value) => saveAutomation(agent.agentId, value)}
1638
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
1639
              runtimeWorking={working === `runtime:${agent.agentId}`}
1640
              deleteWorking={working === `delete:${agent.agentId}`}
1641
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
1642
              onDisableRuntime={() => disableRuntime(agent.agentId)}
1643
              accessTokens={data.accessTokens.filter(
1644
                (token) =>
1645
                  token.agentId === agent.agentId &&
1646
                  !token.revokedAt &&
1647
                  Date.parse(String(token.expiresAt)) > Date.now(),
1648
              )}
1649
              accessTokenWorking={working === `token:${agent.agentId}`}
1650
              onRevokeAccessToken={(tokenId) => revokeAccessToken(agent.agentId, tokenId)}
1651
              onDelete={() => deleteAgent(agent)}
1652
            />
1653
          ))
1654
        ) : (
1655
          <Empty
1656
            title="No agents connected"
1657
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
1658
            action="Open connection guide"
1659
            onAction={() => navigate('connect')}
1660
          />
1661
        )}
1662
      </section>
1663
    </>
1664
  );
1665
}
1666
1667
function Invitations({
1668
  data,
1669
  refreshDashboard,
1670
  api,
1671
}: {
1672
  data: DashboardData;
1673
  refreshDashboard: () => Promise<void>;
1674
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1675
}) {
1676
  const [working, setWorking] = useState('');
1677
  const [error, setError] = useState('');
1678
  const ownedAgentIds = useMemo(
1679
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
1680
    [data.agents],
1681
  );
1682
  const incoming = data.federatedInteractions.filter(
1683
    (interaction) =>
1684
      interaction.status === 'pending' && ownedAgentIds.has(String(interaction.responderAgentId)),
1685
  );
1686
  const respond = async (interactionId: string, agentId: string, decision: 'accept' | 'reject') => {
1687
    setWorking(interactionId);
1688
    setError('');
1689
    try {
1690
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/respond`, {
1691
        method: 'POST',
1692
        body: JSON.stringify({ agentId, decision }),
1693
      });
1694
      await refreshDashboard();
1695
    } catch (reason) {
1696
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
1697
    } finally {
1698
      setWorking('');
1699
    }
1700
  };
1701
  if (!incoming.length && !data.federatedInteractions.length) return null;
1702
  return (
1703
    <Panel
1704
      title={incoming.length ? `Agent invitations (${incoming.length})` : 'Shared interactions'}
1705
      subtitle="Both accounts see the same immutable contract and acceptance state"
1706
    >
1707
      {error ? (
1708
        <div className="errorBar" role="alert">
1709
          {error}
1710
        </div>
1711
      ) : null}
1712
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
1713
        <article className="setupRequest" key={interaction.interactionId}>
1714
          <div>
1715
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
1716
            <small>
1717
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
1718
            </small>
1719
            <small>Contract: {String(interaction.termsHash).slice(0, 16)}…</small>
1720
            {data.liveSessions.find(
1721
              (session) => session.interactionId === interaction.interactionId,
1722
            ) ? (
1723
              <small className="autoAccepted">
1724
                Live session:{' '}
1725
                {
1726
                  data.liveSessions.find(
1727
                    (session) => session.interactionId === interaction.interactionId,
1728
                  )?.status
1729
                }
1730
              </small>
1731
            ) : null}
1732
            {interaction.status === 'active' &&
1733
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
1734
              'policy_auto_accept' ? (
1735
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
1736
            ) : null}
1737
          </div>
1738
          {interaction.status === 'pending' &&
1739
          ownedAgentIds.has(String(interaction.responderAgentId)) ? (
1740
            <div className="decisionButtons">
1741
              <button
1742
                className="secondary"
1743
                type="button"
1744
                disabled={working === interaction.interactionId}
1745
                onClick={() =>
1746
                  void respond(interaction.interactionId, interaction.responderAgentId, 'reject')
1747
                }
1748
              >
1749
                Reject
1750
              </button>
1751
              <button
1752
                className="primary"
1753
                type="button"
1754
                disabled={working === interaction.interactionId}
1755
                onClick={() =>
1756
                  void respond(interaction.interactionId, interaction.responderAgentId, 'accept')
1757
                }
1758
              >
1759
                {working === interaction.interactionId ? 'Working…' : 'Accept contract'}
1760
              </button>
1761
            </div>
1762
          ) : null}
1763
        </article>
1764
      ))}
1765
    </Panel>
1766
  );
1767
}
1768
1769
function Insights({ data }: { data: DashboardData }) {
1770
  const eligible = data.learningEligibility.filter((item) => item.eligible);
1771
  const pendingFeedback = data.feedbackRequests.filter((item) => item.status === 'pending');
1772
  const provisional = data.interactionConclusions.filter(
1773
    (item) => item.lifecycle === 'provisional',
1774
  );
1775
  return (
1776
    <>
1777
      <PageHead page="insights" />
1778
      <section className="insightPrinciple">
1779
        <div>
1780
          <span className="eyebrow">HOW TO READ THIS</span>
1781
          <h2>Reliability is contextual, not a leaderboard.</h2>
1782
          <p>
1783
            Every profile is tied to an agent version and task category. Scores reflect eligible
1784
            structured outcomes and decay as history gets older.
1785
          </p>
1786
        </div>
1787
        <div className="insightStats">
1788
          <span>
1789
            <strong>{data.profiles.length}</strong> contextual profiles
1790
          </span>
1791
          <span>
1792
            <strong>{eligible.length}</strong> eligible interactions
1793
          </span>
1794
          <span>
1795
            <strong>{pendingFeedback.length}</strong> feedback pending
1796
          </span>
1797
          <span>
1798
            <strong>{provisional.length}</strong> provisional signals
1799
          </span>
1800
        </div>
1801
      </section>
1802
      {!!provisional.length && (
1803
        <Panel
1804
          title="Provisional interaction signals"
1805
          subtitle="Available now, kept separate from corroborated reliability history"
1806
        >
1807
          <div className="learningDecisionList">
1808
            {provisional
1809
              .slice()
1810
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
1811
              .map((conclusion) => (
1812
                <article key={conclusion.conclusionId}>
1813
                  <StatusPill value="provisional" />
1814
                  <div>
1815
                    <strong>{conclusion.summary}</strong>
1816
                    <small>
1817
                      {Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer{' '}
1818
                      {humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()} ·{' '}
1819
                      {relativeTime(timestamp(conclusion))}
1820
                    </small>
1821
                    <p>
1822
                      Missing report from{' '}
1823
                      {(conclusion.missingReportAgentIds ?? []).join(', ') || 'the counterparty'}.
1824
                      The signal will be revised if more structured evidence arrives.
1825
                    </p>
1826
                  </div>
1827
                </article>
1828
              ))}
1829
          </div>
1830
        </Panel>
1831
      )}
1832
      <section className="insightGrid">
1833
        {data.profiles.length ? (
1834
          data.profiles.map((profile) => {
1835
            const deltas = data.profileDeltas
1836
              .filter(
1837
                (delta) =>
1838
                  delta.agentId === profile.agentId &&
1839
                  delta.agentVersion === profile.agentVersion &&
1840
                  delta.taskCategory === profile.taskCategory,
1841
              )
1842
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)));
1843
            return (
1844
              <Panel
1845
                key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
1846
                title={profile.agentId}
1847
                subtitle={`${humanize(profile.taskCategory)} · version ${profile.agentVersion}`}
1848
              >
1849
                <Profile profile={profile} expanded deltas={deltas} showHeader={false} />
1850
              </Panel>
1851
            );
1852
          })
1853
        ) : (
1854
          <Empty
1855
            title="Not enough verified evidence"
1856
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
1857
          />
1858
        )}
1859
      </section>
1860
      {!!data.learningEligibility.length && (
1861
        <Panel
1862
          title="Recent learning decisions"
1863
          subtitle="Why an interaction did or did not affect contextual history"
1864
        >
1865
          <div className="learningDecisionList">
1866
            {data.learningEligibility
1867
              .slice()
1868
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
1869
              .slice(0, 8)
1870
              .map((decision) => (
1871
                <article key={decision.decisionId}>
1872
                  <StatusPill value={decision.eligible ? 'eligible' : 'excluded'} />
1873
                  <div>
1874
                    <strong>{decision.interactionId}</strong>
1875
                    <small>
1876
                      {Math.round(Number(decision.sampleWeight ?? 0) * 100)}% weight ·{' '}
1877
                      {humanize(decision.contributionMode)} · {relativeTime(timestamp(decision))}
1878
                    </small>
1879
                    <p>{decision.reasons?.[0] ?? 'No reason supplied.'}</p>
1880
                  </div>
1881
                </article>
1882
              ))}
1883
          </div>
1884
        </Panel>
1885
      )}
1886
    </>
1887
  );
1888
}
1889
1890
function Connect({
1891
  data,
1892
  navigate,
1893
  refreshDashboard,
1894
  api,
1895
}: {
1896
  data: DashboardData;
1897
  navigate: (page: Page) => void;
1898
  refreshDashboard: () => Promise<void>;
1899
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1900
}) {
1901
  const endpoint = 'https://openclasp.vercel.app/mcp';
1902
  const [connectionType, setConnectionType] = useState<'interactive' | 'hosted'>('hosted');
1903
  const [copied, setCopied] = useState(false);
1904
  const [tokenCopied, setTokenCopied] = useState(false);
1905
  const [working, setWorking] = useState('');
1906
  const [decisionError, setDecisionError] = useState('');
1907
  const [providerError, setProviderError] = useState('');
1908
  const [providerForm, setProviderForm] = useState({
1909
    provider: 'botpress' as 'botpress' | 'custom',
1910
    agentName: '',
1911
    projectName: '',
1912
    description: '',
1913
    capabilities: '',
1914
    limitations: '',
1915
    expiresInDays: 365,
1916
  });
1917
  const [providerResult, setProviderResult] = useState<{
1918
    provider: 'botpress' | 'custom';
1919
    agent: { agentId: string; name: string; framework: string };
1920
    accessToken: { token: string; expiresAt: string };
1921
  }>();
1922
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
1923
  useEffect(() => {
1924
    const poll = () => void refreshDashboard().catch(() => undefined);
1925
    poll();
1926
    const timer = window.setInterval(poll, 5000);
1927
    return () => window.clearInterval(timer);
1928
  }, [refreshDashboard]);
1929
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
1930
    setWorking(requestId);
1931
    setDecisionError('');
1932
    try {
1933
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
1934
        method: 'POST',
1935
      });
1936
      await refreshDashboard();
1937
    } catch (reason) {
1938
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
1939
    } finally {
1940
      setWorking('');
1941
    }
1942
  };
1943
  const connectHostedProvider = async (event: React.FormEvent) => {
1944
    event.preventDefault();
1945
    setWorking('hosted-provider');
1946
    setProviderError('');
1947
    try {
1948
      const list = (value: string) =>
1949
        value
1950
          .split(/[,\n]/)
1951
          .map((item) => item.trim())
1952
          .filter(Boolean);
1953
      const created = (await api('/v0.1/provider-connections', {
1954
        method: 'POST',
1955
        body: JSON.stringify({
1956
          provider: providerForm.provider,
1957
          agentName: providerForm.agentName,
1958
          projectName: providerForm.projectName,
1959
          description: providerForm.description,
1960
          capabilities: list(providerForm.capabilities),
1961
          limitations: list(providerForm.limitations),
1962
          expiresInDays: providerForm.expiresInDays,
1963
        }),
1964
      })) as {
1965
        provider: 'botpress' | 'custom';
1966
        agent: { agentId: string; name: string; framework: string };
1967
        accessToken: { token: string; expiresAt: string };
1968
      };
1969
      setProviderResult(created);
1970
      await refreshDashboard().catch(() => undefined);
1971
    } catch (reason) {
1972
      setProviderError(reason instanceof Error ? reason.message : 'Could not create connection');
1973
    } finally {
1974
      setWorking('');
1975
    }
1976
  };
1977
  return (
1978
    <>
1979
      <PageHead page="connect" />
1980
      {pending.length > 0 && (
1981
        <section className="setupRequests">
1982
          <div>
1983
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
1984
            <h2>Approve agent setup</h2>
1985
            <p>
1986
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
1987
            </p>
1988
          </div>
1989
          {pending.map((request) => (
1990
            <article className="setupRequest" key={request.requestId}>
1991
              <div>
1992
                <strong>{request.agentName ?? 'Switch installation'}</strong>
1993
                <small>
1994
                  {request.projectName ?? request.existingAgentId} · {request.framework}
1995
                </small>
1996
                {!!request.capabilities?.length && (
1997
                  <div className="tags">
1998
                    {request.capabilities.slice(0, 5).map((capability: string) => (
1999
                      <span key={capability}>{capability}</span>
2000
                    ))}
2001
                  </div>
2002
                )}
2003
                {!!request.limitations?.length && (
2004
                  <small>Limitations: {request.limitations.join(' · ')}</small>
2005
                )}
2006
                <div className="automationPreview">
2007
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
2008
                  <span>
2009
                    {(request.agentMode ?? 'temporary_chat') === 'temporary_chat'
2010
                      ? 'Hosted temporary A2A · encrypted history'
2011
                      : 'Persistent runtime · direct A2A'}
2012
                  </span>
2013
                  <span>
2014
                    {request.autoAcceptPolicy === 'safe_matching'
2015
                      ? 'Auto-accept safe matches'
2016
                      : 'Review every request'}
2017
                  </span>
2018
                </div>
2019
              </div>
2020
              <div className="decisionButtons">
2021
                <button
2022
                  className="secondary"
2023
                  type="button"
2024
                  disabled={working === request.requestId}
2025
                  onClick={() => void decide(request.requestId, 'reject')}
2026
                >
2027
                  Reject
2028
                </button>
2029
                <button
2030
                  className="primary"
2031
                  type="button"
2032
                  disabled={working === request.requestId}
2033
                  onClick={() => void decide(request.requestId, 'approve')}
2034
                >
2035
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
2036
                </button>
2037
              </div>
2038
            </article>
2039
          ))}
2040
          {decisionError && (
2041
            <div className="loginError" role="alert">
2042
              {decisionError}
2043
            </div>
2044
          )}
2045
        </section>
2046
      )}
2047
      <div className="connectionTypePicker" role="tablist" aria-label="Agent connection type">
2048
        <button
2049
          type="button"
2050
          role="tab"
2051
          aria-selected={connectionType === 'hosted'}
2052
          className={connectionType === 'hosted' ? 'active' : ''}
2053
          onClick={() => setConnectionType('hosted')}
2054
        >
2055
          Hosted provider
2056
          <small>Botpress and similar platforms</small>
2057
        </button>
2058
        <button
2059
          type="button"
2060
          role="tab"
2061
          aria-selected={connectionType === 'interactive'}
2062
          className={connectionType === 'interactive' ? 'active' : ''}
2063
          onClick={() => setConnectionType('interactive')}
2064
        >
2065
          Interactive agent
2066
          <small>Codex, Cursor, and OAuth clients</small>
2067
        </button>
2068
      </div>
2069
      {connectionType === 'hosted' ? (
2070
        <section className="connectLayout providerConnectLayout">
2071
          <Panel title="Add a hosted agent" subtitle="Creates an isolated identity and credential">
2072
            {providerResult ? (
2073
              <div className="providerResult">
2074
                <div className="successBanner">
2075
                  <strong>{providerResult.agent.name} created</strong>
2076
                  <span>{providerResult.agent.agentId}</span>
2077
                </div>
2078
                <label>
2079
                  <span>MCP URL</span>
2080
                  <code>{endpoint}</code>
2081
                </label>
2082
                <label>
2083
                  <span>Agent token — copy it now; it will not be shown again</span>
2084
                  <code>{providerResult.accessToken.token}</code>
2085
                </label>
2086
                <small>
2087
                  This agent-bound token authorizes MCP and automatic runtime registration. It
2088
                  cannot connect or manage another agent.
2089
                </small>
2090
                <div className="agentActions">
2091
                  <button
2092
                    className="secondary"
2093
                    type="button"
2094
                    onClick={() => {
2095
                      void navigator.clipboard.writeText(providerResult.accessToken.token);
2096
                      setTokenCopied(true);
2097
                      window.setTimeout(() => setTokenCopied(false), 2000);
2098
                    }}
2099
                  >
2100
                    {tokenCopied ? 'Copied' : 'Copy token'}
2101
                  </button>
2102
                  <button className="primary" type="button" onClick={() => navigate('agents')}>
2103
                    View agent
2104
                  </button>
2105
                </div>
2106
              </div>
2107
            ) : (
2108
              <form
2109
                className="providerForm"
2110
                onSubmit={(event) => void connectHostedProvider(event)}
2111
              >
2112
                <label>
2113
                  <span>Provider</span>
2114
                  <select
2115
                    value={providerForm.provider}
2116
                    onChange={(event) =>
2117
                      setProviderForm((value) => ({
2118
                        ...value,
2119
                        provider: event.target.value as 'botpress' | 'custom',
2120
                      }))
2121
                    }
2122
                  >
2123
                    <option value="botpress">Botpress</option>
2124
                    <option value="custom">Custom / self-hosted</option>
2125
                  </select>
2126
                </label>
2127
                <label>
2128
                  <span>Agent name</span>
2129
                  <input
2130
                    required
2131
                    maxLength={100}
2132
                    value={providerForm.agentName}
2133
                    onChange={(event) =>
2134
                      setProviderForm((value) => ({ ...value, agentName: event.target.value }))
2135
                    }
2136
                    placeholder="Recruiting agent"
2137
                  />
2138
                </label>
2139
                <label>
2140
                  <span>Project</span>
2141
                  <input
2142
                    required
2143
                    maxLength={100}
2144
                    value={providerForm.projectName}
2145
                    onChange={(event) =>
2146
                      setProviderForm((value) => ({ ...value, projectName: event.target.value }))
2147
                    }
2148
                    placeholder="Recruiting"
2149
                  />
2150
                </label>
2151
                <label className="fullWidth">
2152
                  <span>Purpose</span>
2153
                  <textarea
2154
                    required
2155
                    maxLength={500}
2156
                    value={providerForm.description}
2157
                    onChange={(event) =>
2158
                      setProviderForm((value) => ({ ...value, description: event.target.value }))
2159
                    }
2160
                    placeholder="Matches suitable candidates with open roles"
2161
                  />
2162
                </label>
2163
                <label>
2164
                  <span>Capabilities, comma-separated</span>
2165
                  <input
2166
                    required
2167
                    value={providerForm.capabilities}
2168
                    onChange={(event) =>
2169
                      setProviderForm((value) => ({ ...value, capabilities: event.target.value }))
2170
                    }
2171
                    placeholder="candidate matching, interview coordination"
2172
                  />
2173
                </label>
2174
                <label>
2175
                  <span>Limitations, comma-separated</span>
2176
                  <input
2177
                    value={providerForm.limitations}
2178
                    onChange={(event) =>
2179
                      setProviderForm((value) => ({ ...value, limitations: event.target.value }))
2180
                    }
2181
                    placeholder="no final hiring decisions"
2182
                  />
2183
                </label>
2184
                <label>
2185
                  <span>Credential expiry</span>
2186
                  <select
2187
                    value={providerForm.expiresInDays}
2188
                    onChange={(event) =>
2189
                      setProviderForm((value) => ({
2190
                        ...value,
2191
                        expiresInDays: Number(event.target.value),
2192
                      }))
2193
                    }
2194
                  >
2195
                    <option value={30}>30 days</option>
2196
                    <option value={90}>90 days</option>
2197
                    <option value={365}>1 year</option>
2198
                  </select>
2199
                </label>
2200
                <div className="providerSubmit fullWidth">
2201
                  <p>This does not reuse or modify your Codex agent.</p>
2202
                  <button
2203
                    className="primary"
2204
                    type="submit"
2205
                    disabled={working === 'hosted-provider'}
2206
                  >
2207
                    {working === 'hosted-provider' ? 'Creating…' : 'Create agent credentials'}
2208
                  </button>
2209
                </div>
2210
                {providerError ? (
2211
                  <div className="loginError fullWidth" role="alert">
2212
                    {providerError}
2213
                  </div>
2214
                ) : null}
2215
              </form>
2216
            )}
2217
          </Panel>
2218
          <Panel
2219
            title={
2220
              providerForm.provider === 'botpress' ? 'Configure Botpress' : 'Deploy the sidecar'
2221
            }
2222
            subtitle={
2223
              providerForm.provider === 'botpress'
2224
                ? 'MCP and inbound runtime are separate connections'
2225
                : 'Runs beside an agent on any cloud'
2226
            }
2227
          >
2228
            {providerForm.provider === 'botpress' ? (
2229
              <>
2230
                <div className="connectSteps">
2231
                  <div className="connectStep">
2232
                    <b>1</b>
2233
                    <span>Add the displayed MCP URL with Bearer authentication.</span>
2234
                  </div>
2235
                  <div className="connectStep">
2236
                    <b>2</b>
2237
                    <span>
2238
                      Paste the generated <code>oc_at_…</code> agent token.
2239
                    </span>
2240
                  </div>
2241
                  <div className="connectStep">
2242
                    <b>3</b>
2243
                    <span>Install the OpenClasp Botpress runtime integration for inbound A2A.</span>
2244
                  </div>
2245
                </div>
2246
                <div className="notice providerNotice">
2247
                  MCP proves outbound tool access only. Autonomous runtime becomes connected only
2248
                  after the Botpress integration registers its webhook successfully.
2249
                </div>
2250
              </>
2251
            ) : (
2252
              <>
2253
                <div className="connectSteps">
2254
                  <div className="connectStep">
2255
                    <b>1</b>
2256
                    <span>
2257
                      Deploy <code>Dockerfile.sidecar</code> beside the agent.
2258
                    </span>
2259
                  </div>
2260
                  <div className="connectStep">
2261
                    <b>2</b>
2262
                    <span>
2263
                      Set <code>OPENCLASP_AGENT_TOKEN</code>, <code>OPENCLASP_RUNTIME_URL</code>,
2264
                      and <code>AGENT_ADAPTER_URL</code>.
2265
                    </span>
2266
                  </div>
2267
                  <div className="connectStep">
2268
                    <b>3</b>
2269
                    <span>
2270
                      The sidecar verifies and registers itself; no endpoint paste is needed.
2271
                    </span>
2272
                  </div>
2273
                </div>
2274
                <div className="notice providerNotice">
2275
                  The application implements three internal POST hooks: session-offer,
2276
                  session-activated, and message. Agent-to-agent content remains off OpenClasp.
2277
                </div>
2278
              </>
2279
            )}
2280
          </Panel>
2281
        </section>
2282
      ) : (
2283
        <section className="connectLayout">
2284
          <Panel title="Connect with OAuth" subtitle="For interactive MCP clients">
2285
            <div className="endpoint">
2286
              <code>{endpoint}</code>
2287
              <button
2288
                type="button"
2289
                onClick={() => {
2290
                  void navigator.clipboard.writeText(endpoint);
2291
                  setCopied(true);
2292
                  window.setTimeout(() => setCopied(false), 2000);
2293
                }}
2294
              >
2295
                {copied ? 'Copied' : 'Copy'}
2296
              </button>
2297
            </div>
2298
            <div className="connectSteps">
2299
              <div className="connectStep">
2300
                <b>1</b>
2301
                <span>Add this MCP URL and complete OAuth.</span>
2302
              </div>
2303
              <div className="connectStep">
2304
                <b>2</b>
2305
                <span>
2306
                  Tell the agent: <code>Set yourself up on OpenClasp</code>.
2307
                </span>
2308
              </div>
2309
              <div className="connectStep">
2310
                <b>3</b>
2311
                <span>Approve the proposed identity and mode here.</span>
2312
              </div>
2313
            </div>
2314
          </Panel>
2315
          <Panel title="Interactive clients" subtitle="Best for temporary user-driven sessions">
2316
            <ul className="checkList">
2317
              <li>OAuth opens in the browser</li>
2318
              <li>Each installation is explicitly approved</li>
2319
              <li>Temporary chats receive hosted A2A history</li>
2320
              <li>Persistent runtimes can switch to direct A2A</li>
2321
            </ul>
2322
          </Panel>
2323
        </section>
2324
      )}
2325
    </>
2326
  );
2327
}
2328
2329
function SettingsPage({
2330
  settings,
2331
  setSettings,
2332
  api,
2333
}: {
2334
  settings: Settings;
2335
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
2336
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2337
}) {
2338
  const [saving, setSaving] = useState(false);
2339
  const [saved, setSaved] = useState(false);
2340
  const [error, setError] = useState('');
2341
  const save = useCallback(async () => {
2342
    setSaving(true);
2343
    setSaved(false);
2344
    setError('');
2345
    try {
2346
      const updated = (await api('/v0.1/settings', {
2347
        method: 'PUT',
2348
        body: JSON.stringify(settings),
2349
      })) as Settings;
2350
      setSettings(updated);
2351
      setSaved(true);
2352
    } catch (reason) {
2353
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
2354
    } finally {
2355
      setSaving(false);
2356
    }
2357
  }, [api, settings, setSettings]);
2358
  return (
2359
    <>
2360
      <PageHead page="settings" />
2361
      <section className="settingsCard">
2362
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
2363
          <input
2364
            value={settings.displayName}
2365
            onChange={(event) =>
2366
              setSettings((value) => ({ ...value, displayName: event.target.value }))
2367
            }
2368
            placeholder="Team or operator name"
2369
          />
2370
        </Setting>
2371
        <Setting
2372
          label="Network contribution"
2373
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
2374
        >
2375
          <Toggle
2376
            checked={settings.contributionEnabled}
2377
            label="Network contribution"
2378
            onChange={(checked) =>
2379
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
2380
            }
2381
          />
2382
        </Setting>
2383
        <Setting
2384
          label="Evidence sharing"
2385
          description="Controls when permitted evidence references may be shared with interaction participants."
2386
        >
2387
          <select
2388
            value={settings.evidenceSharing}
2389
            onChange={(event) =>
2390
              setSettings((value) => ({
2391
                ...value,
2392
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
2393
              }))
2394
            }
2395
          >
2396
            <option value="never">Never</option>
2397
            <option value="ask">Ask every time</option>
2398
            <option value="contract_only">When contract permits</option>
2399
          </select>
2400
        </Setting>
2401
        <Setting
2402
          label="Structured record retention"
2403
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
2404
        >
2405
          <select
2406
            value={settings.retentionDays}
2407
            onChange={(event) =>
2408
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
2409
            }
2410
          >
2411
            <option value={7}>7 days</option>
2412
            <option value={30}>30 days</option>
2413
            <option value={90}>90 days</option>
2414
            <option value={365}>1 year</option>
2415
            <option value={0}>No automatic deletion</option>
2416
          </select>
2417
        </Setting>
2418
        <Setting
2419
          label="Raw agent messages"
2420
          description="Persistent runtimes remain direct and are not stored. Temporary chat messages pass through OpenClasp and are encrypted at rest for 30 days."
2421
        >
2422
          <span className="locked">MODE DEPENDENT</span>
2423
        </Setting>
2424
        <div className="saveRow">
2425
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
2426
            {saving ? 'Saving…' : 'Save settings'}
2427
          </button>
2428
          {saved && <span role="status">Saved</span>}
2429
          {error && (
2430
            <span className="loginError" role="alert">
2431
              {error}
2432
            </span>
2433
          )}
2434
        </div>
2435
      </section>
2436
    </>
2437
  );
2438
}
2439
2440
function PageHead({
2441
  page,
2442
  action,
2443
  onAction,
2444
}: {
2445
  page: Page;
2446
  action?: string;
2447
  onAction?: () => void;
2448
}) {
2449
  const meta = pageMeta[page];
2450
  return (
2451
    <header className="pageHead">
2452
      <div>
2453
        <p className="eyebrow">{meta.eyebrow}</p>
2454
        <h1>{meta.title}</h1>
2455
        <p className="lede">{meta.lede}</p>
2456
      </div>
2457
      {action && (
2458
        <button className="primary" type="button" onClick={onAction}>
2459
          {action}
2460
        </button>
2461
      )}
2462
    </header>
2463
  );
2464
}
2465
function Nav({
2466
  page,
2467
  active,
2468
  onClick,
2469
  label,
2470
  icon,
2471
  badge = 0,
2472
}: {
2473
  page: Page;
2474
  active: Page;
2475
  onClick: (page: Page) => void;
2476
  label: string;
2477
  icon: IconName;
2478
  badge?: number;
2479
}) {
2480
  return (
2481
    <button
2482
      className={active === page ? 'active' : ''}
2483
      type="button"
2484
      aria-current={active === page ? 'page' : undefined}
2485
      onClick={() => onClick(page)}
2486
    >
2487
      <Icon name={icon} />
2488
      {label}
2489
      {badge > 0 ? <span className="navBadge">{badge}</span> : null}
2490
    </button>
2491
  );
2492
}
2493
function Metric({
2494
  label,
2495
  value,
2496
  note,
2497
  warn,
2498
}: {
2499
  label: string;
2500
  value: number;
2501
  note: string;
2502
  warn?: boolean;
2503
}) {
2504
  return (
2505
    <article className={warn ? 'metric warn' : 'metric'}>
2506
      <span>{label}</span>
2507
      <strong>{value}</strong>
2508
      <small>{note}</small>
2509
    </article>
2510
  );
2511
}
2512
function Panel({
2513
  title,
2514
  subtitle,
2515
  children,
2516
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
2517
  return (
2518
    <section className="panel">
2519
      <div className="panelHead">
2520
        <div>
2521
          <h2>{title}</h2>
2522
          <p>{subtitle}</p>
2523
        </div>
2524
      </div>
2525
      {children}
2526
    </section>
2527
  );
2528
}
2529
function Timeline({ events }: { events: Record<string, any>[] }) {
2530
  return events.length ? (
2531
    <div className="timeline">
2532
      {events.map((event) => (
2533
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
2534
      ))}
2535
    </div>
2536
  ) : (
2537
    <Empty
2538
      title="Nothing recorded yet"
2539
      text="Activity appears after your connected agents submit signed structured events."
2540
    />
2541
  );
2542
}
2543
function HistoryRow({ item }: { item: Record<string, any> }) {
2544
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
2545
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
2546
  return (
2547
    <article className="historyRow">
2548
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
2549
      <div>
2550
        <strong>{kind}</strong>
2551
        <small>
2552
          {id} · {new Date(timestamp(item)).toLocaleString()}
2553
        </small>
2554
      </div>
2555
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
2556
    </article>
2557
  );
2558
}
2559
function AgentCard({
2560
  agent,
2561
  projectName,
2562
  published,
2563
  working,
2564
  onSave,
2565
  runtime,
2566
  runtimeWorking,
2567
  deleteWorking,
2568
  onRuntime,
2569
  onDisableRuntime,
2570
  accessTokens,
2571
  accessTokenWorking,
2572
  onRevokeAccessToken,
2573
  onDelete,
2574
}: {
2575
  agent: Record<string, any>;
2576
  projectName?: string;
2577
  published: boolean;
2578
  working: boolean;
2579
  onSave: (value: {
2580
    autoPublish: boolean;
2581
    autoAcceptPolicy: 'off' | 'safe_matching';
2582
    autoAcceptTaskCategories: string[];
2583
  }) => Promise<boolean>;
2584
  runtime: Record<string, any> | undefined;
2585
  runtimeWorking: boolean;
2586
  deleteWorking: boolean;
2587
  onRuntime: (endpoint: string) => void;
2588
  onDisableRuntime: () => void;
2589
  accessTokens: Record<string, any>[];
2590
  accessTokenWorking: boolean;
2591
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
2592
  onDelete: () => void;
2593
}) {
2594
  const [editing, setEditing] = useState(false);
2595
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
2596
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
2597
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
2598
  );
2599
  const [categories, setCategories] = useState<string>(
2600
    (agent.autoAcceptTaskCategories?.length
2601
      ? agent.autoAcceptTaskCategories
2602
      : (agent.capabilities ?? [])
2603
    ).join(', '),
2604
  );
2605
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
2606
  const mode = agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
2607
  const temporary = mode === 'temporary_chat';
2608
  const ready = published && (temporary || runtime?.status === 'verified');
2609
  const online = agent.presence?.status === 'online';
2610
  const providerConnected = accessTokens.length > 0;
2611
  const endpoint = String(
2612
    temporary
2613
      ? `/a2a/temporary/${encodeURIComponent(agent.agentId)}`
2614
      : (runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first'),
2615
  );
2616
  const identityLabel = agent.revoked
2617
    ? 'REVOKED'
2618
    : agent.identityMode === 'owner_managed'
2619
      ? 'OWNER VERIFIED'
2620
      : agent.identityMode === 'oauth_installation'
2621
        ? 'AUTHENTICATED'
2622
        : 'VERIFIED';
2623
  return (
2624
    <article className="agentCard">
2625
      <div className="agentTop">
2626
        <span className="agentGlyph">
2627
          <Icon name="agents" />
2628
        </span>
2629
        <div className="agentBadges">
2630
          <b className={online && temporary ? 'onlineBadge' : 'offlineBadge'}>
2631
            {temporary
2632
              ? online
2633
                ? 'CHAT ACTIVE'
2634
                : 'CHAT IDLE'
2635
              : runtime?.status === 'verified'
2636
                ? 'ENDPOINT VERIFIED'
2637
                : providerConnected
2638
                  ? 'TOKEN ONLY'
2639
                  : 'ENDPOINT MISSING'}
2640
          </b>
2641
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
2642
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
2643
        </div>
2644
      </div>
2645
      <h2>{agent.name ?? agent.agentId}</h2>
2646
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
2647
      <div className="tags">
2648
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
2649
          <span key={capability}>{capability}</span>
2650
        ))}
2651
      </div>
2652
      <small>
2653
        {agent.framework ? `${agent.framework} · ` : ''}
2654
        {agent.identityMode === 'owner_managed'
2655
          ? 'Owner-created · '
2656
          : agent.identityMode === 'oauth_installation'
2657
            ? 'OAuth-bound · '
2658
            : 'Ed25519 · '}
2659
        Created {new Date(agent.createdAt).toLocaleDateString()}
2660
      </small>
2661
      <small>
2662
        {agent.presence?.lastSeenAt
2663
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
2664
          : 'No agent activity recorded yet'}
2665
      </small>
2666
      {published ? (
2667
        <a
2668
          href={`/agents/${encodeURIComponent(agent.agentId)}/card.json`}
2669
          target="_blank"
2670
          rel="noreferrer"
2671
        >
2672
          Public Agent Card
2673
        </a>
2674
      ) : null}
2675
      <div className="automationSummary">
2676
        <span>{published ? 'Public discovery' : 'Private'}</span>
2677
        <span>{temporary ? 'Hosted temporary A2A' : 'Direct agent-owned A2A'}</span>
2678
        <span>
2679
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
2680
        </span>
2681
      </div>
2682
      <div className="runtimeBox">
2683
        <div>
2684
          <strong>{temporary ? 'Temporary chat endpoint' : 'Autonomous runtime'}</strong>
2685
          <span
2686
            className={
2687
              temporary || runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'
2688
            }
2689
          >
2690
            {temporary
2691
              ? 'HOSTED BY OPENCLASP'
2692
              : runtime?.status === 'verified'
2693
                ? 'VERIFIED'
2694
                : 'NOT CONNECTED'}
2695
          </span>
2696
        </div>
2697
        <p>
2698
          {temporary
2699
            ? 'OpenClasp receives A2A for this temporary identity and encrypts its history at rest. Connect a runtime below to switch to direct A2A.'
2700
            : runtime?.status === 'verified'
2701
              ? 'The agent runtime registered itself successfully. Live message bodies travel directly between agents.'
2702
              : providerConnected
2703
                ? 'MCP is connected, but inbound A2A is not. Install the provider connector or deploy the sidecar; it registers this endpoint automatically.'
2704
                : 'Install a provider connector, deploy the sidecar, or register a native A2A endpoint.'}
2705
        </p>
2706
        <input
2707
          type="url"
2708
          value={runtimeEndpoint}
2709
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
2710
          placeholder="https://agent.example.com/openclasp"
2711
        />
2712
        <small>
2713
          Manual registration is only for a runtime that already implements OpenClasp A2A.
2714
        </small>
2715
        <div className="agentActions">
2716
          {runtime?.status === 'verified' ? (
2717
            <button
2718
              className="secondary"
2719
              type="button"
2720
              disabled={runtimeWorking}
2721
              onClick={onDisableRuntime}
2722
            >
2723
              Disable runtime
2724
            </button>
2725
          ) : null}
2726
          <button
2727
            className="primary"
2728
            type="button"
2729
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
2730
            onClick={() => onRuntime(runtimeEndpoint.trim())}
2731
          >
2732
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
2733
          </button>
2734
        </div>
2735
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
2736
      </div>
2737
      {accessTokens.length ? (
2738
        <div className="accessTokenBox">
2739
          <div className="accessTokenHead">
2740
            <div>
2741
              <strong>Provider connections</strong>
2742
              <span>{accessTokens.length} ACTIVE</span>
2743
            </div>
2744
            <p>Created through Connect. Revoke a provider here if its credential is exposed.</p>
2745
          </div>
2746
          <div className="tokenList">
2747
            {accessTokens.map((token) => (
2748
              <div key={token.tokenId}>
2749
                <span>
2750
                  <strong>{token.name}</strong>
2751
                  <small>
2752
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
2753
                    {new Date(token.expiresAt).toLocaleDateString()}
2754
                    {token.lastUsedAt
2755
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
2756
                      : ' · never used'}
2757
                  </small>
2758
                </span>
2759
                <button
2760
                  className="secondary dangerButton"
2761
                  type="button"
2762
                  disabled={accessTokenWorking}
2763
                  onClick={() => {
2764
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
2765
                      void onRevokeAccessToken(String(token.tokenId));
2766
                  }}
2767
                >
2768
                  Revoke
2769
                </button>
2770
              </div>
2771
            ))}
2772
          </div>
2773
        </div>
2774
      ) : null}
2775
      {editing ? (
2776
        <div className="automationForm">
2777
          <label>
2778
            <span>
2779
              {temporary ? 'OpenClasp temporary A2A endpoint' : 'Agent-owned A2A endpoint'}
2780
            </span>
2781
            <input type="url" value={endpoint} readOnly />
2782
          </label>
2783
          <label>
2784
            <span>Invitation policy</span>
2785
            <select
2786
              value={autoAcceptPolicy}
2787
              onChange={(event) =>
2788
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
2789
              }
2790
            >
2791
              <option value="safe_matching">Auto-accept safe matches</option>
2792
              <option value="off">Review every request</option>
2793
            </select>
2794
          </label>
2795
          <label>
2796
            <span>Safe task categories</span>
2797
            <input
2798
              value={categories}
2799
              onChange={(event) => setCategories(event.target.value)}
2800
              placeholder="research, planning, coding"
2801
            />
2802
          </label>
2803
          <label className="checkControl">
2804
            <input
2805
              type="checkbox"
2806
              checked={autoPublish}
2807
              onChange={(event) => setAutoPublish(event.target.checked)}
2808
            />
2809
            <span>Publish and keep Agent Card updated</span>
2810
          </label>
2811
          <div className="agentActions">
2812
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
2813
              Cancel
2814
            </button>
2815
            <button
2816
              className="primary"
2817
              type="button"
2818
              disabled={working || agent.status === 'revoked'}
2819
              onClick={() =>
2820
                void onSave({
2821
                  autoPublish,
2822
                  autoAcceptPolicy,
2823
                  autoAcceptTaskCategories: categories
2824
                    .split(',')
2825
                    .map((value) => value.trim())
2826
                    .filter(Boolean),
2827
                }).then((ok) => {
2828
                  if (ok) setEditing(false);
2829
                })
2830
              }
2831
            >
2832
              {working ? 'Saving…' : 'Save automation'}
2833
            </button>
2834
          </div>
2835
        </div>
2836
      ) : (
2837
        <div className="agentActions agentManagementActions">
2838
          <button
2839
            className="secondary dangerButton"
2840
            type="button"
2841
            disabled={working || runtimeWorking || deleteWorking}
2842
            onClick={onDelete}
2843
          >
2844
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
2845
          </button>
2846
          <button
2847
            className="secondary"
2848
            type="button"
2849
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
2850
            onClick={() => setEditing(true)}
2851
          >
2852
            Configure automation
2853
          </button>
2854
        </div>
2855
      )}
2856
    </article>
2857
  );
2858
}
2859
function Profile({
2860
  profile,
2861
  expanded,
2862
  deltas = [],
2863
  showHeader = true,
2864
}: {
2865
  profile: Record<string, any>;
2866
  expanded?: boolean;
2867
  deltas?: Record<string, any>[];
2868
  showHeader?: boolean;
2869
}) {
2870
  const dimensions = [
2871
    ['Completion', profile.completion],
2872
    ['Acceptance', profile.acceptance],
2873
    ['Specification', profile.specification],
2874
    ['Scope', profile.scope],
2875
    ['Evidence', profile.evidence],
2876
    ...(expanded
2877
      ? [
2878
          ['Communication', profile.communication],
2879
          ['Deadline', profile.deadline],
2880
          ['Dispute-free', 1 - Number(profile.disputes ?? 0)],
2881
        ]
2882
      : []),
2883
  ] as [string, number][];
2884
  const latestDelta = deltas[0];
2885
  return (
2886
    <article className="profile">
2887
      <div className={showHeader ? 'profileHead' : 'profileHead profileHeadCompact'}>
2888
        {showHeader ? (
2889
          <div>
2890
            <strong>{profile.agentId}</strong>
2891
            <small>
2892
              {humanize(profile.taskCategory)} · version {profile.agentVersion ?? 'unknown'}
2893
            </small>
2894
          </div>
2895
        ) : (
2896
          <small>
2897
            Effective evidence{' '}
2898
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
2899
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
2900
          </small>
2901
        )}
2902
        <span className="sampleBadge">
2903
          {profile.sampleSize ?? 0} outcome{Number(profile.sampleSize) === 1 ? '' : 's'}
2904
        </span>
2905
      </div>
2906
      {showHeader ? (
2907
        <div className="profileEvidenceLine">
2908
          <small>
2909
            Effective evidence{' '}
2910
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
2911
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
2912
          </small>
2913
        </div>
2914
      ) : null}
2915
      {dimensions.map(([label, value]) => (
2916
        <Meter key={label} label={label} value={Number(value ?? 0.5)} />
2917
      ))}
2918
      {latestDelta ? (
2919
        <div className="profileDelta">
2920
          <span>Latest evidence impact</span>
2921
          <div>
2922
            {Object.entries(latestDelta.dimensionDeltas ?? {})
2923
              .filter(([, value]) => Math.abs(Number(value)) >= 0.001)
2924
              .slice(0, 5)
2925
              .map(([dimension, value]) => (
2926
                <b className={Number(value) >= 0 ? 'positive' : 'negative'} key={dimension}>
2927
                  {humanize(dimension)} {Number(value) >= 0 ? '+' : ''}
2928
                  {(Number(value) * 100).toFixed(1)}
2929
                </b>
2930
              ))}
2931
          </div>
2932
        </div>
2933
      ) : null}
2934
    </article>
2935
  );
2936
}
2937
function Meter({ label, value }: { label: string; value: number }) {
2938
  const bounded = Math.max(0, Math.min(1, value));
2939
  return (
2940
    <div className="meter">
2941
      <span>{label}</span>
2942
      <i>
2943
        <b style={{ width: `${Math.round(bounded * 100)}%` }} />
2944
      </i>
2945
      <strong>{Math.round(bounded * 100)}%</strong>
2946
    </div>
2947
  );
2948
}
2949
function Empty({
2950
  title,
2951
  text,
2952
  action,
2953
  onAction,
2954
}: {
2955
  title: string;
2956
  text: string;
2957
  action?: string;
2958
  onAction?: () => void;
2959
}) {
2960
  return (
2961
    <div className="empty">
2962
      <Icon name="agents" />
2963
      <strong>{title}</strong>
2964
      <p>{text}</p>
2965
      {action && (
2966
        <button className="secondary" type="button" onClick={onAction}>
2967
          {action}
2968
        </button>
2969
      )}
2970
    </div>
2971
  );
2972
}
2973
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
2974
  return (
2975
    <button className="textButton" type="button" onClick={onClick}>
2976
      {children}
2977
    </button>
2978
  );
2979
}
2980
function Setting({
2981
  label,
2982
  description,
2983
  children,
2984
}: React.PropsWithChildren<{ label: string; description: string }>) {
2985
  return (
2986
    <div className="setting">
2987
      <div>
2988
        <strong>{label}</strong>
2989
        <p>{description}</p>
2990
      </div>
2991
      <div>{children}</div>
2992
    </div>
2993
  );
2994
}
2995
function Toggle({
2996
  checked,
2997
  onChange,
2998
  label,
2999
}: {
3000
  checked: boolean;
3001
  onChange: (checked: boolean) => void;
3002
  label?: string;
3003
}) {
3004
  return (
3005
    <button
3006
      className={checked ? 'toggle on' : 'toggle'}
3007
      type="button"
3008
      role="switch"
3009
      aria-checked={checked}
3010
      aria-label={label}
3011
      onClick={() => onChange(!checked)}
3012
    >
3013
      <span />
3014
    </button>
3015
  );
3016
}
3017
function Loading({ compact }: { compact?: boolean }) {
3018
  return (
3019
    <div className={compact ? 'loading compact' : 'loading'}>
3020
      <span className="spinner" aria-hidden="true" />
3021
      <p>Verifying session…</p>
3022
    </div>
3023
  );
3024
}
3025
3026
type IconName =
3027
  'home' | 'history' | 'agents' | 'insights' | 'connect' | 'settings' | 'sun' | 'moon';
3028
3029
function Icon({ name }: { name: IconName }) {
3030
  return (
3031
    <svg
3032
      viewBox="0 0 24 24"
3033
      fill="none"
3034
      stroke="currentColor"
3035
      strokeWidth="1.8"
3036
      strokeLinecap="round"
3037
      strokeLinejoin="round"
3038
      aria-hidden="true"
3039
    >
3040
      {name === 'home' && <path d="M4 11 12 4l8 7v9a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z" />}
3041
      {name === 'history' && (
3042
        <>
3043
          <path d="M4 12a8 8 0 1 0 2.2-5.5" />
3044
          <path d="M4 4v5h5" />
3045
          <path d="M12 8v5l3 2" />
3046
        </>
3047
      )}
3048
      {name === 'agents' && (
3049
        <>
3050
          <path d="M8 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
3051
          <path d="M16 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6" />
3052
          <path d="M4 20v-1a4 4 0 0 1 4-4h1" />
3053
          <path d="M20 20v-1a4 4 0 0 0-4-4h-1" />
3054
        </>
3055
      )}
3056
      {name === 'insights' && (
3057
        <>
3058
          <path d="M4 19h16" />
3059
          <path d="M7 16V9" />
3060
          <path d="M12 16V5" />
3061
          <path d="M17 16v-6" />
3062
        </>
3063
      )}
3064
      {name === 'connect' && (
3065
        <>
3066
          <path d="M12 5v14" />
3067
          <path d="M5 12h14" />
3068
        </>
3069
      )}
3070
      {name === 'settings' && (
3071
        <>
3072
          <circle cx="12" cy="12" r="3" />
3073
          <path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M16.9 16.9l1.5 1.5M5.6 18.4l1.4-1.4M16.9 7.1l1.5-1.5" />
3074
        </>
3075
      )}
3076
      {name === 'sun' && (
3077
        <>
3078
          <circle cx="12" cy="12" r="4" />
3079
          <path d="M12 3v2M12 19v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M3 12h2M19 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
3080
        </>
3081
      )}
3082
      {name === 'moon' && <path d="M16 13a6 6 0 0 1-7.8-7.8A7 7 0 1 0 16 13" />}
3083
    </svg>
3084
  );
3085
}
3086
3087
function GoogleMark() {
3088
  return (
3089
    <svg viewBox="0 0 24 24" aria-hidden="true">
3090
      <path
3091
        fill="#4285F4"
3092
        d="M22.6 12.2c0-.8-.1-1.6-.2-2.3H12v4.4h5.9c-.3 1.4-1 2.6-2.2 3.4v2.8h3.6c2.1-1.9 3.3-4.8 3.3-8.3"
3093
      />
3094
      <path
3095
        fill="#34A853"
3096
        d="M12 23c3 0 5.5-1 7.3-2.7l-3.6-2.8c-1 .7-2.3 1.1-3.7 1.1-2.8 0-5.2-1.9-6.1-4.4H2.2v2.9C4 20.5 7.7 23 12 23"
3097
      />
3098
      <path
3099
        fill="#FBBC05"
3100
        d="M5.9 14.2c-.2-.7-.4-1.4-.4-2.2s.1-1.5.4-2.2V7H2.2C1.4 8.6 1 10.3 1 12s.4 3.4 1.2 5z"
3101
      />
3102
      <path
3103
        fill="#EA4335"
3104
        d="M12 4.8c1.6 0 3.1.6 4.2 1.7l3.2-3.2C17.5 1.5 15 0.5 12 .5 7.7.5 4 3 2.2 7l3.7 2.9C6.8 6.7 9.2 4.8 12 4.8"
3105
      />
3106
    </svg>
3107
  );
3108
}
3109
3110
function GitHubMark() {
3111
  return (
3112
    <svg viewBox="0 0 24 24" aria-hidden="true">
3113
      <path
3114
        fill="currentColor"
3115
        d="M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.7c-2.8.6-3.4-1.2-3.4-1.2-.5-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.4 1.1 3 .8.1-.6.4-1.1.6-1.3-2.2-.3-4.6-1.1-4.6-5a3.9 3.9 0 0 1 1-2.7 3.6 3.6 0 0 1 .1-2.6s.8-.3 2.7 1a9.3 9.3 0 0 1 5 0c1.9-1.3 2.7-1 2.7-1a3.6 3.6 0 0 1 .1 2.6 3.9 3.9 0 0 1 1 2.7c0 3.9-2.3 4.7-4.6 5 .4.3.7.9.7 1.9v2.8c0 .3.2.6.7.5A10 10 0 0 0 12 2"
3116
      />
3117
    </svg>
3118
  );
3119
}
3120
3121
function humanize(value: unknown) {
3122
  const text = String(value ?? '')
3123
    .replaceAll('_', ' ')
3124
    .trim();
3125
  return text ? text[0]!.toUpperCase() + text.slice(1) : 'Unknown';
3126
}
3127
3128
function formatDate(value: string) {
3129
  const date = new Date(value);
3130
  return Number.isNaN(date.getTime()) ? 'Time unavailable' : date.toLocaleString();
3131
}
3132
3133
function relativeTime(value: string) {
3134
  const milliseconds = Date.now() - Date.parse(value);
3135
  if (!Number.isFinite(milliseconds)) return 'Time unavailable';
3136
  const minutes = Math.round(Math.abs(milliseconds) / 60_000);
3137
  const future = milliseconds < 0;
3138
  const phrase =
3139
    minutes < 1
3140
      ? 'just now'
3141
      : minutes < 60
3142
        ? `${minutes}m`
3143
        : minutes < 1_440
3144
          ? `${Math.round(minutes / 60)}h`
3145
          : `${Math.round(minutes / 1_440)}d`;
3146
  return minutes < 1 ? phrase : future ? `in ${phrase}` : `${phrase} ago`;
3147
}
3148
3149
function feedbackState(requests: Record<string, any>[]) {
3150
  if (!requests.length) return 'Starts after a completion report';
3151
  const pending = requests.filter((request) => request.status === 'pending').length;
3152
  if (pending) return `${pending} response${pending === 1 ? '' : 's'} still pending`;
3153
  const expired = requests.filter((request) => request.status === 'expired').length;
3154
  return expired
3155
    ? `${expired} request${expired === 1 ? '' : 's'} timed out`
3156
    : 'Bilateral feedback closed';
3157
}
3158
3159
function StatusPill({ value }: { value: string }) {
3160
  const normalized = String(value).toLowerCase();
3161
  const tone = [
3162
    'success',
3163
    'active',
3164
    'ready',
3165
    'met',
3166
    'eligible',
3167
    'attested',
3168
    'allow',
3169
    'completed',
3170
  ].includes(normalized)
3171
    ? 'good'
3172
    : ['failure', 'denied', 'deny', 'missed', 'rejected', 'violation'].some((item) =>
3173
          normalized.includes(item),
3174
        )
3175
      ? 'danger'
3176
      : ['partial', 'pending', 'provisional', 'challenge', 'conflict', 'expired', 'excluded'].some(
3177
            (item) => normalized.includes(item),
3178
          )
3179
        ? 'warn'
3180
        : 'neutral';
3181
  return <span className={`statusPill ${tone}`}>{humanize(value)}</span>;
3182
}
3183
3184
function initials(value: string) {
3185
  return value
3186
    .split(/\s+|@/)
3187
    .slice(0, 2)
3188
    .map((part) => part[0]?.toUpperCase())
3189
    .join('');
3190
}
3191
function timestamp(item: Record<string, any>) {
3192
  return String(
3193
    item.timestamp ??
3194
      item.generatedAt ??
3195
      item.decidedAt ??
3196
      item.appliedAt ??
3197
      item.submittedAt ??
3198
      item.completedAt ??
3199
      item.activatedAt ??
3200
      item.updatedAt ??
3201
      item.requestedAt ??
3202
      item.createdAt ??
3203
      new Date(0).toISOString(),
3204
  );
3205
}
3206
3207
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
3208
  throw new Error('Auth0 public configuration is incomplete');
3209
createRoot(document.getElementById('root')!).render(
3210
  <React.StrictMode>
3211
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
3212
  </React.StrictMode>,
3213
);