为打造自有纯 CLI 产品 zmo(类 Claude Code),在保留上游同步能力的前提下做套壳改造: - 入口隐藏 9 个非 CLI 命令(console/web/github/pr/stats/export/import/db/debug) - 全面 rebrand:scriptName、Logo、配置目录(~/.config/zmo)、bin 名、构建产物、可见文案 - env 双读兼容(ZMO_* 优先,回退 OPENCODE_*),配置文件名新增 zmo.json - 刻意保留 @opencode-ai/* 内部包名与 opencode provider,避免破坏功能与上游同步 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
878 lines
27 KiB
TypeScript
878 lines
27 KiB
TypeScript
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
|
// CLI entry point for `opencode run`.
|
|
//
|
|
// Handles three modes:
|
|
// 1. Non-interactive (default): sends a single prompt, streams events to
|
|
// stdout, and exits when the session goes idle.
|
|
// 2. Interactive local (`--interactive`): boots the split-footer direct mode
|
|
// with an in-process server (no external HTTP).
|
|
// 3. Interactive attach (`--interactive --attach`): connects to a running
|
|
// opencode server and runs interactive mode against it.
|
|
//
|
|
// Also supports `--command` for slash-command execution, `--format json` for
|
|
// raw event streaming, `--continue` / `--session` for session resumption,
|
|
// and `--fork` for forking before continuing.
|
|
import type { Argv } from "yargs"
|
|
import path from "path"
|
|
import { pathToFileURL } from "url"
|
|
import { Effect } from "effect"
|
|
import { UI } from "../ui"
|
|
import { effectCmd } from "../effect-cmd"
|
|
import { EOL } from "os"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
|
import { FormatError, FormatUnknownError } from "../error"
|
|
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
|
|
|
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
|
|
|
function pick(value: string | undefined): ModelInput | undefined {
|
|
if (!value) return undefined
|
|
const [providerID, ...rest] = value.split("/")
|
|
return {
|
|
providerID,
|
|
modelID: rest.join("/"),
|
|
} as ModelInput
|
|
}
|
|
|
|
function resolveRunInput(value?: string, piped?: string): string | undefined {
|
|
if (!value) {
|
|
return piped
|
|
}
|
|
|
|
if (!piped) {
|
|
return value
|
|
}
|
|
|
|
return value + "\n" + piped
|
|
}
|
|
|
|
type FilePart = {
|
|
type: "file"
|
|
url: string
|
|
filename: string
|
|
mime: string
|
|
}
|
|
|
|
type Inline = {
|
|
icon: string
|
|
title: string
|
|
description?: string
|
|
}
|
|
|
|
type SessionInfo = {
|
|
id: string
|
|
title?: string
|
|
directory?: string
|
|
}
|
|
|
|
function inline(info: Inline) {
|
|
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
|
|
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
|
|
}
|
|
|
|
function block(info: Inline, output?: string) {
|
|
UI.empty()
|
|
inline(info)
|
|
if (!output?.trim()) return
|
|
UI.println(output)
|
|
UI.empty()
|
|
}
|
|
|
|
function formatRunError(error: unknown) {
|
|
return FormatError(error) ?? FormatUnknownError(error)
|
|
}
|
|
|
|
async function tool(part: ToolPart) {
|
|
try {
|
|
const { toolInlineInfo } = await import("./run/tool")
|
|
const next = toolInlineInfo(part)
|
|
if (next.mode === "block") {
|
|
block(next, next.body)
|
|
return
|
|
}
|
|
|
|
inline(next)
|
|
} catch {
|
|
inline({
|
|
icon: "\u2699",
|
|
title: part.tool,
|
|
})
|
|
}
|
|
}
|
|
|
|
async function toolError(part: ToolPart) {
|
|
try {
|
|
const { toolInlineInfo } = await import("./run/tool")
|
|
const next = toolInlineInfo(part)
|
|
inline({
|
|
icon: "✗",
|
|
title: `${next.title} failed`,
|
|
...(next.description && { description: next.description }),
|
|
})
|
|
return
|
|
} catch {
|
|
inline({
|
|
icon: "✗",
|
|
title: `${part.tool} failed`,
|
|
})
|
|
}
|
|
}
|
|
|
|
export const RunCommand = effectCmd({
|
|
command: "run [message..]",
|
|
describe: "run zmo with a message",
|
|
// --attach connects to a remote server (no local instance needed); the
|
|
// default path runs an in-process server and needs the project instance.
|
|
instance: (args) => !args.attach,
|
|
// For --dir without --attach, load instance for the resolved target dir.
|
|
// The handler also chdirs (preserving the legacy order: chdir → file resolution).
|
|
directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()),
|
|
builder: (yargs: Argv) =>
|
|
yargs
|
|
.positional("message", {
|
|
describe: "message to send",
|
|
type: "string",
|
|
array: true,
|
|
default: [],
|
|
})
|
|
.option("command", {
|
|
describe: "the command to run, use message for args",
|
|
type: "string",
|
|
})
|
|
.option("continue", {
|
|
alias: ["c"],
|
|
describe: "continue the last session",
|
|
type: "boolean",
|
|
})
|
|
.option("session", {
|
|
alias: ["s"],
|
|
describe: "session id to continue",
|
|
type: "string",
|
|
})
|
|
.option("fork", {
|
|
describe: "fork the session before continuing (requires --continue or --session)",
|
|
type: "boolean",
|
|
})
|
|
.option("share", {
|
|
type: "boolean",
|
|
describe: "share the session",
|
|
})
|
|
.option("model", {
|
|
type: "string",
|
|
alias: ["m"],
|
|
describe: "model to use in the format of provider/model",
|
|
})
|
|
.option("agent", {
|
|
type: "string",
|
|
describe: "agent to use",
|
|
})
|
|
.option("format", {
|
|
type: "string",
|
|
choices: ["default", "json"],
|
|
default: "default",
|
|
describe: "format: default (formatted) or json (raw JSON events)",
|
|
})
|
|
.option("file", {
|
|
alias: ["f"],
|
|
type: "string",
|
|
array: true,
|
|
describe: "file(s) to attach to message",
|
|
})
|
|
.option("title", {
|
|
type: "string",
|
|
describe: "title for the session (uses truncated prompt if no value provided)",
|
|
})
|
|
.option("attach", {
|
|
type: "string",
|
|
describe: "attach to a running zmo server (e.g., http://localhost:4096)",
|
|
})
|
|
.option("password", {
|
|
alias: ["p"],
|
|
type: "string",
|
|
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
|
})
|
|
.option("username", {
|
|
alias: ["u"],
|
|
type: "string",
|
|
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
|
})
|
|
.option("dir", {
|
|
type: "string",
|
|
describe: "directory to run in, path on remote server if attaching",
|
|
})
|
|
.option("port", {
|
|
type: "number",
|
|
describe: "port for the local server (defaults to random port if no value provided)",
|
|
})
|
|
.option("variant", {
|
|
type: "string",
|
|
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
|
|
})
|
|
.option("thinking", {
|
|
type: "boolean",
|
|
describe: "show thinking blocks",
|
|
})
|
|
.option("replay", {
|
|
type: "boolean",
|
|
default: true,
|
|
describe: "replay interactive session history on resume and after resize (use --no-replay to disable)",
|
|
})
|
|
.option("replay-limit", {
|
|
type: "number",
|
|
describe: "cap visible interactive replay to the newest N messages",
|
|
})
|
|
.option("interactive", {
|
|
alias: ["i"],
|
|
type: "boolean",
|
|
describe: "run in direct interactive split-footer mode",
|
|
default: false,
|
|
})
|
|
.option("dangerously-skip-permissions", {
|
|
type: "boolean",
|
|
describe: "auto-approve permissions that are not explicitly denied (dangerous!)",
|
|
default: false,
|
|
})
|
|
.option("demo", {
|
|
type: "boolean",
|
|
default: false,
|
|
describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately",
|
|
}),
|
|
handler: Effect.fn("Cli.run")(function* (args) {
|
|
const { Agent } = yield* Effect.promise(() => import("@/agent/agent"))
|
|
const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags"))
|
|
const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref"))
|
|
const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth"))
|
|
const agentSvc = yield* Agent.Service
|
|
const flags = yield* RuntimeFlags.Service
|
|
const localInstance = yield* InstanceRef
|
|
yield* Effect.promise(async () => {
|
|
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
|
const thinking = args.interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
|
const die = (message: string): never => {
|
|
UI.error(message)
|
|
process.exit(1)
|
|
}
|
|
const dieInteractive = (error: unknown): never => {
|
|
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) {
|
|
die(error.message)
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
let message = [...args.message, ...(args["--"] || [])]
|
|
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
|
.join(" ")
|
|
|
|
if (args.interactive && args.command) {
|
|
die("--interactive cannot be used with --command")
|
|
}
|
|
|
|
if (args.demo && !args.interactive) {
|
|
die("--demo requires --interactive")
|
|
}
|
|
|
|
if (args.interactive && args.format === "json") {
|
|
die("--interactive cannot be used with --format json")
|
|
}
|
|
|
|
if (args["replay-limit"] !== undefined && !args.interactive) {
|
|
die("--replay-limit requires --interactive")
|
|
}
|
|
|
|
if (
|
|
args["replay-limit"] !== undefined &&
|
|
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
|
|
) {
|
|
die("--replay-limit must be a positive integer")
|
|
}
|
|
|
|
if (args.interactive && !process.stdout.isTTY) {
|
|
die("--interactive requires a TTY stdout")
|
|
}
|
|
|
|
if (args.interactive) {
|
|
try {
|
|
resolveInteractiveStdin().cleanup?.()
|
|
} catch (error) {
|
|
dieInteractive(error)
|
|
}
|
|
}
|
|
|
|
const replay = args.replay || args["replay-limit"] !== undefined
|
|
|
|
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
|
const directory = (() => {
|
|
if (!args.dir) return args.attach ? undefined : root
|
|
if (args.attach) return args.dir
|
|
|
|
try {
|
|
process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir))
|
|
return process.cwd()
|
|
} catch {
|
|
UI.error("Failed to change directory to " + args.dir)
|
|
process.exit(1)
|
|
}
|
|
})()
|
|
const attachHeaders = args.attach
|
|
? ServerAuth.headers({ password: args.password, username: args.username })
|
|
: undefined
|
|
const attachSDK = (dir?: string) => {
|
|
return createOpencodeClient({
|
|
baseUrl: args.attach!,
|
|
directory: dir,
|
|
headers: attachHeaders,
|
|
})
|
|
}
|
|
|
|
const files: FilePart[] = []
|
|
if (args.file) {
|
|
const list = Array.isArray(args.file) ? args.file : [args.file]
|
|
|
|
for (const filePath of list) {
|
|
const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath)
|
|
if (!(await Filesystem.exists(resolvedPath))) {
|
|
UI.error(`File not found: ${filePath}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
|
|
|
files.push({
|
|
type: "file",
|
|
url: pathToFileURL(resolvedPath).href,
|
|
filename: path.basename(resolvedPath),
|
|
mime,
|
|
})
|
|
}
|
|
}
|
|
|
|
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
|
|
message = resolveRunInput(message, piped) ?? ""
|
|
const initialInput = resolveRunInput(rawMessage, piped)
|
|
|
|
if (message.trim().length === 0 && !args.command && !args.interactive) {
|
|
UI.error("You must provide a message or a command")
|
|
process.exit(1)
|
|
}
|
|
|
|
if (args.fork && !args.continue && !args.session) {
|
|
UI.error("--fork requires --continue or --session")
|
|
process.exit(1)
|
|
}
|
|
|
|
const rules: PermissionV1.Ruleset = args.interactive
|
|
? []
|
|
: [
|
|
{
|
|
permission: "question",
|
|
action: "deny",
|
|
pattern: "*",
|
|
},
|
|
{
|
|
permission: "plan_enter",
|
|
action: "deny",
|
|
pattern: "*",
|
|
},
|
|
{
|
|
permission: "plan_exit",
|
|
action: "deny",
|
|
pattern: "*",
|
|
},
|
|
]
|
|
|
|
function title() {
|
|
if (args.title === undefined) return
|
|
if (args.title !== "") return args.title
|
|
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
|
}
|
|
|
|
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
|
if (args.session) {
|
|
const current = await sdk.session
|
|
.get({
|
|
sessionID: args.session,
|
|
})
|
|
.catch(() => undefined)
|
|
|
|
if (!current?.data) {
|
|
UI.error("Session not found")
|
|
process.exit(1)
|
|
}
|
|
|
|
if (args.fork) {
|
|
const forked = await sdk.session.fork({
|
|
sessionID: args.session,
|
|
})
|
|
const id = forked.data?.id
|
|
if (!id) {
|
|
return
|
|
}
|
|
|
|
return {
|
|
id,
|
|
title: forked.data?.title ?? current.data.title,
|
|
directory: forked.data?.directory ?? current.data.directory,
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: current.data.id,
|
|
title: current.data.title,
|
|
directory: current.data.directory,
|
|
}
|
|
}
|
|
|
|
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
|
|
|
|
if (base && args.fork) {
|
|
const forked = await sdk.session.fork({
|
|
sessionID: base.id,
|
|
})
|
|
const id = forked.data?.id
|
|
if (!id) {
|
|
return
|
|
}
|
|
|
|
return {
|
|
id,
|
|
title: forked.data?.title ?? base.title,
|
|
directory: forked.data?.directory ?? base.directory,
|
|
}
|
|
}
|
|
|
|
if (base) {
|
|
return {
|
|
id: base.id,
|
|
title: base.title,
|
|
directory: base.directory,
|
|
}
|
|
}
|
|
|
|
const name = title()
|
|
const result = await sdk.session.create({
|
|
title: name,
|
|
permission: [...rules],
|
|
})
|
|
const id = result.data?.id
|
|
if (!id) {
|
|
return
|
|
}
|
|
|
|
return {
|
|
id,
|
|
title: result.data?.title ?? name,
|
|
directory: result.data?.directory,
|
|
}
|
|
}
|
|
|
|
async function share(sdk: OpencodeClient, sessionID: string) {
|
|
const cfg = await sdk.config.get()
|
|
if (!cfg.data) return
|
|
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
|
|
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
|
if (error instanceof Error && error.message.includes("disabled")) {
|
|
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
|
}
|
|
return { error }
|
|
})
|
|
if (!res.error && "data" in res && res.data?.share?.url) {
|
|
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
|
}
|
|
}
|
|
|
|
async function createFreshSession(
|
|
sdk: OpencodeClient,
|
|
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
|
|
): Promise<SessionInfo> {
|
|
const result = await sdk.session.create({
|
|
title: args.title !== undefined && args.title !== "" ? args.title : undefined,
|
|
agent: input.agent,
|
|
model: input.model
|
|
? {
|
|
providerID: input.model.providerID,
|
|
id: input.model.modelID,
|
|
variant: input.variant,
|
|
}
|
|
: undefined,
|
|
permission: [...rules],
|
|
})
|
|
const id = result.data?.id
|
|
if (!id) {
|
|
throw new Error("Failed to create session")
|
|
}
|
|
|
|
void share(sdk, id).catch(() => {})
|
|
return {
|
|
id,
|
|
title: result.data?.title,
|
|
}
|
|
}
|
|
|
|
async function current(sdk: OpencodeClient): Promise<string> {
|
|
if (!args.attach) {
|
|
return directory ?? root
|
|
}
|
|
|
|
const next = await sdk.path
|
|
.get()
|
|
.then((x) => x.data?.directory)
|
|
.catch(() => undefined)
|
|
if (next) {
|
|
return next
|
|
}
|
|
|
|
UI.error("Failed to resolve remote directory")
|
|
process.exit(1)
|
|
}
|
|
|
|
async function localAgent() {
|
|
if (!args.agent) return undefined
|
|
const name = args.agent
|
|
|
|
const entry = await Effect.runPromise(
|
|
agentSvc.get(name).pipe(Effect.provideService(InstanceRef, localInstance)),
|
|
)
|
|
if (!entry) {
|
|
UI.println(
|
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
|
UI.Style.TEXT_NORMAL,
|
|
`agent "${name}" not found. Falling back to default agent`,
|
|
)
|
|
return undefined
|
|
}
|
|
if (entry.mode === "subagent") {
|
|
UI.println(
|
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
|
UI.Style.TEXT_NORMAL,
|
|
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
|
)
|
|
return undefined
|
|
}
|
|
return name
|
|
}
|
|
|
|
async function attachAgent(sdk: OpencodeClient) {
|
|
if (!args.agent) return undefined
|
|
const name = args.agent
|
|
|
|
const modes = await sdk.app
|
|
.agents(undefined, { throwOnError: true })
|
|
.then((x) => x.data ?? [])
|
|
.catch(() => undefined)
|
|
|
|
if (!modes) {
|
|
UI.println(
|
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
|
UI.Style.TEXT_NORMAL,
|
|
`failed to list agents from ${args.attach}. Falling back to default agent`,
|
|
)
|
|
return undefined
|
|
}
|
|
|
|
const agent = modes.find((a) => a.name === name)
|
|
if (!agent) {
|
|
UI.println(
|
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
|
UI.Style.TEXT_NORMAL,
|
|
`agent "${name}" not found. Falling back to default agent`,
|
|
)
|
|
return undefined
|
|
}
|
|
|
|
if (agent.mode === "subagent") {
|
|
UI.println(
|
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
|
UI.Style.TEXT_NORMAL,
|
|
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
|
)
|
|
return undefined
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
async function pickAgent(sdk: OpencodeClient) {
|
|
if (!args.agent) return undefined
|
|
if (args.attach) {
|
|
return attachAgent(sdk)
|
|
}
|
|
|
|
return localAgent()
|
|
}
|
|
|
|
async function execute(sdk: OpencodeClient) {
|
|
const sess = await session(sdk)
|
|
if (!sess?.id) {
|
|
UI.error("Session not found")
|
|
process.exit(1)
|
|
}
|
|
const sessionID = sess.id
|
|
|
|
function emit(type: string, data: Record<string, unknown>) {
|
|
if (args.format === "json") {
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
type,
|
|
timestamp: Date.now(),
|
|
sessionID,
|
|
...data,
|
|
}) + EOL,
|
|
)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Consume one subscribed event stream for the active session and mirror it
|
|
// to stdout/UI. `client` is passed explicitly because attach mode may
|
|
// rebind the SDK to the session's directory after the subscription is
|
|
// created, and replies issued from inside the loop must use that client.
|
|
async function loop(client: OpencodeClient, events: Awaited<ReturnType<typeof sdk.event.subscribe>>) {
|
|
const toggles = new Map<string, boolean>()
|
|
let error: string | undefined
|
|
|
|
for await (const event of events.stream) {
|
|
if (
|
|
event.type === "message.updated" &&
|
|
event.properties.sessionID === sessionID &&
|
|
event.properties.info.role === "assistant" &&
|
|
args.format !== "json" &&
|
|
toggles.get("start") !== true
|
|
) {
|
|
UI.empty()
|
|
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
|
|
UI.empty()
|
|
toggles.set("start", true)
|
|
}
|
|
|
|
if (event.type === "message.part.updated") {
|
|
const part = event.properties.part
|
|
if (part.sessionID !== sessionID) continue
|
|
|
|
if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) {
|
|
if (emit("tool_use", { part })) continue
|
|
if (part.state.status === "completed") {
|
|
await tool(part)
|
|
continue
|
|
}
|
|
await toolError(part)
|
|
UI.error(part.state.error)
|
|
}
|
|
|
|
if (
|
|
part.type === "tool" &&
|
|
part.tool === "task" &&
|
|
part.state.status === "running" &&
|
|
args.format !== "json"
|
|
) {
|
|
if (toggles.get(part.id) === true) continue
|
|
await tool(part)
|
|
toggles.set(part.id, true)
|
|
}
|
|
|
|
if (part.type === "step-start") {
|
|
if (emit("step_start", { part })) continue
|
|
}
|
|
|
|
if (part.type === "step-finish") {
|
|
if (emit("step_finish", { part })) continue
|
|
}
|
|
|
|
if (part.type === "text" && part.time?.end) {
|
|
if (emit("text", { part })) continue
|
|
const text = part.text.trim()
|
|
if (!text) continue
|
|
if (!process.stdout.isTTY) {
|
|
process.stdout.write(text + EOL)
|
|
continue
|
|
}
|
|
UI.empty()
|
|
UI.println(text)
|
|
UI.empty()
|
|
}
|
|
|
|
if (part.type === "reasoning" && part.time?.end && thinking) {
|
|
if (emit("reasoning", { part })) continue
|
|
const text = part.text.trim()
|
|
if (!text) continue
|
|
const line = `Thinking: ${text}`
|
|
if (process.stdout.isTTY) {
|
|
UI.empty()
|
|
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
|
UI.empty()
|
|
continue
|
|
}
|
|
process.stdout.write(line + EOL)
|
|
}
|
|
}
|
|
|
|
if (event.type === "session.error") {
|
|
const props = event.properties
|
|
if (props.sessionID !== sessionID || !props.error) continue
|
|
let err = String(props.error.name)
|
|
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
|
err = String(props.error.data.message)
|
|
}
|
|
error = error ? error + EOL + err : err
|
|
if (emit("error", { error: props.error })) continue
|
|
UI.error(err)
|
|
}
|
|
|
|
if (
|
|
event.type === "session.status" &&
|
|
event.properties.sessionID === sessionID &&
|
|
event.properties.status.type === "idle"
|
|
) {
|
|
break
|
|
}
|
|
|
|
if (event.type === "permission.asked") {
|
|
const permission = event.properties
|
|
if (permission.sessionID !== sessionID) continue
|
|
|
|
if (args["dangerously-skip-permissions"]) {
|
|
await client.permission.reply({
|
|
requestID: permission.id,
|
|
reply: "once",
|
|
})
|
|
} else {
|
|
UI.println(
|
|
UI.Style.TEXT_WARNING_BOLD + "!",
|
|
UI.Style.TEXT_NORMAL +
|
|
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
|
)
|
|
await client.permission.reply({
|
|
requestID: permission.id,
|
|
reply: "reject",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return error
|
|
}
|
|
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
|
|
const client = args.attach ? attachSDK(cwd) : sdk
|
|
|
|
// Validate agent if specified
|
|
const agent = await pickAgent(client)
|
|
|
|
await share(client, sessionID)
|
|
|
|
if (!args.interactive) {
|
|
const events = await client.event.subscribe()
|
|
loop(client, events).catch((e) => {
|
|
console.error(e)
|
|
process.exit(1)
|
|
})
|
|
|
|
if (args.command) {
|
|
const result = await client.session.command({
|
|
sessionID,
|
|
agent,
|
|
model: args.model,
|
|
command: args.command,
|
|
arguments: message,
|
|
variant: args.variant,
|
|
})
|
|
if (result.error) {
|
|
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
|
|
process.exitCode = 1
|
|
}
|
|
return
|
|
}
|
|
|
|
const model = pick(args.model)
|
|
const result = await client.session.prompt({
|
|
sessionID,
|
|
agent,
|
|
model,
|
|
variant: args.variant,
|
|
parts: [...files, { type: "text", text: message }],
|
|
})
|
|
if (result.error) {
|
|
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
|
|
process.exitCode = 1
|
|
}
|
|
return
|
|
}
|
|
|
|
const model = pick(args.model)
|
|
const { runInteractiveMode } = await import("./run/runtime")
|
|
try {
|
|
await runInteractiveMode({
|
|
sdk: client,
|
|
directory: cwd,
|
|
sessionID,
|
|
sessionTitle: sess.title,
|
|
resume: Boolean(args.session || args.continue) && !args.fork,
|
|
replay,
|
|
replayLimit: args["replay-limit"],
|
|
agent,
|
|
model,
|
|
variant: args.variant,
|
|
files,
|
|
initialInput,
|
|
createSession: createFreshSession,
|
|
thinking,
|
|
demo: args.demo,
|
|
})
|
|
} catch (error) {
|
|
dieInteractive(error)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (args.interactive && !args.attach && !args.session && !args.continue) {
|
|
const model = pick(args.model)
|
|
const { runInteractiveLocalMode } = await import("./run/runtime")
|
|
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const { Server } = await import("@/server/server")
|
|
const request = new Request(input, init)
|
|
return Server.Default().app.fetch(request)
|
|
}) as typeof globalThis.fetch
|
|
|
|
try {
|
|
return await runInteractiveLocalMode({
|
|
directory: directory ?? root,
|
|
fetch: fetchFn,
|
|
resolveAgent: localAgent,
|
|
session,
|
|
share,
|
|
createSession: createFreshSession,
|
|
agent: args.agent,
|
|
model,
|
|
variant: args.variant,
|
|
replay,
|
|
replayLimit: args["replay-limit"],
|
|
files,
|
|
initialInput,
|
|
thinking,
|
|
demo: args.demo,
|
|
})
|
|
} catch (error) {
|
|
dieInteractive(error)
|
|
}
|
|
}
|
|
|
|
if (args.attach) {
|
|
const sdk = attachSDK(directory)
|
|
return await execute(sdk)
|
|
}
|
|
|
|
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const { Server } = await import("@/server/server")
|
|
const request = new Request(input, init)
|
|
return Server.Default().app.fetch(request)
|
|
}) as typeof globalThis.fetch
|
|
const sdk = createOpencodeClient({
|
|
baseUrl: "http://opencode.internal",
|
|
fetch: fetchFn,
|
|
directory,
|
|
})
|
|
await execute(sdk)
|
|
})
|
|
}),
|
|
})
|