Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,4 @@ tests/e2e/reports/
ASSETS_LICENSES.md

external/
resources/omp/*.exe
226 changes: 226 additions & 0 deletions resources/claude-bridge/bridge.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#!/usr/bin/env node
/**
* Claude Agent SDK → BitFun JSONL bridge.
*
* Reads JSONL commands from stdin, writes JSONL events to stdout.
* Uses @anthropic-ai/claude-agent-sdk for agent execution.
*
* Command format (stdin, one JSON object per line):
* {"command":"prompt","text":"...","model":"...","workingDir":"..."}
* {"command":"abort"}
*
* Event format (stdout, one JSON object per line):
* {"type":"text_delta","delta":"..."}
* {"type":"thinking_delta","delta":"..."}
* {"type":"tool_call_start","tool_call_id":"...","tool_name":"..."}
* {"type":"tool_call_delta","tool_call_id":"...","delta":"..."}
* {"type":"tool_result","tool_call_id":"...","result":"..."}
* {"type":"turn_end","stopReason":"completed"}
* {"type":"error","message":"..."}
*/

import { query } from '@anthropic-ai/claude-agent-sdk';
import { createInterface } from 'node:readline';

// ── Message translation ─────────────────────────────────────────────────────

/**
* Translate a Claude SDK message into one or more BitFun JSONL events.
* Returns an array of event objects (may be empty if the message type is unhandled).
*/
function translateMessage(msg) {
const events = [];

// Case 1: stream_event — the SDK emits streaming content deltas
if (msg.type === 'stream_event' && msg.event) {
const ev = msg.event;
switch (ev.type) {
case 'content_block_start': {
const block = ev.content_block || ev.index != null ? ev : null;
if (block?.content_block?.type === 'tool_use') {
const tu = block.content_block;
events.push({
type: 'tool_call_start',
tool_call_id: tu.id ?? '',
tool_name: tu.name ?? '',
});
}
break;
}
case 'content_block_delta': {
const delta = ev.delta;
if (!delta) break;
if (delta.type === 'text_delta') {
events.push({ type: 'text_delta', delta: delta.text ?? '' });
} else if (delta.type === 'input_json_delta') {
// Tool call argument streaming — emit as tool_call_delta
events.push({
type: 'tool_call_delta',
tool_call_id: ev.index != null ? String(ev.index) : '',
delta: delta.partial_json ?? '',
});
} else if (delta.type === 'thinking_delta') {
events.push({
type: 'thinking_delta',
delta: delta.thinking ?? '',
});
} else if (delta.type === 'signature_delta') {
// signature deltas are internal; skip
}
break;
}
case 'content_block_stop': {
// End of a content block; no event to emit
break;
}
default:
// Unknown stream event — log to stderr but don't fail
break;
}
return events;
}

// Case 2: Full assistant message (non-streaming or final)
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
switch (block.type) {
case 'text':
events.push({ type: 'text_delta', delta: block.text ?? '' });
break;
case 'tool_use':
events.push({
type: 'tool_call_start',
tool_call_id: block.id ?? '',
tool_name: block.name ?? '',
});
if (block.input) {
events.push({
type: 'tool_call_delta',
tool_call_id: block.id ?? '',
delta: JSON.stringify(block.input),
});
}
break;
case 'thinking':
events.push({
type: 'thinking_delta',
delta: block.thinking ?? '',
});
break;
default:
break;
}
}
return events;
}

// Case 3: User message (tool results come back as user messages with tool_result blocks)
if (msg.type === 'user' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_result') {
const resultText =
typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.map(c => c.text ?? '').join('')
: JSON.stringify(block.content ?? '');
events.push({
type: 'tool_result',
tool_call_id: block.tool_use_id ?? '',
result: resultText,
});
}
}
return events;
}

// Case 4: Result message (end of query)
if (msg.type === 'result' || msg.subtype === 'success' || msg.result !== undefined) {
// This is emitted by the bridge after the loop, not here
return events;
}

