|
|
|
@@ -1,37 +1,6 @@
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
import {
|
|
|
|
|
getHomeDir,
|
|
|
|
|
getNativeToolsDir,
|
|
|
|
|
getGlobalToolsDir,
|
|
|
|
|
getUserToolsDir,
|
|
|
|
|
getNativeSkillsDir,
|
|
|
|
|
getGlobalSkillsDir,
|
|
|
|
|
getUserSkillsDir,
|
|
|
|
|
toShellUsername,
|
|
|
|
|
} 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 { proxySecret } from '@@/api/anthropic-proxy';
|
|
|
|
|
import type { MessageCost, PiEvent } from '@@/api/pi/types';
|
|
|
|
|
|
|
|
|
|
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
|
|
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
|
|
|
|
|
|
|
|
|
async function hasOwnCredentials(homeDir: string): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
return await Bun.file(join(homeDir, '.claude', '.credentials.json')).exists();
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH
|
|
|
|
|
const CLAUDE_BIN = (() => {
|
|
|
|
|
const result = Bun.spawnSync({ cmd: ['which', 'claude'], stdout: 'pipe', stderr: 'ignore' });
|
|
|
|
|
return result.stdout.toString().trim() || 'claude';
|
|
|
|
|
})();
|
|
|
|
|
import * as sidecar from '@@/sidecar-client';
|
|
|
|
|
|
|
|
|
|
type ClaudeCodeParams = {
|
|
|
|
|
userId: number;
|
|
|
|
@@ -49,235 +18,13 @@ type ClaudeCodeResult = {
|
|
|
|
|
cost: MessageCost;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ClaudeCodeOutput = {
|
|
|
|
|
result: string;
|
|
|
|
|
session_id: string;
|
|
|
|
|
cost_usd: number;
|
|
|
|
|
input_tokens: number;
|
|
|
|
|
output_tokens: number;
|
|
|
|
|
is_error: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Map channel session key → Claude Code session ID for --resume
|
|
|
|
|
const claudeCodeSessions = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
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 = join(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');
|
|
|
|
|
sidecar.clearClaudeSession(sessionKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
|
|
|
|
const { userId, email, username, prompt, sessionKey } = params;
|
|
|
|
|
const homeDir = getHomeDir(email);
|
|
|
|
|
const shellUsername = toShellUsername(username, email);
|
|
|
|
|
const toolEnv = await buildHostToolEnv(userId, email);
|
|
|
|
|
|
|
|
|
|
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
|
|
|
|
|
|
|
|
|
const subModel = params.model?.split('/')[1];
|
|
|
|
|
if (subModel) claudeArgs.push('--model', subModel);
|
|
|
|
|
|
|
|
|
|
const existingSession = claudeCodeSessions.get(sessionKey);
|
|
|
|
|
if (existingSession) {
|
|
|
|
|
claudeArgs.push('--resume', existingSession);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
|
|
|
|
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
|
|
|
|
|
|
|
|
|
|
// Service user or users with own credentials use their HOME directly.
|
|
|
|
|
// Other users route through the local Anthropic proxy.
|
|
|
|
|
const authEnv = isServiceUser
|
|
|
|
|
? { HOME: process.env.HOME ?? '' }
|
|
|
|
|
: userHasCredentials
|
|
|
|
|
? { HOME: homeDir }
|
|
|
|
|
: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret };
|
|
|
|
|
|
|
|
|
|
const env: Record<string, string> = {
|
|
|
|
|
...toolEnv,
|
|
|
|
|
...authEnv,
|
|
|
|
|
PATH: process.env.PATH ?? '',
|
|
|
|
|
TERM: 'xterm-256color',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
logger.info('Claude Code exec', {
|
|
|
|
|
sessionKey,
|
|
|
|
|
username: shellUsername,
|
|
|
|
|
isServiceUser,
|
|
|
|
|
userHasCredentials,
|
|
|
|
|
resume: existingSession ?? null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const proc = isServiceUser
|
|
|
|
|
? Bun.spawn(claudeArgs, {
|
|
|
|
|
stdin: 'pipe',
|
|
|
|
|
stdout: 'pipe',
|
|
|
|
|
stderr: 'pipe',
|
|
|
|
|
env: { ...process.env, ...env },
|
|
|
|
|
})
|
|
|
|
|
: Bun.spawn(
|
|
|
|
|
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
|
|
|
|
{
|
|
|
|
|
stdin: 'pipe',
|
|
|
|
|
stdout: 'pipe',
|
|
|
|
|
stderr: 'pipe',
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
try {
|
|
|
|
|
proc.kill();
|
|
|
|
|
} catch {
|
|
|
|
|
/* already dead */
|
|
|
|
|
}
|
|
|
|
|
}, SEND_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
|
|
|
|
|
|
|
|
const exitCode = await proc.exited;
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
|
|
|
|
if (stderr.trim()) {
|
|
|
|
|
logger.info('Claude Code stderr', { text: stderr.trim().slice(0, 500) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (exitCode !== 0 && !stdout.trim()) {
|
|
|
|
|
throw new Error(`claude exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse JSON output
|
|
|
|
|
let output: ClaudeCodeOutput;
|
|
|
|
|
try {
|
|
|
|
|
output = JSON.parse(stdout) as ClaudeCodeOutput;
|
|
|
|
|
} catch {
|
|
|
|
|
// Non-JSON output — treat raw stdout as result text
|
|
|
|
|
return {
|
|
|
|
|
text: stdout.trim() || '(no response)',
|
|
|
|
|
sessionId: sessionKey,
|
|
|
|
|
model: 'claude-code',
|
|
|
|
|
cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (output.is_error) {
|
|
|
|
|
throw new Error(output.result || 'Claude Code returned an error');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store session for --resume on next message
|
|
|
|
|
if (output.session_id) {
|
|
|
|
|
claudeCodeSessions.set(sessionKey, output.session_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cost: MessageCost = {
|
|
|
|
|
inputTokens: output.input_tokens ?? 0,
|
|
|
|
|
outputTokens: output.output_tokens ?? 0,
|
|
|
|
|
totalUSD: output.cost_usd ?? 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
logger.info('Claude Code result', {
|
|
|
|
|
sessionKey,
|
|
|
|
|
sessionId: output.session_id,
|
|
|
|
|
cost: cost.totalUSD,
|
|
|
|
|
tokens: cost.inputTokens + cost.outputTokens,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
text: output.result || '(no response)',
|
|
|
|
|
sessionId: sessionKey,
|
|
|
|
|
model: 'claude-code',
|
|
|
|
|
cost,
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
logger.info('Claude Code exec (via sidecar)', { sessionKey: params.sessionKey });
|
|
|
|
|
return sidecar.spawnClaude(params);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Streaming variant for Chat Panel WebSocket ──
|
|
|
|
@@ -294,284 +41,32 @@ type ClaudeCodeStreamingParams = {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ClaudeCodeStreamingHandle = {
|
|
|
|
|
proc: ReturnType<typeof Bun.spawn>;
|
|
|
|
|
kill: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise<ClaudeCodeStreamingHandle> {
|
|
|
|
|
const { userId, email, username, prompt, sessionKey, cwd, onEvent } = params;
|
|
|
|
|
const { onEvent, ...spawnParams } = params;
|
|
|
|
|
|
|
|
|
|
const shellUsername = toShellUsername(username, email);
|
|
|
|
|
const homeDir = getHomeDir(email);
|
|
|
|
|
const workDir = cwd ?? homeDir;
|
|
|
|
|
logger.info('Claude Code streaming exec (via sidecar)', { sessionKey: params.sessionKey });
|
|
|
|
|
|
|
|
|
|
const claudeArgs = [
|
|
|
|
|
CLAUDE_BIN,
|
|
|
|
|
'-p',
|
|
|
|
|
prompt,
|
|
|
|
|
'--dangerously-skip-permissions',
|
|
|
|
|
'--output-format',
|
|
|
|
|
'stream-json',
|
|
|
|
|
'--verbose',
|
|
|
|
|
'--include-partial-messages',
|
|
|
|
|
];
|
|
|
|
|
// Subscribe to events for this session
|
|
|
|
|
const unsub = sidecar.onClaudeEvent((sessionKey, event) => {
|
|
|
|
|
if (sessionKey === params.sessionKey) {
|
|
|
|
|
onEvent(event);
|
|
|
|
|
|
|
|
|
|
const subModel = params.model?.split('/')[1];
|
|
|
|
|
if (subModel) claudeArgs.push('--model', subModel);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toolEnv = await buildHostToolEnv(userId, email);
|
|
|
|
|
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
|
|
|
|
|
|
|
|
|
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
|
|
|
|
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
|
|
|
|
|
|
|
|
|
|
// Service user or users with own credentials use their HOME directly.
|
|
|
|
|
// Other users route through the local Anthropic proxy.
|
|
|
|
|
const authEnv = isServiceUser
|
|
|
|
|
? { HOME: cleanEnv.HOME ?? '' }
|
|
|
|
|
: userHasCredentials
|
|
|
|
|
? { HOME: homeDir }
|
|
|
|
|
: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret };
|
|
|
|
|
|
|
|
|
|
const env: Record<string, string> = {
|
|
|
|
|
...toolEnv,
|
|
|
|
|
...authEnv,
|
|
|
|
|
PATH: cleanEnv.PATH ?? '',
|
|
|
|
|
TERM: 'xterm-256color',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
logger.info('Claude Code streaming exec', {
|
|
|
|
|
sessionKey,
|
|
|
|
|
username: shellUsername,
|
|
|
|
|
isServiceUser,
|
|
|
|
|
userHasCredentials,
|
|
|
|
|
cwd: workDir,
|
|
|
|
|
resume: existingSession ?? null,
|
|
|
|
|
// Unsubscribe when we get a terminal event
|
|
|
|
|
if (event.type === 'result' || event.type === 'error') {
|
|
|
|
|
unsub();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const proc = isServiceUser
|
|
|
|
|
? Bun.spawn(claudeArgs, {
|
|
|
|
|
cwd: workDir,
|
|
|
|
|
stdin: 'ignore',
|
|
|
|
|
stdout: 'pipe',
|
|
|
|
|
stderr: 'pipe',
|
|
|
|
|
env: { ...cleanEnv, ...env },
|
|
|
|
|
})
|
|
|
|
|
: Bun.spawn(
|
|
|
|
|
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
|
|
|
|
{
|
|
|
|
|
cwd: workDir,
|
|
|
|
|
stdin: 'ignore',
|
|
|
|
|
stdout: 'pipe',
|
|
|
|
|
stderr: 'pipe',
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
await sidecar.spawnClaudeStreaming(spawnParams);
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
return {
|
|
|
|
|
kill: () => {
|
|
|
|
|
sidecar.killClaude(params.sessionKey);
|
|
|
|
|
unsub();
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|