Skip to content

Commit bbc7228

Browse files
feat(core): showcase built-in worktree tools
Copy the four changed-file snapshots from anomalyco/opencode PR anomalyco#53981 for an architecture-diff demo against this fork's dev. This is not a runnable backport: the newer upstream tool plugin and worktree APIs are absent here. Upstream source: anomalyco#53981 Original author: James Long Amp-Thread-ID: https://ampcode.com/threads/T-01a13d0d-fbe1-76fa-8fad-21c3940a2399 Co-authored-by: Amp <amp@ampcode.com>
1 parent d340117 commit bbc7228

4 files changed

Lines changed: 956 additions & 42 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
export * as OpenCodeTools from "./opencode.js"
2+
3+
import { SystemPart, ToolFailure } from "@opencode/ai"
4+
import type { Context } from "@opencode/plugin/effect/plugin"
5+
import type { SessionHooks } from "@opencode/plugin/effect/session"
6+
import { Model } from "@opencode/schema/model"
7+
import { AbsolutePath } from "@opencode/schema/schema"
8+
import { Session } from "@opencode/schema/session"
9+
import { Effect, Schema } from "effect"
10+
import { FileAccess } from "../../file-access.js"
11+
import { Git } from "../../git.js"
12+
import { Permission } from "../../permission.js"
13+
import { Worktree } from "../../worktree.js"
14+
15+
export const RenameInput = Schema.Struct({
16+
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to rename the current session." }),
17+
title: Schema.String.check(Schema.isMinLength(1)).annotate({ description: "New session title." }),
18+
})
19+
20+
const RenameOutput = Schema.Struct({ sessionID: Session.ID, title: Schema.String })
21+
22+
export const MoveInput = Schema.Struct({
23+
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to move the current session." }),
24+
directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({
25+
description: "Destination directory, relative to the target session's directory or absolute. Supports ~.",
26+
}),
27+
})
28+
29+
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
30+
31+
export const WorktreeCreateInput = Schema.Struct({
32+
name: Schema.Trim.check(Schema.isNonEmpty()).annotate({ description: "Worktree name." }),
33+
})
34+
35+
export const WorktreeRemoveInput = Schema.Struct({
36+
directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({
37+
description: "Absolute path of the worktree to remove.",
38+
}),
39+
force: Schema.optionalKey(Schema.Boolean).annotate({
40+
description: "Discard modified and untracked files. Only set this when the user has confirmed forced removal.",
41+
}),
42+
})
43+
44+
const WorktreeRemoveOutput = Schema.Struct({ directory: AbsolutePath, removed: Schema.Boolean })
45+
46+
export const ModelsInput = Schema.Struct({
47+
query: Schema.optionalKey(Schema.String).annotate({
48+
description: "Text to search for in model names and IDs.",
49+
}),
50+
provider: Schema.optionalKey(Schema.String).annotate({
51+
description: "Provider ID or name to filter by. Try your own provider first.",
52+
}),
53+
all: Schema.optionalKey(Schema.Boolean).annotate({
54+
description: "Include older versions of each model family. By default only the newest version is listed.",
55+
}),
56+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({
57+
description: "Maximum number of models to return. Defaults to 20.",
58+
}),
59+
offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({
60+
description: "Number of models to skip, for paging through results.",
61+
}),
62+
})
63+
64+
const ModelEntry = Schema.Struct({
65+
id: Schema.String.annotate({ description: "providerID/modelID" }),
66+
name: Schema.String,
67+
released: Model.Info.fields.time.fields.released.annotate({
68+
description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.",
69+
}),
70+
variants: Schema.Array(Model.VariantID),
71+
cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }),
72+
status: Model.Info.fields.status,
73+
})
74+
75+
const ModelsOutput = Schema.Struct({
76+
providers: Schema.Array(
77+
Schema.Struct({
78+
id: Schema.String,
79+
name: Schema.String,
80+
models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }),
81+
}),
82+
).annotate({ description: "Matching models grouped by provider. Your own provider comes first." }),
83+
total: Schema.Int.annotate({ description: "Number of matching models across all pages." }),
84+
next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }),
85+
})
86+
87+
export const Plugin = {
88+
id: "opencode.tools",
89+
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
90+
const access = yield* FileAccess.Service
91+
const permission = yield* Permission.Service
92+
const hook = (event: SessionHooks["context"]) =>
93+
Effect.sync(() => {
94+
event.system.push(
95+
SystemPart.make(
96+
"When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.",
97+
),
98+
)
99+
})
100+
yield* ctx.session.hook("context", hook)
101+
yield* ctx.session.hook("compaction", hook)
102+
yield* ctx.session.hook("generate", hook)
103+
yield* ctx.tool
104+
.transform((draft) => {
105+
draft.namespace({
106+
name: "opencode",
107+
description:
108+
"Tools for managing OpenCode itself, such as working with sessions and worktrees, searching the available models, and reading MCP resources.",
109+
})
110+
draft.add({
111+
name: "session_rename",
112+
description:
113+
"Rename a session, or omit sessionID to rename the current session. Use a short, specific title that summarizes the work being done.",
114+
input: RenameInput,
115+
output: RenameOutput,
116+
options: { namespace: "opencode", codemode: true },
117+
execute: (input, context) => {
118+
const sessionID = input.sessionID ?? context.sessionID
119+
const title = input.title.trim()
120+
if (!title) return Effect.fail(new ToolFailure({ message: "Session title must not be empty" }))
121+
return ctx.session.update({ sessionID, title }).pipe(
122+
Effect.as({
123+
output: { sessionID, title },
124+
content: `Renamed session ${sessionID} to ${title}.`,
125+
}),
126+
Effect.mapError((error) => new ToolFailure({ message: `Unable to rename session ${sessionID}`, error })),
127+
)
128+
},
129+
})
130+
draft.add({
131+
name: "session_move",
132+
description:
133+
"Move a session to another directory, or omit sessionID to move the current session. The current session moves at the next safe boundary; do not run destination-dependent tools in the same execute call.",
134+
input: MoveInput,
135+
output: MoveOutput,
136+
options: { namespace: "opencode", codemode: true, pinned: true },
137+
execute: (input, context) =>
138+
Effect.gen(function* () {
139+
const sessionID = input.sessionID ?? context.sessionID
140+
yield* ctx.session.move({
141+
sessionID,
142+
directory: input.directory,
143+
delivery: "steer",
144+
})
145+
return {
146+
output: { sessionID, directory: input.directory },
147+
content: `Moved session ${sessionID} to ${input.directory}.`,
148+
}
149+
}).pipe(
150+
Effect.mapError(
151+
(error) => new ToolFailure({ message: `Unable to move session to ${input.directory}`, error }),
152+
),
153+
),
154+
})
155+
draft.add({
156+
name: "worktree_create",
157+
description: "Create a named worktree. Returns its directory.",
158+
input: WorktreeCreateInput,
159+
output: Worktree.Info,
160+
options: { namespace: "opencode", codemode: true, pinned: true },
161+
execute: (input) =>
162+
ctx.worktree.create({ projectID: ctx.location.project.id, name: input.name }).pipe(
163+
Effect.map((output) => ({ output, content: `Created worktree in ${output.directory}.` })),
164+
Effect.mapError(
165+
(error) => new ToolFailure({ message: `Unable to create worktree ${input.name}`, error }),
166+
),
167+
),
168+
})
169+
draft.add({
170+
name: "worktree_list",
171+
description: "List a repository's worktrees.",
172+
input: Schema.Struct({}),
173+
output: Schema.Struct({ worktrees: Worktree.List }),
174+
options: { namespace: "opencode", codemode: true, pinned: true },
175+
execute: () =>
176+
ctx.worktree.list({ projectID: ctx.location.project.id }).pipe(
177+
Effect.map((worktrees) => ({ output: { worktrees } })),
178+
Effect.mapError((error) => new ToolFailure({ message: "Unable to list worktrees", error })),
179+
),
180+
})
181+
draft.add({
182+
name: "worktree_remove",
183+
description:
184+
"Remove a worktree. The repository location selects configuration; directory identifies the worktree to remove.",
185+
input: WorktreeRemoveInput,
186+
output: WorktreeRemoveOutput,
187+
options: { namespace: "opencode", codemode: true, pinned: true, permission: "edit" },
188+
execute: (input, context) =>
189+
Effect.gen(function* () {
190+
// Removing a worktree deletes files, so it requires the same approval as editing them.
191+
const target = yield* access.resolve({ path: input.directory, kind: "directory" })
192+
yield* permission.assert({
193+
action: "edit",
194+
resources: [target.resource],
195+
save: ["*"],
196+
sessionID: context.sessionID,
197+
agent: context.agent,
198+
source: { type: "tool", messageID: context.messageID, id: context.id },
199+
})
200+
yield* ctx.worktree
201+
.remove({
202+
projectID: ctx.location.project.id,
203+
directory: target.absolute,
204+
force: input.force ?? false,
205+
})
206+
.pipe(Effect.mapError((error) => new ToolFailure({ message: removeFailure(target.absolute, error) })))
207+
return {
208+
output: { directory: target.absolute, removed: true },
209+
content: `Removed worktree ${target.absolute}.`,
210+
}
211+
}).pipe(
212+
Effect.mapError((error) =>
213+
error instanceof ToolFailure
214+
? error
215+
: new ToolFailure({ message: `Unable to remove worktree ${input.directory}`, error }),
216+
),
217+
),
218+
})
219+
draft.add({
220+
name: "models",
221+
description:
222+
"Search the models available to use. Use this to turn a model name the user mentions into an exact reference before running a subagent on it. Check your own provider first.",
223+
input: ModelsInput,
224+
output: ModelsOutput,
225+
options: { namespace: "opencode", codemode: true },
226+
execute: (input, context) =>
227+
Effect.gen(function* () {
228+
const offset = input.offset ?? 0
229+
const limit = input.limit ?? 20
230+
const own = (yield* ctx.session.get({ sessionID: context.sessionID })).model?.providerID
231+
const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? []
232+
const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name]))
233+
const provider = input.provider?.toLowerCase()
234+
const matching = (yield* ctx.model.list()).data
235+
.filter(
236+
(model) =>
237+
provider === undefined ||
238+
model.providerID.toLowerCase() === provider ||
239+
names.get(model.providerID)?.toLowerCase() === provider,
240+
)
241+
.filter((model) => {
242+
const text = `${model.providerID}/${model.id} ${model.name}`.toLowerCase()
243+
return terms.every((term) => text.includes(term))
244+
})
245+
.toSorted(
246+
(left, right) =>
247+
Number(right.providerID === own) - Number(left.providerID === own) ||
248+
left.providerID.localeCompare(right.providerID) ||
249+
right.time.released - left.time.released,
250+
)
251+
.filter((model, index, sorted) => {
252+
if (input.all || model.family === undefined) return true
253+
return (
254+
sorted.findIndex(
255+
(other) => other.providerID === model.providerID && other.family === model.family,
256+
) === index
257+
)
258+
})
259+
const page = matching.slice(offset, offset + limit)
260+
const providers = Array.from(new Set(page.map((model) => model.providerID))).map((id) => ({
261+
id,
262+
name: names.get(id) ?? id,
263+
models: page
264+
.filter((model) => model.providerID === id)
265+
.map((model) => ({
266+
id: `${model.providerID}/${model.id}`,
267+
name: model.name,
268+
released: model.time.released,
269+
variants: model.variants.map((variant) => variant.id),
270+
cost: model.cost,
271+
status: model.status,
272+
})),
273+
}))
274+
return {
275+
output: {
276+
providers,
277+
total: matching.length,
278+
next: offset + limit < matching.length ? offset + limit : null,
279+
},
280+
}
281+
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error }))),
282+
})
283+
})
284+
.pipe(Effect.orDie)
285+
}),
286+
}
287+
288+
// Worktree errors carry no model-facing message, and Git's own text suggests forcing removal.
289+
function removeFailure(directory: string, error: unknown) {
290+
if (error instanceof Worktree.DirectoryUnavailableError)
291+
return `Unable to remove worktree ${directory}: the directory does not exist or is not a directory.`
292+
if (error instanceof Worktree.InvalidDirectoryError)
293+
return `Unable to remove ${directory}: it is not a worktree of this project. Use worktree_list to see the project's worktrees.`
294+
if (error instanceof Worktree.StrategyUnavailableError)
295+
return `Unable to remove worktree ${directory}: its worktree strategy ${error.strategy} is not available.`
296+
if ((error instanceof Git.WorktreeError || error instanceof Worktree.OperationError) && error.forceRequired)
297+
return `Unable to remove worktree ${directory}: it has modified or untracked files.`
298+
return `Unable to remove worktree ${directory}.`
299+
}

0 commit comments

Comments
 (0)