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

Viewing files at commit 8974f6e2faf1Commit detailsView latest

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