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

Viewing files at commit 278de6001cb3Commit detailsView latest

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