Compare commits

...

7 Commits

Author SHA1 Message Date
ONLY-yours f049ab1610 fix: sloved the login error problem 2026-04-29 18:23:10 +08:00
ONLY-yours 542aacd07f feat: add the useCreateCloudCode modal 2026-04-29 16:48:21 +08:00
ONLY-yours 4be4bc69c2 feat: add the cloud claude code agent create way & add the debug log 2026-04-29 16:21:02 +08:00
ONLY-yours a62def2fe9 feat: add cloud-claude-code frontend flow with polling
- Add 'cloud-claude-code' to HeterogeneousProviderConfig type
- Add cloud CC branch in conversationLifecycle: persists messages,
  calls cloudClaudeCode.start (fire-and-forget), starts 3s polling
  for message updates, creates operation for stop button
- Polling auto-stops after 10 minutes (safety net)
- Cancel handler clears polling interval

Covers LOBE-8302, LOBE-8317, LOBE-8318, LOBE-8319

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 16:47:26 +08:00
ONLY-yours 54f972b7dd feat: wire sandbox runCommand in cloud CC start mutation
Replace TODO with actual ServerSandboxService.callTool('runCommand')
call. The sandbox command is fire-and-forget — CC runs asynchronously,
results come back via ingest POST callbacks.

LOBE-8301

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 16:29:12 +08:00
ONLY-yours be676bca4f feat: add Cloud CC sandbox wrapper and start mutation
- sandboxWrapper.ts: inline Node.js script for sandbox, does step-level
  boundary detection on CC stream-json stdout and POSTs each step to
  TRPC ingest endpoint via HTTP (no lh CLI dependency needed)
- cloudClaudeCode router: add `start` mutation that generates JWT,
  builds wrapper command with env injection, ready for sandbox runCommand

LOBE-8301

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 16:14:29 +08:00
ONLY-yours cdfa64956f feat: add Cloud Claude Code ingest pipeline
Add server-side persistence and CLI command for Cloud Claude Code integration.
This enables CC stream-json output to be ingested via `lh ingest` and persisted
as structured messages (assistant + tool messages) in the database.

- CloudCCMessagePersistence: server-side adapter + DB write logic
- cloudClaudeCode TRPC router: ingest endpoint
- `lh ingest` CLI command: reads stdin NDJSON, detects step boundaries, batches POST

