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

Viewing files at commit 2c749578ec22Commit detailsView latest

apps/dashboard/src/main.tsx 3044 lines · 107.9 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import { Bot } from 'lucide-react';
4
import { AppShell } from '@/components/app-shell';
5
import { Input } from '@/components/ui/input';
6
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
7
import { pageMeta, pages, type Page } from '@/lib/navigation';
8
import {
9
  applyPreviewRequest,
10
  createPreviewData,
11
  defaultPreviewSettings,
12
  disablePreview,
13
  enablePreview,
14
  isPreviewActive,
15
  previewSession,
16
} from './preview.js';
17
import './styles.css';
18
19
declare const __AUTH0_DOMAIN__: string;
20
declare const __AUTH0_CLIENT_ID__: string;
21
declare const __AUTH0_AUDIENCE__: string;
22
23
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
24
type AuthSession = { user: Auth0User };
25
type AuthTransaction = { state: string; nonce: string; verifier: string };
26
type Theme = 'dark' | 'light';
27
28
const authTransactionKey = 'openclasp.auth0.transaction';
29
const themeKey = 'openclasp.theme.v1';
30
31
type DashboardData = {
32
  agents: Record<string, any>[];
33
  projects: Record<string, any>[];
34
  installations: Record<string, any>[];
35
  setupRequests: Record<string, any>[];
36
  publications: Record<string, any>[];
37
  interactions: Record<string, any>[];
38
  federatedInteractions: Record<string, any>[];
39
  liveSessions: Record<string, any>[];
40
  hostedThreads: Record<string, any>[];
41
  events: Record<string, any>[];
42
  conflicts: Record<string, any>[];
43
  receipts: Record<string, any>[];
44
  profiles: Record<string, any>[];
45
  counterpartyBriefs: Record<string, any>[];
46
  completionReports: Record<string, any>[];
47
  feedbackRequests: Record<string, any>[];
48
  interactionFeedback: Record<string, any>[];
49
  interactionConclusions: Record<string, any>[];
50
  learningEligibility: Record<string, any>[];
51
  profileDeltas: Record<string, any>[];
52
  runtimes: Record<string, any>[];
53
  accessTokens: Record<string, any>[];
54
};
55
56
type Settings = {
57
  displayName: string;
58
  contributionEnabled: boolean;
59
  retentionDays: number;
60
  evidenceSharing: 'never' | 'ask' | 'contract_only';
61
  rawConversationsStored: false;
62
};
63
64
const emptyData: DashboardData = {
65
  agents: [],
66
  projects: [],
67
  installations: [],
68
  setupRequests: [],
69
  publications: [],
70
  interactions: [],
71
  federatedInteractions: [],
72
  liveSessions: [],
73
  hostedThreads: [],
74
  events: [],
75
  conflicts: [],
76
  receipts: [],
77
  profiles: [],
78
  counterpartyBriefs: [],
79
  completionReports: [],
80
  feedbackRequests: [],
81
  interactionFeedback: [],
82
  interactionConclusions: [],
83
  learningEligibility: [],
84
  profileDeltas: [],
85
  runtimes: [],
86
  accessTokens: [],
87
};
88
const defaultSettings: Settings = {
89
  displayName: '',
90
  contributionEnabled: false,
91
  retentionDays: 30,
92
  evidenceSharing: 'ask',
93
  rawConversationsStored: false,
94
};
95
96
function normalizeDashboard(value: unknown): DashboardData {
97
  return { ...emptyData, ...(value as Partial<DashboardData>) };
98
}
99
100
function base64Url(bytes: Uint8Array) {
101
  let binary = '';
102
  for (const byte of bytes) binary += String.fromCharCode(byte);
103
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
104
}
105
106
function randomValue(size = 32) {
107
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
108
}
109
110
async function beginAuth(provider: 'google' | 'github') {
111
  const verifier = randomValue(48);
112
  const digest = await crypto.subtle.digest(
113
    'SHA-256',
114
    new TextEncoder().encode(verifier) as BufferSource,
115
  );
116
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
117
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
118
  const parameters = new URLSearchParams({
119
    client_id: __AUTH0_CLIENT_ID__,
120
    redirect_uri: `${new URL(__AUTH0_AUDIENCE__).origin}/sso-callback`,
121
    response_type: 'code',
122
    scope: 'openid profile email',
123
    audience: __AUTH0_AUDIENCE__,
124
    state: transaction.state,
125
    nonce: transaction.nonce,
126
    code_challenge: base64Url(new Uint8Array(digest)),
127
    code_challenge_method: 'S256',
128
    connection: provider === 'google' ? 'google-oauth2' : 'github',
129
  });
130
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
131
}
132
133
async function signOut(preview: boolean) {
134
  if (preview) {
135
    disablePreview();
136
    location.replace('/login');
137
    return;
138
  }
139
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
140
  const parameters = new URLSearchParams({
141
    client_id: __AUTH0_CLIENT_ID__,
142
    returnTo: `${new URL(__AUTH0_AUDIENCE__).origin}/login`,
143
  });
144
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
145
}
146
147
async function remoteApi(path: string, init?: RequestInit) {
148
  const headers = new Headers(init?.headers);
149
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
150
  return fetch(path, {
151
    ...init,
152
    credentials: 'same-origin',
153
    headers,
154
  }).then(async (response) => {
155
    if (!response.ok)
156
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
157
    return response.json();
158
  });
159
}
160
161
function route(): Page {
162
  const value = location.pathname.slice(1) || 'dashboard';
163
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
164
}
165
166
function initialTheme(): Theme {
167
  const saved = localStorage.getItem(themeKey);
168
  if (saved === 'dark' || saved === 'light') return saved;
169
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
170
}
171
172
function App() {
173
  const [session, setSession] = useState<AuthSession | null>();
174
  const [preview, setPreview] = useState(false);
175
  const [theme, setTheme] = useState<Theme>(initialTheme);
176
  const [page, setPage] = useState<Page>(route());
177
  const [data, setData] = useState<DashboardData>(emptyData);
178
  const [settings, setSettings] = useState<Settings>(defaultSettings);
179
  const [loading, setLoading] = useState(true);
180
  const [error, setError] = useState('');
181
  const api = useCallback(
182
    async (path: string, init?: RequestInit) => {
183
      if (preview) {
184
        const next = applyPreviewRequest(data, settings, path, init);
185
        setData(next.data);
186
        setSettings(next.settings);
187
        return next.result;
188
      }
189
      return remoteApi(path, init);
190
    },
191
    [data, preview, settings],
192
  );
193
  const refreshDashboard = useCallback(async () => {
194
    if (preview) return;
195
    setData(normalizeDashboard(await remoteApi('/v0.1/dashboard')));
196
  }, [preview]);
197
198
  useEffect(() => {
199
    document.title = session
200
      ? `${pageMeta[page].label} · OpenClasp`
201
      : session === null
202
        ? 'Sign in · OpenClasp'
203
        : 'OpenClasp';
204
  }, [page, session]);
205
206
  useEffect(() => {
207
    let cancelled = false;
208
    const boot = async () => {
209
      if (import.meta.env.DEV && isPreviewActive()) {
210
        if (cancelled) return;
211
        setPreview(true);
212
        setSession(previewSession);
213
        setData(createPreviewData());
214
        setSettings(defaultPreviewSettings);
215
        setLoading(false);
216
        return;
217
      }
218
      try {
219
        const response = await fetch('/api/session', { credentials: 'same-origin' });
220
        if (!response.ok) {
221
          if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
222
          return;
223
        }
224
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) {
225
          setSession((await response.json()) as AuthSession);
226
        }
227
      } catch {
228
        if (!cancelled && !(import.meta.env.DEV && isPreviewActive())) setSession(null);
229
      }
230
    };
231
    void boot();
232
    return () => {
233
      cancelled = true;
234
    };
235
  }, []);
236
237
  useEffect(() => {
238
    document.documentElement.dataset.theme = theme;
239
    document.documentElement.classList.toggle('dark', theme === 'dark');
240
    localStorage.setItem(themeKey, theme);
241
    const meta = document.querySelector('meta[name="theme-color"]');
242
    if (meta) meta.setAttribute('content', theme === 'light' ? '#f7f4f2' : '#0c0a0a');
243
  }, [theme]);
244
245
  useEffect(() => {
246
    const onPopState = () => setPage(route());
247
    addEventListener('popstate', onPopState);
248
    return () => removeEventListener('popstate', onPopState);
249
  }, []);
250
251
  useEffect(() => {
252
    if (session === undefined) return;
253
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
254
    if (!session) return;
255
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
256
    setPage(route());
257
    if (preview) {
258
      setLoading(false);
259
      return;
260
    }
261
    Promise.all([remoteApi('/v0.1/dashboard'), remoteApi('/v0.1/settings')])
262
      .then(([dashboard, accountSettings]) => {
263
        setData(normalizeDashboard(dashboard));
264
        setSettings(accountSettings as Settings);
265
      })
266
      .catch((reason: unknown) =>
267
        setError(reason instanceof Error ? reason.message : 'Load failed'),
268
      )
269
      .finally(() => setLoading(false));
270
  }, [preview, session]);
271
272
  useEffect(() => {
273
    if (!session || preview) return;
274
    const refresh = () => {
275
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
276
    };
277
    const timer = window.setInterval(refresh, 10_000);
278
    document.addEventListener('visibilitychange', refresh);
279
    return () => {
280
      window.clearInterval(timer);
281
      document.removeEventListener('visibilitychange', refresh);
282
    };
283
  }, [preview, refreshDashboard, session]);
284
285
  const navigate = (next: Page) => {
286
    history.pushState({}, '', `/${next}`);
287
    setPage(next);
288
  };
289
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
290
  const pendingInvites = data.federatedInteractions.filter(
291
    (interaction) => interaction.status === 'pending',
292
  ).length;
293
  const unreadThreads = data.hostedThreads.reduce(
294
    (total, thread) => total + Number(thread.unreadCount ?? 0),
295
    0,
296
  );
297
298
  if (session === undefined) return <Loading />;
299
  if (!session)
300
    return import.meta.env.DEV ? (
301
      <Login
302
        onPreview={() => {
303
          enablePreview();
304
          setPreview(true);
305
          setSession(previewSession);
306
          setData(createPreviewData());
307
          setSettings(defaultPreviewSettings);
308
          setLoading(false);
309
          history.replaceState({}, '', '/dashboard');
310
          setPage('dashboard');
311
        }}
312
      />
313
    ) : (
314
      <Login />
315
    );
