Compare commits

...

15 Commits

Author SHA1 Message Date
Nikhil Sonti
5359ec4680 fix: address ACP agent review feedback 2026-04-28 15:25:53 -07:00
Nikhil Sonti
a56a29d21a fix: hide persisted harness turns 2026-04-28 15:10:13 -07:00
Nikhil Sonti
9b6e2efcbc refactor: split agents page components 2026-04-28 14:51:39 -07:00
Nikhil Sonti
685d539712 fix: combine openclaw and harness agents UI 2026-04-28 14:43:36 -07:00
Nikhil Sonti
18e0a58e90 chore: self-review fixes 2026-04-28 14:23:34 -07:00
Nikhil Sonti
2eef2c6d8e chore: remove obsolete agent profile spike 2026-04-28 14:15:03 -07:00
Nikhil Sonti
120afc6d5e feat: chat with persisted harness agents 2026-04-28 14:14:09 -07:00
Nikhil Sonti
2441f71d0f feat: create harness agents from agents page 2026-04-28 14:11:03 -07:00
Nikhil Sonti
1e01120587 feat: add harness agent frontend api 2026-04-28 14:09:32 -07:00
Nikhil Sonti
8f38e77955 feat: expose generic agent harness routes 2026-04-28 14:08:10 -07:00
Nikhil Sonti
76ac30efef feat: route harness service through agent records 2026-04-28 14:06:31 -07:00
Nikhil Sonti
7fffd81242 feat: persist agent transcripts 2026-04-28 14:03:36 -07:00
Nikhil Sonti
c1ae563493 feat: persist harness agents in json 2026-04-28 14:02:58 -07:00
Nikhil Sonti
a1bb7600c7 feat: add agent harness catalog 2026-04-28 14:02:10 -07:00
Nikhil Sonti
6c26a3ac84 feat: add acp agent runtime spike 2026-04-28 12:53:58 -07:00
45 changed files with 4708 additions and 1205 deletions

View File

