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

Viewing files at commit c6b86fb80ff9Commit detailsView latest

apps/dashboard/src/main.tsx 4352 lines · 157.9 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import {
4
  ArrowRight,
5
  Bot,
6
  Check,
7
  ChevronDown,
8
  Circle,
9
  Cloud,
10
  Copy,
11
  Download,
12
  ExternalLink,
13
  MessageCircle,
14
  Moon,
15
  Plus,
16
  Search,
17
  Share2,
18
  ShieldCheck,
19
  Sun,
20
  X,
21
} from 'lucide-react';
22
import { AppShell } from '@/components/app-shell';
23
import { ClaspMark } from '@/components/clasp-mark';
24
import { LandingBackdrop, LandingClock, LandingDiagram } from '@/components/landing-scene';
25
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
26
import { Input } from '@/components/ui/input';
27
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
28
import { pageMeta, type Page } from '@/lib/navigation';
29
import {
30
  applyPreviewRequest,
31
  createPreviewData,
32
  defaultPreviewSettings,
33
  disablePreview,
34
  enablePreview,
35
  isPreviewActive,
36
  previewSession,
37
} from './preview.js';
38
import './styles.css';
39
40
declare const __AUTH0_DOMAIN__: string;
41
declare const __AUTH0_CLIENT_ID__: string;
42
declare const __AUTH0_AUDIENCE__: string;
43
declare const __OPENCLASP_PUBLIC_URL__: string;
44
declare const __PUBLIC_LOGIN_ENABLED__: boolean;
45
46
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
47
type AuthSession = { user: Auth0User };
48
type AuthTransaction = { state: string; nonce: string; verifier: string };
49
type Theme = 'dark' | 'light';
50
51
const authTransactionKey = 'openclasp.auth0.transaction';
52
const themeKey = 'openclasp.theme.v1';
53
const demoUrl = 'https://cal.com/arya-amin-rjkw8c';
54
const landingCapabilities = [
55
  'verified identity',
56
  'contextual intelligence',
57
  'signed agreements',
58
  'direct A2A',
59
  'verified outcomes',
60
  'behavioural profiles',
61
];
62
63
type DashboardData = {
64
  agents: Record<string, any>[];
65
  projects: Record<string, any>[];
66
  installations: Record<string, any>[];
67
  setupRequests: Record<string, any>[];
68
  publications: Record<string, any>[];
69
  interactions: Record<string, any>[];
70
  federatedInteractions: Record<string, any>[];
71
  liveSessions: Record<string, any>[];
72
  hostedThreads: Record<string, any>[];
73
  events: Record<string, any>[];
74
  conflicts: Record<string, any>[];
75
  receipts: Record<string, any>[];
76
  profiles: Record<string, any>[];
77
  counterpartyBriefs: Record<string, any>[];
78
  completionReports: Record<string, any>[];
79
  feedbackRequests: Record<string, any>[];
80
  interactionFeedback: Record<string, any>[];
81
  interactionConclusions: Record<string, any>[];
82
  learningEligibility: Record<string, any>[];
83
  profileDeltas: Record<string, any>[];
84
  intelligenceSummaries: Record<string, any>[];
85
  runtimes: Record<string, any>[];
86
  accessTokens: Record<string, any>[];
87
};
88
89
type Settings = {
90
  displayName: string;
91
  contributionEnabled: boolean;
92
  retentionDays: number;
93
  evidenceSharing: 'never' | 'ask' | 'contract_only';
94
  rawConversationsStored: false;
95
};
96
97
const emptyData: DashboardData = {
98
  agents: [],
99
  projects: [],
100
  installations: [],
101
  setupRequests: [],
102
  publications: [],
103
  interactions: [],
104
  federatedInteractions: [],
105
  liveSessions: [],
106
  hostedThreads: [],
107
  events: [],
108
  conflicts: [],
109
  receipts: [],
110
  profiles: [],
111
  counterpartyBriefs: [],
112
  completionReports: [],
113
  feedbackRequests: [],
114
  interactionFeedback: [],
115
  interactionConclusions: [],
116
  learningEligibility: [],
117
  profileDeltas: [],
118
  intelligenceSummaries: [],
119
  runtimes: [],
120
  accessTokens: [],
121
};
122
const defaultSettings: Settings = {
123
  displayName: '',
124
  contributionEnabled: false,
125
  retentionDays: 30,
126
  evidenceSharing: 'ask',
127
  rawConversationsStored: false,
128
};
129
130
function normalizeDashboard(value: unknown): DashboardData {
131
  return { ...emptyData, ...(value as Partial<DashboardData>) };
132
}
133
134
function base64Url(bytes: Uint8Array) {
135
  let binary = '';
136
  for (const byte of bytes) binary += String.fromCharCode(byte);
137
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
138
}
139
140
function randomValue(size = 32) {
141
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
142
}
143
144
async function beginAuth(provider: 'google' | 'github') {
145
  const verifier = randomValue(48);
146
  const digest = await crypto.subtle.digest(
147
    'SHA-256',
148
    new TextEncoder().encode(verifier) as BufferSource,
149
  );
150
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
151
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
152
  const parameters = new URLSearchParams({
153
    client_id: __AUTH0_CLIENT_ID__,
154
    redirect_uri: `${location.origin}/sso-callback`,
155
    response_type: 'code',
156
    scope: 'openid profile email',
157
    audience: __AUTH0_AUDIENCE__,
158
    state: transaction.state,
159
    nonce: transaction.nonce,
160
    code_challenge: base64Url(new Uint8Array(digest)),
161
    code_challenge_method: 'S256',
162
    connection: provider === 'google' ? 'google-oauth2' : 'github',
163
  });
164
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
165
}
166
167
async function signOut(preview: boolean) {
168
  if (preview) {
169
    disablePreview();
170
    location.replace('/login');
171
    return;
172
  }
173
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
174
  const parameters = new URLSearchParams({
175
    client_id: __AUTH0_CLIENT_ID__,
176
    returnTo: `${location.origin}/login`,
177
  });
178
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
179
}
180
181
async function remoteApi(path: string, init?: RequestInit) {
182
  const headers = new Headers(init?.headers);
183
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
184
  return fetch(path, {
185
    ...init,
186
    credentials: 'same-origin',
187
    headers,
188
  }).then(async (response) => {
189
    if (!response.ok)
190
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
191
    return response.json();
192
  });
193
}
194
195
function route(): Page {
196
  const value = location.pathname.slice(1) || 'dashboard';
197
  if (value === 'marketplace' || value === 'settings' || value === 'connect') return value;
198
  return 'dashboard';
199
}
200
201
function initialTheme(): Theme {
202
  const saved = localStorage.getItem(themeKey);
203
  if (saved === 'dark' || saved === 'light') return saved;
204
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
205
}
206
207
function App() {
208
  const [session, setSession] = useState<AuthSession | null>();
209
  const [preview, setPreview] = useState(false);
210
  const [theme, setTheme] = useState<Theme>(initialTheme);
211
  const [page, setPage] = useState<Page>(route());
212
  const [data, setData] = useState<DashboardData>(emptyData);
213
  const [settings, setSettings] = useState<Settings>(defaultSettings);
214
  const [loading, setLoading] = useState(true);
215
  const [error, setError] = useState('');
216
  const api = useCallback(
217
    async (path: string, init?: RequestInit) => {
218
      if (preview) {
219
        const next = applyPreviewRequest(data, settings, path, init);
220
        setData(next.data);
221
        setSettings(next.settings);
222
        return next.result;
223
      }
224
      return remoteApi(path, init);
225
    },
226
    [data, preview, settings],
227
  );
228
  const refreshDashboard = useCallback(async () => {
229
    if (preview) return;
230
    setData(normalizeDashboard(await remoteApi('/v0.1/dashboard')));
231
  }, [preview]);
232
233
  useEffect(() => {
234
    document.title = session
235
      ? `${pageMeta[page].title} · OpenClasp`
236
      : session === null
237
        ? 'OpenClasp · Trust for agent communication'
238
        : 'OpenClasp';
239
  }, [page, session]);
240
241
  useEffect(() => {
242
    let cancelled = false;
243
    const boot = async () => {
244
      if (import.meta.env.DEV && isPreviewActive()) {
245
        if (cancelled) return;
246
        setPreview(true);
247
        setSession(previewSession);
248
        setData(createPreviewData());
249
        setSettings(defaultPreviewSettings);
250
        setLoading(false);
251
        return;
252
      }
253
      try {
254
        const response = await fetch('/api/session', { credentials: 'same-origin' });
255
        if (!response.ok) {
256
          if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
257
          return;
258
        }
259
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) {
260
          setSession((await response.json()) as AuthSession);
261
        }
262
      } catch {
263
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
264
      }
265
    };
266
    void boot();
267
    return () => {
268
      cancelled = true;
269
    };
270
  }, []);
271
272
  useEffect(() => {
273
    document.documentElement.dataset.theme = theme;
274
    document.documentElement.classList.toggle('dark', theme === 'dark');
275
    localStorage.setItem(themeKey, theme);
276
    const meta = document.querySelector('meta[name="theme-color"]');
277
    if (meta) meta.setAttribute('content', theme === 'light' ? '#f7f4f2' : '#0c0a0a');
278
  }, [theme]);
279
280
  useEffect(() => {
281
    const onPopState = () => setPage(route());
282
    addEventListener('popstate', onPopState);
283
    return () => removeEventListener('popstate', onPopState);
284
  }, []);
285
286
  useEffect(() => {
287
    if (session === undefined) return;
288
    if (!session) {
289
      const publicPath = __PUBLIC_LOGIN_ENABLED__ ? '/login' : '/';
290
      if (location.pathname !== publicPath) history.replaceState({}, '', publicPath);
291
      return;
292
    }
293
    if (location.pathname === '/' || location.pathname === '/login')
294
      history.replaceState({}, '', '/dashboard');
295
    setPage(route());
296
    if (preview) {
297
      setLoading(false);
298
      return;
299
    }
300
    Promise.all([remoteApi('/v0.1/dashboard'), remoteApi('/v0.1/settings')])
301
      .then(([dashboard, accountSettings]) => {
302
        setData(normalizeDashboard(dashboard));
303
        setSettings(accountSettings as Settings);
304
      })
305
      .catch((reason: unknown) =>
306
        setError(reason instanceof Error ? reason.message : 'Load failed'),
307
      )
308
      .finally(() => setLoading(false));
309
  }, [preview, session]);
310
311
  useEffect(() => {
312
    if (!session || preview) return;
313
    const refresh = () => {
314
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
315
    };
316
    const timer = window.setInterval(refresh, 10_000);
317
    document.addEventListener('visibilitychange', refresh);
318
    return () => {
319
      window.clearInterval(timer);
320
      document.removeEventListener('visibilitychange', refresh);
321
    };
322
  }, [preview, refreshDashboard, session]);
323
324
  const navigate = (next: Page) => {
325
    const target = ['marketplace', 'settings', 'connect'].includes(next) ? next : 'dashboard';
326
    history.pushState({}, '', `/${target}`);
327
    setPage(target as Page);
328
  };
329
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
330
  const pendingInvites = data.federatedInteractions.filter(
331
    (interaction) => interaction.status === 'pending',
332
  ).length;
333
  const unreadThreads = data.hostedThreads.reduce(
334
    (total, thread) => total + Number(thread.unreadCount ?? 0),
335
    0,
336
  );
337
338
  if (session === undefined) return <Loading />;
339
  if (!session)
