Repository navigation
feat(core): showcase worktree tools and permission dependencies #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ivanmilevtues
wants to merge
1
commit into
dev
Choose a base branch
from
worktree-tools-demo
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,299 @@ | ||
| export * as OpenCodeTools from "./opencode.js" | ||
|
|
||
| import { SystemPart, ToolFailure } from "@opencode/ai" | ||
| import type { Context } from "@opencode/plugin/effect/plugin" | ||
| import type { SessionHooks } from "@opencode/plugin/effect/session" | ||
| import { Model } from "@opencode/schema/model" | ||
| import { AbsolutePath } from "@opencode/schema/schema" | ||
| import { Session } from "@opencode/schema/session" | ||
| import { Effect, Schema } from "effect" | ||
| import { FileAccess } from "../../file-access.js" | ||
| import { Git } from "../../git.js" | ||
| import { Permission } from "../../permission.js" | ||
| import { Worktree } from "../../worktree.js" | ||
|
|
||
| export const RenameInput = Schema.Struct({ | ||
| sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to rename the current session." }), | ||
| title: Schema.String.check(Schema.isMinLength(1)).annotate({ description: "New session title." }), | ||
| }) | ||
|
|
||
| const RenameOutput = Schema.Struct({ sessionID: Session.ID, title: Schema.String }) | ||
|
|
||
| export const MoveInput = Schema.Struct({ | ||
| sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to move the current session." }), | ||
| directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({ | ||
| description: "Destination directory, relative to the target session's directory or absolute. Supports ~.", | ||
| }), | ||
| }) | ||
|
|
||
| const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath }) | ||
|
|
||
| export const WorktreeCreateInput = Schema.Struct({ | ||
| name: Schema.Trim.check(Schema.isNonEmpty()).annotate({ description: "Worktree name." }), | ||
| }) | ||
|
|
||
| export const WorktreeRemoveInput = Schema.Struct({ | ||
| directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({ | ||
| description: "Absolute path of the worktree to remove.", | ||
| }), | ||
| force: Schema.optionalKey(Schema.Boolean).annotate({ | ||
| description: "Discard modified and untracked files. Only set this when the user has confirmed forced removal.", | ||
| }), | ||
| }) | ||
|
|
||
| const WorktreeRemoveOutput = Schema.Struct({ directory: AbsolutePath, removed: Schema.Boolean }) | ||
|
|
||
| export const ModelsInput = Schema.Struct({ | ||
| query: Schema.optionalKey(Schema.String).annotate({ | ||
| description: "Text to search for in model names and IDs.", | ||
| }), | ||
| provider: Schema.optionalKey(Schema.String).annotate({ | ||
| description: "Provider ID or name to filter by. Try your own provider first.", | ||
| }), | ||
| all: Schema.optionalKey(Schema.Boolean).annotate({ | ||
| description: "Include older versions of each model family. By default only the newest version is listed.", | ||
| }), | ||
| limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))).annotate({ | ||
| description: "Maximum number of models to return. Defaults to 20.", | ||
| }), | ||
| offset: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))).annotate({ | ||
| description: "Number of models to skip, for paging through results.", | ||
| }), | ||
| }) | ||
|
|
||
| const ModelEntry = Schema.Struct({ | ||
| id: Schema.String.annotate({ description: "providerID/modelID" }), | ||
| name: Schema.String, | ||
| released: Model.Info.fields.time.fields.released.annotate({ | ||
| description: "Release date as a Unix timestamp in milliseconds, or 0 when unknown.", | ||
| }), | ||
| variants: Schema.Array(Model.VariantID), | ||
| cost: Model.Info.fields.cost.annotate({ description: "Pricing in USD per million tokens." }), | ||
| status: Model.Info.fields.status, | ||
| }) | ||
|
|
||
| const ModelsOutput = Schema.Struct({ | ||
| providers: Schema.Array( | ||
| Schema.Struct({ | ||
| id: Schema.String, | ||
| name: Schema.String, | ||
| models: Schema.Array(ModelEntry).annotate({ description: "Newest first." }), | ||
| }), | ||
| ).annotate({ description: "Matching models grouped by provider. Your own provider comes first." }), | ||
| total: Schema.Int.annotate({ description: "Number of matching models across all pages." }), | ||
| next: Schema.NullOr(Schema.Int).annotate({ description: "Offset of the next page, or null on the last page." }), | ||
| }) | ||
|
|
||
| export const Plugin = { | ||
| id: "opencode.tools", | ||
| effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) { | ||
| const access = yield* FileAccess.Service | ||
| const permission = yield* Permission.Service | ||
| const hook = (event: SessionHooks["context"]) => | ||
| Effect.sync(() => { | ||
| event.system.push( | ||
| SystemPart.make( | ||
| "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.", | ||
| ), | ||
| ) | ||
| }) | ||
| yield* ctx.session.hook("context", hook) | ||
| yield* ctx.session.hook("compaction", hook) | ||
| yield* ctx.session.hook("generate", hook) | ||
| yield* ctx.tool | ||
| .transform((draft) => { | ||
| draft.namespace({ | ||
| name: "opencode", | ||
| description: | ||
| "Tools for managing OpenCode itself, such as working with sessions and worktrees, searching the available models, and reading MCP resources.", | ||
| }) | ||
| draft.add({ | ||
| name: "session_rename", | ||
| description: | ||
| "Rename a session, or omit sessionID to rename the current session. Use a short, specific title that summarizes the work being done.", | ||
| input: RenameInput, | ||
| output: RenameOutput, | ||
| options: { namespace: "opencode", codemode: true }, | ||
| execute: (input, context) => { | ||
| const sessionID = input.sessionID ?? context.sessionID | ||
| const title = input.title.trim() | ||
| if (!title) return Effect.fail(new ToolFailure({ message: "Session title must not be empty" })) | ||
| return ctx.session.update({ sessionID, title }).pipe( | ||
| Effect.as({ | ||
| output: { sessionID, title }, | ||
| content: `Renamed session ${sessionID} to ${title}.`, | ||
| }), | ||
| Effect.mapError((error) => new ToolFailure({ message: `Unable to rename session ${sessionID}`, error })), | ||
| ) | ||
| }, | ||
| }) | ||
| draft.add({ | ||
| name: "session_move", | ||
| description: | ||
| "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.", | ||
| input: MoveInput, | ||
| output: MoveOutput, | ||
| options: { namespace: "opencode", codemode: true, pinned: true }, | ||
| execute: (input, context) => | ||
| Effect.gen(function* () { | ||
| const sessionID = input.sessionID ?? context.sessionID | ||
| yield* ctx.session.move({ | ||
| sessionID, | ||
| directory: input.directory, | ||
| delivery: "steer", | ||
| }) | ||
| return { | ||
| output: { sessionID, directory: input.directory }, | ||
| content: `Moved session ${sessionID} to ${input.directory}.`, | ||
| } | ||
| }).pipe( | ||
| Effect.mapError( | ||
| (error) => new ToolFailure({ message: `Unable to move session to ${input.directory}`, error }), | ||
| ), | ||
| ), | ||
| }) | ||
| draft.add({ | ||
| name: "worktree_create", | ||
| description: "Create a named worktree. Returns its directory.", | ||
| input: WorktreeCreateInput, | ||
| output: Worktree.Info, | ||
| options: { namespace: "opencode", codemode: true, pinned: true }, | ||
| execute: (input) => | ||
| ctx.worktree.create({ projectID: ctx.location.project.id, name: input.name }).pipe( | ||
| Effect.map((output) => ({ output, content: `Created worktree in ${output.directory}.` })), | ||
| Effect.mapError( | ||
| (error) => new ToolFailure({ message: `Unable to create worktree ${input.name}`, error }), | ||
| ), | ||
| ), | ||
| }) | ||
| draft.add({ | ||
| name: "worktree_list", | ||
| description: "List a repository's worktrees.", | ||
| input: Schema.Struct({}), | ||
| output: Schema.Struct({ worktrees: Worktree.List }), | ||
| options: { namespace: "opencode", codemode: true, pinned: true }, | ||
| execute: () => | ||
| ctx.worktree.list({ projectID: ctx.location.project.id }).pipe( | ||
| Effect.map((worktrees) => ({ output: { worktrees } })), | ||
| Effect.mapError((error) => new ToolFailure({ message: "Unable to list worktrees", error })), | ||
| ), | ||
| }) | ||
| draft.add({ | ||
| name: "worktree_remove", | ||
| description: | ||
| "Remove a worktree. The repository location selects configuration; directory identifies the worktree to remove.", | ||
| input: WorktreeRemoveInput, | ||
| output: WorktreeRemoveOutput, | ||
| options: { namespace: "opencode", codemode: true, pinned: true, permission: "edit" }, | ||
| execute: (input, context) => | ||
| Effect.gen(function* () { | ||
| // Removing a worktree deletes files, so it requires the same approval as editing them. | ||
| const target = yield* access.resolve({ path: input.directory, kind: "directory" }) | ||
| yield* permission.assert({ | ||
| action: "edit", | ||
| resources: [target.resource], | ||
| save: ["*"], | ||
| sessionID: context.sessionID, | ||
| agent: context.agent, | ||
| source: { type: "tool", messageID: context.messageID, id: context.id }, | ||
| }) | ||
| yield* ctx.worktree | ||
| .remove({ | ||
| projectID: ctx.location.project.id, | ||
| directory: target.absolute, | ||
| force: input.force ?? false, | ||
| }) | ||
| .pipe(Effect.mapError((error) => new ToolFailure({ message: removeFailure(target.absolute, error) }))) | ||
| return { | ||
| output: { directory: target.absolute, removed: true }, | ||
| content: `Removed worktree ${target.absolute}.`, | ||
| } | ||
| }).pipe( | ||
| Effect.mapError((error) => | ||
| error instanceof ToolFailure | ||
| ? error | ||
| : new ToolFailure({ message: `Unable to remove worktree ${input.directory}`, error }), | ||
| ), | ||
| ), | ||
| }) | ||
| draft.add({ | ||
| name: "models", | ||
| description: | ||
| "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.", | ||
| input: ModelsInput, | ||
| output: ModelsOutput, | ||
| options: { namespace: "opencode", codemode: true }, | ||
| execute: (input, context) => | ||
| Effect.gen(function* () { | ||
| const offset = input.offset ?? 0 | ||
| const limit = input.limit ?? 20 | ||
| const own = (yield* ctx.session.get({ sessionID: context.sessionID })).model?.providerID | ||
| const terms = input.query?.toLowerCase().split(/\s+/).filter(Boolean) ?? [] | ||
| const names = new Map((yield* ctx.provider.list()).data.map((provider) => [provider.id, provider.name])) | ||
| const provider = input.provider?.toLowerCase() | ||
| const matching = (yield* ctx.model.list()).data | ||
| .filter( | ||
| (model) => | ||
| provider === undefined || | ||
| model.providerID.toLowerCase() === provider || | ||
| names.get(model.providerID)?.toLowerCase() === provider, | ||
| ) | ||
| .filter((model) => { | ||
| const text = `${model.providerID}/${model.id} ${model.name}`.toLowerCase() | ||
| return terms.every((term) => text.includes(term)) | ||
| }) | ||
| .toSorted( | ||
| (left, right) => | ||
| Number(right.providerID === own) - Number(left.providerID === own) || | ||
| left.providerID.localeCompare(right.providerID) || | ||
| right.time.released - left.time.released, | ||
| ) | ||
| .filter((model, index, sorted) => { | ||
| if (input.all || model.family === undefined) return true | ||
| return ( | ||
| sorted.findIndex( | ||
| (other) => other.providerID === model.providerID && other.family === model.family, | ||
| ) === index | ||
| ) | ||
| }) | ||
| const page = matching.slice(offset, offset + limit) | ||
| const providers = Array.from(new Set(page.map((model) => model.providerID))).map((id) => ({ | ||
| id, | ||
| name: names.get(id) ?? id, | ||
| models: page | ||
| .filter((model) => model.providerID === id) | ||
| .map((model) => ({ | ||
| id: `${model.providerID}/${model.id}`, | ||
| name: model.name, | ||
| released: model.time.released, | ||
| variants: model.variants.map((variant) => variant.id), | ||
| cost: model.cost, | ||
| status: model.status, | ||
| })), | ||
| })) | ||
| return { | ||
| output: { | ||
| providers, | ||
| total: matching.length, | ||
| next: offset + limit < matching.length ? offset + limit : null, | ||
| }, | ||
| } | ||
| }).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error }))), | ||
| }) | ||
| }) | ||
| .pipe(Effect.orDie) | ||
| }), | ||
| } | ||
|
|
||
| // Worktree errors carry no model-facing message, and Git's own text suggests forcing removal. | ||
| function removeFailure(directory: string, error: unknown) { | ||
| if (error instanceof Worktree.DirectoryUnavailableError) | ||
| return `Unable to remove worktree ${directory}: the directory does not exist or is not a directory.` | ||
| if (error instanceof Worktree.InvalidDirectoryError) | ||
| return `Unable to remove ${directory}: it is not a worktree of this project. Use worktree_list to see the project's worktrees.` | ||
| if (error instanceof Worktree.StrategyUnavailableError) | ||
| return `Unable to remove worktree ${directory}: its worktree strategy ${error.strategy} is not available.` | ||
| if ((error instanceof Git.WorktreeError || error instanceof Worktree.OperationError) && error.forceRequired) | ||
| return `Unable to remove worktree ${directory}: it has modified or untracked files.` | ||
| return `Unable to remove worktree ${directory}.` | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
Pluginis never imported or installed by any production bootstrap, sosession_rename,session_move, the worktree operations, andmodelswill not appear for users despite the new documentation. A repo-wide search at this commit finds only this declaration and tests, and those tests mask the omission by invokingOpenCodeTools.Plugin.effect(...)directly; register these built-ins through the production tool registration path.AGENTS.md reference: packages/core/src/tool/AGENTS.md:L32-L32
Useful? React with 👍 / 👎.