// Case 5: Error message
if (msg.type === 'error') {
events.push({
type: 'error',
message: msg.message ?? msg.error ?? 'Unknown SDK error',
});
return events;
}

// Unknown message shape — log for debugging, but skip
return events;
}

// ── Main loop ────────────────────────────────────────────────────────────────

async function main() {
const rl = createInterface({
input: process.stdin,
crlfDelay: Infinity,
});

// Process commands line by line
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;

let cmd;
try {
cmd = JSON.parse(trimmed);
} catch {
process.stderr.write(`bridge: invalid JSON: ${trimmed}\n`);
continue;
}

if (cmd.command === 'abort') {
process.exit(0);
}

if (cmd.command !== 'prompt') {
process.stderr.write(`bridge: unknown command: ${cmd.command}\n`);
continue;
}

// Build options for the Claude SDK
const options = {};
if (cmd.model) options.model = cmd.model;
if (cmd.workingDir) options.workingDir = cmd.workingDir;

try {
const messages = query({
prompt: cmd.text,
options,
});

for await (const msg of messages) {
const events = translateMessage(msg);
for (const ev of events) {
process.stdout.write(JSON.stringify(ev) + '\n');
}
}

// Turn completed successfully
process.stdout.write(
JSON.stringify({ type: 'turn_end', stopReason: 'completed' }) + '\n',
);
} catch (err) {
// Report error and end turn with error status
process.stdout.write(
JSON.stringify({
type: 'error',
message: err.message ?? String(err),
}) + '\n',
);
process.stdout.write(
JSON.stringify({ type: 'turn_end', stopReason: 'error' }) + '\n',
);
}
}
}

main().catch((err) => {
process.stderr.write(`bridge fatal: ${err.message}\n`);
process.exit(1);
});
9 changes: 9 additions & 0 deletions resources/claude-bridge/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "claude-agent-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.154"
}
}
1 change: 1 addition & 0 deletions resources/omp/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# placeholder - omp binary is bundled at build time
23 changes: 23 additions & 0 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ pub struct UpdateSessionModelRequest {
pub model_name: String,
}

#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionRuntimeRequest {
pub session_id: String,
pub runtime_id: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionTitleRequest {
Expand Down Expand Up @@ -209,6 +216,8 @@ pub struct SessionResponse {
pub state: String,
pub turn_count: usize,
pub created_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -590,6 +599,7 @@ pub async fn create_session(
remote_connection_id: remote_conn.clone(),
remote_ssh_host: remote_ssh_host.clone(),
model_id: c.model_name,
runtime_id: None,
})
.unwrap_or(SessionConfig {
workspace_path: Some(request.workspace_path.clone()),
Expand Down Expand Up @@ -658,6 +668,17 @@ pub async fn update_session_model(
.map_err(|e| format!("Failed to update session model: {}", e))
}

#[tauri::command]
pub async fn update_session_runtime(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: UpdateSessionRuntimeRequest,
) -> Result<(), String> {
coordinator
.update_session_runtime(&request.session_id, &request.runtime_id)
.await
.map_err(|e| format!("Failed to update session runtime: {}", e))
}

#[tauri::command]
pub async fn update_session_title(
coordinator: State<'_, Arc<ConversationCoordinator>>,
Expand Down Expand Up @@ -1469,6 +1490,7 @@ pub async fn list_sessions(
state: format!("{:?}", summary.state),
turn_count: summary.turn_count,
created_at: system_time_to_unix_secs(summary.created_at),
runtime_id: None,
})
.collect();

Expand Down Expand Up @@ -1628,6 +1650,7 @@ fn session_to_response(session: Session) -> SessionResponse {
state: format!("{:?}", session.state),
turn_count: session.dialog_turn_ids.len(),
created_at: system_time_to_unix_secs(session.created_at),
runtime_id: session.config.runtime_id.clone(),
}
}

Expand Down
Loading