340
    return import.meta.env.DEV ? (
341
      <PublicLanding
342
        theme={theme}
343
        onToggleTheme={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
344
        onPreview={() => {
345
          enablePreview();
346
          setPreview(true);
347
          setSession(previewSession);
348
          setData(createPreviewData());
349
          setSettings(defaultPreviewSettings);
350
          setLoading(false);
351
          history.replaceState({}, '', '/dashboard');
352
          setPage('dashboard');
353
        }}
354
      />
355
    ) : (
356
      <PublicLanding
357
        theme={theme}
358
        onToggleTheme={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
359
      />
360
    );
361
362
  return (
363
    <AppShell
364
      page={page}
365
      navigate={navigate}
366
      user={session.user}
367
      theme={theme}
368
      onToggleTheme={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
369
      onSignOut={() => void signOut(preview)}
370
      badges={{
371
        dashboard: pendingInvites || pendingSetup ? pendingInvites + pendingSetup : 0,
372
        conversations: unreadThreads,
373
        connect: pendingSetup,
374
      }}
375
      agents={data.agents.map((agent) => ({
376
        agentId: String(agent.agentId ?? ''),
377
        name: typeof agent.name === 'string' ? agent.name : undefined,
378
      }))}
379
      attention={{ setup: pendingSetup, invites: pendingInvites, inbox: unreadThreads }}
380
    >
381
      {preview && <div className="previewBanner">local preview · changes stay here</div>}
382
      {error && (
383
        <div className="errorBar" role="alert">
384
          {error}
385
        </div>
386
      )}
387
      {loading ? (
388
        <Loading compact />
389
      ) : (
390
        <PageContent
391
          page={page}
392
          data={data}
393
          settings={settings}
394
          setSettings={setSettings}
395
          navigate={navigate}
396
          refreshDashboard={refreshDashboard}
397
          api={api}
398
        />
399
      )}
400
    </AppShell>
401
  );
402
}
403
404
function PublicLanding({
405
  onPreview,
406
  theme,
407
  onToggleTheme,
408
}: {
409
  onPreview?: () => void;
410
  theme: Theme;
411
  onToggleTheme: () => void;
412
}) {
413
  const [error, setError] = useState('');
414
  const continueWith = async (provider: 'google' | 'github') => {
415
    setError('');
416
    try {
417
      await beginAuth(provider);
418
    } catch {
419
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
420
    }
421
  };
422
  const scrollToAnchor = (event: React.MouseEvent<HTMLAnchorElement>) => {
423
    const id = event.currentTarget.hash.slice(1);
424
    const target = document.getElementById(id);
425
    if (!target) return;
426
    event.preventDefault();
427
    const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
428
    history.pushState({}, '', `#${id}`);
429
    target.classList.remove('is-arriving');
430
    if (!reducedMotion && id !== 'top') {
431
      void target.offsetWidth;
432
      target.classList.add('is-arriving');
433
      window.setTimeout(() => target.classList.remove('is-arriving'), 900);
434
    }
435
    target.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' });
436
  };
437
  return (
438
    <div className="landingPage">
439
      <LandingBackdrop />
440
      <header className="landingNav">
441
        <a
442
          className="landingBrand"
443
          href="#top"
444
          aria-label="OpenClasp home"
445
          onClick={scrollToAnchor}
446
        >
447
          <ClaspMark className="landingMark" size={22} />
448
          <strong>openclasp</strong>
449
        </a>
450
        <nav aria-label="Main navigation">
451
          <a href="#product" onClick={scrollToAnchor}>
452
            how it works
453
          </a>
454
          <a href="#intelligence" onClick={scrollToAnchor}>
455
            intelligence
456
          </a>
457
        </nav>
458
        <div className="landingNavActions">
459
          <LandingClock />
460
          <button
461
            className="themeButton"
462
            type="button"
463
            onClick={onToggleTheme}
464
            aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
465
          >
466
            {theme === 'dark' ? <Sun /> : <Moon />}
467
          </button>
468
          {__PUBLIC_LOGIN_ENABLED__ ? (
469
            <a className="navCta" href="#access" onClick={scrollToAnchor}>
470
              sign in <ArrowRight />
471
            </a>
472
          ) : (
473
            <a className="navCta" href={demoUrl} target="_blank" rel="noreferrer">
474
              book a demo <ArrowRight />
475
            </a>
476
          )}
477
        </div>
478
      </header>
479
480
      <main id="top">
481
        <section className="landingHero">
482
          <div className="heroCopy">
483
            <p className="landingKicker">
484
              <span>//</span> assurance and behavioural intelligence for AI agents
485
            </p>
486
            <h1>
487
              Agents can talk.
488
              <br />
489
              <em>Now they can build trust.</em>
490
            </h1>
491
            <p>
492
              OpenClasp verifies counterparties, records agreed terms, and turns signed outcomes
493
              into task-specific reliability intelligence—while agents communicate directly over
494
              A2A.
495
            </p>
496
            <div className="heroActions">
497
              {__PUBLIC_LOGIN_ENABLED__ ? (
498
                <a className="landingPrimary" href="#access" onClick={scrollToAnchor}>
499
                  sign in <ArrowRight />
500
                </a>
501
              ) : (
502
                <a className="landingPrimary" href={demoUrl} target="_blank" rel="noreferrer">
503
                  book a demo <ArrowRight />
504
                </a>
505
              )}
506
              <a className="landingSecondary" href="#product" onClick={scrollToAnchor}>
507
                see how it works
508
              </a>
509
            </div>
510
            <div className="heroNotes" aria-label="Protocol flags">
511
              <span>--direct-a2a</span>
512
              <span>--signed-outcomes</span>
513
              <span>--no-universal-score</span>
514
            </div>
515
          </div>
516
          <LandingDiagram />
517
        </section>
518
519
        <div className="capabilityRail" aria-label="OpenClasp capabilities">
520
          {landingCapabilities.map((item) => (
521
            <span key={item}>{item}</span>
522
          ))}
523
        </div>
524
525
        <section className="productSection" id="product">
526
          <div className="sectionIntro">
527
            <p className="landingKicker">
528
              <span>01</span> before, during, after
529
            </p>
530
            <h2>
531
              Trust infrastructure
532
              <br />
533
              <em>for the agent economy.</em>
534
            </h2>
535
            <p>
536
              OpenClasp works before, during, and after an interaction. The conversation remains
537
              direct; the assurance becomes durable.
538
            </p>
539
          </div>
540
          <div className="recordAsk" aria-label="Questions OpenClasp makes inspectable">
541
            <span>? query</span>
542
            <p>who is this agent</p>
543
            <p>what are they reliable at</p>
544
            <p>what did both sides agree</p>
545
          </div>
546
          <ol className="recordSteps">
547
            <li>
548
              <span>before</span>
549
              <div>
550
                <h3>Know the counterparty.</h3>
551
                <p>
552
                  Verify identity and see task-specific strengths, risks, and evidence confidence.
553
                </p>
554
                <pre className="asciiMini">{`IDENTITY   operator verified
555
CONTEXT    procurement · 12 outcomes
556
CLUE       require pricing evidence`}</pre>
557
              </div>
558
            </li>
559
            <li>
560
              <span>agree</span>
561
              <div>
562
                <h3>Agree, then work directly.</h3>
563
                <p>
564
                  Agents can revise and sign shared terms, then keep communicating directly over
565
                  A2A.
566
                </p>
567
                <pre className="asciiMini">{`SCOPE      5t · A4 · 80 GSM
568
EVIDENCE   signed quote required
569
CHANNEL    direct A2A`}</pre>
570
              </div>
571
            </li>
572
            <li>
573
              <span>after</span>
574
              <div>
575
                <h3>Turn outcomes into intelligence.</h3>
576
                <p>
577
                  Signed outcomes and bilateral feedback update a contextual behavioural profile.
578
                </p>
579
                <pre className="asciiMini">{`OUTCOME    delivered on time
580
EVIDENCE   quote verified
581
HISTORY    procurement profile updated`}</pre>
582
              </div>
583
            </li>
584
          </ol>
585
        </section>
586
587
        <section className="intelligenceSection" id="intelligence">
588
          <div className="sectionIntro">
589
            <p className="landingKicker">
590
              <span>02</span> the compounding layer
591
            </p>
592
            <h2>
593
              Every verified outcome
594
              <br />
595
              <em>makes the network smarter.</em>
596
            </h2>
597
            <p>
598
              OpenClasp learns from signed structured events—not harvested conversations—and returns
599
              private, task-specific intelligence before the next interaction.
600
            </p>
601
          </div>
602
          <ol className="intelligenceLoop" aria-label="OpenClasp intelligence flywheel">
603
            <li>
604
              <span>01</span>
605
              <strong>Verified interactions</strong>
606
            </li>
607
            <li>
608
              <span>02</span>
609
              <strong>Structured outcomes</strong>
610
            </li>
611
            <li>
612
              <span>03</span>
613
              <strong>Behavioural profiles</strong>
614
            </li>
615
            <li>
616
              <span>04</span>
617
              <strong>Better agent decisions</strong>
618
            </li>
619
          </ol>
620
          <p className="intelligenceBoundary">
621
            Raw conversations remain private. Intelligence stays contextual. Network contribution is
622
            opt-in.
623
          </p>
624
        </section>
625
626
        <section className="principlesSection" id="principles">
627
          <div>
628
            <p className="landingKicker">
629
              <span>03</span> deliberately bounded
630
            </p>
631
            <h2>
632
              Trust should be
633
              <br />
634
              <em>specific and inspectable.</em>
635
            </h2>
636
          </div>
637
          <div className="principleList">
638
            <article>
639
              <span>DIRECT</span>
640
              <div>
641
                <h3>Agents stay independent</h3>
642
                <p>Bring any programmable agent. OpenClasp does not provide or control it.</p>
643
              </div>
644
            </article>
645
            <article>
646
              <span>OPEN</span>
647
              <div>
648
                <h3>A2A remains the transport</h3>
649
                <p>OpenClasp adds assurance without becoming a proprietary message relay.</p>
650
              </div>
651
            </article>
652
            <article>
653
              <span>CONTEXT</span>
654
              <div>
655
                <h3>No universal trust score</h3>
656
                <p>Standard behavioural dimensions remain task-specific and evidence-backed.</p>
657
              </div>
658
            </article>
659
          </div>
660
        </section>
661
662
        <section className="accessSection" id="access">
663
          <div className="accessCopy">
664
            <p className="landingKicker">
665
              <span>04</span> {__PUBLIC_LOGIN_ENABLED__ ? 'identity required' : 'design partners'}
666
            </p>
667
            <h2>
668
              {__PUBLIC_LOGIN_ENABLED__
669
                ? 'Access your agent network.'
670
                : 'Build the trust layer into your agent network.'}
671
            </h2>
672
            <p>
673
              {__PUBLIC_LOGIN_ENABLED__
674
                ? 'Sign in to connect agents, control network participation, and inspect signed history.'
675
                : 'We are working with agent platforms, enterprise teams, and marketplaces that need reliable cross-agent collaboration.'}
676
            </p>
677
          </div>
678
          <div className="accessCard">
679
            <div className="accessChrome">
680
              <span>{__PUBLIC_LOGIN_ENABLED__ ? '$ authenticate' : '$ pilot openclasp'}</span>
681
              <span className="accessBlink">_</span>
682
            </div>
683
            {__PUBLIC_LOGIN_ENABLED__ ? (
684
              <>
685
                <div className="socialButtons">
686
                  <button type="button" onClick={() => void continueWith('google')}>
687
                    <GoogleMark /> continue with google
688
                  </button>
689
                  <button type="button" onClick={() => void continueWith('github')}>
690
                    <GitHubMark /> continue with github
691
                  </button>
692
                  {onPreview && (
693
                    <button type="button" onClick={() => void onPreview()}>
694
                      open local preview <ArrowRight />
695
                    </button>
696
                  )}
697
                </div>
698
                {error && (
699
                  <div className="loginError" role="alert">
700
                    {error}
701
                  </div>
702
                )}
703
                <small>google or github · openclasp never receives your password</small>
704
              </>
705
            ) : (
706
              <>
707
                <ul className="pilotList">
708
                  <li>Agent identity and discovery</li>
709
                  <li>Signed agreements and receipts</li>
710
                  <li>Contextual reliability intelligence</li>
711
                </ul>
712
                <div className="demoActions">
713
                  <a className="landingPrimary" href={demoUrl} target="_blank" rel="noreferrer">
714
                    book a demo <ArrowRight />
715
                  </a>
716
                  {onPreview && (
717
                    <button type="button" onClick={() => void onPreview()}>
718
                      open local preview <ArrowRight />
719
                    </button>
720
                  )}
721
                </div>
722
                <small>30 minutes · product walkthrough · pilot fit</small>
723
              </>
724
            )}
725
          </div>
726
        </section>
727
      </main>
728
729
      <footer className="landingFooter">
730
        <a className="landingBrand" href="#top" onClick={scrollToAnchor}>
731
          <ClaspMark className="landingMark" size={22} />
732
          <strong>openclasp</strong>
733
        </a>
734
        <p>assurance and behavioural intelligence for AI agents.</p>
735
        <small>Signed outcomes, contextual intelligence, and direct agent communication.</small>
736
      </footer>
737
    </div>
738
  );
739
}
740
741
let callbackStarted = false;
742
function AuthCallback() {
743
  const [error, setError] = useState('');
744
  useEffect(() => {
745
    if (callbackStarted) return;
746
    callbackStarted = true;
747
    const finish = async () => {
748
      const query = new URLSearchParams(location.search);
749
      const oauthError = query.get('error_description') ?? query.get('error');
750
      if (oauthError) throw new Error(oauthError);
751
      const code = query.get('code');
752
      const returnedState = query.get('state');
753
      if (code && returnedState?.startsWith('oc_tx_')) {
754
        const response = await fetch('/api/oauth-callback', {
755
          method: 'POST',
756
          credentials: 'same-origin',
757
          headers: { 'content-type': 'application/json' },
758
          body: JSON.stringify({ code, state: returnedState }),
759
        });
760
        if (!response.ok) throw new Error('MCP authorization failed');
761
        const result = (await response.json()) as { redirectTo?: unknown };
762
        if (typeof result.redirectTo !== 'string')
763
          throw new Error('Invalid MCP authorization response');
764
        location.replace(result.redirectTo);
765
        return;
766
      }
767
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
768
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
769
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
770
      sessionStorage.removeItem(authTransactionKey);
771
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
772
      const response = await fetch('/api/session', {
773
        method: 'POST',
774
        credentials: 'same-origin',
775
        headers: { 'content-type': 'application/json' },
776
        body: JSON.stringify({ code, codeVerifier: transaction.verifier }),
777
      });
778
      if (!response.ok) throw new Error('Auth0 token exchange failed');
779
      location.replace('/dashboard');
780
    };
781
    void finish().catch((reason: unknown) =>
782
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
783
    );
784
  }, []);
785
  if (error)
786
    return (
787
      <div className="loading">
788
        <span className="mark">OC</span>
789
        <p>{error}</p>
790
        <a href="/">Return home</a>
791
      </div>
792
    );
793
  return <Loading />;
794
}
795
796
function PageContent({
797
  page,
798
  data,
799
  settings,
800
  setSettings,
801
  navigate,
802
  refreshDashboard,
803
  api,
804
}: {
805
  page: Page;
806
  data: DashboardData;
807
  settings: Settings;
808
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
809
  navigate: (page: Page) => void;
810
  refreshDashboard: () => Promise<void>;
811
  api: (path: string, init?: RequestInit) => Promise<unknown>;
812
}) {
813
  if (page === 'connect')
814
    return (
815
      <Connect data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
816
    );
817
  if (page === 'marketplace') return <Marketplace data={data} />;
818
  if (page === 'settings')
819
    return <SettingsPage settings={settings} setSettings={setSettings} api={api} />;
820
  if (page === 'history') return <History data={data} />;
821
  if (page === 'conversations')
822
    return <Conversations data={data} refreshDashboard={refreshDashboard} api={api} />;
823
  if (page === 'agents')
824
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
825
  if (page === 'insights') return <Insights data={data} />;
826
  if (page !== 'dashboard')
827
    return (
828
      <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
829
    );
830
  return (
831
    <AgentWorkspace data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
832
  );
833
}
834
835
type AgentHistoryItem = {
836
  interactionId: string;
837
  title: string;
838
  counterpart: string;
839
  counterpartMode: 'agent' | 'temporary';
840
  outcome: 'success' | 'partial' | 'failure' | 'cancelled' | 'provisional' | 'active' | 'pending';
841
  at: string;
842
};
843
844
const SCORECARD_DIMENSIONS = [
845
  { key: 'completion', label: 'Task completion', short: 'Completion' },
846
  { key: 'acceptance', label: 'Outcome satisfaction', short: 'Satisfaction' },
847
  { key: 'specification', label: 'Requirement adherence', short: 'Requirements' },
848
  { key: 'deadline', label: 'Timeliness', short: 'Timeliness' },
849
  { key: 'communication', label: 'Communication', short: 'Communication' },
850
  { key: 'evidence', label: 'Evidence quality', short: 'Evidence' },
851
  { key: 'scope', label: 'Scope adherence', short: 'Scope' },
852
  { key: 'correction', label: 'Correction behaviour', short: 'Corrections' },
853
  { key: 'limitations', label: 'Limitation disclosure', short: 'Limitations' },
854
  { key: 'disputes', label: 'Dispute-free outcomes', short: 'Dispute-free' },
855
] as const;
856
857
type ScorecardMetric = {
858
  key: (typeof SCORECARD_DIMENSIONS)[number]['key'];
859
  label: string;
860
  short: string;
861
  value: number | null;
862
  evidence: number;
863
};
864
865
function AgentWorkspace({
866
  data,
867
  navigate,
868
  refreshDashboard,
869
  api,
870
}: {
871
  data: DashboardData;
872
  navigate: (page: Page) => void;
873
  refreshDashboard: () => Promise<void>;
874
  api: (path: string, init?: RequestInit) => Promise<unknown>;
875
}) {
876
  const [expandedAgent, setExpandedAgent] = useState('');
877
  const [scorecardAgentId, setScorecardAgentId] = useState(
878
    () => new URLSearchParams(window.location.search).get('scorecard') ?? '',
879
  );
880
  const [working, setWorking] = useState('');
881
  const [error, setError] = useState('');
882
  const pendingSetups = data.setupRequests.filter((request) => request.status === 'pending');
883
884
  const decideSetup = async (requestId: string, decision: 'approve' | 'reject') => {
885
    setWorking(`setup:${requestId}`);
886
    setError('');
887
    try {
888
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
889
        method: 'POST',
890
      });
891
      await refreshDashboard();
892
    } catch (reason) {
893
      setError(reason instanceof Error ? reason.message : 'Could not update request');
894
    } finally {
895
      setWorking('');
896
    }
897
  };
898
899
  const respondToContract = async (
900
    interaction: Record<string, any>,
901
    agentId: string,
902
    decision: 'accept' | 'reject',
903
  ) => {
904
    const proposal = latestContractProposal(interaction);
905
    if (!proposal) return;
906
    setWorking(`contract:${interaction.interactionId}`);
907
    setError('');
908
    try {
909
      await api(
910
        `/v0.1/federated-interactions/${encodeURIComponent(String(interaction.interactionId))}/contract-proposals/${encodeURIComponent(String(proposal.revisionId))}/respond`,
911
        { method: 'POST', body: JSON.stringify({ agentId, decision }) },
912
      );
913
      await refreshDashboard();
914
    } catch (reason) {
915
      setError(reason instanceof Error ? reason.message : 'Could not respond to request');
916
    } finally {
917
      setWorking('');
918
    }
919
  };
920
921
  return (
922
    <div className="workspacePage">
923
      <header className="workspaceHead">
924
        <div>
925
          <p className="eyebrow">your workspace</p>
926
          <h1>My agents</h1>
927
          <p>{data.agents.length} connected</p>
928
        </div>
929
        <button className="connectAgentButton" type="button" onClick={() => navigate('connect')}>
930
          <Plus /> Connect new agent
931
        </button>
932
      </header>
933
934
      {error ? (
935
        <div className="errorBar" role="alert">
936
          {error}
937
        </div>
938
      ) : null}
939
940
      {pendingSetups.length ? (
941
        <section className="compactRequests" aria-label="New agent requests">
942
          {pendingSetups.map((request) => (
943
            <article key={request.requestId}>
944
              <span className="requestIcon">
945
                <Bot />
946
              </span>
947
              <div>
948
                <strong>{request.agentName ?? 'New agent'}</strong>
949
                <small>{request.framework ?? 'Agent'} wants to connect</small>
950
              </div>
951
              <div className="iconDecisions">
952
                <button
953
                  type="button"
954
                  aria-label={`Reject ${String(request.agentName ?? 'agent')}`}
955
                  disabled={working === `setup:${request.requestId}`}
956
                  onClick={() => void decideSetup(String(request.requestId), 'reject')}
957
                >
958
                  <X />
959
                </button>
960
                <button
961
                  className="approve"
962
                  type="button"
963
                  aria-label={`Approve ${String(request.agentName ?? 'agent')}`}
964
                  disabled={working === `setup:${request.requestId}`}
965
                  onClick={() => void decideSetup(String(request.requestId), 'approve')}
966
                >
967
                  <Check />
968
                </button>
969
              </div>
970
            </article>
971
          ))}
972
        </section>
973
      ) : null}
974
975
      <section className="agentList" aria-label="Connected agents">
976
        {data.agents.length ? (
977
          data.agents.map((agent) => {
978
            const agentId = String(agent.agentId);
979
            const expanded = expandedAgent === agentId;
980
            const invitations = invitationsForAgent(data, agentId);
981
            const history = historyForAgent(data, agent);
982
            const reliability = reliabilityForAgent(data, agentId);
983
            const mode = String(
984
              agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat'),
985
            );
986
            const temporary = mode === 'temporary_chat';
987
            const runtime = data.runtimes.find((item) => item.agentId === agentId);
988
            const verified = !agent.revoked && Boolean(agent.identityMode);
989
            const online = agent.presence?.status === 'online';
990
            const published = data.publications.some(
991
              (item) => item.agentId === agentId && item.published,
992
            );
993
            return (
994
              <article className={`agentRow ${expanded ? 'isExpanded' : ''}`} key={agentId}>
995
                <div className="agentRowSummary">
996
                  <button
997
                    className="agentRowCore"
998
                    type="button"
999
                    aria-expanded={expanded}
1000
                    onClick={() => setExpandedAgent(expanded ? '' : agentId)}
1001
                  >
1002
                    <span className="agentAvatar">
1003
                      <Bot />
1004
                      <i className={online ? 'online' : ''} />
1005
                    </span>
1006
                    <span className="agentIdentity">
1007
                      <strong>{agent.name ?? agentId}</strong>
1008
                      <small>{agent.framework ?? 'Agent'}</small>
1009
                    </span>
1010
                    <span className="agentSignals">
1011
                      <span className={online ? 'signalOnline' : ''}>
1012
                        <Circle /> {online ? 'Online' : 'Offline'}
1013
                      </span>
1014
                      <span className={verified ? 'signalVerified' : ''}>
1015
                        <ShieldCheck /> {verified ? 'Verified' : 'Unverified'}
1016
                      </span>
1017
                      <span>
1018
                        {temporary ? <MessageCircle /> : <Cloud />}
1019
                        {temporary
1020
                          ? 'Temporary'
1021
                          : runtime?.status === 'verified'
1022
                            ? 'Cloud'
1023
                            : 'Cloud · disconnected'}
1024
                      </span>
1025
                    </span>
1026
                  </button>
1027
                  <button
1028
                    className="scorecardTeaser"
1029
                    type="button"
1030
                    onClick={() => setScorecardAgentId(agentId)}
1031
                    aria-label={`Open behavioural scorecard for ${String(agent.name ?? agentId)}`}
1032
                  >
1033
                    <span className="scorecardBars" aria-hidden="true">
1034
                      {[0, 1, 2, 3].map((index) => (
1035
                        <i
1036
                          key={index}
1037
                          style={{
1038
                            width: `${Math.max(18, Number(reliability.score ?? 50) - index * 9)}%`,
1039
                          }}
1040
                        />
1041
                      ))}
1042
                    </span>
1043
                    <span>
1044
                      <strong>Behaviour card</strong>
1045
                      <small>
1046
                        {reliability.summary
1047
                          ? `${humanize(reliability.summary.confidence.level)} confidence · ${reliability.samples}`
1048
                          : 'Awaiting verified outcomes'}
1049
                      </small>
1050
                    </span>
1051
                  </button>
1052
                  {invitations.length ? (
1053
                    <span
1054
                      className="notificationCount"
1055
                      aria-label={`${invitations.length} requests`}
1056
                    >
1057
                      {invitations.length}
1058
                    </span>
1059
                  ) : null}
1060
                  <button
1061
                    className="agentExpandButton"
1062
                    type="button"
1063
                    aria-expanded={expanded}
1064
                    aria-label={`${expanded ? 'Collapse' : 'Expand'} ${String(agent.name ?? agentId)}`}
1065
                    onClick={() => setExpandedAgent(expanded ? '' : agentId)}
1066
                  >
1067
                    <ChevronDown className="expandIcon" />
1068
                  </button>
1069
                </div>
1070
1071
                {expanded ? (
1072
                  <div className="agentExpanded">
1073
                    {invitations.length ? (
1074
                      <section className="agentRequests" aria-label="Contract requests">
1075
                        <p className="sectionLabel">Requests</p>
1076
                        {invitations.map((interaction) => {
1077
                          const proposal = latestContractProposal(interaction);
1078
                          return (
1079
                            <article key={interaction.interactionId}>
1080
                              <span className="requestPulse" />
1081
                              <div>
1082
                                <strong>
1083
                                  {interaction.contract?.purpose ?? 'New interaction request'}
1084
                                </strong>
1085
                                <small>from {counterpartyName(data, interaction, agentId)}</small>
1086
                              </div>
1087
                              <div className="iconDecisions">
1088
                                <button
1089
                                  type="button"
1090
                                  aria-label="Reject request"
1091
                                  disabled={working === `contract:${interaction.interactionId}`}
1092
                                  onClick={() =>
1093
                                    void respondToContract(interaction, agentId, 'reject')
1094
                                  }
1095
                                >
1096
                                  <X />
1097
                                </button>
1098
                                <button
1099
                                  className="approve"
1100
                                  type="button"
1101
                                  aria-label="Accept request"
1102
                                  disabled={
1103
                                    !proposal || working === `contract:${interaction.interactionId}`
1104
                                  }
1105
                                  onClick={() =>
1106
                                    void respondToContract(interaction, agentId, 'accept')
1107
                                  }
1108
                                >
1109
                                  <Check />
1110
                                </button>
1111
                              </div>
1112
                            </article>
1113
                          );
1114
                        })}
1115
                      </section>
1116
                    ) : null}
1117
1118
                    <div className="agentDetailStrip">
1119
                      <span>
1120
                        <b>{reliability.samples || '—'}</b>
1121
                        verified outcomes
1122
                      </span>
1123
                      <span>
1124
                        <b>{published ? 'Public' : 'Private'}</b>
1125
                        discovery
1126
                      </span>
1127
                      <span>
1128
                        <b>
1129
                          {online
1130
                            ? 'Now'
1131
                            : agent.presence?.lastSeenAt
1132
                              ? relativeTime(agent.presence.lastSeenAt)
1133
                              : 'Never'}
1134
                        </b>
1135
                        last active
1136
                      </span>
1137
                      {published ? (
1138
                        <a
1139
                          href={`/a/${encodeURIComponent(agentId)}`}
1140
                          target="_blank"
1141
                          rel="noreferrer"
1142
                        >
1143
                          Profile <ExternalLink />
1144
                        </a>
1145
                      ) : null}
1146
                    </div>
1147
1148
                    <section className="agentHistory">
1149
                      <div className="historyHeading">
1150
                        <p className="sectionLabel">History</p>
1151
                        <small>{history.length} interactions</small>
1152
                      </div>
1153
                      {history.length ? (
1154
                        <ol>
1155
                          {history.map((item) => (
1156
                            <li key={item.interactionId}>
1157
                              <OutcomeSymbol outcome={item.outcome} />
1158
                              <div>
1159
                                <strong>{item.counterpart}</strong>
1160
                                <small>
1161
                                  {item.counterpartMode === 'temporary'
1162
                                    ? 'Temporary chat'
1163
                                    : 'Agent'}
1164
                                  {' · '}
1165
                                  {relativeTime(item.at)}
1166
                                </small>
1167
                              </div>
1168
                              <span className="historyPurpose">{item.title}</span>
1169
                              <b className={`outcomeText ${item.outcome}`}>
1170
                                {humanize(item.outcome)}
1171
                              </b>
1172
                            </li>
1173
                          ))}
1174
                        </ol>
1175
                      ) : (
1176
                        <div className="quietEmpty">No interactions yet</div>
1177
                      )}
1178
                    </section>
1179
                  </div>
1180
                ) : null}
1181
              </article>
1182
            );
1183
          })
1184
        ) : (
1185
          <button className="emptyAgentState" type="button" onClick={() => navigate('connect')}>
1186
            <Plus />
1187
            <strong>Connect your first agent</strong>
1188
            <span>It takes about a minute.</span>
1189
          </button>
1190
        )}
1191
      </section>
1192
      <BehaviourScorecardDialog
1193
        open={Boolean(scorecardAgentId)}
1194
        onOpenChange={(open) => {
1195
          if (!open) setScorecardAgentId('');
1196
        }}
1197
        data={data}
1198
        agent={data.agents.find((item) => item.agentId === scorecardAgentId)}
1199
      />
1200
    </div>
1201
  );
1202
}
1203
1204
function latestContractProposal(interaction: Record<string, any>) {
1205
  return [...(interaction.contractRevisions ?? [])]
1206
    .reverse()
1207
    .find((revision) => revision.status === 'proposed');
1208
}
1209
1210
function invitationsForAgent(data: DashboardData, agentId: string) {
1211
  return data.federatedInteractions.filter((interaction) => {
1212
    const proposal = latestContractProposal(interaction);
1213
    return (
1214
      proposal &&
1215
      (
1216
        interaction.contract?.parties ?? [
1217
          interaction.initiatorAgentId,
1218
          interaction.responderAgentId,
1219
        ]
1220
      ).includes(agentId) &&
1221
      !proposal.acceptances?.[agentId]
1222
    );
1223
  });
1224
}
1225
1226
function counterpartyName(data: DashboardData, interaction: Record<string, any>, agentId: string) {
1227
  const counterpartyId =
1228
    interaction.initiatorAgentId === agentId
1229
      ? interaction.responderAgentId
1230
      : interaction.initiatorAgentId;
1231
  return data.agents.find((agent) => agent.agentId === counterpartyId)?.name ?? counterpartyId;
1232
}
1233
1234
function reliabilityForAgent(data: DashboardData, agentId: string) {
1235
  const summaries = data.intelligenceSummaries
1236
    .filter((summary) => summary.agentId === agentId)
1237
    .sort((left, right) => {
1238
      if (left.versionStatus?.status !== right.versionStatus?.status)
1239
        return left.versionStatus?.status === 'current' ? -1 : 1;
1240
      return Number(right.confidence?.value ?? 0) - Number(left.confidence?.value ?? 0);
1241
    });
1242
  const summary = summaries[0];
1243
  if (summary)
1244
    return {
1245
      score: Math.round(Number(summary.score) * 100),
1246
      samples: Number(summary.confidence?.evidenceCount ?? 0),
1247
      summary,
1248
      all: summaries,
1249
    };
1250
  const profiles = data.profiles.filter((profile) => profile.agentId === agentId);
1251
  if (!profiles.length) return { score: null, samples: 0, summary: null, all: [] };
1252
  let weightedScore = 0;
1253
  let weight = 0;
1254
  for (const profile of profiles) {
1255
    const dimensions = [
1256
      profile.completion,
1257
      profile.acceptance,
1258
      profile.specification,
1259
      profile.scope,
1260
      profile.evidence,
1261
      profile.communication,
1262
    ].filter((value) => Number.isFinite(Number(value)));
1263
    const profileScore = dimensions.length
1264
      ? dimensions.reduce((sum, value) => sum + Number(value), 0) / dimensions.length
1265
      : 0.5;
1266
    const sampleWeight = Math.max(
1267
      1,
1268
      Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 1),
1269
    );
1270
    weightedScore += profileScore * sampleWeight;
1271
    weight += sampleWeight;
1272
  }
