UNPKG

@deep-assistant/agent

Version:

A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.

372 lines (333 loc) 11.8 kB
#!/usr/bin/env bun import { Server } from './server/server.ts' import { Instance } from './project/instance.ts' import { Log } from './util/log.ts' import { Bus } from './bus/index.ts' import { Session } from './session/index.ts' import { SessionPrompt } from './session/prompt.ts' import { EOL } from 'os' import yargs from 'yargs' import { hideBin } from 'yargs/helpers' async function readStdin() { return new Promise((resolve, reject) => { let data = '' const onData = chunk => { data += chunk } const onEnd = () => { cleanup() resolve(data) } const onError = err => { cleanup() reject(err) } const cleanup = () => { process.stdin.removeListener('data', onData) process.stdin.removeListener('end', onEnd) process.stdin.removeListener('error', onError) } process.stdin.on('data', onData) process.stdin.on('end', onEnd) process.stdin.on('error', onError) }) } async function main() { try { // Parse command line arguments const argv = await yargs(hideBin(process.argv)) .option('model', { type: 'string', description: 'Model to use in format providerID/modelID', default: 'opencode/grok-code' }) .option('system-message', { type: 'string', description: 'Full override of the system message' }) .option('system-message-file', { type: 'string', description: 'Full override of the system message from file' }) .option('append-system-message', { type: 'string', description: 'Append to the default system message' }) .option('append-system-message-file', { type: 'string', description: 'Append to the default system message from file' }) .option('server', { type: 'boolean', description: 'Run in server mode (default)', default: true }) .help() .argv // Parse model argument const modelParts = argv.model.split('/') const providerID = modelParts[0] || 'opencode' const modelID = modelParts[1] || 'grok-code' // Read system message files let systemMessage = argv['system-message'] let appendSystemMessage = argv['append-system-message'] if (argv['system-message-file']) { const resolvedPath = require('path').resolve(process.cwd(), argv['system-message-file']) const file = Bun.file(resolvedPath) if (!(await file.exists())) { console.error(`System message file not found: ${argv['system-message-file']}`) process.exit(1) } systemMessage = await file.text() } if (argv['append-system-message-file']) { const resolvedPath = require('path').resolve(process.cwd(), argv['append-system-message-file']) const file = Bun.file(resolvedPath) if (!(await file.exists())) { console.error(`Append system message file not found: ${argv['append-system-message-file']}`) process.exit(1) } appendSystemMessage = await file.text() } // Initialize logging to redirect to log file instead of stderr // This prevents log messages from mixing with JSON output await Log.init({ print: false, // Don't print to stderr level: 'INFO' }) // Read input from stdin const input = await readStdin() const trimmedInput = input.trim() // Try to parse as JSON, if it fails treat it as plain text message let request try { request = JSON.parse(trimmedInput) } catch (e) { // Not JSON, treat as plain text message request = { message: trimmedInput } } // Wrap in Instance.provide for OpenCode infrastructure await Instance.provide({ directory: process.cwd(), fn: async () => { if (argv.server) { // SERVER MODE: Start server and communicate via HTTP await runServerMode() } else { // DIRECT MODE: Run everything in single process await runDirectMode() } } }) async function runServerMode() { // Start server like OpenCode does const server = Server.listen({ port: 0, hostname: "127.0.0.1" }) let unsub = null try { // Create a session const createRes = await fetch(`http://${server.hostname}:${server.port}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) const session = await createRes.json() const sessionID = session.id if (!sessionID) { throw new Error("Failed to create session") } // Subscribe to all bus events to output them in OpenCode format const eventPromise = new Promise((resolve) => { unsub = Bus.subscribeAll((event) => { // Output events in OpenCode JSON format if (event.type === 'message.part.updated') { const part = event.properties.part if (part.sessionID !== sessionID) return // Output different event types (pretty-printed for readability) if (part.type === 'step-start') { process.stdout.write(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } if (part.type === 'step-finish') { process.stdout.write(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } if (part.type === 'text' && part.time?.end) { process.stdout.write(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } if (part.type === 'tool' && part.state.status === 'completed') { process.stdout.write(JSON.stringify({ type: 'tool_use', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } } // Handle session idle to know when to stop if (event.type === 'session.idle' && event.properties.sessionID === sessionID) { resolve() } // Handle errors if (event.type === 'session.error') { const props = event.properties if (props.sessionID !== sessionID || !props.error) return process.stdout.write(JSON.stringify({ type: 'error', timestamp: Date.now(), sessionID, error: props.error }, null, 2) + EOL) } }) }) // Send message to session with specified model (default: opencode/grok-code) const message = request.message || "hi" const parts = [{ type: "text", text: message }] // Start the prompt (don't wait for response, events come via Bus) fetch(`http://${server.hostname}:${server.port}/session/${sessionID}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parts, model: { providerID, modelID }, system: systemMessage, appendSystem: appendSystemMessage }) }).catch(() => { // Ignore errors, we're listening to events }) // Wait for session to become idle await eventPromise } finally { // Always clean up resources if (unsub) unsub() server.stop() await Instance.dispose() } } async function runDirectMode() { // DIRECT MODE: Run in single process without server let unsub = null try { // Create a session directly const session = await Session.createNext({ directory: process.cwd() }) const sessionID = session.id // Subscribe to all bus events to output them in OpenCode format const eventPromise = new Promise((resolve) => { unsub = Bus.subscribeAll((event) => { // Output events in OpenCode JSON format if (event.type === 'message.part.updated') { const part = event.properties.part if (part.sessionID !== sessionID) return // Output different event types (pretty-printed for readability) if (part.type === 'step-start') { process.stdout.write(JSON.stringify({ type: 'step_start', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } if (part.type === 'step-finish') { process.stdout.write(JSON.stringify({ type: 'step_finish', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } if (part.type === 'text' && part.time?.end) { process.stdout.write(JSON.stringify({ type: 'text', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } if (part.type === 'tool' && part.state.status === 'completed') { process.stdout.write(JSON.stringify({ type: 'tool_use', timestamp: Date.now(), sessionID, part }, null, 2) + EOL) } } // Handle session idle to know when to stop if (event.type === 'session.idle' && event.properties.sessionID === sessionID) { resolve() } // Handle errors if (event.type === 'session.error') { const props = event.properties if (props.sessionID !== sessionID || !props.error) return process.stdout.write(JSON.stringify({ type: 'error', timestamp: Date.now(), sessionID, error: props.error }, null, 2) + EOL) } }) }) // Send message to session directly const message = request.message || "hi" const parts = [{ type: "text", text: message }] // Start the prompt directly without HTTP SessionPrompt.prompt({ sessionID, parts, model: { providerID, modelID }, system: systemMessage, appendSystem: appendSystemMessage }).catch((error) => { process.stdout.write(JSON.stringify({ type: 'error', timestamp: Date.now(), sessionID, error: error instanceof Error ? error.message : String(error) }, null, 2) + EOL) }) // Wait for session to become idle await eventPromise } finally { // Always clean up resources if (unsub) unsub() await Instance.dispose() } } // Explicitly exit to ensure process terminates process.exit(0) } catch (error) { console.error(JSON.stringify({ type: 'error', timestamp: Date.now(), error: error instanceof Error ? error.message : String(error) })) process.exit(1) } } main()