Skip to content
Draft
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
34 changes: 32 additions & 2 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ function normalizePath(input?: string) {
return input
}

function auth() {
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return `Basic ${btoa(`${username}:${password}`)}`
}

export const RunCommand = cmd({
command: "run [message..]",
describe: "run opencode with a message",
Expand Down Expand Up @@ -648,7 +655,18 @@ export const RunCommand = cmd({
}

if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
const authorization = auth()
const sdk = createOpencodeClient({
baseUrl: args.attach,
directory,
...(authorization
? {
headers: {
authorization,
},
}
: {}),
})
return await execute(sdk)
}

Expand All @@ -657,7 +675,19 @@ export const RunCommand = cmd({
const request = new Request(input, init)
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
const authorization = auth()
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: fetchFn,
directory: process.cwd(),
...(authorization
? {
headers: {
authorization,
},
}
: {}),
})
await execute(sdk)
})
},
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,54 @@ export namespace Config {
})
export type Command = z.infer<typeof Command>

const AutoContinuePattern = z.string().refine(
(value) => {
try {
new RegExp(value, "i")
return true
} catch {
return false
}
},
{
message: "Invalid regex pattern",
},
)

export const AutoContinue = z
.union([
z.boolean(),
z.object({
prompt: z.string().min(1).optional().describe("Message to auto-send when a match is detected"),
patterns: z
.array(AutoContinuePattern)
.min(1)
.optional()
.describe("Case-insensitive regex patterns matched against the assistant's closing paragraph"),
}),
])
.transform((value) => {
const prompt = typeof value === "object" && value.prompt ? value.prompt : "Yes. Do this."
const patterns =
typeof value === "object" && value.patterns
? value.patterns
: [
"\\bif you want\\b",
"\\bif you like\\b",
"\\bif you(?:'d| would)? like\\b",
"\\blet me know if you(?:'d| would)? like me to continue\\b",
]
return {
enabled: value === true || typeof value === "object",
prompt,
patterns,
}
})
.meta({
ref: "AutoContinueConfig",
})
export type AutoContinue = z.infer<typeof AutoContinue>

export const Skills = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
Expand Down Expand Up @@ -1150,6 +1198,9 @@ export namespace Config {
experimental: z
.object({
disable_paste_summary: z.boolean().optional(),
auto_continue: AutoContinue.optional().describe(
"Automatically continue when the assistant ends with a continuation prompt",
),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
openTelemetry: z
.boolean()
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { AutoContinue } from "@/session/autocontinue"

export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
Expand All @@ -24,6 +25,7 @@ export async function InstanceBootstrap() {
Vcs.init()
Snapshot.init()
Truncate.init()
AutoContinue.init()

Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
Expand Down
91 changes: 91 additions & 0 deletions packages/opencode/src/session/autocontinue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
import { SessionPrompt } from "@/session/prompt"
import { Log } from "@/util/log"
import { MessageV2 } from "./message-v2"

export namespace AutoContinue {
const log = Log.create({ service: "session.autocontinue" })

const state = Instance.state(() => ({
init: false,
seen: new Set<string>(),
}))

export function init() {
const s = state()
if (s.init) return
s.init = true
Bus.subscribe(MessageV2.Event.Updated, async (event) => {
await update(event.properties.info)
})
}

export async function update(info: MessageV2.Info) {
if (info.role !== "assistant" || info.error) return
if (!info.finish || info.finish === "tool-calls" || info.finish === "unknown") return

const cfg = (await Config.get()).experimental?.auto_continue
if (!cfg?.enabled) return

const s = state()
if (s.seen.has(info.id)) return
s.seen.add(info.id)
if ((await latest(info.sessionID))?.info.id !== info.id) {
s.seen.delete(info.id)
return
}

const msg = await MessageV2.get({
sessionID: info.sessionID,
messageID: info.id,
})
const text = tail(msg.parts)
if (!text || !match(text, cfg)) {
s.seen.delete(info.id)
return
}

log.info("continuing", { sessionID: info.sessionID, messageID: info.id })
void SessionPrompt.prompt({
sessionID: info.sessionID,
parts: [
{
type: "text",
text: cfg.prompt,
synthetic: true,
},
],
}).catch((err) => {
log.error("failed to continue", { sessionID: info.sessionID, messageID: info.id, error: err })
})
}

export function match(text: string, cfg: Config.AutoContinue) {
return cfg.patterns.some((pattern) => new RegExp(pattern, "i").test(text))
}

export function tail(parts: MessageV2.Part[]) {
const text = parts
.filter((part) => part.type === "text")
.map((part) => part.text.trim())
.filter(Boolean)
.join("\n")
.trim()
if (!text) return ""
return (
text
.split(/\n\s*\n/g)
.map((part) => part.trim())
.filter(Boolean)
.pop() ?? text
)
}

async function latest(sessionID: string) {
for await (const msg of MessageV2.stream(sessionID)) {
return msg
}
}
}
62 changes: 62 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,3 +1994,65 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
}
})
})

describe("experimental.auto_continue", () => {
test("normalizes boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
experimental: {
auto_continue: true,
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
expect(cfg.experimental?.auto_continue).toEqual({
enabled: true,
prompt: "Yes. Do this.",
patterns: [
"\\bif you want\\b",
"\\bif you like\\b",
"\\bif you(?:'d| would)? like\\b",
"\\blet me know if you(?:'d| would)? like me to continue\\b",
],
})
},
})
})

test("normalizes custom config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
experimental: {
auto_continue: {
prompt: "Keep going.",
patterns: ["continue please"],
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const cfg = await Config.get()
expect(cfg.experimental?.auto_continue).toEqual({
enabled: true,
prompt: "Keep going.",
patterns: ["continue please"],
})
},
})
})
})
Loading
Loading