1273
  return {
1274
    score: Math.round((weightedScore / Math.max(1, weight)) * 100),
1275
    samples: profiles.reduce((sum, profile) => sum + Number(profile.sampleSize ?? 0), 0),
1276
    summary: null,
1277
    all: [],
1278
  };
1279
}
1280
1281
type BehaviourScorecardModel = {
1282
  agentId: string;
1283
  name: string;
1284
  version: string;
1285
  taskCategory: string;
1286
  confidence: string;
1287
  confidenceValue: number;
1288
  evidenceCount: number;
1289
  trend: string;
1290
  updatedAt: string;
1291
  verified: boolean;
1292
  metrics: ScorecardMetric[];
1293
};
1294
1295
function scorecardForAgent(
1296
  data: DashboardData,
1297
  agent: Record<string, any>,
1298
): BehaviourScorecardModel {
1299
  const agentId = String(agent.agentId);
1300
  const reliability = reliabilityForAgent(data, agentId);
1301
  const summary = reliability.summary;
1302
  const profiles = data.profiles
1303
    .filter(
1304
      (profile) =>
1305
        profile.agentId === agentId &&
1306
        (!summary ||
1307
          (profile.taskCategory === summary.taskCategory &&
1308
            profile.agentVersion === summary.agentVersion)),
1309
    )
1310
    .sort(
1311
      (left, right) =>
1312
        Date.parse(String(right.updatedAt ?? 0)) - Date.parse(String(left.updatedAt ?? 0)),
1313
    );
1314
  const profile = profiles[0];
1315
  const summaryDimensions = new Map<string, number>();
1316
  for (const item of [...(summary?.strengths ?? []), ...(summary?.risks ?? [])])
1317
    summaryDimensions.set(String(item.dimension), Number(item.score));
1318
  const legacyDimensions = new Set([
1319
    'completion',
1320
    'acceptance',
1321
    'specification',
1322
    'deadline',
1323
    'communication',
1324
    'evidence',
1325
    'scope',
1326
    'disputes',
1327
  ]);
1328
  const metrics = SCORECARD_DIMENSIONS.map((definition): ScorecardMetric => {
1329
    const raw = Number(profile?.[definition.key]);
1330
    const evidence = Number(
1331
      profile?.dimensionSampleSizes?.[definition.key] ??
1332
        (legacyDimensions.has(definition.key) ? (profile?.effectiveSampleSize ?? 0) : 0),
1333
    );
1334
    const profileValue = Number.isFinite(raw)
1335
      ? definition.key === 'disputes'
1336
        ? 1 - raw
1337
        : raw
1338
      : undefined;
1339
    const fallback = summaryDimensions.get(definition.key);
1340
    const measured = evidence > 0 || typeof fallback === 'number';
1341
    return {
1342
      ...definition,
1343
      value: measured
1344
        ? Math.round(Math.max(0, Math.min(1, profileValue ?? fallback ?? 0)) * 100)
1345
        : null,
1346
      evidence: Math.max(0, evidence),
1347
    };
1348
  });
1349
  return {
1350
    agentId,
1351
    name: String(agent.name ?? agentId),
1352
    version: String(summary?.agentVersion ?? agent.agentVersion ?? 'unknown'),
1353
    taskCategory: String(summary?.taskCategory ?? profile?.taskCategory ?? 'general'),
1354
    confidence: String(summary?.confidence?.level ?? 'unrated'),
1355
    confidenceValue: Number(summary?.confidence?.value ?? 0),
1356
    evidenceCount: Number(summary?.confidence?.evidenceCount ?? profile?.sampleSize ?? 0),
1357
    trend: String(summary?.trend?.direction ?? 'unrated'),
1358
    updatedAt: String(summary?.updatedAt ?? profile?.updatedAt ?? new Date().toISOString()),
1359
    verified: !agent.revoked && Boolean(agent.identityMode),
1360
    metrics,
1361
  };
1362
}
1363
1364
function scorecardSummary(model: BehaviourScorecardModel) {
1365
  const metrics = model.metrics
1366
    .map(
1367
      (metric) =>
1368
        `${metric.label}: ${metric.value === null ? 'not measured' : `${metric.value}/100`}`,
1369
    )
1370
    .join('\n');
1371
  return `${model.name} — OpenClasp behavioural scorecard\nContext: ${humanize(model.taskCategory)} · ${humanize(model.confidence)} confidence · ${model.evidenceCount} verified outcomes\n${metrics}\nGenerated from signed structured outcomes; no raw conversation content.`;
1372
}
1373
1374
function escapeSvg(value: string) {
1375
  return value
1376
    .replaceAll('&', '&amp;')
1377
    .replaceAll('<', '&lt;')
1378
    .replaceAll('>', '&gt;')
1379
    .replaceAll('"', '&quot;')
1380
    .replaceAll("'", '&apos;');
1381
}
1382
1383
function scorecardSvg(model: BehaviourScorecardModel) {
1384
  const metricRows = model.metrics
1385
    .map((metric, index) => {
1386
      const column = index >= 5 ? 1 : 0;
1387
      const row = index % 5;
1388
      const x = 76 + column * 554;
1389
      const y = 266 + row * 64;
1390
      const width = metric.value === null ? 0 : Math.round((metric.value / 100) * 360);
1391
      const value = metric.value === null ? 'NOT MEASURED' : `${metric.value}`;
1392
      return `<g><text x="${x}" y="${y}" class="label">${escapeSvg(metric.short.toUpperCase())}</text><text x="${x + 454}" y="${y}" class="value">${value}</text><rect x="${x}" y="${y + 14}" width="454" height="5" rx="2.5" fill="#282321"/><rect x="${x}" y="${y + 14}" width="${width}" height="5" rx="2.5" fill="#ff4d2e"/></g>`;
1393
    })
1394
    .join('');
1395
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675"><style>.brand{font:700 22px Arial,sans-serif;fill:#f5f1ed}.name{font:700 48px Arial,sans-serif;fill:#f5f1ed}.meta{font:500 16px Arial,sans-serif;fill:#a49a93}.label{font:600 13px Arial,sans-serif;letter-spacing:1.5px;fill:#a49a93}.value{font:700 15px Arial,sans-serif;text-anchor:end;fill:#f5f1ed}.foot{font:500 13px Arial,sans-serif;fill:#756d68}</style><rect width="1200" height="675" fill="#0c0b0a"/><rect x="34" y="34" width="1132" height="607" fill="none" stroke="#3a312d"/><circle cx="78" cy="77" r="12" fill="none" stroke="#ff4d2e" stroke-width="3"/><text x="105" y="85" class="brand">openclasp / behaviour card</text><text x="76" y="160" class="name">${escapeSvg(model.name)}</text><text x="76" y="198" class="meta">${escapeSvg(humanize(model.taskCategory))} · ${escapeSvg(humanize(model.confidence))} confidence · ${model.evidenceCount} verified outcomes · v${escapeSvg(model.version)}</text><line x1="76" y1="228" x2="1124" y2="228" stroke="#3a312d"/>${metricRows}<line x1="76" y1="598" x2="1124" y2="598" stroke="#3a312d"/><text x="76" y="622" class="foot">STANDARD SCORECARD · SIGNED STRUCTURED OUTCOMES · RAW CONVERSATIONS EXCLUDED</text></svg>`;
1396
}
1397
1398
function BehaviourScorecardDialog({
1399
  open,
1400
  onOpenChange,
1401
  data,
1402
  agent,
1403
}: {
1404
  open: boolean;
1405
  onOpenChange: (open: boolean) => void;
1406
  data: DashboardData;
1407
  agent?: Record<string, any>;
1408
}) {
1409
  const [actionState, setActionState] = useState('');
1410
  useEffect(() => setActionState(''), [agent?.agentId]);
1411
  if (!agent) return null;
1412
  const model = scorecardForAgent(data, agent);
1413
  const saveCard = () => {
1414
    const blob = new Blob([scorecardSvg(model)], { type: 'image/svg+xml' });
1415
    const url = URL.createObjectURL(blob);
1416
    const anchor = document.createElement('a');
1417
    anchor.href = url;
1418
    anchor.download = `${model.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, '-')}-openclasp-card.svg`;
1419
    anchor.click();
1420
    URL.revokeObjectURL(url);
1421
    setActionState('Downloaded');
1422
  };
1423
  const shareCard = async () => {
1424
    const file = new File([scorecardSvg(model)], 'openclasp-behaviour-card.svg', {
1425
      type: 'image/svg+xml',
1426
    });
1427
    try {
1428
      if (navigator.share && navigator.canShare?.({ files: [file] })) {
1429
        await navigator.share({ title: `${model.name} behaviour card`, files: [file] });
1430
        setActionState('Shared');
1431
      } else {
1432
        saveCard();
1433
      }
1434
    } catch (reason) {
1435
      if (!(reason instanceof DOMException && reason.name === 'AbortError'))
1436
        setActionState('Failed');
1437
    }
1438
  };
1439
  const copyCard = async () => {
1440
    try {
1441
      await navigator.clipboard.writeText(scorecardSummary(model));
1442
      setActionState('Copied');
1443
    } catch {
1444
      setActionState('Copy failed');
1445
    }
1446
  };
1447
  return (
1448
    <Dialog open={open} onOpenChange={onOpenChange}>
1449
      <DialogContent className="scorecardDialog" showCloseButton>
1450
        <DialogTitle className="scorecardA11yTitle">{model.name} behavioural scorecard</DialogTitle>
1451
        <DialogDescription className="scorecardA11yTitle">
1452
          Standard OpenClasp behavioural dimensions based on verified structured outcomes.
1453
        </DialogDescription>
1454
        <article className="behaviourScorecard">
1455
          <header className="scorecardHeader">
1456
            <div className="scorecardBrand">
1457
              <ClaspMark />
1458
              <span>openclasp / behaviour card</span>
1459
            </div>
1460
            <span className={model.verified ? 'scorecardVerified' : ''}>
1461
              <ShieldCheck /> {model.verified ? 'Identity verified' : 'Identity unverified'}
1462
            </span>
1463
          </header>
1464
          <div className="scorecardIdentity">
1465
            <div>
1466
              <p>STANDARD AGENT SCORECARD</p>
1467
              <h2>{model.name}</h2>
1468
            </div>
1469
            <div className="scorecardContext">
1470
              <span>{humanize(model.taskCategory)}</span>
1471
              <span>{humanize(model.confidence)} confidence</span>
1472
              <span>{model.evidenceCount} outcomes</span>
1473
              <span>v{model.version}</span>
1474
            </div>
1475
          </div>
1476
          <div className="scorecardMetrics">
1477
            {model.metrics.map((metric) => (
1478
              <div className={metric.value === null ? 'isUnmeasured' : ''} key={metric.key}>
1479
                <span>
1480
                  <strong>{metric.label}</strong>
1481
                  <small>
1482
                    {metric.value === null
1483
                      ? 'Awaiting eligible evidence'
1484
                      : `${metric.evidence.toFixed(1)} effective samples`}
1485
                  </small>
1486
                </span>
1487
                <span className="scorecardMeter" aria-hidden="true">
1488
                  <i style={{ width: `${metric.value ?? 0}%` }} />
1489
                </span>
1490
                <b>{metric.value === null ? '—' : metric.value}</b>
1491
              </div>
1492
            ))}
1493
          </div>
1494
          <footer className="scorecardFoot">
1495
            <span>
1496
              {model.trend === 'improving' ? '↗' : model.trend === 'declining' ? '↘' : '→'}{' '}
1497
              {humanize(model.trend)} trend
1498
            </span>
1499
            <span>Updated {relativeTime(model.updatedAt)}</span>
1500
            <small>Signed structured outcomes · raw conversations excluded</small>
1501
          </footer>
1502
        </article>
1503
        <div className="scorecardActions">
1504
          <span aria-live="polite">{actionState}</span>
1505
          <button type="button" onClick={() => void copyCard()}>
1506
            <Copy /> Copy summary
1507
          </button>
1508
          <button type="button" onClick={saveCard}>
1509
            <Download /> Download
1510
          </button>
1511
          <button className="primary" type="button" onClick={() => void shareCard()}>
1512
            <Share2 /> Share card
1513
          </button>
1514
        </div>
1515
      </DialogContent>
1516
    </Dialog>
1517
  );
1518
}
1519
1520
function historyForAgent(data: DashboardData, agent: Record<string, any>): AgentHistoryItem[] {
1521
  const agentId = String(agent.agentId);
1522
  const interactions = new Map<string, Record<string, any>>();
1523
  for (const interaction of [...data.interactions, ...data.federatedInteractions]) {
1524
    const participants = [
1525
      interaction.agentId,
1526
      interaction.initiatorAgentId,
1527
      interaction.responderAgentId,
1528
      ...(interaction.contract?.parties ?? []),
1529
    ];
1530
    if (participants.includes(agentId))
1531
      interactions.set(String(interaction.interactionId), interaction);
1532
  }
1533
  return [...interactions.values()]
1534
    .map((interaction) => {
1535
      const interactionId = String(interaction.interactionId);
1536
      const report = [...data.completionReports]
1537
        .reverse()
1538
        .find((item) => item.interactionId === interactionId);
1539
      const receipt = [...data.receipts]
1540
        .reverse()
1541
        .find((item) => item.interactionId === interactionId);
1542
      const conclusion = [...data.interactionConclusions]
1543
        .reverse()
1544
        .find((item) => item.interactionId === interactionId);
1545
      const thread = data.hostedThreads.find((item) => item.interactionId === interactionId);
1546
      const counterpartId = interaction.initiatorAgentId
1547
        ? interaction.initiatorAgentId === agentId
1548
          ? interaction.responderAgentId
1549
          : interaction.initiatorAgentId
1550
        : (interaction.counterpartyAgentId ?? 'Local interaction');
1551
      const counterpart =
1552
        data.agents.find((item) => item.agentId === counterpartId)?.name ??
1553
        (thread ? 'Temporary chat' : counterpartId);
1554
      const counterpartMode: AgentHistoryItem['counterpartMode'] = thread ? 'temporary' : 'agent';
1555
      const rawOutcome = String(
1556
        conclusion?.lifecycle === 'provisional'
1557
          ? 'provisional'
1558
          : (conclusion?.outcome ??
1559
              receipt?.outcome ??
1560
              report?.outcome ??
1561
              (interaction.status === 'completed' ? 'success' : (interaction.status ?? 'pending'))),
1562
      );
1563
      const outcome = (
1564
        ['success', 'partial', 'failure', 'cancelled', 'provisional', 'active'].includes(rawOutcome)
1565
          ? rawOutcome
1566
          : 'pending'
1567
      ) as AgentHistoryItem['outcome'];
1568
      return {
1569
        interactionId,
1570
        title: String(interaction.contract?.purpose ?? interaction.purpose ?? 'Interaction'),
1571
        counterpart: String(counterpart),
1572
        counterpartMode,
1573
        outcome,
1574
        at: String(
1575
          report?.completedAt ??
1576
            receipt?.issuedAt ??
1577
            conclusion?.concludedAt ??
1578
            interaction.completedAt ??
1579
            interaction.updatedAt ??
1580
            interaction.createdAt ??
1581
            new Date().toISOString(),
1582
        ),
1583
      };
1584
    })
1585
    .sort((left, right) => Date.parse(right.at) - Date.parse(left.at));
1586
}
1587
1588
function OutcomeSymbol({ outcome }: { outcome: AgentHistoryItem['outcome'] }) {
1589
  if (outcome === 'success')
1590
    return (
1591
      <span className="outcomeSymbol success">
1592
        <Check />
1593
      </span>
1594
    );
1595
  if (outcome === 'failure' || outcome === 'cancelled')
1596
    return (
1597
      <span className="outcomeSymbol failure">
1598
        <X />
1599
      </span>
1600
    );
1601
  if (outcome === 'partial' || outcome === 'provisional')
1602
    return <span className="outcomeSymbol partial">½</span>;
1603
  return (
1604
    <span className="outcomeSymbol pending">
1605
      <Circle />
1606
    </span>
1607
  );
1608
}
1609
1610
function Marketplace({ data }: { data: DashboardData }) {
1611
  const [query, setQuery] = useState('');
1612
  const [selectedAgentId, setSelectedAgentId] = useState(() =>
1613
    String(data.agents[0]?.agentId ?? ''),
1614
  );
1615
  const [taskCategory, setTaskCategory] = useState(() =>
1616
    String(data.agents[0]?.capabilities?.[0] ?? 'general'),
1617
  );
1618
  const [results, setResults] = useState<Record<string, any>[]>([]);
1619
  const [loading, setLoading] = useState(true);
1620
  const [error, setError] = useState('');
1621
1622
  useEffect(() => {
1623
    const controller = new AbortController();
1624
    const timer = window.setTimeout(() => {
1625
      const parameters = new URLSearchParams({ limit: '50' });
1626
      if (query.trim()) parameters.set('query', query.trim());
1627
      if (selectedAgentId) parameters.set('agentId', selectedAgentId);
1628
      if (taskCategory.trim()) parameters.set('taskCategory', taskCategory.trim());
1629
      remoteApi(`/v0.1/marketplace?${parameters}`, { signal: controller.signal })
1630
        .then((result) => setResults(result as Record<string, any>[]))
1631
        .catch((reason: unknown) => {
1632
          if (controller.signal.aborted) return;
1633
          setError(reason instanceof Error ? reason.message : 'Directory unavailable');
1634
          setResults(
1635
            data.agents
1636
              .filter((agent) =>
1637
                data.publications.some(
1638
                  (publication) => publication.agentId === agent.agentId && publication.published,
1639
                ),
1640
              )
1641
              .filter((agent) =>
1642
                `${agent.name} ${agent.description} ${(agent.capabilities ?? []).join(' ')}`
1643
                  .toLowerCase()
1644
                  .includes(query.toLowerCase()),
1645
              )
1646
              .map((card) => ({
1647
                card,
1648
                taskCategory,
1649
                match: {
1650
                  score: 0.5,
1651
                  label: 'possible',
1652
                  reasons: ['Preview capability match', 'No verified private history'],
1653
                },
1654
                contextualReliability: data.intelligenceSummaries.find(
1655
                  (summary) =>
1656
                    summary.agentId === card.agentId &&
1657
                    summary.taskCategory.toLowerCase() === taskCategory.toLowerCase(),
1658
                ),
1659
              })),
1660
          );
1661
        })
1662
        .finally(() => {
1663
          if (!controller.signal.aborted) setLoading(false);
1664
        });
1665
    }, 220);
1666
    return () => {
1667
      window.clearTimeout(timer);
1668
      controller.abort();
1669
    };
1670
  }, [
1671
    data.agents,
1672
    data.intelligenceSummaries,
1673
    data.publications,
1674
    query,
1675
    selectedAgentId,
1676
    taskCategory,
1677
  ]);
1678
1679
  return (
1680
    <div className="workspacePage marketplacePage">
1681
      <header className="workspaceHead marketplaceHead">
1682
        <div>
1683
          <p className="eyebrow">public directory</p>
1684
          <h1>Marketplace</h1>
1685
          <p>Find verified agents ready to work.</p>
1686
        </div>
1687
        <div className="marketFilters">
1688
          <label>
1689
            <span>For</span>
1690
            <select
1691
              value={selectedAgentId}
1692
              onChange={(event) => {
1693
                const agentId = event.target.value;
1694
                const agent = data.agents.find((item) => item.agentId === agentId);
1695
                setSelectedAgentId(agentId);
1696
                setTaskCategory(String(agent?.capabilities?.[0] ?? 'general'));
1697
                setLoading(true);
1698
              }}
1699
            >
1700
              {data.agents.map((agent) => (
1701
                <option value={agent.agentId} key={agent.agentId}>
1702
                  {agent.name ?? agent.agentId}
1703
                </option>
1704
              ))}
1705
            </select>
1706
          </label>
1707
          <label>
1708
            <span>Task</span>
1709
            <input
1710
              value={taskCategory}
1711
              onChange={(event) => {
1712
                setTaskCategory(event.target.value);
1713
                setLoading(true);
1714
              }}
1715
              placeholder="procurement"
1716
            />
1717
          </label>
1718
          <label className="marketSearch">
1719
            <Search />
1720
            <input
1721
              type="search"
1722
              value={query}
1723
              onChange={(event) => {
1724
                setLoading(true);
1725
                setError('');
1726
                setQuery(event.target.value);
1727
              }}
1728
              placeholder="Search agents"
1729
            />
1730
          </label>
1731
        </div>
1732
      </header>
1733
      <div className="marketContext">
1734
        Recommendations combine public capabilities with your private, verified history. Scores are
1735
        task-specific and confidence-adjusted.
1736
      </div>
1737
      {error && !results.length ? <div className="errorBar">{error}</div> : null}
1738
      <section className="marketGrid" aria-live="polite">
1739
        {loading ? (
1740
          Array.from({ length: 6 }, (_, index) => <div className="marketSkeleton" key={index} />)
1741
        ) : results.length ? (
1742
          results.map((result) => {
1743
            const agent = result.card;
1744
            const online = agent.presence?.status === 'online';
1745
            const temporary = agent.agentMode === 'temporary_chat';
1746
            const intelligence = result.contextualReliability;
1747
            return (
1748
              <article className="marketCard" key={agent.agentId}>
1749
                <div className="marketCardTop">
1750
                  <span className="agentAvatar">
1751
                    <Bot />
1752
                    <i className={online ? 'online' : ''} />
1753
                  </span>
1754
                  <span className={online ? 'marketPresence online' : 'marketPresence'}>
1755
                    {online ? 'Online' : 'Offline'}
1756
                  </span>
1757
                </div>
1758
                <div className={`matchBadge ${result.match.label}`}>
1759
                  <strong>{Math.round(Number(result.match.score) * 100)}%</strong>
1760
                  <span>
1761
                    {result.match.label} match for {result.taskCategory}
1762
                  </span>
1763
                </div>
1764
                <h2>{agent.name}</h2>
1765
                <p>{agent.description || 'Public OpenClasp agent'}</p>
1766
                <div className="marketTags">
1767
                  {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
1768
                    <span key={capability}>{capability}</span>
1769
                  ))}
1770
                </div>
1771
                <div className="marketMeta">
1772
                  <span>
1773
                    <ShieldCheck /> Verified
1774
                  </span>
1775
                  <span>
1776
                    {temporary ? <MessageCircle /> : <Cloud />} {temporary ? 'Temporary' : 'Cloud'}
1777
                  </span>
1778
                </div>
1779
                <div className="marketIntelligence">
1780
                  {intelligence ? (
1781
                    <>
1782
                      <strong>{Math.round(Number(intelligence.score) * 100)}%</strong>
1783
                      <span>
1784
                        contextual reliability · {intelligence.confidence.level} confidence ·{' '}
1785
                        {intelligence.confidence.evidenceCount} outcomes
1786
                      </span>
1787
                    </>
1788
                  ) : (
1789
                    <span>No verified history for this task</span>
1790
                  )}
1791
                </div>
1792
                <a
1793
                  className="marketProfileLink"
1794
                  href={agent.profileUrl ?? `/a/${encodeURIComponent(String(agent.agentId))}`}
1795
                  target="_blank"
1796
                  rel="noreferrer"
1797
                >
1798
                  View agent <ArrowRight />
1799
                </a>
1800
              </article>
1801
            );
1802
          })
1803
        ) : (
1804
          <div className="quietEmpty marketEmpty">No matching public agents</div>
1805
        )}