Closes LOBE-8297, LOBE-8298, LOBE-8299

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 15:49:06 +08:00
20 changed files with 2033 additions and 6 deletions
+96
View File
@@ -0,0 +1,96 @@
import { createInterface } from 'node:readline';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { log } from '../utils/logger';
/**
* Detect step boundaries in Claude Code stream-json output.
*
* A "step" boundary occurs when a new assistant `message.id` appears,
* indicating the start of a new CC turn. We flush the accumulated lines
* for the previous step before starting the new one.
*
* On stdin EOF (CC process exits), we flush the remaining buffer.
*/
export function registerIngestCommand(program: Command) {
program
.command('ingest')
.description(
'Pipe Claude Code stream-json stdout to LobeHub, persisting structured messages per step',
)
.requiredOption('--topic-id <id>', 'Target topic ID')
.option('--agent-id <id>', 'Agent ID')
.option('--json', 'Output JSON results')
.action(async (options: { agentId?: string; json?: boolean; topicId: string }) => {
log.debug('ingest: topicId=%s, agentId=%s', options.topicId, options.agentId);
const client = await getTrpcClient();
const rl = createInterface({ input: process.stdin });
let buffer: any[] = [];
let currentMessageId: string | undefined;
let stepCount = 0;
const flush = async (lines: any[]) => {
if (lines.length === 0) return;
stepCount++;
try {
const result = await (client as any).cloudClaudeCode.ingest.mutate({
agentId: options.agentId,
lines,
topicId: options.topicId,
});
if (options.json) {
console.log(JSON.stringify({ step: stepCount, ...result }));
} else {
const toolInfo =
result.toolMessageIds?.length > 0 ? ` + ${result.toolMessageIds.length} tool(s)` : '';
console.error(
`${pc.green('↑')} Step ${stepCount}: ${pc.bold(result.assistantMessageId || 'no-msg')}${toolInfo}`,
);
}
} catch (error: any) {
console.error(`${pc.red('✗')} Step ${stepCount} failed: ${error.message}`);
}
};
for await (const raw of rl) {
let line: any;
try {
line = JSON.parse(raw);
} catch {
// Skip non-JSON lines (stderr leaks, etc.)
continue;
}
// Detect step boundary: assistant message.id change
if (line.type === 'assistant' && line.message?.id) {
if (currentMessageId && line.message.id !== currentMessageId) {
// New message.id → previous step is complete → flush
const prevStepLines = buffer;
buffer = [line];
await flush(prevStepLines);
} else {
buffer.push(line);
}
currentMessageId = line.message.id;
} else {
buffer.push(line);
}
}
// stdin EOF → CC finished → flush remaining
await flush(buffer);
if (!options.json) {
console.error(
`${pc.green('✓')} Done: ${stepCount} step(s) ingested to topic ${pc.bold(options.topicId)}`,
);
}
});
}
+2
View File
@@ -14,6 +14,7 @@ import { registerDocCommand } from './commands/doc';
import { registerEvalCommand } from './commands/eval';
import { registerFileCommand } from './commands/file';
import { registerGenerateCommand } from './commands/generate';
import { registerIngestCommand } from './commands/ingest';
import { registerKbCommand } from './commands/kb';
import { registerLoginCommand } from './commands/login';
import { registerLogoutCommand } from './commands/logout';
@@ -61,6 +62,7 @@ export function createProgram() {
registerBotCommand(program);
registerCronCommand(program);
registerGenerateCommand(program);
registerIngestCommand(program);
registerFileCommand(program);
registerSkillCommand(program);
registerSessionGroupCommand(program);
+20
View File
@@ -68,6 +68,25 @@
"cliRateLimitGuide.resetAt": "Resets at",
"cliRateLimitGuide.resetInApprox": "Resets in about {{duration}}",
"cliRateLimitGuide.title": "{{name}} usage limit reached",
"cloudClaudeCodeSetup.actions.cancel": "Cancel",
"cloudClaudeCodeSetup.actions.confirm": "Continue",
"cloudClaudeCodeSetup.desc": "Before creating the agent, complete the Claude Code token and GitHub authorization setup.",
"cloudClaudeCodeSetup.errors.githubRequired": "Connect GitHub before continuing.",
"cloudClaudeCodeSetup.errors.refresh": "Failed to refresh the current authorization status.",
"cloudClaudeCodeSetup.errors.submit": "Failed to save the Cloud Claude Code prerequisites.",
"cloudClaudeCodeSetup.errors.tokenRequired": "Paste your CLAUDE_CODE_OAUTH_TOKEN first.",
"cloudClaudeCodeSetup.github.authorized": "GitHub authorization is already available. We will create the injectable credential automatically when you continue.",
"cloudClaudeCodeSetup.github.connected": "Detected an available GitHub credential.",
"cloudClaudeCodeSetup.github.desc": "Claude Code will use your GitHub identity to access repositories and write code for you.",
"cloudClaudeCodeSetup.github.footer": "After GitHub authorization succeeds, continue creating the agent.",
"cloudClaudeCodeSetup.github.title": "GitHub Authorization",
"cloudClaudeCodeSetup.title": "Enable Cloud Claude Code",
"cloudClaudeCodeSetup.token.commandPrefix": "Run this inside your Claude Code session",
"cloudClaudeCodeSetup.token.connected": "Detected an existing CLAUDE_CODE_OAUTH_TOKEN and will reuse it.",
"cloudClaudeCodeSetup.token.desc": "This token lets Cloud Claude Code run on your behalf.",
"cloudClaudeCodeSetup.token.hint": "If you do not have the token yet, get the long-lived credential from your own Claude Code first.",
"cloudClaudeCodeSetup.token.placeholder": "Paste CLAUDE_CODE_OAUTH_TOKEN",
"cloudClaudeCodeSetup.token.title": "Claude Code Token",
"codexInstallGuide.actions.openDocs": "Open Install Guide",
"codexInstallGuide.actions.openSystemTools": "Open System Tools",
"codexInstallGuide.afterInstall": "After installing, run Codex once to sign in, then retry your message or click Re-detect in System Tools.",
@@ -294,6 +313,7 @@
"minimap.senderUser": "You",
"newAgent": "Create Agent",
"newClaudeCodeAgent": "Add Claude Code",
"newCloudClaudeCode": "Add Cloud Claude Code",
"newCodexAgent": "Add Codex",
"newGroupChat": "Create Group",
"newPage": "Create Page",
+20
View File
@@ -68,6 +68,25 @@
"cliRateLimitGuide.resetAt": "重置时间",
"cliRateLimitGuide.resetInApprox": "约 {{duration}} 后重置",
"cliRateLimitGuide.title": "{{name}} 已达到使用上限",
"cloudClaudeCodeSetup.actions.cancel": "取消",
"cloudClaudeCodeSetup.actions.confirm": "继续创建",
"cloudClaudeCodeSetup.desc": "创建前需要先完成 Claude Code 凭证和 GitHub 授权。",
"cloudClaudeCodeSetup.errors.githubRequired": "请先完成 GitHub 授权。",
"cloudClaudeCodeSetup.errors.refresh": "刷新当前授权状态失败。",
"cloudClaudeCodeSetup.errors.submit": "保存 Cloud Claude Code 前置凭证失败。",
"cloudClaudeCodeSetup.errors.tokenRequired": "请先填写 CLAUDE_CODE_OAUTH_TOKEN。",
"cloudClaudeCodeSetup.github.authorized": "已检测到 GitHub 授权,继续创建时会自动生成可注入凭证。",
"cloudClaudeCodeSetup.github.connected": "已检测到可用的 GitHub 凭证。",
"cloudClaudeCodeSetup.github.desc": "让 Claude Code 继承你的 GitHub 身份,用于访问仓库和编写代码。",
"cloudClaudeCodeSetup.github.footer": "GitHub 授权完成后,继续创建即可。",
"cloudClaudeCodeSetup.github.title": "GitHub 授权",
"cloudClaudeCodeSetup.title": "启用云端 Claude Code",
"cloudClaudeCodeSetup.token.commandPrefix": "在你自己的 Claude Code 会话里运行",
"cloudClaudeCodeSetup.token.connected": "已检测到现有 CLAUDE_CODE_OAUTH_TOKEN,会直接复用。",
"cloudClaudeCodeSetup.token.desc": "这个 token 用于让云端 Claude Code 代表你运行。",
"cloudClaudeCodeSetup.token.hint": "如果你还没有这个 token,请先到你自己的 Claude Code 里拿到长期有效凭证。",
"cloudClaudeCodeSetup.token.placeholder": "粘贴 CLAUDE_CODE_OAUTH_TOKEN",
"cloudClaudeCodeSetup.token.title": "Claude Code 凭证",
"codexInstallGuide.actions.openDocs": "打开安装指南",
"codexInstallGuide.actions.openSystemTools": "打开系统工具",
"codexInstallGuide.afterInstall": "安装完成后,请先运行一次 Codex 完成登录,然后重试刚才的消息,或在系统工具中点击“重新检测”。",
@@ -294,6 +313,7 @@
"minimap.senderUser": "你",
"newAgent": "创建助理",
"newClaudeCodeAgent": "添加 Claude Code",
"newCloudClaudeCode": "新增云端 Claude Code",
"newCodexAgent": "添加 Codex",
"newGroupChat": "创建群组",
"newPage": "创建文稿",
+1 -1
View File
@@ -11,7 +11,7 @@ export interface HeterogeneousProviderConfig {
/** Custom environment variables */
env?: Record<string, string>;
/** Agent runtime type */
type: 'claude-code' | 'codex';
type: 'claude-code' | 'cloud-claude-code' | 'codex';
}
/**
@@ -98,6 +98,11 @@ export const EmojiReactionSchema = z.object({
export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSchema).extend({
collapsed: z.boolean().optional(),
cloudClaudeCodeCompletedAt: z.string().optional(),
cloudClaudeCodeError: z.string().optional(),
cloudClaudeCodeRunId: z.string().optional(),
cloudClaudeCodeRunStatus: z.enum(['running', 'completed', 'failed']).optional(),
cloudClaudeCodeStartedAt: z.string().optional(),
inspectExpanded: z.boolean().optional(),
isMultimodal: z.boolean().optional(),
isSupervisor: z.boolean().optional(),
@@ -150,6 +155,11 @@ export interface MessageMetadata {
acceptedPredictionTokens?: number;
activeBranchIndex?: number;
activeColumn?: boolean;
cloudClaudeCodeCompletedAt?: string;
cloudClaudeCodeError?: string;
cloudClaudeCodeRunId?: string;
cloudClaudeCodeRunStatus?: 'running' | 'completed' | 'failed';
cloudClaudeCodeStartedAt?: string;
/**
* Message collapse state
* true: collapsed, false/undefined: expanded
@@ -0,0 +1,325 @@
'use client';
import { Button, Flexbox, Text } from '@lobehub/ui';
import { createModal, useModalContext } from '@lobehub/ui/base-ui';
import { Alert, Input, Spin } from 'antd';
import { createStaticStyles } from 'antd-style';
import { t } from 'i18next';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SocialConnectButton from '@/layout/AuthProvider/MarketAuth/SocialConnectButton';
import {
type SocialProfile,
useSocialConnect,
} from '@/layout/AuthProvider/MarketAuth/useSocialConnect';
import { lambdaClient } from '@/libs/trpc/client';
const CLAUDE_CODE_TOKEN_CRED_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
const GITHUB_CRED_KEY = 'GITHUB';
const GITHUB_TOKEN_CRED_KEY = 'GITHUB_TOKEN';
const styles = createStaticStyles(({ css, cssVar }) => ({
actions: css`
display: flex;
gap: 12px;
justify-content: flex-end;
margin-block-start: 24px;
`,
code: css`
display: inline-flex;
padding-block: 2px;
padding-inline: 6px;
border-radius: ${cssVar.borderRadius}px;
font-family: ${cssVar.fontFamilyCode};
font-size: 12px;
background: ${cssVar.colorFillTertiary};
`,
content: css`
padding-block: 4px 8px;
padding-inline: 0;
`,
section: css`
padding: 16px;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: ${cssVar.borderRadiusLG}px;
`,
}));
interface CloudClaudeCodeCredItem {
id: number;
key: string;
name?: string;
}
interface CloudClaudeCodeOAuthConnection {
avatar?: string;
email?: string;
id: number;
name?: string;
providerId?: string;
providerName?: string;
providerUserName?: string;
}
interface CloudClaudeCodeSetupState {
claudeCodeTokenCred?: CloudClaudeCodeCredItem;
githubConnection?: CloudClaudeCodeOAuthConnection;
githubCred?: CloudClaudeCodeCredItem;
}
const resolveGithubProfile = (
connection?: CloudClaudeCodeOAuthConnection,
): SocialProfile | null => {
if (!connection) return null;
return {
avatarUrl: connection.avatar,
id: String(connection.id),
provider: 'github',
username:
connection.providerUserName ||
connection.email ||
connection.name ||
connection.providerName ||
'github',
};
};
const getCloudClaudeCodeSetupState = async (): Promise<CloudClaudeCodeSetupState> => {
const [credsResult, connectionsResult] = await Promise.all([
lambdaClient.market.creds.list.query(),
lambdaClient.market.creds.listOAuthConnections.query(),
]);
const creds = (credsResult.data || []) as CloudClaudeCodeCredItem[];
const connections = (connectionsResult.connections || []) as CloudClaudeCodeOAuthConnection[];
return {
claudeCodeTokenCred: creds.find((cred) => cred.key === CLAUDE_CODE_TOKEN_CRED_KEY),
githubConnection: connections.find((connection) => connection.providerId === 'github'),
githubCred: creds.find(
(cred) => cred.key === GITHUB_CRED_KEY || cred.key === GITHUB_TOKEN_CRED_KEY,
),
};
};
const ensureGithubCredential = async (connectionId: number) => {
await lambdaClient.market.creds.createOAuth.mutate({
key: GITHUB_CRED_KEY,
name: 'GitHub OAuth Token',
oauthConnectionId: connectionId,
});
};
const createClaudeCodeTokenCredential = async (token: string) => {
await lambdaClient.market.creds.createKV.mutate({
key: CLAUDE_CODE_TOKEN_CRED_KEY,
name: 'Claude Code OAuth Token',
type: 'kv-env',
values: {
[CLAUDE_CODE_TOKEN_CRED_KEY]: token,
},
});
};
interface CloudClaudeCodeSetupModalContentProps {
initialState: CloudClaudeCodeSetupState;
onCreated: () => void;
}
const CloudClaudeCodeSetupModalContent = ({
initialState,
onCreated,
}: CloudClaudeCodeSetupModalContentProps) => {
const { t } = useTranslation('chat');
const { close } = useModalContext();
const [setupState, setSetupState] = useState(initialState);
const [tokenValue, setTokenValue] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>();
const refreshSetupState = useCallback(async () => {
setIsRefreshing(true);
setErrorMessage(undefined);
try {
const nextState = await getCloudClaudeCodeSetupState();
setSetupState(nextState);
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : t('cloudClaudeCodeSetup.errors.refresh'),
);
} finally {
setIsRefreshing(false);
}
}, [t]);
const githubConnect = useSocialConnect({
onConnectSuccess: () => {
void refreshSetupState();
},
provider: 'github',
});
useEffect(() => {
if (!githubConnect.profile && setupState.githubConnection) {
void githubConnect.fetchProfile();
}
}, [githubConnect, setupState.githubConnection]);
const handleSubmit = useCallback(async () => {
if (!setupState.claudeCodeTokenCred && !tokenValue.trim()) {
setErrorMessage(t('cloudClaudeCodeSetup.errors.tokenRequired'));
return;
}
if (!setupState.githubCred && !setupState.githubConnection) {
setErrorMessage(t('cloudClaudeCodeSetup.errors.githubRequired'));
return;
}
setIsSubmitting(true);
setErrorMessage(undefined);
try {
if (!setupState.claudeCodeTokenCred) {
await createClaudeCodeTokenCredential(tokenValue.trim());
}
if (!setupState.githubCred && setupState.githubConnection) {
await ensureGithubCredential(setupState.githubConnection.id);
}
onCreated();
close();
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : t('cloudClaudeCodeSetup.errors.submit'),
);
} finally {
setIsSubmitting(false);
}
}, [close, onCreated, setupState, t, tokenValue]);
const githubProfile = githubConnect.profile || resolveGithubProfile(setupState.githubConnection);
return (
<Flexbox className={styles.content} gap={16}>
<Text type="secondary">{t('cloudClaudeCodeSetup.desc')}</Text>
{errorMessage && <Alert showIcon message={errorMessage} type="error" />}
<Flexbox className={styles.section} gap={12}>
<Flexbox gap={4}>
<Text strong>{t('cloudClaudeCodeSetup.token.title')}</Text>
<Text type="secondary">{t('cloudClaudeCodeSetup.token.desc')}</Text>
</Flexbox>
{setupState.claudeCodeTokenCred ? (
<Alert showIcon message={t('cloudClaudeCodeSetup.token.connected')} type="success" />
) : (
<Flexbox gap={12}>
<Alert showIcon message={t('cloudClaudeCodeSetup.token.hint')} type="info" />
<Text type="secondary">
{t('cloudClaudeCodeSetup.token.commandPrefix')}{' '}
<span className={styles.code}>set token</span>
</Text>
<Input.Password
placeholder={t('cloudClaudeCodeSetup.token.placeholder')}
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
</Flexbox>
)}
</Flexbox>
<Flexbox className={styles.section} gap={12}>
<Flexbox gap={4}>
<Text strong>{t('cloudClaudeCodeSetup.github.title')}</Text>
<Text type="secondary">{t('cloudClaudeCodeSetup.github.desc')}</Text>
</Flexbox>
{setupState.githubCred ? (
<Alert showIcon message={t('cloudClaudeCodeSetup.github.connected')} type="success" />
) : setupState.githubConnection ? (
<Alert showIcon message={t('cloudClaudeCodeSetup.github.authorized')} type="success" />
) : isRefreshing ? (
<Flexbox align="center" justify="center" style={{ minHeight: 48 }}>
<Spin />
</Flexbox>
) : (
<Flexbox gap={12}>
<SocialConnectButton
isConnecting={githubConnect.isConnecting}
isDisconnecting={false}
profile={githubProfile}
provider="github"
onConnect={githubConnect.connect}
onDisconnect={() => undefined}
/>
<Text type="secondary">{t('cloudClaudeCodeSetup.github.footer')}</Text>
</Flexbox>
)}
</Flexbox>
<div className={styles.actions}>
<Button onClick={close}>{t('cloudClaudeCodeSetup.actions.cancel')}</Button>
<Button loading={isSubmitting} type="primary" onClick={() => void handleSubmit()}>
{t('cloudClaudeCodeSetup.actions.confirm')}
</Button>
</div>
</Flexbox>
);
};
export const openCloudClaudeCodeSetupModal = async (): Promise<boolean> => {
const initialState = await getCloudClaudeCodeSetupState();
if (initialState.claudeCodeTokenCred && initialState.githubCred) {
return true;
}
if (
initialState.claudeCodeTokenCred &&
initialState.githubConnection &&
!initialState.githubCred
) {
await ensureGithubCredential(initialState.githubConnection.id);
return true;
}
return new Promise<boolean>((resolve) => {
let isResolved = false;
const complete = (result: boolean) => {
if (isResolved) return;
isResolved = true;
resolve(result);
};
createModal({
afterClose: () => complete(false),
content: (
<CloudClaudeCodeSetupModalContent
initialState={initialState}
onCreated={() => complete(true)}
/>
),
footer: null,
maskClosable: false,
title: t('cloudClaudeCodeSetup.title', { ns: 'chat' }),
width: 'min(92vw, 640px)',
});
});
};
export const prepareCloudClaudeCodeSetup = async (): Promise<boolean> => {
return openCloudClaudeCodeSetupModal();
};
export type { CloudClaudeCodeSetupState };
+5 -2
View File
@@ -84,14 +84,17 @@ export const signInternalJWT = async (): Promise<string> => {
* Used by server-side sandbox execution to authenticate CLI commands.
* The token contains `sub: userId` and passes standard OIDC JWT validation.
*/
export const signUserJWT = async (userId: string): Promise<string> => {
export const signUserJWT = async (
userId: string,
expirationTime: string = '5m',
): Promise<string> => {
const { key, kid } = await getSigningKey();
return new SignJWT({ purpose: 'cli-sandbox' })
.setProtectedHeader({ alg: 'RS256', kid })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime('5m')
.setExpirationTime(expirationTime)
.sign(key);
};
+26
View File
@@ -317,6 +317,32 @@ export default {
'codexInstallGuide.title': 'Install Codex CLI',
'newAgent': 'Create Agent',
'newClaudeCodeAgent': 'Add Claude Code',
'cloudClaudeCodeSetup.actions.cancel': 'Cancel',
'cloudClaudeCodeSetup.actions.confirm': 'Continue',
'cloudClaudeCodeSetup.desc':
'Before creating the agent, complete the Claude Code token and GitHub authorization setup.',
'cloudClaudeCodeSetup.errors.githubRequired': 'Connect GitHub before continuing.',
'cloudClaudeCodeSetup.errors.refresh': 'Failed to refresh the current authorization status.',
'cloudClaudeCodeSetup.errors.submit': 'Failed to save the Cloud Claude Code prerequisites.',
'cloudClaudeCodeSetup.errors.tokenRequired': 'Paste your CLAUDE_CODE_OAUTH_TOKEN first.',
'cloudClaudeCodeSetup.github.authorized':
'GitHub authorization is already available. We will create the injectable credential automatically when you continue.',
'cloudClaudeCodeSetup.github.connected': 'Detected an available GitHub credential.',
'cloudClaudeCodeSetup.github.desc':
'Claude Code will use your GitHub identity to access repositories and write code for you.',
'cloudClaudeCodeSetup.github.footer':
'After GitHub authorization succeeds, continue creating the agent.',
'cloudClaudeCodeSetup.github.title': 'GitHub Authorization',
'cloudClaudeCodeSetup.title': 'Enable Cloud Claude Code',
'cloudClaudeCodeSetup.token.commandPrefix': 'Run this inside your Claude Code session',
'cloudClaudeCodeSetup.token.connected':
'Detected an existing CLAUDE_CODE_OAUTH_TOKEN and will reuse it.',
'cloudClaudeCodeSetup.token.desc': 'This token lets Cloud Claude Code run on your behalf.',
'cloudClaudeCodeSetup.token.hint':
'If you do not have the token yet, get the long-lived credential from your own Claude Code first.',
'cloudClaudeCodeSetup.token.placeholder': 'Paste CLAUDE_CODE_OAUTH_TOKEN',
'cloudClaudeCodeSetup.token.title': 'Claude Code Token',
'newCloudClaudeCode': 'Add Cloud Claude Code',
'newCodexAgent': 'Add Codex',
'newGroupChat': 'Create Group',
'newPage': 'Create Page',
@@ -27,6 +27,7 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
// Create menu items
const {
createAgentMenuItem,
createCloudClaudeCodeMenuItem,
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
isLoading,
@@ -34,6 +35,7 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
const addMenuItems = useMemo(() => {
const heterogeneousItems = createHeterogeneousAgentMenuItems();
const cloudCCItem = createCloudClaudeCodeMenuItem();
return [
createAgentMenuItem(),
@@ -41,8 +43,14 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
...(heterogeneousItems.length > 0
? [{ type: 'divider' as const }, ...heterogeneousItems]
: []),
...(cloudCCItem ? [{ type: 'divider' as const }, cloudCCItem] : []),
];
}, [createAgentMenuItem, createGroupChatMenuItem, createHeterogeneousAgentMenuItems]);
}, [
createAgentMenuItem,
createCloudClaudeCodeMenuItem,
createGroupChatMenuItem,
createHeterogeneousAgentMenuItems,
]);
const handleOpenConfigGroupModal = useCallback(() => {
openConfigGroupModal();
@@ -1,5 +1,8 @@
import { isDesktop } from '@lobechat/const';
import { HETEROGENEOUS_AGENT_CLIENT_CONFIGS } from '@lobechat/heterogeneous-agents/client';
import {
getHeterogeneousAgentClientConfig,
HETEROGENEOUS_AGENT_CLIENT_CONFIGS,
} from '@lobechat/heterogeneous-agents/client';
import { Icon } from '@lobehub/ui';
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
import { App } from 'antd';
@@ -12,6 +15,7 @@ import useSWRMutation from 'swr/mutation';
import { useGroupTemplates } from '@/components/ChatGroupWizard/templates';
import { DEFAULT_CHAT_GROUP_CHAT_CONFIG } from '@/const/settings';
import { prepareCloudClaudeCodeSetup } from '@/features/CloudClaudeCode/SetupModal';
import { useOptionalAgentModal } from '@/routes/(main)/home/_layout/Body/Agent/ModalProvider';
import type { CreateAgentParams } from '@/services/agent';
import type { GroupMemberConfig } from '@/services/chatGroup';
@@ -275,6 +279,44 @@ export const useCreateMenuItems = () => {
[t, createHeterogeneousAgent],
);
/**
* Create Cloud Claude Code agent menu item (Web only)
*/
const createCloudClaudeCodeMenuItem = useCallback(
(options?: CreateAgentOptions): ItemType | null => {
if (isDesktop) return null;
return {
icon: <Icon icon={BotIcon} />,
key: 'newCloudClaudeCode',
label: t('newCloudClaudeCode'),
onClick: async (info) => {
info.domEvent?.stopPropagation();
const isReady = await prepareCloudClaudeCodeSetup();
if (!isReady) return;
const claudeCodeConfig = getHeterogeneousAgentClientConfig('claude-code');
const result = await storeCreateAgent({
config: {
agencyConfig: {
heterogeneousProvider: {
type: 'cloud-claude-code',
},
},
avatar: claudeCodeConfig?.avatar || '🤖',
systemRole: '',
title: 'Cloud Claude Code',
},
groupId: options?.groupId,
});
await refreshAgentList();
navigate(`/agent/${result.agentId}`);
},
};
},
[t, storeCreateAgent, refreshAgentList, navigate],
);
/**
* Create group chat menu item
* Creates an empty group and navigates to its profile page
@@ -366,6 +408,7 @@ export const useCreateMenuItems = () => {
configMenuItem,
createAgent,
createAgentMenuItem,
createCloudClaudeCodeMenuItem,
createEmptyGroup,
createGroupChatMenuItem,
createGroupFromTemplate,
@@ -0,0 +1,179 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useCreateMenuItems } from './useCreateMenuItems';
const createAgentMock = vi.hoisted(() =>
vi.fn().mockResolvedValue({ agentId: 'agent-cloud-claude' }),
);
const refreshAgentListMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const addGroupMock = vi.hoisted(() => vi.fn());
const switchToGroupMock = vi.hoisted(() => vi.fn());
const createGroupMock = vi.hoisted(() => vi.fn());
const loadGroupsMock = vi.hoisted(() => vi.fn());
const createNewPageMock = vi.hoisted(() => vi.fn());
const messageErrorMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
const prepareCloudClaudeCodeSetupMock = vi.hoisted(() => vi.fn());
vi.mock('@lobechat/const', () => ({
isDesktop: false,
}));
vi.mock('@lobechat/heterogeneous-agents/client', () => ({
HETEROGENEOUS_AGENT_CLIENT_CONFIGS: [],
getHeterogeneousAgentClientConfig: vi.fn(() => ({
avatar: 'claude-desktop-avatar',
})),
}));
vi.mock('@lobehub/ui', () => ({
Icon: () => null,
}));
vi.mock('@lobehub/ui/icons', () => ({
GroupBotSquareIcon: () => null,
}));
vi.mock('antd', () => ({
App: {
useApp: () => ({
message: { error: messageErrorMock },
notification: { error: vi.fn() },
}),
},
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}));
vi.mock('swr/mutation', () => ({
default: () => ({
isMutating: false,
trigger: vi.fn(),
}),
}));
vi.mock('@/components/ChatGroupWizard/templates', () => ({
useGroupTemplates: () => [],
}));
vi.mock('@/features/CloudClaudeCode/SetupModal', () => ({
prepareCloudClaudeCodeSetup: prepareCloudClaudeCodeSetupMock,
}));
vi.mock('@/routes/(main)/home/_layout/Body/Agent/ModalProvider', () => ({
useOptionalAgentModal: () => undefined,
}));
vi.mock('@/services/chatGroup', () => ({
chatGroupService: {
createGroupWithMembers: vi.fn(),
},
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
createAgent: createAgentMock,
}),
}));
vi.mock('@/store/agentGroup', () => ({
useAgentGroupStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
createGroup: createGroupMock,
loadGroups: loadGroupsMock,
}),
}));
vi.mock('@/store/home', () => ({
useHomeStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
addGroup: addGroupMock,
refreshAgentList: refreshAgentListMock,
switchToGroup: switchToGroupMock,
}),
}));
vi.mock('@/store/page', () => ({
usePageStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
createNewPage: createNewPageMock,
}),
}));
const isActionItem = (
item: unknown,
): item is {
key: string;
onClick?: (info: { domEvent?: { stopPropagation?: () => void } }) => Promise<void>;
} => !!item && typeof item === 'object' && 'key' in item;
describe('useCreateMenuItems (web)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates the Cloud Claude Code agent only after setup succeeds', async () => {
prepareCloudClaudeCodeSetupMock.mockResolvedValue(true);
const { result } = renderHook(() => useCreateMenuItems());
const item = result.current.createCloudClaudeCodeMenuItem();
if (!isActionItem(item)) {
throw new Error('Expected Cloud Claude Code menu item');
}
await act(async () => {
await item.onClick?.({ domEvent: { stopPropagation: vi.fn() } });
});
expect(prepareCloudClaudeCodeSetupMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).toHaveBeenCalledWith({
config: {
agencyConfig: {
heterogeneousProvider: {
type: 'cloud-claude-code',
},
},
avatar: 'claude-desktop-avatar',
systemRole: '',
title: 'Cloud Claude Code',
},
groupId: undefined,
});
expect(refreshAgentListMock).toHaveBeenCalled();
expect(navigateMock).toHaveBeenCalledWith('/agent/agent-cloud-claude');
});
it('does not create the Cloud Claude Code agent when setup is cancelled', async () => {
prepareCloudClaudeCodeSetupMock.mockResolvedValue(false);
const { result } = renderHook(() => useCreateMenuItems());
const item = result.current.createCloudClaudeCodeMenuItem();
if (!isActionItem(item)) {
throw new Error('Expected Cloud Claude Code menu item');
}
await act(async () => {
await item.onClick?.({ domEvent: { stopPropagation: vi.fn() } });
});
expect(prepareCloudClaudeCodeSetupMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).not.toHaveBeenCalled();
expect(refreshAgentListMock).not.toHaveBeenCalled();
expect(navigateMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,229 @@
/* eslint-disable no-console */
import debug from 'debug';
import { z } from 'zod';
import { MessageModel } from '@/database/models/message';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { signUserJWT } from '@/libs/trpc/utils/internalJwt';
import {
buildSandboxWrapperCommand,
CloudCCMessagePersistence,
} from '@/server/services/cloudClaudeCode';
import { FileService } from '@/server/services/file';
import { MarketService } from '@/server/services/market';
import { ServerSandboxService } from '@/server/services/sandbox';
const log = debug('lobe-server:cloud-claude-code-router');
const cloudCCProcedure = authedProcedure.use(serverDatabase);
const IngestSchema = z.object({
/** Agent ID for the messages */
agentId: z.string().optional(),
/** Existing assistant message ID to reuse for the first ingested step */
assistantMessageId: z.string().optional(),
/** One complete step's worth of raw CC stream-json lines */
lines: z.array(z.any()).min(1),
/** Target topic ID */
topicId: z.string(),
});
const StartSchema = z.object({
/** Agent ID */
agentId: z.string(),
/** Existing assistant message ID to reuse for the first ingested step */
assistantMessageId: z.string().optional(),
/** User prompt */
prompt: z.string(),
/** Resume session ID for multi-turn */
resumeSessionId: z.string().optional(),
/** Target topic ID */
topicId: z.string(),
});
const DebugLogSchema = z.object({
agentId: z.string().optional(),
payload: z.record(z.string(), z.any()),
phase: z.string(),
runId: z.string().optional(),
topicId: z.string(),
});
const RunStatusSchema = z.object({
agentId: z.string().optional(),
assistantMessageId: z.string(),
errorMessage: z.string().optional(),
runId: z.string().optional(),
status: z.enum(['running', 'completed', 'failed']),
topicId: z.string(),
});
export const cloudClaudeCodeRouter = router({
debugLog: cloudCCProcedure.input(DebugLogSchema).mutation(async ({ input }) => {
const { topicId, agentId, runId, phase, payload } = input;
log(
'debugLog: topicId=%s, agentId=%s, runId=%s, phase=%s, payload=%O',
topicId,
agentId,
runId,
phase,
payload,
);
console.log(
'[CloudCC Debug] topicId=%s agentId=%s runId=%s phase=%s payload=%s',
topicId,
agentId,
runId,
phase,
JSON.stringify(payload),
);
return { ok: true };
}),
updateRunStatus: cloudCCProcedure.input(RunStatusSchema).mutation(async ({ input, ctx }) => {
const { assistantMessageId, status, runId, errorMessage } = input;
const messageModel = new MessageModel(ctx.serverDB, ctx.userId);
const now = new Date().toISOString();
await messageModel.updateMetadata(assistantMessageId, {
cloudClaudeCodeCompletedAt: status === 'completed' ? now : undefined,
cloudClaudeCodeError: errorMessage,
cloudClaudeCodeRunId: runId,
cloudClaudeCodeRunStatus: status,
cloudClaudeCodeStartedAt: status === 'running' ? now : undefined,
});
log('updateRunStatus: assistant=%s, status=%s, runId=%s', assistantMessageId, status, runId);
return { ok: true };
}),
/**
* Receive a batch of raw Claude Code stream-json lines (one step),
* convert via ClaudeCodeAdapter, and persist as structured messages.
*/
ingest: cloudCCProcedure.input(IngestSchema).mutation(async ({ input, ctx }) => {
const { topicId, agentId, assistantMessageId, lines } = input;
log('ingest: topicId=%s, agentId=%s, lines=%d', topicId, agentId, lines.length);
const persistence = new CloudCCMessagePersistence(
ctx.serverDB,
ctx.userId,
topicId,
agentId,
assistantMessageId,
);
const result = await persistence.processBatch(lines);
log(
'ingest done: assistantMsg=%s, toolMsgs=%d, sessionId=%s',
result.assistantMessageId,
result.toolMessageIds.length,
result.sessionId,
);
return result;
}),
/**
* Start a Cloud Claude Code session in the sandbox.
* Generates JWT, builds wrapper command, and invokes sandbox runCommand.
*/
start: cloudCCProcedure.input(StartSchema).mutation(async ({ input, ctx }) => {
const { topicId, agentId, assistantMessageId, prompt, resumeSessionId } = input;
console.log(
'[CloudCC Server] start: topicId=%s, agentId=%s, prompt=%s',
topicId,
agentId,
prompt.slice(0, 80),
);
// 1. Generate short-lived JWT for sandbox → server callback
const jwt = await signUserJWT(ctx.userId, '2h');
// FIXME: hardcoded tunnel URL for local testing — revert before merge
const serverUrl = 'https://sonic-fridge-detected-interested.trycloudflare.com';
console.log('[CloudCC Server] serverUrl:', serverUrl);
// 2. Build the inline wrapper command
const wrapperCommand = buildSandboxWrapperCommand({
agentId,
assistantMessageId,
prompt,
resumeSessionId,
topicId,
});
// 3. Build the full command with env vars injected
const envPrefix = [
`LOBEHUB_JWT=${jwt}`,
`LOBEHUB_SERVER=${serverUrl}`,
'LOBEHUB_CLOUD_CC_DEBUG=1',
'GITHUB_TOKEN=${GITHUB_TOKEN:-$GITHUB_ACCESS_TOKEN}',
]
.filter(Boolean)
.join(' ');
const fullCommand = `${envPrefix} ${wrapperCommand}`;
log('start: command length=%d', fullCommand.length);
// 4. Call sandbox runCommand (fire-and-forget)
const marketService = new MarketService({ userInfo: { userId: ctx.userId } });
const fileService = new FileService(ctx.serverDB, ctx.userId);
const sandboxService = new ServerSandboxService({
fileService,
marketService,
topicId,
userId: ctx.userId,
});
// 4a. Inject Claude Code auth first. Mixing GitHub creds into the same
// inject call can interfere with Claude CLI login state in the sandbox.
console.log('[CloudCC Server] injecting CLAUDE_CODE_OAUTH_TOKEN to sandbox...');
try {
const result = await marketService.market.creds.inject({
keys: ['CLAUDE_CODE_OAUTH_TOKEN'],
sandbox: true,
topicId,
userId: ctx.userId,
});
console.log('[CloudCC Server] creds injected OK, notFound:', result.notFound);
} catch (e) {
console.error('[CloudCC Server] creds injection failed (CC may not be authenticated):', e);
}
// 4b. Inject GitHub creds separately so a missing/extra GitHub key does
// not affect Claude Code's own auth bootstrap.
console.log('[CloudCC Server] injecting GitHub creds to sandbox...');
try {
const result = await marketService.market.creds.inject({
keys: ['GITHUB', 'GITHUB_TOKEN'],
sandbox: true,
topicId,
userId: ctx.userId,
});
console.log('[CloudCC Server] GitHub creds injected OK, notFound:', result.notFound);
} catch (e) {
console.error('[CloudCC Server] GitHub creds injection failed:', e);
}
console.log('[CloudCC Server] calling sandbox runCommand, command length:', fullCommand.length);
// Await sandbox runCommand — blocks until CC finishes.
// Frontend calls this mutation and uses .finally() to know when CC is done.
const sandboxResult = await sandboxService.callTool('runCommand', { command: fullCommand });
console.log(
'[CloudCC Server] sandbox runCommand result:',
JSON.stringify(sandboxResult).slice(0, 500),
);
return {
serverUrl,
topicId,
};
}),
});
+2
View File
@@ -26,6 +26,7 @@ import { apiKeyRouter } from './apiKey';
import { botMessageRouter } from './botMessage';
import { briefRouter } from './brief';
import { chunkRouter } from './chunk';
import { cloudClaudeCodeRouter } from './cloudClaudeCode';
import { comfyuiRouter } from './comfyui';
import { configRouter } from './config';
import { deviceRouter } from './device';
@@ -67,6 +68,7 @@ export const lambdaRouter = router({
agent: agentRouter,
agentBotProvider: agentBotProviderRouter,
agentNotify: agentNotifyRouter,
cloudClaudeCode: cloudClaudeCodeRouter,
botMessage: botMessageRouter,
agentCronJob: agentCronJobRouter,
agentDocument: agentDocumentRouter,
@@ -0,0 +1,2 @@
export { CloudCCMessagePersistence } from './messagePersistence';
export { buildSandboxWrapperCommand } from './sandboxWrapper';
@@ -0,0 +1,263 @@
// @vitest-environment node
import { createAdapter } from '@lobechat/heterogeneous-agents';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageModel } from '@/database/models/message';
import { CloudCCMessagePersistence } from './messagePersistence';
vi.mock('@lobechat/heterogeneous-agents', () => ({
createAdapter: vi.fn(),
}));
vi.mock('@/database/models/message', () => ({
MessageModel: vi.fn(),
}));
describe('CloudCCMessagePersistence', () => {
const mockCreate = vi.fn();
const mockQuery = vi.fn();
const mockUpdate = vi.fn();
const mockUpdateMetadata = vi.fn();
const mockUpdatePluginState = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(MessageModel).mockImplementation(
() =>
({
create: mockCreate,
query: mockQuery,
update: mockUpdate,
updateMetadata: mockUpdateMetadata,
updatePluginState: mockUpdatePluginState,
}) as any,
);
});
it('reuses the existing assistant message for the first ingested step', async () => {
mockQuery.mockResolvedValue([]);
vi.mocked(createAdapter).mockReturnValue({
adapt: vi
.fn()
.mockReturnValueOnce([
{
data: { model: 'claude-sonnet-4-6', provider: 'cloud-claude-code' },
type: 'stream_start',
},
])
.mockReturnValueOnce([
{
data: { chunkType: 'text', content: 'Hello from Cloud CC' },
type: 'stream_chunk',
},
]),
flush: vi.fn().mockReturnValue([]),
sessionId: 'cc-session-1',
} as any);
const persistence = new CloudCCMessagePersistence(
{} as any,
'user-1',
'topic-1',
'agent-1',
'assistant-existing',
);
const result = await persistence.processBatch([{ type: 'assistant' }, { type: 'assistant' }]);
expect(mockCreate).not.toHaveBeenCalled();
expect(mockUpdate).toHaveBeenCalledWith('assistant-existing', {
content: 'Hello from Cloud CC',
model: 'claude-sonnet-4-6',
});
expect(result).toEqual({
assistantMessageId: 'assistant-existing',
sessionId: 'cc-session-1',
toolMessageIds: [],
});
});
it('ignores subagent-only batches so they do not mutate the main assistant', async () => {
mockQuery.mockResolvedValue([]);
vi.mocked(createAdapter).mockReturnValue({
adapt: vi
.fn()
.mockReturnValueOnce([
{
data: {
chunkType: 'text',
content: 'subagent summary',
subagent: {
parentToolCallId: 'toolu-parent',
subagentMessageId: 'msg-sub-1',
},
},
type: 'stream_chunk',
},
])
.mockReturnValueOnce([
{
data: {
content: 'tool result',
subagent: { parentToolCallId: 'toolu-parent' },
toolCallId: 'toolu-child',
},
type: 'tool_result',
},
]),
flush: vi.fn().mockReturnValue([]),
sessionId: 'cc-session-subagent',
} as any);
const persistence = new CloudCCMessagePersistence(
{} as any,
'user-1',
'topic-1',
'agent-1',
'assistant-existing',
);
const result = await persistence.processBatch([{ type: 'assistant' }, { type: 'user' }]);
expect(mockCreate).not.toHaveBeenCalled();
expect(mockUpdate).not.toHaveBeenCalled();
expect(mockUpdateMetadata).not.toHaveBeenCalled();
expect(result).toEqual({
assistantMessageId: 'assistant-existing',
sessionId: 'cc-session-subagent',
toolMessageIds: [],
});
});
it('chains later main-agent steps after the latest persisted message', async () => {
mockQuery.mockResolvedValue([{ id: 'assistant-prev' }, { id: 'tool-prev' }]);
mockCreate.mockResolvedValue({ id: 'assistant-step-2' });
vi.mocked(createAdapter).mockReturnValue({
adapt: vi
.fn()
.mockReturnValueOnce([
{
data: { model: 'claude-sonnet-4-6', provider: 'cloud-claude-code' },
type: 'stream_start',
},
])
.mockReturnValueOnce([
{
data: { chunkType: 'text', content: 'Final answer' },
type: 'stream_chunk',
},
]),
flush: vi.fn().mockReturnValue([]),
sessionId: 'cc-session-step-2',
} as any);
const persistence = new CloudCCMessagePersistence({} as any, 'user-1', 'topic-1', 'agent-1');
const result = await persistence.processBatch([{ type: 'assistant' }, { type: 'assistant' }]);
expect(mockCreate).toHaveBeenCalledWith({
agentId: 'agent-1',
content: '',
model: 'claude-sonnet-4-6',
parentId: 'tool-prev',
provider: 'cloud-claude-code',
role: 'assistant',
topicId: 'topic-1',
});
expect(mockUpdate).toHaveBeenCalledWith('assistant-step-2', {
content: 'Final answer',
model: 'claude-sonnet-4-6',
});
expect(result).toEqual({
assistantMessageId: 'assistant-step-2',
sessionId: 'cc-session-step-2',
toolMessageIds: [],
});
});
it('pre-registers tools on the assistant before creating tool rows', async () => {
mockQuery.mockResolvedValue([]);
mockCreate
.mockResolvedValueOnce({ id: 'assistant-step-1' })
.mockResolvedValueOnce({ id: 'tool-msg-1' });
vi.mocked(createAdapter).mockReturnValue({
adapt: vi
.fn()
.mockReturnValueOnce([
{
data: { model: 'claude-sonnet-4-6', provider: 'cloud-claude-code' },
type: 'stream_start',
},
])
.mockReturnValueOnce([
{
data: {
chunkType: 'tools_calling',
toolsCalling: [
{
apiName: 'WebFetch',
arguments: '{"url":"https://github.com/lobehub/lobehub"}',
id: 'tool-call-1',
identifier: 'claude-code',
type: 'default',
},
],
},
type: 'stream_chunk',
},
])
.mockReturnValueOnce([
{
data: {
toolCalling: {
apiName: 'WebFetch',
arguments: '{"url":"https://github.com/lobehub/lobehub"}',
id: 'tool-call-1',
identifier: 'claude-code',
type: 'default',
},
},
type: 'tool_start',
},
]),
flush: vi.fn().mockReturnValue([]),
sessionId: 'cc-session-tools',
} as any);
await new CloudCCMessagePersistence({} as any, 'user-1', 'topic-1', 'agent-1').processBatch([
{ type: 'assistant' },
{ type: 'assistant' },
{ type: 'assistant' },
]);
expect(mockUpdate).toHaveBeenNthCalledWith(
1,
'assistant-step-1',
expect.objectContaining({
model: 'claude-sonnet-4-6',
tools: [
expect.objectContaining({
apiName: 'WebFetch',
id: 'tool-call-1',
}),
],
}),
);
expect(mockCreate).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
parentId: 'assistant-step-1',
role: 'tool',
tool_call_id: 'tool-call-1',
}),
);
});
});
@@ -0,0 +1,311 @@
import type {
HeterogeneousAgentEvent,
StreamChunkData,
ToolCallPayload,
ToolResultData,
} from '@lobechat/heterogeneous-agents';
import { createAdapter } from '@lobechat/heterogeneous-agents';
import type { ChatToolPayload } from '@lobechat/types';
import { MessageModel } from '@/database/models/message';
/**
* Server-side persistence for Cloud Claude Code.
*
* Each `processBatch` call handles one complete step's worth of raw
* stream-json lines. A fresh ClaudeCodeAdapter is created per call,
* so no cross-request state is needed.
*
* The adapter converts raw CC CLI NDJSON lines into HeterogeneousAgentEvent[],
* and this class maps those events to DB writes (assistant + tool messages).
*/
export class CloudCCMessagePersistence {
private messageModel: MessageModel;
private initialAssistantMessageId?: string;
private findLatestMessageId = async () => {
const messages = await this.messageModel.query({
agentId: this.agentId,
topicId: this.topicId,
});
return messages.at(-1)?.id;
};
private updateAssistantSnapshot = async (params: {
assistantMessageId: string;
content: string;
model?: string;
reasoning: string;
tools: ChatToolPayload[];
}) => {
const { assistantMessageId, content, model, reasoning, tools } = params;
const updatePayload: Record<string, any> = {};
if (content) updatePayload.content = content;
if (model) updatePayload.model = model;
if (tools.length > 0) updatePayload.tools = tools;
if (Object.keys(updatePayload).length > 0) {
await this.messageModel.update(assistantMessageId, updatePayload);
}
if (reasoning) {
await this.messageModel.updateMetadata(assistantMessageId, {
reasoning: { content: reasoning },
});
}
};
constructor(
private readonly serverDB: any,
private readonly userId: string,
private readonly topicId: string,
private readonly agentId?: string,
initialAssistantMessageId?: string,
) {
this.messageModel = new MessageModel(serverDB, userId);
this.initialAssistantMessageId = initialAssistantMessageId;
}
/**
* Process a batch of raw stream-json lines (one complete CC step).
*
* Flow: raw lines → ClaudeCodeAdapter.adapt() → HeterogeneousAgentEvent[] → DB writes
*/
async processBatch(rawLines: any[]): Promise<{
assistantMessageId?: string;
sessionId?: string;
toolMessageIds: string[];
}> {
// 1. Create a fresh adapter per batch (stateless across requests)
const adapter = createAdapter('claude-code');
// 2. Feed all lines through the adapter
const events: HeterogeneousAgentEvent[] = [];
for (const line of rawLines) {
events.push(...adapter.adapt(line));
}
events.push(...adapter.flush());
// 3. Process events into DB writes
let assistantMessageId = this.initialAssistantMessageId;
let content = '';
let reasoning = '';
const tools: ChatToolPayload[] = [];
const toolMessageIds: string[] = [];
// Map toolCallId → tool message DB id, for updating tool_result
const toolMsgIdByCallId = new Map<string, string>();
let hasMainAssistantActivity = false;
let model: string | undefined;
let provider: string | undefined;
for (const event of events) {
switch (event.type) {
case 'stream_start': {
const data = event.data as { model?: string; provider?: string };
model = data.model;
provider = data.provider || 'cloud-claude-code';
if (!assistantMessageId) {
const parentId = await this.findLatestMessageId();
const msg = await this.messageModel.create({
agentId: this.agentId,
content: '',
model,
parentId,
provider,
role: 'assistant',
topicId: this.topicId,
});
assistantMessageId = msg.id;
}
break;
}
case 'stream_chunk': {
const chunk = event.data as StreamChunkData;
if (chunk.subagent) break;
if (chunk.chunkType === 'text' && chunk.content) {
hasMainAssistantActivity = true;
content += chunk.content;
}
if (chunk.chunkType === 'reasoning' && chunk.reasoning) {
reasoning += chunk.reasoning;
}
// tools_calling: register tool calls on the assistant message
if (chunk.chunkType === 'tools_calling' && chunk.toolsCalling) {
hasMainAssistantActivity = true;
let hasFreshTool = false;
for (const tc of chunk.toolsCalling) {
// Only add if not already tracked
if (!tools.some((t) => t.id === tc.id)) {
hasFreshTool = true;
tools.push({
apiName: tc.apiName,
arguments: tc.arguments,
id: tc.id,
identifier: tc.identifier,
type: tc.type as ChatToolPayload['type'],
});
} else {
// Update arguments for existing tool (streaming partial → complete)
const existing = tools.find((t) => t.id === tc.id);
if (existing) {
existing.arguments = tc.arguments;
}
}
}
if (assistantMessageId && hasFreshTool) {
await this.updateAssistantSnapshot({
assistantMessageId,
content,
model,
reasoning,
tools,
});
}
}
break;
}
case 'tool_start': {
const { toolCalling, subagent } = event.data as {
subagent?: any;
toolCalling: ToolCallPayload;
};
// Skip subagent tools for now (future iteration)
if (subagent) break;
if (!toolCalling) break;
// Create tool message
const toolMsg = await this.messageModel.create({
agentId: this.agentId,
content: '',
parentId: assistantMessageId,
plugin: {
apiName: toolCalling.apiName,
arguments: toolCalling.arguments || '',
identifier: toolCalling.identifier,
type: (toolCalling.type || 'default') as ChatToolPayload['type'],
},
role: 'tool',
tool_call_id: toolCalling.id,
topicId: this.topicId,
});
toolMessageIds.push(toolMsg.id);
toolMsgIdByCallId.set(toolCalling.id, toolMsg.id);
// Ensure this tool is in the assistant's tools array
if (!tools.some((t) => t.id === toolCalling.id)) {
tools.push({
apiName: toolCalling.apiName,
arguments: toolCalling.arguments || '',
id: toolCalling.id,
identifier: toolCalling.identifier,
type: (toolCalling.type || 'default') as ChatToolPayload['type'],
});
}
break;
}
case 'tool_result': {
const {
toolCallId,
content: resultContent,
pluginState,
subagent,
} = event.data as ToolResultData;
// Skip subagent tool results for now
if (subagent) break;
const toolMsgId = toolMsgIdByCallId.get(toolCallId);
if (toolMsgId) {
// Update tool message with result content
await this.messageModel.update(toolMsgId, { content: resultContent || '' });
// Update pluginState if present
if (pluginState) {
await this.messageModel.updatePluginState(toolMsgId, pluginState);
}
}
break;
}
case 'step_complete': {
const stepData = event.data as { model?: string; usage?: Record<string, unknown> };
// Update usage metadata on the assistant message
if (assistantMessageId && stepData.usage) {
await this.messageModel.updateMetadata(assistantMessageId, {
usage: stepData.usage,
});
}
if (stepData.model) {
model = stepData.model;
}
break;
}
case 'stream_end':
case 'agent_runtime_end': {
// Finalize: handled after the loop
break;
}
case 'error': {
hasMainAssistantActivity = true;
// Persist error on assistant message
if (assistantMessageId) {
const errorData = event.data as { message?: string };
await this.messageModel.update(assistantMessageId, {
error: {
body: { message: errorData.message || 'Cloud Claude Code error' },
type: 'AgentRuntimeError',
},
});
}
break;
}
}
}
// 4. Finalize assistant message with accumulated content + tools
if (assistantMessageId && hasMainAssistantActivity) {
// Write tools with result_msg_id backfilled
if (tools.length > 0) {
const toolsWithResultIds = tools.map((t) => ({
...t,
result_msg_id: toolMsgIdByCallId.get(t.id),
}));
await this.updateAssistantSnapshot({
assistantMessageId,
content,
model,
reasoning,
tools: toolsWithResultIds,
});
} else {
await this.updateAssistantSnapshot({
assistantMessageId,
content,
model,
reasoning,
tools,
});
}
}
return {
assistantMessageId,
sessionId: adapter.sessionId,
toolMessageIds,
};
}
}
@@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { buildSandboxWrapperCommand } from './sandboxWrapper';
describe('buildSandboxWrapperCommand', () => {
it('cuts main-agent batches on stream_event message_start boundaries', () => {
const command = buildSandboxWrapperCommand({
agentId: 'agent-1',
prompt: 'summarize this repo',
topicId: 'topic-1',
});
expect(command).toContain('streamEventMessageId');
expect(command).toContain('message_start');
expect(command).toContain('nextMainMessageId = streamEventMessageId || assistantMessageId');
expect(command).toContain('let processing = Promise.resolve()');
expect(command).toContain('then(() => processLine(raw))');
});
it('posts structured debug logs when debug mode is enabled', () => {
const command = buildSandboxWrapperCommand({
agentId: 'agent-1',
assistantMessageId: 'assistant-1',
prompt: 'summarize this repo',
topicId: 'topic-1',
});
expect(command).toContain('LOBEHUB_CLOUD_CC_DEBUG');
expect(command).toContain('function postDebug(phase, payload)');
expect(command).toContain('/trpc/lambda/cloudClaudeCode.debugLog');
expect(command).toContain('/trpc/lambda/cloudClaudeCode.updateRunStatus');
expect(command).toContain('postRunStatus');
expect(command).toContain('completed');
expect(command).toContain('boundary');
expect(command).toContain('flush:start');
});
});
@@ -0,0 +1,262 @@
/**
* Generate the inline Node.js script that runs inside the sandbox.
*
* The script:
* 1. Spawns `claude` CLI with stream-json output
* 2. Reads stdout line by line
* 3. Detects step boundaries (assistant message.id changes)
* 4. POSTs each step's lines to the TRPC ingest endpoint via curl-style HTTP
*
* The script is injected via `runCommand` as `node -e "<script>"`.
* Environment variables (LOBEHUB_JWT, LOBEHUB_SERVER, CLAUDE_CODE_OAUTH_TOKEN)
* are injected by preprocessLhCommand or directly via runCommand env.
*/
export function buildSandboxWrapperCommand(params: {
agentId: string;
assistantMessageId?: string;
prompt: string;
resumeSessionId?: string;
topicId: string;
}): string {
const { topicId, agentId, assistantMessageId, prompt, resumeSessionId } = params;
const escapeForSingleQuotedJs = (value: string) =>
value.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
// Escape single quotes in prompt for safe embedding in JS string
const escapedPrompt = escapeForSingleQuotedJs(prompt);
const escapedAgentId = escapeForSingleQuotedJs(agentId);
const escapedAssistantMessageId = assistantMessageId
? escapeForSingleQuotedJs(assistantMessageId)
: '';
const escapedResumeSessionId = resumeSessionId
? escapeForSingleQuotedJs(resumeSessionId)
: undefined;
const escapedTopicId = escapeForSingleQuotedJs(topicId);
const resumeArgs = escapedResumeSessionId ? `'--resume', '${escapedResumeSessionId}',` : '';
// The inline Node.js script that runs inside the sandbox
const script = `
const { spawn } = require('child_process');
const { createInterface } = require('readline');
const http = require('http');
const https = require('https');
const SERVER = process.env.LOBEHUB_SERVER || 'https://app.lobehub.com';
const JWT = process.env.LOBEHUB_JWT || '';
const DEBUG_ENABLED = process.env.LOBEHUB_CLOUD_CC_DEBUG === '1';
const TOPIC_ID = '${escapedTopicId}';
const AGENT_ID = '${escapedAgentId}';
const INITIAL_ASSISTANT_MESSAGE_ID = '${escapedAssistantMessageId}';
const RUN_ID = [
TOPIC_ID,
AGENT_ID,
INITIAL_ASSISTANT_MESSAGE_ID || 'no-assistant',
String(Date.now()),
].join(':');
function postTrpc(path, input) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
json: input,
});
const url = new URL(SERVER + path);
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request({
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'Oidc-Auth': JWT,
},
}, (res) => {
let data = '';
res.on('data', (d) => data += d);
res.on('end', () => {
if (res.statusCode >= 400) {
console.error('POST failed:', path, res.statusCode, data.slice(0, 200));
}
resolve(data);
});
});
req.on('error', (e) => { console.error('POST error:', path, e.message); resolve(''); });
req.write(body);
req.end();
});
}
function post(lines, assistantMessageId) {
return postTrpc('/trpc/lambda/cloudClaudeCode.ingest', {
topicId: TOPIC_ID,
agentId: AGENT_ID,
...(assistantMessageId ? { assistantMessageId } : {}),
lines,
});
}
async function postDebug(phase, payload) {
if (!DEBUG_ENABLED) return;
await postTrpc('/trpc/lambda/cloudClaudeCode.debugLog', {
topicId: TOPIC_ID,
agentId: AGENT_ID,
phase,
payload,
runId: RUN_ID,
});
}
async function postRunStatus(status, errorMessage) {
if (!INITIAL_ASSISTANT_MESSAGE_ID) return;
await postTrpc('/trpc/lambda/cloudClaudeCode.updateRunStatus', {
topicId: TOPIC_ID,
agentId: AGENT_ID,
assistantMessageId: INITIAL_ASSISTANT_MESSAGE_ID,
...(errorMessage ? { errorMessage } : {}),
runId: RUN_ID,
status,
});
}
const args = [
'-p', '${escapedPrompt}',
'--output-format', 'stream-json',
'--verbose',
'--include-partial-messages',
'--allowedTools', 'WebFetch,WebSearch',
'--permission-mode', 'acceptEdits',
${resumeArgs}
];
const child = spawn('claude', args, {
env: { ...process.env },
stdio: ['inherit', 'pipe', 'inherit'],
});
const rl = createInterface({ input: child.stdout });
let buffer = [];
let curMsgId;
let initialAssistantMessageId = INITIAL_ASSISTANT_MESSAGE_ID || undefined;
let stepCount = 0;
let processing = Promise.resolve();
async function flush(lines) {
if (!lines.length) return;
stepCount++;
const assistantMessageId = initialAssistantMessageId;
initialAssistantMessageId = undefined;
await postDebug('flush:start', {
assistantMessageId: assistantMessageId || null,
bufferLength: lines.length,
firstLineType: lines[0]?.type || null,
lastLineType: lines[lines.length - 1]?.type || null,
stepCount,
});
await post(lines, assistantMessageId);
await postDebug('flush:done', {
assistantMessageId: assistantMessageId || null,
bufferLength: lines.length,
stepCount,
});
console.error('Step ' + stepCount + ': ' + lines.length + ' events posted');
}
async function processLine(raw) {
let line;
try { line = JSON.parse(raw); } catch { return; }
const streamEventMessageId =
line.type === 'stream_event' &&
!line.parent_tool_use_id &&
line.event?.type === 'message_start' &&
line.event?.message?.id
? line.event.message.id
: undefined;
const isSubagentAssistant =
line.type === 'assistant' &&
!!line.parent_tool_use_id &&
line.message &&
line.message.id;
const assistantMessageId =
!isSubagentAssistant &&
line.type === 'assistant' &&
line.message &&
line.message.id
? line.message.id
: undefined;
const nextMainMessageId = streamEventMessageId || assistantMessageId;
const eventType =
line.type === 'stream_event' && line.event && line.event.type
? line.event.type
: undefined;
await postDebug('line', {
assistantMessageId: assistantMessageId || null,
bufferLength: buffer.length,
curMsgId: curMsgId || null,
eventType: eventType || null,
isSubagentAssistant,
lineType: line.type || null,
nextMainMessageId: nextMainMessageId || null,
parentToolUseId: line.parent_tool_use_id || null,
stepCount,
streamEventMessageId: streamEventMessageId || null,
});
if (nextMainMessageId) {
if (curMsgId && nextMainMessageId !== curMsgId) {
const prev = buffer;
buffer = [line];
await postDebug('boundary', {
flushedBufferLength: prev.length,
fromMessageId: curMsgId,
nextMainMessageId,
stepCount,
});
await flush(prev);
} else {
buffer.push(line);
}
curMsgId = nextMainMessageId;
} else {
buffer.push(line);
}
}
rl.on('line', (raw) => {
processing = processing
.then(() => processLine(raw))
.catch((error) => {
console.error('Line processing error:', error && error.message ? error.message : error);
});
});
child.on('close', () => {
processing = processing
.then(async () => {
await postDebug('close:start', {
bufferLength: buffer.length,
curMsgId: curMsgId || null,
stepCount,
});
await flush(buffer);
await postRunStatus('completed');
await postDebug('close:done', {
curMsgId: curMsgId || null,
stepCount,
});
console.error('Done: ' + stepCount + ' step(s)');
})
.catch((error) => {
console.error('Close processing error:', error && error.message ? error.message : error);
});
});
`.trim();
// Return as node -e command
return `node -e '${script.replaceAll("'", "'\\''")}'`;
}
@@ -1,3 +1,4 @@
/* eslint-disable no-console */
// Disable the auto sort key eslint rule to make the code more logic and readable
import { createCallAgentManifest } from '@lobechat/builtin-tool-agent-management';
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
@@ -388,10 +389,197 @@ export class ConversationLifecycleActionImpl {
inputSendErrorMsg: undefined,
});
// ── External agent mode: delegate to heterogeneous agent CLI (desktop only) ──
// ── External agent mode ──
// Per-agent heterogeneousProvider config takes priority over the global gateway mode.
const agentConfig = agentSelectors.getAgentConfigById(agentId)(getAgentStoreState());
const heterogeneousProvider = agentConfig?.agencyConfig?.heterogeneousProvider;
// ── Cloud Claude Code: server-side sandbox execution ──
console.log('[CloudCC] heterogeneousProvider:', JSON.stringify(heterogeneousProvider));
if (heterogeneousProvider?.type === 'cloud-claude-code') {
console.log('[CloudCC] ✅ Entering cloud-claude-code branch');
console.log('[CloudCC] 1. Persisting messages to DB...');
// Persist messages to DB (same pattern as desktop hetero)
let cloudData: SendMessageServerResponse | undefined;
try {
cloudData = await aiChatService.sendMessageInServer(
{
agentId: operationContext.agentId,
groupId: operationContext.groupId ?? undefined,
newAssistantMessage: { provider: 'cloud-claude-code' },
newTopic: !operationContext.topicId
? {
title: message.slice(0, 20) || t('defaultTitle', { ns: 'topic' }),
topicMessageIds: messages.map((m) => m.id),
}
: undefined,
newUserMessage: {
content: message,
editorData,
files: fileIdList,
pageSelections,
parentId,
},
threadId: operationContext.threadId ?? undefined,
topicFilter: this.#getTopicFilter(
operationContext.agentId,
operationContext.groupId ?? undefined,
),
topicId: operationContext.topicId ?? undefined,
},
abortController,
);
} catch (e) {
console.error('[CloudClaudeCode] Failed to persist messages:', e);
this.#get().failOperation(operationId, {
message: e instanceof Error ? e.message : 'Unknown error',
type: 'CloudClaudeCodeError',
});
return;
}
console.log(
'[CloudCC] 2. sendMessageInServer result:',
cloudData ? 'OK' : 'EMPTY',
'topicId:',
cloudData?.topicId,
'assistantMsgId:',
cloudData?.assistantMessageId,
);
if (!cloudData) return;
const cloudContext = {
...operationContext,
topicId: cloudData.topicId ?? operationContext.topicId,
};
// Replace optimistic messages with persisted ones
this.#get().replaceMessages(cloudData.messages, {
action: 'sendMessage/cloudCC',
context: cloudContext,
});
// Handle new topic creation
if (cloudData.isCreateNewTopic && cloudData.topicId) {
if (cloudData.topics) {
const pageSize = systemStatusSelectors.topicPageSize(useGlobalStore.getState());
this.#get().internal_updateTopics(operationContext.agentId, {
groupId: operationContext.groupId,
items: cloudData.topics.items,
pageSize,
total: cloudData.topics.total,
});
}
await this.#get().switchTopic(cloudData.topicId, {
clearNewKey: true,
skipRefreshMessage: true,
});
}
// Clean up temp messages
this.#get().internal_dispatchMessage(
{ ids: [tempId, tempAssistantId], type: 'deleteMessages' },
{ operationId },
);
this.#get().completeOperation(operationId);
this.#get().updateOperationMetadata(operationId, { inputEditorTempState: null });
// Create a child operation so the stop button shows
const { operationId: cloudOpId } = this.#get().startOperation({
context: cloudContext,
label: 'Cloud Claude Code',
metadata: { heterogeneousType: 'cloud-claude-code' },
parentOperationId: operationId,
type: 'execHeterogeneousAgent',
});
if (cloudData.assistantMessageId) {
this.#get().associateMessageWithOperation(cloudData.assistantMessageId, cloudOpId);
}
const hasCloudCCCompleted = (
messages: Array<{ id: string; metadata?: Record<string, any> }>,
) =>
!!cloudData.assistantMessageId &&
messages.some(
(msg) =>
msg.id === cloudData.assistantMessageId &&
msg.metadata?.cloudClaudeCodeRunStatus === 'completed',
);
console.log('[CloudCC] 3. Operation created, calling cloudClaudeCode.start...');
// Fire-and-forget: start CC in sandbox via TRPC
const { lambdaClient } = await import('@/libs/trpc/client');
const topicId = cloudContext.topicId!;
lambdaClient.cloudClaudeCode.start
.mutate({
agentId,
assistantMessageId: cloudData.assistantMessageId,
prompt: message,
topicId,
})
.catch((e: unknown) => {
console.error('[CloudClaudeCode] start failed:', e);
clearInterval(pollInterval);
if (this.#get().operations?.[cloudOpId]?.status === 'running') {
this.#get().failOperation(cloudOpId, {
message: e instanceof Error ? e.message : 'Cloud Claude Code start failed',
type: 'CloudClaudeCodeError',
});
}
});
console.log('[CloudCC] 4. start mutation fired, starting polling...');
// Start polling for new messages from sandbox CC
let isPolling = false;
const pollInterval = setInterval(async () => {
if (isPolling) return;
isPolling = true;
try {
const msgs = await messageService.getMessages(cloudContext);
this.#get().replaceMessages(msgs, { context: cloudContext });
if (hasCloudCCCompleted(msgs)) {
clearInterval(pollInterval);
if (this.#get().operations?.[cloudOpId]?.status === 'running') {
this.#get().completeOperation(cloudOpId);
}
}
} catch {
// Ignore polling errors
} finally {
isPolling = false;
}
}, 3000);
// Register cancel handler: stop polling + cancel sandbox
this.#get().onOperationCancel?.(cloudOpId, async () => {
clearInterval(pollInterval);
// TODO: call cancel endpoint to kill sandbox CC process
});
// Auto-stop polling after 10 minutes (safety net)
setTimeout(
() => {
clearInterval(pollInterval);
if (this.#get().operations?.[cloudOpId]?.status === 'running') {
this.#get().completeOperation(cloudOpId);
}
},
10 * 60 * 1000,
);
return {
assistantMessageId: cloudData.assistantMessageId,
userMessageId: cloudData.userMessageId,
};
}
// ── Desktop heterogeneous agent mode (desktop only) ──
if (isDesktop && heterogeneousProvider) {
// Resolve cwd up-front so the new topic is bound to a project at
// creation time. Otherwise the row stays NULL until the post-execution