diff --git a/README.md b/README.md
index 423d756..607075f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
diff --git a/assets/logo.jpeg b/assets/logo.jpeg
new file mode 100644
index 0000000..3dab15a
Binary files /dev/null and b/assets/logo.jpeg differ
diff --git a/src/config.ts b/src/config.ts
index 219b0c9..5f91a6a 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -10,6 +10,7 @@ const envConfig = readEnvFile([
'ASSISTANT_HAS_OWN_NUMBER',
'DISCORD_BOT_TOKEN',
'DISCORD_ONLY',
+ 'AGENT_BACKEND',
]);
export const ASSISTANT_NAME =
@@ -20,6 +21,11 @@ export const DISCORD_BOT_TOKEN =
process.env.DISCORD_BOT_TOKEN || envConfig.DISCORD_BOT_TOKEN || '';
export const DISCORD_ONLY =
(process.env.DISCORD_ONLY || envConfig.DISCORD_ONLY) === 'true';
+
+// Agent backend: 'container' (Claude Agent SDK in containers) or 'opencode' (OpenCode CLI/SDK)
+export const AGENT_BACKEND =
+ (process.env.AGENT_BACKEND || envConfig.AGENT_BACKEND || 'container').toLowerCase() as 'container' | 'opencode';
+
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
diff --git a/src/index.ts b/src/index.ts
index d61b832..99d7e53 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path';
import {
+ AGENT_BACKEND,
ASSISTANT_NAME,
DATA_DIR,
DISCORD_BOT_TOKEN,
@@ -16,7 +17,107 @@ import { DiscordChannel } from './channels/discord.js';
import { WhatsAppChannel } from './channels/whatsapp.js';
import {
ContainerOutput,
- runContainerAgent,
+ async function runAgent(
+ group: RegisteredGroup,
+ prompt: string,
+ chatJid: string,
+ onOutput?: (output: ContainerOutput) => Promise,
+ ): Promise<'success' | 'error'> {
+ // --- OpenCode backend ---
+ if (opencode) {
+ try {
+ const response = await opencode.chat(prompt, chatJid);
+ if (response.error) {
+ logger.error({ group: group.name, error: response.error }, 'OpenCode agent error');
+ if (onOutput) {
+ await onOutput({ status: 'error', error: response.error, result: null, newSessionId: response.sessionId });
+ }
+ return 'error';
+ }
+ if (onOutput && response.text) {
+ await onOutput({ status: 'success', result: response.text, error: null, newSessionId: response.sessionId });
+ }
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'OpenCode agent error');
+ return 'error';
+ }
+ }
+
+ // --- Container backend (Claude Agent SDK) ---
+ const isMain = group.folder === MAIN_GROUP_FOLDER;
+ const sessionId = sessions[group.folder];
+
+ // Update tasks snapshot for container to read (filtered by group)
+ const tasks = getAllTasks();
+ writeTasksSnapshot(
+ group.folder,
+ isMain,
+ tasks.map((t) => ({
+ id: t.id,
+ groupFolder: t.group_folder,
+ prompt: t.prompt,
+ schedule_type: t.schedule_type,
+ schedule_value: t.schedule_value,
+ status: t.status,
+ next_run: t.next_run,
+ })),
+ );
+
+ // Update available groups snapshot (main group only can see all groups)
+ const availableGroups = getAvailableGroups();
+ writeGroupsSnapshot(
+ group.folder,
+ isMain,
+ availableGroups,
+ new Set(Object.keys(registeredGroups)),
+ );
+
+ // Wrap onOutput to track session ID from streamed results
+ const wrappedOnOutput = onOutput
+ ? async (output: ContainerOutput) => {
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+ await onOutput(output);
+ }
+ : undefined;
+
+ try {
+ const output = await runContainerAgent(
+ group,
+ {
+ prompt,
+ sessionId,
+ groupFolder: group.folder,
+ chatJid,
+ isMain,
+ },
+ (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
+ wrappedOnOutput,
+ );
+
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+
+ if (output.status === 'error') {
+ logger.error(
+ { group: group.name, error: output.error },
+ 'Container agent error',
+ );
+ return 'error';
+ }
+
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'Agent error');
+ return 'error';
+ }
+ }
+,
writeGroupsSnapshot,
writeTasksSnapshot,
} from './container-runner.js';
@@ -41,6 +142,7 @@ import { findChannel, formatMessages, formatOutbound } from './router.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
+import { OpenCodeRuntime } from './opencode/runtime.js';
// Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';
@@ -54,6 +156,7 @@ let messageLoopRunning = false;
let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
const queue = new GroupQueue();
+const opencode = AGENT_BACKEND === 'opencode' ? new OpenCodeRuntime() : null;
function loadState(): void {
lastTimestamp = getRouterState('last_timestamp') || '';
@@ -221,6 +324,28 @@ async function runAgent(
chatJid: string,
onOutput?: (output: ContainerOutput) => Promise,
): Promise<'success' | 'error'> {
+ // --- OpenCode backend ---
+ if (opencode) {
+ try {
+ const response = await opencode.chat(prompt, chatJid);
+ if (response.error) {
+ logger.error({ group: group.name, error: response.error }, 'OpenCode agent error');
+ if (onOutput) {
+ await onOutput({ status: 'error', error: response.error, result: null, newSessionId: response.sessionId });
+ }
+ return 'error';
+ }
+ if (onOutput && response.text) {
+ await onOutput({ status: 'success', result: response.text, error: null, newSessionId: response.sessionId });
+ }
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'OpenCode agent error');
+ return 'error';
+ }
+ }
+
+ // --- Container backend (Claude Agent SDK) ---
const isMain = group.folder === MAIN_GROUP_FOLDER;
const sessionId = sessions[group.folder];
@@ -464,7 +589,11 @@ function ensureContainerSystemRunning(): void {
}
async function main(): Promise {
- ensureContainerSystemRunning();
+ if (AGENT_BACKEND === 'container') {
+ ensureContainerSystemRunning();
+ } else {
+ logger.info({ backend: AGENT_BACKEND }, 'Using OpenCode agent backend, skipping container system check');
+ }
initDatabase();
logger.info('Database initialized');
loadState();
diff --git a/src/opencode/test-opencode.ts b/src/opencode/test-opencode.ts
new file mode 100644
index 0000000..cc9c741
--- /dev/null
+++ b/src/opencode/test-opencode.ts
@@ -0,0 +1,31 @@
+import { OpenCodeRuntime } from './runtime.js';
+
+async function main() {
+ const runtime = new OpenCodeRuntime({
+ mode: 'cli',
+ timeoutMs: 30_000,
+ });
+
+ console.log('Status:', runtime.getStatus());
+
+ // Test 1: Empty message rejection
+ const empty = await runtime.chat(' ');
+ console.log('\nEmpty message test:', empty);
+
+ // Test 2: Actual chat (requires opencode binary installed)
+ try {
+ const response = await runtime.chat('Say hello in one sentence.', 'test-conv-1');
+ console.log('\nChat response:', response);
+ } catch (err) {
+ console.log('\nChat failed (opencode binary probably not installed):', err);
+ }
+
+ // Test 3: Session status after chat
+ console.log('\nStatus after chat:', runtime.getStatus());
+
+ // Cleanup
+ runtime.closeSession('test-conv-1');
+ console.log('\nSession closed. Final status:', runtime.getStatus());
+}
+
+main().catch(console.error);