1806
      </section>
1807
    </div>
1808
  );
1809
}
1810
1811
function Overview({
1812
  data,
1813
  navigate,
1814
  refreshDashboard,
1815
  api,
1816
}: {
1817
  data: DashboardData;
1818
  navigate: (page: Page) => void;
1819
  refreshDashboard: () => Promise<void>;
1820
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1821
}) {
1822
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
1823
  const warnings = data.events.filter((item) =>
1824
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
1825
  ).length;
1826
  const publishedIds = new Set(
1827
    data.publications
1828
      .filter((publication) => publication.published)
1829
      .map((publication) => String(publication.agentId)),
1830
  );
1831
  const runtimeIds = new Set(
1832
    data.runtimes
1833
      .filter((runtime) => runtime.status === 'verified')
1834
      .map((runtime) => String(runtime.agentId)),
1835
  );
1836
  const modeOf = (agent: Record<string, any>) =>
1837
    agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
1838
  const readyAgents = data.agents.filter(
1839
    (agent) =>
1840
      publishedIds.has(String(agent.agentId)) &&
1841
      (modeOf(agent) === 'temporary_chat' || runtimeIds.has(String(agent.agentId))),
1842
  ).length;
1843
  const activeTemporaryChats = data.agents.filter(
1844
    (agent) => modeOf(agent) === 'temporary_chat' && agent.presence?.status === 'online',
1845
  ).length;
1846
  const pendingInvitations = data.federatedInteractions.filter(
1847
    (interaction) => interaction.status === 'pending',
1848
  ).length;
1849
  const pendingFeedback = data.feedbackRequests.filter(
1850
    (request) => request.status === 'pending',
1851
  ).length;
1852
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
1853
  const reviewCount = warnings + pendingInvitations + pendingFeedback;
1854
  return (
1855
    <>
1856
      <PageHead page="dashboard" action="Connect agent" onAction={() => navigate('connect')} />
1857
      {pendingSetup > 0 && (
1858
        <button className="attentionBanner" type="button" onClick={() => navigate('connect')}>
1859
          <div>
1860
            <span className="statusIndex">//</span>
1861
            <div>
1862
              <strong>
1863
                {pendingSetup} setup request{pendingSetup === 1 ? '' : 's'} waiting
1864
              </strong>
1865
            </div>
1866
          </div>
1867
        </button>
1868
      )}
1869
      {!(readyAgents === data.agents.length && readyAgents) && (
1870
        <section className="readiness">
1871
          <div>
1872
            <span className="statusIndex">!</span>
1873
            <div>
1874
              <strong>
1875
                {readyAgents} of {data.agents.length} agents can receive A2A work
1876
              </strong>
1877
            </div>
1878
          </div>
1879
          <button className="secondary" type="button" onClick={() => navigate('agents')}>
1880
            Manage
1881
          </button>
1882
        </section>
1883
      )}
1884
      <section className="metrics">
1885
        <Metric
1886
          label="Active temporary chats"
1887
          value={activeTemporaryChats}
1888
          note={`${runtimeIds.size} persistent runtimes connected`}
1889
        />
1890
        <Metric
1891
          label="Interactions"
1892
          value={data.interactions.length + data.federatedInteractions.length}
1893
          note="local and shared"
1894
        />
1895
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
1896
        <Metric
1897
          label="Needs review"
1898
          value={reviewCount}
1899
          note={`${pendingFeedback} feedback · ${pendingInvitations} invitations`}
1900
          warn={reviewCount > 0}
1901
        />
1902
      </section>
1903
      <Invitations data={data} refreshDashboard={refreshDashboard} api={api} />
1904
      <section className="contentGrid">
1905
        <Panel title="Recent activity" subtitle="">
1906
          <Timeline events={data.events.slice(-6).reverse()} />
1907
          <TextButton onClick={() => navigate('history')}>All history</TextButton>
1908
        </Panel>
1909
        <Panel title="Reliability" subtitle="">
1910
          {data.profiles.length ? (
1911
            data.profiles.slice(0, 4).map((profile) => {
1912
              const deltas = data.profileDeltas.filter(
1913
                (delta) =>
1914
                  delta.agentId === profile.agentId &&
1915
                  delta.agentVersion === profile.agentVersion &&
1916
                  delta.taskCategory === profile.taskCategory,
1917
              );
1918
              return (
1919
                <Profile
1920
                  key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
1921
                  profile={profile}
1922
                  deltas={deltas}
1923
                />
1924
              );
1925
            })
1926
          ) : (
1927
            <Empty
1928
              title="No reliability profile yet"
1929
              text="Profiles appear after signed receipts and eligible bilateral feedback."
1930
            />
1931
          )}
1932
        </Panel>
1933
      </section>
1934
    </>
1935
  );
1936
}
1937
1938
function Conversations({
1939
  data,
1940
  refreshDashboard,
1941
  api,
1942
}: {
1943
  data: DashboardData;
1944
  refreshDashboard: () => Promise<void>;
1945
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1946
}) {
1947
  const [selected, setSelected] = useState<Record<string, any> | null>(null);
1948
  const [selectedAgentId, setSelectedAgentId] = useState('');
1949
  const [draft, setDraft] = useState('');
1950
  const [working, setWorking] = useState(false);
1951
  const [error, setError] = useState('');
1952
  const temporaryAgentFor = (thread: Record<string, any>) =>
1953
    data.agents.find(
1954
      (agent) =>
1955
        thread.participantAgentIds?.includes(agent.agentId) &&
1956
        (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
1957
          'temporary_chat',
1958
    );
1959
  const openThread = async (thread: Record<string, any>) => {
1960
    const agent = temporaryAgentFor(thread);
1961
    if (!agent) return;
1962
    setWorking(true);
1963
    setError('');
1964
    try {
1965
      const [detail] = (await Promise.all([
1966
        api(
1967
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}`,
1968
        ),
1969
        api(
1970
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}/read`,
1971
          { method: 'POST' },
1972
        ),
1973
      ])) as [Record<string, any>, unknown];
1974
      setSelected(detail);
1975
      setSelectedAgentId(String(agent.agentId));
1976
      await refreshDashboard();
1977
    } catch (reason) {
1978
      setError(reason instanceof Error ? reason.message : 'Could not open conversation');
1979
    } finally {
1980
      setWorking(false);
1981
    }
1982
  };
1983
  const reply = async () => {
1984
    if (!selected || !draft.trim() || !selectedAgentId) return;
1985
    setWorking(true);
1986
    setError('');
1987
    try {
1988
      await api(`/v0.1/agents/${encodeURIComponent(selectedAgentId)}/messages`, {
1989
        method: 'POST',
1990
        body: JSON.stringify({
1991
          interactionId: selected.thread.interactionId,
1992
          content: draft.trim(),
1993
        }),
1994
      });
1995
      setDraft('');
1996
      await openThread(selected.thread);
1997
    } catch (reason) {
1998
      setError(reason instanceof Error ? reason.message : 'Could not send reply');
1999
      setWorking(false);
2000
    }
2001
  };
2002
  return (
2003
    <>
2004
      <PageHead page="conversations" />
2005
      <div className="notice">
2006
        <strong>Temporary only.</strong> Encrypted 30 days. Persistent runtime messages stay direct.
2007
      </div>
2008
      {error ? (
2009
        <div className="errorBar" role="alert">
2010
          {error}
2011
        </div>
2012
      ) : null}
2013
      <section className="conversationLayout">
2014
        <Panel title="Threads" subtitle="Temporary chat identities only">
2015
          <div className="threadList">
2016
            {data.hostedThreads.length ? (
2017
              data.hostedThreads.map((thread) => {
2018
                const agent = temporaryAgentFor(thread);
2019
                const peer = thread.participantAgentIds?.find(
2020
                  (value: string) => value !== agent?.agentId,
2021
                );
2022
                return (
2023
                  <button
2024
                    type="button"
2025
                    key={thread.threadId}
2026
                    className={selected?.thread?.threadId === thread.threadId ? 'active' : ''}
2027
                    onClick={() => void openThread(thread)}
2028
                    disabled={working}
2029
                  >
2030
                    <span>
2031
                      <strong>{peer ?? 'Counterparty'}</strong>
2032
                      <small>{agent?.name ?? agent?.agentId}</small>
2033
                    </span>
2034
                    <b>{thread.unreadCount ? `${thread.unreadCount} NEW` : thread.status}</b>
2035
                  </button>
2036
                );
2037
              })
2038
            ) : (
2039
              <Empty
2040
                title="No temporary conversations"
2041
                text="Messages appear after a temporary chat identity accepts an interaction with a persistent runtime."
2042
              />
2043
            )}
2044
          </div>
2045
        </Panel>
2046
        <Panel
2047
          title={selected ? 'Conversation' : 'Select a thread'}
2048
          subtitle="Private insights are attributable and task-specific"
2049
        >
2050
          {selected ? (
2051
            <div className="conversationDetail">
2052
              {!!selected.insights?.length && (
2053
                <div className="conversationInsights">
2054
                  {selected.insights.map((insight: Record<string, any>) => (
2055
                    <p key={insight.code}>
2056
                      <b>{String(insight.severity).toUpperCase()}</b> {insight.message}
2057
                    </p>
2058
                  ))}
2059
                </div>
2060
              )}
2061
              <div className="messageList">
2062
                {selected.messages.map((message: Record<string, any>) => (
2063
                  <article
2064
                    key={message.messageId}
2065
                    className={message.senderAgentId === selectedAgentId ? 'sent' : 'received'}
2066
                  >
2067
                    <small>{message.senderAgentId === selectedAgentId ? 'You' : 'Peer'}</small>
2068
                    <p>{message.content}</p>
2069
                    <span>{new Date(message.createdAt).toLocaleString()}</span>
2070
                  </article>
2071
                ))}
2072
              </div>
2073
              <div className="replyBox">
2074
                <textarea
2075
                  aria-label="Temporary chat reply"
2076
                  value={draft}
2077
                  onChange={(event) => setDraft(event.target.value)}
2078
                  maxLength={20_000}
2079
                  placeholder="Reply through the temporary OpenClasp A2A adapter"
2080
                />
2081
                <button
2082
                  className="primary"
2083
                  type="button"
2084
                  disabled={working || !draft.trim() || selected.thread.status !== 'open'}
2085
                  onClick={() => void reply()}
2086
                >
2087
                  {working ? 'Sending…' : 'Send reply'}
2088
                </button>
2089
              </div>
2090
            </div>
2091
          ) : (
2092
            <Empty title="Nothing selected" text="Choose a thread to read its hosted history." />
2093
          )}
2094
        </Panel>
2095
      </section>
2096
    </>
2097
  );
2098
}
2099
2100
function History({ data }: { data: DashboardData }) {
2101
  const [query, setQuery] = useState('');
2102
  const [status, setStatus] = useState<
2103
    'all' | 'pending' | 'active' | 'provisional' | 'finalizing' | 'completed'
2104
  >('all');
2105
  const journeys = useMemo(() => interactionJourneys(data), [data]);
2106
  const filtered = useMemo(() => {
2107
    const needle = query.trim().toLowerCase();
2108
    return journeys.filter((journey) => {
2109
      if (status !== 'all' && journey.status !== status) return false;
2110
      if (!needle) return true;
2111
      const hay = [
2112
        journey.interactionId,
2113
        journey.purpose,
2114
        journey.taskCategory,
2115
        journey.status,
2116
        ...journey.participants,
2117
      ]
2118
        .filter(Boolean)
2119
        .join(' ')
2120
        .toLowerCase();
2121
      return hay.includes(needle);
2122
    });
2123
  }, [journeys, query, status]);
2124
  const [selectedId, setSelectedId] = useState('');
2125
  const selected = filtered.find((journey) => journey.interactionId === selectedId) ?? filtered[0];
2126
  return (
2127
    <>
2128
      <PageHead page="history" />
2129
      <div className="historyToolbar">
2130
        <Input
2131
          value={query}
2132
          onChange={(event) => setQuery(event.target.value)}
2133
          placeholder="Search purpose, category, or agent"
2134
          aria-label="Search interaction history"
2135
          className="h-9 min-w-[220px] flex-1 rounded-none border-0 border-b bg-transparent px-0 shadow-none dark:bg-transparent"
2136
        />
2137
        <Tabs
2138
          value={status === 'finalizing' ? 'all' : status}
2139
          onValueChange={(value) =>
2140
            setStatus(value as 'all' | 'pending' | 'active' | 'provisional' | 'completed')
2141
          }
2142
        >
2143
          <TabsList variant="line" aria-label="Interaction status filters">
2144
            {(['all', 'pending', 'active', 'provisional', 'completed'] as const).map((value) => (
2145
              <TabsTrigger key={value} value={value} className="capitalize">
2146
                {value}
2147
                <span className="font-mono text-[10px] text-muted-foreground">
2148
                  {value === 'all'
2149
                    ? journeys.length
2150
                    : journeys.filter((item) => item.status === value).length}
2151
                </span>
2152
              </TabsTrigger>
2153
            ))}
2154
          </TabsList>
2155
        </Tabs>
2156
      </div>
2157
      {filtered.length ? (
2158
        <section className="journeyLayout">
2159
          <div className="journeyList" aria-label="Interactions">
2160
            {filtered.map((journey) => (
2161
              <button
2162
                key={journey.interactionId}
2163
                className={selected?.interactionId === journey.interactionId ? 'active' : ''}
2164
                type="button"
2165
                onClick={() => {
2166
                  setSelectedId(journey.interactionId);
2167
                  if (matchMedia('(max-width: 980px)').matches)
2168
                    requestAnimationFrame(() =>
2169
                      document
2170
                        .querySelector('.journeyDetail')
2171
                        ?.scrollIntoView({ behavior: 'smooth' }),
2172
                    );
2173
                }}
2174
              >
2175
                <span className="journeyListTop">
2176
                  <b>{journey.taskCategory}</b>
2177
                  <StatusPill value={journey.status} />
2178
                </span>
2179
                <strong>{journey.purpose}</strong>
2180
                <small>{journey.participants.join(' ↔ ') || 'Participants unavailable'}</small>
2181
                <span className="journeyListMeta">
2182
                  {relativeTime(journey.updatedAt)}
2183
                  <i>{journey.stepCount} recorded steps</i>
2184
                </span>
2185
              </button>
2186
            ))}
2187
          </div>
2188
          {selected ? <InteractionJourney journey={selected} /> : null}
2189
        </section>
2190
      ) : (
2191
        <section className="panel">
2192
          <Empty
2193
            title={journeys.length ? 'No matching interactions' : 'No interactions recorded'}
2194
            text={
2195
              journeys.length
2196
                ? 'Clear the search or choose another status.'
2197
                : 'Completed contracts, outcomes, feedback, and learning will appear as one readable journey.'
2198
            }
2199
          />
2200
        </section>
2201
      )}
2202
    </>
2203
  );
2204
}
2205
2206
type InteractionJourneyModel = {
2207
  interactionId: string;
2208
  purpose: string;
2209
  taskCategory: string;
2210
  status: string;
2211
  participants: string[];
2212
  updatedAt: string;
2213
  stepCount: number;
2214
  interaction: Record<string, any>;
2215
  session?: Record<string, any>;
2216
  events: Record<string, any>[];
2217
  briefs: Record<string, any>[];
2218
  reports: Record<string, any>[];
2219
  feedbackRequests: Record<string, any>[];
2220
  feedback: Record<string, any>[];
2221
  conclusion?: Record<string, any>;
2222
  receipt?: Record<string, any>;
2223
  eligibility?: Record<string, any>;
2224
  deltas: Record<string, any>[];
2225
  conflicts: Record<string, any>[];
2226
};
2227
2228
function interactionJourneys(data: DashboardData): InteractionJourneyModel[] {
2229
  const ids = new Set<string>();
2230
  const collections = [
2231
    data.interactions,
2232
    data.federatedInteractions,
2233
    data.liveSessions,
2234
    data.events,
2235
    data.counterpartyBriefs,
2236
    data.completionReports,
2237
    data.feedbackRequests,
2238
    data.interactionFeedback,
2239
    data.interactionConclusions,
2240
    data.receipts,
2241
    data.learningEligibility,
2242
    data.profileDeltas,
2243
    data.conflicts,
2244
  ];
2245
  for (const collection of collections)
2246
    for (const item of collection) if (item.interactionId) ids.add(String(item.interactionId));
2247
  return [...ids]
2248
    .map((interactionId) => {
2249
      const interaction = data.federatedInteractions.find(
2250
        (item) => item.interactionId === interactionId,
2251
      ) ??
2252
        data.interactions.find((item) => item.interactionId === interactionId) ?? { interactionId };
2253
      const session = data.liveSessions.find((item) => item.interactionId === interactionId);
2254
      const events = data.events.filter((item) => item.interactionId === interactionId);
2255
      const briefs = data.counterpartyBriefs.filter((item) => item.interactionId === interactionId);
2256
      const reports = data.completionReports.filter((item) => item.interactionId === interactionId);
2257
      const feedbackRequests = data.feedbackRequests.filter(
2258
        (item) => item.interactionId === interactionId,
2259
      );
2260
      const feedback = data.interactionFeedback.filter(
2261
        (item) => item.interactionId === interactionId,
2262
      );
2263
      const conclusion = data.interactionConclusions.find(
2264
        (item) => item.interactionId === interactionId,
2265
      );
2266
      const receipt = data.receipts.find((item) => item.interactionId === interactionId);
2267
      const eligibility = data.learningEligibility.find(
2268
        (item) => item.interactionId === interactionId,
2269
      );
2270
      const deltas = data.profileDeltas.filter((item) => item.interactionId === interactionId);
2271
      const conflicts = data.conflicts.filter((item) => item.interactionId === interactionId);
2272
      const participants = [
2273
        interaction.initiatorAgentId,
2274
        interaction.responderAgentId,
2275
        interaction.agentId,
2276
        ...reports.flatMap((report) => [report.reportingAgentId, report.counterpartyAgentId]),
2277
      ]
2278
        .filter((value): value is string => typeof value === 'string' && Boolean(value))
2279
        .filter((value, index, values) => values.indexOf(value) === index);
2280
      const dates = [
2281
        interaction.updatedAt,
2282
        interaction.createdAt,
2283
        session?.completedAt,
2284
        session?.activatedAt,
2285
        ...events.map(timestamp),
2286
        ...reports.map(timestamp),
2287
        ...feedback.map(timestamp),
2288
        conclusion?.generatedAt,
2289
        receipt?.completedAt,
2290
        eligibility?.decidedAt,
2291
      ].filter((value): value is string => typeof value === 'string');
2292
      return {
2293
        interactionId,
2294
        purpose: String(
2295
          interaction.contract?.purpose ??
2296
            interaction.contract?.requestedOutcome ??
2297
            conclusion?.summary ??
2298
            'Assured agent interaction',
2299
        ),
2300
        taskCategory: String(interaction.contract?.taskCategory ?? 'general'),
2301
        status: String(
2302
          conclusion || receipt
2303
            ? conclusion?.lifecycle === 'provisional' || receipt?.provisional
2304
              ? 'provisional'
2305
              : 'completed'
2306
            : reports.length > 0
2307
              ? 'finalizing'
2308
              : (session?.status ?? interaction.status ?? 'pending'),
2309
        ),
2310
        participants,
2311
        updatedAt:
2312
          dates.sort((left, right) => Date.parse(right) - Date.parse(left))[0] ??
2313
          new Date(0).toISOString(),
2314
        stepCount:
2315
          1 +
2316
          Math.max(0, Number(interaction.contractRevisions?.length ?? 0) - 1) +
2317
          events.length +
2318
          briefs.length +
2319
          reports.length +
2320
          feedbackRequests.length +
2321
          feedback.length +
2322
          conflicts.length +
2323
          Number(Boolean(conclusion)) +
2324
          Number(Boolean(receipt)) +
2325
          Number(Boolean(eligibility)) +
2326
          deltas.length,
2327
        interaction,
2328
        ...(session ? { session } : {}),
2329
        events,
2330
        briefs,
2331
        reports,
2332
        feedbackRequests,
2333
        feedback,
2334
        ...(conclusion ? { conclusion } : {}),
2335
        ...(receipt ? { receipt } : {}),
2336
        ...(eligibility ? { eligibility } : {}),
2337
        deltas,
2338
        conflicts,
2339
      };
2340
    })
2341
    .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
2342
}
2343
2344
function InteractionJourney({ journey }: { journey: InteractionJourneyModel }) {
2345
  const timeline = interactionTimeline(journey);
2346
  const conclusion = journey.conclusion;
2347
  const checkpoints = journey.events.filter(
2348
    (event) => (event.type ?? event.eventType) === 'progress_checkpoint' && event.checkpoint,
2349
  );
2350
  const latestCheckpoint = checkpoints.at(-1)?.checkpoint;
2351
  const progress = Number(latestCheckpoint?.progress ?? 0);
2352
  const submittedFeedback = new Set(
2353
    journey.feedback.map((item) => item.reviewerAgentId).filter(Boolean),
2354
  ).size;
2355
  return (
2356
    <article className="journeyDetail">
2357
      <header className="journeyHero">
2358
        <div>
2359
          <span className="eyebrow">{journey.taskCategory}</span>
2360
          <h2>{journey.purpose}</h2>
2361
          <p>{journey.participants.join(' ↔ ')}</p>
2362
        </div>
2363
        <StatusPill value={conclusion?.outcome ?? journey.status} />
2364
      </header>
2365
2366
      <div className="journeySummaryGrid">
2367
        <section>
2368
          <span>Progress</span>
2369
          <strong>
2370
            {latestCheckpoint
2371
              ? `${Math.round(progress * 100)}% · ${humanize(latestCheckpoint.state)}`
2372
              : journey.reports.length
2373
                ? 'One side ended'
2374
                : 'Awaiting checkpoint'}
2375
          </strong>
2376
          <small>
2377
            {latestCheckpoint
2378
              ? `${humanize(latestCheckpoint.topicStatus)} · ${Math.round(Number(latestCheckpoint.confidence ?? 0) * 100)}% confidence`
2379
              : 'Agents report compact progress every few exchanges'}
2380
          </small>
2381
        </section>
2382
        <section>
2383
          <span>Outcome</span>
2384
          <strong>
2385
            {conclusion
2386
              ? humanize(conclusion.outcome)
2387
              : `${journey.reports.length}/2 terminal reports`}
2388
          </strong>
2389
          <small>
2390
            {conclusion
2391
              ? conclusion.lifecycle === 'provisional'
2392
                ? `${Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer ${humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()}`
2393
                : humanize(conclusion.consensus)
2394
              : journey.reports.length === 1
2395
                ? 'One agent finished; confirming with the peer'
2396
                : 'Conversation remains active'}
2397
          </small>
2398
        </section>
2399
        <section>
2400
          <span>Sealed feedback</span>
2401
          <strong>
2402
            {journey.feedbackRequests.some((request) => request.status === 'pending')
2403
              ? `${submittedFeedback}/2 sealed`
2404
              : 'Window closed'}
2405
          </strong>
2406
          <small>{feedbackState(journey.feedbackRequests)}</small>
2407
        </section>
2408
        <section>
2409
          <span>Learning</span>
2410
          <strong>
2411
            {journey.eligibility
2412
              ? journey.eligibility.eligible
2413
                ? `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% weight`
2414
                : 'Not eligible'
2415
              : conclusion?.lifecycle === 'provisional'
2416
                ? 'Pending corroboration'
2417
                : 'Not assessed'}
2418
          </strong>
2419
          <small>{humanize(journey.eligibility?.contributionMode ?? 'Local only')}</small>
2420
        </section>
2421
      </div>
2422
2423
      {latestCheckpoint ? (
2424
        <section className="progressCard">
2425
          <div className="progressCardTop">
2426
            <div>
2427
              <span className="eyebrow">LATEST PRIVATE CHECKPOINT</span>
2428
              <h3>{humanize(latestCheckpoint.state)}</h3>
2429
            </div>
2430
            <strong>{Math.round(progress * 100)}%</strong>
2431
          </div>
2432
          <div
2433
            className="progressTrack"
2434
            role="progressbar"
2435
            aria-label="Interaction progress"
2436
            aria-valuemin={0}
2437
            aria-valuemax={100}
2438
            aria-valuenow={Math.round(progress * 100)}
2439
          >
2440
            <i style={{ width: `${Math.round(progress * 100)}%` }} />
2441
          </div>
2442
          <div className="progressFacts">
2443
            <span>{latestCheckpoint.criteriaMet?.length ?? 0} criteria met</span>
2444
            <span>{latestCheckpoint.criteriaRemaining?.length ?? 0} remaining</span>
2445
            <span>{latestCheckpoint.expectedRemainingTurns ?? '—'} expected turns</span>
2446
            <span>
2447
              {latestCheckpoint.needsHuman ? 'Human input needed' : 'No human input needed'}
2448
            </span>
2449
          </div>
2450
          {latestCheckpoint.topicStatus !== 'in_scope' ? (
2451
            <p className="progressWarning">
2452
              Topic {humanize(latestCheckpoint.topicStatus)}. Amend the contract or start a new
2453
              interaction before continuing.
2454
            </p>
2455
          ) : null}
2456
        </section>
2457
      ) : null}
2458
2459
      {conclusion ? (
2460
        <section className="outcomeCard">
2461
          <div>
2462
            <span className="eyebrow">
2463
              {conclusion.lifecycle === 'provisional' ? 'PROVISIONAL INSIGHT' : 'WHAT HAPPENED'}
2464
            </span>
2465
            <h3>{conclusion.summary}</h3>
2466
            {conclusion.lifecycle === 'provisional' ? (
2467
              <p className="progressWarning">
2468
                Based on {conclusion.reportIds?.length ?? 1} participant report. Missing:{' '}
2469
                {(conclusion.missingReportAgentIds ?? []).join(', ') || 'peer report'}. This is
2470
                usable now at reduced confidence and will be revised if the peer responds.
2471
              </p>
2472
            ) : null}
2473
          </div>
2474
          {!!conclusion.criteria?.length && (
2475
            <ul>
2476
              {conclusion.criteria.map((criterion: Record<string, any>) => (
2477
                <li key={criterion.criterion}>
2478
                  <StatusPill value={String(criterion.status)} />
2479
                  <span>{criterion.criterion}</span>
2480
                </li>
2481
              ))}
2482
            </ul>
2483
          )}
2484
          {!!Object.keys(conclusion.averageRatings ?? {}).length && (
2485
            <div className="ratingGrid">
2486
              {Object.entries(conclusion.averageRatings).map(([label, value]) => (
2487
                <Meter key={label} label={humanize(label)} value={Number(value)} />
2488
              ))}
2489
            </div>
2490
          )}
2491
        </section>
2492
      ) : (
2493
        <div className="journeyNotice">
2494
          {journey.reports.length === 1
2495
            ? 'Finalizing: one agent submitted its terminal report. OpenClasp has requested independent confirmation from the peer.'
2496
            : journey.reports.length >= 2
2497
              ? 'Both agents finished. Sealed feedback remains private until both respond or the feedback window expires.'
2498
              : 'Active interaction. Progress checkpoints appear after several meaningful exchanges; either agent can finalize immediately when the task is done.'}
2499
        </div>
2500
      )}
2501
2502
      <section className="journeyTimelineSection">
2503
        <div>
2504
          <h3>Interaction timeline</h3>
2505
          <small>Contract → session → outcome → feedback → learning</small>
2506
        </div>
2507
        <ol className="journeyTimeline">
2508
          {timeline.map((step, index) => (
2509
            <li key={`${step.at}-${step.title}-${index}`}>
2510
              <span className={`journeyNode ${step.tone}`} />
2511
              <div>
2512
                <span>{formatDate(step.at)}</span>
2513
                <strong>{step.title}</strong>
2514
                <p>{step.detail}</p>
2515
              </div>
2516
              <StatusPill value={step.status} />
2517
            </li>
2518
          ))}
2519
        </ol>
2520
      </section>
2521
    </article>
2522
  );
2523
}
2524
2525
function interactionTimeline(journey: InteractionJourneyModel) {
2526
  const revisions = journey.interaction.contractRevisions ?? [];
2527
  const contractSteps = revisions.length
2528
    ? revisions.map((revision: Record<string, any>) => ({
2529
        at: String(revision.updatedAt ?? revision.createdAt ?? journey.interaction.createdAt),
2530
        title: `Contract v${revision.revision} ${humanize(revision.status)}`,
2531
        detail: `${revision.proposedByAgentId} · ${Object.keys(revision.acceptances ?? {}).length}/${journey.participants.length || 2} accepted · ${String(revision.termsHash).slice(0, 12)}…`,
2532
        status: String(revision.status),
2533
        tone:
2534
          revision.status === 'accepted'
2535
            ? 'good'
2536
            : revision.status === 'rejected'
2537
              ? 'danger'
2538
              : revision.status === 'proposed'
2539
                ? 'warn'
2540
                : 'neutral',
2541
      }))
2542
    : [
2543
        {
2544
          at: String(journey.interaction.createdAt ?? journey.updatedAt),
2545
          title: 'Contract proposed',
2546
          detail: `${journey.participants.length || 2} participants · ${journey.taskCategory}`,
2547
          status: String(journey.interaction.status ?? 'recorded'),
2548
          tone: 'neutral',
2549
        },
2550
        ...Object.values(journey.interaction.acceptances ?? {}).map((acceptance: any) => ({
2551
          at: String(acceptance.acceptedAt ?? journey.interaction.updatedAt ?? journey.updatedAt),
2552
          title: `Contract accepted by ${acceptance.agentId}`,
2553
          detail: `Acceptance method: ${humanize(acceptance.method ?? 'recorded')}`,
2554
          status: 'accepted',
2555
          tone: 'good',
2556
        })),
2557
      ];
2558
  const steps: { at: string; title: string; detail: string; status: string; tone: string }[] = [
2559
    ...contractSteps,
2560
    ...journey.briefs.map((brief) => ({
2561
      at: String(brief.generatedAt ?? journey.updatedAt),
2562
      title: `Private counterparty brief for ${brief.recipientAgentId}`,
2563
      detail: `${brief.relevantSampleSize ?? 0} relevant samples · ${Math.round(Number(brief.historyConfidence ?? 0) * 100)}% history confidence`,
2564
      status: String(brief.decision ?? 'ready'),
2565
      tone: brief.decision === 'DENY' ? 'danger' : brief.decision === 'CHALLENGE' ? 'warn' : 'good',
2566
    })),
2567
    ...(journey.session?.activatedAt
2568
      ? [
2569
          {
2570
            at: String(journey.session.activatedAt),
2571
            title: 'A2A session activated',
2572
            detail:
2573
              'Scoped credentials were issued. Persistent runtimes exchange content directly; temporary mode uses the hosted adapter.',
2574
            status: 'active',
2575
            tone: 'good',
2576
          },
2577
        ]
2578
      : []),
2579
    ...journey.events.map((event) => {
2580
      const eventType = String(event.type ?? event.eventType ?? 'structured_event');
2581
      const checkpoint = event.checkpoint;
2582
      return {
2583
        at: timestamp(event),
2584
        title:
2585
          eventType === 'progress_checkpoint'
2586
            ? `${Math.round(Number(checkpoint?.progress ?? 0) * 100)}% progress checkpoint`
2587
            : humanize(eventType),
2588
        detail:
2589
          eventType === 'progress_checkpoint'
2590
            ? `${event.agentId} · ${humanize(checkpoint?.state ?? 'active')} · ${humanize(checkpoint?.topicStatus ?? 'in_scope')}`
2591
            : String(event.agentId ?? 'OpenClasp') + ' submitted structured metadata.',
2592
        status: String(checkpoint?.state ?? event.visibility ?? 'recorded'),
2593
        tone:
2594
          checkpoint?.topicStatus === 'changed' || checkpoint?.state === 'blocked'
2595
            ? 'warn'
2596
            : eventType.includes('violation')
2597
              ? 'danger'
2598
              : eventType.includes('warning')
2599
                ? 'warn'
2600
                : checkpoint
2601
                  ? 'good'
2602
                  : 'neutral',
2603
      };
2604
    }),
2605
    ...journey.reports.map((report) => ({
2606
      at: timestamp(report),
2607
      title: `Completion reported by ${report.reportingAgentId}`,
2608
      detail: String(report.summary ?? 'Structured completion report submitted.'),
2609
      status: String(report.outcome ?? 'reported'),
2610
      tone:
2611
        report.outcome === 'failure' ? 'danger' : report.outcome === 'partial' ? 'warn' : 'good',
2612
    })),
2613
    ...journey.feedback.map((item) => ({
2614
      at: timestamp(item),
2615
      title: `Feedback submitted by ${item.reviewerAgentId}`,
2616
      detail: `Would work again: ${humanize(item.wouldWorkAgain ?? 'unsure')} · private comment concealed`,
2617
      status: 'sealed',
2618
      tone: 'neutral',
2619
    })),
2620
    ...journey.feedbackRequests
2621
      .filter((request) => request.status !== 'submitted')
2622
      .map((request) => ({
2623
        at: String(request.requestedAt ?? journey.updatedAt),
2624
        title: `Feedback request ${humanize(request.status)}`,
2625
        detail:
2626
          request.status === 'pending'
2627
            ? `Response due ${formatDate(String(request.dueAt))}`
2628
            : 'The bilateral conclusion continued without this response.',
2629
        status: String(request.status),
2630
        tone: request.status === 'pending' ? 'warn' : 'neutral',
2631
      })),
2632
    ...journey.conflicts.map((conflict) => ({
2633
      at: timestamp(conflict),
2634
      title: 'Dispute recorded',
2635
      detail: String(conflict.summary ?? 'The interaction has a structured dispute record.'),
2636
      status: String(conflict.status ?? 'open'),
2637
      tone: conflict.status === 'resolved' ? 'good' : 'danger',
2638
    })),
2639
    ...(journey.conclusion
2640
      ? [
2641
          {
2642
            at: String(journey.conclusion.generatedAt),
2643
            title:
2644
              journey.conclusion.lifecycle === 'provisional'
2645
                ? 'Provisional insight generated'
2646
                : 'Conclusion released',
2647
            detail: String(journey.conclusion.summary),
2648
            status: String(journey.conclusion.consensus),
2649
            tone:
2650
              journey.conclusion.lifecycle === 'provisional' ||
2651
              journey.conclusion.consensus === 'conflicting'
2652
                ? 'warn'
2653
                : 'good',
2654
          },
2655
        ]
2656
      : []),
2657
    ...(journey.receipt
2658
      ? [
2659
          {
2660
            at: timestamp(journey.receipt),
2661
            title: journey.receipt.provisional
2662
              ? 'Provisional outcome attested'
2663
              : 'Outcome receipt attested',
2664
            detail: journey.receipt.provisional
2665
              ? 'The available one-sided report was sealed without claiming peer agreement.'
2666
              : 'Contract result, commitment status, and evidence hashes were sealed.',
2667
            status: String(journey.receipt.outcome ?? 'signed'),
2668
            tone: journey.receipt.outcome === 'failure' ? 'danger' : 'good',
2669
          },
2670
        ]
2671
      : []),
2672
    ...(journey.eligibility
2673
      ? [
2674
          {
2675
            at: String(journey.eligibility.decidedAt),
2676
            title: journey.eligibility.eligible
2677
              ? 'Eligible history applied'
2678
              : 'History excluded from profile',
2679
            detail: `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% sample weight · ${humanize(journey.eligibility.contributionMode)}`,
2680
            status: journey.eligibility.eligible ? 'attested' : 'excluded',
2681
            tone: journey.eligibility.eligible ? 'good' : 'warn',
2682
          },
2683
        ]
2684
      : []),
2685
  ];
2686
  return steps.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
2687
}
2688
2689
function Agents({
2690
  data,
2691
  navigate,
2692
  refreshDashboard,
2693
  api,
2694
}: {
2695
  data: DashboardData;
2696
  navigate: (page: Page) => void;
2697
  refreshDashboard: () => Promise<void>;
2698
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2699
}) {
2700
  const [working, setWorking] = useState('');
2701
  const [error, setError] = useState('');
2702
  const saveAutomation = async (
2703
    agentId: string,
2704
    value: {
2705
      autoPublish: boolean;
2706
      autoAcceptPolicy: 'off' | 'safe_matching';
2707
      autoAcceptTaskCategories: string[];
2708
    },
2709
  ) => {
2710
    setWorking(agentId);
2711
    setError('');
2712
    try {
2713
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
2714
        method: 'PUT',
2715
        body: JSON.stringify(value),
2716
      });
2717
      await refreshDashboard();
2718
      return true;
2719
    } catch (reason) {
2720
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
2721
      return false;
2722
    } finally {
2723
      setWorking('');
2724
    }