316
317
  return (
318
    <AppShell
319
      page={page}
320
      navigate={navigate}
321
      user={session.user}
322
      theme={theme}
323
      onToggleTheme={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
324
      onSignOut={() => void signOut(preview)}
325
      badges={{
326
        dashboard: pendingInvites || pendingSetup ? pendingInvites + pendingSetup : 0,
327
        conversations: unreadThreads,
328
        connect: pendingSetup,
329
      }}
330
      agents={data.agents.map((agent) => ({
331
        agentId: String(agent.agentId ?? ''),
332
        name: typeof agent.name === 'string' ? agent.name : undefined,
333
      }))}
334
      attention={{ setup: pendingSetup, invites: pendingInvites, inbox: unreadThreads }}
335
    >
336
      {preview && <div className="previewBanner">Local preview. Changes stay here.</div>}
337
      {error && (
338
        <div className="errorBar" role="alert">
339
          {error}
340
        </div>
341
      )}
342
      {loading ? (
343
        <Loading compact />
344
      ) : (
345
        <PageContent
346
          page={page}
347
          data={data}
348
          settings={settings}
349
          setSettings={setSettings}
350
          navigate={navigate}
351
          refreshDashboard={refreshDashboard}
352
          api={api}
353
        />
354
      )}
355
    </AppShell>
356
  );
357
}
358
359
function Login({ onPreview }: { onPreview?: () => void }) {
360
  const [error, setError] = useState('');
361
  const continueWith = async (provider: 'google' | 'github') => {
362
    setError('');
363
    try {
364
      await beginAuth(provider);
365
    } catch {
366
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
367
    }
368
  };
369
  return (
370
    <div className="loginPage">
371
      <section className="loginPitch">
372
        <div className="brand">
373
          <div className="mark">OC</div>
374
          <strong>OpenClasp</strong>
375
        </div>
376
        <div>
377
          <p className="eyebrow">ASSURANCE</p>
378
          <h1>Know who your agent is dealing with.</h1>
379
          <p>Verified identities and signed outcomes. No message bodies for scoring.</p>
380
        </div>
381
        <div className="loginProof">
382
          <span>01</span> User-owned history
383
          <span>02</span> No universal trust score
384
          <span>03</span> Explicit network consent
385
        </div>
386
      </section>
387
      <section className="loginCard">
388
        <div>
389
          <p className="eyebrow">SECURE ACCESS</p>
390
          <h2>Sign in</h2>
391
          <p>Agents, signed history, and network controls.</p>
392
        </div>
393
        <div className="socialButtons">
394
          <button type="button" onClick={() => void continueWith('google')}>
395
            <GoogleMark /> Continue with Google
396
          </button>
397
          <button type="button" onClick={() => void continueWith('github')}>
398
            <GitHubMark /> Continue with GitHub
399
          </button>
400
          {onPreview && (
401
            <button type="button" onClick={() => void onPreview()}>
402
              Continue with local preview
403
            </button>
404
          )}
405
        </div>
406
        {error && (
407
          <div className="loginError" role="alert">
408
            {error}
409
          </div>
410
        )}
411
        <small>
412
          Google and GitHub handle authentication. OpenClasp never receives your password.
413
        </small>
414
      </section>
415
    </div>
416
  );
417
}
418
419
let callbackStarted = false;
420
function AuthCallback() {
421
  const [error, setError] = useState('');
422
  useEffect(() => {
423
    if (callbackStarted) return;
424
    callbackStarted = true;
425
    const finish = async () => {
426
      const query = new URLSearchParams(location.search);
427
      const oauthError = query.get('error_description') ?? query.get('error');
428
      if (oauthError) throw new Error(oauthError);
429
      const code = query.get('code');
430
      const returnedState = query.get('state');
431
      if (code && returnedState?.startsWith('oc_tx_')) {
432
        const response = await fetch('/api/oauth-callback', {
433
          method: 'POST',
434
          credentials: 'same-origin',
435
          headers: { 'content-type': 'application/json' },
436
          body: JSON.stringify({ code, state: returnedState }),
437
        });
438
        if (!response.ok) throw new Error('MCP authorization failed');
439
        const result = (await response.json()) as { redirectTo?: unknown };
440
        if (typeof result.redirectTo !== 'string')
441
          throw new Error('Invalid MCP authorization response');
442
        location.replace(result.redirectTo);
443
        return;
444
      }
445
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
446
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
447
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
448
      sessionStorage.removeItem(authTransactionKey);
449
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
450
      const response = await fetch('/api/session', {
451
        method: 'POST',
452
        credentials: 'same-origin',
453
        headers: { 'content-type': 'application/json' },
454
        body: JSON.stringify({
455
          code,
456
          codeVerifier: transaction.verifier,
457
        }),
458
      });
459
      if (!response.ok) throw new Error('Auth0 token exchange failed');
460
      location.replace('/dashboard');
461
    };
462
    void finish().catch((reason: unknown) =>
463
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
464
    );
465
  }, []);
466
  if (error)
467
    return (
468
      <div className="loading">
469
        <span className="mark">OC</span>
470
        <p>{error}</p>
471
        <a href="/login">Return to login</a>
472
      </div>
473
    );
474
  return <Loading />;
475
}
476
477
function PageContent({
478
  page,
479
  data,
480
  settings,
481
  setSettings,
482
  navigate,
483
  refreshDashboard,
484
  api,
485
}: {
486
  page: Page;
487
  data: DashboardData;
488
  settings: Settings;
489
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
490
  navigate: (page: Page) => void;
491
  refreshDashboard: () => Promise<void>;
492
  api: (path: string, init?: RequestInit) => Promise<unknown>;
493
}) {
494
  if (page === 'history') return <History data={data} />;
495
  if (page === 'conversations')
496
    return <Conversations data={data} refreshDashboard={refreshDashboard} api={api} />;
497
  if (page === 'agents')
498
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
499
  if (page === 'insights') return <Insights data={data} />;
500
  if (page === 'connect')
501
    return (
502
      <Connect data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />
503
    );
504
  if (page === 'settings')
505
    return <SettingsPage settings={settings} setSettings={setSettings} api={api} />;
506
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} api={api} />;
507
}
508
509
function Overview({
510
  data,
511
  navigate,
512
  refreshDashboard,
513
  api,
514
}: {
515
  data: DashboardData;
516
  navigate: (page: Page) => void;
517
  refreshDashboard: () => Promise<void>;
518
  api: (path: string, init?: RequestInit) => Promise<unknown>;
519
}) {
520
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
521
  const warnings = data.events.filter((item) =>
522
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
523
  ).length;
524
  const publishedIds = new Set(
525
    data.publications
526
      .filter((publication) => publication.published)
527
      .map((publication) => String(publication.agentId)),
528
  );
529
  const runtimeIds = new Set(
530
    data.runtimes
531
      .filter((runtime) => runtime.status === 'verified')
532
      .map((runtime) => String(runtime.agentId)),
533
  );
534
  const modeOf = (agent: Record<string, any>) =>
535
    agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
536
  const readyAgents = data.agents.filter(
537
    (agent) =>
538
      publishedIds.has(String(agent.agentId)) &&
539
      (modeOf(agent) === 'temporary_chat' || runtimeIds.has(String(agent.agentId))),
540
  ).length;
541
  const activeTemporaryChats = data.agents.filter(
542
    (agent) => modeOf(agent) === 'temporary_chat' && agent.presence?.status === 'online',
543
  ).length;
544
  const pendingInvitations = data.federatedInteractions.filter(
545
    (interaction) => interaction.status === 'pending',
546
  ).length;
547
  const pendingFeedback = data.feedbackRequests.filter(
548
    (request) => request.status === 'pending',
549
  ).length;
550
  const pendingSetup = data.setupRequests.filter((request) => request.status === 'pending').length;
551
  const reviewCount = warnings + pendingInvitations + pendingFeedback;
552
  return (
553
    <>
554
      <PageHead page="dashboard" action="Connect agent" onAction={() => navigate('connect')} />
555
      {pendingSetup > 0 && (
556
        <button className="attentionBanner" type="button" onClick={() => navigate('connect')}>
557
          <div>
558
            <span className="statusOrb">{pendingSetup}</span>
559
            <div>
560
              <strong>
561
                {pendingSetup} setup request{pendingSetup === 1 ? '' : 's'} waiting
562
              </strong>
563
            </div>
564
          </div>
565
        </button>
566
      )}
567
      {!(readyAgents === data.agents.length && readyAgents) && (
568
        <section className="readiness">
569
          <div>
570
            <span className="statusOrb">!</span>
571
            <div>
572
              <strong>
573
                {readyAgents} of {data.agents.length} agents can receive A2A work
574
              </strong>
575
            </div>
576
          </div>
577
          <button className="secondary" type="button" onClick={() => navigate('agents')}>
578
            Manage
579
          </button>
580
        </section>
581
      )}
582
      <section className="metrics">
583
        <Metric
584
          label="Active temporary chats"
585
          value={activeTemporaryChats}
586
          note={`${runtimeIds.size} persistent runtimes connected`}
587
        />
588
        <Metric
589
          label="Interactions"
590
          value={data.interactions.length + data.federatedInteractions.length}
591
          note="local and shared"
592
        />
593
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
594
        <Metric
595
          label="Needs review"
596
          value={reviewCount}
597
          note={`${pendingFeedback} feedback · ${pendingInvitations} invitations`}
598
          warn={reviewCount > 0}
599
        />
600
      </section>
601
      <Invitations data={data} refreshDashboard={refreshDashboard} api={api} />
602
      <section className="contentGrid">
603
        <Panel title="Recent activity" subtitle="">
604
          <Timeline events={data.events.slice(-6).reverse()} />
605
          <TextButton onClick={() => navigate('history')}>All history</TextButton>
606
        </Panel>
607
        <Panel title="Reliability" subtitle="">
608
          {data.profiles.length ? (
609
            data.profiles.slice(0, 4).map((profile) => {
610
              const deltas = data.profileDeltas.filter(
611
                (delta) =>
612
                  delta.agentId === profile.agentId &&
613
                  delta.agentVersion === profile.agentVersion &&
614
                  delta.taskCategory === profile.taskCategory,
615
              );
616
              return (
617
                <Profile
618
                  key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
619
                  profile={profile}
620
                  deltas={deltas}
621
                />
622
              );
623
            })
624
          ) : (
625
            <Empty
626
              title="No reliability profile yet"
627
              text="Profiles appear after signed receipts and eligible bilateral feedback."
628
            />
629
          )}
630
        </Panel>
631
      </section>
632
    </>
633
  );
634
}
635
636
function Conversations({
637
  data,
638
  refreshDashboard,
639
  api,
640
}: {
641
  data: DashboardData;
642
  refreshDashboard: () => Promise<void>;
643
  api: (path: string, init?: RequestInit) => Promise<unknown>;
644
}) {
645
  const [selected, setSelected] = useState<Record<string, any> | null>(null);
646
  const [selectedAgentId, setSelectedAgentId] = useState('');
647
  const [draft, setDraft] = useState('');
648
  const [working, setWorking] = useState(false);
649
  const [error, setError] = useState('');
650
  const temporaryAgentFor = (thread: Record<string, any>) =>
651
    data.agents.find(
652
      (agent) =>
653
        thread.participantAgentIds?.includes(agent.agentId) &&
654
        (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
655
          'temporary_chat',
656
    );
657
  const openThread = async (thread: Record<string, any>) => {
658
    const agent = temporaryAgentFor(thread);
659
    if (!agent) return;
660
    setWorking(true);
661
    setError('');
662
    try {
663
      const [detail] = (await Promise.all([
664
        api(
665
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}`,
666
        ),
667
        api(
668
          `/v0.1/agents/${encodeURIComponent(agent.agentId)}/threads/${encodeURIComponent(thread.threadId)}/read`,
669
          { method: 'POST' },
670
        ),
671
      ])) as [Record<string, any>, unknown];
672
      setSelected(detail);
673
      setSelectedAgentId(String(agent.agentId));
674
      await refreshDashboard();
675
    } catch (reason) {
676
      setError(reason instanceof Error ? reason.message : 'Could not open conversation');
677
    } finally {
678
      setWorking(false);
679
    }
680
  };
681
  const reply = async () => {
682
    if (!selected || !draft.trim() || !selectedAgentId) return;
683
    setWorking(true);
684
    setError('');
685
    try {
686
      await api(`/v0.1/agents/${encodeURIComponent(selectedAgentId)}/messages`, {
687
        method: 'POST',
688
        body: JSON.stringify({
689
          interactionId: selected.thread.interactionId,
690
          content: draft.trim(),
691
        }),
692
      });
693
      setDraft('');
694
      await openThread(selected.thread);
695
    } catch (reason) {
696
      setError(reason instanceof Error ? reason.message : 'Could not send reply');
697
      setWorking(false);
698
    }
699
  };
700
  return (
701
    <>
702
      <PageHead page="conversations" />
703
      <div className="notice">
704
        <strong>Temporary only.</strong> Encrypted 30 days. Persistent runtime messages stay direct.
705
      </div>
706
      {error ? (
707
        <div className="errorBar" role="alert">
708
          {error}
709
        </div>
710
      ) : null}
711
      <section className="conversationLayout">
712
        <Panel title="Threads" subtitle="Temporary chat identities only">
713
          <div className="threadList">
714
            {data.hostedThreads.length ? (
715
              data.hostedThreads.map((thread) => {
716
                const agent = temporaryAgentFor(thread);
717
                const peer = thread.participantAgentIds?.find(
718
                  (value: string) => value !== agent?.agentId,
719
                );
720
                return (
721
                  <button
722
                    type="button"
723
                    key={thread.threadId}
724
                    className={selected?.thread?.threadId === thread.threadId ? 'active' : ''}
725
                    onClick={() => void openThread(thread)}
726
                    disabled={working}
727
                  >
728
                    <span>
729
                      <strong>{peer ?? 'Counterparty'}</strong>
730
                      <small>{agent?.name ?? agent?.agentId}</small>
731
                    </span>
732
                    <b>{thread.unreadCount ? `${thread.unreadCount} NEW` : thread.status}</b>
733
                  </button>
734
                );
735
              })
736
            ) : (
737
              <Empty
738
                title="No temporary conversations"
739
                text="Messages appear after a temporary chat identity accepts an interaction with a persistent runtime."
740
              />
741
            )}
742
          </div>
743
        </Panel>
744
        <Panel
745
          title={selected ? 'Conversation' : 'Select a thread'}
746
          subtitle="Private insights are attributable and task-specific"
747
        >
748
          {selected ? (
749
            <div className="conversationDetail">
750
              {!!selected.insights?.length && (
751
                <div className="conversationInsights">
752
                  {selected.insights.map((insight: Record<string, any>) => (
753
                    <p key={insight.code}>
754
                      <b>{String(insight.severity).toUpperCase()}</b> {insight.message}
755
                    </p>
756
                  ))}
757
                </div>
758
              )}
759
              <div className="messageList">
760
                {selected.messages.map((message: Record<string, any>) => (
761
                  <article
762
                    key={message.messageId}
763
                    className={message.senderAgentId === selectedAgentId ? 'sent' : 'received'}
764
                  >
765
                    <small>{message.senderAgentId === selectedAgentId ? 'You' : 'Peer'}</small>
766
                    <p>{message.content}</p>
767
                    <span>{new Date(message.createdAt).toLocaleString()}</span>
768
                  </article>
769
                ))}
770
              </div>
771
              <div className="replyBox">
772
                <textarea
773
                  aria-label="Temporary chat reply"
774
                  value={draft}
775
                  onChange={(event) => setDraft(event.target.value)}
776
                  maxLength={20_000}
777
                  placeholder="Reply through the temporary OpenClasp A2A adapter"
778
                />
779
                <button
780
                  className="primary"
781
                  type="button"
782
                  disabled={working || !draft.trim() || selected.thread.status !== 'open'}
783
                  onClick={() => void reply()}
784
                >
785
                  {working ? 'Sending…' : 'Send reply'}
786
                </button>
787
              </div>
788
            </div>
789
          ) : (
790
            <Empty title="Nothing selected" text="Choose a thread to read its hosted history." />
791
          )}
792
        </Panel>
793
      </section>
794
    </>
795
  );
796
}
797
798
function History({ data }: { data: DashboardData }) {
799
  const [query, setQuery] = useState('');
800
  const [status, setStatus] = useState<
801
    'all' | 'pending' | 'active' | 'provisional' | 'finalizing' | 'completed'
802
  >('all');
803
  const journeys = useMemo(() => interactionJourneys(data), [data]);
804
  const filtered = useMemo(() => {
805
    const needle = query.trim().toLowerCase();
806
    return journeys.filter((journey) => {
807
      if (status !== 'all' && journey.status !== status) return false;
808
      if (!needle) return true;
809
      const hay = [
810
        journey.interactionId,
811
        journey.purpose,
812
        journey.taskCategory,
813
        journey.status,
814
        ...journey.participants,
815
      ]
816
        .filter(Boolean)
817
        .join(' ')
818
        .toLowerCase();
819
      return hay.includes(needle);
820
    });
821
  }, [journeys, query, status]);
822
  const [selectedId, setSelectedId] = useState('');
823
  const selected = filtered.find((journey) => journey.interactionId === selectedId) ?? filtered[0];
824
  return (
825
    <>
826
      <PageHead page="history" />
827
      <div className="historyToolbar">
828
        <Input
829
          value={query}
830
          onChange={(event) => setQuery(event.target.value)}
831
          placeholder="Search purpose, category, or agent"
832
          aria-label="Search interaction history"
833
          className="h-9 min-w-[220px] flex-1 rounded-none border-0 border-b bg-transparent px-0 shadow-none dark:bg-transparent"
834
        />
835
        <Tabs
836
          value={status === 'finalizing' ? 'all' : status}
837
          onValueChange={(value) =>
838
            setStatus(value as 'all' | 'pending' | 'active' | 'provisional' | 'completed')
839
          }
840
        >
841
          <TabsList variant="line" aria-label="Interaction status filters">
842
            {(['all', 'pending', 'active', 'provisional', 'completed'] as const).map((value) => (
843
              <TabsTrigger key={value} value={value} className="capitalize">
844
                {value}
845
                <span className="font-mono text-[10px] text-muted-foreground">
846
                  {value === 'all'
847
                    ? journeys.length
848
                    : journeys.filter((item) => item.status === value).length}
849
                </span>
850
              </TabsTrigger>
851
            ))}
852
          </TabsList>
853
        </Tabs>
854
      </div>
855
      {filtered.length ? (
856
        <section className="journeyLayout">
857
          <div className="journeyList" aria-label="Interactions">
858
            {filtered.map((journey) => (
859
              <button
860
                key={journey.interactionId}
861
                className={selected?.interactionId === journey.interactionId ? 'active' : ''}
862
                type="button"
863
                onClick={() => {
864
                  setSelectedId(journey.interactionId);
865
                  if (matchMedia('(max-width: 980px)').matches)
866
                    requestAnimationFrame(() =>
867
                      document
868
                        .querySelector('.journeyDetail')
869
                        ?.scrollIntoView({ behavior: 'smooth' }),
870
                    );
871
                }}
872
              >
873
                <span className="journeyListTop">
874
                  <b>{journey.taskCategory}</b>
875
                  <StatusPill value={journey.status} />
876
                </span>
877
                <strong>{journey.purpose}</strong>
878
                <small>{journey.participants.join(' ↔ ') || 'Participants unavailable'}</small>
879
                <span className="journeyListMeta">
880
                  {relativeTime(journey.updatedAt)}
881
                  <i>{journey.stepCount} recorded steps</i>
882
                </span>
883
              </button>
884
            ))}
885
          </div>
886
          {selected ? <InteractionJourney journey={selected} /> : null}
887
        </section>
888
      ) : (
889
        <section className="panel">
890
          <Empty
891
            title={journeys.length ? 'No matching interactions' : 'No interactions recorded'}
892
            text={
893
              journeys.length
894
                ? 'Clear the search or choose another status.'
895
                : 'Completed contracts, outcomes, feedback, and learning will appear as one readable journey.'
896
            }
897
          />
898
        </section>
899
      )}
900
    </>
901
  );
902
}
903
904
type InteractionJourneyModel = {
905
  interactionId: string;
906
  purpose: string;
907
  taskCategory: string;
908
  status: string;
909
  participants: string[];
910
  updatedAt: string;
911
  stepCount: number;
912
  interaction: Record<string, any>;
913
  session?: Record<string, any>;
914
  events: Record<string, any>[];
915
  briefs: Record<string, any>[];
916
  reports: Record<string, any>[];
917
  feedbackRequests: Record<string, any>[];
918
  feedback: Record<string, any>[];
919
  conclusion?: Record<string, any>;
920
  receipt?: Record<string, any>;
921
  eligibility?: Record<string, any>;
922
  deltas: Record<string, any>[];
923
  conflicts: Record<string, any>[];
924
};
925
926
function interactionJourneys(data: DashboardData): InteractionJourneyModel[] {
927
  const ids = new Set<string>();
928
  const collections = [
929
    data.interactions,
930
    data.federatedInteractions,
931
    data.liveSessions,
932
    data.events,
933
    data.counterpartyBriefs,
934
    data.completionReports,
935
    data.feedbackRequests,
936
    data.interactionFeedback,
937
    data.interactionConclusions,
938
    data.receipts,
939
    data.learningEligibility,
940
    data.profileDeltas,
941
    data.conflicts,
942
  ];
943
  for (const collection of collections)
944
    for (const item of collection) if (item.interactionId) ids.add(String(item.interactionId));
945
  return [...ids]
946
    .map((interactionId) => {
947
      const interaction = data.federatedInteractions.find(
948
        (item) => item.interactionId === interactionId,
949
      ) ??
950
        data.interactions.find((item) => item.interactionId === interactionId) ?? { interactionId };
951
      const session = data.liveSessions.find((item) => item.interactionId === interactionId);
952
      const events = data.events.filter((item) => item.interactionId === interactionId);
953
      const briefs = data.counterpartyBriefs.filter((item) => item.interactionId === interactionId);
954
      const reports = data.completionReports.filter((item) => item.interactionId === interactionId);
955
      const feedbackRequests = data.feedbackRequests.filter(
956
        (item) => item.interactionId === interactionId,
957
      );
958
      const feedback = data.interactionFeedback.filter(
959
        (item) => item.interactionId === interactionId,
960
      );
961
      const conclusion = data.interactionConclusions.find(
962
        (item) => item.interactionId === interactionId,
963
      );
964
      const receipt = data.receipts.find((item) => item.interactionId === interactionId);
965
      const eligibility = data.learningEligibility.find(
966
        (item) => item.interactionId === interactionId,
967
      );
968
      const deltas = data.profileDeltas.filter((item) => item.interactionId === interactionId);
969
      const conflicts = data.conflicts.filter((item) => item.interactionId === interactionId);
970
      const participants = [
971
        interaction.initiatorAgentId,
972
        interaction.responderAgentId,
973
        interaction.agentId,
974
        ...reports.flatMap((report) => [report.reportingAgentId, report.counterpartyAgentId]),
975
      ]
976
        .filter((value): value is string => typeof value === 'string' && Boolean(value))
977
        .filter((value, index, values) => values.indexOf(value) === index);
978
      const dates = [
979
        interaction.updatedAt,
980
        interaction.createdAt,
981
        session?.completedAt,
982
        session?.activatedAt,
983
        ...events.map(timestamp),
984
        ...reports.map(timestamp),
985
        ...feedback.map(timestamp),
986
        conclusion?.generatedAt,
987
        receipt?.completedAt,
988
        eligibility?.decidedAt,
989
      ].filter((value): value is string => typeof value === 'string');
990
      return {
991
        interactionId,
992
        purpose: String(
993
          interaction.contract?.purpose ??
994
            interaction.contract?.requestedOutcome ??
995
            conclusion?.summary ??
996
            'Assured agent interaction',
997
        ),
998
        taskCategory: String(interaction.contract?.taskCategory ?? 'general'),
999
        status: String(
1000
          conclusion || receipt
1001
            ? conclusion?.lifecycle === 'provisional' || receipt?.provisional
1002
              ? 'provisional'
1003
              : 'completed'
1004
            : reports.length > 0
1005
              ? 'finalizing'
1006
              : (session?.status ?? interaction.status ?? 'pending'),
1007
        ),
1008
        participants,
1009
        updatedAt:
1010
          dates.sort((left, right) => Date.parse(right) - Date.parse(left))[0] ??
1011
          new Date(0).toISOString(),
1012
        stepCount:
1013
          1 +
1014
          Math.max(0, Number(interaction.contractRevisions?.length ?? 0) - 1) +
1015
          events.length +
1016
          briefs.length +
1017
          reports.length +
1018
          feedbackRequests.length +
1019
          feedback.length +
1020
          conflicts.length +
1021
          Number(Boolean(conclusion)) +
1022
          Number(Boolean(receipt)) +
1023
          Number(Boolean(eligibility)) +
1024
          deltas.length,
1025
        interaction,
1026
        ...(session ? { session } : {}),
1027
        events,
1028
        briefs,
1029
        reports,
1030
        feedbackRequests,
1031
        feedback,
1032
        ...(conclusion ? { conclusion } : {}),
1033
        ...(receipt ? { receipt } : {}),
1034
        ...(eligibility ? { eligibility } : {}),
1035
        deltas,
1036
        conflicts,
1037
      };
1038
    })
1039
    .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
1040
}
1041
1042
function InteractionJourney({ journey }: { journey: InteractionJourneyModel }) {
1043
  const timeline = interactionTimeline(journey);
1044
  const conclusion = journey.conclusion;
1045
  const checkpoints = journey.events.filter(
1046
    (event) => (event.type ?? event.eventType) === 'progress_checkpoint' && event.checkpoint,
1047
  );
1048
  const latestCheckpoint = checkpoints.at(-1)?.checkpoint;
1049
  const progress = Number(latestCheckpoint?.progress ?? 0);
1050
  const submittedFeedback = new Set(
1051
    journey.feedback.map((item) => item.reviewerAgentId).filter(Boolean),
1052
  ).size;
1053
  return (
1054
    <article className="journeyDetail">
1055
      <header className="journeyHero">
1056
        <div>
1057
          <span className="eyebrow">{journey.taskCategory}</span>
1058
          <h2>{journey.purpose}</h2>
1059
          <p>{journey.participants.join(' ↔ ')}</p>
1060
        </div>
1061
        <StatusPill value={conclusion?.outcome ?? journey.status} />
1062
      </header>
1063
1064
      <div className="journeySummaryGrid">
1065
        <section>
1066
          <span>Progress</span>
1067
          <strong>
1068
            {latestCheckpoint
1069
              ? `${Math.round(progress * 100)}% · ${humanize(latestCheckpoint.state)}`
1070
              : journey.reports.length
1071
                ? 'One side ended'
1072
                : 'Awaiting checkpoint'}
1073
          </strong>
1074
          <small>
1075
            {latestCheckpoint
1076
              ? `${humanize(latestCheckpoint.topicStatus)} · ${Math.round(Number(latestCheckpoint.confidence ?? 0) * 100)}% confidence`
1077
              : 'Agents report compact progress every few exchanges'}
1078
          </small>
1079
        </section>
1080
        <section>
1081
          <span>Outcome</span>
1082
          <strong>
1083
            {conclusion
1084
              ? humanize(conclusion.outcome)
1085
              : `${journey.reports.length}/2 terminal reports`}
1086
          </strong>
1087
          <small>
1088
            {conclusion
1089
              ? conclusion.lifecycle === 'provisional'
1090
                ? `${Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer ${humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()}`
1091
                : humanize(conclusion.consensus)
1092
              : journey.reports.length === 1
1093
                ? 'One agent finished; confirming with the peer'
1094
                : 'Conversation remains active'}
1095
          </small>
1096
        </section>
1097
        <section>
1098
          <span>Sealed feedback</span>
1099
          <strong>
1100
            {journey.feedbackRequests.some((request) => request.status === 'pending')
1101
              ? `${submittedFeedback}/2 sealed`
1102
              : 'Window closed'}
1103
          </strong>
1104
          <small>{feedbackState(journey.feedbackRequests)}</small>
1105
        </section>
1106
        <section>
1107
          <span>Learning</span>
1108
          <strong>
1109
            {journey.eligibility
1110
              ? journey.eligibility.eligible
1111
                ? `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% weight`
1112
                : 'Not eligible'
1113
              : conclusion?.lifecycle === 'provisional'
1114
                ? 'Pending corroboration'
1115
                : 'Not assessed'}
1116
          </strong>
1117
          <small>{humanize(journey.eligibility?.contributionMode ?? 'Local only')}</small>
1118
        </section>
1119
      </div>
1120
1121
      {latestCheckpoint ? (
1122
        <section className="progressCard">
1123
          <div className="progressCardTop">
1124
            <div>
1125
              <span className="eyebrow">LATEST PRIVATE CHECKPOINT</span>
1126
              <h3>{humanize(latestCheckpoint.state)}</h3>
1127
            </div>
1128
            <strong>{Math.round(progress * 100)}%</strong>
1129
          </div>
1130
          <div
1131
            className="progressTrack"
1132
            role="progressbar"
1133
            aria-label="Interaction progress"
1134
            aria-valuemin={0}
1135
            aria-valuemax={100}
1136
            aria-valuenow={Math.round(progress * 100)}
1137
          >
1138
            <i style={{ width: `${Math.round(progress * 100)}%` }} />
1139
          </div>
1140
          <div className="progressFacts">
1141
            <span>{latestCheckpoint.criteriaMet?.length ?? 0} criteria met</span>
1142
            <span>{latestCheckpoint.criteriaRemaining?.length ?? 0} remaining</span>
1143
            <span>{latestCheckpoint.expectedRemainingTurns ?? '—'} expected turns</span>
1144
            <span>
1145
              {latestCheckpoint.needsHuman ? 'Human input needed' : 'No human input needed'}
1146
            </span>
1147
          </div>
1148
          {latestCheckpoint.topicStatus !== 'in_scope' ? (
1149
            <p className="progressWarning">
1150
              Topic {humanize(latestCheckpoint.topicStatus)}. Amend the contract or start a new
1151
              interaction before continuing.
1152
            </p>
1153
          ) : null}
1154
        </section>
1155
      ) : null}
1156
1157
      {conclusion ? (
1158
        <section className="outcomeCard">
1159
          <div>
1160
            <span className="eyebrow">
1161
              {conclusion.lifecycle === 'provisional' ? 'PROVISIONAL INSIGHT' : 'WHAT HAPPENED'}
1162
            </span>
1163
            <h3>{conclusion.summary}</h3>
1164
            {conclusion.lifecycle === 'provisional' ? (
1165
              <p className="progressWarning">
1166
                Based on {conclusion.reportIds?.length ?? 1} participant report. Missing:{' '}
1167
                {(conclusion.missingReportAgentIds ?? []).join(', ') || 'peer report'}. This is
1168
                usable now at reduced confidence and will be revised if the peer responds.
1169
              </p>
1170
            ) : null}
1171
          </div>
1172
          {!!conclusion.criteria?.length && (
1173
            <ul>
1174
              {conclusion.criteria.map((criterion: Record<string, any>) => (
1175
                <li key={criterion.criterion}>
1176
                  <StatusPill value={String(criterion.status)} />
1177
                  <span>{criterion.criterion}</span>
1178
                </li>
1179
              ))}
1180
            </ul>
1181
          )}
1182
          {!!Object.keys(conclusion.averageRatings ?? {}).length && (
1183
            <div className="ratingGrid">
1184
              {Object.entries(conclusion.averageRatings).map(([label, value]) => (
1185
                <Meter key={label} label={humanize(label)} value={Number(value)} />
1186
              ))}
1187
            </div>
1188
          )}
1189
        </section>
1190
      ) : (
1191
        <div className="journeyNotice">
1192
          {journey.reports.length === 1
1193
            ? 'Finalizing: one agent submitted its terminal report. OpenClasp has requested independent confirmation from the peer.'
1194
            : journey.reports.length >= 2
1195
              ? 'Both agents finished. Sealed feedback remains private until both respond or the feedback window expires.'
1196
              : 'Active interaction. Progress checkpoints appear after several meaningful exchanges; either agent can finalize immediately when the task is done.'}
1197
        </div>
1198
      )}
1199
1200
      <section className="journeyTimelineSection">
1201
        <div>
1202
          <h3>Interaction timeline</h3>
1203
          <small>Contract → session → outcome → feedback → learning</small>
1204
        </div>
1205
        <ol className="journeyTimeline">
1206
          {timeline.map((step, index) => (
1207
            <li key={`${step.at}-${step.title}-${index}`}>
1208
              <span className={`journeyNode ${step.tone}`} />
1209
              <div>
1210
                <span>{formatDate(step.at)}</span>
1211
                <strong>{step.title}</strong>
1212
                <p>{step.detail}</p>
1213
              </div>
1214
              <StatusPill value={step.status} />
1215
            </li>
1216
          ))}
1217
        </ol>
1218
      </section>
1219
    </article>
1220
  );
1221
}
1222
1223
function interactionTimeline(journey: InteractionJourneyModel) {
1224
  const revisions = journey.interaction.contractRevisions ?? [];
1225
  const contractSteps = revisions.length
1226
    ? revisions.map((revision: Record<string, any>) => ({
1227
        at: String(revision.updatedAt ?? revision.createdAt ?? journey.interaction.createdAt),
1228
        title: `Contract v${revision.revision} ${humanize(revision.status)}`,
1229
        detail: `${revision.proposedByAgentId} · ${Object.keys(revision.acceptances ?? {}).length}/${journey.participants.length || 2} accepted · ${String(revision.termsHash).slice(0, 12)}…`,
1230
        status: String(revision.status),
1231
        tone:
1232
          revision.status === 'accepted'
1233
            ? 'good'
1234
            : revision.status === 'rejected'
1235
              ? 'danger'
1236
              : revision.status === 'proposed'
1237
                ? 'warn'
1238
                : 'neutral',
1239
      }))
1240
    : [
1241
        {
1242
          at: String(journey.interaction.createdAt ?? journey.updatedAt),
1243
          title: 'Contract proposed',
1244
          detail: `${journey.participants.length || 2} participants · ${journey.taskCategory}`,
1245
          status: String(journey.interaction.status ?? 'recorded'),
1246
          tone: 'neutral',
1247
        },
1248
        ...Object.values(journey.interaction.acceptances ?? {}).map((acceptance: any) => ({
1249
          at: String(acceptance.acceptedAt ?? journey.interaction.updatedAt ?? journey.updatedAt),
1250
          title: `Contract accepted by ${acceptance.agentId}`,
1251
          detail: `Acceptance method: ${humanize(acceptance.method ?? 'recorded')}`,
1252
          status: 'accepted',
1253
          tone: 'good',
1254
        })),
1255
      ];
1256
  const steps: { at: string; title: string; detail: string; status: string; tone: string }[] = [
1257
    ...contractSteps,
1258
    ...journey.briefs.map((brief) => ({
1259
      at: String(brief.generatedAt ?? journey.updatedAt),
1260
      title: `Private counterparty brief for ${brief.recipientAgentId}`,
1261
      detail: `${brief.relevantSampleSize ?? 0} relevant samples · ${Math.round(Number(brief.historyConfidence ?? 0) * 100)}% history confidence`,
1262
      status: String(brief.decision ?? 'ready'),
1263
      tone: brief.decision === 'DENY' ? 'danger' : brief.decision === 'CHALLENGE' ? 'warn' : 'good',
1264
    })),
1265
    ...(journey.session?.activatedAt
1266
      ? [
1267
          {
1268
            at: String(journey.session.activatedAt),
1269
            title: 'A2A session activated',
1270
            detail:
1271
              'Scoped credentials were issued. Persistent runtimes exchange content directly; temporary mode uses the hosted adapter.',
1272
            status: 'active',
1273
            tone: 'good',
1274
          },
1275
        ]
1276
      : []),
1277
    ...journey.events.map((event) => {
1278
      const eventType = String(event.type ?? event.eventType ?? 'structured_event');
1279
      const checkpoint = event.checkpoint;
1280
      return {
1281
        at: timestamp(event),
1282
        title:
1283
          eventType === 'progress_checkpoint'
1284
            ? `${Math.round(Number(checkpoint?.progress ?? 0) * 100)}% progress checkpoint`
1285
            : humanize(eventType),
1286
        detail:
1287
          eventType === 'progress_checkpoint'
1288
            ? `${event.agentId} · ${humanize(checkpoint?.state ?? 'active')} · ${humanize(checkpoint?.topicStatus ?? 'in_scope')}`
1289
            : String(event.agentId ?? 'OpenClasp') + ' submitted structured metadata.',
1290
        status: String(checkpoint?.state ?? event.visibility ?? 'recorded'),
1291
        tone:
1292
          checkpoint?.topicStatus === 'changed' || checkpoint?.state === 'blocked'
1293
            ? 'warn'
1294
            : eventType.includes('violation')
1295
              ? 'danger'
1296
              : eventType.includes('warning')
1297
                ? 'warn'
1298
                : checkpoint
1299
                  ? 'good'
1300
                  : 'neutral',
1301
      };
1302
    }),
1303
    ...journey.reports.map((report) => ({
1304
      at: timestamp(report),
1305
      title: `Completion reported by ${report.reportingAgentId}`,
1306
      detail: String(report.summary ?? 'Structured completion report submitted.'),
1307
      status: String(report.outcome ?? 'reported'),
1308
      tone:
1309
        report.outcome === 'failure' ? 'danger' : report.outcome === 'partial' ? 'warn' : 'good',
1310
    })),
1311
    ...journey.feedback.map((item) => ({
1312
      at: timestamp(item),
1313
      title: `Feedback submitted by ${item.reviewerAgentId}`,
1314
      detail: `Would work again: ${humanize(item.wouldWorkAgain ?? 'unsure')} · private comment concealed`,
1315
      status: 'sealed',
1316
      tone: 'neutral',
1317
    })),
1318
    ...journey.feedbackRequests
1319
      .filter((request) => request.status !== 'submitted')
1320
      .map((request) => ({
1321
        at: String(request.requestedAt ?? journey.updatedAt),
1322
        title: `Feedback request ${humanize(request.status)}`,
1323
        detail:
1324
          request.status === 'pending'
1325
            ? `Response due ${formatDate(String(request.dueAt))}`
1326
            : 'The bilateral conclusion continued without this response.',
1327
        status: String(request.status),
1328
        tone: request.status === 'pending' ? 'warn' : 'neutral',
1329
      })),
1330
    ...journey.conflicts.map((conflict) => ({
1331
      at: timestamp(conflict),
1332
      title: 'Dispute recorded',
1333
      detail: String(conflict.summary ?? 'The interaction has a structured dispute record.'),
1334
      status: String(conflict.status ?? 'open'),
1335
      tone: conflict.status === 'resolved' ? 'good' : 'danger',
1336
    })),
1337
    ...(journey.conclusion
1338
      ? [
1339
          {
1340
            at: String(journey.conclusion.generatedAt),
1341
            title:
1342
              journey.conclusion.lifecycle === 'provisional'
1343
                ? 'Provisional insight generated'
1344
                : 'Conclusion released',
1345
            detail: String(journey.conclusion.summary),
1346
            status: String(journey.conclusion.consensus),
1347
            tone:
1348
              journey.conclusion.lifecycle === 'provisional' ||
1349
              journey.conclusion.consensus === 'conflicting'
1350
                ? 'warn'
1351
                : 'good',
1352
          },
1353
        ]
1354
      : []),
1355
    ...(journey.receipt
1356
      ? [
1357
          {
1358
            at: timestamp(journey.receipt),
1359
            title: journey.receipt.provisional
1360
              ? 'Provisional outcome attested'
1361
              : 'Outcome receipt attested',
1362
            detail: journey.receipt.provisional
1363
              ? 'The available one-sided report was sealed without claiming peer agreement.'
1364
              : 'Contract result, commitment status, and evidence hashes were sealed.',
1365
            status: String(journey.receipt.outcome ?? 'signed'),
1366
            tone: journey.receipt.outcome === 'failure' ? 'danger' : 'good',
1367
          },
1368
        ]
1369
      : []),
1370
    ...(journey.eligibility
1371
      ? [
1372
          {
1373
            at: String(journey.eligibility.decidedAt),
1374
            title: journey.eligibility.eligible
1375
              ? 'Eligible history applied'
1376
              : 'History excluded from profile',
1377
            detail: `${Math.round(Number(journey.eligibility.sampleWeight ?? 0) * 100)}% sample weight · ${humanize(journey.eligibility.contributionMode)}`,
1378
            status: journey.eligibility.eligible ? 'attested' : 'excluded',
1379
            tone: journey.eligibility.eligible ? 'good' : 'warn',
1380
          },
1381
        ]
1382
      : []),
1383
  ];
1384
  return steps.sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
1385
}
1386
1387
function Agents({
1388
  data,
1389
  navigate,
1390
  refreshDashboard,
1391
  api,
1392
}: {
1393
  data: DashboardData;
1394
  navigate: (page: Page) => void;
1395
  refreshDashboard: () => Promise<void>;
1396
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1397
}) {
1398
  const [working, setWorking] = useState('');
1399
  const [error, setError] = useState('');
1400
  const saveAutomation = async (
1401
    agentId: string,
1402
    value: {
1403
      autoPublish: boolean;
1404
      autoAcceptPolicy: 'off' | 'safe_matching';
1405
      autoAcceptTaskCategories: string[];
1406
    },
1407
  ) => {
1408
    setWorking(agentId);
1409
    setError('');
1410
    try {
1411
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
1412
        method: 'PUT',
1413
        body: JSON.stringify(value),
1414
      });
1415
      await refreshDashboard();
1416
      return true;
1417
    } catch (reason) {
1418
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
1419
      return false;
1420
    } finally {
1421
      setWorking('');
1422
    }
1423
  };
1424
  const saveRuntime = async (agentId: string, endpoint: string) => {
1425
    setWorking(`runtime:${agentId}`);
1426
    setError('');
1427
    try {
1428
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
1429
        method: 'PUT',
1430
        body: JSON.stringify({ endpoint }),
1431
      });
1432
      await refreshDashboard();
1433
    } catch (reason) {
1434
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
1435
    } finally {
1436
      setWorking('');
1437
    }
1438
  };
1439
  const disableRuntime = async (agentId: string) => {
1440
    setWorking(`runtime:${agentId}`);
1441
    setError('');
1442
    try {
1443
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
1444
      await refreshDashboard();
1445
    } catch (reason) {
1446
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
1447
    } finally {
1448
      setWorking('');
1449
    }
1450
  };
1451
  const revokeAccessToken = async (agentId: string, tokenId: string) => {
1452
    setWorking(`token:${agentId}`);
1453
    setError('');
1454
    try {
1455
      await api(
1456
        `/v0.1/agents/${encodeURIComponent(agentId)}/access-tokens/${encodeURIComponent(tokenId)}`,
1457
        { method: 'DELETE' },
1458
      );
1459
      await refreshDashboard();
1460
    } catch (reason) {
1461
      setError(reason instanceof Error ? reason.message : 'Could not revoke agent access token');
1462
    } finally {
1463
      setWorking('');
1464
    }
1465
  };
1466
  const deleteAgent = async (agent: Record<string, any>) => {
1467
    const agentId = String(agent.agentId);
1468
    if (
1469
      !window.confirm(
1470
        `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.`,
1471
      )
1472
    )
1473
      return;
1474
    setWorking(`delete:${agentId}`);
1475
    setError('');
1476
    try {
1477
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
1478
      await refreshDashboard();
1479
    } catch (reason) {
1480
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
1481
    } finally {
1482
      setWorking('');
1483
    }
1484
  };
1485
  return (
1486
    <>
1487
      <PageHead page="agents" action="Connect agent" onAction={() => navigate('connect')} />
1488
      <div className="notice">
1489
        <strong>Two explicit modes.</strong> Persistent runtimes talk directly over A2A. Temporary
1490
        chats use an OpenClasp-hosted endpoint with encrypted-at-rest history. There is no offline
1491
        relay for persistent runtimes.
1492
      </div>
1493
      {error && (
1494
        <div className="errorBar" role="alert">
1495
          {error}
1496
        </div>
1497
      )}
1498
      <section className="agentGrid">
1499
        {data.agents.length ? (
1500
          data.agents.map((agent) => (
1501
            <AgentCard
1502
              key={agent.agentId}
1503
              agent={agent}
1504
              projectName={
1505
                data.projects.find((project) => project.projectId === agent.projectId)?.name
1506
              }
1507
              published={data.publications.some(
1508
                (publication) => publication.agentId === agent.agentId && publication.published,
1509
              )}
1510
              working={working === agent.agentId}
1511
              onSave={(value) => saveAutomation(agent.agentId, value)}
1512
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
1513
              runtimeWorking={working === `runtime:${agent.agentId}`}
1514
              deleteWorking={working === `delete:${agent.agentId}`}
1515
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
1516
              onDisableRuntime={() => disableRuntime(agent.agentId)}
1517
              accessTokens={data.accessTokens.filter(
1518
                (token) =>
1519
                  token.agentId === agent.agentId &&
1520
                  !token.revokedAt &&
1521
                  Date.parse(String(token.expiresAt)) > Date.now(),
1522
              )}
1523
              accessTokenWorking={working === `token:${agent.agentId}`}
1524
              onRevokeAccessToken={(tokenId) => revokeAccessToken(agent.agentId, tokenId)}
1525
              onDelete={() => deleteAgent(agent)}
1526
            />
1527
          ))
1528
        ) : (
1529
          <Empty
1530
            title="No agents connected"
1531
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
1532
            action="Open connection guide"
1533
            onAction={() => navigate('connect')}
1534
          />
1535
        )}
1536
      </section>
1537
    </>
1538
  );
1539
}
1540
1541
function Invitations({
1542
  data,
1543
  refreshDashboard,
1544
  api,
1545
}: {
1546
  data: DashboardData;
1547
  refreshDashboard: () => Promise<void>;
1548
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1549
}) {
1550
  const [working, setWorking] = useState('');
1551
  const [error, setError] = useState('');
1552
  const ownedAgentIds = useMemo(
1553
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
1554
    [data.agents],
1555
  );
1556
  const currentProposal = (interaction: Record<string, any>) =>
1557
    [...(interaction.contractRevisions ?? [])]
1558
      .reverse()
1559
      .find((revision) => revision.status === 'proposed');
1560
  const respondingAgent = (interaction: Record<string, any>) => {
1561
    const proposal = currentProposal(interaction);
1562
    return (interaction.contract?.parties ?? []).find(
1563
      (agentId: string) => ownedAgentIds.has(String(agentId)) && !proposal?.acceptances?.[agentId],
1564
    );
1565
  };
1566
  const incoming = data.federatedInteractions.filter(
1567
    (interaction) => currentProposal(interaction) && respondingAgent(interaction),
1568
  );
1569
  const respond = async (
1570
    interactionId: string,
1571
    revisionId: string,
1572
    agentId: string,
1573
    decision: 'accept' | 'reject',
1574
  ) => {
1575
    setWorking(interactionId);
1576
    setError('');
1577
    try {
1578
      await api(
1579
        `/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/contract-proposals/${encodeURIComponent(revisionId)}/respond`,
1580
        {
1581
          method: 'POST',
1582
          body: JSON.stringify({ agentId, decision }),
1583
        },
1584
      );
1585
      await refreshDashboard();
1586
    } catch (reason) {
1587
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
1588
    } finally {
1589
      setWorking('');
1590
    }
1591
  };
1592
  if (!incoming.length && !data.federatedInteractions.length) return null;
1593
  return (
1594
    <Panel
1595
      title={incoming.length ? `Contract decisions (${incoming.length})` : 'Shared interactions'}
1596
      subtitle=""
1597
    >
1598
      {error ? (
1599
        <div className="errorBar" role="alert">
1600
          {error}
1601
        </div>
1602
      ) : null}
1603
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
1604
        <article className="setupRequest" key={interaction.interactionId}>
1605
          <div>
1606
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
1607
            <small>
1608
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
1609
            </small>
1610
            <small>
1611
              Contract v{interaction.contractRevision ?? 1}:{' '}
1612
              {String(currentProposal(interaction)?.termsHash ?? interaction.termsHash).slice(
1613
                0,
1614
                16,
1615
              )}
1616
              …
1617
              {currentProposal(interaction)
1618
                ? ` · proposed by ${currentProposal(interaction).proposedByAgentId}`
1619
                : ' · agreed'}
1620
            </small>
1621
            {interaction.contractRevisions?.length > 1 ? (
1622
              <small>{interaction.contractRevisions.length} recorded contract revisions</small>
1623
            ) : null}
1624
            {data.liveSessions.find(
1625
              (session) => session.interactionId === interaction.interactionId,
1626
            ) ? (
1627
              <small className="autoAccepted">
1628
                Live session:{' '}
1629
                {
1630
                  data.liveSessions.find(
1631
                    (session) => session.interactionId === interaction.interactionId,
1632
                  )?.status
1633
                }
1634
              </small>
1635
            ) : null}
1636
            {interaction.status === 'active' &&
1637
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
1638
              'policy_auto_accept' ? (
1639
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
1640
            ) : null}
1641
          </div>
1642
          {currentProposal(interaction) && respondingAgent(interaction) ? (
1643
            <div className="decisionButtons">
1644
              <button
1645
                className="secondary"
1646
                type="button"
1647
                disabled={working === interaction.interactionId}
1648
                onClick={() =>
1649
                  void respond(
1650
                    interaction.interactionId,
1651
                    currentProposal(interaction).revisionId,
1652
                    respondingAgent(interaction),
1653
                    'reject',
1654
                  )
1655
                }
1656
              >
1657
                Reject
1658
              </button>
1659
              <button
1660
                className="primary"
1661
                type="button"
1662
                disabled={working === interaction.interactionId}
1663
                onClick={() =>
1664
                  void respond(
1665
                    interaction.interactionId,
1666
                    currentProposal(interaction).revisionId,
1667
                    respondingAgent(interaction),
1668
                    'accept',
1669
                  )
1670
                }
1671
              >
1672
                {working === interaction.interactionId
1673
                  ? 'Working…'
1674
                  : interaction.status === 'active'
1675
                    ? 'Accept amendment'
1676
                    : 'Accept contract'}
1677
              </button>
1678
            </div>
1679
          ) : null}
1680
        </article>
1681
      ))}
1682
    </Panel>
1683
  );
1684
}
1685
1686
function Insights({ data }: { data: DashboardData }) {
1687
  const eligible = data.learningEligibility.filter((item) => item.eligible);
1688
  const pendingFeedback = data.feedbackRequests.filter((item) => item.status === 'pending');
1689
  const provisional = data.interactionConclusions.filter(
1690
    (item) => item.lifecycle === 'provisional',
1691
  );
1692
  return (
1693
    <>
1694
      <PageHead page="insights" />
1695
      <section className="insightPrinciple">
1696
        <div>
1697
          <span className="eyebrow">HOW TO READ THIS</span>
1698
          <h2>Reliability is contextual, not a leaderboard.</h2>
1699
          <p>
1700
            Every profile is tied to an agent version and task category. Scores reflect eligible
1701
            structured outcomes and decay as history gets older.
1702
          </p>
1703
        </div>
1704
        <div className="insightStats">
1705
          <span>
1706
            <strong>{data.profiles.length}</strong> contextual profiles
1707
          </span>
1708
          <span>
1709
            <strong>{eligible.length}</strong> eligible interactions
1710
          </span>
1711
          <span>
1712
            <strong>{pendingFeedback.length}</strong> feedback pending
1713
          </span>
1714
          <span>
1715
            <strong>{provisional.length}</strong> provisional signals
1716
          </span>
1717
        </div>
1718
      </section>
1719
      {!!provisional.length && (
1720
        <Panel
1721
          title="Provisional interaction signals"
1722
          subtitle="Available now, kept separate from corroborated reliability history"
1723
        >
1724
          <div className="learningDecisionList">
1725
            {provisional
1726
              .slice()
1727
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
1728
              .map((conclusion) => (
1729
                <article key={conclusion.conclusionId}>
1730
                  <StatusPill value="provisional" />
1731
                  <div>
1732
                    <strong>{conclusion.summary}</strong>
1733
                    <small>
1734
                      {Math.round(Number(conclusion.confidence ?? 0) * 100)}% confidence · peer{' '}
1735
                      {humanize(conclusion.peerReportStatus ?? 'awaiting').toLowerCase()} ·{' '}
1736
                      {relativeTime(timestamp(conclusion))}
1737
                    </small>
1738
                    <p>
1739
                      Missing report from{' '}
1740
                      {(conclusion.missingReportAgentIds ?? []).join(', ') || 'the counterparty'}.
1741
                      The signal will be revised if more structured evidence arrives.
1742
                    </p>
1743
                  </div>
1744
                </article>
1745
              ))}
1746
          </div>
1747
        </Panel>
1748
      )}
1749
      <section className="insightGrid">
1750
        {data.profiles.length ? (
1751
          data.profiles.map((profile) => {
1752
            const deltas = data.profileDeltas
1753
              .filter(
1754
                (delta) =>
1755
                  delta.agentId === profile.agentId &&
1756
                  delta.agentVersion === profile.agentVersion &&
1757
                  delta.taskCategory === profile.taskCategory,
1758
              )
1759
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)));
1760
            return (
1761
              <Panel
1762
                key={`${profile.agentId}-${profile.agentVersion}-${profile.taskCategory}`}
1763
                title={profile.agentId}
1764
                subtitle={`${humanize(profile.taskCategory)} · version ${profile.agentVersion}`}
1765
              >
1766
                <Profile profile={profile} expanded deltas={deltas} showHeader={false} />
1767
              </Panel>
1768
            );
1769
          })
1770
        ) : (
1771
          <Empty
1772
            title="Not enough verified evidence"
1773
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
1774
          />
1775
        )}
1776
      </section>
1777
      {!!data.learningEligibility.length && (
1778
        <Panel
1779
          title="Recent learning decisions"
1780
          subtitle="Why an interaction did or did not affect contextual history"
1781
        >
1782
          <div className="learningDecisionList">
1783
            {data.learningEligibility
1784
              .slice()
1785
              .sort((left, right) => Date.parse(timestamp(right)) - Date.parse(timestamp(left)))
1786
              .slice(0, 8)
1787
              .map((decision) => (
1788
                <article key={decision.decisionId}>
1789
                  <StatusPill value={decision.eligible ? 'eligible' : 'excluded'} />
1790
                  <div>
1791
                    <strong>{decision.interactionId}</strong>
1792
                    <small>
1793
                      {Math.round(Number(decision.sampleWeight ?? 0) * 100)}% weight ·{' '}
1794
                      {humanize(decision.contributionMode)} · {relativeTime(timestamp(decision))}
1795
                    </small>
1796
                    <p>{decision.reasons?.[0] ?? 'No reason supplied.'}</p>
1797
                  </div>
1798
                </article>
1799
              ))}
1800
          </div>
1801
        </Panel>
1802
      )}
1803
    </>
1804
  );
1805
}
1806
1807
function Connect({
1808
  data,
1809
  navigate,
1810
  refreshDashboard,
1811
  api,
1812
}: {
1813
  data: DashboardData;
1814
  navigate: (page: Page) => void;
1815
  refreshDashboard: () => Promise<void>;
1816
  api: (path: string, init?: RequestInit) => Promise<unknown>;
1817
}) {
1818
  const endpoint = 'https://openclasp.vercel.app/mcp';
1819
  const [connectionType, setConnectionType] = useState<'interactive' | 'hosted'>('hosted');
1820
  const [copied, setCopied] = useState(false);
1821
  const [tokenCopied, setTokenCopied] = useState(false);
1822
  const [working, setWorking] = useState('');
1823
  const [decisionError, setDecisionError] = useState('');
1824
  const [providerError, setProviderError] = useState('');
1825
  const [providerForm, setProviderForm] = useState({
1826
    provider: 'botpress' as 'botpress' | 'custom',
1827
    agentName: '',
1828
    projectName: '',
1829
    description: '',
1830
    capabilities: '',
1831
    limitations: '',
1832
    expiresInDays: 365,
1833
  });
1834
  const [providerResult, setProviderResult] = useState<{
1835
    provider: 'botpress' | 'custom';
1836
    agent: { agentId: string; name: string; framework: string };
1837
    accessToken: { token: string; expiresAt: string };
1838
  }>();
1839
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
1840
  useEffect(() => {
1841
    const poll = () => void refreshDashboard().catch(() => undefined);
1842
    poll();
1843
    const timer = window.setInterval(poll, 5000);
1844
    return () => window.clearInterval(timer);
1845
  }, [refreshDashboard]);
1846
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
1847
    setWorking(requestId);
1848
    setDecisionError('');
1849
    try {
1850
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
1851
        method: 'POST',
1852
      });
1853
      await refreshDashboard();
1854
    } catch (reason) {
1855
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
1856
    } finally {
1857
      setWorking('');
1858
    }
1859
  };
1860
  const connectHostedProvider = async (event: React.FormEvent) => {
1861
    event.preventDefault();
1862
    setWorking('hosted-provider');
1863
    setProviderError('');
1864
    try {
1865
      const list = (value: string) =>
1866
        value
1867
          .split(/[,\n]/)
1868
          .map((item) => item.trim())
1869
          .filter(Boolean);
1870
      const created = (await api('/v0.1/provider-connections', {
1871
        method: 'POST',
1872
        body: JSON.stringify({
1873
          provider: providerForm.provider,
1874
          agentName: providerForm.agentName,
1875
          projectName: providerForm.projectName,
1876
          description: providerForm.description,
1877
          capabilities: list(providerForm.capabilities),
1878
          limitations: list(providerForm.limitations),
1879
          expiresInDays: providerForm.expiresInDays,
1880
        }),
1881
      })) as {
1882
        provider: 'botpress' | 'custom';
1883
        agent: { agentId: string; name: string; framework: string };
1884
        accessToken: { token: string; expiresAt: string };
1885
      };
1886
      setProviderResult(created);
1887
      await refreshDashboard().catch(() => undefined);
1888
    } catch (reason) {
1889
      setProviderError(reason instanceof Error ? reason.message : 'Could not create connection');
1890
    } finally {
1891
      setWorking('');
1892
    }
1893
  };
1894
  return (
1895
    <>
1896
      <PageHead page="connect" />
1897
      {pending.length > 0 && (
1898
        <section className="setupRequests">
1899
          <div>
1900
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
1901
            <h2>Approve agent setup</h2>
1902
            <p>
1903
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
1904
            </p>
1905
          </div>
1906
          {pending.map((request) => (
1907
            <article className="setupRequest" key={request.requestId}>
1908
              <div>
1909
                <strong>{request.agentName ?? 'Switch installation'}</strong>
1910
                <small>
1911
                  {request.projectName ?? request.existingAgentId} · {request.framework}
1912
                </small>
1913
                {!!request.capabilities?.length && (
1914
                  <div className="tags">
1915
                    {request.capabilities.slice(0, 5).map((capability: string) => (
1916
                      <span key={capability}>{capability}</span>
1917
                    ))}
1918
                  </div>
1919
                )}
1920
                {!!request.limitations?.length && (
1921
                  <small>Limitations: {request.limitations.join(' · ')}</small>
1922
                )}
1923
                <div className="automationPreview">
1924
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
1925
                  <span>
1926
                    {(request.agentMode ?? 'temporary_chat') === 'temporary_chat'
1927
                      ? 'Hosted temporary A2A · encrypted history'
1928
                      : 'Persistent runtime · direct A2A'}
1929
                  </span>
1930
                  <span>
1931
                    {request.autoAcceptPolicy === 'safe_matching'
1932
                      ? 'Auto-accept safe matches'
1933
                      : 'Review every request'}
1934
                  </span>
1935
                </div>
1936
              </div>
1937
              <div className="decisionButtons">
1938
                <button
1939
                  className="secondary"
1940
                  type="button"
1941
                  disabled={working === request.requestId}
1942
                  onClick={() => void decide(request.requestId, 'reject')}
1943
                >
1944
                  Reject
1945
                </button>
1946
                <button
1947
                  className="primary"
1948
                  type="button"
1949
                  disabled={working === request.requestId}
1950
                  onClick={() => void decide(request.requestId, 'approve')}
1951
                >
1952
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
1953
                </button>
1954
              </div>
1955
            </article>
1956
          ))}
1957
          {decisionError && (
1958
            <div className="loginError" role="alert">
1959
              {decisionError}
1960
            </div>
1961
          )}
1962
        </section>
1963
      )}
1964
      <div className="connectionTypePicker" role="tablist" aria-label="Agent connection type">
1965
        <button
1966
          type="button"
1967
          role="tab"
1968
          aria-selected={connectionType === 'hosted'}
1969
          className={connectionType === 'hosted' ? 'active' : ''}
1970
          onClick={() => setConnectionType('hosted')}
1971
        >
1972
          Hosted provider
1973
          <small>Botpress and similar platforms</small>
1974
        </button>
1975
        <button
1976
          type="button"
1977
          role="tab"
1978
          aria-selected={connectionType === 'interactive'}
1979
          className={connectionType === 'interactive' ? 'active' : ''}
1980
          onClick={() => setConnectionType('interactive')}
1981
        >
1982
          Interactive agent
1983
          <small>Codex, Cursor, and OAuth clients</small>
1984
        </button>
1985
      </div>
1986
      {connectionType === 'hosted' ? (
1987
        <section className="connectLayout providerConnectLayout">
1988
          <Panel title="Add a hosted agent" subtitle="Creates an isolated identity and credential">
1989
            {providerResult ? (
1990
              <div className="providerResult">
1991
                <div className="successBanner">
1992
                  <strong>{providerResult.agent.name} created</strong>
1993
                  <span>{providerResult.agent.agentId}</span>
1994
                </div>
1995
                <label>
1996
                  <span>MCP URL</span>
1997
                  <code>{endpoint}</code>
1998
                </label>
1999
                <label>
2000
                  <span>Agent token — copy it now; it will not be shown again</span>
2001
                  <code>{providerResult.accessToken.token}</code>
2002
                </label>
2003
                <small>
2004
                  This agent-bound token authorizes MCP and automatic runtime registration. It
2005
                  cannot connect or manage another agent.
2006
                </small>
2007
                <div className="agentActions">
2008
                  <button
2009
                    className="secondary"
2010
                    type="button"
2011
                    onClick={() => {
2012
                      void navigator.clipboard.writeText(providerResult.accessToken.token);
2013
                      setTokenCopied(true);
2014
                      window.setTimeout(() => setTokenCopied(false), 2000);
2015
                    }}
2016
                  >
2017
                    {tokenCopied ? 'Copied' : 'Copy token'}
2018
                  </button>
2019
                  <button className="primary" type="button" onClick={() => navigate('agents')}>
2020
                    View agent
2021
                  </button>
2022
                </div>
2023
              </div>
2024
            ) : (
2025
              <form
2026
                className="providerForm"
2027
                onSubmit={(event) => void connectHostedProvider(event)}
2028
              >
2029
                <label>
2030
                  <span>Provider</span>
2031
                  <select
2032
                    value={providerForm.provider}
2033
                    onChange={(event) =>
2034
                      setProviderForm((value) => ({
2035
                        ...value,
2036
                        provider: event.target.value as 'botpress' | 'custom',
2037
                      }))
2038
                    }
2039
                  >
2040
                    <option value="botpress">Botpress</option>
2041
                    <option value="custom">Custom / self-hosted</option>
2042
                  </select>
2043
                </label>
2044
                <label>
2045
                  <span>Agent name</span>
2046
                  <input
2047
                    required
2048
                    maxLength={100}
2049
                    value={providerForm.agentName}
2050
                    onChange={(event) =>
2051
                      setProviderForm((value) => ({ ...value, agentName: event.target.value }))
2052
                    }
2053
                    placeholder="Recruiting agent"
2054
                  />
2055
                </label>
2056
                <label>
2057
                  <span>Project</span>
2058
                  <input
2059
                    required
2060
                    maxLength={100}
2061
                    value={providerForm.projectName}
2062
                    onChange={(event) =>
2063
                      setProviderForm((value) => ({ ...value, projectName: event.target.value }))
2064
                    }
2065
                    placeholder="Recruiting"
2066
                  />
2067
                </label>
2068
                <label className="fullWidth">
2069
                  <span>Purpose</span>
2070
                  <textarea
2071
                    required
2072
                    maxLength={500}
2073
                    value={providerForm.description}
2074
                    onChange={(event) =>
2075
                      setProviderForm((value) => ({ ...value, description: event.target.value }))
2076
                    }
2077
                    placeholder="Matches suitable candidates with open roles"
2078
                  />
2079
                </label>
2080
                <label>
2081
                  <span>Capabilities, comma-separated</span>
2082
                  <input
2083
                    required
2084
                    value={providerForm.capabilities}
2085
                    onChange={(event) =>
2086
                      setProviderForm((value) => ({ ...value, capabilities: event.target.value }))
2087
                    }
2088
                    placeholder="candidate matching, interview coordination"
2089
                  />
2090
                </label>
2091
                <label>
2092
                  <span>Limitations, comma-separated</span>
2093
                  <input
2094
                    value={providerForm.limitations}
2095
                    onChange={(event) =>
2096
                      setProviderForm((value) => ({ ...value, limitations: event.target.value }))
2097
                    }
2098
                    placeholder="no final hiring decisions"
2099
                  />
2100
                </label>
2101
                <label>
2102
                  <span>Credential expiry</span>
2103
                  <select
2104
                    value={providerForm.expiresInDays}
2105
                    onChange={(event) =>
2106
                      setProviderForm((value) => ({
2107
                        ...value,
2108
                        expiresInDays: Number(event.target.value),
2109
                      }))
2110
                    }
2111
                  >
2112
                    <option value={30}>30 days</option>
2113
                    <option value={90}>90 days</option>
2114
                    <option value={365}>1 year</option>
2115
                  </select>
2116
                </label>
2117
                <div className="providerSubmit fullWidth">
2118
                  <p>This does not reuse or modify your Codex agent.</p>
2119
                  <button
2120
                    className="primary"
2121
                    type="submit"
2122
                    disabled={working === 'hosted-provider'}
2123
                  >
2124
                    {working === 'hosted-provider' ? 'Creating…' : 'Create agent credentials'}
2125
                  </button>
2126
                </div>
2127
                {providerError ? (
2128
                  <div className="loginError fullWidth" role="alert">
2129
                    {providerError}
2130
                  </div>
2131
                ) : null}
2132
              </form>
2133
            )}
2134
          </Panel>
2135
          <Panel
2136
            title={
2137
              providerForm.provider === 'botpress' ? 'Configure Botpress' : 'Deploy the sidecar'
2138
            }
2139
            subtitle={
2140
              providerForm.provider === 'botpress'
2141
                ? 'MCP and inbound runtime are separate connections'
2142
                : 'Runs beside an agent on any cloud'
2143
            }
2144
          >
2145
            {providerForm.provider === 'botpress' ? (
2146
              <>
2147
                <div className="connectSteps">
2148
                  <div className="connectStep">
2149
                    <b>1</b>
2150
                    <span>Add the displayed MCP URL with Bearer authentication.</span>
2151
                  </div>
2152
                  <div className="connectStep">
2153
                    <b>2</b>
2154
                    <span>
2155
                      Paste the generated <code>oc_at_…</code> agent token.
2156
                    </span>
2157
                  </div>
2158
                  <div className="connectStep">
2159
                    <b>3</b>
2160
                    <span>Install the OpenClasp Botpress runtime integration for inbound A2A.</span>
2161
                  </div>
2162
                </div>
2163
                <div className="notice providerNotice">
2164
                  MCP proves outbound tool access only. Autonomous runtime becomes connected only
2165
                  after the Botpress integration registers its webhook successfully.
2166
                </div>
2167
              </>
2168
            ) : (
2169
              <>
2170
                <div className="connectSteps">
2171
                  <div className="connectStep">
2172
                    <b>1</b>
2173
                    <span>
2174
                      Deploy <code>Dockerfile.sidecar</code> beside the agent.
2175
                    </span>
2176
                  </div>
2177
                  <div className="connectStep">
2178
                    <b>2</b>
2179
                    <span>
2180
                      Set <code>OPENCLASP_AGENT_TOKEN</code>, <code>OPENCLASP_RUNTIME_URL</code>,
2181
                      and <code>AGENT_ADAPTER_URL</code>.
2182
                    </span>
2183
                  </div>
2184
                  <div className="connectStep">
2185
                    <b>3</b>
2186
                    <span>
2187
                      The sidecar verifies and registers itself; no endpoint paste is needed.
2188
                    </span>
2189
                  </div>
2190
                </div>
2191
                <div className="notice providerNotice">
2192
                  The application implements three internal POST hooks: session-offer,
2193
                  session-activated, and message. Agent-to-agent content remains off OpenClasp.
2194
                </div>
2195
              </>
2196
            )}
2197
          </Panel>
2198
        </section>
2199
      ) : (
2200
        <section className="connectLayout">
2201
          <Panel title="Connect with OAuth" subtitle="For interactive MCP clients">
2202
            <div className="endpoint">
2203
              <code>{endpoint}</code>
2204
              <button
2205
                type="button"
2206
                onClick={() => {
2207
                  void navigator.clipboard.writeText(endpoint);
2208
                  setCopied(true);
2209
                  window.setTimeout(() => setCopied(false), 2000);
2210
                }}
2211
              >
2212
                {copied ? 'Copied' : 'Copy'}
2213
              </button>
2214
            </div>
2215
            <div className="connectSteps">
2216
              <div className="connectStep">
2217
                <b>1</b>
2218
                <span>Add this MCP URL and complete OAuth.</span>
2219
              </div>
2220
              <div className="connectStep">
2221
                <b>2</b>
2222
                <span>
2223
                  Tell the agent: <code>Set yourself up on OpenClasp</code>.
2224
                </span>
2225
              </div>
2226
              <div className="connectStep">
2227
                <b>3</b>
2228
                <span>Approve the proposed identity and mode here.</span>
2229
              </div>
2230
            </div>
2231
          </Panel>
2232
          <Panel title="Interactive clients" subtitle="Best for temporary user-driven sessions">
2233
            <ul className="checkList">
2234
              <li>OAuth opens in the browser</li>
2235
              <li>Each installation is explicitly approved</li>
2236
              <li>Temporary chats receive hosted A2A history</li>
2237
              <li>Persistent runtimes can switch to direct A2A</li>
2238
            </ul>
2239
          </Panel>
2240
        </section>
2241
      )}
2242
    </>
2243
  );
2244
}
2245
2246
function SettingsPage({
2247
  settings,
2248
  setSettings,
2249
  api,
2250
}: {
2251
  settings: Settings;
2252
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
2253
  api: (path: string, init?: RequestInit) => Promise<unknown>;
2254
}) {
2255
  const [saving, setSaving] = useState(false);
2256
  const [saved, setSaved] = useState(false);
2257
  const [error, setError] = useState('');
2258
  const save = useCallback(async () => {
2259
    setSaving(true);
2260
    setSaved(false);
2261
    setError('');
2262
    try {
2263
      const updated = (await api('/v0.1/settings', {
2264
        method: 'PUT',
2265
        body: JSON.stringify(settings),
2266
      })) as Settings;
2267
      setSettings(updated);
2268
      setSaved(true);
2269
    } catch (reason) {
2270
      setError(reason instanceof Error ? reason.message : 'Could not save settings');
2271
    } finally {
2272
      setSaving(false);
2273
    }
2274
  }, [api, settings, setSettings]);
2275
  return (
2276
    <>
2277
      <PageHead page="settings" />
2278
      <section className="settingsCard">
2279
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
2280
          <input
2281
            value={settings.displayName}
2282
            onChange={(event) =>
2283
              setSettings((value) => ({ ...value, displayName: event.target.value }))
2284
            }
2285
            placeholder="Team or operator name"
2286
          />
2287
        </Setting>
2288
        <Setting
2289
          label="Network contribution"
2290
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
2291
        >
2292
          <Toggle
2293
            checked={settings.contributionEnabled}
2294
            label="Network contribution"
2295
            onChange={(checked) =>
2296
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
2297
            }
2298
          />
2299
        </Setting>
2300
        <Setting
2301
          label="Evidence sharing"
2302
          description="Controls when permitted evidence references may be shared with interaction participants."
2303
        >
2304
          <select
2305
            value={settings.evidenceSharing}
2306
            onChange={(event) =>
2307
              setSettings((value) => ({
2308
                ...value,
2309
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
2310
              }))
2311
            }
2312
          >
2313
            <option value="never">Never</option>
2314
            <option value="ask">Ask every time</option>
2315
            <option value="contract_only">When contract permits</option>
2316
          </select>
2317
        </Setting>
2318
        <Setting
2319
          label="Structured record retention"
2320
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
2321
        >
2322
          <select
2323
            value={settings.retentionDays}
2324
            onChange={(event) =>
2325
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
2326
            }
2327
          >
2328
            <option value={7}>7 days</option>
2329
            <option value={30}>30 days</option>
2330
            <option value={90}>90 days</option>
2331
            <option value={365}>1 year</option>
2332
            <option value={0}>No automatic deletion</option>
2333
          </select>
2334
        </Setting>
2335
        <Setting
2336
          label="Raw agent messages"
2337
          description="Persistent runtimes remain direct and are not stored. Temporary chat messages pass through OpenClasp and are encrypted at rest for 30 days."
2338
        >
2339
          <span className="locked">MODE DEPENDENT</span>
2340
        </Setting>
2341
        <div className="saveRow">
2342
          <button className="primary" type="button" onClick={() => void save()} disabled={saving}>
2343
            {saving ? 'Saving…' : 'Save settings'}
2344
          </button>
2345
          {saved && <span role="status">Saved</span>}
2346
          {error && (
2347
            <span className="loginError" role="alert">
2348
              {error}
2349
            </span>
2350
          )}
2351
        </div>
2352
      </section>
2353
    </>
2354
  );
2355
}
2356
2357
function PageHead({
2358
  page,
2359
  action,
2360
  onAction,
2361
}: {
2362
  page: Page;
2363
  action?: string;
2364
  onAction?: () => void;
2365
}) {
2366
  const meta = pageMeta[page];
2367
  return (
2368
    <header className="pageHead">
2369
      <div>
2370
        <h1>{meta.title}</h1>
2371
        <p className="lede">{meta.lede}</p>
2372
      </div>
2373
      {action && (
2374
        <button className="primary" type="button" onClick={onAction}>
2375
          {action}
2376
          <span aria-hidden="true">→</span>
2377
        </button>
2378
      )}
2379
    </header>
2380
  );
2381
}
2382
function Metric({
2383
  label,
2384
  value,
2385
  note,
2386
  warn,
2387
}: {
2388
  label: string;
2389
  value: number;
2390
  note: string;
2391
  warn?: boolean;
2392
}) {
2393
  return (
2394
    <article className={warn ? 'metric warn' : 'metric'} title={note}>
2395
      <span>{label}</span>
2396
      <strong>{value}</strong>
2397
      <small>{note}</small>
2398
    </article>
2399
  );
2400
}
2401
function Panel({
2402
  title,
2403
  subtitle,
2404
  children,
2405
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
2406
  return (
2407
    <section className="panel">
2408
      <div className="panelHead">
2409
        <div>
2410
          <h2>{title}</h2>
2411
          {subtitle ? <p>{subtitle}</p> : null}
2412
        </div>
2413
      </div>
2414
      {children}
2415
    </section>
2416
  );
2417
}
2418
function Timeline({ events }: { events: Record<string, any>[] }) {
2419
  return events.length ? (
2420
    <div className="timeline">
2421
      {events.map((event) => (
2422
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
2423
      ))}
2424
    </div>
2425
  ) : (
2426
    <Empty
2427
      title="Nothing recorded yet"
2428
      text="Activity appears after your connected agents submit signed structured events."
2429
    />
2430
  );
2431
}
2432
function HistoryRow({ item }: { item: Record<string, any> }) {
2433
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
2434
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
2435
  return (
2436
    <article className="historyRow">
2437
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
2438
      <div>
2439
        <strong>{kind}</strong>
2440
        <small>
2441
          {id} · {new Date(timestamp(item)).toLocaleString()}
2442
        </small>
2443
      </div>
2444
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
2445
    </article>
2446
  );
2447
}
2448
function AgentCard({
2449
  agent,
2450
  projectName,
2451
  published,
2452
  working,
2453
  onSave,
2454
  runtime,
2455
  runtimeWorking,
2456
  deleteWorking,
2457
  onRuntime,
2458
  onDisableRuntime,
2459
  accessTokens,
2460
  accessTokenWorking,
2461
  onRevokeAccessToken,
2462
  onDelete,
2463
}: {
2464
  agent: Record<string, any>;
2465
  projectName?: string;
2466
  published: boolean;
2467
  working: boolean;
2468
  onSave: (value: {
2469
    autoPublish: boolean;
2470
    autoAcceptPolicy: 'off' | 'safe_matching';
2471
    autoAcceptTaskCategories: string[];
2472
  }) => Promise<boolean>;
2473
  runtime: Record<string, any> | undefined;
2474
  runtimeWorking: boolean;
2475
  deleteWorking: boolean;
2476
  onRuntime: (endpoint: string) => void;
2477
  onDisableRuntime: () => void;
2478
  accessTokens: Record<string, any>[];
2479
  accessTokenWorking: boolean;
2480
  onRevokeAccessToken: (tokenId: string) => Promise<void>;
2481
  onDelete: () => void;
2482
}) {
2483
  const [editing, setEditing] = useState(false);
2484
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
2485
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
2486
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
2487
  );
2488
  const [categories, setCategories] = useState<string>(
2489
    (agent.autoAcceptTaskCategories?.length
2490
      ? agent.autoAcceptTaskCategories
2491
      : (agent.capabilities ?? [])
2492
    ).join(', '),
2493
  );
2494
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
2495
  const mode = agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
2496
  const temporary = mode === 'temporary_chat';
2497
  const ready = published && (temporary || runtime?.status === 'verified');
2498
  const online = agent.presence?.status === 'online';
2499
  const providerConnected = accessTokens.length > 0;
2500
  const endpoint = String(
2501
    temporary
2502
      ? `/a2a/temporary/${encodeURIComponent(agent.agentId)}`
2503
      : (runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first'),
2504
  );
2505
  const identityLabel = agent.revoked
2506
    ? 'REVOKED'
2507
    : agent.identityMode === 'owner_managed'
2508
      ? 'OWNER VERIFIED'
2509
      : agent.identityMode === 'oauth_installation'
2510
        ? 'AUTHENTICATED'
2511
        : 'VERIFIED';
2512
  return (
2513
    <article className="agentCard">
2514
      <div className="agentTop">
2515
        <span className="agentGlyph">
2516
          <Bot />
2517
        </span>
2518
        <div className="agentBadges">
2519
          <b className={online && temporary ? 'onlineBadge' : 'offlineBadge'}>
2520
            {temporary
2521
              ? online
2522
                ? 'CHAT ACTIVE'
2523
                : 'CHAT IDLE'
2524
              : runtime?.status === 'verified'
2525
                ? 'ENDPOINT VERIFIED'
2526
                : providerConnected
2527
                  ? 'TOKEN ONLY'
2528
                  : 'ENDPOINT MISSING'}
2529
          </b>
2530
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
2531
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
2532
        </div>
2533
      </div>
2534
      <h2>{agent.name ?? agent.agentId}</h2>
2535
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
2536
      <div className="tags">
2537
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
2538
          <span key={capability}>{capability}</span>
2539
        ))}
2540
      </div>
2541
      <small>
2542
        {agent.framework ? `${agent.framework} · ` : ''}
2543
        {agent.identityMode === 'owner_managed'
2544
          ? 'Owner-created · '
2545
          : agent.identityMode === 'oauth_installation'
2546
            ? 'OAuth-bound · '
2547
            : 'Ed25519 · '}
2548
        Created {new Date(agent.createdAt).toLocaleDateString()}
2549
      </small>
2550
      <small>
2551
        {agent.presence?.lastSeenAt
2552
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
2553
          : 'No agent activity recorded yet'}
2554
      </small>
2555
      {published ? (
2556
        <div className="agentPublicLinks">
2557
          <a href={`/a/${encodeURIComponent(agent.agentId)}`} target="_blank" rel="noreferrer">
2558
            Verified public profile
2559
          </a>
2560
          <a
2561
            href={`/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`}
2562
            target="_blank"
2563
            rel="noreferrer"
2564
          >
2565
            A2A Agent Card
2566
          </a>
2567
        </div>
2568
      ) : null}
2569
      <div className="automationSummary">
2570
        <span>{published ? 'Public discovery' : 'Private'}</span>
2571
        <span>{temporary ? 'Hosted temporary A2A' : 'Direct agent-owned A2A'}</span>
2572
        <span>
2573
          {agent.autoAcceptPolicy === 'safe_matching' ? 'Safe tasks automatic' : 'Manual approval'}
2574
        </span>
2575
      </div>
2576
      <div className="runtimeBox">
2577
        <div>
2578
          <strong>{temporary ? 'Temporary chat endpoint' : 'Autonomous runtime'}</strong>
2579
          <span
2580
            className={
2581
              temporary || runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'
2582
            }
2583
          >
2584
            {temporary
2585
              ? 'HOSTED BY OPENCLASP'
2586
              : runtime?.status === 'verified'
2587
                ? 'VERIFIED'
2588
                : 'NOT CONNECTED'}
2589
          </span>
2590
        </div>
2591
        <p>
2592
          {temporary
2593
            ? 'OpenClasp receives A2A for this temporary identity and encrypts its history at rest. Connect a runtime below to switch to direct A2A.'
2594
            : runtime?.status === 'verified'
2595
              ? 'The agent runtime registered itself successfully. Live message bodies travel directly between agents.'
2596
              : providerConnected
2597
                ? 'MCP is connected, but inbound A2A is not. Install the provider connector or deploy the sidecar; it registers this endpoint automatically.'
2598
                : 'Install a provider connector, deploy the sidecar, or register a native A2A endpoint.'}
2599
        </p>
2600
        <input
2601
          type="url"
2602
          value={runtimeEndpoint}
2603
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
2604
          placeholder="https://agent.example.com/openclasp"
2605
        />
2606
        <small>
2607
          Manual registration is only for a runtime that already implements OpenClasp A2A.
2608
        </small>
2609
        <div className="agentActions">
2610
          {runtime?.status === 'verified' ? (
2611
            <button
2612
              className="secondary"
2613
              type="button"
2614
              disabled={runtimeWorking}
2615
              onClick={onDisableRuntime}
2616
            >
2617
              Disable runtime
2618
            </button>
2619
          ) : null}
2620
          <button
2621
            className="primary"
2622
            type="button"
2623
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
2624
            onClick={() => onRuntime(runtimeEndpoint.trim())}
2625
          >
2626
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
2627
          </button>
2628
        </div>
2629
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
2630
      </div>
2631
      {accessTokens.length ? (
2632
        <div className="accessTokenBox">
2633
          <div className="accessTokenHead">
2634
            <div>
2635
              <strong>Provider connections</strong>
2636
              <span>{accessTokens.length} ACTIVE</span>
2637
            </div>
2638
            <p>Created through Connect. Revoke a provider here if its credential is exposed.</p>
2639
          </div>
2640
          <div className="tokenList">
2641
            {accessTokens.map((token) => (
2642
              <div key={token.tokenId}>
2643
                <span>
2644
                  <strong>{token.name}</strong>
2645
                  <small>
2646
                    Created {new Date(token.createdAt).toLocaleDateString()} · expires{' '}
2647
                    {new Date(token.expiresAt).toLocaleDateString()}
2648
                    {token.lastUsedAt
2649
                      ? ` · used ${new Date(token.lastUsedAt).toLocaleString()}`
2650
                      : ' · never used'}
2651
                  </small>
2652
                </span>
2653
                <button
2654
                  className="secondary dangerButton"
2655
                  type="button"
2656
                  disabled={accessTokenWorking}
2657
                  onClick={() => {
2658
                    if (window.confirm(`Revoke “${String(token.name)}”? Botpress will disconnect.`))
2659
                      void onRevokeAccessToken(String(token.tokenId));
2660
                  }}
2661
                >
2662
                  Revoke
2663
                </button>
2664
              </div>
2665
            ))}
2666
          </div>
2667
        </div>
2668
      ) : null}
2669
      {editing ? (
2670
        <div className="automationForm">
2671
          <label>
2672
            <span>
2673
              {temporary ? 'OpenClasp temporary A2A endpoint' : 'Agent-owned A2A endpoint'}
2674
            </span>
2675
            <input type="url" value={endpoint} readOnly />
2676
          </label>
2677
          <label>
2678
            <span>Invitation policy</span>
2679
            <select
2680
              value={autoAcceptPolicy}
2681
              onChange={(event) =>
2682
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
2683
              }
2684
            >
2685
              <option value="safe_matching">Auto-accept safe matches</option>
2686
              <option value="off">Review every request</option>
2687
            </select>
2688
          </label>
2689
          <label>
2690
            <span>Safe task categories</span>
2691
            <input
2692
              value={categories}
2693
              onChange={(event) => setCategories(event.target.value)}
2694
              placeholder="research, planning, coding"
2695
            />
2696
          </label>
2697
          <label className="checkControl">
2698
            <input
2699
              type="checkbox"
2700
              checked={autoPublish}
2701
              onChange={(event) => setAutoPublish(event.target.checked)}
2702
            />
2703
            <span>Publish and keep Agent Card updated</span>
2704
          </label>
2705
          <div className="agentActions">
2706
            <button className="secondary" type="button" onClick={() => setEditing(false)}>
2707
              Cancel
2708
            </button>
2709
            <button
2710
              className="primary"
2711
              type="button"
2712
              disabled={working || agent.status === 'revoked'}
2713
              onClick={() =>
2714
                void onSave({
2715
                  autoPublish,
2716
                  autoAcceptPolicy,
2717
                  autoAcceptTaskCategories: categories
2718
                    .split(',')
2719
                    .map((value) => value.trim())
2720
                    .filter(Boolean),
2721
                }).then((ok) => {
2722
                  if (ok) setEditing(false);
2723
                })
2724
              }
2725
            >
2726
              {working ? 'Saving…' : 'Save automation'}
2727
            </button>
2728
          </div>
2729
        </div>
2730
      ) : (
2731
        <div className="agentActions agentManagementActions">
2732
          <button
2733
            className="secondary dangerButton"
2734
            type="button"
2735
            disabled={working || runtimeWorking || deleteWorking}
2736
            onClick={onDelete}
2737
          >
2738
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
2739
          </button>
2740
          <button
2741
            className="secondary"
2742
            type="button"
2743
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
2744
            onClick={() => setEditing(true)}
2745
          >
2746
            Configure automation
2747
          </button>
2748
        </div>
2749
      )}
2750
    </article>
2751
  );
2752
}
2753
function Profile({
2754
  profile,
2755
  expanded,
2756
  deltas = [],
2757
  showHeader = true,
2758
}: {
2759
  profile: Record<string, any>;
2760
  expanded?: boolean;
2761
  deltas?: Record<string, any>[];
2762
  showHeader?: boolean;
2763
}) {
2764
  const dimensions = [
2765
    ['Completion', profile.completion],
2766
    ['Acceptance', profile.acceptance],
2767
    ['Specification', profile.specification],
2768
    ['Scope', profile.scope],
2769
    ['Evidence', profile.evidence],
2770
    ...(expanded
2771
      ? [
2772
          ['Communication', profile.communication],
2773
          ['Deadline', profile.deadline],
2774
          ['Dispute-free', 1 - Number(profile.disputes ?? 0)],
2775
        ]
2776
      : []),
2777
  ] as [string, number][];
2778
  const latestDelta = deltas[0];
2779
  return (
2780
    <article className="profile">
2781
      <div className={showHeader ? 'profileHead' : 'profileHead profileHeadCompact'}>
2782
        {showHeader ? (
2783
          <div>
2784
            <strong>{profile.agentId}</strong>
2785
            <small>
2786
              {humanize(profile.taskCategory)} · version {profile.agentVersion ?? 'unknown'}
2787
            </small>
2788
          </div>
2789
        ) : (
2790
          <small>
2791
            Effective evidence{' '}
2792
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
2793
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
2794
          </small>
2795
        )}
2796
        <span className="sampleBadge">
2797
          {profile.sampleSize ?? 0} outcome{Number(profile.sampleSize) === 1 ? '' : 's'}
2798
        </span>
2799
      </div>
2800
      {showHeader ? (
2801
        <div className="profileEvidenceLine">
2802
          <small>
2803
            Effective evidence{' '}
2804
            {Number(profile.effectiveSampleSize ?? profile.sampleSize ?? 0).toFixed(1)}
2805
            {profile.updatedAt ? ` · updated ${relativeTime(profile.updatedAt)}` : ''}
2806
          </small>
2807
        </div>
2808
      ) : null}
2809
      {dimensions.map(([label, value]) => (
2810
        <Meter key={label} label={label} value={Number(value ?? 0.5)} />
2811
      ))}
2812
      {latestDelta ? (
2813
        <div className="profileDelta">
2814
          <span>Latest evidence impact</span>
2815
          <div>
2816
            {Object.entries(latestDelta.dimensionDeltas ?? {})
2817
              .filter(([, value]) => Math.abs(Number(value)) >= 0.001)
2818
              .slice(0, 5)
2819
              .map(([dimension, value]) => (
2820
                <b className={Number(value) >= 0 ? 'positive' : 'negative'} key={dimension}>
2821
                  {humanize(dimension)} {Number(value) >= 0 ? '+' : ''}
2822
                  {(Number(value) * 100).toFixed(1)}
2823
                </b>
2824
              ))}
2825
          </div>
2826
        </div>
2827
      ) : null}
2828
    </article>
2829
  );
2830
}
2831
function Meter({ label, value }: { label: string; value: number }) {
2832
  const bounded = Math.max(0, Math.min(1, value));
2833
  return (
2834
    <div className="meter">
2835
      <span>{label}</span>
2836
      <i>
2837
        <b style={{ width: `${Math.round(bounded * 100)}%` }} />
2838
      </i>
2839
      <strong>{Math.round(bounded * 100)}%</strong>
2840
    </div>
2841
  );
2842
}
2843
function Empty({
2844
  title,
2845
  text,
2846
  action,
2847
  onAction,
2848
}: {
2849
  title: string;
2850
  text: string;
2851
  action?: string;
2852
  onAction?: () => void;
2853
}) {
2854
  return (
2855
    <div className="empty">
2856
      <Bot />
2857
      <strong>{title}</strong>
2858
      <p>{text}</p>
2859
      {action && (
2860
        <button className="secondary" type="button" onClick={onAction}>
2861
          {action}
2862
        </button>
2863
      )}
2864
    </div>
2865
  );
2866
}
2867
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
2868
  return (
2869
    <button className="textButton" type="button" onClick={onClick}>
2870
      {children}
2871
    </button>
2872
  );
2873
}
2874
function Setting({
2875
  label,
2876
  description,
2877
  children,
2878
}: React.PropsWithChildren<{ label: string; description: string }>) {
2879
  return (
2880
    <div className="setting">
2881
      <div>
2882
        <strong>{label}</strong>
2883
        <p>{description}</p>
2884
      </div>
2885
      <div>{children}</div>
2886
    </div>
2887
  );
2888
}
2889
function Toggle({
2890
  checked,
2891
  onChange,
2892
  label,
2893
}: {
2894
  checked: boolean;
2895
  onChange: (checked: boolean) => void;
2896
  label?: string;
2897
}) {
2898
  return (
2899
    <button
2900
      className={checked ? 'toggle on' : 'toggle'}
2901
      type="button"
2902
      role="switch"
2903
      aria-checked={checked}
2904
      aria-label={label}
2905
      onClick={() => onChange(!checked)}
2906
    >
2907
      <span />
2908
    </button>
2909
  );
2910
}
2911
function Loading({ compact }: { compact?: boolean }) {
2912
  return (
2913
    <div className={compact ? 'loading compact' : 'loading'}>
2914
      <span className="spinner" aria-hidden="true" />
2915
      <p>Verifying session…</p>
2916
    </div>
2917
  );
2918
}
2919
2920
function GoogleMark() {
2921
  return (
2922
    <svg viewBox="0 0 24 24" aria-hidden="true">
2923
      <path
2924
        fill="#4285F4"
2925
        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"
2926
      />
2927
      <path
2928
        fill="#34A853"
2929
        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"
2930
      />
2931
      <path
2932
        fill="#FBBC05"
2933
        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"
2934
      />
2935
      <path
2936
        fill="#EA4335"
2937
        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"
2938
      />
2939
    </svg>
2940
  );
2941
}
2942
2943
function GitHubMark() {
2944
  return (
2945
    <svg viewBox="0 0 24 24" aria-hidden="true">
2946
      <path
2947
        fill="currentColor"
2948
        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"
2949
      />
2950
    </svg>
2951
  );
2952
}
2953
2954
function humanize(value: unknown) {
2955
  const text = String(value ?? '')
2956
    .replaceAll('_', ' ')
2957
    .trim();
2958
  return text ? text[0]!.toUpperCase() + text.slice(1) : 'Unknown';
2959
}
2960
2961
function formatDate(value: string) {
2962
  const date = new Date(value);
2963
  return Number.isNaN(date.getTime()) ? 'Time unavailable' : date.toLocaleString();
2964
}
2965
2966
function relativeTime(value: string) {
2967
  const milliseconds = Date.now() - Date.parse(value);
2968
  if (!Number.isFinite(milliseconds)) return 'Time unavailable';
2969
  const minutes = Math.round(Math.abs(milliseconds) / 60_000);
2970
  const future = milliseconds < 0;
2971
  const phrase =
2972
    minutes < 1
2973
      ? 'just now'
2974
      : minutes < 60
2975
        ? `${minutes}m`
2976
        : minutes < 1_440
2977
          ? `${Math.round(minutes / 60)}h`
2978
          : `${Math.round(minutes / 1_440)}d`;
2979
  return minutes < 1 ? phrase : future ? `in ${phrase}` : `${phrase} ago`;
2980
}
2981
2982
function feedbackState(requests: Record<string, any>[]) {
2983
  if (!requests.length) return 'Starts after a completion report';
2984
  const pending = requests.filter((request) => request.status === 'pending').length;
2985
  if (pending) return `${pending} response${pending === 1 ? '' : 's'} still pending`;
2986
  const expired = requests.filter((request) => request.status === 'expired').length;
2987
  return expired
2988
    ? `${expired} request${expired === 1 ? '' : 's'} timed out`
2989
    : 'Bilateral feedback closed';
2990
}
2991
2992
function StatusPill({ value }: { value: string }) {
2993
  const normalized = String(value).toLowerCase();
2994
  const tone = [
2995
    'success',
2996
    'active',
2997
    'ready',
2998
    'met',
2999
    'eligible',
3000
    'attested',
3001
    'allow',
3002
    'completed',
3003
  ].includes(normalized)
3004
    ? 'good'
3005
    : ['failure', 'denied', 'deny', 'missed', 'rejected', 'violation'].some((item) =>
3006
          normalized.includes(item),
3007
        )
3008
      ? 'danger'
3009
      : ['partial', 'pending', 'provisional', 'challenge', 'conflict', 'expired', 'excluded'].some(
3010
            (item) => normalized.includes(item),
3011
          )
3012
        ? 'warn'
3013
        : 'neutral';
3014
  return (
3015
    <span className={`statusPill ${tone}`}>
3016
      <i />
3017
      {humanize(value)}
3018
    </span>
3019
  );
3020
}
3021
3022
function timestamp(item: Record<string, any>) {
3023
  return String(
3024
    item.timestamp ??
3025
      item.generatedAt ??
3026
      item.decidedAt ??
3027
      item.appliedAt ??
3028
      item.submittedAt ??
3029
      item.completedAt ??
3030
      item.activatedAt ??
3031
      item.updatedAt ??
3032
      item.requestedAt ??
3033
      item.createdAt ??
3034
      new Date(0).toISOString(),
3035
  );
3036
}
3037
3038
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
3039
  throw new Error('Auth0 public configuration is incomplete');
3040
createRoot(document.getElementById('root')!).render(
3041
  <React.StrictMode>
3042
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
3043
  </React.StrictMode>,
3044
);