Skip to content
Open
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
13 changes: 13 additions & 0 deletions .opencode/command/btw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
description: ask in background or append a todo
---

Background helper.

Usage:

- `/btw ask <question>` to start a background child session and post the result back here later.
- `/btw todo <text>` to append a todo to the current session immediately.
- `/btw status` to list child background tasks for the current session.

If no subcommand is provided, treat the remaining text as `ask`.
4 changes: 2 additions & 2 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ export namespace MessageV2 {
parts: [],
}
for (const part of msg.parts) {
if (part.type === "text")
if (part.type === "text" && !part.synthetic)
assistantMessage.parts.push({
type: "text",
text: part.text,
Expand Down Expand Up @@ -756,7 +756,7 @@ export namespace MessageV2 {
}
}
if (assistantMessage.parts.length > 0) {
result.push(assistantMessage)
if (assistantMessage.parts.length > 0) result.push(assistantMessage)
// Inject pending media as a user message for providers that don't support
// media (images, PDFs) in tool results
if (media.length > 0) {
Expand Down
216 changes: 216 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { decodeDataUrl } from "@/util/data-url"
import { Todo } from "./todo"

// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
Expand Down Expand Up @@ -1746,6 +1747,210 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
const placeholderRegex = /\$(\d+)/g
const quoteTrimRegex = /^["']|["']$/g

function text(parts: MessageV2.Part[]) {
return parts.findLast((part) => part.type === "text")?.text.trim() ?? ""
}

function label(prefix: string, value: string) {
const next = value.trim() || prefix
return next.length > 80 ? next.slice(0, 77) + "..." : next
}

async function idle(sessionID: SessionID) {
while (SessionStatus.get(sessionID).type !== "idle") {
await Bun.sleep(50)
}
}

async function post(input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
title: string
text: string
wait?: boolean
}) {
if (input.wait) await idle(input.sessionID)
const user: MessageV2.User = {
id: MessageID.ascending(),
sessionID: input.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: input.agent,
model: input.model,
}
await Session.updateMessage(user)
await Session.updatePart({
id: PartID.ascending(),
messageID: user.id,
sessionID: input.sessionID,
type: "text",
text: input.title,
synthetic: true,
} satisfies MessageV2.TextPart)

const assistant: MessageV2.Assistant = {
id: MessageID.ascending(),
sessionID: input.sessionID,
parentID: user.id,
mode: input.agent,
agent: input.agent,
cost: 0,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
time: {
created: Date.now(),
completed: Date.now(),
},
role: "assistant",
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: input.model.modelID,
providerID: input.model.providerID,
}
await Session.updateMessage(assistant)
const part = {
id: PartID.ascending(),
messageID: assistant.id,
sessionID: input.sessionID,
type: "text",
text: input.text,
synthetic: true,
} satisfies MessageV2.TextPart
await Session.updatePart(part)
return {
info: assistant,
parts: [part],
}
}

async function update(input: { part: MessageV2.TextPart; text: string; wait?: boolean }) {
if (input.wait) await idle(input.part.sessionID)
input.part.text = [input.part.text, input.text].filter(Boolean).join("\n\n")
await Session.updatePart(input.part)
}

async function status(sessionID: SessionID) {
const kids = await Session.children(sessionID)
if (kids.length === 0) return "No background tasks yet."

const lines = await Promise.all(
kids.map(async (child) => {
const state = SessionStatus.get(child.id)
if (state.type === "busy") return `- ${child.title} (\`${child.id}\`) - running`
if (state.type === "retry") return `- ${child.title} (\`${child.id}\`) - retry ${state.attempt}`

const msgs = await Session.messages({ sessionID: child.id, limit: 3 })
const assistant = msgs.find((msg) => msg.info.role === "assistant")
const value = assistant ? text(assistant.parts) || "completed" : "idle"
return `- ${child.title} (\`${child.id}\`) - ${value}`
}),
)

return lines.join("\n")
}

export async function commandBtw(input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
arguments: string
variant?: string
parts?: CommandInput["parts"]
run?: (input: PromptInput) => Promise<MessageV2.WithParts>
}) {
const run = input.run ?? prompt
const args = input.arguments.trim()
if (args === "status") {
return post({
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
title: "Background status",
text: await status(input.sessionID),
})
}

const todo = args.startsWith("todo ") ? args.slice(5).trim() : ""
if (todo) {
Todo.append({
sessionID: input.sessionID,
todo: { content: todo, status: "pending", priority: "medium" },
})
return post({
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
title: `Background note: ${label("todo", todo)}`,
text: `Added todo: ${todo}`,
})
}

const query = args.startsWith("ask ") ? args.slice(4).trim() : args
if (!query) {
return post({
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
title: "Background helper",
text: "Usage: `/btw ask <question>`, `/btw todo <text>`, or `/btw status`.",
})
}

const parent = await Session.get(input.sessionID)
const child = await Session.create({
parentID: input.sessionID,
workspaceID: parent.workspaceID,
title: `BTW: ${label("background task", query)}`,
})

const note = await post({
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
title: `Background task: ${label("ask", query)}`,
text: `Started background task in child session \`${child.id}\`. I'll post the result here when it finishes.`,
})

void run({
sessionID: child.id,
model: input.model,
agent: input.agent,
variant: input.variant,
parts: [
{
type: "text",
text: query,
},
...(input.parts ?? []),
],
})
.then((result) =>
update({
part: note.parts[0] as MessageV2.TextPart,
text: `Child session \`${child.id}\` completed.\n\n${text(result.parts) || "(no text output)"}`,
wait: true,
}),
)
.catch((err) =>
update({
part: note.parts[0] as MessageV2.TextPart,
text: `Child session \`${child.id}\` failed: ${err instanceof Error ? err.message : String(err)}`,
wait: true,
}),
)

return note
}
/**
* Regular expression to match @ file references in text
* Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
Expand Down Expand Up @@ -1841,6 +2046,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
throw error
}

if (input.command === "btw") {
return commandBtw({
sessionID: input.sessionID,
agent: agent.name,
model: taskModel,
arguments: input.arguments,
variant: input.variant,
parts: input.parts,
})
}

const templateParts = await resolvePromptParts(template)
const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
const parts = isSubtask
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/src/session/todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SessionID } from "./schema"
import z from "zod"
import { Database, eq, asc } from "../storage/db"
import { TodoTable } from "./session.sql"
import { max } from "drizzle-orm"

export namespace Todo {
export const Info = z
Expand Down Expand Up @@ -54,4 +55,27 @@ export namespace Todo {
priority: row.priority,
}))
}

export function append(input: { sessionID: SessionID; todo: Info }) {
Database.transaction((db) => {
const row = db
.select({ position: max(TodoTable.position) })
.from(TodoTable)
.where(eq(TodoTable.session_id, input.sessionID))
.get()
db.insert(TodoTable)
.values({
session_id: input.sessionID,
content: input.todo.content,
status: input.todo.status,
priority: input.todo.priority,
position: (row?.position ?? -1) + 1,
})
.run()
})
Bus.publish(Event.Updated, {
sessionID: input.sessionID,
todos: get(input.sessionID),
})
}
}
Loading
Loading