2725
  };
2726
  const saveRuntime = async (agentId: string, endpoint: string) => {
2727
    setWorking(`runtime:${agentId}`);
2728
    setError('');
2729
    try {
2730
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
2731
        method: 'PUT',
2732
        body: JSON.stringify({ endpoint }),
2733
      });
2734
      await refreshDashboard();
2735
    } catch (reason) {
2736
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
2737
    } finally {
2738
      setWorking('');
2739
    }
2740
  };
2741
  const disableRuntime = async (agentId: string) => {
2742
    setWorking(`runtime:${agentId}`);
2743
    setError('');
2744
    try {
2745
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
2746
      await refreshDashboard();
2747
    } catch (reason) {
2748
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
2749
    } finally {
2750
      setWorking('');
2751
    }
2752
  };
2753
  const revokeAccessToken = async (agentId: string, tokenId: string) => {
2754
    setWorking(`token:${agentId}`);
2755
    setError('');
2756
    try {
2757
      await api(
2758
        `/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens/${encodeURIComponent(tokenId)}`,
2759
        { method: 'DELETE' },
2760
      );
2761
      await refreshDashboard();
2762
    } catch (reason) {
2763
      setError(reason instanceof Error ? reason.message : 'Could not revoke agent access token');
2764
    } finally {
2765
      setWorking('');
2766
    }
2767
  };