@@ -12,10 +12,12 @@ import { ClawChat } from './ClawChat'
import { ConversationInput } from './ConversationInput'
import {
buildChatHistoryFromClawMessages,
filterTurnsPersistedInHistory,
flattenHistoryPages,
} from './claw-chat-types'
import { useAgentConversation } from './useAgentConversation'
import { useClawChatHistory } from './useClawChatHistory'
import { useHarnessChatHistory } from './useHarnessChatHistory'
import { useOutboundQueue } from './useOutboundQueue'
function StatusBadge({ status }: { status: string }) {
@@ -132,7 +134,7 @@ function AgentRailList({
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
{agents.map((entry) => {
const active = entry.agentId === activeAgentId
const modelName = getModelDisplayName(entry.model) ?? 'OpenClaw agent'
const modelName = getAgentEntryMeta(entry)
return (
<button
@@ -167,6 +169,13 @@ function AgentRailList({
)
}
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
if (agent?.source === 'agent-harness') {
return getModelDisplayName(agent.model) ?? 'ACP agent'
}
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
}
function getConversationStatusCopy(status: string | undefined): string {
if (status === 'running') return 'Ready'
if (status === 'starting') return 'Connecting'
@@ -198,35 +207,66 @@ function AgentConversationController({
const [streamSessionKey, setStreamSessionKey] = useState<string | null>(null)
const agent = agents.find((entry) => entry.agentId === agentId)
const agentName = agent?.name || agentId || 'Agent'
// Single source of truth: the history endpoint resolves the session itself
// when sessionKey is null. Once a chat creates a new session, streamSessionKey
// overrides it and the history queryKey rotates to refetch for that session.
const historyQuery = useClawChatHistory({
const isAgentHarnessAgent = agent?.source === 'agent-harness'
const clawHistoryQuery = useClawChatHistory({
agentId,
sessionKey: streamSessionKey,
enabled: Boolean(agent) && !isAgentHarnessAgent,
})
const harnessHistoryQuery = useHarnessChatHistory(
agentId,
Boolean(agent) && isAgentHarnessAgent,
)
const historyMessages = useMemo(
() => flattenHistoryPages(historyQuery.data?.pages ?? []),
[historyQuery.data?.pages],
() =>
flattenHistoryPages(
isAgentHarnessAgent
? harnessHistoryQuery.data
? [harnessHistoryQuery.data]
: []
: (clawHistoryQuery.data?.pages ?? []),
),
[
clawHistoryQuery.data?.pages,
harnessHistoryQuery.data,
isAgentHarnessAgent,
],
)
const chatHistory = useMemo(
() => buildChatHistoryFromClawMessages(historyMessages),
[historyMessages],
)
const resolvedSessionKey =
streamSessionKey ?? historyQuery.data?.pages?.[0]?.sessionKey ?? null
streamSessionKey ??
(isAgentHarnessAgent
? null
: (clawHistoryQuery.data?.pages?.[0]?.sessionKey ?? null))
const { turns, streaming } = useAgentConversation(agentId, {
const { turns, streaming, send } = useAgentConversation(agentId, {
runtime: isAgentHarnessAgent ? 'agent-harness' : 'openclaw',
sessionKey: resolvedSessionKey,
history: chatHistory,
onComplete: () => {
if (isAgentHarnessAgent) {
void harnessHistoryQuery.refetch()
}
},
onSessionKeyChange: (sessionKey) => {
setStreamSessionKey(sessionKey)
},
})
const visibleTurns = useMemo(
() =>
isAgentHarnessAgent
? filterTurnsPersistedInHistory(turns, historyMessages)
: turns,
[historyMessages, isAgentHarnessAgent, turns],
)
const outboundQueue = useOutboundQueue({
agentId,
sessionKey: resolvedSessionKey,
enabled: Boolean(agent) && !isAgentHarnessAgent,
})
onInitialMessageConsumedRef.current = onInitialMessageConsumed
@@ -238,6 +278,7 @@ function AgentConversationController({
// signal we have without exposing per-turn SSE.
const previousSendingIdsRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (isAgentHarnessAgent) return
const currentSending = new Set(
outboundQueue.queue
.filter((item) => item.status === 'sending')
@@ -248,27 +289,38 @@ function AgentConversationController({
)
previousSendingIdsRef.current = currentSending
if (dropped.length > 0) {
void historyQuery.refetch()
void clawHistoryQuery.refetch()
}
}, [outboundQueue.queue, historyQuery])
}, [clawHistoryQuery, isAgentHarnessAgent, outboundQueue.queue])
const disabled = status?.status !== 'running'
const disabled =
!agent || (!isAgentHarnessAgent && status?.status !== 'running')
// Two-part gate: cover both "still fetching" AND "just got enabled but
// hasn't started fetching yet". When `enabled` flips true (baseUrl
// resolves), there's a render frame where React Query reports
// isLoading=false but hasn't run the queryFn yet — `isFetched` is still
// false. Without this we render EmptyState during that one frame.
const isInitialLoading =
historyQuery.isLoading || (!historyQuery.isFetched && !historyQuery.isError)
!isAgentHarnessAgent &&
(clawHistoryQuery.isLoading ||
(!clawHistoryQuery.isFetched && !clawHistoryQuery.isError))
const historyReady = historyQuery.isFetched || historyQuery.isError
const historyReady =
(isAgentHarnessAgent &&
(harnessHistoryQuery.isFetched || harnessHistoryQuery.isError)) ||
(!isAgentHarnessAgent &&
(clawHistoryQuery.isFetched || clawHistoryQuery.isError))
const initialMessageKey = initialMessage
? `${agentId}:${initialMessage}`
: null
const error = historyQuery.error ?? null
const error = isAgentHarnessAgent
? (harnessHistoryQuery.error ?? null)
: (clawHistoryQuery.error ?? null)
const enqueueRef = useRef(outboundQueue.enqueue)
enqueueRef.current = outboundQueue.enqueue
const sendRef = useRef(send)
sendRef.current = send
useEffect(() => {
const query = initialMessage?.trim()
@@ -277,11 +329,6 @@ function AgentConversationController({
return
}
// The initial-message handoff (home composer → conversation page via
// ?q=) goes through the outbound queue too, so it inherits the same
// single-flight serialization. We no longer need to gate on
// `streaming` — the queue worker drains as soon as the agent is
// free.
if (
!query ||
initialMessageSentRef.current === initialMessageKey ||
@@ -293,8 +340,18 @@ function AgentConversationController({
initialMessageSentRef.current = initialMessageKey
onInitialMessageConsumedRef.current()
enqueueRef.current({ text: query })
}, [disabled, historyReady, initialMessage, initialMessageKey])
if (isAgentHarnessAgent) {
void sendRef.current({ text: query })
} else {
enqueueRef.current({ text: query })
}
}, [
disabled,
historyReady,
initialMessage,
initialMessageKey,
isAgentHarnessAgent,
])
const handleSelectAgent = (entry: AgentEntry) => {
navigate(`${agentPathPrefix}/${entry.agentId}`)
@@ -305,17 +362,29 @@ function AgentConversationController({
<ClawChat
agentName={agentName}
historyMessages={historyMessages}
turns={turns}
turns={visibleTurns}
streaming={streaming}
isInitialLoading={isInitialLoading}
isInitialLoading={
isAgentHarnessAgent ? harnessHistoryQuery.isLoading : isInitialLoading
}
error={error}
hasNextPage={Boolean(historyQuery.hasNextPage)}
isFetchingNextPage={historyQuery.isFetchingNextPage}
hasNextPage={
isAgentHarnessAgent ? false : Boolean(clawHistoryQuery.hasNextPage)
}
isFetchingNextPage={
isAgentHarnessAgent ? false : clawHistoryQuery.isFetchingNextPage
}
onFetchNextPage={() => {
void historyQuery.fetchNextPage()
if (!isAgentHarnessAgent) {
void clawHistoryQuery.fetchNextPage()
}
}}
onRetry={() => {
void historyQuery.refetch()
if (isAgentHarnessAgent) {
void harnessHistoryQuery.refetch()
} else {
void clawHistoryQuery.refetch()
}
}}
/>
@@ -327,27 +396,40 @@ function AgentConversationController({
selectedAgentId={agentId}
onSelectAgent={handleSelectAgent}
onSend={(input) => {
outboundQueue.enqueue({
text: input.text,
attachments: input.attachments.map((a) => a.payload),
attachmentPreviews: input.attachments.map((a) => ({
id: a.id,
kind: a.kind,
mediaType: a.mediaType,
name: a.name,
dataUrl: a.dataUrl,
})),
history: chatHistory,
})
const attachments = input.attachments.map((a) => a.payload)
const attachmentPreviews = input.attachments.map((a) => ({
id: a.id,
kind: a.kind,
mediaType: a.mediaType,
name: a.name,
dataUrl: a.dataUrl,
}))
if (isAgentHarnessAgent) {
void send({ text: input.text, attachments, attachmentPreviews })
} else {
outboundQueue.enqueue({
text: input.text,
attachments,
attachmentPreviews,
history: chatHistory,
})
}
}}
onCreateAgent={() => navigate(createAgentPath)}
streaming={streaming}
disabled={disabled}
status={status?.status}
status={isAgentHarnessAgent ? 'running' : status?.status}
attachmentsEnabled={!isAgentHarnessAgent}
placeholder={`Message ${agentName}...`}
outboundQueue={outboundQueue.queue}
onCancelQueued={outboundQueue.cancel}
onRetryQueued={outboundQueue.retry}
outboundQueue={
isAgentHarnessAgent ? undefined : outboundQueue.queue
}
onCancelQueued={
isAgentHarnessAgent ? undefined : outboundQueue.cancel
}
onRetryQueued={
isAgentHarnessAgent ? undefined : outboundQueue.retry
}
/>
</div>
</div>
@@ -376,7 +458,7 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
const resolvedAgentId = agentId ?? ''
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
const agentName = agent?.name || resolvedAgentId || 'Agent'
const agentMeta = getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
const agentMeta = getAgentEntryMeta(agent)
const initialMessage = searchParams.get('q')
const isPageVariant = variant === 'page'
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
@@ -389,7 +471,10 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
navigate(`${agentPathPrefix}/${entry.agentId}`)
}
const statusCopy = getConversationStatusCopy(status?.status)
const statusCopy =
agent?.source === 'agent-harness'
? 'Ready'
: getConversationStatusCopy(status?.status)
return (
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">

View File

@@ -1,4 +1,4 @@
import { ArrowRight, Bot, Plus, Settings2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { type FC, useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
@@ -13,34 +13,6 @@ import { AgentCardDock } from './AgentCardDock'
import { useAgentCommandData } from './agent-command-layout'
import { ConversationInput } from './ConversationInput'
import { buildAgentCardData } from './useAgentCardData'
import { useAgentDashboard } from './useAgentDashboard'
function AgentCommandSetupState({
onOpenAgents,
}: {
onOpenAgents: () => void
}) {
return (
<Card className="border-border/60 bg-card/90 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Bot className="size-5" />
</div>
<div className="space-y-2">
<h2 className="font-semibold text-lg">Set up your first agent</h2>
<p className="max-w-md text-muted-foreground text-sm leading-6">
Connect OpenClaw and create an agent before using the new tab as
your workspace.
</p>
</div>
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
Open Agent Setup
<ArrowRight className="size-4" />
</Button>
</CardContent>
</Card>
)
}
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
return (
@@ -63,33 +35,6 @@ function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
)
}
function OpenClawUnavailableState({
onOpenAgents,
}: {
onOpenAgents: () => void
}) {
return (
<Card className="border-border/60 bg-card/90 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Settings2 className="size-5" />
</div>
<div className="space-y-2">
<h2 className="font-semibold text-lg">OpenClaw is unavailable</h2>
<p className="max-w-md text-muted-foreground text-sm leading-6">
Review your agent setup to restart the gateway or reconnect the
local service.
</p>
</div>
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
Open Agent Setup
<ArrowRight className="size-4" />
</Button>
</CardContent>
</Card>
)
}
function RecentThreads({
activeAgentId,
agents,
@@ -134,10 +79,9 @@ function RecentThreads({
export const AgentCommandHome: FC = () => {
const navigate = useNavigate()
const activeHint = useActiveHint()
const { status, agents } = useAgentCommandData()
const { agents, status } = useAgentCommandData()
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const { data: dashboard } = useAgentDashboard(status?.status === 'running')
const cardData = buildAgentCardData(agents, status?.status, dashboard?.agents)
const cardData = buildAgentCardData(agents, status?.status, undefined)
useEffect(() => {
if (agents.length === 0) {
@@ -157,11 +101,6 @@ export const AgentCommandHome: FC = () => {
const handleSend = (input: { text: string }) => {
if (!selectedAgentId) return
// Home composer navigates to the conversation page with the prompt in
// the query string. Attachments are dropped at this boundary in v1 —
// the conversation page (where staging UX is most useful anyway) is
// where users can attach. A future iteration can stash staged files
// in chrome.storage.session and replay them on first mount there.
navigate(
`/home/agents/${selectedAgentId}?q=${encodeURIComponent(input.text)}`,
)
@@ -171,71 +110,65 @@ export const AgentCommandHome: FC = () => {
setSelectedAgentId(agent.agentId)
}
const openClawStatus = status?.status
const isSetup = openClawStatus != null && openClawStatus !== 'uninitialized'
const shouldShowUnavailableState =
openClawStatus != null &&
openClawStatus !== 'running' &&
openClawStatus !== 'uninitialized' &&
cardData.length === 0
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
const selectedAgentReady = selectedAgent
? selectedAgent.source === 'agent-harness' || status?.status === 'running'
: false
const selectedAgentStatus =
selectedAgent?.source === 'agent-harness' ? 'running' : status?.status
const selectedCard =
cardData.find((agent) => agent.agentId === selectedAgentId) ?? cardData[0]
return (
<div className="min-h-full px-4 py-6">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
{isSetup ? (
shouldShowUnavailableState ? (
<OpenClawUnavailableState
onOpenAgents={() => navigate('/agents')}
/>
) : cardData.length > 0 ? (
<>
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
<div className="space-y-3">
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
What should your agent work on next?
</h1>
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
Start with a task, continue a thread, or switch to another
agent without leaving the new tab.
</p>
</div>
<div className="w-full max-w-3xl">
<ConversationInput
variant="home"
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
onSend={handleSend}
onCreateAgent={() => navigate('/agents')}
streaming={false}
disabled={status?.status !== 'running'}
status={status?.status}
placeholder={
status?.status === 'running'
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
: 'OpenClaw is not running...'
}
/>
</div>
{cardData.length > 0 ? (
<>
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
<div className="space-y-3">
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
What should your agent work on next?
</h1>
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
Start with a task, continue a thread, or switch to another
agent without leaving the new tab.
</p>
</div>
<Separator />
<div className="w-full max-w-3xl">
<ConversationInput
variant="home"
agents={agents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
onSend={handleSend}
onCreateAgent={() => navigate('/agents')}
streaming={false}
disabled={!selectedAgentReady}
status={selectedAgentStatus}
attachmentsEnabled={false}
placeholder={
selectedAgentReady
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
: 'Agent runtime is not running...'
}
/>
</div>
</div>
<RecentThreads
activeAgentId={selectedAgentId}
agents={cardData}
onOpenAgents={() => navigate('/agents')}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
/>
</>
) : (
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
)
<Separator />
<RecentThreads
activeAgentId={selectedAgentId}
agents={cardData}
onOpenAgents={() => navigate('/agents')}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
/>
</>
) : (
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
)}
</div>

View File

@@ -55,6 +55,7 @@ interface ConversationInputProps {
disabled?: boolean
status?: string
placeholder?: string
attachmentsEnabled?: boolean
variant?: 'home' | 'conversation'
// Outbound queue: when present, the composer renders the queue strip
// above the textarea and lets the user keep sending while a previous
@@ -155,6 +156,7 @@ function ContextControls({
status,
onAttachClick,
attachDisabled,
attachmentsEnabled,
}: {
agents: AgentEntry[]
onCreateAgent?: () => void
@@ -166,6 +168,7 @@ function ContextControls({
status?: string
onAttachClick: () => void
attachDisabled: boolean
attachmentsEnabled: boolean
}) {
const { supports } = useCapabilities()
const { selectedFolder } = useWorkspace()
@@ -229,7 +232,7 @@ function ContextControls({
type="button"
variant="ghost"
onClick={onAttachClick}
disabled={attachDisabled}
disabled={attachDisabled || !attachmentsEnabled}
title="Attach files"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
@@ -306,6 +309,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
disabled,
status,
placeholder,
attachmentsEnabled = true,
variant = 'conversation',
outboundQueue,
onCancelQueued,
@@ -328,6 +332,10 @@ export const ConversationInput: FC<ConversationInputProps> = ({
const stageFiles = async (files: File[]) => {
if (files.length === 0) return
if (!attachmentsEnabled) {
setAttachmentError('Attachments are not supported for this agent yet.')
return
}
setIsStaging(true)
setAttachmentError(null)
try {
@@ -369,6 +377,12 @@ export const ConversationInput: FC<ConversationInputProps> = ({
}
}, [voice.transcript, voice.isTranscribing, voice])
useEffect(() => {
if (attachmentsEnabled) return
setAttachments([])
setAttachmentError(null)
}, [attachmentsEnabled])
const toggleTab = (tab: chrome.tabs.Tab) => {
setSelectedTabs((prev) => {
const isSelected = prev.some((selected) => selected.id === tab.id)
@@ -435,6 +449,10 @@ export const ConversationInput: FC<ConversationInputProps> = ({
}
const openFilePicker = () => {
if (!attachmentsEnabled) {
setAttachmentError('Attachments are not supported for this agent yet.')
return
}
fileInputRef.current?.click()
}
@@ -565,6 +583,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
status={status}
onAttachClick={openFilePicker}
attachDisabled={attachments.length >= 10 || isStaging || !!disabled}
attachmentsEnabled={attachmentsEnabled}
/>
{isDragOver ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[inherit] bg-background/80 font-medium text-foreground text-sm backdrop-blur-sm">

View File

@@ -1,8 +1,11 @@
import type { FC } from 'react'
import { Outlet, useOutletContext } from 'react-router'
import { useHarnessAgents } from '@/entrypoints/app/agents/useAgents'
import type {
AgentEntry,
OpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
import {
type AgentEntry,
type OpenClawStatus,
useOpenClawAgents,
useOpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
@@ -16,16 +19,24 @@ interface AgentCommandContextValue {
export const AgentCommandLayout: FC = () => {
const { status, loading: statusLoading } = useOpenClawStatus(5000)
const { agents, loading: agentsLoading } = useOpenClawAgents(
status?.status === 'running' && status.controlPlaneStatus === 'connected',
)
const openClawEnabled =
status?.status === 'running' && status.controlPlaneStatus === 'connected'
const { agents: openClawAgents, loading: openClawAgentsLoading } =
useOpenClawAgents(openClawEnabled)
const { agents: harnessAgents, loading: harnessAgentsLoading } =
useHarnessAgents()
const visibleOpenClawAgents = openClawEnabled ? openClawAgents : []
const agents = [...visibleOpenClawAgents, ...harnessAgents]
return (
<Outlet
context={
{
agents,
agentsLoading,
agentsLoading:
harnessAgentsLoading ||
statusLoading ||
(openClawEnabled && openClawAgentsLoading),
status,
statusLoading,
} satisfies AgentCommandContextValue

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'bun:test'
import { mapAgentHarnessToolStatus } from './agent-stream-events'
describe('mapAgentHarnessToolStatus', () => {
it('normalizes ACP tool statuses for the chat renderer', () => {
expect(mapAgentHarnessToolStatus('running')).toBe('running')
expect(mapAgentHarnessToolStatus('completed')).toBe('completed')
expect(mapAgentHarnessToolStatus('failed')).toBe('error')
expect(mapAgentHarnessToolStatus('incomplete')).toBe('running')
expect(mapAgentHarnessToolStatus(undefined)).toBe('running')
})
})

View File

@@ -0,0 +1,19 @@
import type { ToolEntry } from '@/lib/agent-conversations/types'
export function mapAgentHarnessToolStatus(
status: string | undefined,
): ToolEntry['status'] {
if (!status) return 'running'
const normalized = status.toLowerCase()
if (['error', 'failed', 'failure', 'denied'].includes(normalized)) {
return 'error'
}
if (
['complete', 'completed', 'done', 'success', 'succeeded'].includes(
normalized,
)
) {
return 'completed'
}
return 'running'
}

View File

@@ -1,8 +1,10 @@
import { describe, expect, it } from 'bun:test'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
import {
type AgentHistoryPageResponse,
type BrowserOSChatHistoryItem,
buildChatHistoryFromClawMessages,
filterTurnsPersistedInHistory,
flattenHistoryPages,
mapHistoryItemToClawMessage,
} from './claw-chat-types'
@@ -118,4 +120,64 @@ describe('claw-chat-types', () => {
{ role: 'assistant', content: 'Assistant answer' },
])
})
it('hides completed live turns once harness history contains the same turn', () => {
const turn: AgentConversationTurn = {
id: 'live-turn',
userText: 'hello',
parts: [{ kind: 'text', text: 'hi there' }],
done: true,
timestamp: 1_000,
}
const visible = filterTurnsPersistedInHistory(
[turn],
[
{
id: 'history-user',
role: 'user',
sessionKey: 'main',
timestamp: 1_050,
status: 'historical',
parts: [{ type: 'text', text: 'hello' }],
},
{
id: 'history-assistant',
role: 'assistant',
sessionKey: 'main',
timestamp: 1_100,
status: 'historical',
parts: [{ type: 'text', text: 'hi there' }],
},
],
)
expect(visible).toEqual([])
})
it('keeps completed live turns until matching assistant history arrives', () => {
const turn: AgentConversationTurn = {
id: 'live-turn',
userText: 'hello',
parts: [{ kind: 'text', text: 'hi there' }],
done: true,
timestamp: 1_000,
}
const visible = filterTurnsPersistedInHistory(
[turn],
[
{
id: 'history-user',
role: 'user',
sessionKey: 'main',
timestamp: 1_050,
status: 'historical',
parts: [{ type: 'text', text: 'hello' }],
},
],
)
expect(visible).toEqual([turn])
})
})

View File

@@ -1,4 +1,5 @@
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
export type ClawChatRole = 'user' | 'assistant'
@@ -221,3 +222,66 @@ export function buildChatHistoryFromClawMessages(
Boolean(message),
)
}
const TURN_HISTORY_MATCH_WINDOW_MS = 5_000
export function filterTurnsPersistedInHistory(
turns: AgentConversationTurn[],
historyMessages: ClawChatMessage[],
): AgentConversationTurn[] {
return turns.filter(
(turn) => !isTurnPersistedInHistory(turn, historyMessages),
)
}
function isTurnPersistedInHistory(
turn: AgentConversationTurn,
historyMessages: ClawChatMessage[],
): boolean {
if (!turn.done) return false
const assistantText = getTurnAssistantText(turn)
if (!assistantText) return false
const minTimestamp = turn.timestamp - TURN_HISTORY_MATCH_WINDOW_MS
const userText = turn.userText.trim()
const userPersisted =
!userText ||
historyMessages.some(
(message) =>
message.role === 'user' &&
isHistoryMessageAfter(message, minTimestamp) &&
getClawMessageText(message) === userText,
)
const assistantPersisted = historyMessages.some(
(message) =>
message.role === 'assistant' &&
isHistoryMessageAfter(message, minTimestamp) &&
getClawMessageText(message) === assistantText,
)
return userPersisted && assistantPersisted
}
function isHistoryMessageAfter(
message: ClawChatMessage,
minTimestamp: number,
): boolean {
return message.timestamp == null || message.timestamp >= minTimestamp
}
function getTurnAssistantText(turn: AgentConversationTurn): string {
return turn.parts
.filter((part) => part.kind === 'text')
.map((part) => part.text)
.join('')
.trim()
}
function getClawMessageText(message: ClawChatMessage): string {
return message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
.trim()
}

View File

@@ -39,7 +39,10 @@ export function buildAgentCardData(
agentId: agent.agentId,
name: agent.name,
model: getModelDisplayName(agent.model),
status: resolveAgentStatus(status, overview?.status),
status:
agent.source === 'agent-harness'
? 'idle'
: resolveAgentStatus(status, overview?.status),
lastMessage: overview?.latestMessage?.slice(0, 200) ?? undefined,
lastMessageTimestamp: overview?.latestMessageAt ?? undefined,
activitySummary: overview?.activitySummary ?? undefined,

View File

@@ -1,4 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import {
type AgentHarnessStreamEvent,
chatWithHarnessAgent,
} from '@/entrypoints/app/agents/useAgents'
import {
chatWithAgent,
type OpenClawChatHistoryMessage,
@@ -7,11 +11,13 @@ import {
import type {
AgentConversationTurn,
AssistantPart,
ToolEntry,
UserAttachmentPreview,
} from '@/lib/agent-conversations/types'
import type { ServerAttachmentPayload } from '@/lib/attachments'
import { consumeSSEStream } from '@/lib/sse'
import { buildToolLabel } from '@/lib/tool-labels'
import { mapAgentHarnessToolStatus } from './agent-stream-events'
export interface SendInput {
text: string
@@ -23,8 +29,10 @@ export interface SendInput {
}
interface UseAgentConversationOptions {
runtime?: 'openclaw' | 'agent-harness'
sessionKey?: string | null
history?: OpenClawChatHistoryMessage[]
onComplete?: () => void
onSessionKeyChange?: (sessionKey: string) => void
}
@@ -39,6 +47,7 @@ export function useAgentConversation(
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const streamAbortRef = useRef<AbortController | null>(null)
const onCompleteRef = useRef(options.onComplete)
const onSessionKeyChangeRef = useRef(options.onSessionKeyChange)
useEffect(() => {
@@ -49,6 +58,10 @@ export function useAgentConversation(
historyRef.current = options.history ?? []
}, [options.history])
useEffect(() => {
onCompleteRef.current = options.onComplete
}, [options.onComplete])
useEffect(() => {
onSessionKeyChangeRef.current = options.onSessionKeyChange
}, [options.onSessionKeyChange])
@@ -72,34 +85,12 @@ export function useAgentConversation(
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
appendTextDelta((event.data.text as string) ?? '')
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
appendThinkingDelta((event.data.text as string) ?? '')
break
}
@@ -155,16 +146,7 @@ export function useAgentConversation(
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
markCurrentTurnDone()
break
}
@@ -173,15 +155,127 @@ export function useAgentConversation(
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
appendErrorText(msg)
break
}
}
}
const appendTextDelta = (delta: string) => {
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
}
const appendThinkingDelta = (delta: string) => {
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
}
const appendErrorText = (message: string) => {
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${message}` },
])
}
const markCurrentTurnDone = () => {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
}
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
if (event.type !== 'tool_call') return
const rawName = event.title || event.rawType || 'tool call'
const { label, subject } = buildToolLabel(
rawName,
event.text ? { description: event.text } : undefined,
)
const tool: ToolEntry = {
id: event.id ?? crypto.randomUUID(),
name: rawName,
label,
subject,
status: mapAgentHarnessToolStatus(event.status),
}
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((existing) => existing.id === tool.id)
) {
const tools = part.tools.map((existing) =>
existing.id === tool.id ? { ...existing, ...tool } : existing,
)
return [
...parts.slice(0, i),
{ ...part, tools },
...parts.slice(i + 1),
]
}
}
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
}
const processAgentHarnessStreamEvent = (event: AgentHarnessStreamEvent) => {
switch (event.type) {
case 'text_delta':
if (event.stream === 'thought') {
appendThinkingDelta(event.text)
} else {
appendTextDelta(event.text)
}
break
case 'tool_call':
upsertAgentHarnessTool(event)
break
case 'done':
markCurrentTurnDone()
break
case 'error':
appendErrorText(event.message)
break
case 'status':
break
}
}
const send = async (input: string | SendInput) => {
const normalized: SendInput =
typeof input === 'string' ? { text: input } : input
@@ -210,15 +304,20 @@ export function useAgentConversation(
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
trimmed,
sessionKeyRef.current || undefined,
historyRef.current,
abortController.signal,
attachments,
)
const responseSessionKey = response.headers.get('X-Session-Key')
const response =
options.runtime === 'agent-harness'
? await chatWithHarnessAgent(agentId, trimmed, abortController.signal)
: await chatWithAgent(
agentId,
trimmed,
sessionKeyRef.current || undefined,
historyRef.current,
abortController.signal,
attachments,
)
const responseSessionKey =
response.headers.get('X-Session-Key') ??
response.headers.get('X-Session-Id')
if (responseSessionKey) {
sessionKeyRef.current = responseSessionKey
onSessionKeyChangeRef.current?.(responseSessionKey)
@@ -231,11 +330,19 @@ export function useAgentConversation(
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
if (options.runtime === 'agent-harness') {
await consumeSSEStream<AgentHarnessStreamEvent>(
response,
processAgentHarnessStreamEvent,
abortController.signal,
)
} else {
await consumeSSEStream<OpenClawStreamEvent>(
response,
processStreamEvent,
abortController.signal,
)
}
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
@@ -247,6 +354,7 @@ export function useAgentConversation(
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
onCompleteRef.current?.()
setStreaming(false)
}
}

View File

@@ -0,0 +1,68 @@
import { useQuery } from '@tanstack/react-query'
import type { HarnessAgentHistoryPage } from '@/entrypoints/app/agents/agent-harness-types'
import { fetchHarnessAgentHistory } from '@/entrypoints/app/agents/useAgents'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type {
AgentHistoryPageResponse,
BrowserOSChatHistoryItem,
} from './claw-chat-types'
const HISTORY_QUERY_KEY = 'harness-agent-history'
export function useHarnessChatHistory(agentId: string, enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentHistoryPageResponse, Error>({
queryKey: [HISTORY_QUERY_KEY, baseUrl, agentId, 'main'],
queryFn: async () => {
return mapHarnessHistoryPage(await fetchHarnessAgentHistory(agentId))
},
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
})
return {
...query,
error: query.error ?? urlError,
isLoading: query.isLoading || urlLoading,
}
}
function mapHarnessHistoryPage(
page: HarnessAgentHistoryPage,
): AgentHistoryPageResponse {
const items: BrowserOSChatHistoryItem[] = page.items.map((item, index) => ({
id: item.id,
role: item.role,
text: item.text,
timestamp: item.createdAt,
messageSeq: index + 1,
sessionKey: 'main',
source: 'user-chat',
}))
const updatedAt =
page.items.length > 0
? Math.max(...page.items.map((item) => item.createdAt))
: Date.now()
return {
agentId: page.agentId,
sessionKey: 'main',
session: {
key: 'main',
updatedAt,
sessionId: 'main',
agentId: page.agentId,
kind: 'agent-harness',
source: 'user-chat',
},
items,
page: {
hasMore: false,
limit: items.length,
},
}
}

View File

@@ -33,6 +33,7 @@ export interface OutboundQueueApi {
interface UseOutboundQueueOptions {
agentId: string | null | undefined
sessionKey?: string | null
enabled?: boolean
}
interface ServerQueuedItem {
@@ -68,7 +69,7 @@ function makeId(): string {
export function useOutboundQueue(
options: UseOutboundQueueOptions,
): OutboundQueueApi {
const { agentId, sessionKey } = options
const { agentId, enabled = true, sessionKey } = options
const { baseUrl } = useAgentServerUrl()
const sessionKeyRef = useRef<string | null | undefined>(sessionKey)
sessionKeyRef.current = sessionKey
@@ -85,7 +86,7 @@ export function useOutboundQueue(
const previewMapRef = useRef<Map<string, UserAttachmentPreview[]>>(new Map())
useEffect(() => {
if (!baseUrl || !agentId) {
if (!enabled || !baseUrl || !agentId) {
setItems([])
everSeenByServerRef.current = new Set()
previewMapRef.current = new Map()
@@ -136,11 +137,11 @@ export function useOutboundQueue(
cancelled = true
source.close()
}
}, [baseUrl, agentId])
}, [baseUrl, agentId, enabled])
const enqueue = useCallback(
(input: OutboundQueueEnqueueInput) => {
if (!baseUrl || !agentId) return
if (!enabled || !baseUrl || !agentId) return
const trimmed = input.text.trim()
const attachments = input.attachments ?? []
if (!trimmed && attachments.length === 0) return
@@ -215,7 +216,7 @@ export function useOutboundQueue(
}
})()
},
[baseUrl, agentId],
[baseUrl, agentId, enabled],
)
const cancel = useCallback(
@@ -226,13 +227,13 @@ export function useOutboundQueue(
setItems((prev) => prev.filter((item) => item.id !== id))
return
}
if (!baseUrl || !agentId) return
if (!enabled || !baseUrl || !agentId) return
void fetch(
`${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
).catch(() => {})
},
[baseUrl, agentId],
[baseUrl, agentId, enabled],
)
const retry = useCallback(
@@ -249,13 +250,13 @@ export function useOutboundQueue(
)
return
}
if (!baseUrl || !agentId) return
if (!enabled || !baseUrl || !agentId) return
void fetch(
`${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(id)}/retry`,
{ method: 'POST' },
).catch(() => {})
},
[baseUrl, agentId],
[baseUrl, agentId, enabled],
)
return { queue: items, enqueue, cancel, retry }

View File

@@ -0,0 +1,117 @@
import { Bot, Cpu, Loader2, MessageSquare, Plus, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { AgentListItem } from './agents-page-types'
interface AgentListProps {
agents: AgentListItem[]
loading: boolean
deletingAgentKey: string | null
onChatAgent: (agent: AgentListItem) => void
onCreateAgent: () => void
onDeleteAgent: (agent: AgentListItem) => void
}
export const AgentList: FC<AgentListProps> = ({
agents,
loading,
deletingAgentKey,
onChatAgent,
onCreateAgent,
onDeleteAgent,
}) => {
if (loading && agents.length === 0) {
return (
<div className="flex h-36 items-center justify-center rounded-lg border border-border/70">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)
}
if (agents.length === 0) {
return (
<Card>
<CardContent className="flex h-48 flex-col items-center justify-center gap-4 text-center">
<div className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Bot className="size-5" />
</div>
<div className="space-y-1">
<h2 className="font-medium text-base">No agents</h2>
<p className="text-muted-foreground text-sm">
Create an OpenClaw, Claude Code, or Codex agent.
</p>
</div>
<Button variant="outline" onClick={onCreateAgent}>
<Plus className="mr-2 size-4" />
New Agent
</Button>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-3">
{agents.map((agent) => (
<Card key={agent.key} className="rounded-lg border-border/70">
<CardHeader className="flex flex-row items-center justify-between gap-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{agent.source === 'openclaw' ? (
<Cpu className="size-5" />
) : (
<Bot className="size-5" />
)}
</div>
<div className="min-w-0">
<CardTitle className="truncate text-base">
{agent.name}
</CardTitle>
<div className="mt-1 flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<Badge variant="outline" className="rounded-md">
{agent.runtimeLabel}
</Badge>
<span>{agent.modelLabel}</span>
<Badge variant="outline" className="rounded-md">
main
</Badge>
</div>
<p className="mt-1 truncate font-mono text-muted-foreground text-xs">
{agent.detail}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onChatAgent(agent)}
disabled={!agent.canChat}
>
<MessageSquare className="mr-1 size-4" />
Chat
</Button>
{agent.canDelete ? (
<Button
variant="ghost"
size="icon"
title="Delete agent"
onClick={() => onDeleteAgent(agent)}
disabled={deletingAgentKey === agent.key}
>
{deletingAgentKey === agent.key ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4 text-destructive" />
)}
</Button>
) : null}
</div>
</CardHeader>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { AlertCircle, Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type {
HarnessAdapterDescriptor,
HarnessAgentAdapter,
} from './agent-harness-types'
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
import { ProviderSelector } from './OpenClawControls'
import {
type OpenClawCliProvider,
type OpenClawCliProviderAuthStatus,
OpenClawCliProviderStatusPanel,
} from './openclaw-cli-providers'
interface NewAgentDialogProps {
adapters: HarnessAdapterDescriptor[]
canManageOpenClaw: boolean
createError: string | null
createRuntime: CreateAgentRuntime
creating: boolean
defaultProviderId: string
harnessAdapterId: HarnessAgentAdapter
harnessModelId: string
harnessReasoningEffort: string
name: string
open: boolean
providers: ProviderOption[]
selectedCliProvider: OpenClawCliProvider | undefined
selectedProviderId: string
cliAuthError: Error | null
cliAuthLoading: boolean
cliAuthStatus: OpenClawCliProviderAuthStatus | undefined
onConnectCliProvider: () => void
onCreate: () => void
onOpenChange: (open: boolean) => void
onRuntimeChange: (runtime: CreateAgentRuntime) => void
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
onHarnessModelChange: (modelId: string) => void
onHarnessReasoningChange: (reasoningEffort: string) => void
onNameChange: (name: string) => void
onProviderChange: (providerId: string) => void
}
export const NewAgentDialog: FC<NewAgentDialogProps> = ({
adapters,
canManageOpenClaw,
createError,
createRuntime,
creating,
defaultProviderId,
harnessAdapterId,
harnessModelId,
harnessReasoningEffort,
name,
open,
providers,
selectedCliProvider,
selectedProviderId,
cliAuthError,
cliAuthLoading,
cliAuthStatus,
onConnectCliProvider,
onCreate,
onOpenChange,
onRuntimeChange,
onHarnessAdapterChange,
onHarnessModelChange,
onHarnessReasoningChange,
onNameChange,
onProviderChange,
}) => {
const selectedHarnessAdapter =
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
const isHarnessRuntime = createRuntime !== 'openclaw'
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
const cliBlocked =
createRuntime === 'openclaw' &&
!!selectedCliProvider &&
!cliAuthStatus?.loggedIn
const canCreate =
Boolean(name.trim()) &&
!creating &&
!openClawBlocked &&
!cliBlocked &&
(createRuntime === 'openclaw'
? providers.length > 0
: Boolean(selectedHarnessAdapter))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Agent</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
{createError ? (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Create failed</AlertTitle>
<AlertDescription>{createError}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-2">
<Label htmlFor="agent-name">Name</Label>
<Input
id="agent-name"
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder={
createRuntime === 'openclaw' ? 'research-agent' : 'Review bot'
}
onKeyDown={(event) => {
if (event.key === 'Enter' && canCreate) onCreate()
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="agent-runtime">Adapter</Label>
<Select
value={createRuntime}
onValueChange={(value) => {
if (
value === 'openclaw' ||
value === 'claude' ||
value === 'codex'
) {
onRuntimeChange(value)
if (value !== 'openclaw') onHarnessAdapterChange(value)
}
}}
>
<SelectTrigger id="agent-runtime">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openclaw">OpenClaw</SelectItem>
{adapters.map((adapter) => (
<SelectItem key={adapter.id} value={adapter.id}>
{adapter.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createRuntime === 'openclaw' ? (
<>
{openClawBlocked ? (
<Alert>
<AlertCircle className="size-4" />
<AlertTitle>OpenClaw is not ready</AlertTitle>
<AlertDescription>
Start or set up the OpenClaw gateway before creating an
OpenClaw agent.
</AlertDescription>
</Alert>
) : null}
<ProviderSelector
providers={providers}
defaultProviderId={defaultProviderId}
selectedId={selectedProviderId}
onSelect={onProviderChange}
hideApiKeyHint={!!selectedCliProvider}
/>
{selectedCliProvider ? (
<OpenClawCliProviderStatusPanel
provider={selectedCliProvider}
status={cliAuthStatus}
loading={cliAuthLoading}
fetchError={cliAuthError}
onConnect={onConnectCliProvider}
/>
) : null}
</>
) : null}
{isHarnessRuntime ? (
<>
<div className="grid gap-2">
<Label htmlFor="harness-model">Model</Label>
<Select
value={harnessModelId}
onValueChange={onHarnessModelChange}
>
<SelectTrigger id="harness-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(selectedHarnessAdapter?.models ?? []).map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="harness-effort">Reasoning</Label>
<Select
value={harnessReasoningEffort}
onValueChange={onHarnessReasoningChange}
>
<SelectTrigger id="harness-effort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(selectedHarnessAdapter?.reasoningEfforts ?? []).map(
(effort) => (
<SelectItem key={effort.id} value={effort.id}>
{effort.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
</>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={creating}
>
Cancel
</Button>
<Button disabled={!canCreate} onClick={onCreate}>
{creating ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,387 @@
import {
AlertCircle,
Cpu,
Loader2,
Plus,
RefreshCw,
ShieldAlert,
Square,
TerminalSquare,
WifiOff,
Wrench,
} from 'lucide-react'
import type { FC } from 'react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ProviderOption } from './agents-page-types'
import {
CONTROL_PLANE_COPY,
FALLBACK_CONTROL_PLANE_COPY,
} from './agents-page-types'
import type { getControlPlaneCopy } from './agents-page-utils'
import type { OpenClawStatus } from './useOpenClaw'
const StatusBadge: FC<{ status: OpenClawStatus['status'] }> = ({ status }) => {
const variants: Record<
OpenClawStatus['status'],
{
variant: 'default' | 'secondary' | 'outline' | 'destructive'
label: string
}
> = {
running: { variant: 'default', label: 'Running' },
starting: { variant: 'secondary', label: 'Starting...' },
stopped: { variant: 'outline', label: 'Stopped' },
error: { variant: 'destructive', label: 'Error' },
uninitialized: { variant: 'outline', label: 'Not Set Up' },
}
const current = variants[status] ?? {
variant: 'outline' as const,
label: 'Unknown',
}
return <Badge variant={current.variant}>{current.label}</Badge>
}
const ControlPlaneBadge: FC<{
status: OpenClawStatus['controlPlaneStatus']
}> = ({ status }) => {
const current = CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
return <Badge variant={current.badgeVariant}>{current.badgeLabel}</Badge>
}
interface ProviderSelectorProps {
providers: ProviderOption[]
defaultProviderId: string
selectedId: string
onSelect: (id: string) => void
hideApiKeyHint?: boolean
}
export const ProviderSelector: FC<ProviderSelectorProps> = ({
providers,
defaultProviderId,
selectedId,
onSelect,
hideApiKeyHint,
}) => {
if (providers.length === 0) {
return (
<div className="space-y-2">
<p className="font-medium text-sm">LLM Provider</p>
<p className="text-muted-foreground text-sm">
No compatible LLM providers configured.{' '}
<a href="#/settings/ai" className="underline">
Add one in AI settings
</a>{' '}
first.
</p>
</div>
)
}
return (
<div className="space-y-2">
<Label htmlFor="provider-select">LLM Provider</Label>
<Select value={selectedId} onValueChange={onSelect}>
<SelectTrigger id="provider-select">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name} - {provider.modelId}
{provider.id === defaultProviderId ? ' (default)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{!hideApiKeyHint && (
<p className="text-muted-foreground text-xs">
Uses your existing API key from BrowserOS settings. The key is passed
to the container and never leaves your machine.
</p>
)}
</div>
)
}
interface AgentsPageHeaderProps {
actionInProgress: boolean
controlPlaneBusy: boolean
reconnecting: boolean
status: OpenClawStatus | null
onCreateAgent: () => void
onOpenTerminal: () => void
onReconnect: () => void
onRefresh: () => void
onRestart: () => void
onStop: () => void
}
export const AgentsPageHeader: FC<AgentsPageHeaderProps> = ({
actionInProgress,
controlPlaneBusy,
reconnecting,
status,
onCreateAgent,
onOpenTerminal,
onReconnect,
onRefresh,
onRestart,
onStop,
}) => (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-semibold text-2xl tracking-normal">Agents</h1>
<p className="text-muted-foreground text-sm">
OpenClaw, Claude Code, and Codex agents
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{status ? (
<>
<StatusBadge status={status.status} />
{status.status !== 'uninitialized' && (
<ControlPlaneBadge status={status.controlPlaneStatus} />
)}
</>
) : null}
{status?.status === 'running' &&
status.controlPlaneStatus !== 'connected' ? (
<Button
variant="outline"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
) : null}
{status?.status === 'running' ? (
<>
<Button
variant="ghost"
size="icon"
onClick={onRestart}
disabled={actionInProgress}
title="Restart gateway"
>
<RefreshCw className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onStop}
disabled={actionInProgress}
title="Stop gateway"
>
<Square className="size-4" />
</Button>
<Button variant="outline" onClick={onOpenTerminal}>
<TerminalSquare className="mr-2 size-4" />
Terminal
</Button>
</>
) : null}
<Button variant="ghost" size="icon" onClick={onRefresh} title="Refresh">
<RefreshCw className="size-4" />
</Button>
<Button onClick={onCreateAgent}>
<Plus className="mr-2 size-4" />
New Agent
</Button>
</div>
</div>
)
export function LifecycleAlert({ message }: { message: string }) {
return (
<Alert>
<Loader2 className="size-4 animate-spin" />
<AlertTitle>{message}</AlertTitle>
</Alert>
)
}
export function InlineErrorAlert({
message,
onDismiss,
}: {
message: string
onDismiss: () => void
}) {
return (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Agent action failed</AlertTitle>
<AlertDescription>
<p>{message}</p>
<div className="mt-2">
<Button variant="outline" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</AlertDescription>
</Alert>
)
}
interface ControlPlaneAlertProps {
actionInProgress: boolean
controlPlaneBusy: boolean
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
reconnecting: boolean
recoveryDetail: string | null
status: OpenClawStatus
onReconnect: () => void
onRestart: () => void
}
export const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
actionInProgress,
controlPlaneBusy,
controlPlaneCopy,
reconnecting,
recoveryDetail,
status,
onReconnect,
onRestart,
}) => (
<Alert
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
>
{status.controlPlaneStatus === 'failed' ? (
<ShieldAlert className="size-4" />
) : status.controlPlaneStatus === 'recovering' ? (
<Wrench className="size-4" />
) : (
<WifiOff className="size-4" />
)}
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
<AlertDescription>
<p>{controlPlaneCopy.description}</p>
{recoveryDetail ? <p>{recoveryDetail}</p> : null}
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={onReconnect}
disabled={actionInProgress || controlPlaneBusy}
>
{reconnecting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<RefreshCw className="mr-2 size-4" />
)}
Retry Connection
</Button>
<Button
variant="outline"
size="sm"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</AlertDescription>
</Alert>
)
interface GatewayStateCardsProps {
actionInProgress: boolean
status: OpenClawStatus | null
onOpenSetup: () => void
onRestart: () => void
onStart: () => void
}
export const GatewayStateCards: FC<GatewayStateCardsProps> = ({
actionInProgress,
status,
onOpenSetup,
onRestart,
onStart,
}) => (
<>
{status?.status === 'uninitialized' ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
<p className="text-muted-foreground text-sm">
{status.podmanAvailable
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
: 'BrowserOS VM runtime is unavailable on this system.'}
</p>
</div>
{status.podmanAvailable ? (
<Button onClick={onOpenSetup}>Set Up Now</Button>
) : null}
</CardContent>
</Card>
) : null}
{status?.status === 'stopped' ? (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-12">
<Cpu className="size-12 text-muted-foreground" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
<p className="text-muted-foreground text-sm">
The OpenClaw gateway is not running.
</p>
</div>
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
</CardContent>
</Card>
) : null}
{status?.status === 'error' ? (
<Card className="border-destructive">
<CardContent className="flex flex-col items-center gap-4 py-12">
<AlertCircle className="size-12 text-destructive" />
<div className="text-center">
<h3 className="font-semibold text-lg">Gateway Error</h3>
<p className="text-muted-foreground text-sm">
{status.error ?? status.lastGatewayError}
</p>
</div>
<div className="flex gap-2">
<Button onClick={onStart} disabled={actionInProgress}>
Start Gateway
</Button>
<Button
variant="outline"
onClick={onRestart}
disabled={actionInProgress}
>
Restart Gateway
</Button>
</div>
</CardContent>
</Card>
) : null}
</>
)

View File

@@ -0,0 +1,76 @@
import { Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { ProviderOption } from './agents-page-types'
import { ProviderSelector } from './OpenClawControls'
import type { OpenClawCliProvider } from './openclaw-cli-providers'
interface SetupOpenClawDialogProps {
defaultProviderId: string
open: boolean
providers: ProviderOption[]
selectedProviderId: string
selectedCliProvider: OpenClawCliProvider | undefined
settingUp: boolean
onOpenChange: (open: boolean) => void
onProviderChange: (providerId: string) => void
onSetup: () => void
}
export const SetupOpenClawDialog: FC<SetupOpenClawDialogProps> = ({
defaultProviderId,
open,
providers,
selectedProviderId,
selectedCliProvider,
settingUp,
onOpenChange,
onProviderChange,
onSetup,
}) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Up OpenClaw</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<ProviderSelector
providers={providers}
defaultProviderId={defaultProviderId}
selectedId={selectedProviderId}
onSelect={onProviderChange}
hideApiKeyHint={!!selectedCliProvider}
/>
{selectedCliProvider ? (
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 text-muted-foreground text-xs">
{selectedCliProvider.description}. Clicking{' '}
<span className="font-medium">Set Up &amp; Start</span> starts the
gateway and opens a terminal to sign in.
</p>
) : null}
<Button
onClick={onSetup}
disabled={settingUp || providers.length === 0}
className="w-full"
>
{settingUp ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Setting up...
</>
) : (
'Set Up & Start'
)}
</Button>
</div>
</DialogContent>
</Dialog>
)

View File

@@ -0,0 +1,4 @@
export function buildAgentApiUrl(baseUrl: string, path: string): string {
const normalizedPath = path === '/' ? '' : path
return `${baseUrl}/agents${normalizedPath}`
}

View File

@@ -0,0 +1,88 @@
import type { AgentEntry } from './useOpenClaw'
export type HarnessAgentAdapter = 'claude' | 'codex'
export type AgentHarnessStreamEvent =
| {
type: 'text_delta'
text: string
stream: 'output' | 'thought'
rawType?: string
}
| {
type: 'tool_call'
text: string
title: string
id?: string
status?: string
rawType?: string
}
| {
type: 'status'
text: string
rawType?: string
}
| {
type: 'done'
text?: string
stopReason?: string
}
| {
type: 'error'
message: string
code?: string
}
export interface HarnessAgent {
id: string
name: string
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
permissionMode: 'approve-all'
sessionKey: string
createdAt: number
updatedAt: number
}
export interface HarnessAdapterDescriptor {
id: HarnessAgentAdapter
name: string
defaultModelId: string
defaultReasoningEffort: string
modelControl: 'runtime-supported' | 'best-effort'
models: Array<{ id: string; label: string; recommended?: boolean }>
reasoningEfforts: Array<{ id: string; label: string; recommended?: boolean }>
}
export interface CreateHarnessAgentInput {
name: string
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
}
export interface HarnessTranscriptEntry {
id: string
agentId: string
sessionId: 'main'
role: 'user' | 'assistant'
text: string
createdAt: number
}
export interface HarnessAgentHistoryPage {
agentId: string
sessionId: 'main'
items: HarnessTranscriptEntry[]
}
export function mapHarnessAgentToEntry(agent: HarnessAgent): AgentEntry {
return {
agentId: agent.id,
name: agent.name,
workspace: `${agent.adapter}:main`,
model: agent.modelId,
source: 'agent-harness',
}
}

View File

@@ -0,0 +1,172 @@
import type { NavigateFunction } from 'react-router'
import {
AGENT_CREATED_EVENT,
AGENT_DELETED_EVENT,
} from '@/lib/constants/analyticsEvents'
import { track } from '@/lib/metrics/track'
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
import type {
AgentListItem,
CreateAgentRuntime,
ProviderOption,
} from './agents-page-types'
import { findOpenClawCliProviderById } from './openclaw-cli-providers'
import type {
AgentEntry,
OpenClawAgentMutationInput,
OpenClawSetupInput,
} from './useOpenClaw'
export interface AgentPageActionInput {
createProviderId: string
createRuntime: CreateAgentRuntime
harnessModelId: string
harnessReasoningEffort: string
navigate: NavigateFunction
newName: string
selectableOpenClawProviders: ProviderOption[]
setupProviderId: string
createHarnessAgent: (input: {
name: string
adapter: HarnessAgentAdapter
modelId?: string
reasoningEffort?: string
}) => Promise<HarnessAgent>
createOpenClawAgent: (
input: OpenClawAgentMutationInput,
) => Promise<{ agent: AgentEntry }>
deleteHarnessAgent: (agentId: string) => Promise<unknown>
deleteOpenClawAgent: (agentId: string) => Promise<unknown>
setCliAuthModalOpen: (open: boolean) => void
setCreateError: (error: string | null) => void
setCreateOpen: (open: boolean) => void
setDeletingAgentKey: (key: string | null) => void
setNewName: (name: string) => void
setPageError: (error: string | null) => void
setSetupOpen: (open: boolean) => void
setupOpenClaw: (input: OpenClawSetupInput) => Promise<unknown>
}
export function createAgentPageActions(input: AgentPageActionInput) {
const runWithPageErrorHandling = async (fn: () => Promise<unknown>) => {
input.setPageError(null)
try {
await fn()
} catch (err) {
input.setPageError(err instanceof Error ? err.message : String(err))
}
}
const handleSetup = async () => {
const option = input.selectableOpenClawProviders.find(
(item) => item.id === input.setupProviderId,
)
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
const llmOption = !isCli && option ? option : undefined
await runWithPageErrorHandling(async () => {
await input.setupOpenClaw({
providerType: option?.type,
providerName: isCli ? undefined : option?.name,
baseUrl: llmOption?.baseUrl,
apiKey: llmOption?.apiKey,
modelId: option?.modelId,
})
input.setSetupOpen(false)
if (isCli) input.setCliAuthModalOpen(true)
})
}
const handleOpenClawCreate = async () => {
if (!input.newName.trim()) return
const option = input.selectableOpenClawProviders.find(
(item) => item.id === input.createProviderId,
)
const normalizedName = input.newName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
const llmOption = !isCli && option ? option : undefined
input.setCreateError(null)
try {
const result = await input.createOpenClawAgent({
name: normalizedName,
providerType: option?.type,
providerName: isCli ? undefined : option?.name,
baseUrl: llmOption?.baseUrl,
apiKey: llmOption?.apiKey,
modelId: option?.modelId,
})
input.setCreateOpen(false)
input.setNewName('')
track(AGENT_CREATED_EVENT, {
runtime: 'openclaw',
provider_type: option?.type,
})
input.navigate(`/agents/${result.agent.agentId}`)
} catch (err) {
input.setCreateError(err instanceof Error ? err.message : String(err))
}
}
const handleHarnessCreate = async () => {
if (!input.newName.trim()) return
input.setCreateError(null)
try {
const agent = await input.createHarnessAgent({
name: input.newName.trim(),
adapter: input.createRuntime as HarnessAgentAdapter,
modelId: input.harnessModelId || undefined,
reasoningEffort: input.harnessReasoningEffort || undefined,
})
input.setCreateOpen(false)
input.setNewName('')
track(AGENT_CREATED_EVENT, {
runtime: input.createRuntime,
model_id: input.harnessModelId || undefined,
reasoning_effort: input.harnessReasoningEffort || undefined,
})
input.navigate(`/agents/${agent.id}`)
} catch (err) {
input.setCreateError(err instanceof Error ? err.message : String(err))
}
}
const handleCreate = () => {
const createByRuntime: Record<CreateAgentRuntime, () => Promise<void>> = {
openclaw: handleOpenClawCreate,
claude: handleHarnessCreate,
codex: handleHarnessCreate,
}
void createByRuntime[input.createRuntime]()
}
const handleDelete = async (agent: AgentListItem) => {
input.setDeletingAgentKey(agent.key)
await runWithPageErrorHandling(async () => {
const deleteBySource: Record<
AgentListItem['source'],
(agentId: string) => Promise<unknown>
> = {
openclaw: (agentId) => input.deleteOpenClawAgent(agentId),
'agent-harness': (agentId) => input.deleteHarnessAgent(agentId),
}
await deleteBySource[agent.source](agent.agentId)
track(AGENT_DELETED_EVENT, {
runtime: agent.source,
agent_id: agent.agentId,
})
})
input.setDeletingAgentKey(null)
}
return {
handleCreate,
handleDelete,
handleSetup,
runWithPageErrorHandling,
}
}

View File

@@ -0,0 +1,173 @@
import { type Dispatch, type SetStateAction, useEffect, useMemo } from 'react'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type {
HarnessAdapterDescriptor,
HarnessAgentAdapter,
} from './agent-harness-types'
import type { CreateAgentRuntime } from './agents-page-types'
import { toProviderOptions } from './agents-page-utils'
import {
buildOpenClawCliProviderOptions,
findOpenClawCliProviderById,
useOpenClawCliProviderAuthStatus,
} from './openclaw-cli-providers'
export function useDefaultAgentName(
createOpen: boolean,
setNewName: Dispatch<SetStateAction<string>>,
): void {
useEffect(() => {
if (!createOpen) return
setNewName((current) => current || 'agent')
}, [createOpen, setNewName])
}
export function useHarnessAgentDefaults(input: {
adapters: HarnessAdapterDescriptor[]
createOpen: boolean
harnessAdapterId: HarnessAgentAdapter
setHarnessAdapterId: Dispatch<SetStateAction<HarnessAgentAdapter>>
setHarnessModelId: Dispatch<SetStateAction<string>>
setHarnessReasoningEffort: Dispatch<SetStateAction<string>>
}): void {
const {
adapters,
createOpen,
harnessAdapterId,
setHarnessAdapterId,
setHarnessModelId,
setHarnessReasoningEffort,
} = input
useEffect(() => {
if (!createOpen) return
const adapter =
adapters.find((entry) => entry.id === harnessAdapterId) ?? adapters[0]
if (!adapter) return
setHarnessAdapterId(adapter.id)
setHarnessModelId((current) => current || adapter.defaultModelId)
setHarnessReasoningEffort(
(current) => current || adapter.defaultReasoningEffort,
)
}, [
adapters,
createOpen,
harnessAdapterId,
setHarnessAdapterId,
setHarnessModelId,
setHarnessReasoningEffort,
])
}
export function useOpenClawProviderSelection(input: {
providers: LlmProviderConfig[]
defaultProviderId: string
createOpen: boolean
createRuntime: CreateAgentRuntime
createProviderId: string
setCreateProviderId: Dispatch<SetStateAction<string>>
setupOpen: boolean
setupProviderId: string
setSetupProviderId: Dispatch<SetStateAction<string>>
cliAuthModalOpen: boolean
setCliAuthModalOpen: Dispatch<SetStateAction<boolean>>
}) {
const {
providers,
defaultProviderId,
createOpen,
createRuntime,
createProviderId,
setCreateProviderId,
setupOpen,
setupProviderId,
setSetupProviderId,
cliAuthModalOpen,
setCliAuthModalOpen,
} = input
const cliProviderOptions = useMemo(
() => buildOpenClawCliProviderOptions(),
[],
)
const selectableOpenClawProviders = useMemo(
() => toProviderOptions(providers, cliProviderOptions),
[providers, cliProviderOptions],
)
useEffect(() => {
if (selectableOpenClawProviders.length === 0) return
const fallbackId =
selectableOpenClawProviders.find(
(provider) => provider.id === defaultProviderId,
)?.id ?? selectableOpenClawProviders[0].id
if (createOpen && !createProviderId) {
setCreateProviderId(fallbackId)
}
}, [
createOpen,
createProviderId,
defaultProviderId,
selectableOpenClawProviders,
setCreateProviderId,
])
useEffect(() => {
if (selectableOpenClawProviders.length === 0) return
const fallbackId =
selectableOpenClawProviders.find(
(provider) => provider.id === defaultProviderId,
)?.id ?? selectableOpenClawProviders[0].id
if (setupOpen && !setupProviderId) {
setSetupProviderId(fallbackId)
}
}, [
defaultProviderId,
selectableOpenClawProviders,
setSetupProviderId,
setupOpen,
setupProviderId,
])
const selectedCreateOption = selectableOpenClawProviders.find(
(provider) => provider.id === createProviderId,
)
const selectedCliProvider = selectedCreateOption
? findOpenClawCliProviderById(selectedCreateOption.type)
: undefined
const selectedSetupOption = selectableOpenClawProviders.find(
(provider) => provider.id === setupProviderId,
)
const selectedSetupCliProvider = selectedSetupOption
? findOpenClawCliProviderById(selectedSetupOption.type)
: undefined
const activeCliProvider =
(setupOpen && selectedSetupCliProvider) ||
(createOpen && createRuntime === 'openclaw' && selectedCliProvider) ||
undefined
const {
data: cliAuthStatus,
isLoading: cliAuthLoading,
error: cliAuthError,
} = useOpenClawCliProviderAuthStatus(
activeCliProvider?.id ?? '',
!!activeCliProvider,
)
useEffect(() => {
if (cliAuthModalOpen && cliAuthStatus?.loggedIn) {
setCliAuthModalOpen(false)
}
}, [cliAuthModalOpen, cliAuthStatus?.loggedIn, setCliAuthModalOpen])
return {
selectableOpenClawProviders,
selectedCliProvider,
selectedSetupCliProvider,
authTerminalProvider: selectedSetupCliProvider ?? selectedCliProvider,
cliAuthStatus,
cliAuthLoading,
cliAuthError,
}
}

View File

@@ -0,0 +1,119 @@
import type { HarnessAgentAdapter } from './agent-harness-types'
import type { GatewayLifecycleAction, OpenClawStatus } from './useOpenClaw'
export type CreateAgentRuntime = 'openclaw' | HarnessAgentAdapter
export interface ProviderOption {
id: string
type: string
name: string
modelId: string
baseUrl?: string
apiKey?: string
}
export interface AgentListItem {
key: string
agentId: string
name: string
source: 'openclaw' | 'agent-harness'
runtimeLabel: string
modelLabel: string
detail: string
canChat: boolean
canDelete: boolean
}
export interface GatewayUiState {
canManageAgents: boolean
controlPlaneDegraded: boolean
controlPlaneBusy: boolean
}
export const DEFAULT_HARNESS_ADAPTER: HarnessAgentAdapter = 'claude'
export const DEFAULT_CREATE_RUNTIME: CreateAgentRuntime = 'openclaw'
export const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
setup: 'Setting up OpenClaw...',
start: 'Starting gateway...',
stop: 'Stopping gateway...',
restart: 'Restarting gateway...',
reconnect: 'Restoring gateway connection...',
}
export const CONTROL_PLANE_COPY: Record<
OpenClawStatus['controlPlaneStatus'],
{
badgeVariant: 'default' | 'secondary' | 'outline' | 'destructive'
badgeLabel: string
title: string
description: string
}
> = {
connected: {
badgeVariant: 'default',
badgeLabel: 'Control Plane Ready',
title: 'Gateway Connected',
description: 'OpenClaw can create, manage, and chat with agents normally.',
},
connecting: {
badgeVariant: 'secondary',
badgeLabel: 'Connecting',
title: 'Connecting to Gateway',
description:
'BrowserOS is establishing the OpenClaw control channel for agent operations.',
},
reconnecting: {
badgeVariant: 'secondary',
badgeLabel: 'Reconnecting',
title: 'Reconnecting Control Plane',
description:
'The gateway process is up, but BrowserOS is restoring the control channel.',
},
recovering: {
badgeVariant: 'secondary',
badgeLabel: 'Recovering',
title: 'Recovering Gateway Connection',
description:
'BrowserOS detected a control-plane fault and is trying a safe recovery path.',
},
disconnected: {
badgeVariant: 'outline',
badgeLabel: 'Disconnected',
title: 'Gateway Disconnected',
description: 'The gateway process is not available to BrowserOS right now.',
},
failed: {
badgeVariant: 'destructive',
badgeLabel: 'Needs Attention',
title: 'Gateway Recovery Failed',
description:
'BrowserOS could not restore the OpenClaw control channel automatically.',
},
}
export const FALLBACK_CONTROL_PLANE_COPY = {
badgeVariant: 'outline' as const,
badgeLabel: 'Unknown',
title: 'Gateway State Unknown',
description:
'BrowserOS received a gateway status it does not recognize yet. Refreshing or reconnecting should restore a known state.',
}
export const RECOVERY_REASON_COPY: Record<
NonNullable<OpenClawStatus['lastRecoveryReason']>,
string
> = {
transient_disconnect:
'The control channel dropped briefly and BrowserOS is retrying it.',
signature_expired:
'The gateway rejected the signed device handshake because its clock drifted.',
pairing_required:
'The gateway asked BrowserOS to approve its local device identity again.',
token_mismatch:
'BrowserOS had to reload the gateway token before reconnecting.',
container_not_ready:
'The OpenClaw gateway process is not ready yet, so control-plane recovery cannot start.',
unknown:
'BrowserOS hit an unexpected gateway error and could not classify it cleanly.',
}

View File

@@ -0,0 +1,172 @@
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
import {
type AgentListItem,
CONTROL_PLANE_COPY,
FALLBACK_CONTROL_PLANE_COPY,
type GatewayUiState,
LIFECYCLE_BANNER_COPY,
type ProviderOption,
RECOVERY_REASON_COPY,
} from './agents-page-types'
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
import {
type AgentEntry,
type GatewayLifecycleAction,
getModelDisplayName,
type OpenClawStatus,
} from './useOpenClaw'
export function getControlPlaneCopy(
status: OpenClawStatus['controlPlaneStatus'],
) {
return CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
}
export function getRecoveryDetail(status: OpenClawStatus): string | null {
if (!status.lastRecoveryReason && !status.lastGatewayError) return null
const detail = status.lastRecoveryReason
? RECOVERY_REASON_COPY[status.lastRecoveryReason]
: null
if (status.lastGatewayError && detail) {
return `${detail} Latest gateway error: ${status.lastGatewayError}`
}
return status.lastGatewayError ?? detail
}
export function formatHarnessAdapter(adapter: HarnessAgentAdapter): string {
return adapter === 'claude' ? 'Claude Code' : 'Codex'
}
export function toProviderOptions(
providers: LlmProviderConfig[],
cliProviders: ProviderOption[],
): ProviderOption[] {
return [...getOpenClawSupportedProviders(providers), ...cliProviders]
}
export function toOpenClawListItem(
agent: AgentEntry,
canManageAgents: boolean,
): AgentListItem {
return {
key: `openclaw:${agent.agentId}`,
agentId: agent.agentId,
name: agent.name,
source: 'openclaw',
runtimeLabel: 'OpenClaw',
modelLabel: getModelDisplayName(agent.model) ?? 'default',
detail: agent.workspace,
canChat: canManageAgents,
canDelete: canManageAgents && agent.agentId !== 'main',
}
}
export function toHarnessListItem(agent: HarnessAgent): AgentListItem {
return {
key: `agent-harness:${agent.id}`,
agentId: agent.id,
name: agent.name,
source: 'agent-harness',
runtimeLabel: formatHarnessAdapter(agent.adapter),
modelLabel: agent.modelId ?? 'default',
detail: `${agent.adapter}:main`,
canChat: true,
canDelete: true,
}
}
export function getGatewayUiState(
status: OpenClawStatus | null,
): GatewayUiState {
if (!status) {
return {
canManageAgents: false,
controlPlaneDegraded: false,
controlPlaneBusy: false,
}
}
const controlPlaneBusy =
status.controlPlaneStatus === 'connecting' ||
status.controlPlaneStatus === 'reconnecting' ||
status.controlPlaneStatus === 'recovering'
return {
canManageAgents:
status.status === 'running' && status.controlPlaneStatus === 'connected',
controlPlaneBusy,
controlPlaneDegraded:
status.status === 'running' && status.controlPlaneStatus !== 'connected',
}
}
export function getLifecycleBanner(
action: GatewayLifecycleAction | null,
): string | null {
return action ? LIFECYCLE_BANNER_COPY[action] : null
}
export function canManageOpenClawAgents(
state: GatewayUiState,
lifecyclePending: boolean,
): boolean {
return state.canManageAgents && !lifecyclePending
}
export function shouldShowControlPlaneDegraded(
state: GatewayUiState,
lifecyclePending: boolean,
): boolean {
return state.controlPlaneDegraded && !lifecyclePending
}
export function getControlPlaneCopyForStatus(status: OpenClawStatus | null) {
return status
? getControlPlaneCopy(status.controlPlaneStatus)
: FALLBACK_CONTROL_PLANE_COPY
}
export function getVisibleOpenClawAgents(
enabled: boolean,
agents: AgentEntry[],
): AgentEntry[] {
return enabled ? agents : []
}
export function getAgentsLoading(input: {
statusLoading: boolean
adaptersLoading: boolean
harnessAgentsLoading: boolean
openClawAgentsEnabled: boolean
openClawAgentsLoading: boolean
}): boolean {
return (
input.statusLoading ||
input.adaptersLoading ||
input.harnessAgentsLoading ||
(input.openClawAgentsEnabled && input.openClawAgentsLoading)
)
}
export function getInlineError(input: {
lifecyclePending: boolean
pageError: string | null
statusError: Error | null
openClawAgentsError: Error | null
adaptersError: Error | null
harnessAgentsError: Error | null
}): string | null {
if (input.lifecyclePending) return null
return (
input.pageError ??
input.statusError?.message ??
input.openClawAgentsError?.message ??
input.adaptersError?.message ??
input.harnessAgentsError?.message ??
null
)
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'bun:test'
import { buildAgentApiUrl } from './agent-api-url'
import { mapHarnessAgentToEntry } from './agent-harness-types'
describe('mapHarnessAgentToEntry', () => {
it('maps created harness agents into chat-compatible entries', () => {
expect(
mapHarnessAgentToEntry({
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}),
).toEqual({
agentId: 'agent-1',
name: 'Review bot',
workspace: 'codex:main',
model: 'gpt-5.5',
source: 'agent-harness',
})
})
})
describe('buildAgentApiUrl', () => {
it('does not add a trailing slash for the harness root route', () => {
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/')).toBe(
'http://127.0.0.1:9105/agents',
)
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/adapters')).toBe(
'http://127.0.0.1:9105/agents/adapters',
)
})
})

View File

@@ -0,0 +1,162 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import { buildAgentApiUrl } from './agent-api-url'
import {
type AgentHarnessStreamEvent,
type CreateHarnessAgentInput,
type HarnessAdapterDescriptor,
type HarnessAgent,
type HarnessAgentHistoryPage,
mapHarnessAgentToEntry,
} from './agent-harness-types'
export type { AgentHarnessStreamEvent }
const AGENT_QUERY_KEYS = {
adapters: 'agent-harness-adapters',
agents: 'agent-harness-agents',
} as const
async function agentsFetch<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(buildAgentApiUrl(baseUrl, path), init)
if (!res.ok) {
let message = `Request failed with status ${res.status}`
try {
const body = (await res.json()) as { error?: string }
if (body.error) message = body.error
} catch {}
throw new Error(message)
}
return res.json() as Promise<T>
}
export function useAgentAdapters(enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<HarnessAdapterDescriptor[], Error>({
queryKey: [AGENT_QUERY_KEYS.adapters, baseUrl],
queryFn: async () => {
const data = await agentsFetch<{ adapters: HarnessAdapterDescriptor[] }>(
baseUrl as string,
'/adapters',
)
return data.adapters ?? []
},
enabled: Boolean(baseUrl) && !urlLoading && enabled,
})
return {
adapters: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useHarnessAgents(enabled = true) {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<HarnessAgent[], Error>({
queryKey: [AGENT_QUERY_KEYS.agents, baseUrl],
queryFn: async () => {
const data = await agentsFetch<{ agents: HarnessAgent[] }>(
baseUrl as string,
'/',
)
return data.agents ?? []
},
enabled: Boolean(baseUrl) && !urlLoading && enabled,
})
return {
agents: (query.data ?? []).map(mapHarnessAgentToEntry),
harnessAgents: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useCreateHarnessAgent() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: CreateHarnessAgentInput) => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
const data = await agentsFetch<{ agent: HarnessAgent }>(baseUrl, '/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return data.agent
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [AGENT_QUERY_KEYS.agents],
})
},
})
}
export function useDeleteHarnessAgent() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (agentId: string) => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
return agentsFetch<{ success: boolean }>(
baseUrl,
`/${encodeURIComponent(agentId)}`,
{ method: 'DELETE' },
)
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [AGENT_QUERY_KEYS.agents],
})
},
})
}
export async function chatWithHarnessAgent(
agentId: string,
message: string,
signal?: AbortSignal,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
signal,
})
}
export async function fetchHarnessAgentHistory(
agentId: string,
): Promise<HarnessAgentHistoryPage> {
const baseUrl = await getAgentServerUrl()
return agentsFetch<HarnessAgentHistoryPage>(
baseUrl,
`/${encodeURIComponent(agentId)}/sessions/main/history`,
)
}

View File

@@ -7,6 +7,7 @@ export interface AgentEntry {
name: string
workspace: string
model?: unknown
source?: 'openclaw' | 'agent-harness'
}
export interface OpenClawStatus {
@@ -98,7 +99,10 @@ async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
return data.agents ?? []
return (data.agents ?? []).map((agent) => ({
...agent,
source: 'openclaw',
}))
}
async function invalidateOpenClawQueries(

View File

@@ -75,6 +75,12 @@ export const MCP_EXTERNAL_ACCESS_DISABLED_EVENT =
/** @public */
export const MCP_SERVER_RESTARTED_EVENT = 'settings.mcp_server.restarted'
/** @public */
export const AGENT_CREATED_EVENT = 'agents.agent.created'
/** @public */
export const AGENT_DELETED_EVENT = 'agents.agent.deleted'
/** @public */
export const NEW_SCHEDULED_TASK_CREATED_EVENT =
'settings.scheduled_task.created'

View File

@@ -82,7 +82,6 @@
"@ai-sdk/openai-compatible": "^2.0.30",
"@ai-sdk/provider": "^3.0.8",
"@browseros-ai/agent-sdk": "workspace:*",
"@huggingface/transformers": "^3.4.0",
"@browseros/cdp-protocol": "workspace:*",
"@browseros/shared": "workspace:*",
"@google/gemini-cli-core": "^0.16.0",
@@ -90,9 +89,11 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.6",
"@hono/zod-validator": "^0.4.3",
"@huggingface/transformers": "^3.4.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@sentry/bun": "^10.31.0",
"acpx": "0.6.1",
"ai": "^6.0.94",
"chrome-devtools-frontend": "^1.0.1577886",
"commander": "^14.0.1",

View File

@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { AGENT_HARNESS_LIMITS } from '@browseros/shared/constants/limits'
import { type Context, Hono } from 'hono'
import { stream } from 'hono/streaming'
import {
AGENT_ADAPTER_CATALOG,
isAgentAdapter,
isSupportedAgentModel,
isSupportedReasoningEffort,
} from '../../lib/agents/agent-catalog'
import type {
AgentAdapter,
AgentDefinition,
} from '../../lib/agents/agent-types'
import type { AgentHistoryPage, AgentStreamEvent } from '../../lib/agents/types'
import {
AgentHarnessService,
UnknownAgentError,
} from '../services/agents/agent-harness-service'
import type { Env } from '../types'
type AgentRouteService = {
listAgents(): Promise<AgentDefinition[]>
createAgent(input: {
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
}): Promise<AgentDefinition>
getAgent(agentId: string): Promise<AgentDefinition | null>
deleteAgent(agentId: string): Promise<boolean>
getHistory(agentId: string): Promise<AgentHistoryPage>
send(input: {
agentId: string
message: string
signal?: AbortSignal
}): Promise<ReadableStream<AgentStreamEvent>>
}
type AgentRouteDeps = {
service?: AgentRouteService
}
export function createAgentRoutes(deps: AgentRouteDeps = {}) {
const service = deps.service ?? new AgentHarnessService()
return new Hono<Env>()
.get('/adapters', (c) => c.json({ adapters: AGENT_ADAPTER_CATALOG }))
.get('/', async (c) => c.json({ agents: await service.listAgents() }))
.post('/', async (c) => {
const parsed = await parseCreateAgentBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
try {
return c.json({ agent: await service.createAgent(parsed) })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId', async (c) => {
try {
const agent = await service.getAgent(c.req.param('agentId'))
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
return c.json({ agent })
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.delete('/:agentId', async (c) => {
try {
return c.json({
success: await service.deleteAgent(c.req.param('agentId')),
})
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.get('/:agentId/sessions/main/history', async (c) => {
try {
return c.json(await service.getHistory(c.req.param('agentId')))
} catch (err) {
return handleAgentRouteError(c, err)
}
})
.post('/:agentId/chat', async (c) => {
const agentId = c.req.param('agentId')
const parsed = await parseChatBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
let eventStream: ReadableStream<AgentStreamEvent>
try {
eventStream = await service.send({
agentId,
message: parsed.message,
signal: c.req.raw.signal,
})
} catch (err) {
return handleAgentRouteError(c, err)
}
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('X-Session-Id', 'main')
return stream(c, async (s) => {
const reader = eventStream.getReader()
const encoder = new TextEncoder()
let completed = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
await s.write(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
}
await s.write(encoder.encode('data: [DONE]\n\n'))
completed = true
} finally {
if (completed) {
reader.releaseLock()
} else {
await reader.cancel('BrowserOS HTTP stream ended').catch(() => {})
}
}
})
})
}
async function parseCreateAgentBody(c: Context<Env>): Promise<
| {
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
}
| { error: string }
> {
const body = await readJsonBody(c)
if ('error' in body) return body
const record = body.value
const name = typeof record.name === 'string' ? record.name.trim() : ''
if (!name) return { error: 'Name is required' }
if (name.length > AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS) {
return {
error: `Name must be ${AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS} characters or fewer`,
}
}
if (!isAgentAdapter(record.adapter)) {
return { error: 'Invalid adapter' }
}
const modelId =
typeof record.modelId === 'string' && record.modelId.trim()
? record.modelId.trim()
: undefined
const reasoningEffort =
typeof record.reasoningEffort === 'string' && record.reasoningEffort.trim()
? record.reasoningEffort.trim()
: undefined
if (!isSupportedAgentModel(record.adapter, modelId)) {
return { error: 'Invalid modelId' }
}
if (!isSupportedReasoningEffort(record.adapter, reasoningEffort)) {
return { error: 'Invalid reasoningEffort' }
}
return {
name,
adapter: record.adapter,
modelId,
reasoningEffort,
}
}
async function parseChatBody(
c: Context<Env>,
): Promise<{ message: string } | { error: string }> {
const body = await readJsonBody(c)
if ('error' in body) return body
const message =
typeof body.value.message === 'string' ? body.value.message.trim() : ''
return message ? { message } : { error: 'Message is required' }
}
async function readJsonBody(
c: Context<Env>,
): Promise<{ value: Record<string, unknown> } | { error: string }> {
let body: unknown
try {
body = await c.req.json()
} catch {
return { error: 'Invalid JSON body' }
}
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return { error: 'JSON object body is required' }
}
return { value: body as Record<string, unknown> }
}
function handleAgentRouteError(c: Context<Env>, err: unknown) {
if (err instanceof UnknownAgentError) {
return c.json({ error: err.message }, 404)
}
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}

View File

@@ -24,6 +24,7 @@ import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
import { createAclRoutes } from './routes/acl'
import { createAgentRoutes } from './routes/agents'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
import { createHealthRoute } from './routes/health'
@@ -128,6 +129,10 @@ export async function createHttpServer(config: HttpServerConfig) {
.use('/*', requireTrustedAppOrigin())
.route('/', createMonitoringRoutes())
const agentRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createAgentRoutes())
const app = new Hono<Env>()
.use('/*', cors(defaultCorsConfig))
.route('/health', createHealthRoute({ browser }))
@@ -202,6 +207,7 @@ export async function createHttpServer(config: HttpServerConfig) {
browserosId,
}),
)
.route('/agents', agentRoutes)
.route('/claw', clawRoutes)
// Error handler

View File

@@ -0,0 +1,150 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { AcpxRuntime } from '../../../lib/agents/acpx-runtime'
import type { AgentDefinition } from '../../../lib/agents/agent-types'
import {
type CreateAgentInput,
FileAgentStore,
} from '../../../lib/agents/file-agent-store'
import { FileTranscriptStore } from '../../../lib/agents/file-transcript-store'
import type {
AgentHistoryPage,
AgentRuntime,
AgentStreamEvent,
} from '../../../lib/agents/types'
export class AgentHarnessService {
private readonly agentStore: FileAgentStore
private readonly transcriptStore: FileTranscriptStore
private readonly runtime: AgentRuntime
constructor(
deps: {
agentStore?: FileAgentStore
transcriptStore?: FileTranscriptStore
runtime?: AgentRuntime
} = {},
) {
this.agentStore = deps.agentStore ?? new FileAgentStore()
this.transcriptStore = deps.transcriptStore ?? new FileTranscriptStore()
this.runtime = deps.runtime ?? new AcpxRuntime()
}
listAgents(): Promise<AgentDefinition[]> {
return this.agentStore.list()
}
createAgent(input: CreateAgentInput): Promise<AgentDefinition> {
return this.agentStore.create(input)
}
deleteAgent(agentId: string): Promise<boolean> {
return this.agentStore.delete(agentId)
}
getAgent(agentId: string): Promise<AgentDefinition | null> {
return this.agentStore.get(agentId)
}
async getHistory(agentId: string): Promise<AgentHistoryPage> {
const agent = await this.requireAgent(agentId)
return {
agentId: agent.id,
sessionId: 'main',
items: await this.transcriptStore.list({
agentId: agent.id,
sessionId: 'main',
}),
}
}
async send(input: {
agentId: string
message: string
signal?: AbortSignal
}): Promise<ReadableStream<AgentStreamEvent>> {
const agent = await this.requireAgent(input.agentId)
await this.transcriptStore.append({
agentId: agent.id,
sessionId: 'main',
role: 'user',
text: input.message,
})
const runtimeStream = await this.runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: input.message,
permissionMode: agent.permissionMode,
signal: input.signal,
})
return this.persistAssistantTranscript(agent, runtimeStream)
}
private async requireAgent(agentId: string): Promise<AgentDefinition> {
const agent = await this.agentStore.get(agentId)
if (!agent) {
throw new UnknownAgentError(agentId)
}
return agent
}
private persistAssistantTranscript(
agent: AgentDefinition,
stream: ReadableStream<AgentStreamEvent>,
): ReadableStream<AgentStreamEvent> {
let reader: ReadableStreamDefaultReader<AgentStreamEvent> | null = null
let assistantText = ''
let transcriptFlushed = false
const flushAssistantTranscript = async () => {
if (transcriptFlushed || !assistantText.trim()) return
transcriptFlushed = true
await this.transcriptStore.append({
agentId: agent.id,
sessionId: 'main',
role: 'assistant',
text: assistantText,
})
}
return new ReadableStream<AgentStreamEvent>({
start: async (controller) => {
reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value.type === 'text_delta' && value.stream === 'output') {
assistantText += value.text
} else if (value.type === 'done' && !assistantText && value.text) {
assistantText = value.text
}
controller.enqueue(value)
}
await flushAssistantTranscript()
controller.close()
} catch (err) {
controller.error(err)
} finally {
reader?.releaseLock()
}
},
cancel: async () => {
await flushAssistantTranscript()
await reader?.cancel('BrowserOS stream cancelled')
},
})
}
}
export class UnknownAgentError extends Error {
constructor(readonly agentId: string) {
super(`Unknown agent: ${agentId}`)
this.name = 'UnknownAgentError'
}
}

View File

@@ -0,0 +1,310 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { join } from 'node:path'
import {
type AcpRuntimeEvent,
type AcpRuntimeHandle,
type AcpRuntimeOptions,
type AcpRuntimeTurn,
type AcpRuntimeTurnResult,
type AcpRuntime as AcpxCoreRuntime,
createAcpRuntime,
createAgentRegistry,
createRuntimeStore,
} from 'acpx/runtime'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import type {
AgentHistoryPage,
AgentPromptInput,
AgentRuntime,
AgentSession,
AgentStatus,
AgentStreamEvent,
} from './types'
type AcpxRuntimeOptions = {
cwd?: string
stateDir?: string
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
}
export class AcpxRuntime implements AgentRuntime {
private readonly cwd: string
private readonly stateDir: string
private readonly runtimeFactory: (
options: AcpRuntimeOptions,
) => AcpxCoreRuntime
private readonly runtimes = new Map<string, AcpxCoreRuntime>()
constructor(options: AcpxRuntimeOptions = {}) {
this.cwd = options.cwd ?? process.cwd()
this.stateDir =
options.stateDir ??
process.env.BROWSEROS_ACPX_STATE_DIR ??
join(getBrowserosDir(), 'agents', 'acpx')
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
}
async status(): Promise<AgentStatus> {
return { state: 'unknown', message: 'acpx status is checked on send' }
}
async listSessions(
input: AgentPromptInput['agent'],
): Promise<AgentSession[]> {
return [{ agentId: input.id, id: 'main', updatedAt: input.updatedAt }]
}
async getHistory(input: {
agent: AgentPromptInput['agent']
sessionId: 'main'
}): Promise<AgentHistoryPage> {
return { agentId: input.agent.id, sessionId: input.sessionId, items: [] }
}
async send(
input: AgentPromptInput,
): Promise<ReadableStream<AgentStreamEvent>> {
logger.info('Agent harness acpx send requested', {
agentId: input.agent.id,
adapter: input.agent.adapter,
sessionId: input.sessionId,
sessionKey: input.sessionKey,
cwd: this.cwd,
stateDir: this.stateDir,
permissionMode: input.permissionMode,
modelId: input.agent.modelId,
reasoningEffort: input.agent.reasoningEffort,
messageLength: input.message.length,
})
const runtime = this.getRuntime({
cwd: this.cwd,
permissionMode: input.permissionMode,
nonInteractivePermissions: 'fail',
})
return createAcpxEventStream(runtime, input, this.cwd)
}
private getRuntime(input: {
cwd: string
permissionMode: AcpRuntimeOptions['permissionMode']
nonInteractivePermissions: AcpRuntimeOptions['nonInteractivePermissions']
}): AcpxCoreRuntime {
const key = JSON.stringify(input)
const existing = this.runtimes.get(key)
if (existing) return existing
const runtime = this.runtimeFactory({
cwd: input.cwd,
sessionStore: createRuntimeStore({ stateDir: this.stateDir }),
agentRegistry: createAgentRegistry(),
permissionMode: input.permissionMode,
nonInteractivePermissions: input.nonInteractivePermissions,
})
this.runtimes.set(key, runtime)
logger.debug('Agent harness acpx runtime created', {
cwd: input.cwd,
stateDir: this.stateDir,
permissionMode: input.permissionMode,
nonInteractivePermissions: input.nonInteractivePermissions,
})
return runtime
}
}
function createAcpxEventStream(
runtime: AcpxCoreRuntime,
input: AgentPromptInput,
cwd: string,
): ReadableStream<AgentStreamEvent> {
let activeTurn: AcpRuntimeTurn | null = null
return new ReadableStream<AgentStreamEvent>({
start(controller) {
const run = async () => {
const handle = await runtime.ensureSession({
sessionKey: input.sessionKey,
agent: input.agent.adapter,
mode: 'persistent',
cwd,
})
logger.info('Agent harness acpx session ensured', {
agentId: input.agent.id,
adapter: input.agent.adapter,
sessionKey: input.sessionKey,
backendSessionId: handle.backendSessionId,
agentSessionId: handle.agentSessionId,
acpxRecordId: handle.acpxRecordId,
cwd,
})
for (const event of await applyRuntimeControls(
runtime,
handle,
input,
)) {
controller.enqueue(event)
}
const turn = runtime.startTurn({
handle,
text: input.message,
mode: 'prompt',
requestId: crypto.randomUUID(),
timeoutMs: input.timeoutMs,
signal: input.signal,
})
activeTurn = turn
for await (const event of turn.events) {
controller.enqueue(mapRuntimeEvent(event))
}
controller.enqueue(mapTurnResult(await turn.result))
logger.info('Agent harness acpx turn completed', {
agentId: input.agent.id,
adapter: input.agent.adapter,
sessionKey: input.sessionKey,
})
controller.close()
}
void run().catch((err) => {
logger.error('Agent harness acpx turn failed', {
agentId: input.agent.id,
adapter: input.agent.adapter,
sessionKey: input.sessionKey,
error: err instanceof Error ? err.message : String(err),
})
controller.enqueue({
type: 'error',
message: err instanceof Error ? err.message : String(err),
})
controller.close()
})
},
cancel() {
void activeTurn?.cancel({ reason: 'BrowserOS stream cancelled' })
},
})
}
async function applyRuntimeControls(
runtime: AcpxCoreRuntime,
handle: AcpRuntimeHandle,
input: AgentPromptInput,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = []
if (input.agent.modelId && input.agent.modelId !== 'default') {
events.push({
type: 'status',
text: 'Requested model is stored on the BrowserOS agent, but this acpx/runtime version does not expose public model control. Using adapter default.',
})
}
if (!input.agent.reasoningEffort) return events
const key = input.agent.adapter === 'codex' ? 'reasoning_effort' : 'effort'
if (!runtime.setConfigOption) {
events.push({
type: 'status',
text: `Requested ${key}=${input.agent.reasoningEffort}, but this acpx/runtime version does not expose config control.`,
})
return events
}
try {
await runtime.setConfigOption({
handle,
key,
value: input.agent.reasoningEffort,
})
logger.debug('Agent harness acpx config applied', {
agentId: input.agent.id,
adapter: input.agent.adapter,
sessionKey: input.sessionKey,
key,
value: input.agent.reasoningEffort,
})
} catch (err) {
logger.warn('Agent harness acpx config unavailable', {
agentId: input.agent.id,
adapter: input.agent.adapter,
sessionKey: input.sessionKey,
key,
value: input.agent.reasoningEffort,
error: err instanceof Error ? err.message : String(err),
})
events.push({
type: 'status',
text: `Could not apply ${key}=${input.agent.reasoningEffort}; continuing with the adapter default. ${
err instanceof Error ? err.message : String(err)
}`,
})
}
return events
}
function mapRuntimeEvent(event: AcpRuntimeEvent): AgentStreamEvent {
switch (event.type) {
case 'text_delta':
return {
type: 'text_delta',
text: event.text,
stream: event.stream ?? 'output',
rawType: event.tag,
}
case 'tool_call':
return {
type: 'tool_call',
text: event.text,
title: event.title ?? 'tool call',
id: event.toolCallId,
status: event.status,
rawType: event.tag,
}
case 'status':
return {
type: 'status',
text: event.text,
rawType: event.tag,
}
case 'done':
return {
type: 'done',
stopReason: event.stopReason,
}
case 'error':
return {
type: 'error',
message: event.message,
code: event.code,
}
default: {
const exhaustive: never = event
return exhaustive
}
}
}
function mapTurnResult(result: AcpRuntimeTurnResult): AgentStreamEvent {
switch (result.status) {
case 'completed':
return { type: 'done', stopReason: result.stopReason }
case 'cancelled':
return { type: 'done', stopReason: result.stopReason ?? 'cancelled' }
case 'failed':
return {
type: 'error',
message: result.error.message,
code: result.error.code,
}
default: {
const exhaustive: never = result
return exhaustive
}
}
}

View File

@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AgentAdapter, AgentAdapterDescriptor } from './agent-types'
export const AGENT_ADAPTER_CATALOG: AgentAdapterDescriptor[] = [
{
id: 'claude',
name: 'Claude Code',
defaultModelId: 'haiku',
defaultReasoningEffort: 'medium',
modelControl: 'best-effort',
models: [
{ id: 'opus', label: 'Opus' },
{ id: 'sonnet', label: 'Sonnet' },
{ id: 'haiku', label: 'Haiku', recommended: true },
],
reasoningEfforts: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium', recommended: true },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra high' },
{ id: 'max', label: 'Max' },
],
},
{
id: 'codex',
name: 'Codex',
defaultModelId: 'gpt-5.5',
defaultReasoningEffort: 'medium',
modelControl: 'best-effort',
models: [{ id: 'gpt-5.5', label: 'GPT-5.5', recommended: true }],
reasoningEfforts: [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium', recommended: true },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra high' },
],
},
]
export function getAgentAdapterDescriptor(
adapter: AgentAdapter,
): AgentAdapterDescriptor | null {
return AGENT_ADAPTER_CATALOG.find((entry) => entry.id === adapter) ?? null
}
export function isAgentAdapter(value: unknown): value is AgentAdapter {
return value === 'claude' || value === 'codex'
}
export function resolveDefaultModelId(adapter: AgentAdapter): string {
return getAgentAdapterDescriptor(adapter)?.defaultModelId ?? 'default'
}
export function resolveDefaultReasoningEffort(adapter: AgentAdapter): string {
return getAgentAdapterDescriptor(adapter)?.defaultReasoningEffort ?? 'medium'
}
export function isSupportedAgentModel(
adapter: AgentAdapter,
modelId: string | undefined,
): boolean {
if (!modelId || modelId === 'default') return true
const descriptor = getAgentAdapterDescriptor(adapter)
return Boolean(descriptor?.models.some((model) => model.id === modelId))
}
export function isSupportedReasoningEffort(
adapter: AgentAdapter,
effort: string | undefined,
): boolean {
if (!effort) return true
const descriptor = getAgentAdapterDescriptor(adapter)
return Boolean(
descriptor?.reasoningEfforts.some((option) => option.id === effort),
)
}

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export type AgentAdapter = 'claude' | 'codex'
export type AgentPermissionMode = 'approve-all'
export interface AgentDefinition {
id: string
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
permissionMode: AgentPermissionMode
sessionKey: string
createdAt: number
updatedAt: number
}
export interface AgentAdapterDescriptor {
id: AgentAdapter
name: string
defaultModelId: string
defaultReasoningEffort: string
modelControl: 'runtime-supported' | 'best-effort'
models: Array<{
id: string
label: string
recommended?: boolean
}>
reasoningEfforts: Array<{
id: string
label: string
recommended?: boolean
}>
}
export interface AgentTranscriptEntry {
id: string
agentId: string
sessionId: 'main'
role: 'user' | 'assistant'
text: string
createdAt: number
}

View File

@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import {
resolveDefaultModelId,
resolveDefaultReasoningEffort,
} from './agent-catalog'
import type { AgentAdapter, AgentDefinition } from './agent-types'
interface AgentStoreFile {
version: 1
agents: AgentDefinition[]
}
export interface CreateAgentInput {
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
}
export class FileAgentStore {
private readonly filePath: string
private writeQueue: Promise<unknown> = Promise.resolve()
constructor(options: { filePath?: string } = {}) {
this.filePath =
options.filePath ??
join(getBrowserosDir(), 'agents', 'harness', 'agents.json')
}
async list(): Promise<AgentDefinition[]> {
const file = await this.read()
const agents = [...file.agents].sort((a, b) => b.updatedAt - a.updatedAt)
logger.debug('Agent harness store listed agents', {
count: agents.length,
filePath: this.filePath,
})
return agents
}
async get(id: string): Promise<AgentDefinition | null> {
const file = await this.read()
const agent = file.agents.find((entry) => entry.id === id) ?? null
logger.debug('Agent harness store loaded agent', {
agentId: id,
found: Boolean(agent),
adapter: agent?.adapter,
filePath: this.filePath,
})
return agent
}
async create(input: CreateAgentInput): Promise<AgentDefinition> {
return this.withWriteLock(async () => {
const now = Date.now()
const id = randomUUID()
const agent: AgentDefinition = {
id,
name: input.name.trim(),
adapter: input.adapter,
modelId: input.modelId ?? resolveDefaultModelId(input.adapter),
reasoningEffort:
input.reasoningEffort ?? resolveDefaultReasoningEffort(input.adapter),
permissionMode: 'approve-all',
sessionKey: `agent:${id}:main`,
createdAt: now,
updatedAt: now,
}
const file = await this.read()
await this.write({ ...file, agents: [...file.agents, agent] })
logger.info('Agent harness store created agent', {
agentId: agent.id,
name: agent.name,
adapter: agent.adapter,
modelId: agent.modelId,
reasoningEffort: agent.reasoningEffort,
sessionKey: agent.sessionKey,
filePath: this.filePath,
})
return agent
})
}
async delete(id: string): Promise<boolean> {
return this.withWriteLock(async () => {
const file = await this.read()
const agents = file.agents.filter((agent) => agent.id !== id)
if (agents.length === file.agents.length) return false
await this.write({ ...file, agents })
logger.info('Agent harness store deleted agent', {
agentId: id,
filePath: this.filePath,
})
return true
})
}
private async read(): Promise<AgentStoreFile> {
try {
const raw = await readFile(this.filePath, 'utf8')
const parsed = JSON.parse(raw) as AgentStoreFile
if (parsed.version !== 1 || !Array.isArray(parsed.agents)) {
return emptyStoreFile()
}
return parsed
} catch (err) {
if (isNotFoundError(err)) return emptyStoreFile()
throw err
}
}
private async write(file: AgentStoreFile): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true })
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
await writeFile(tmpPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8')
await rename(tmpPath, this.filePath)
}
private withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
const result = this.writeQueue.then(fn, fn)
this.writeQueue = result.then(
() => undefined,
() => undefined,
)
return result
}
}
function emptyStoreFile(): AgentStoreFile {
return { version: 1, agents: [] }
}
function isNotFoundError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ENOENT'
)
}

View File

@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randomUUID } from 'node:crypto'
import { appendFile, mkdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import type { AgentTranscriptEntry } from './agent-types'
export interface TranscriptListInput {
agentId: string
sessionId: 'main'
}
export interface TranscriptAppendInput {
agentId: string
sessionId: 'main'
role: 'user' | 'assistant'
text: string
}
export class FileTranscriptStore {
private readonly rootDir: string
constructor(options: { rootDir?: string } = {}) {
this.rootDir =
options.rootDir ??
join(getBrowserosDir(), 'agents', 'harness', 'transcripts')
}
async append(input: TranscriptAppendInput): Promise<AgentTranscriptEntry> {
const entry: AgentTranscriptEntry = {
id: randomUUID(),
agentId: input.agentId,
sessionId: input.sessionId,
role: input.role,
text: input.text,
createdAt: Date.now(),
}
const filePath = this.pathFor(input)
await mkdir(dirname(filePath), { recursive: true })
await appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf8')
logger.debug('Agent harness transcript appended entry', {
agentId: entry.agentId,
sessionId: entry.sessionId,
role: entry.role,
textLength: entry.text.length,
filePath,
})
return entry
}
async list(input: TranscriptListInput): Promise<AgentTranscriptEntry[]> {
try {
const raw = await readFile(this.pathFor(input), 'utf8')
const entries = raw
.split('\n')
.filter(Boolean)
.map((line) => this.parseLine(line, input))
.filter((entry): entry is AgentTranscriptEntry => entry !== null)
.sort((a, b) => a.createdAt - b.createdAt)
logger.debug('Agent harness transcript listed entries', {
agentId: input.agentId,
sessionId: input.sessionId,
count: entries.length,
filePath: this.pathFor(input),
})
return entries
} catch (err) {
if (isNotFoundError(err)) {
logger.debug('Agent harness transcript file missing', {
agentId: input.agentId,
sessionId: input.sessionId,
filePath: this.pathFor(input),
})
return []
}
throw err
}
}
private pathFor(input: TranscriptListInput): string {
return join(this.rootDir, input.agentId, `${input.sessionId}.jsonl`)
}
private parseLine(
line: string,
input: TranscriptListInput,
): AgentTranscriptEntry | null {
try {
return JSON.parse(line) as AgentTranscriptEntry
} catch (err) {
logger.warn('Agent harness transcript skipped malformed line', {
agentId: input.agentId,
sessionId: input.sessionId,
filePath: this.pathFor(input),
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
}
function isNotFoundError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ENOENT'
)
}

View File

@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type {
AgentDefinition,
AgentPermissionMode,
AgentTranscriptEntry,
} from './agent-types'
export interface AgentStatus {
state: 'ready' | 'unknown' | 'error'
message?: string
}
export interface AgentSession {
agentId: string
id: 'main'
updatedAt: number
}
export interface AgentHistoryPage {
agentId: string
sessionId: 'main'
items: AgentTranscriptEntry[]
}
export type AgentStreamEvent =
| {
type: 'text_delta'
text: string
stream: 'output' | 'thought'
rawType?: string
}
| {
type: 'tool_call'
text: string
title: string
id?: string
status?: string
rawType?: string
}
| {
type: 'status'
text: string
rawType?: string
}
| {
type: 'done'
text?: string
stopReason?: string
}
| {
type: 'error'
message: string
code?: string
}
export interface AgentPromptInput {
agent: AgentDefinition
sessionId: 'main'
sessionKey: string
message: string
permissionMode: AgentPermissionMode
timeoutMs?: number
signal?: AbortSignal
}
export interface AgentRuntime {
status(agent: AgentDefinition): Promise<AgentStatus>
listSessions(agent: AgentDefinition): Promise<AgentSession[]>
getHistory(input: {
agent: AgentDefinition
sessionId: 'main'
}): Promise<AgentHistoryPage>
send(input: AgentPromptInput): Promise<ReadableStream<AgentStreamEvent>>
cancel?(input: {
agent: AgentDefinition
sessionId: 'main'
reason?: string
}): Promise<void>
}

View File

@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { AGENT_HARNESS_LIMITS } from '@browseros/shared/constants/limits'
import { Hono } from 'hono'
import { createAgentRoutes } from '../../../src/api/routes/agents'
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
describe('createAgentRoutes', () => {
it('creates and lists harness agents', async () => {
const agents: AgentDefinition[] = []
const route = createMountedRoutes(agents)
const created = await route.request('/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
}),
})
expect(created.status).toBe(200)
expect(await created.json()).toMatchObject({
agent: { name: 'Review bot', adapter: 'codex' },
})
const list = await route.request('/agents')
expect(await list.json()).toMatchObject({
agents: [{ name: 'Review bot', adapter: 'codex' }],
})
})
it('streams chat for an agent main session', async () => {
const route = createMountedRoutes([
{
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
},
])
const response = await route.request('/agents/agent-1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'hi' }),
})
expect(response.status).toBe(200)
expect(response.headers.get('X-Session-Id')).toBe('main')
expect(await response.text()).toContain('data: [DONE]')
})
it('rejects overlong agent names', async () => {
const route = createMountedRoutes([])
const response = await route.request('/agents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'a'.repeat(AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS + 1),
adapter: 'codex',
}),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: `Name must be ${AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS} characters or fewer`,
})
})
})
function createMountedRoutes(agents: AgentDefinition[]) {
return new Hono().route(
'/agents',
createAgentRoutes({ service: createFakeService(agents) }),
)
}
function createFakeService(agents: AgentDefinition[]) {
return {
async listAgents() {
return agents
},
async createAgent(input: {
name: string
adapter: 'claude' | 'codex'
modelId?: string
reasoningEffort?: string
}) {
const agent: AgentDefinition = {
id: `agent-${agents.length + 1}`,
name: input.name,
adapter: input.adapter,
modelId: input.modelId,
reasoningEffort: input.reasoningEffort,
permissionMode: 'approve-all',
sessionKey: `agent:agent-${agents.length + 1}:main`,
createdAt: 1000,
updatedAt: 1000,
}
agents.push(agent)
return agent
},
async getAgent(agentId: string) {
return agents.find((agent) => agent.id === agentId) ?? null
},
async deleteAgent(agentId: string) {
const index = agents.findIndex((agent) => agent.id === agentId)
if (index < 0) return false
agents.splice(index, 1)
return true
},
async getHistory(agentId: string) {
return {
agentId,
sessionId: 'main' as const,
items: [],
}
},
async send() {
return new ReadableStream<AgentStreamEvent>({
start(controller) {
controller.enqueue({
type: 'text_delta',
text: 'Hello',
stream: 'output',
})
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
controller.close()
},
})
},
}
}

View File

@@ -0,0 +1,204 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
import type {
AgentDefinition,
AgentTranscriptEntry,
} from '../../../../src/lib/agents/agent-types'
import type { FileAgentStore } from '../../../../src/lib/agents/file-agent-store'
import type { FileTranscriptStore } from '../../../../src/lib/agents/file-transcript-store'
import type {
AgentRuntime,
AgentStreamEvent,
} from '../../../../src/lib/agents/types'
describe('AgentHarnessService', () => {
it('creates named agents and sends prompts through the main session', async () => {
const agents: AgentDefinition[] = []
const transcripts: AgentTranscriptEntry[] = []
const runtimeInputs: unknown[] = []
const agentStore = {
async list() {
return agents
},
async get(id: string) {
return agents.find((agent) => agent.id === id) ?? null
},
async create(input) {
const agent: AgentDefinition = {
id: 'agent-1',
name: input.name,
adapter: input.adapter,
modelId: input.modelId,
reasoningEffort: input.reasoningEffort,
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
agents.push(agent)
return agent
},
async delete() {
return true
},
} satisfies Partial<FileAgentStore>
const transcriptStore = {
async append(input) {
const entry: AgentTranscriptEntry = {
id: String(transcripts.length + 1),
createdAt: 1000 + transcripts.length,
...input,
}
transcripts.push(entry)
return entry
},
async list() {
return transcripts
},
} satisfies Partial<FileTranscriptStore>
const runtime: AgentRuntime = {
async status() {
return { state: 'ready' }
},
async listSessions() {
return []
},
async getHistory() {
return { agentId: 'agent-1', sessionId: 'main', items: [] }
},
async send(input) {
runtimeInputs.push(input)
return new ReadableStream<AgentStreamEvent>({
start(controller) {
controller.enqueue({
type: 'text_delta',
text: 'answer',
stream: 'output',
})
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
controller.close()
},
})
},
}
const service = new AgentHarnessService({
agentStore: agentStore as FileAgentStore,
transcriptStore: transcriptStore as FileTranscriptStore,
runtime,
})
const agent = await service.createAgent({
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
})
const stream = await service.send({
agentId: agent.id,
message: 'hello',
})
await stream.pipeTo(new WritableStream())
expect(runtimeInputs[0]).toMatchObject({
agent,
sessionId: 'main',
sessionKey: 'agent:agent-1:main',
message: 'hello',
permissionMode: 'approve-all',
})
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'answer' },
])
})
it('flushes partial assistant text when the response stream is cancelled', async () => {
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
const transcripts: AgentTranscriptEntry[] = []
const agentStore = {
async list() {
return [agent]
},
async get(id: string) {
return id === agent.id ? agent : null
},
async create() {
return agent
},
async delete() {
return true
},
} satisfies Partial<FileAgentStore>
const transcriptStore = {
async append(input) {
const entry: AgentTranscriptEntry = {
id: String(transcripts.length + 1),
createdAt: 1000 + transcripts.length,
...input,
}
transcripts.push(entry)
return entry
},
async list() {
return transcripts
},
} satisfies Partial<FileTranscriptStore>
const runtime: AgentRuntime = {
async status() {
return { state: 'ready' }
},
async listSessions() {
return []
},
async getHistory() {
return { agentId: agent.id, sessionId: 'main', items: [] }
},
async send() {
return new ReadableStream<AgentStreamEvent>({
start(controller) {
controller.enqueue({
type: 'text_delta',
text: 'partial answer',
stream: 'output',
})
},
})
},
}
const service = new AgentHarnessService({
agentStore: agentStore as FileAgentStore,
transcriptStore: transcriptStore as FileTranscriptStore,
runtime,
})
const reader = (
await service.send({
agentId: agent.id,
message: 'hello',
})
).getReader()
await reader.read()
await reader.cancel()
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'partial answer' },
])
})
})

View File

@@ -0,0 +1,283 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeOptions,
AcpRuntime as AcpxCoreRuntime,
} from 'acpx/runtime'
import { AcpxRuntime } from '../../../src/lib/agents/acpx-runtime'
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
describe('AcpxRuntime', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('uses acpx/runtime to ensure a session and stream a turn', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
tempDirs.push(cwd, stateDir)
const calls: Array<{ method: string; input: unknown }> = []
const runtimeFactory = (options: AcpRuntimeOptions): AcpxCoreRuntime => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
}
const runtime = new AcpxRuntime({ cwd, stateDir, runtimeFactory })
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
const stream = await runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: 'say hello',
permissionMode: 'approve-all',
})
const events = await collectStream(stream)
expect(calls.map((call) => call.method)).toEqual([
'createRuntime',
'ensureSession',
'setConfigOption',
'startTurn',
])
expect(calls[0]?.input).toMatchObject({
cwd,
permissionMode: 'approve-all',
nonInteractivePermissions: 'fail',
})
expect(calls[1]?.input).toEqual({
sessionKey: 'agent:agent-1:main',
agent: 'codex',
mode: 'persistent',
cwd,
})
expect(calls[2]?.input).toMatchObject({
key: 'reasoning_effort',
value: 'medium',
})
expect(calls[3]?.input).toMatchObject({
text: 'say hello',
mode: 'prompt',
})
expect(events).toEqual([
{
type: 'status',
text: 'Requested model is stored on the BrowserOS agent, but this acpx/runtime version does not expose public model control. Using adapter default.',
},
{
type: 'text_delta',
text: 'Hello from fake runtime',
stream: 'output',
rawType: 'agent_message_chunk',
},
{
type: 'tool_call',
text: 'Run tests (completed)',
title: 'Run tests',
id: 'tool-1',
status: 'completed',
rawType: 'tool_call_update',
},
{
type: 'done',
stopReason: 'end_turn',
},
])
})
it('continues the turn when runtime config control is unavailable', async () => {
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
cwd: '/tmp/browseros-acpx-runtime',
stateDir: '/tmp/browseros-acpx-state',
runtimeFactory: () => createFakeAcpRuntime(calls, { failConfig: true }),
})
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Claude bot',
adapter: 'claude',
modelId: 'haiku',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
const events = await collectStream(
await runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: 'say hello',
permissionMode: 'approve-all',
}),
)
expect(events.map((event) => event.type)).toEqual([
'status',
'status',
'text_delta',
'tool_call',
'done',
])
expect(events[1]).toMatchObject({
type: 'status',
text: expect.stringContaining('Could not apply effort=medium'),
})
})
it('reuses cached runtime instances across per-turn timeouts', async () => {
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({
cwd: '/tmp/browseros-acpx-runtime',
stateDir: '/tmp/browseros-acpx-state',
runtimeFactory: (options) => {
calls.push({ method: 'createRuntime', input: options })
return createFakeAcpRuntime(calls)
},
})
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Codex bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
await collectStream(
await runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: 'first',
permissionMode: 'approve-all',
timeoutMs: 1_000,
}),
)
await collectStream(
await runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message: 'second',
permissionMode: 'approve-all',
timeoutMs: 2_000,
}),
)
expect(
calls.filter((call) => call.method === 'createRuntime'),
).toHaveLength(1)
expect(
calls
.filter((call) => call.method === 'startTurn')
.map((call) => (call.input as { timeoutMs?: number }).timeoutMs),
).toEqual([1_000, 2_000])
})
})
function createFakeAcpRuntime(
calls: Array<{ method: string; input: unknown }>,
options: { failConfig?: boolean } = {},
): AcpxCoreRuntime {
return {
async ensureSession(input) {
calls.push({ method: 'ensureSession', input })
return {
sessionKey: input.sessionKey,
backend: 'acpx',
runtimeSessionName: 'encoded-runtime-state',
cwd: input.cwd,
acpxRecordId: 'record-1',
} satisfies AcpRuntimeHandle
},
startTurn(input) {
calls.push({ method: 'startTurn', input })
return {
requestId: input.requestId,
events: iterableEvents([
{
type: 'text_delta',
text: 'Hello from fake runtime',
stream: 'output',
tag: 'agent_message_chunk',
},
{
type: 'tool_call',
text: 'Run tests (completed)',
title: 'Run tests',
toolCallId: 'tool-1',
status: 'completed',
tag: 'tool_call_update',
},
]),
result: Promise.resolve({
status: 'completed',
stopReason: 'end_turn',
}),
async cancel() {},
async closeStream() {},
}
},
async *runTurn() {},
async setConfigOption(input) {
calls.push({ method: 'setConfigOption', input })
if (options.failConfig) {
throw new Error('config key is not supported')
}
},
async cancel() {},
async close() {},
}
}
async function* iterableEvents(events: AcpRuntimeEvent[]) {
for (const event of events) yield event
}
async function collectStream(
stream: ReadableStream<AgentStreamEvent>,
): Promise<AgentStreamEvent[]> {
const reader = stream.getReader()
const events: AgentStreamEvent[] = []
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
} finally {
reader.releaseLock()
}
return events
}

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it } from 'bun:test'
import {
AGENT_ADAPTER_CATALOG,
getAgentAdapterDescriptor,
isSupportedAgentModel,
isSupportedReasoningEffort,
} from '../../../src/lib/agents/agent-catalog'
describe('AGENT_ADAPTER_CATALOG', () => {
it('exposes Claude and Codex adapters with model and effort options', () => {
expect(AGENT_ADAPTER_CATALOG.map((adapter) => adapter.id)).toEqual([
'claude',
'codex',
])
expect(getAgentAdapterDescriptor('claude')).toMatchObject({
id: 'claude',
name: 'Claude Code',
defaultModelId: 'haiku',
defaultReasoningEffort: 'medium',
modelControl: 'best-effort',
})
expect(getAgentAdapterDescriptor('codex')).toMatchObject({
id: 'codex',
name: 'Codex',
defaultModelId: 'gpt-5.5',
defaultReasoningEffort: 'medium',
modelControl: 'best-effort',
})
expect(isSupportedAgentModel('claude', 'haiku')).toBe(true)
expect(isSupportedAgentModel('codex', 'gpt-5.5')).toBe(true)
expect(isSupportedReasoningEffort('codex', 'xhigh')).toBe(true)
expect(isSupportedReasoningEffort('claude', 'banana')).toBe(false)
})
})

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { FileAgentStore } from '../../../src/lib/agents/file-agent-store'
describe('FileAgentStore', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('creates, lists, loads, and deletes named agents', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
tempDirs.push(dir)
const store = new FileAgentStore({ filePath: join(dir, 'agents.json') })
const agent = await store.create({
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
})
expect(agent).toMatchObject({
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: `agent:${agent.id}:main`,
})
expect(await store.list()).toEqual([agent])
expect(await store.get(agent.id)).toEqual(agent)
await store.delete(agent.id)
expect(await store.list()).toEqual([])
})
it('serializes concurrent creates without dropping agents', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
tempDirs.push(dir)
const store = new FileAgentStore({ filePath: join(dir, 'agents.json') })
const created = await Promise.all(
Array.from({ length: 10 }, (_, index) =>
store.create({
name: `Agent ${index}`,
adapter: index % 2 === 0 ? 'codex' : 'claude',
}),
),
)
const listed = await store.list()
expect(listed).toHaveLength(created.length)
expect(new Set(listed.map((agent) => agent.id)).size).toBe(created.length)
})
})

View File

@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { FileTranscriptStore } from '../../../src/lib/agents/file-transcript-store'
describe('FileTranscriptStore', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('appends and lists main-session transcript entries', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-transcripts-'))
tempDirs.push(dir)
const store = new FileTranscriptStore({ rootDir: dir })
await store.append({
agentId: 'agent-1',
sessionId: 'main',
role: 'user',
text: 'hello',
})
await store.append({
agentId: 'agent-1',
sessionId: 'main',
role: 'assistant',
text: 'hi',
})
expect(
(await store.list({ agentId: 'agent-1', sessionId: 'main' })).map(
({ role, text }) => ({ role, text }),
),
).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'hi' },
])
})
it('skips malformed JSONL lines when listing transcript entries', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-transcripts-'))
tempDirs.push(dir)
const store = new FileTranscriptStore({ rootDir: dir })
const agentDir = join(dir, 'agent-1')
await mkdir(agentDir, { recursive: true })
await writeFile(
join(agentDir, 'main.jsonl'),
[
JSON.stringify({
id: '1',
agentId: 'agent-1',
sessionId: 'main',
role: 'user',
text: 'hello',
createdAt: 1,
}),
'{bad json',
JSON.stringify({
id: '2',
agentId: 'agent-1',
sessionId: 'main',
role: 'assistant',
text: 'hi',
createdAt: 2,
}),
'',
].join('\n'),
'utf8',
)
expect(
(await store.list({ agentId: 'agent-1', sessionId: 'main' })).map(
({ role, text }) => ({ role, text }),
),
).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'hi' },
])
})
})

