Implement all 5 CLI extension options for external orchestration: - --port <port>: Start server on port, or attach if port occupied - --attach <url>: Connect to existing opencode server - --session-id <id>: Resume existing session instead of creating new - --on-complete <command>: Execute shell command with env vars on completion - --json: Output structured RunResult JSON to stdout Refactor runner.ts into focused modules: - agent-resolver.ts: Agent resolution logic - server-connection.ts: Server connection management - session-resolver.ts: Session create/resume with retry - json-output.ts: Stdout redirect + JSON emission - on-complete-hook.ts: Shell command execution with env vars Fixes #1586
154 lines
4.4 KiB
TypeScript
154 lines
4.4 KiB
TypeScript
import pc from "picocolors"
|
|
import type { RunOptions, RunContext } from "./types"
|
|
import { checkCompletionConditions } from "./completion"
|
|
import { createEventState, processEvents, serializeError } from "./events"
|
|
import { loadPluginConfig } from "../../plugin-config"
|
|
import { createServerConnection } from "./server-connection"
|
|
import { resolveSession } from "./session-resolver"
|
|
import { createJsonOutputManager } from "./json-output"
|
|
import { executeOnCompleteHook } from "./on-complete-hook"
|
|
import { resolveRunAgent } from "./agent-resolver"
|
|
|
|
export { resolveRunAgent }
|
|
|
|
const POLL_INTERVAL_MS = 500
|
|
const DEFAULT_TIMEOUT_MS = 0
|
|
|
|
export async function run(options: RunOptions): Promise<number> {
|
|
process.env.OPENCODE_CLI_RUN_MODE = "true"
|
|
|
|
const startTime = Date.now()
|
|
const {
|
|
message,
|
|
directory = process.cwd(),
|
|
timeout = DEFAULT_TIMEOUT_MS,
|
|
} = options
|
|
|
|
const jsonManager = options.json ? createJsonOutputManager() : null
|
|
if (jsonManager) jsonManager.redirectToStderr()
|
|
|
|
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
|
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
|
const abortController = new AbortController()
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
|
|
if (timeout > 0) {
|
|
timeoutId = setTimeout(() => {
|
|
console.log(pc.yellow("\nTimeout reached. Aborting..."))
|
|
abortController.abort()
|
|
}, timeout)
|
|
}
|
|
|
|
try {
|
|
const { client, cleanup: serverCleanup } = await createServerConnection({
|
|
port: options.port,
|
|
attach: options.attach,
|
|
signal: abortController.signal,
|
|
})
|
|
|
|
const cleanup = () => {
|
|
if (timeoutId) clearTimeout(timeoutId)
|
|
serverCleanup()
|
|
}
|
|
|
|
process.on("SIGINT", () => {
|
|
console.log(pc.yellow("\nInterrupted. Shutting down..."))
|
|
cleanup()
|
|
process.exit(130)
|
|
})
|
|
|
|
try {
|
|
const sessionID = await resolveSession({
|
|
client,
|
|
sessionId: options.sessionId,
|
|
})
|
|
|
|
console.log(pc.dim(`Session: ${sessionID}`))
|
|
|
|
const ctx: RunContext = { client, sessionID, directory, abortController }
|
|
const events = await client.event.subscribe()
|
|
const eventState = createEventState()
|
|
const eventProcessor = processEvents(ctx, events.stream, eventState)
|
|
|
|
console.log(pc.dim("\nSending prompt..."))
|
|
await client.session.promptAsync({
|
|
path: { id: sessionID },
|
|
body: {
|
|
agent: resolvedAgent,
|
|
parts: [{ type: "text", text: message }],
|
|
},
|
|
query: { directory },
|
|
})
|
|
|
|
console.log(pc.dim("Waiting for completion...\n"))
|
|
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
|
|
|
await eventProcessor.catch(() => {})
|
|
cleanup()
|
|
|
|
const durationMs = Date.now() - startTime
|
|
|
|
if (options.onComplete) {
|
|
await executeOnCompleteHook({
|
|
command: options.onComplete,
|
|
sessionId: sessionID,
|
|
exitCode,
|
|
durationMs,
|
|
messageCount: eventState.messageCount,
|
|
})
|
|
}
|
|
|
|
if (jsonManager) {
|
|
jsonManager.emitResult({
|
|
sessionId: sessionID,
|
|
success: exitCode === 0,
|
|
durationMs,
|
|
messageCount: eventState.messageCount,
|
|
summary: eventState.lastPartText.slice(0, 200) || "Run completed",
|
|
})
|
|
}
|
|
|
|
return exitCode
|
|
} catch (err) {
|
|
cleanup()
|
|
throw err
|
|
}
|
|
} catch (err) {
|
|
if (timeoutId) clearTimeout(timeoutId)
|
|
if (jsonManager) jsonManager.restore()
|
|
if (err instanceof Error && err.name === "AbortError") {
|
|
return 130
|
|
}
|
|
console.error(pc.red(`Error: ${serializeError(err)}`))
|
|
return 1
|
|
}
|
|
}
|
|
|
|
async function pollForCompletion(
|
|
ctx: RunContext,
|
|
eventState: ReturnType<typeof createEventState>,
|
|
abortController: AbortController
|
|
): Promise<number> {
|
|
while (!abortController.signal.aborted) {
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
|
|
|
|
if (!eventState.mainSessionIdle) continue
|
|
|
|
if (eventState.mainSessionError) {
|
|
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
|
|
console.error(pc.yellow("Check if todos were completed before the error."))
|
|
return 1
|
|
}
|
|
|
|
if (!eventState.hasReceivedMeaningfulWork) continue
|
|
|
|
const shouldExit = await checkCompletionConditions(ctx)
|
|
if (shouldExit) {
|
|
console.log(pc.green("\n\nAll tasks completed."))
|
|
return 0
|
|
}
|
|
}
|
|
|
|
return 130
|
|
}
|