Compare commits

...

11 Commits

Author SHA1 Message Date
Dax Raad 4b2ce14ff3 bring back task tool 2025-07-15 00:05:54 -04:00
Jase Kraft 294a11752e fix: --continue pull the latest session id consistently (#918)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-07-14 20:32:00 -04:00
Dax Raad 1cf1d1f634 docs: fix agents.md 2025-07-14 20:23:05 -04:00
Ryan Roden-Corrent 2ce694d41f Add support for job-control suspend (ctrl+z/SIGSTP). (#944) 2025-07-14 20:13:46 -04:00
CodinCat d6eff3b3a3 improve error handling and logging for GitHub API failures in upgrade and install script (#972) 2025-07-14 20:13:12 -04:00
Dax Raad e63a6d45c1 docs: README 2025-07-14 20:10:43 -04:00
Dax Raad 93686519ba docs: README 2025-07-14 20:06:15 -04:00
Mike Wallio f593792fb5 Standardize parameter description references in Edit and MultiEdit tools (#984) 2025-07-14 20:03:59 -04:00
Dax Raad 2cdb37c32b support anthropic console login flow 2025-07-14 18:07:55 -04:00
Timo Clasen 535d79b64c docs: fix typo (#982) 2025-07-14 16:40:16 -04:00
Dax Raad b4e4c3f662 wip: snapshot 2025-07-14 15:29:08 -04:00
24 changed files with 639 additions and 463 deletions
+3 -1
View File
@@ -2,9 +2,11 @@
## Style
- prefer single word variable names
- prefer single word variable/function names
- avoid try catch where possible - prefer to let exceptions bubble up
- avoid else statements where possible
- do not make useless helper functions - inline functionality unless the
function is reusable or composable
- prefer Bun apis
## Workflow
+17 -3
View File
@@ -38,10 +38,24 @@ For more info on how to configure opencode [**head over to our docs**](https://o
### Contributing
For any new features we'd appreciate it if you could open an issue first to discuss what you'd like to implement. We're pretty responsive there and it'll save you from working on something that we don't end up using. No need to do this for simpler fixes.
WE DO NOT ACCEPT PRs FOR CORE FEATURES
> **Note**: Please talk to us via github issues before spending time working on
> a new feature
opencode is an opinionated tool so any fundamental feature needs to go through a
design process with the core team.
However we still merge a ton of PRs - you can contribute:
- bug fixes
- improvements to LLM performance
- support for new providers
- fixes for env specific quirks
- missing standard behavior
- documentation
Take a look at the git history to see what kind of PRs we end up merging.
> **Note**: If you do not follow the above guidelines we might close your PR
> that you worked really hard on.
To run opencode locally you need.
+1 -1
View File
@@ -48,7 +48,7 @@ if [ -z "$requested_version" ]; then
url="https://github.com/sst/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/sst/opencode/releases/latest | awk -F'"' '/"tag_name": "/ {gsub(/^v/, "", $4); print $4}')
if [[ $? -ne 0 ]]; then
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo "${RED}Failed to fetch version information${NC}"
exit 1
fi
+1 -10
View File
@@ -26,29 +26,20 @@
},
"dependencies": {
"@clack/prompts": "0.11.0",
"@flystorage/file-storage": "1.1.0",
"@flystorage/local-fs": "1.1.0",
"@hono/zod-validator": "0.5.0",
"@modelcontextprotocol/sdk": "1.15.1",
"@openauthjs/openauth": "0.4.3",
"@standard-schema/spec": "1.0.0",
"ai": "catalog:",
"decimal.js": "10.5.0",
"diff": "8.0.2",
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",
"turndown": "7.2.0",
"vscode-jsonrpc": "8.2.1",
"vscode-languageclient": "8",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2"
"zod": "catalog:"
}
}
+8 -5
View File
@@ -4,9 +4,13 @@ import { Auth } from "./index"
export namespace AuthAnthropic {
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
export async function authorize() {
export async function authorize(mode: "max" | "console") {
const pkce = await generatePKCE()
const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
const url = new URL(
`https://${mode === "console" ? "console.anthropic.com" : "claude.ai"}/oauth/authorize`,
import.meta.url,
)
url.searchParams.set("code", "true")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("response_type", "code")
@@ -39,12 +43,11 @@ export namespace AuthAnthropic {
})
if (!result.ok) throw new ExchangeFailed()
const json = await result.json()
await Auth.set("anthropic", {
type: "oauth",
return {
refresh: json.refresh_token as string,
access: json.access_token as string,
expires: Date.now() + json.expires_in * 1000,
})
}
}
export async function access() {
+66 -9
View File
@@ -132,20 +132,24 @@ export const AuthLoginCommand = cmd({
options: [
{
label: "Claude Pro/Max",
value: "oauth",
value: "max",
},
{
label: "API Key",
label: "Create API Key",
value: "console",
},
{
label: "Manually enter API Key",
value: "api",
},
],
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
if (method === "oauth") {
if (method === "max") {
// some weird bug where program exits without this
await new Promise((resolve) => setTimeout(resolve, 10))
const { url, verifier } = await AuthAnthropic.authorize()
const { url, verifier } = await AuthAnthropic.authorize("max")
prompts.note("Trying to open browser...")
try {
await open(url)
@@ -162,13 +166,66 @@ export const AuthLoginCommand = cmd({
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
await AuthAnthropic.exchange(code, verifier)
.then(() => {
prompts.log.success("Login successful")
try {
const credentials = await AuthAnthropic.exchange(code, verifier)
await Auth.set("anthropic", {
type: "oauth",
refresh: credentials.refresh,
access: credentials.access,
expires: credentials.expires,
})
.catch(() => {
prompts.log.error("Invalid code")
prompts.log.success("Login successful")
} catch {
prompts.log.error("Invalid code")
}
prompts.outro("Done")
return
}
if (method === "console") {
// some weird bug where program exits without this
await new Promise((resolve) => setTimeout(resolve, 10))
const { url, verifier } = await AuthAnthropic.authorize("console")
prompts.note("Trying to open browser...")
try {
await open(url)
} catch (e) {
prompts.log.error(
"Failed to open browser perhaps you are running without a display or X server, please open the following URL in your browser:",
)
}
prompts.log.info(url)
const code = await prompts.text({
message: "Paste the authorization code here: ",
validate: (x) => (x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
try {
const credentials = await AuthAnthropic.exchange(code, verifier)
const accessToken = credentials.access
const response = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json, text/plain, */*",
},
})
if (!response.ok) {
throw new Error("Failed to create API key")
}
const json = await response.json()
await Auth.set("anthropic", {
type: "api",
key: json.raw_key,
})
prompts.log.success("Login successful - API key created and saved")
} catch (error) {
prompts.log.error("Invalid code or failed to create API key")
}
prompts.outro("Done")
return
}
@@ -4,11 +4,11 @@ import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) => yargs.command(SnapshotCreateCommand).command(SnapshotRestoreCommand).demandCommand(),
builder: (yargs) => yargs.command(CreateCommand).command(RestoreCommand).command(DiffCommand).demandCommand(),
async handler() {},
})
export const SnapshotCreateCommand = cmd({
const CreateCommand = cmd({
command: "create",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
@@ -18,7 +18,7 @@ export const SnapshotCreateCommand = cmd({
},
})
export const SnapshotRestoreCommand = cmd({
const RestoreCommand = cmd({
command: "restore <commit>",
builder: (yargs) =>
yargs.positional("commit", {
@@ -33,3 +33,20 @@ export const SnapshotRestoreCommand = cmd({
})
},
})
export const DiffCommand = cmd({
command: "diff <commit>",
describe: "diff",
builder: (yargs) =>
yargs.positional("commit", {
type: "string",
description: "commit",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const diff = await Snapshot.diff("test", args.commit)
console.log(diff)
})
},
})
+7 -1
View File
@@ -140,6 +140,12 @@ export namespace Installation {
export async function latest() {
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => res.json())
.then((data) => data.tag_name.slice(1) as string)
.then((data) => {
if (typeof data.tag_name !== "string") {
log.error("GitHub API error", data)
throw new Error("failed to fetch latest version")
}
return data.tag_name.slice(1) as string
})
}
}
+2 -10
View File
@@ -21,7 +21,7 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
// import { TaskTool } from "../tool/task"
import { TaskTool } from "../tool/task"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -456,7 +456,7 @@ export namespace Provider {
WriteTool,
TodoWriteTool,
TodoReadTool,
// TaskTool,
TaskTool,
]
const TOOL_MAPPING: Record<string, Tool.Info[]> = {
@@ -531,12 +531,4 @@ export namespace Provider {
providerID: z.string(),
}),
)
export const AuthError = NamedError.create(
"ProviderAuthError",
z.object({
providerID: z.string(),
message: z.string(),
}),
)
}
+251 -220
View File
@@ -230,8 +230,7 @@ export namespace Session {
info: MessageV2.Info
parts: MessageV2.Part[]
}[]
const list = Storage.list("session/message/" + sessionID)
for await (const p of list) {
for (const p of await Storage.list("session/message/" + sessionID)) {
const read = await Storage.readJSON<MessageV2.Info>(p)
result.push({
info: read,
@@ -248,7 +247,7 @@ export namespace Session {
export async function parts(sessionID: string, messageID: string) {
const result = [] as MessageV2.Part[]
for await (const item of Storage.list("session/part/" + sessionID + "/" + messageID)) {
for (const item of await Storage.list("session/part/" + sessionID + "/" + messageID)) {
const read = await Storage.readJSON<MessageV2.Part>(item)
result.push(read)
}
@@ -257,7 +256,7 @@ export namespace Session {
}
export async function* list() {
for await (const item of Storage.list("session/info")) {
for (const item of await Storage.list("session/info")) {
const sessionID = path.basename(item, ".json")
yield get(sessionID)
}
@@ -265,7 +264,7 @@ export namespace Session {
export async function children(parentID: string) {
const result = [] as Session.Info[]
for await (const item of Storage.list("session/info")) {
for (const item of await Storage.list("session/info")) {
const sessionID = path.basename(item, ".json")
const session = await get(sessionID)
if (session.parentID !== parentID) continue
@@ -444,7 +443,7 @@ export namespace Session {
const result = await ReadTool.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
messageID: "", // read tool doesn't use message ID
messageID: userMsg.id,
metadata: async () => {},
})
return [
@@ -578,20 +577,22 @@ export namespace Session {
await updateMessage(assistantMsg)
const tools: Record<string, AITool> = {}
const processor = createProcessor(assistantMsg, model.info)
for (const item of await Provider.tools(input.providerID)) {
if (mode.tools[item.id] === false) continue
if (session.parentID && item.id === "task") continue
tools[item.id] = tool({
id: item.id as any,
description: item.description,
inputSchema: item.parameters as ZodSchema,
async execute(args) {
async execute(args, options) {
const result = await item.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
messageID: assistantMsg.id,
metadata: async () => {
/*
const match = toolCalls[opts.toolCallId]
metadata: async (val) => {
const match = processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
await updatePart({
...match,
@@ -599,14 +600,13 @@ export namespace Session {
title: val.title,
metadata: val.metadata,
status: "running",
input: args.input,
input: args,
time: {
start: Date.now(),
},
},
})
}
*/
},
})
return result
@@ -677,230 +677,260 @@ export namespace Session {
],
}),
})
const result = await processStream(assistantMsg, model.info, stream)
const result = await processor.process(stream)
return result
}
async function processStream(
assistantMsg: MessageV2.Assistant,
model: ModelsDev.Model,
stream: StreamTextResult<Record<string, AITool>, never>,
) {
try {
let currentText: MessageV2.TextPart | undefined
const toolCalls: Record<string, MessageV2.ToolPart> = {}
function createProcessor(assistantMsg: MessageV2.Assistant, model: ModelsDev.Model) {
const toolCalls: Record<string, MessageV2.ToolPart> = {}
return {
partFromToolCall(toolCallID: string) {
return toolCalls[toolCallID]
},
async process(stream: StreamTextResult<Record<string, AITool>, never>) {
try {
let currentText: MessageV2.TextPart | undefined
for await (const value of stream.fullStream) {
log.info("part", {
type: value.type,
})
switch (value.type) {
case "start":
break
case "tool-input-start":
const part = await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "tool",
tool: value.toolName,
callID: value.id,
state: {
status: "pending",
},
for await (const value of stream.fullStream) {
log.info("part", {
type: value.type,
})
toolCalls[value.id] = part as MessageV2.ToolPart
break
switch (value.type) {
case "start":
const snapshot = await Snapshot.create(assistantMsg.sessionID)
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "snapshot",
snapshot,
})
break
case "tool-input-delta":
break
case "tool-input-start":
const part = await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "tool",
tool: value.toolName,
callID: value.id,
state: {
status: "pending",
},
})
toolCalls[value.id] = part as MessageV2.ToolPart
break
case "tool-call": {
const match = toolCalls[value.toolCallId]
if (match) {
const part = await updatePart({
...match,
state: {
status: "running",
input: value.input,
case "tool-input-delta":
break
case "tool-call": {
const match = toolCalls[value.toolCallId]
if (match) {
const part = await updatePart({
...match,
state: {
status: "running",
input: value.input,
time: {
start: Date.now(),
},
},
})
toolCalls[value.toolCallId] = part as MessageV2.ToolPart
}
break
}
case "tool-result": {
const match = toolCalls[value.toolCallId]
if (match && match.state.status === "running") {
await updatePart({
...match,
state: {
status: "completed",
input: value.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: {
start: match.state.time.start,
end: Date.now(),
},
},
})
delete toolCalls[value.toolCallId]
const snapshot = await Snapshot.create(assistantMsg.sessionID)
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "snapshot",
snapshot,
})
}
break
}
case "tool-error": {
const match = toolCalls[value.toolCallId]
if (match && match.state.status === "running") {
await updatePart({
...match,
state: {
status: "error",
input: value.input,
error: (value.error as any).toString(),
time: {
start: match.state.time.start,
end: Date.now(),
},
},
})
delete toolCalls[value.toolCallId]
const snapshot = await Snapshot.create(assistantMsg.sessionID)
if (snapshot)
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "snapshot",
snapshot,
})
}
break
}
case "error":
throw value.error
case "start-step":
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "step-start",
})
break
case "finish-step":
const usage = getUsage(model, value.usage, value.providerMetadata)
assistantMsg.cost += usage.cost
assistantMsg.tokens = usage.tokens
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "step-finish",
tokens: usage.tokens,
cost: usage.cost,
})
await updateMessage(assistantMsg)
break
case "text-start":
currentText = {
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "text",
text: "",
time: {
start: Date.now(),
},
},
})
toolCalls[value.toolCallId] = part as MessageV2.ToolPart
}
break
}
case "tool-result": {
const match = toolCalls[value.toolCallId]
if (match && match.state.status === "running") {
await updatePart({
...match,
state: {
status: "completed",
input: value.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
time: {
start: match.state.time.start,
}
break
case "text":
if (currentText) {
currentText.text += value.text
await updatePart(currentText)
}
break
case "text-end":
if (currentText && currentText.text) {
currentText.time = {
start: Date.now(),
end: Date.now(),
},
},
})
delete toolCalls[value.toolCallId]
}
await updatePart(currentText)
}
currentText = undefined
break
case "finish":
assistantMsg.time.completed = Date.now()
await updateMessage(assistantMsg)
break
default:
log.info("unhandled", {
...value,
})
continue
}
break
}
case "tool-error": {
const match = toolCalls[value.toolCallId]
if (match && match.state.status === "running") {
await updatePart({
...match,
state: {
status: "error",
input: value.input,
error: (value.error as any).toString(),
time: {
start: match.state.time.start,
end: Date.now(),
},
} catch (e) {
log.error("", {
error: e,
})
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
assistantMsg.error = new MessageV2.AbortedError(
{ message: e.message },
{
cause: e,
},
})
delete toolCalls[value.toolCallId]
}
break
).toObject()
break
case MessageV2.OutputLengthError.isInstance(e):
assistantMsg.error = e
break
case LoadAPIKeyError.isInstance(e):
assistantMsg.error = new MessageV2.AuthError(
{
providerID: model.id,
message: e.message,
},
{ cause: e },
).toObject()
break
case e instanceof Error:
assistantMsg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
break
default:
assistantMsg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
}
case "error":
throw value.error
case "start-step":
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "step-start",
})
break
case "finish-step":
const usage = getUsage(model, value.usage, value.providerMetadata)
assistantMsg.cost += usage.cost
assistantMsg.tokens = usage.tokens
await updatePart({
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "step-finish",
tokens: usage.tokens,
cost: usage.cost,
})
await updateMessage(assistantMsg)
break
case "text-start":
currentText = {
id: Identifier.ascending("part"),
messageID: assistantMsg.id,
sessionID: assistantMsg.sessionID,
type: "text",
text: "",
time: {
start: Date.now(),
},
}
break
case "text":
if (currentText) {
currentText.text += value.text
await updatePart(currentText)
}
break
case "text-end":
if (currentText && currentText.text) {
currentText.time = {
start: Date.now(),
end: Date.now(),
}
await updatePart(currentText)
}
currentText = undefined
break
case "finish":
assistantMsg.time.completed = Date.now()
await updateMessage(assistantMsg)
break
default:
log.info("unhandled", {
...value,
})
continue
Bus.publish(Event.Error, {
sessionID: assistantMsg.sessionID,
error: assistantMsg.error,
})
}
}
} catch (e) {
log.error("", {
error: e,
})
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
assistantMsg.error = new MessageV2.AbortedError(
{ message: e.message },
{
cause: e,
},
).toObject()
break
case MessageV2.OutputLengthError.isInstance(e):
assistantMsg.error = e
break
case LoadAPIKeyError.isInstance(e):
assistantMsg.error = new Provider.AuthError(
{
providerID: model.id,
message: e.message,
},
{ cause: e },
).toObject()
break
case e instanceof Error:
assistantMsg.error = new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
break
default:
assistantMsg.error = new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e })
}
Bus.publish(Event.Error, {
sessionID: assistantMsg.sessionID,
error: assistantMsg.error,
})
const p = await parts(assistantMsg.sessionID, assistantMsg.id)
for (const part of p) {
if (part.type === "tool" && part.state.status !== "completed") {
updatePart({
...part,
state: {
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
input: {},
},
})
}
}
assistantMsg.time.completed = Date.now()
await updateMessage(assistantMsg)
return { info: assistantMsg, parts: p }
},
}
const p = await parts(assistantMsg.sessionID, assistantMsg.id)
for (const part of p) {
if (part.type === "tool" && part.state.status !== "completed") {
updatePart({
...part,
state: {
status: "error",
error: "Tool execution aborted",
time: {
start: Date.now(),
end: Date.now(),
},
input: {},
},
})
}
}
assistantMsg.time.completed = Date.now()
await updateMessage(assistantMsg)
return { info: assistantMsg, parts: p }
}
export async function revert(_input: { sessionID: string; messageID: string; part: number }) {
@@ -980,6 +1010,7 @@ export namespace Session {
}
await updateMessage(next)
const processor = createProcessor(next, model.info)
const stream = streamText({
abortSignal: abort.signal,
model: model.language,
@@ -1003,7 +1034,7 @@ export namespace Session {
],
})
const result = await processStream(next, model.info, stream)
const result = await processor.process(stream)
return result
}
+14 -3
View File
@@ -1,6 +1,5 @@
import z from "zod"
import { Bus } from "../bus"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
import { Message } from "./message"
import { convertToModelMessages, type ModelMessage, type UIMessage } from "ai"
@@ -9,6 +8,13 @@ import { Identifier } from "../id/id"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({}))
export const AuthError = NamedError.create(
"ProviderAuthError",
z.object({
providerID: z.string(),
message: z.string(),
}),
)
export const ToolStatePending = z
.object({
@@ -79,6 +85,11 @@ export namespace MessageV2 {
messageID: z.string(),
})
export const SnapshotPart = PartBase.extend({
type: z.literal("snapshot"),
snapshot: z.string(),
})
export const TextPart = PartBase.extend({
type: z.literal("text"),
text: z.string(),
@@ -154,7 +165,7 @@ export namespace MessageV2 {
export type User = z.infer<typeof User>
export const Part = z
.discriminatedUnion("type", [TextPart, FilePart, ToolPart, StepStartPart, StepFinishPart])
.discriminatedUnion("type", [TextPart, FilePart, ToolPart, StepStartPart, StepFinishPart, SnapshotPart])
.openapi({
ref: "Part",
})
@@ -168,7 +179,7 @@ export namespace MessageV2 {
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
AbortedError.Schema,
+8 -6
View File
@@ -1,9 +1,15 @@
import z from "zod"
import { Provider } from "../provider/provider"
import { NamedError } from "../util/error"
export namespace Message {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AuthError = NamedError.create(
"ProviderAuthError",
z.object({
providerID: z.string(),
message: z.string(),
}),
)
export const ToolCall = z
.object({
@@ -134,11 +140,7 @@ export namespace Message {
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [
Provider.AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
])
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.optional(),
sessionID: z.string(),
tool: z.record(
+14 -11
View File
@@ -9,10 +9,8 @@ export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
return
log.info("creating snapshot")
const app = App.info()
const git = gitdir(sessionID)
// not a git repo, check if too big to snapshot
if (!app.git) {
@@ -24,6 +22,7 @@ export namespace Snapshot {
if (files.length > 1000) return
}
const git = gitdir(sessionID)
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
@@ -39,23 +38,27 @@ export namespace Snapshot {
await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow()
log.info("added files")
const result =
await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
log.info("commit")
const result = await $`git --git-dir ${git} commit -m "snapshot" --author="opencode <mail@opencode.ai>"`
.quiet()
.cwd(app.path.cwd)
.nothrow()
const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/)
if (!match) return
return match![1]
}
export async function restore(sessionID: string, commit: string) {
log.info("restore", { commit })
export async function restore(sessionID: string, snapshot: string) {
log.info("restore", { commit: snapshot })
const app = App.info()
const git = gitdir(sessionID)
await $`git --git-dir=${git} checkout ${commit} --force`.quiet().cwd(app.path.root)
await $`git --git-dir=${git} checkout ${snapshot} --force`.quiet().cwd(app.path.root)
}
export async function diff(sessionID: string, commit: string) {
const git = gitdir(sessionID)
const result = await $`git --git-dir=${git} diff -R ${commit}`.quiet().cwd(App.info().path.root)
return result.stdout.toString("utf8")
}
function gitdir(sessionID: string) {
+10 -9
View File
@@ -121,18 +121,19 @@ export namespace Storage {
}
const glob = new Bun.Glob("**/*")
export async function* list(prefix: string) {
export async function list(prefix: string) {
const dir = await state().then((x) => x.dir)
try {
for await (const item of glob.scan({
cwd: path.join(dir, prefix),
onlyFiles: true,
})) {
const result = path.join(prefix, item.slice(0, -5))
yield result
}
const result = await Array.fromAsync(
glob.scan({
cwd: path.join(dir, prefix),
onlyFiles: true,
}),
).then((items) => items.map((item) => path.join(prefix, item.slice(0, -5))))
result.sort()
return result
} catch {
return
return []
}
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ export const EditTool = Tool.define({
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from old_string)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of old_string (default false)"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
+3 -3
View File
@@ -2,8 +2,8 @@ Performs exact string replacements in files.
Usage:
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
- The edit will FAIL if `oldString` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
+7 -7
View File
@@ -8,9 +8,9 @@ Before using this tool:
To make multiple file edits, provide the following:
1. file_path: The absolute path to the file to modify (must be absolute, not relative)
2. edits: An array of edit operations to perform, where each edit contains:
- old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
- new_string: The edited text to replace the old_string
- replace_all: Replace all occurrences of old_string. This parameter is optional and defaults to false.
- oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation)
- newString: The edited text to replace the oldString
- replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false.
IMPORTANT:
- All edits are applied in sequence, in the order they are provided
@@ -24,8 +24,8 @@ CRITICAL REQUIREMENTS:
3. Plan your edits carefully to avoid conflicts between sequential operations
WARNING:
- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace)
- The tool will fail if edits.old_string and edits.new_string are the same
- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace)
- The tool will fail if edits.oldString and edits.newString are the same
- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find
When making edits:
@@ -33,9 +33,9 @@ When making edits:
- Do not leave the code in a broken state
- Always use absolute file paths (starting with /)
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
If you want to create a new file, use:
- A new file path, including dir name if needed
- First edit: empty old_string and the new file's contents as new_string
- First edit: empty oldString and the new file's contents as newString
- Subsequent edits: normal edit operations on the created content
+7 -14
View File
@@ -15,21 +15,15 @@ export const TaskTool = Tool.define({
}),
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = (await Session.getMessage(ctx.sessionID, ctx.messageID)) as MessageV2.Assistant
const parts: Record<string, MessageV2.Part> = {}
function summary(input: MessageV2.Part[]) {
const result = []
for (const part of input) {
if (part.type === "tool" && part.state.status === "completed") {
result.push(part)
}
}
return result
}
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
if (msg.role !== "assistant") throw new Error("Not an assistant message")
const messageID = Identifier.ascending("message")
const parts: Record<string, MessageV2.ToolPart> = {}
const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
if (evt.properties.part.sessionID !== session.id) return
if (evt.properties.part.messageID === messageID) return
if (evt.properties.part.type !== "tool") return
parts[evt.properties.part.id] = evt.properties.part
ctx.metadata({
title: params.description,
@@ -42,7 +36,6 @@ export const TaskTool = Tool.define({
ctx.abort.addEventListener("abort", () => {
Session.abort(session.id)
})
const messageID = Identifier.ascending("message")
const result = await Session.chat({
messageID,
sessionID: session.id,
@@ -62,7 +55,7 @@ export const TaskTool = Tool.define({
return {
title: params.description,
metadata: {
summary: summary(result.parts),
summary: result.parts.filter((x) => x.type === "tool"),
},
output: result.parts.findLast((x) => x.type === "text")!.text,
}
+115 -122
View File
@@ -305,10 +305,8 @@ func renderToolDetails(
return ""
}
if toolCall.State.Status == opencode.ToolPartStateStatusPending ||
toolCall.State.Status == opencode.ToolPartStateStatusRunning {
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
title := renderToolTitle(toolCall, width)
title = styles.NewStyle().Width(width - 6).Render(title)
return renderContentBlock(app, title, highlight, width)
}
@@ -339,128 +337,124 @@ func renderToolDetails(
borderColor = t.BorderActive()
}
if toolCall.State.Status == opencode.ToolPartStateStatusCompleted {
metadata := toolCall.State.Metadata.(map[string]any)
switch toolCall.Tool {
case "read":
preview := metadata["preview"]
if preview != nil && toolInputMap["filePath"] != nil {
filename := toolInputMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolInputMap["filePath"].(string); ok {
diffField := metadata["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Padding(1, 2).
Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
metadata := toolCall.State.Metadata.(map[string]any)
switch toolCall.Tool {
case "read":
preview := metadata["preview"]
if preview != nil && toolInputMap["filePath"] != nil {
filename := toolInputMap["filePath"].(string)
body = preview.(string)
body = util.RenderFile(filename, body, width, util.WithTruncate(6))
}
case "edit":
if filename, ok := toolInputMap["filePath"].(string); ok {
diffField := metadata["diff"]
if diffField != nil {
patch := diffField.(string)
var formattedDiff string
formattedDiff, _ = diff.FormatUnifiedDiff(
filename,
patch,
diff.WithWidth(width-2),
)
body = strings.TrimSpace(formattedDiff)
style := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Padding(1, 2).
Width(width - 4)
if highlight {
style = style.Foreground(t.Text()).Bold(true)
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
diagnostics = style.Render(diagnostics)
body += "\n" + diagnostics
}
title := renderToolTitle(toolCall, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
title := renderToolTitle(toolCall, width)
title = style.Render(title)
content := title + "\n" + body
content = renderContentBlock(
app,
content,
highlight,
width,
WithPadding(0),
WithBorderColor(borderColor),
)
return content
}
}
case "write":
if filename, ok := toolInputMap["filePath"].(string); ok {
if content, ok := toolInputMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
}
}
case "write":
if filename, ok := toolInputMap["filePath"].(string); ok {
if content, ok := toolInputMap["content"].(string); ok {
body = util.RenderFile(filename, content, width)
if diagnostics := renderDiagnostics(metadata, filename); diagnostics != "" {
body += "\n\n" + diagnostics
}
}
}
case "bash":
stdout := metadata["stdout"]
if stdout != nil {
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
}
case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
if format == "html" || format == "markdown" {
body = util.ToMarkdown(body, width, backgroundColor)
}
}
case "todowrite":
todos := metadata["todos"]
if todos != nil {
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"].(string)
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
// strike through cancelled todo
body += fmt.Sprintf("- [~] ~~%s~~\n", content)
case "in_progress":
// highlight in progress todo
body += fmt.Sprintf("- [ ] `%s`\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata["summary"]
if summary != nil {
toolcalls := summary.([]any)
steps := []string{}
for _, toolcall := range toolcalls {
call := toolcall.(map[string]any)
if toolInvocation, ok := call["toolInvocation"].(map[string]any); ok {
data, _ := json.Marshal(toolInvocation)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width)
step = "∟ " + step
steps = append(steps, step)
}
}
body = strings.Join(steps, "\n")
}
default:
if result == nil {
empty := ""
result = &empty
}
}
case "bash":
stdout := metadata["stdout"]
if stdout != nil {
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n> %s\n%s```", command, stdout)
body = util.ToMarkdown(body, width, backgroundColor)
}
case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil {
body = *result
body = util.TruncateHeight(body, 10)
body = styles.NewStyle().Width(width - 6).Render(body)
if format == "html" || format == "markdown" {
body = util.ToMarkdown(body, width, backgroundColor)
}
}
case "todowrite":
todos := metadata["todos"]
if todos != nil {
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"].(string)
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
case "cancelled":
// strike through cancelled todo
body += fmt.Sprintf("- [~] ~~%s~~\n", content)
case "in_progress":
// highlight in progress todo
body += fmt.Sprintf("- [ ] `%s`\n", content)
default:
body += fmt.Sprintf("- [ ] %s\n", content)
}
}
body = util.ToMarkdown(body, width, backgroundColor)
}
case "task":
summary := metadata["summary"]
if summary != nil {
toolcalls := summary.([]any)
steps := []string{}
for _, item := range toolcalls {
data, _ := json.Marshal(item)
var toolCall opencode.ToolPart
_ = json.Unmarshal(data, &toolCall)
step := renderToolTitle(toolCall, width)
step = "∟ " + step
steps = append(steps, step)
}
body = strings.Join(steps, "\n")
}
body = styles.NewStyle().Width(width - 6).Render(body)
default:
if result == nil {
empty := ""
result = &empty
}
body = *result
body = util.TruncateHeight(body, 10)
body = styles.NewStyle().Width(width - 6).Render(body)
}
error := ""
@@ -539,10 +533,9 @@ func renderToolTitle(
toolCall opencode.ToolPart,
width int,
) string {
// TODO: handle truncate to width
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
return renderToolAction(toolCall.Tool)
title := renderToolAction(toolCall.Tool)
return styles.NewStyle().Width(width - 6).Render(title)
}
toolArgs := ""
@@ -596,7 +589,7 @@ func renderToolTitle(
func renderToolAction(name string) string {
switch name {
case "task":
return "Searching..."
return "Planning..."
case "bash":
return "Writing command..."
case "edit":
+5
View File
@@ -114,6 +114,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
keyString := msg.String()
// Handle Ctrl+Z for suspend
if keyString == "ctrl+z" {
return a, tea.Suspend
}
// 1. Handle active modal
if a.modal != nil {
switch keyString {
+2 -2
View File
@@ -1,4 +1,4 @@
configured_endpoints: 22
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-9bdc593eab163d2165321716af6a4c13253792ad409420790ed6196da4178d3a.yml
openapi_spec_hash: c687f53ada739d315e2e7056df93d999
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-05150c78e0e6e97b0ce97ed685ebcf1cb01dc839beccb99e9d3ead5b783cfd47.yml
openapi_spec_hash: 833a5b6d53d98dc2beac2c4c394b20d5
config_hash: 3695cfc829cfaae14490850b4a1ed282
+66 -20
View File
@@ -605,6 +605,7 @@ type Part struct {
Cost float64 `json:"cost"`
Filename string `json:"filename"`
Mime string `json:"mime"`
Snapshot string `json:"snapshot"`
// This field can have the runtime type of [ToolPartState].
State interface{} `json:"state"`
Synthetic bool `json:"synthetic"`
@@ -629,6 +630,7 @@ type partJSON struct {
Cost apijson.Field
Filename apijson.Field
Mime apijson.Field
Snapshot apijson.Field
State apijson.Field
Synthetic apijson.Field
Text apijson.Field
@@ -657,13 +659,13 @@ func (r *Part) UnmarshalJSON(data []byte) (err error) {
// for more type safety.
//
// Possible runtime types of the union are [TextPart], [FilePart], [ToolPart],
// [StepStartPart], [StepFinishPart].
// [StepStartPart], [StepFinishPart], [PartObject].
func (r Part) AsUnion() PartUnion {
return r.union
}
// Union satisfied by [TextPart], [FilePart], [ToolPart], [StepStartPart] or
// [StepFinishPart].
// Union satisfied by [TextPart], [FilePart], [ToolPart], [StepStartPart],
// [StepFinishPart] or [PartObject].
type PartUnion interface {
implementsPart()
}
@@ -671,35 +673,78 @@ type PartUnion interface {
func init() {
apijson.RegisterUnion(
reflect.TypeOf((*PartUnion)(nil)).Elem(),
"type",
"",
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(TextPart{}),
DiscriminatorValue: "text",
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(TextPart{}),
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(FilePart{}),
DiscriminatorValue: "file",
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(FilePart{}),
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(ToolPart{}),
DiscriminatorValue: "tool",
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(ToolPart{}),
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(StepStartPart{}),
DiscriminatorValue: "step-start",
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(StepStartPart{}),
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(StepFinishPart{}),
DiscriminatorValue: "step-finish",
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(StepFinishPart{}),
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(PartObject{}),
},
)
}
type PartObject struct {
ID string `json:"id,required"`
MessageID string `json:"messageID,required"`
SessionID string `json:"sessionID,required"`
Snapshot string `json:"snapshot,required"`
Type PartObjectType `json:"type,required"`
JSON partObjectJSON `json:"-"`
}
// partObjectJSON contains the JSON metadata for the struct [PartObject]
type partObjectJSON struct {
ID apijson.Field
MessageID apijson.Field
SessionID apijson.Field
Snapshot apijson.Field
Type apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *PartObject) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r partObjectJSON) RawJSON() string {
return r.raw
}
func (r PartObject) implementsPart() {}
type PartObjectType string
const (
PartObjectTypeSnapshot PartObjectType = "snapshot"
)
func (r PartObjectType) IsKnown() bool {
switch r {
case PartObjectTypeSnapshot:
return true
}
return false
}
type PartType string
const (
@@ -708,11 +753,12 @@ const (
PartTypeTool PartType = "tool"
PartTypeStepStart PartType = "step-start"
PartTypeStepFinish PartType = "step-finish"
PartTypeSnapshot PartType = "snapshot"
)
func (r PartType) IsKnown() bool {
switch r {
case PartTypeText, PartTypeFile, PartTypeTool, PartTypeStepStart, PartTypeStepFinish:
case PartTypeText, PartTypeFile, PartTypeTool, PartTypeStepStart, PartTypeStepFinish, PartTypeSnapshot:
return true
}
return false
+1 -1
View File
@@ -66,7 +66,7 @@ Logs you into a provider and saves them in the credentials file in `~/.local/sha
opencode auth login
```
When opencode starts up it will loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project.
When opencode starts up it loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project.
---
+9
View File
@@ -0,0 +1,9 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}