2768
  const deleteAgent = async (agent: Record<string, any>) => {
2769
    const agentId = String(agent.agentId);
2770
    if (
2771
      !window.confirm(
2772
        `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.`,
2773
      )
2774
    )
2775
      return;
2776
    setWorking(`delete:${agentId}`);
2777
    setError('');
2778
    try {
2779
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
2780
      await refreshDashboard();
2781
    } catch (reason) {
2782
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
2783
    } finally {
2784
      setWorking('');
2785
    }
2786
  };
2787
  return (
2788
    <>
2789
      <PageHead page="agents" action="Connect agent" onAction={() => navigate('connect')} />
2790
      <div className="notice">
2791
        <strong>Two explicit modes.</strong> Persistent runtimes talk directly over A2A. Temporary
2792
        chats use an OpenClasp-hosted endpoint with encrypted-at-rest history. There is no offline
2793
        relay for persistent runtimes.
2794
      </div>
2795
      {error && (
2796
        <div className="errorBar" role="alert">
2797
          {error}
2798
        </div>
2799
      )}
2800
      <section className="agentGrid">
2801
        {data.agents.length ? (
2802
          data.agents.map((agent) => (
2803
            <AgentCard
2804
              key={agent.agentId}
2805
              agent={agent}
2806
              projectName={
2807
                data.projects.find((project) => project.projectId === agent.projectId)?.name
2808
              }
2809
              published={data.publications.some(
2810
                (publication) => publication.agentId === agent.agentId && publication.published,
2811
              )}
2812
              working={working === agent.agentId}
2813
              onSave={(value) => saveAutomation(agent.agentId, value)}
2814
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
2815
              runtimeWorking={working === `runtime:${agent.agentId}`}
2816
              deleteWorking={working === `delete:${agent.agentId}`}
2817
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
2818
              onDisableRuntime={() => disableRuntime(agent.agentId)}
2819
              accessTokens={data.accessTokens.filter(
2820
                (token) =>
2821
                  token.agentId === agent.agentId &&
2822
                  !token.revokedAt &&
2823
                  Date.parse(String(token.expiresAt)) > Date.now(),
2824
              )}
2825
              accessTokenWorking={working === `token:${agent.agentId}`}
2826
              onRevokeAccessToken={(tokenId) => revokeAccessToken(agent.agentId, tokenId)}
2827
              onDelete={() => deleteAgent(agent)}
2828
            />
2829
          ))
2830
        ) : (
2831
          <Empty
2832
            title="No agents connected"
2833
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
2834
            action="Open connection guide"
2835
            onAction={() => navigate('connect')}
2836
          />
2837
        )}
2838
      </section>
2839
    </>
2840
  );
2841
}
2842
2843
function Invitations({
2844
  data,
2845
  refreshDashboard,
2846
  api,
2847
}: {
2848
  data: DashboardData;
2849
  refreshDashboard: () => Promise<void>;
2850
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2851
}) {
2852
  const [working, setWorking] = useState('');
2853
  const [error, setError] = useState('');
2854
  const ownedAgentIds = useMemo(
2855
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
2856
    [data.agents],
2857
  );
2858
  const currentProposal = (interaction: Record<string, any>) =>
2859
    [...(interaction.contractRevisions ?? [])]
2860
      .reverse()
2861
      .find((revision) => revision.status === 'proposed');
2862
  const respondingAgent = (interaction: Record<string, any>) => {
2863
    const proposal = currentProposal(interaction);
2864
    return (interaction.contract?.parties ?? []).find(
2865
      (agentId: string) => ownedAgentIds.has(String(agentId)) && !proposal?.acceptances?.[agentId],
2866
    );
2867
  };
2868
  const incoming = data.federatedInteractions.filter(
2869
    (interaction) => currentProposal(interaction) && respondingAgent(interaction),
2870
  );
2871
  const respond = async (
2872
    interactionId: string,
2873
    revisionId: string,
2874
    agentId: string,
2875
    decision: 'accept' | 'reject',
2876
  ) => {
2877
    setWorking(interactionId);
2878
    setError('');
2879
    try {
2880
      await api(
2881
        `/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/contract-proposals/${encodeURIComponent(revisionId)}/respond`,
2882
        {
2883
          method: 'POST',
2884
          body: JSON.stringify({ agentId, decision }),
2885
        },
2886
      );
2887
      await refreshDashboard();
2888
    } catch (reason) {
2889
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
2890
    } finally {
2891
      setWorking('');
2892
    }
2893
  };
2894
  if (!incoming.length && !data.federatedInteractions.length) return null;
2895
  return (
2896
    <Panel
2897
      title={incoming.length ? `Contract decisions (${incoming.length})` : 'Shared interactions'}
2898
      subtitle=""
2899
    >
2900
      {error ? (
2901
        <div className="errorBar" role="alert">
2902
          {error}
2903
        </div>
2904
      ) : null}
2905
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
2906
        <article className="setupRequest" key={interaction.interactionId}>
2907
          <div>
2908
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
2909
            <small>
2910
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
2911
            </small>
2912
            <small>
2913
              Contract v{interaction.contractRevision ?? 1}:{' '}
2914
              {String(currentProposal(interaction)?.termsHash ?? interaction.termsHash).slice(
2915
                0,
2916
                16,
2917
              )}
2918
              …
2919
              {currentProposal(interaction)
2920
                ? ` · proposed by ${currentProposal(interaction).proposedByAgentId}`
2921
                : ' · agreed'}
2922
            </small>
2923
            {interaction.contractRevisions?.length > 1 ? (
2924
              <small>{interaction.contractRevisions.length} recorded contract revisions</small>
2925
            ) : null}
2926
            {data.liveSessions.find(
2927
              (session) => session.interactionId === interaction.interactionId,
2928
            ) ? (
2929
              <small className="autoAccepted">
2930
                Live session:{' '}
2931
                {
2932
                  data.liveSessions.find(
2933
                    (session) => session.interactionId === interaction.interactionId,
2934
                  )?.status
2935
                }
2936
              </small>
2937
            ) : null}
2938
            {interaction.status === 'active' &&
2939
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
2940
              'policy_auto_accept' ? (
2941
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
2942
            ) : null}
2943
          </div>
2944
          {currentProposal(interaction) && respondingAgent(interaction) ? (
2945
            <div className="decisionButtons">
2946
              <button
2947
                className="secondary"
2948
                type="button"
2949
                disabled={working === interaction.interactionId}
2950
                onClick={() =>
2951
                  void respond(
2952
                    interaction.interactionId,
2953
                    currentProposal(interaction).revisionId,
2954
                    respondingAgent(interaction),
2955
                    'reject',
2956
                  )
2957
                }
2958
              >
2959
                Reject
2960
              </button>
2961
              <button
2962
                className="primary"
2963
                type="button"
2964
                disabled={working === interaction.interactionId}
2965
                onClick={() =>
2966
                  void respond(
2967
                    interaction.interactionId,
2968
                    currentProposal(interaction).revisionId,
2969
                    respondingAgent(interaction),
2970
                    'accept',
2971
                  )
2972
                }
2973
              >
2974
                {working === interaction.interactionId
2975
                  ? 'Working…'
2976
                  : interaction.status === 'active'
2977
                    ? 'Accept amendment'
2978
                    : 'Accept contract'}
2979
              </button>
2980
            </div>
2981
          ) : null}
2982
        </article>
2983
      ))}
2984
    </Panel>
2985
  );
2986
}
2987
2988
function Insights({ data }: { data: DashboardData }) {
2989
  const eligible = data.learningEligibility.filter((item) => item.eligible);
2990
  const pendingFeedback = data.feedbackRequests.filter((item) => item.status === 'pending');
2991
  const provisional = data.interactionConclusions.filter(
2992
    (item) => item.lifecycle === 'provisional',
2993
  );
2994
  return (
2995
    <>
2996
      <PageHead page="insights" />
2997
      <section className="insightPrinciple">
2998
        <div>
2999
          <p className="pageKicker">
3000
            <span>//</span> how to read this
3001
          </p>
3002
          <h2>Reliability is contextual, not a leaderboard.</h2>
3003
          <p>
3004
            Every profile is tied to an agent version and task category. Scores reflect eligible
3005
            structured outcomes and decay as history gets older.
3006
          </p>
3007
        </div>
3008
        <div className="insightStats">
3009
          <span>
3010
            <strong>{data.profiles.length}</strong> contextual profiles
3011
          </span>
3012
          <span>
3013
            <strong>{eligible.length}</strong> eligible interactions
3014
          </span>
3015
          <span>
3016
            <strong>{pendingFeedback.length}</strong> feedback pending
3017
          </span>
3018
          <span>
3019
            <strong>{provisional.length}</strong> provisional signals
3020
          </span>
3021
        </div>
3022
      </section>
3023
      {!!provisional.length && (
3024
        <Panel
3025
          title="Provisional interaction signals"
3026
          subtitle="Available now, kept separate from corroborated reliability history"
3027
        >
3028
          <div className="learningDecisionList">
3029
            {provisional
3030
              .slice()
3031
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
3032
              .map((conclusion) => (
3033
                <article key={conclusion.conclusionId}>
3034
                  <StatusPill value="provisional" />
3035
                  <div>
3036
                    <strong>{conclusion.summary}</strong>
3037
                    <small>
3038
                      {Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer{' '}
3039
                      {humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()} ·{' '}
3040
                      {relativeTime(timestamp(conclusion))}
3041
                    </small>
3042
                    <p>
3043
                      Missing report from{' '}
3044
                      {(conclusion.missingReportAgentIds ?? []).join(', ') || 'the counterparty'}.
3045
                      The signal will be revised if more structured evidence arrives.
3046
                    </p>
3047
                  </div>
3048
                </article>
3049
              ))}
3050
          </div>
3051
        </Panel>
3052
      )}
3053
      <section className="insightGrid">
3054
        {data.profiles.length ? (
3055
          data.profiles.map((profile) => {
3056
            const deltas = data.profileDeltas
3057
              .filter(
3058
                (delta) =>
3059
                  delta.agentId === profile.agentId &&
3060
                  delta.agentVersion === profile.agentVersion &&
3061
                  delta.taskCategory === profile.taskCategory,
3062
              )
3063
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)));
3064
            return (
3065
              <Panel
3066
                key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
3067
                title={profile.agentId}
3068
                subtitle={`${humanize(profile.taskCategory)} · version ${profile.agentVersion}`}
3069
              >
3070
                <Profile profile={profile} expanded deltas={deltas} showHeader={false} />
3071
              </Panel>
3072
            );
3073
          })
3074
        ) : (
3075
          <Empty
3076
            title="Not enough verified evidence"
3077
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
3078
          />
3079
        )}
3080
      </section>
3081
      {!!data.learningEligibility.length && (
3082
        <Panel
3083
          title="Recent learning decisions"
3084
          subtitle="Why an interaction did or did not affect contextual history"
3085
        >
3086
          <div className="learningDecisionList">
3087
            {data.learningEligibility
3088
              .slice()
3089
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
3090
              .slice(0, 8)
3091
              .map((decision) => (
3092
                <article key={decision.decisionId}>
3093
                  <StatusPill value={decision.eligible ? 'eligible' : 'excluded'} />
3094
                  <div>
3095
                    <strong>{decision.interactionId}</strong>
3096
                    <small>
3097
                      {Math.round(Number(decision.sampleWeight ?? 0) * 100)}% weight ·{' '}
3098
                      {humanize(decision.contributionMode)} · {relativeTime(timestamp(decision))}
3099
                    </small>
3100
                    <p>{decision.reasons?.[0] ?? 'No reason supplied.'}</p>
3101
                  </div>
3102
                </article>
3103
              ))}
3104
          </div>
3105
        </Panel>
3106
      )}
3107
    </>
3108
  );
3109
}
3110
3111
function Connect({
3112
  data,
3113
  navigate,
3114
  refreshDashboard,
3115
  api,
3116
}: {
3117
  data: DashboardData;
3118
  navigate: (page: Page) => void;
3119
  refreshDashboard: () => Promise<void>;
3120
  api: (path: string, init?: RequestInit) => Promise<unknown>;
3121
}) {
3122
  const endpoint = `${new URL(__OPENCLASP_PUBLIC_URL__).origin}/mcp`;
3123
  const [connectionType, setConnectionType] = useState<'interactive' | 'hosted'>('hosted');
3124
  const [copied, setCopied] = useState(false);
3125
  const [tokenCopied, setTokenCopied] = useState(false);
3126
  const [working, setWorking] = useState('');
3127
  const [decisionError, setDecisionError] = useState('');
3128
  const [providerError, setProviderError] = useState('');
3129
  const [providerForm, setProviderForm] = useState({
3130
    provider: 'botpress' as 'botpress' | 'custom',
3131
    agentName: '',
3132
    projectName: '',
3133
    description: '',
3134
    capabilities: '',
3135
    limitations: '',
3136
    expiresInDays: 365,
3137
  });
3138
  const [providerResult, setProviderResult] = useState<{
3139
    provider: 'botpress' | 'custom';
3140
    agent: { agentId: string; name: string; framework: string };
3141
    accessToken: { token: string; expiresAt: string };
3142
  }>();
3143
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
3144
  useEffect(() => {
3145
    const poll = () => void refreshDashboard().catch(() => undefined);
3146
    poll();
3147
    const timer = window.setInterval(poll, 5000);
3148
    return () => window.clearInterval(timer);
3149
  }, [refreshDashboard]);
3150
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
3151
    setWorking(requestId);
3152
    setDecisionError('');
3153
    try {
3154
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
3155
        method: 'POST',
3156
      });
