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

Viewing files at commit cb968123d147Commit detailsView latest

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