Skip to content

Commit cc2f632

Browse files
ralyodioclaude
andcommitted
feat: agent participants on every surface + pairux CLI
AI agents (Claude Code, moshcode, scripts) can now join a live PairUX session as labelled participants that read and post in the chat. - DB: session_participants.kind ('human'|'agent'), agent_owner_id, agent_client; join_session_as_agent RPC (service role only, max 5 live agents, settings.allowAgents, stale agents retired after 2 min). - CLI sign-in: OAuth 2.1 auth code + PKCE S256 over a loopback redirect, rotating refresh tokens with family revocation on reuse; hashes only. /cli/authorize consent page, /api/v1/cli/{authorize,token,revoke,me}. - Agent API: /api/v1/agents/join, GET/DELETE /api/v1/agents/:id (poll = roster + chat + heartbeat, 410 when removed/ended), POST .../messages. - Web + PWA: Agent badge in chat roster and host sidebar; agents get only "Remove agent" (no DM, mute, control, make-host). - Desktop: same in both roster components. - Mobile: new AgentStrip on host + viewer screens, long-press remove. - packages/cli: @profullstack/pairux with `pairux login|logout|whoami| join|listen|say|who|status|leave`, --json for agents. - Docs: /docs/agents, llms.txt, sitemap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 0676930 commit cc2f632

45 files changed

Lines changed: 3703 additions & 51 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/src/renderer/components/ParticipantList.test.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,40 @@ describe('ParticipantList', () => {
561561
});
562562
});
563563
});
564+
565+
describe('ParticipantList agent participants', () => {
566+
const agent = createMockParticipant({
567+
id: 'agent-1',
568+
user_id: null,
569+
display_name: 'Claude Code',
570+
kind: 'agent',
571+
agent_client: 'claude-code',
572+
});
573+
574+
it('badges an agent instead of calling it a viewer', () => {
575+
render(<ParticipantList participants={[agent]} />);
576+
expect(screen.getByTestId('agent-badge')).toHaveTextContent('Agent');
577+
expect(screen.getByTitle('Agent (claude-code)')).toBeInTheDocument();
578+
expect(screen.queryByText('Viewer')).not.toBeInTheDocument();
579+
});
580+
581+
it('offers the host only removal for an agent: no mute, control or make-host', () => {
582+
const onKickParticipant = vi.fn().mockResolvedValue(undefined);
583+
render(
584+
<ParticipantList
585+
participants={[agent]}
586+
isHost
587+
onGrantControl={vi.fn()}
588+
onRevokeControl={vi.fn()}
589+
onKickParticipant={onKickParticipant}
590+
onTransferHost={vi.fn()}
591+
onMuteParticipant={vi.fn()}
592+
/>
593+
);
594+
expect(screen.queryByTitle('Grant control')).not.toBeInTheDocument();
595+
expect(screen.queryByTitle('Mute participant')).not.toBeInTheDocument();
596+
expect(screen.queryByTitle('Make host')).not.toBeInTheDocument();
597+
fireEvent.click(screen.getByTitle('Remove agent'));
598+
expect(onKickParticipant).toHaveBeenCalledWith('agent-1');
599+
});
600+
});

‎apps/desktop/src/renderer/components/ParticipantList.tsx‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Loader2,
1010
Mic,
1111
MicOff,
12+
Bot,
1213
} from 'lucide-react';
1314
import { useState, useCallback } from 'react';
1415
import type { SessionParticipant } from '@pairux/shared-types';
@@ -40,6 +41,19 @@ function getConnectionColor(status: SessionParticipant['connection_status']): st
4041
}
4142

