Skip to content

Commit 96a6256

Browse files
committed
feat: add scout references
1 parent 365386f commit 96a6256

7 files changed

Lines changed: 287 additions & 18 deletions

File tree

packages/opencode/src/agent/agent.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"
2626
import { zod } from "@/util/effect-zod"
2727
import { withStatics, type DeepMutable } from "@/util/schema"
2828

29+
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
30+
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
31+
2932
export const Info = Schema.Struct({
3033
name: Schema.String,
3134
description: Schema.optional(Schema.String),
@@ -82,7 +85,11 @@ export const layer = Layer.effect(
8285
Effect.fn("Agent.state")(function* (ctx) {
8386
const cfg = yield* config.get()
8487
const skillDirs = yield* skill.dirs()
85-
const whitelistedDirs = [Truncate.GLOB, path.join(Global.Path.tmp, "*"), ...skillDirs.map((dir) => path.join(dir, "*"))]
88+
const whitelistedDirs = [
89+
Truncate.GLOB,
90+
path.join(Global.Path.tmp, "*"),
91+
...skillDirs.map((dir) => path.join(dir, "*")),
92+
]
8693
const readonlyExternalDirectory = {
8794
"*": "ask",
8895
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
@@ -291,6 +298,73 @@ export const layer = Layer.effect(
291298
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
292299
}
293300

301+
function referencePath(value: string) {
302+
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
303+
return path.isAbsolute(value)
304+
? value
305+
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
306+
}
307+
308+
function resolveReference(reference: ReferenceEntry): ResolvedReference {
309+
if (typeof reference === "string") {
310+
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
311+
return { kind: "local", path: referencePath(reference) }
312+
}
313+
return { kind: "git", repository: reference }
314+
}
315+
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
316+
return { kind: "git", repository: reference.repository, branch: reference.branch }
317+
}
318+
319+
function referencePrompt(name: string, reference: ResolvedReference) {
320+
if (reference.kind === "local") {
321+
return [
322+
PROMPT_SCOUT,
323+
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
324+
`Local directory: ${reference.path}`,
325+
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
326+
].join("\n\n")
327+
}
328+
329+
return [
330+
PROMPT_SCOUT,
331+
`You are Scout reference @${name}. This reference points to a git repository.`,
332+
`Repository: ${reference.repository}`,
333+
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
334+
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
335+
].join("\n\n")
336+
}
337+
338+
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
339+
if (agents[name]) continue
340+
const resolved = resolveReference(reference)
341+
const localPath = resolved.kind === "local" ? resolved.path : undefined
342+
agents[name] = {
343+
name,
344+
description:
345+
resolved.kind === "local"
346+
? `Scout reference for local directory ${resolved.path}`
347+
: `Scout reference for repository ${resolved.repository}`,
348+
permission: Permission.merge(
349+
agents.scout.permission,
350+
Permission.fromConfig(
351+
localPath
352+
? {
353+
external_directory: {
354+
[localPath]: "allow",
355+
[path.join(localPath, "*")]: "allow",
356+
},
357+
}
358+
: {},
359+
),
360+
),
361+
prompt: referencePrompt(name, resolved),
362+
options: { reference },
363+
mode: "subagent",
364+
native: false,
365+
}
366+
}
367+
294368
// Ensure Truncate.GLOB is allowed unless explicitly configured
295369
for (const name in agents) {
296370
const agent = agents[name]

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { ConfigPaths } from "./paths"
3939
import { ConfigPermission } from "./permission"
4040
import { ConfigPlugin } from "./plugin"
4141
import { ConfigProvider } from "./provider"
42+
import { ConfigReference } from "./reference"
4243
import { ConfigServer } from "./server"
4344
import { ConfigSkills } from "./skills"
4445
import { ConfigVariable } from "./variable"
@@ -114,6 +115,9 @@ export const Info = Schema.Struct({
114115
description: "Command configuration, see https://opencode.ai/docs/commands",
115116
}),
116117
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
118+
reference: Schema.optional(ConfigReference.Info).annotate({
119+
description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents",
120+
}),
117121
watcher: Schema.optional(
118122
Schema.Struct({
119123
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export * as ConfigReference from "./reference"
2+
3+
import { Schema } from "effect"
4+
import { zod } from "@/util/effect-zod"
5+
import { withStatics } from "@/util/schema"
6+
7+
const Git = Schema.Struct({
8+
repository: Schema.String.annotate({
9+
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
10+
}),
11+
branch: Schema.optional(Schema.String).annotate({
12+
description: "Branch or ref Scout should clone and inspect",
13+
}),
14+
})
15+
16+
const Local = Schema.Struct({
17+
path: Schema.String.annotate({
18+
description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory",
19+
}),
20+
})
21+
22+
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
23+
24+
export const Info = Schema.Record(Schema.String, Entry)
25+
.annotate({ identifier: "ReferenceConfig" })
26+
.pipe(withStatics((s) => ({ zod: zod(s) })))
27+
export type Info = Schema.Schema.Type<typeof Info>

packages/opencode/src/tool/repo_clone.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export const Parameters = Schema.Struct({
1414
refresh: Schema.optional(Schema.Boolean).annotate({
1515
description: "When true, fetches the latest remote state into the managed cache",
1616
}),
17+
branch: Schema.optional(Schema.String).annotate({
18+
description: "Branch or ref to clone and inspect",
19+
}),
1720
})
1821

1922
type Metadata = {
@@ -26,16 +29,19 @@ type Metadata = {
2629
branch?: string
2730
}
2831

29-
function statusForRepository(input: { reuse: boolean; refresh?: boolean }) {
32+
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
3033
if (!input.reuse) return "cloned" as const
34+
if (input.branchMatches === false) return "refreshed" as const
3135
if (input.refresh) return "refreshed" as const
3236
return "cached" as const
3337
}
3438

3539
function resetTarget(input: {
40+
requestedBranch?: string
3641
remoteHead: { code: number; stdout: string }
3742
branch: { code: number; stdout: string }
3843
}) {
44+
if (input.requestedBranch) return `origin/${input.requestedBranch}`
3945
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
4046
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
4147
}
@@ -45,6 +51,14 @@ function resetTarget(input: {
4551
return "HEAD"
4652
}
4753

54+
function validateBranch(branch: string) {
55+
if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) {
56+
throw new Error(
57+
"Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
58+
)
59+
}
60+
}
61+
4862
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
4963
"repo_clone",
5064
Effect.gen(function* () {
@@ -57,7 +71,9 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
5771
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
5872
Effect.gen(function* () {
5973
const reference = parseRepositoryReference(params.repository)
60-
if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
74+
if (!reference)
75+
throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
76+
if (params.branch) validateBranch(params.branch)
6177

6278
const repository = reference.label
6379
const remote = reference.remote
@@ -73,6 +89,7 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
7389
remote,
7490
path: localPath,
7591
refresh: Boolean(params.refresh),
92+
branch: params.branch,
7693
},
7794
})
7895

@@ -87,37 +104,74 @@ export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, AppFileSys
87104
const origin = hasGitDir
88105
? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
89106
: undefined
90-
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
91-
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
107+
const originReference =
108+
origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
109+
const reuse =
110+
hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
92111
if (exists && !reuse) {
93112
yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
94113
}
95114

96-
const status = statusForRepository({ reuse, refresh: params.refresh })
115+
const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined
116+
const status = statusForRepository({
117+
reuse,
118+
refresh: params.refresh,
119+
branchMatches: params.branch ? currentBranch === params.branch : undefined,
120+
})
97121

98122
if (status === "cloned") {
99-
const clone = yield* git.run(["clone", "--depth", "100", remote, localPath], { cwd: path.dirname(localPath) })
123+
const clone = yield* git.run(
124+
[
125+
"clone",
126+
"--depth",
127+
"100",
128+
...(params.branch ? ["--branch", params.branch] : []),
129+
remote,
130+
localPath,
131+
],
132+
{ cwd: path.dirname(localPath) },
133+
)
100134
if (clone.exitCode !== 0) {
101-
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
135+
throw new Error(
136+
clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`,
137+
)
102138
}
103139
}
104140

105141
if (status === "refreshed") {
106142
const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath })
107143
if (fetch.exitCode !== 0) {
108-
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
144+
throw new Error(
145+
fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`,
146+
)
147+
}
148+
149+
if (params.branch) {
150+
const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], {
151+
cwd: localPath,
152+
})
153+
if (checkout.exitCode !== 0) {
154+
throw new Error(
155+
checkout.stderr.toString().trim() ||
156+
checkout.text().trim() ||
157+
`Failed to checkout ${params.branch}`,
158+
)
159+
}
109160
}
110161

111162
const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
112163
const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
113164
const target = resetTarget({
165+
requestedBranch: params.branch,
114166
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
115167
branch: { code: branch.exitCode, stdout: branch.text().trim() },
116168
})
117169

118170
const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath })
119171
if (reset.exitCode !== 0) {
120-
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
172+
throw new Error(
173+
reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`,
174+
)
121175
}
122176
}
123177

packages/opencode/test/agent/agent.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,59 @@ test("scout agent allows repo cloning and repo cache reads", async () => {
123123
})
124124
})
125125

126+
test("reference config creates scout-backed subagents", async () => {
127+
await using tmp = await tmpdir({
128+
config: {
129+
reference: {
130+
effect: "github.com/effect/effect-smol",
131+
effectFull: {
132+
repository: "Effect-TS/effect",
133+
branch: "main",
134+
},
135+
localdocs: "../docs",
136+
localdocsFull: {
137+
path: "../local-docs",
138+
},
139+
},
140+
},
141+
})
142+
await Instance.provide({
143+
directory: tmp.path,
144+
fn: async () => {
145+
const effect = await load(tmp.path, (svc) => svc.get("effect"))
146+
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
147+
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
148+
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
149+
150+
expect(effect).toBeDefined()
151+
expect(effect?.mode).toBe("subagent")
152+
expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
153+
expect(evalPerm(effect, "repo_clone")).toBe("allow")
154+
155+
expect(effectFull).toBeDefined()
156+
expect(effectFull?.mode).toBe("subagent")
157+
expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
158+
expect(effectFull?.prompt).toContain("Branch/ref: main")
159+
expect(evalPerm(effectFull, "repo_clone")).toBe("allow")
160+
161+
expect(local).toBeDefined()
162+
expect(local?.mode).toBe("subagent")
163+
expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`)
164+
expect(
165+
Permission.evaluate(
166+
"external_directory",
167+
path.join(path.resolve(tmp.path, "../docs"), "README.md"),
168+
local!.permission,
169+
).action,
170+
).toBe("allow")
171+
172+
expect(localFull).toBeDefined()
173+
expect(localFull?.mode).toBe("subagent")
174+
expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`)
175+
},
176+
})
177+
})
178+
126179
test("general agent denies todo tools", async () => {
127180
await using tmp = await tmpdir()
128181
await Instance.provide({

0 commit comments

Comments
 (0)