View File

@@ -182,6 +182,7 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@sentry/bun": "^10.31.0",
"acpx": "0.6.1",
"ai": "^6.0.94",
"chrome-devtools-frontend": "^1.0.1577886",
"commander": "^14.0.1",
@@ -270,6 +271,8 @@
"@1natsu/wait-element": ["@1natsu/wait-element@4.1.2", "", { "dependencies": { "defu": "^6.1.4", "many-keys-map": "^2.0.1" } }, "sha512-qWxSJD+Q5b8bKOvESFifvfZ92DuMsY+03SBNjTO34ipJLP6mZ9yK4bQz/vlh48aEQXoJfaZBqUwKL5BdI5iiWw=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.20.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.63", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.46", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNOaIaOXWFZFWbB0xM1l/bQYo7XwTkpdHbrA6n9A2U1c4/DcLF/+Rwc3vZF6MHPVSjoYVG0qxIa7jh39rKftYA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXJPiNHaIiQ6XUqLeSYZ3ZbSzjqt1pNWEUf2hlkXlmmw8IF8KI0ruuGaDwKCExmtuNRf0E4TDxhsc9wRgWTzpw=="],
@@ -498,6 +501,10 @@
"@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="],
"@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="],
"@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
@@ -2046,6 +2053,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acpx": ["acpx@0.6.1", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.20.0", "commander": "^14.0.3", "skillflag": "^0.1.4", "tsx": "^4.21.0", "zod": "^4.3.6" }, "bin": { "acpx": "dist/cli.js" } }, "sha512-qxZPbm3SKq0UqQ0sOJ0M4iTLkF9AR7+I+JE/L/UeMUU1vW5N4nUVkZHytoHTBAu7nrej6THNzCPgrIZfv9T3AA=="],
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
@@ -2244,7 +2253,7 @@
"chrome-devtools-frontend": ["chrome-devtools-frontend@1.0.1577886", "", {}, "sha512-B9hY3o/0RuVCDWNYh9YnkEbRrPUMCY+NaOgBxvZRzGvqbGSMNckkVSdO67SwWR8bm4fo/qplXbUj0cSr229V6w=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.21.0", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-d+iqrRmcwpRFV3Q4DRCF2LCoq+WCRU3GhISKQ9v8g+1C2Uh8upj3urkjxNO4QIjhBMIYei/VQ1OQLFceby80Og=="],
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.23.0", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-VISjVEzdJGJo5hwMfZsGZTX7uQx6P8t3/pfvG/YR7g6dHS78R5j2WB9RZ2H4omFAEzScYtrNN5laykvGAHWx1g=="],
"chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="],
@@ -2672,8 +2681,14 @@
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],
"fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="],
"fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="],
"fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="],
@@ -2776,6 +2791,8 @@
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
@@ -3946,6 +3963,8 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"responselike": ["responselike@4.0.2", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
@@ -4054,6 +4073,8 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"skillflag": ["skillflag@0.1.4", "", { "dependencies": { "@clack/prompts": "^1.0.1", "tar-stream": "^3.1.7" }, "bin": { "skillflag": "dist/bin/skillflag.js", "skill-install": "dist/bin/skill-install.js" } }, "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA=="],
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
@@ -4246,6 +4267,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
@@ -4912,6 +4935,10 @@
"@types/request/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="],
"acpx/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"acpx/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"antd/@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
"antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],

View File

@@ -80,3 +80,7 @@ export const CONTENT_LIMITS = {
CONSOLE_DEFAULT_LIMIT: 50,
CONSOLE_MAX_LIMIT: 200,
} as const
export const AGENT_HARNESS_LIMITS = {
AGENT_NAME_MAX_CHARS: 80,
} as const