4243
function getRoleBadge(participant: SessionParticipant) {
44+
if (participant.kind === 'agent') {
45+
return (
46+
<span
47+
className="flex items-center gap-1 rounded-full bg-violet-500/20 px-2 py-0.5 text-xs font-medium text-violet-400"
48+
data-testid="agent-badge"
49+
title={participant.agent_client ? `Agent (${participant.agent_client})` : 'Agent'}
50+
>
51+
<Bot className="h-3 w-3" />
52+
Agent
53+
</span>
54+
);
55+
}
56+
4357
if (participant.role === 'host') {
4458
return (
4559
<span className="flex items-center gap-1 rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
@@ -165,6 +179,8 @@ export function ParticipantList({
165179
{activeParticipants.map((participant) => {
166180
const isCurrentUser = participant.user_id === currentUserId;
167181
const isParticipantHost = participant.role === 'host';
182+
// Agents watch and chat only: no media to mute, no control to grant.
183+
const isAgent = participant.kind === 'agent';
168184
const hasAnyActions =
169185
Boolean(onGrantControl) ||
170186
Boolean(onRevokeControl) ||
@@ -180,8 +196,14 @@ export function ParticipantList({
180196
>
181197
<div className="flex items-center gap-2">
182198
<div className="relative">
183-
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium text-foreground">
184-
{participant.display_name.charAt(0).toUpperCase()}
199+
<div
200+
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${isAgent ? 'bg-violet-600 text-white' : 'bg-muted text-foreground'}`}
201+
>
202+
{isAgent ? (
203+
<Bot className="h-4 w-4" aria-label="Agent" />
204+
) : (
205+
participant.display_name.charAt(0).toUpperCase()
206+
)}
185207
</div>
186208
<Circle
187209
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 ${getConnectionColor(participant.connection_status)} rounded-full border-2 border-background`}
@@ -209,6 +231,7 @@ export function ParticipantList({
209231
<div className="flex gap-1">
210232
{/* Mute/Unmute button */}
211233
{onMuteParticipant &&
234+
!isAgent &&
212235
(() => {
213236
const targetId = participant.user_id ?? participant.id;
214237
const isMutedNow = mutedParticipants?.has(targetId) ?? false;
@@ -236,7 +259,10 @@ export function ParticipantList({
236259
})()}
237260

238261
{/* Grant/Revoke control button */}
239-
{(participant.control_state === 'granted' ? onRevokeControl : onGrantControl) &&
262+
{!isAgent &&
263+
(participant.control_state === 'granted'
264+
? onRevokeControl
265+
: onGrantControl) &&
240266
(participant.control_state === 'granted' ? (
241267
<button
242268
onClick={() => void handleRevokeControl(participant.id)}
@@ -273,8 +299,8 @@ export function ParticipantList({
273299
onClick={() => void handleKick(participant.id)}
274300
disabled={loadingAction !== null}
275301
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive disabled:opacity-50"
276-
title="Remove participant"
277-
aria-label="Remove participant"
302+
title={isAgent ? 'Remove agent' : 'Remove participant'}
303+
aria-label={isAgent ? 'Remove agent' : 'Remove participant'}
278304
>
279305
{loadingAction === `kick-${participant.id}` ? (
280306
<Loader2 className="h-4 w-4 animate-spin" />
@@ -284,7 +310,7 @@ export function ParticipantList({
284310
</button>
285311
)}
286312

287-
{onTransferHost && (
313+
{onTransferHost && !isAgent && (
288314
<button
289315
onClick={() => void handleTransferHost(participant.id)}
290316
disabled={loadingAction !== null}

‎apps/desktop/src/renderer/components/chat/ParticipantItem.tsx‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { memo, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
2-
import { User, Crown, MessageCircle } from 'lucide-react';
2+
import { User, Crown, MessageCircle, Bot } from 'lucide-react';
33
import type { SessionParticipant } from '@pairux/shared-types';
44

55
interface ParticipantItemProps {
@@ -44,7 +44,8 @@ export const ParticipantItem = memo(function ParticipantItem({
4444
onMuteParticipant,
4545
}: ParticipantItemProps) {
4646
const isParticipantHost = participant.role === 'host';
47-
const avatarColor = stringToColor(participant.display_name);
47+
const isAgent = participant.kind === 'agent';
48+
const avatarColor = isAgent ? 'bg-violet-600' : stringToColor(participant.display_name);
4849
const canModerate = isHostContext && !isCurrentUser && !isParticipantHost;
4950
const [menuOpen, setMenuOpen] = useState(false);
5051
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
@@ -83,7 +84,11 @@ export const ParticipantItem = memo(function ParticipantItem({
8384
<div
8485
className={`relative flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${avatarColor}`}
8586
>
86-
<User className="h-3.5 w-3.5 text-white" />
87+
{isAgent ? (
88+
<Bot className="h-3.5 w-3.5 text-white" aria-label="Agent" />
89+
) : (
90+
<User className="h-3.5 w-3.5 text-white" />
91+
)}
8792
{isParticipantHost && (
8893
<div className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-400">
8994
<Crown className="h-2.5 w-2.5 text-yellow-800" />
@@ -97,9 +102,14 @@ export const ParticipantItem = memo(function ParticipantItem({
97102
{isCurrentUser && <span className="text-xs text-muted-foreground">(you)</span>}
98103
</div>
99104
{isParticipantHost && <span className="text-xs text-muted-foreground">Host</span>}
105+
{isAgent && (
106+
<span className="text-xs text-violet-400" data-testid="agent-badge">
107+
Agent{participant.agent_client ? ` · ${participant.agent_client}` : ''}
108+
</span>
109+
)}
100110
</div>
101111

102-
{!isCurrentUser && onStartDM && (
112+
{!isCurrentUser && !isAgent && onStartDM && (
103113
<button
104114
onClick={() => {
105115
onStartDM(participant);
@@ -118,7 +128,7 @@ export const ParticipantItem = memo(function ParticipantItem({
118128
className="fixed z-50 min-w-44 rounded-md border border-border bg-background p-1 shadow-xl"
119129
style={{ left: menuPos.x, top: menuPos.y }}
120130
>
121-
{participant.control_state === 'granted' ? (
131+
{isAgent ? null : participant.control_state === 'granted' ? (
122132
<button
123133
type="button"
124134
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-muted"
@@ -141,16 +151,18 @@ export const ParticipantItem = memo(function ParticipantItem({
141151
Grant control
142152
</button>
143153
)}
144-
<button
145-
type="button"
146-
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-muted"
147-
onClick={() => {
148-
onMuteParticipant?.(participant, !isMuted);
149-
setMenuOpen(false);
150-
}}
151-
>
152-
{isMuted ? 'Unmute participant' : 'Mute participant'}
153-
</button>
154+
{!isAgent && (
155+
<button
156+
type="button"
157+
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-muted"
158+
onClick={() => {
159+
onMuteParticipant?.(participant, !isMuted);
160+
setMenuOpen(false);
161+
}}
162+
>
163+
{isMuted ? 'Unmute participant' : 'Mute participant'}
164+
</button>
165+
)}
154166
<button
155167
type="button"
156168
className="block w-full rounded px-3 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
@@ -159,7 +171,7 @@ export const ParticipantItem = memo(function ParticipantItem({
159171
setMenuOpen(false);
160172
}}
161173
>
162-
Remove participant
174+
{isAgent ? 'Remove agent' : 'Remove participant'}
163175
</button>
164176
</div>
165177
)}

‎apps/mobile/app/(app)/session/[id].tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import { useWebRTCHost } from '@/hooks/useWebRTCHost';
1414
import { useWebRTCViewer } from '@/hooks/useWebRTCViewer';
1515
import { useScreenShare } from '@/hooks/useScreenShare';
1616
import { useChat } from '@/hooks/useChat';
17+
import { useSessionAgents } from '@/hooks/useSessionAgents';
1718
import { sessionApi } from '@/lib/api/sessions';
1819
import { endHostSession } from '@/lib/host-session';
1920
import { VideoViewer } from '@/components/VideoViewer';
2021
import { ChatPanel } from '@/components/ChatPanel';
2122
import { SessionInfo } from '@/components/SessionInfo';
23+
import { AgentStrip } from '@/components/AgentStrip';
2224
import { ConnectionBadge } from '@/components/ConnectionBadge';
2325
import { MicrophoneNotice } from '@/components/MicrophoneNotice';
2426

@@ -115,6 +117,7 @@ function HostSession({
115117
});
116118
const stopScreenShare = screenShare.stop;
117119
const endingSessionRef = useRef(false);
120+
const sessionAgents = useSessionAgents({ sessionId, enabled: !loadingSession });
118121

119122
// Auto-start hosting when session loads
120123
useEffect(() => {
@@ -171,6 +174,16 @@ function HostSession({
171174
isHosting={webrtc.isHosting}
172175
/>
173176

177+
<AgentStrip
178+
agents={sessionAgents.agents}
179+
canRemove
180+
onRemove={(participantId) => {
181+
void sessionAgents.removeAgent(participantId).then((result) => {
182+
if (result.error) Alert.alert('Could not remove agent', result.error);
183+
});
184+
}}
185+
/>
186+
174187
{/* Error */}
175188
{webrtc.error && (
176189
<View className="bg-red-900/50 px-4 py-2">
@@ -294,9 +307,10 @@ function ViewerSession({
294307
chat,
295308
currentUserId,
296309
router,
297-
loadingSession: _loadingSession,
310+
loadingSession,
298311
}: ViewerSessionProps) {
299312
const leavingSessionRef = useRef(false);
313+
const sessionAgents = useSessionAgents({ sessionId, enabled: !loadingSession });
300314
const webrtc = useWebRTCViewer({
301315
sessionId,
302316
participantId,
@@ -352,6 +366,8 @@ function ViewerSession({
352366
)}
353367
</View>
354368

369+
<AgentStrip agents={sessionAgents.agents} canRemove={false} />
370+
355371
{/* Controls */}
356372
<MicrophoneNotice failure={webrtc.micFailure} unmuteRequested={webrtc.unmuteRequested} />
357373
<View className="flex-row flex-wrap items-center justify-center gap-4 border-t border-gray-800 bg-gray-900 px-4 py-3">
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* AgentStrip — shows the AI agents in the session (joined via the PairUX CLI).
3+
* Hosts can remove one with a long press.
4+
*/
5+
import React from 'react';
6+
import { View, Text, TouchableOpacity, Alert, ScrollView } from 'react-native';
7+
import type { SessionParticipant } from '@pairux/shared-types';
8+
9+
interface AgentStripProps {
10+
agents: SessionParticipant[];
11+
canRemove: boolean;
12+
onRemove?: (participantId: string) => void;
13+
}
14+
15+
export function AgentStrip({ agents, canRemove, onRemove }: AgentStripProps) {
16+
if (agents.length === 0) return null;
17+
18+
function confirmRemove(agent: SessionParticipant) {
19+
if (!canRemove || !onRemove) return;
20+
Alert.alert('Remove agent', `Remove ${agent.display_name} from this session?`, [
21+
{ text: 'Cancel', style: 'cancel' },
22+
{
23+
text: 'Remove',
24+
style: 'destructive',
25+
onPress: () => {
26+
onRemove(agent.id);
27+
},
28+
},
29+
]);
30+
}
31+
32+
return (
33+
<View className="border-b border-gray-800 bg-gray-900 px-4 py-2" testID="agent-strip">
34+
<Text className="mb-1 text-xs font-medium uppercase tracking-wide text-violet-300">
35+
Agents ({agents.length})
36+
</Text>
37+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
38+
<View className="flex-row gap-2">
39+
{agents.map((agent) => (
40+
<TouchableOpacity
41+
key={agent.id}
42+
onLongPress={() => {
43+
confirmRemove(agent);
44+
}}
45+
disabled={!canRemove}
46+
accessibilityLabel={`Agent ${agent.display_name}${canRemove ? ', long press to remove' : ''}`}
47+
className="flex-row items-center gap-1.5 rounded-full bg-violet-900/60 px-3 py-1"
48+
>
49+
<Text className="text-xs">🤖</Text>
50+
<Text className="text-xs font-medium text-violet-100">{agent.display_name}</Text>
51+
{agent.agent_client ? (
52+
<Text className="text-xs text-violet-300">· {agent.agent_client}</Text>
53+
) : null}
54+
</TouchableOpacity>
55+
))}
56+
</View>
57+
</ScrollView>
58+
</View>
59+
);
60+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { SessionParticipant } from '@pairux/shared-types';
3+
import { liveAgents } from './useSessionAgents';
4+
5+
const base: SessionParticipant = {
6+
id: 'p',
7+
session_id: 's',
8+
user_id: null,
9+
display_name: 'x',
10+
role: 'viewer',
11+
control_state: 'view-only',
12+
is_backup_host: false,
13+
connection_status: 'connected',
14+
last_seen_at: null,
15+
joined_at: '2026-09-24T12:00:00.000Z',
16+
left_at: null,
17+
};
18+
19+
describe('liveAgents', () => {
20+
it('keeps only agents that are still in the session', () => {
21+
const rows: SessionParticipant[] = [
22+
// A row read before the migration has no `kind` at all: it is a person.
23+
{ ...base, id: 'human' },
24+
{ ...base, id: 'explicit-human', kind: 'human' },
25+
{ ...base, id: 'agent', kind: 'agent' },
26+
{ ...base, id: 'gone-agent', kind: 'agent', left_at: '2026-09-24T12:05:00.000Z' },
27+
];
28+
expect(liveAgents(rows).map((p) => p.id)).toEqual(['agent']);
29+
});
30+
});

0 commit comments

Comments
 (0)