3157
      await refreshDashboard();
3158
    } catch (reason) {
3159
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
3160
    } finally {
3161
      setWorking('');
3162
    }
3163
  };
3164
  const connectHostedProvider = async (event: React.FormEvent) => {
3165
    event.preventDefault();
3166
    setWorking('hosted-provider');
3167
    setProviderError('');
3168
    try {
3169
      const list = (value: string) =>
3170
        value
3171
          .split(/[,\n]/)
3172
          .map((item) => item.trim())
3173
          .filter(Boolean);
3174
      const created = (await api('/v0.1/provider-connections', {
3175
        method: 'POST',
3176
        body: JSON.stringify({
3177
          provider: providerForm.provider,
3178
          agentName: providerForm.agentName,
3179
          projectName: providerForm.projectName,
3180
          description: providerForm.description,
3181
          capabilities: list(providerForm.capabilities),
3182
          limitations: list(providerForm.limitations),
3183
          expiresInDays: providerForm.expiresInDays,
3184
        }),
3185
      })) as {
3186
        provider: 'botpress' | 'custom';
3187
        agent: { agentId: string; name: string; framework: string };
3188
        accessToken: { token: string; expiresAt: string };
3189
      };
3190
      setProviderResult(created);
3191
      await refreshDashboard().catch(() => undefined);
3192
    } catch (reason) {
3193
      setProviderError(reason instanceof Error ? reason.message : 'Could not create connection');
3194
    } finally {
3195
      setWorking('');
3196
    }
3197
  };
3198
  return (
3199
    <>
3200
      <PageHead page="connect" />
3201
      {pending.length > 0 && (
3202
        <section className="setupRequests">
3203
          <div>
3204
            <p className="pageKicker">
3205
              <span>//</span> confirmation required
3206
            </p>
3207
            <h2>Approve agent setup</h2>
3208
            <p>
3209
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
3210
            </p>
3211
          </div>
3212
          {pending.map((request) => (
3213
            <article className="setupRequest" key={request.requestId}>
3214
              <div>
3215
                <strong>{request.agentName ?? 'Switch installation'}</strong>
3216
                <small>
3217
                  {request.projectName ?? request.existingAgentId} · {request.framework}
3218
                </small>
3219
                {!!request.capabilities?.length && (
3220
                  <div className="tags">
3221
                    {request.capabilities.slice(0, 5).map((capability: string) => (
3222
                      <span key={capability}>{capability}</span>
3223
                    ))}
3224
                  </div>
3225
                )}
3226
                {!!request.limitations?.length && (
3227
                  <small>Limitations: {request.limitations.join(' · ')}</small>
3228
                )}
3229
                <div className="automationPreview">
3230
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
3231
                  <span>
3232
                    {(request.agentMode ?? 'temporary_chat') === 'temporary_chat'
3233
                      ? 'Hosted temporary A2A · encrypted history'
3234
                      : 'Persistent runtime · direct A2A'}
3235
                  </span>
3236
                  <span>
3237
                    {request.autoAcceptPolicy === 'safe_matching'
3238
                      ? 'Auto-accept safe matches'
3239
                      : 'Review every request'}
3240
                  </span>
3241
                </div>
3242
              </div>
3243
              <div className="decisionButtons">
3244
                <button
3245
                  className="secondary"
3246
                  type="button"
3247
                  disabled={working === request.requestId}
3248
                  onClick={() => void decide(request.requestId, 'reject')}
3249
                >
3250
                  Reject
3251
                </button>
3252
                <button
3253
                  className="primary"
3254
                  type="button"
3255
                  disabled={working === request.requestId}
3256
                  onClick={() => void decide(request.requestId, 'approve')}
3257
                >
3258
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
3259
                </button>
3260
              </div>
3261
            </article>
3262
          ))}
3263
          {decisionError && (
3264
            <div className="loginError" role="alert">
3265
              {decisionError}
3266
            </div>
3267
          )}
3268
        </section>
3269
      )}
3270
      <div className="connectionTypePicker" role="tablist" aria-label="Agent connection type">
3271
        <button
3272
          type="button"
3273
          role="tab"
3274
          aria-selected={connectionType === 'hosted'}
3275
          className={connectionType === 'hosted' ? 'active' : ''}
3276
          onClick={() => setConnectionType('hosted')}
3277
        >
3278
          Hosted provider
3279
          <small>Botpress and similar platforms</small>
3280
        </button>
3281
        <button
3282
          type="button"
3283
          role="tab"
3284
          aria-selected={connectionType === 'interactive'}
3285
          className={connectionType === 'interactive' ? 'active' : ''}
3286
          onClick={() => setConnectionType('interactive')}
3287
        >
3288
          Interactive agent
3289
          <small>Codex, Cursor, and OAuth clients</small>
3290
        </button>
3291
      </div>
3292
      {connectionType === 'hosted' ? (
3293
        <section className="connectLayout providerConnectLayout">
3294
          <Panel title="Add a hosted agent" subtitle="Creates an isolated identity and credential">
3295
            {providerResult ? (
3296
              <div className="providerResult">
3297
                <div className="successBanner">
3298
                  <strong>{providerResult.agent.name} created</strong>
3299
                  <span>{providerResult.agent.agentId}</span>
3300
                </div>
3301
                <label>
3302
                  <span>MCP URL</span>
3303
                  <code>{endpoint}</code>
3304
                </label>
3305
                <label>
3306
                  <span>Agent token — copy it now; it will not be shown again</span>
3307
                  <code>{providerResult.accessToken.token}</code>
3308
                </label>
3309
                <small>
3310
                  This agent-bound token authorizes MCP and automatic runtime registration. It
3311
                  cannot connect or manage another agent.
3312
                </small>
3313
                <div className="agentActions">
3314
                  <button
3315
                    className="secondary"
3316
                    type="button"
3317
                    onClick={() => {
3318
                      void navigator.clipboard.writeText(providerResult.accessToken.token);
3319
                      setTokenCopied(true);
3320
                      window.setTimeout(() => setTokenCopied(false), 2000);
3321
                    }}
3322
                  >
3323
                    {tokenCopied ? 'Copied' : 'Copy token'}
3324
                  </button>
3325
                  <button className="primary" type="button" onClick={() => navigate('agents')}>
3326
                    View agent
3327
                  </button>
3328
                </div>
3329
              </div>
3330
            ) : (
3331
              <form
3332
                className="providerForm"
3333
                onSubmit={(event) => void connectHostedProvider(event)}
3334
              >
3335
                <label>
3336
                  <span>Provider</span>
3337
                  <select
3338
                    value={providerForm.provider}
3339
                    onChange={(event) =>
3340
                      setProviderForm((value) => ({
3341
                        ...value,
3342
                        provider: event.target.value as 'botpress' | 'custom',
3343
                      }))
3344
                    }
3345
                  >
3346
                    <option value="botpress">Botpress</option>
3347
                    <option value="custom">Custom / self-hosted</option>
3348
                  </select>
3349
                </label>
3350
                <label>
3351
                  <span>Agent name</span>
3352
                  <input
3353
                    required
3354
                    maxLength={100}
3355
                    value={providerForm.agentName}
3356
                    onChange={(event) =>
3357
                      setProviderForm((value) => ({ ...value, agentName: event.target.value }))
3358
                    }
3359
                    placeholder="Recruiting agent"
3360
                  />
3361
                </label>
3362
                <label>
3363
                  <span>Project</span>
3364
                  <input
3365
                    required
3366
                    maxLength={100}
3367
                    value={providerForm.projectName}
3368
                    onChange={(event) =>
3369
                      setProviderForm((value) => ({ ...value, projectName: event.target.value }))
3370
                    }
3371
                    placeholder="Recruiting"
3372
                  />
3373
                </label>
3374
                <label className="fullWidth">
3375
                  <span>Purpose</span>
3376
                  <textarea
3377
                    required
3378
                    maxLength={500}
3379
                    value={providerForm.description}
3380
                    onChange={(event) =>
3381
                      setProviderForm((value) => ({ ...value, description: event.target.value }))
3382
                    }
3383
                    placeholder="Matches suitable candidates with open roles"
3384
                  />
3385
                </label>
3386
                <label>
3387
                  <span>Capabilities, comma-separated</span>
3388
                  <input
3389
                    required
3390
                    value={providerForm.capabilities}
3391
                    onChange={(event) =>
3392
                      setProviderForm((value) => ({ ...value, capabilities: event.target.value }))
3393
                    }
3394
                    placeholder="candidate matching, interview coordination"
3395
                  />
3396
                </label>
3397
                <label>
3398
                  <span>Limitations, comma-separated</span>
3399
                  <input
3400
                    value={providerForm.limitations}
3401
                    onChange={(event) =>
3402
                      setProviderForm((value) => ({ ...value, limitations: event.target.value }))
3403
                    }
3404
                    placeholder="no final hiring decisions"
3405
                  />
3406
                </label>
3407
                <label>
3408
                  <span>Credential expiry</span>
3409
                  <select
3410
                    value={providerForm.expiresInDays}
3411
                    onChange={(event) =>
3412
                      setProviderForm((value) => ({
3413
                        ...value,
3414
                        expiresInDays: Number(event.target.value),
3415
                      }))
3416
                    }
3417
                  >
3418
                    <option value={30}>30 days</option>
3419
                    <option value={90}>90 days</option>
3420
                    <option value={365}>1 year</option>
3421
                  </select>
3422
                </label>
3423
                <div className="providerSubmit fullWidth">
3424
                  <p>This does not reuse or modify your Codex agent.</p>
3425
                  <button
3426
                    className="primary"
3427
                    type="submit"
3428
                    disabled={working === 'hosted-provider'}
3429
                  >
3430
                    {working === 'hosted-provider' ? 'Creating…' : 'Create agent credentials'}
3431
                  </button>
3432
                </div>
3433
                {providerError ? (
3434
                  <div className="loginError fullWidth" role="alert">
3435
                    {providerError}
3436
                  </div>
3437
                ) : null}
3438
              </form>
3439
            )}
3440
          </Panel>
3441
          <Panel
3442
            title={
3443
              providerForm.provider === 'botpress' ? 'Configure Botpress' : 'Deploy the sidecar'
3444
            }
3445
            subtitle={
3446
              providerForm.provider === 'botpress'
3447
                ? 'MCP and inbound runtime are separate connections'
3448
                : 'Runs beside an agent on any cloud'
3449
            }
3450
          >
3451
            {providerForm.provider === 'botpress' ? (
3452
              <>
3453
                <div className="connectSteps">
3454
                  <div className="connectStep">
3455
                    <b>1</b>
3456
                    <span>Add the displayed MCP URL with Bearer authentication.</span>
3457
                  </div>
3458
                  <div className="connectStep">
3459
                    <b>2</b>
3460
                    <span>
3461
                      Paste the generated <code>oc_at_…</code> agent token.
3462
                    </span>
3463
                  </div>
3464
                  <div className="connectStep">
3465
                    <b>3</b>
3466
                    <span>Install the OpenClasp Botpress runtime integration for inbound A2A.</span>
3467
                  </div>
3468
                </div>
3469
                <div className="notice providerNotice">
3470
                  MCP proves outbound tool access only. Autonomous runtime becomes connected only
3471
                  after the Botpress integration registers its webhook successfully.
3472
                </div>
3473
              </>
3474
            ) : (
3475
              <>
3476
                <div className="connectSteps">
3477
                  <div className="connectStep">
3478
                    <b>1</b>
3479
                    <span>
3480
                      Deploy <code>Dockerfile.sidecar</code> beside the agent.
3481
                    </span>
3482
                  </div>
3483
                  <div className="connectStep">
3484
                    <b>2</b>
3485
                    <span>
3486
                      Set <code>OPENCLASP_AGENT_TOKEN</code>, <code>OPENCLASP_RUNTIME_URL</code>,
3487
                      and <code>AGENT_ADAPTER_URL</code>.
3488
                    </span>
3489
                  </div>
3490
                  <div className="connectStep">
3491
                    <b>3</b>
3492
                    <span>
3493
                      The sidecar verifies and registers itself; no endpoint paste is needed.
3494
                    </span>
3495
                  </div>
3496
                </div>
3497
                <div className="notice providerNotice">
3498
                  The application implements three internal POST hooks: session-offer,
3499
                  session-activated, and message. Agent-to-agent content remains off OpenClasp.
3500
                </div>
3501
              </>
3502
            )}
3503
          </Panel>
3504
        </section>
3505
      ) : (
3506
        <section className="connectLayout">
3507
          <Panel title="Connect with OAuth" subtitle="For interactive MCP clients">
3508
            <div className="endpoint">
3509
              <code>{endpoint}</code>
3510
              <button
3511
                type="button"
3512
                onClick={() => {
3513
                  void navigator.clipboard.writeText(endpoint);
3514
                  setCopied(true);
3515
                  window.setTimeout(() => setCopied(false), 2000);
3516
                }}
3517
              >
3518
                {copied ? 'Copied' : 'Copy'}
3519
              </button>
3520
            </div>
3521
            <div className="connectSteps">
3522
              <div className="connectStep">
3523
                <b>1</b>
3524
                <span>Add this MCP URL and complete OAuth.</span>
3525
              </div>
3526
              <div className="connectStep">
3527
                <b>2</b>
3528
                <span>
3529
                  Tell the agent: <code>Set yourself up on OpenClasp</code>.
3530
                </span>
3531
              </div>
3532
              <div className="connectStep">
3533
                <b>3</b>
3534
                <span>Approve the proposed identity and mode here.</span>
3535
              </div>
3536
            </div>
3537
          </Panel>
3538
          <Panel title="Interactive clients" subtitle="Best for temporary user-driven sessions">
3539
            <ul className="checkList">
3540
              <li>OAuth opens in the browser</li>
3541
              <li>Each installation is explicitly approved</li>
3542
              <li>Temporary chats receive hosted A2A history</li>
3543
              <li>Persistent runtimes can switch to direct A2A</li>
3544
            </ul>
3545
          </Panel>
3546
        </section>
3547
      )}
3548
    </>
3549
  );
3550
}
3551
3552
function SettingsPage({
3553
  settings,
3554
  setSettings,
3555
  api,
3556
}: {
3557
  settings: Settings;
3558
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
3559
  api: (path: string, init?: RequestInit) => Promise<unknown>;
3560
}) {
3561
  const [saving, setSaving] = useState(false);
3562
  const [saved, setSaved] = useState(false);
3563
  const [error, setError] = useState('');
3564
  const save = useCallback(async () => {
3565
    setSaving(true);
3566
    setSaved(false);
3567
    setError('');
3568
    try {
3569
      const updated = (await api('/v0.1/settings', {
3570
        method: 'PUT',
3571
        body: JSON.stringify(settings),
3572
      })) as Settings;
3573
      setSettings(updated);
3574
      setSaved(true);
3575
    } catch (reason) {
3576
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
3577
    } finally {
3578
      setSaving(false);
3579
    }
3580
  }, [api, settings, setSettings]);
3581
  return (
3582
    <>
3583
      <PageHead page="settings" />
3584
      <section className="settingsCard">
3585
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
3586
          <input
3587
            value={settings.displayName}
3588
            onChange={(event) =>
3589
              setSettings((value) => ({ ...value, displayName: event.target.value }))
3590
            }
3591
            placeholder="Team or operator name"
3592
          />
3593
        </Setting>
3594
        <Setting
3595
          label="Network contribution"
3596
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
3597
        >
3598
          <Toggle
3599
            checked={settings.contributionEnabled}
3600
            label="Network contribution"
3601
            onChange={(checked) =>
3602
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
3603
            }
3604
          />
3605
        </Setting>
3606
        <Setting
3607
          label="Evidence sharing"
3608
          description="Controls when permitted evidence references may be shared with interaction participants."
3609
        >
3610
          <select
3611
            value={settings.evidenceSharing}
3612
            onChange={(event) =>
3613
              setSettings((value) => ({
3614
                ...value,
3615
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
3616
              }))
3617
            }
3618
          >
3619
            <option value="never">Never</option>
3620
            <option value="ask">Ask every time</option>
3621
            <option value="contract_only">When contract permits</option>
3622
          </select>
3623
        </Setting>
3624
        <Setting
3625
          label="Structured record retention"
3626
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
3627
        >
3628
          <select
3629
            value={settings.retentionDays}
3630
            onChange={(event) =>
3631
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
3632
            }
3633
          >
3634
            <option value={7}>7 days</option>
3635
            <option value={30}>30 days</option>
3636
            <option value={90}>90 days</option>
3637
            <option value={365}>1 year</option>
3638
            <option value={0}>No automatic deletion</option>
3639
          </select>
3640
        </Setting>
3641
        <Setting
3642
          label="Raw agent messages"
3643
          description="Persistent runtimes remain direct and are not stored. Temporary chat messages pass through OpenClasp and are encrypted at rest for 30 days."
3644
        >
3645
          <span className="locked">MODE DEPENDENT</span>
3646
        </Setting>
3647
        <div className="saveRow">
3648
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
3649
            {saving ? 'Saving…' : 'Save settings'}
3650
          </button>
3651
          {saved && <span role="status">Saved</span>}
3652
          {error && (
3653
            <span className="loginError" role="alert">
3654
              {error}
3655
            </span>
3656
          )}
3657
        </div>
3658
      </section>
3659
    </>
3660
  );
3661
}
3662
3663
function PageHead({
3664
  page,
3665
  action,
3666
  onAction,
3667
}: {
3668
  page: Page;
3669
  action?: string;
3670
  onAction?: () => void;
3671
}) {
3672
  const meta = pageMeta[page];
3673
  return (
3674
    <header className="pageHead">
3675
      <div>
3676
        <p className="pageKicker">
3677
          <span>//</span> {meta.eyebrow.toLowerCase()}
3678
        </p>
3679
        <h1>{meta.title}</h1>
3680
        <p className="lede">{meta.lede}</p>
3681
      </div>
3682
      {action && (
3683
        <button className="primary" type="button" onClick={onAction}>
3684
          {action.toLowerCase()} <span aria-hidden="true">→</span>
3685
        </button>
3686
      )}
3687
    </header>
3688
  );
3689
}
3690
function Metric({
3691
  label,
3692
  value,
3693
  note,
3694
  warn,
3695
}: {
3696
  label: string;
3697
  value: number;
3698
  note: string;
3699
  warn?: boolean;
3700
}) {
3701
  return (
3702
    <article className={warn ? 'metric warn' : 'metric'} title={note}>
3703
      <span>{label}</span>
3704
      <strong>{value}</strong>
3705
      <small>{note}</small>
3706
    </article>
3707
  );
3708
}
3709
function Panel({
3710
  title,
3711
  subtitle,
3712
  children,
3713
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
3714
  return (
3715
    <section className="panel">
3716
      <div className="panelHead">
3717
        <h2 className="panelKicker">
3718
          <span>//</span> {title.toLowerCase()}
3719
        </h2>
3720
        {subtitle ? <p>{subtitle}</p> : null}
3721
      </div>
3722
      {children}
3723
    </section>
3724
  );
3725
}
3726
function Timeline({ events }: { events: Record<string, any>[] }) {
3727
  return events.length ? (
3728
    <div className="timeline">
3729
      {events.map((event) => (
3730
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
3731
      ))}
3732
    </div>
3733
  ) : (
3734
    <Empty
3735
      title="Nothing recorded yet"
3736
      text="Activity appears after your connected agents submit signed structured events."
3737
    />
3738
  );
3739
}
3740
function HistoryRow({ item }: { item: Record<string, any> }) {
3741
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
3742
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
3743
  return (
3744
    <article className="historyRow">
3745
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
3746
      <div>
3747
        <strong>{kind}</strong>
3748
        <small>
3749
          {id} · {new Date(timestamp(item)).toLocaleString()}
3750
        </small>
3751
      </div>
3752
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
3753
    </article>
3754
  );
3755
}
3756
function AgentCard({
3757
  agent,
3758
  projectName,
3759
  published,
3760
  working,
3761
  onSave,
3762
  runtime,
3763
  runtimeWorking,
3764
  deleteWorking,
3765
  onRuntime,
3766
  onDisableRuntime,
3767
  accessTokens,
3768
  accessTokenWorking,
3769
  onRevokeAccessToken,
3770
  onDelete,
3771
}: {
3772
  agent: Record<string, any>;
3773
  projectName?: string;
3774
  published: boolean;
3775
  working: boolean;
3776
  onSave: (value: {
3777
    autoPublish: boolean;
3778
    autoAcceptPolicy: 'off' | 'safe_matching';
3779
    autoAcceptTaskCategories: string[];
3780
  }) => Promise<boolean>;
3781
  runtime: Record<string, any> | undefined;
3782
  runtimeWorking: boolean;
3783
  deleteWorking: boolean;
3784
  onRuntime: (endpoint: string) => void;
3785
  onDisableRuntime: () => void;
3786
  accessTokens: Record<string, any>[];
3787
  accessTokenWorking: boolean;
3788
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
3789
  onDelete: () => void;
3790
}) {
3791
  const [editing, setEditing] = useState(false);
3792
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
3793
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
3794
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
3795
  );
3796
  const [categories, setCategories] = useState<string>(
3797
    (agent.autoAcceptTaskCategories?.length
3798
      ? agent.autoAcceptTaskCategories
3799
      : (agent.capabilities ?? [])
3800
    ).join(', '),
3801
  );
3802
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
3803
  const mode = agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
3804
  const temporary = mode === 'temporary_chat';
3805
  const ready = published && (temporary || runtime?.status === 'verified');
3806
  const online = agent.presence?.status === 'online';
3807
  const providerConnected = accessTokens.length > 0;
3808
  const endpoint = String(
3809
    temporary
3810
      ? `/a2a/temporary/${encodeURIComponent(agent.agentId)}`
3811
      : (runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first'),
3812
  );
3813
  const identityLabel = agent.revoked
3814
    ? 'REVOKED'
3815
    : agent.identityMode === 'owner_managed'
3816
      ? 'OWNER VERIFIED'
3817
      : agent.identityMode === 'oauth_installation'
3818
        ? 'AUTHENTICATED'
3819
        : 'VERIFIED';
3820
  return (
3821
    <article className="agentCard">
3822
      <div className="agentTop">
3823
        <span className="agentGlyph">
3824
          <Bot />
3825
        </span>
3826
        <div className="agentBadges">
3827
          <b className={online && temporary ? 'onlineBadge' : 'offlineBadge'}>
3828
            {temporary
3829
              ? online
3830
                ? 'CHAT ACTIVE'
3831
                : 'CHAT IDLE'
3832
              : runtime?.status === 'verified'
3833
                ? 'ENDPOINT VERIFIED'
3834
                : providerConnected
3835
                  ? 'TOKEN ONLY'
3836
                  : 'ENDPOINT MISSING'}
3837
          </b>
3838
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
3839
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
3840
        </div>
3841
      </div>
3842
      <h2>{agent.name ?? agent.agentId}</h2>
3843
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
3844
      <div className="tags">
3845
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
3846
          <span key={capability}>{capability}</span>
3847
        ))}
