claude-code streaming chat, desktop remote viewer, new-automation route, tiktok task v4, misc fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent 0068244356
commit 40a9768cb3
91 changed files with 17872 additions and 85 deletions
+376 -2
View File
@@ -1,7 +1,13 @@
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { existsSync, symlinkSync, rmdirSync } from 'node:fs';
import { ensureDockerContainer } from '@@/api/terminal/websocket';
import { getHomeDir } from '@@/data-path';
import { getHomeDir, DATA_PATH, getNativeToolsDir, getGlobalToolsDir, getUserToolsDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path';
import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/tools/tools';
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
import { logger } from '@@/api/pi/logger';
import type { MessageCost } from '@@/api/pi/types';
import type { MessageCost, PiEvent } from '@@/api/pi/types';
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
@@ -36,6 +42,86 @@ export function clearClaudeCodeSession(sessionKey: string): void {
claudeCodeSessions.delete(sessionKey);
}
// ── Build dynamic system prompt from available tools & skills ──
async function buildToolsSystemPrompt(email: string): Promise<string | null> {
const [nativeTools, globalTools, userTools, nativeSkills, globalSkills, userSkills] = await Promise.all([
readToolDirs(getNativeToolsDir()),
readToolDirs(getGlobalToolsDir()),
readToolDirs(getUserToolsDir(email)),
readSkillDirs(getNativeSkillsDir()),
readSkillDirs(getGlobalSkillsDir()),
readSkillDirs(getUserSkillsDir(email)),
]);
// Merge tools (user overrides global overrides native)
const mergedTools = new Map(nativeTools);
for (const [name, path] of globalTools) mergedTools.set(name, path);
for (const [name, path] of userTools) mergedTools.set(name, path);
// Merge skills
const mergedSkills = new Map(nativeSkills);
for (const [name, path] of globalSkills) mergedSkills.set(name, path);
for (const [name, path] of userSkills) mergedSkills.set(name, path);
if (mergedTools.size === 0 && mergedSkills.size === 0) return null;
const sections: string[] = [
'# Officer Automation System',
'',
'You are running inside the Officer platform. Officer has its own automation concepts that are DIFFERENT from your built-in tools. When the user or a task references these, use the definitions below — do NOT map them to your own built-in concepts.',
'',
'- **Task**: A markdown file (TASK.md) with instructions for you to execute. When asked to "run a task", read the TASK.md file and follow its instructions step by step.',
'- **Skill**: A knowledge document (SKILL.md) that describes HOW to do something — APIs, commands, patterns. When a task says "use the X skill", follow the instructions from the matching skill section below.',
'- **Tool**: A capability defined by a TOOL.md and implemented in an index.ts file. Tools are NOT shell commands — do NOT try to call them by name. If the tool has a "Run" line below, execute it using that command, passing inputs as a JSON string argument. Only if there is no Run command should you replicate the behavior manually using the TOOL.md documentation.',
'',
'Skills and tools listed here are AVAILABLE to you. Follow their documentation directly.',
'',
];
if (mergedTools.size > 0) {
const toolLines: string[] = ['# Tools', ''];
const entries = await Promise.all(
Array.from(mergedTools.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter, body } = parseToolFrontmatter(raw);
const toolDir = dirname(filePath);
const hasImpl = await Bun.file(`${toolDir}/index.ts`).exists();
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body, toolDir, hasImpl };
}),
);
const runnerPath = `${getNativeToolsDir()}/run.ts`;
for (const tool of entries) {
toolLines.push(`## ${tool.name}`);
if (tool.hasImpl) toolLines.push(`Run: \`bun run ${runnerPath} ${tool.toolDir} '{"param":"value"}'\``);
if (tool.description) toolLines.push(tool.description);
if (tool.body.trim()) toolLines.push('', tool.body.trim());
toolLines.push('');
}
sections.push(toolLines.join('\n'));
}
if (mergedSkills.size > 0) {
const skillLines: string[] = ['# Skills', ''];
const entries = await Promise.all(
Array.from(mergedSkills.entries()).map(async ([dirName, filePath]) => {
const raw = await Bun.file(filePath).text();
const { frontmatter, body } = parseSkillFrontmatter(raw);
return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body };
}),
);
for (const skill of entries) {
skillLines.push(`## ${skill.name}`);
if (skill.description) skillLines.push(skill.description);
if (skill.body.trim()) skillLines.push('', skill.body.trim());
skillLines.push('');
}
sections.push(skillLines.join('\n'));
}
return sections.join('\n\n');
}
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
const { userId, email, username, prompt, sessionKey } = params;
const homeDir = getHomeDir(email);
@@ -137,3 +223,291 @@ export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCo
throw err;
}
}
// ── Streaming variant for Chat Panel WebSocket ──
type ClaudeCodeStreamingParams = {
userId: number;
email: string;
username: string;
prompt: string;
sessionKey: string;
cwd?: string;
sandboxed?: boolean;
onEvent: (event: PiEvent) => void;
};
type ClaudeCodeStreamingHandle = {
proc: ReturnType<typeof Bun.spawn>;
};
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
const { userId, email, username, prompt, sessionKey, cwd, sandboxed = false, onEvent } = params;
const claudeArgs = [
'claude', '-p', prompt,
'--dangerously-skip-permissions',
'--output-format', 'stream-json',
'--verbose',
'--include-partial-messages',
];
const existingSession = claudeCodeSessions.get(sessionKey);
if (existingSession) {
claudeArgs.push('--resume', existingSession);
}
// Append dynamic system prompt with available tools & skills
const systemPrompt = await buildToolsSystemPrompt(email);
if (systemPrompt) {
claudeArgs.push('--append-system-prompt', systemPrompt);
}
let proc: ReturnType<typeof Bun.spawn>;
if (sandboxed) {
// Container execution via docker exec
const homeDir = getHomeDir(email);
const container = await ensureDockerContainer(email, userId, homeDir, username);
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = container.dockerId;
const containerHome = `/home/${username}`;
const workDir = cwd ?? containerHome;
const args = [
dockerPath, 'exec',
'-u', username,
'-w', workDir,
'-e', `HOME=${containerHome}`,
containerId,
...claudeArgs,
];
logger.info('Claude Code streaming exec (container)', { sessionKey, containerId, resume: existingSession ?? null });
proc = Bun.spawn(args, {
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
});
} else {
// Host execution — run claude directly
const claudePath = Bun.which('claude') ?? 'claude';
claudeArgs[0] = claudePath;
const workDir = cwd ?? homedir();
logger.info('Claude Code streaming exec (host)', { sessionKey, cwd: workDir, resume: existingSession ?? null });
const { CLAUDECODE: _, ...cleanEnv } = process.env;
const toolEnv = await buildHostToolEnv(userId, email);
// Ensure Claude Code can find ~/.claude credentials in the user's data home.
// Symlink the host's .claude config into the data home if not already there.
const dataHome = toolEnv.HOME!;
const hostClaudeConfig = join(homedir(), '.claude');
const targetClaudeConfig = join(dataHome, '.claude');
const targetCredentials = join(targetClaudeConfig, '.credentials.json');
if (!existsSync(targetCredentials) && existsSync(hostClaudeConfig)) {
try {
// Remove empty placeholder dir if it exists, then symlink
if (existsSync(targetClaudeConfig)) rmdirSync(targetClaudeConfig);
symlinkSync(hostClaudeConfig, targetClaudeConfig);
} catch { /* race, permission, or non-empty dir */ }
}
proc = Bun.spawn(claudeArgs, {
cwd: workDir,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
env: { ...cleanEnv, ...toolEnv },
});
}
const timeout = setTimeout(() => {
try { proc.kill(); } catch { /* already dead */ }
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
}, SEND_TIMEOUT_MS);
// Process NDJSON stream in background
(async () => {
try {
const stdout = proc.stdout as ReadableStream<Uint8Array>;
const reader = stdout.getReader();
const decoder = new TextDecoder();
let buffer = '';
let textBuffer = '';
let gotResult = false;
let lineCount = 0;
const processLine = (line: string) => {
if (!line.trim()) return;
lineCount++;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
const type = msg.type as string;
if (lineCount <= 5 || type === 'result') {
logger.info('Claude Code NDJSON', { sessionKey, lineCount, type, subtype: msg.subtype ?? null });
}
// stream_event — partial streaming (text deltas)
if (type === 'stream_event') {
const event = msg.event as Record<string, unknown> | undefined;
if (event?.type === 'content_block_delta') {
const delta = event.delta as Record<string, unknown> | undefined;
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
textBuffer += delta.text;
onEvent({ type: 'delta', text: delta.text });
}
}
}
// assistant — complete message with text and tool_use blocks
else if (type === 'assistant') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') {
// Full text block — emit as text event, reset streaming buffer
onEvent({ type: 'text', text: block.text });
textBuffer = '';
} else if (block.type === 'tool_use') {
// Flush any pending streamed text before tool
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({
type: 'tool:start',
toolCallId: (block.id as string) ?? '',
toolName: (block.name as string) ?? 'unknown',
toolInput: (block.input as Record<string, unknown>) ?? {},
});
}
}
}
}
// user — tool results
else if (type === 'user') {
const message = msg.message as Record<string, unknown> | undefined;
const content = message?.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'tool_result') {
let output = '';
if (typeof block.content === 'string') {
output = block.content;
} else if (Array.isArray(block.content)) {
output = (block.content as Array<Record<string, unknown>>)
.filter((c) => c.type === 'text')
.map((c) => c.text as string)
.join('\n');
}
onEvent({
type: 'tool:result',
toolCallId: (block.tool_use_id as string) ?? '',
output,
isError: (block.is_error as boolean) ?? false,
});
}
}
}
}
// system init — extract session_id for --resume
else if (type === 'system' && msg.subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
claudeCodeSessions.set(sessionKey, sessionId);
}
}
// result — final
else if (type === 'result') {
gotResult = true;
clearTimeout(timeout);
const isError = (msg.is_error as boolean) ?? false;
const resultText = (msg.result as string) ?? '';
if (isError) {
logger.info('Claude Code streaming error result', { sessionKey, error: resultText.slice(0, 500) });
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
onEvent({ type: 'error', message: resultText || 'Claude Code returned an error' });
return;
}
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
textBuffer = '';
}
const usage = msg.usage as Record<string, number> | undefined;
const cost: MessageCost = {
inputTokens: usage?.input_tokens ?? 0,
outputTokens: usage?.output_tokens ?? 0,
totalUSD: (msg.total_cost_usd as number) ?? 0,
};
const sessionId = msg.session_id as string | undefined;
if (sessionId) {
claudeCodeSessions.set(sessionKey, sessionId);
}
logger.info('Claude Code streaming result', { sessionKey, cost: cost.totalUSD });
onEvent({ type: 'result', cost });
}
} catch {
// Skip malformed JSON lines
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
processLine(line);
}
}
// Process any remaining buffer
if (buffer.trim()) {
processLine(buffer);
}
logger.info('Claude Code stream ended', { sessionKey, totalLines: lineCount, gotResult });
clearTimeout(timeout);
// If process exited without a result event, emit error or synthetic result
if (!gotResult) {
const exitCode = await proc.exited;
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
logger.info('Claude Code exited without result event', { sessionKey, exitCode, stderr: stderr.trim().slice(0, 500) });
if (textBuffer) {
onEvent({ type: 'text', text: textBuffer });
}
if (exitCode !== 0) {
onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` });
} else {
onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } });
}
}
} catch (err) {
clearTimeout(timeout);
onEvent({ type: 'error', message: String(err) });
}
})();
return { proc };
}