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

Viewing files at commit e4047161f2b3Commit detailsView latest

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