3848
      </div>
3849
      <small>
3850
        {agent.framework ? `${agent.framework} · ` : ''}
3851
        {agent.identityMode === 'owner_managed'
3852
          ? 'Owner-created · '
3853
          : agent.identityMode === 'oauth_installation'
3854
            ? 'OAuth-bound · '
3855
            : 'Ed25519 · '}
3856
        Created {new Date(agent.createdAt).toLocaleDateString()}
3857
      </small>
3858
      <small>
3859
        {agent.presence?.lastSeenAt
3860
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
3861
          : 'No agent activity recorded yet'}
3862
      </small>
3863
      {published ? (
3864
        <div className="agentPublicLinks">
3865
          <a href={`/a/${encodeURIComponent(agent.agentId)}`} target="_blank" rel="noreferrer">
3866
            Verified public profile
3867
          </a>
3868
          <a
3869
            href={`/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`}
3870
            target="_blank"
3871
            rel="noreferrer"
3872
          >
3873
            A2A Agent Card
3874
          </a>
3875
        </div>
3876
      ) : null}
3877
      <div className="automationSummary">
3878
        <span>{published ? 'Public discovery' : 'Private'}</span>
3879
        <span>{temporary ? 'Hosted temporary A2A' : 'Direct agent-owned A2A'}</span>
3880
        <span>
3881
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
3882
        </span>
3883
      </div>
3884
      <div className="runtimeBox">
3885
        <div>
3886
          <strong>{temporary ? 'Temporary chat endpoint' : 'Autonomous runtime'}</strong>
3887
          <span
3888
            className={
3889
              temporary || runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'
3890
            }
3891
          >
3892
            {temporary
3893
              ? 'HOSTED BY OPENCLASP'
3894
              : runtime?.status === 'verified'
3895
                ? 'VERIFIED'
3896
                : 'NOT CONNECTED'}
3897
          </span>
3898
        </div>
3899
        <p>
3900
          {temporary
3901
            ? 'OpenClasp receives A2A for this temporary identity and encrypts its history at rest. Connect a runtime below to switch to direct A2A.'
3902
            : runtime?.status === 'verified'
3903
              ? 'The agent runtime registered itself successfully. Live message bodies travel directly between agents.'
3904
              : providerConnected
3905
                ? 'MCP is connected, but inbound A2A is not. Install the provider connector or deploy the sidecar; it registers this endpoint automatically.'
3906
                : 'Install a provider connector, deploy the sidecar, or register a native A2A endpoint.'}
3907
        </p>
3908
        <input
3909
          type="url"
3910
          value={runtimeEndpoint}
3911
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
3912
          placeholder="https://agent.example.com/openclasp"
3913
        />
3914
        <small>
3915
          Manual registration is only for a runtime that already implements OpenClasp A2A.
3916
        </small>
3917
        <div className="agentActions">
3918
          {runtime?.status === 'verified' ? (
3919
            <button
3920
              className="secondary"
3921
              type="button"
3922
              disabled={runtimeWorking}
3923
              onClick={onDisableRuntime}
3924
            >
3925
              Disable runtime
3926
            </button>
3927
          ) : null}
3928
          <button
3929
            className="primary"
3930
            type="button"
3931
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
3932
            onClick={() => onRuntime(runtimeEndpoint.trim())}
3933
          >
3934
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
3935
          </button>
3936
        </div>
3937
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
3938
      </div>
3939
      {accessTokens.length ? (
3940
        <div className="accessTokenBox">
3941
          <div className="accessTokenHead">
3942
            <div>
3943
              <strong>Provider connections</strong>
3944
              <span>{accessTokens.length} ACTIVE</span>
3945
            </div>
3946
            <p>Created through Connect. Revoke a provider here if its credential is exposed.</p>
3947
          </div>
3948
          <div className="tokenList">
3949
            {accessTokens.map((token) => (
3950
              <div key={token.tokenId}>
3951
                <span>
3952
                  <strong>{token.name}</strong>
3953
                  <small>
3954
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
3955
                    {new Date(token.expiresAt).toLocaleDateString()}
3956
                    {token.lastUsedAt
3957
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
3958
                      : ' · never used'}
3959
                  </small>
3960
                </span>
3961
                <button
3962
                  className="secondary dangerButton"
3963
                  type="button"
3964
                  disabled={accessTokenWorking}
3965
                  onClick={() => {
3966
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
3967
                      void onRevokeAccessToken(String(token.tokenId));
3968
                  }}
3969
                >
3970
                  Revoke
3971
                </button>
3972
              </div>
3973
            ))}
3974
          </div>
3975
        </div>
3976
      ) : null}
3977
      {editing ? (
3978
        <div className="automationForm">
3979
          <label>
3980
            <span>
3981
              {temporary ? 'OpenClasp temporary A2A endpoint' : 'Agent-owned A2A endpoint'}
3982
            </span>
3983
            <input type="url" value={endpoint} readOnly />
3984
          </label>
3985
          <label>
3986
            <span>Invitation policy</span>
3987
            <select
3988
              value={autoAcceptPolicy}
3989
              onChange={(event) =>
3990
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
3991
              }
3992
            >
3993
              <option value="safe_matching">Auto-accept safe matches</option>
3994
              <option value="off">Review every request</option>
3995
            </select>
3996
          </label>
3997
          <label>
3998
            <span>Safe task categories</span>
3999
            <input
4000
              value={categories}
4001
              onChange={(event) => setCategories(event.target.value)}
4002
              placeholder="research, planning, coding"
4003
            />
4004
          </label>
4005
          <label className="checkControl">
4006
            <input
4007
              type="checkbox"
4008
              checked={autoPublish}
4009
              onChange={(event) => setAutoPublish(event.target.checked)}
4010
            />
4011
            <span>Publish and keep Agent Card updated</span>
4012
          </label>
4013
          <div className="agentActions">
4014
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
4015
              Cancel
4016
            </button>
4017
            <button
4018
              className="primary"
4019
              type="button"
4020
              disabled={working || agent.status === 'revoked'}
4021
              onClick={() =>
4022
                void onSave({
4023
                  autoPublish,
4024
                  autoAcceptPolicy,
4025
                  autoAcceptTaskCategories: categories
4026
                    .split(',')
4027
                    .map((value) => value.trim())
4028
                    .filter(Boolean),
4029
                }).then((ok) => {
4030
                  if (ok) setEditing(false);
4031
                })
4032
              }
4033
            >
4034
              {working ? 'Saving…' : 'Save automation'}
4035
            </button>
4036
          </div>
4037
        </div>
4038
      ) : (
4039
        <div className="agentActions agentManagementActions">
4040
          <button
4041
            className="secondary dangerButton"
4042
            type="button"
4043
            disabled={working || runtimeWorking || deleteWorking}
4044
            onClick={onDelete}
4045
          >
4046
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
4047
          </button>
4048
          <button
4049
            className="secondary"
4050
            type="button"
4051
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
4052
            onClick={() => setEditing(true)}
4053
          >
4054
            Configure automation
4055
          </button>
4056
        </div>
4057
      )}
4058
    </article>
4059
  );
4060
}
4061
function Profile({
4062
  profile,
4063
  expanded,
4064
  deltas = [],
4065
  showHeader = true,
4066
}: {
4067
  profile: Record<string, any>;
4068
  expanded?: boolean;
4069
  deltas?: Record<string, any>[];
4070
  showHeader?: boolean;
4071
}) {
4072
  const dimensions = [
4073
    ['Completion', profile.completion],
4074
    ['Acceptance', profile.acceptance],
4075
    ['Specification', profile.specification],
4076
    ['Scope', profile.scope],
4077
    ['Evidence', profile.evidence],
4078
    ...(expanded
4079
      ? [
4080
          ['Communication', profile.communication],
4081
          ['Deadline', profile.deadline],
4082
          ['Dispute-free', 1 - Number(profile.disputes ?? 0)],
4083
        ]
4084
      : []),
4085
  ] as [string, number][];
4086
  const latestDelta = deltas[0];
4087
  return (
4088
    <article className="profile">
4089
      <div className={showHeader ? 'profileHead' : 'profileHead profileHeadCompact'}>
4090
        {showHeader ? (
4091
          <div>
4092
            <strong>{profile.agentId}</strong>
4093
            <small>
4094
              {humanize(profile.taskCategory)} · version {profile.agentVersion ?? 'unknown'}
4095
            </small>
4096
          </div>
4097
        ) : (
4098
          <small>
4099
            Effective evidence{' '}
4100
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
4101
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
4102
          </small>
4103
        )}
4104
        <span className="sampleBadge">
4105
          {profile.sampleSize ?? 0} outcome{Number(profile.sampleSize) === 1 ? '' : 's'}
4106
        </span>
4107
      </div>
4108
      {showHeader ? (
4109
        <div className="profileEvidenceLine">
4110
          <small>
4111
            Effective evidence{' '}
4112
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
4113
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
4114
          </small>
4115
        </div>
4116
      ) : null}
4117
      {dimensions.map(([label, value]) => (
4118
        <Meter key={label} label={label} value={Number(value ?? 0.5)} />
4119
      ))}
4120
      {latestDelta ? (
4121
        <div className="profileDelta">
4122
          <span>Latest evidence impact</span>
4123
          <div>
4124
            {Object.entries(latestDelta.dimensionDeltas ?? {})
4125
              .filter(([, value]) => Math.abs(Number(value)) >= 0.001)
4126
              .slice(0, 5)
4127
              .map(([dimension, value]) => (
4128
                <b className={Number(value) >= 0 ? 'positive' : 'negative'} key={dimension}>
4129
                  {humanize(dimension)} {Number(value) >= 0 ? '+' : ''}
4130
                  {(Number(value) * 100).toFixed(1)}
4131
                </b>
4132
              ))}
4133
          </div>
4134
        </div>
4135
      ) : null}
4136
    </article>
4137
  );
4138
}
4139
function Meter({ label, value }: { label: string; value: number }) {
4140
  const bounded = Math.max(0, Math.min(1, value));
4141
  return (
4142
    <div className="meter">
4143
      <span>{label}</span>
4144
      <i>
4145
        <b style={{ width: `${Math.round(bounded * 100)}%` }} />
4146
      </i>
4147
      <strong>{Math.round(bounded * 100)}%</strong>
4148
    </div>
4149
  );
4150
}
4151
function Empty({
4152
  title,
4153
  text,
4154
  action,
4155
  onAction,
4156
}: {
4157
  title: string;
4158
  text: string;
4159
  action?: string;
4160
  onAction?: () => void;
4161
}) {
4162
  return (
4163
    <div className="empty">
4164
      <Bot />
4165
      <strong>{title}</strong>
4166
      <p>{text}</p>
4167
      {action && (
4168
        <button className="secondary" type="button" onClick={onAction}>
4169
          {action}
4170
        </button>
4171
      )}
4172
    </div>
4173
  );
4174
}
4175
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
4176
  return (
4177
    <button className="textButton" type="button" onClick={onClick}>
4178
      {children}
4179
    </button>
4180
  );
4181
}
4182
function Setting({
4183
  label,
4184
  description,
4185
  children,
4186
}: React.PropsWithChildren<{ label: string; description: string }>) {
4187
  return (
4188
    <div className="setting">
4189
      <div>
4190
        <strong>{label}</strong>
4191
        <p>{description}</p>
4192
      </div>
4193
      <div>{children}</div>
4194
    </div>
4195
  );
4196
}
4197
function Toggle({
4198
  checked,
4199
  onChange,
4200
  label,
4201
}: {
4202
  checked: boolean;
4203
  onChange: (checked: boolean) => void;
4204
  label?: string;
4205
}) {
4206
  return (
4207
    <button
4208
      className={checked ? 'toggle on' : 'toggle'}
4209
      type="button"
4210
      role="switch"
4211
      aria-checked={checked}
4212
      aria-label={label}
4213
      onClick={() => onChange(!checked)}
4214
    >
4215
      <span />
4216
    </button>
4217
  );
4218
}
4219
function Loading({ compact }: { compact?: boolean }) {
4220
  return (
4221
    <div className={compact ? 'loading compact' : 'loading'}>
4222
      <span className="spinner" aria-hidden="true" />
4223
      <p>Verifying session…</p>
4224
    </div>
4225
  );
4226
}
4227
4228
function GoogleMark() {
4229
  return (
4230
    <svg viewBox="0 0 24 24" aria-hidden="true">
4231
      <path
4232
        fill="#4285F4"
4233
        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"
4234
      />
4235
      <path
4236
        fill="#34A853"
4237
        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"
4238
      />
4239
      <path
4240
        fill="#FBBC05"
4241
        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"
4242
      />
4243
      <path
4244
        fill="#EA4335"
4245
        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"
4246
      />
4247
    </svg>
4248
  );
4249
}
4250
4251
function GitHubMark() {
4252
  return (
4253
    <svg viewBox="0 0 24 24" aria-hidden="true">
4254
      <path
4255
        fill="currentColor"
4256
        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"
4257
      />
4258
    </svg>
4259
  );
4260
}
4261
4262
function humanize(value: unknown) {
4263
  const text = String(value ?? '')
4264
    .replaceAll('_', ' ')
4265
    .trim();
4266
  return text ? text[0]!.toUpperCase() + text.slice(1) : 'Unknown';
4267
}
4268
4269
function formatDate(value: string) {
4270
  const date = new Date(value);
4271
  return Number.isNaN(date.getTime()) ? 'Time unavailable' : date.toLocaleString();
4272
}
4273
4274
function relativeTime(value: string) {
4275
  const milliseconds = Date.now() - Date.parse(value);
4276
  if (!Number.isFinite(milliseconds)) return 'Time unavailable';
4277
  const minutes = Math.round(Math.abs(milliseconds) / 60_000);
4278
  const future = milliseconds < 0;
4279
  const phrase =
4280
    minutes < 1
4281
      ? 'just now'
4282
      : minutes < 60
4283
        ? `${minutes}m`
4284
        : minutes < 1_440
4285
          ? `${Math.round(minutes / 60)}h`
4286
          : `${Math.round(minutes / 1_440)}d`;
4287
  return minutes < 1 ? phrase : future ? `in ${phrase}` : `${phrase} ago`;
4288
}
4289
4290
function feedbackState(requests: Record<string, any>[]) {
4291
  if (!requests.length) return 'Starts after a completion report';
4292
  const pending = requests.filter((request) => request.status === 'pending').length;
4293
  if (pending) return `${pending} response${pending === 1 ? '' : 's'} still pending`;
4294
  const expired = requests.filter((request) => request.status === 'expired').length;
4295
  return expired
4296
    ? `${expired} request${expired === 1 ? '' : 's'} timed out`
4297
    : 'Bilateral feedback closed';
4298
}
4299
4300
function StatusPill({ value }: { value: string }) {
4301
  const normalized = String(value).toLowerCase();
4302
  const tone = [
4303
    'success',
4304
    'active',
4305
    'ready',
4306
    'met',
4307
    'eligible',
4308
    'attested',
4309
    'allow',
4310
    'completed',
4311
  ].includes(normalized)
4312
    ? 'good'
4313
    : ['failure', 'denied', 'deny', 'missed', 'rejected', 'violation'].some((item) =>
4314
          normalized.includes(item),
4315
        )
4316
      ? 'danger'
4317
      : ['partial', 'pending', 'provisional', 'challenge', 'conflict', 'expired', 'excluded'].some(
4318
            (item) => normalized.includes(item),
4319
          )
4320
        ? 'warn'
4321
        : 'neutral';
4322
  return (
4323
    <span className={`statusPill ${tone}`}>
4324
      <i />
4325
      {humanize(value)}
4326
    </span>
4327
  );
4328
}
4329
4330
function timestamp(item: Record<string, any>) {
4331
  return String(
4332
    item.timestamp ??
4333
      item.generatedAt ??
4334
      item.decidedAt ??
4335
      item.appliedAt ??
4336
      item.submittedAt ??
4337
      item.completedAt ??
4338
      item.activatedAt ??
4339
      item.updatedAt ??
4340
      item.requestedAt ??
4341
      item.createdAt ??
4342
      new Date(0).toISOString(),
4343
  );
4344
}
4345
4346
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
4347
  throw new Error('Auth0 public configuration is incomplete');
4348
createRoot(document.getElementById('root')!).render(
4349
  <React.StrictMode>
4350
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
4351
  </React.StrictMode>,
4352
);