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

Viewing files at commit 28cda791c50eCommit detailsView latest

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