diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml
index 87e655fe4b..bc393269f9 100644
--- a/.github/workflows/duplicate-issues.yml
+++ b/.github/workflows/duplicate-issues.yml
@@ -18,100 +18,65 @@ jobs:
- uses: ./.github/actions/setup-bun
+ - name: Install dependencies
+ run: bun install
+
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
+ - name: Build prompt
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const fs = require("fs")
+ const issue = context.payload.issue
+ const body = issue.body ?? ""
+ const text = [
+ "Check this new issue for compliance and duplicates:",
+ "",
+ `CURRENT_ISSUE_NUMBER: ${issue.number}`,
+ "",
+ `Title: ${issue.title}`,
+ "",
+ "Description:",
+ body,
+ ].join("\n")
+
+ fs.writeFileSync("issue_info.txt", text)
+
- name: Check duplicates and compliance
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- OPENCODE_PERMISSION: |
- {
- "bash": {
- "*": "deny",
- "gh issue*": "allow"
- },
- "webfetch": "deny"
- }
run: |
- opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
+ bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details and return either a comment body or No action required" > issue_comment.txt
- Issue number: ${{ github.event.issue.number }}
+ - name: Post comment and label issue
+ env:
+ COMMENT: ${{ github.workspace }}/issue_comment.txt
+ uses: actions/github-script@v8
+ with:
+ script: |
+ const fs = require("fs")
+ const comment = fs.readFileSync(process.env.COMMENT, "utf8").trim()
- Lookup this issue with gh issue view ${{ github.event.issue.number }}.
+ if (comment === "No action required") {
+ core.info("No comment needed")
+ return
+ }
- You have TWO tasks. Perform both, then post a SINGLE comment (if needed).
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.issue.number,
+ body: `_The following comment was made by an LLM, it may be inaccurate:_\n\n${comment}`,
+ })
- ---
+ if (!comment.includes("")) return
- TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK
-
- Check whether the issue follows our contributing guidelines and issue templates.
-
- This project has three issue templates that every issue MUST use one of:
-
- 1. Bug Report - requires a Description field with real content
- 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
- 3. Question - requires the Question field with real content
-
- Additionally check:
- - No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
- - The issue has real content, not just template placeholder text left unchanged
- - Bug reports should include some context about how to reproduce
- - Feature requests should explain the problem or need
- - We want to push for having the user provide system description & information
-
- Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
-
- ---
-
- TASK 2: DUPLICATE CHECK
-
- Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates.
- Consider:
- 1. Similar titles or descriptions
- 2. Same error messages or symptoms
- 3. Related functionality or components
- 4. Similar feature requests
-
- Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997.
-
- ---
-
- POSTING YOUR COMMENT:
-
- Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
-
- If the issue is NOT compliant, start the comment with:
-
- Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
-
- If duplicates were found, include a section about potential duplicates with links.
-
- If the issue mentions keybinds/keyboard shortcuts, include a note about #4997.
-
- If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all.
-
- Use this format for the comment:
-
- [If not compliant:]
-
- This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
-
- **What needs to be fixed:**
- - [specific reasons]
-
- Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
-
- [If duplicates found, add:]
- ---
- This issue might be a duplicate of existing issues. Please check:
- - #[issue_number]: [brief description of similarity]
-
- [If keybind-related, add:]
- For keybind-related issues, please also check our pinned keybinds documentation: #4997
-
- [End with if not compliant:]
- If you believe this was flagged incorrectly, please let a maintainer know.
-
- Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.issue.number,
+ labels: ["needs:compliance"],
+ })
diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml
index 397f794a1c..e6929af2cd 100644
--- a/.github/workflows/pr-standards.yml
+++ b/.github/workflows/pr-standards.yml
@@ -16,6 +16,7 @@ jobs:
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
+ github.event.pull_request.user.login != 'R44VC0RP' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest
permissions:
diff --git a/.opencode/agent/duplicate-issue.md b/.opencode/agent/duplicate-issue.md
new file mode 100644
index 0000000000..efad62b455
--- /dev/null
+++ b/.opencode/agent/duplicate-issue.md
@@ -0,0 +1,86 @@
+---
+mode: primary
+hidden: true
+model: opencode/claude-haiku-4-5
+color: "#E67E22"
+tools:
+ "*": false
+ "github-issue-search": true
+---
+
+You are a duplicate issue detection agent. When an issue is opened, your job is to search for potentially duplicate or related open issues.
+
+You have two jobs:
+
+1. Check if the issue follows our issue templates/contributing requirements.
+2. Check for potential duplicate issues.
+
+Use the github-issue-search tool to find potentially related issues.
+
+IMPORTANT: The input will contain a line `CURRENT_ISSUE_NUMBER: NNNN`. Never mark that issue as a duplicate of itself.
+
+## Compliance checks
+
+This project has three issue templates:
+
+1. Bug Report - needs a Description field with real content.
+2. Feature Request - title should start with `[FEATURE]:` and include verification checkbox + meaningful description.
+3. Question - needs a Question field with real content.
+
+Also check:
+
+- no AI-generated walls of text
+- required sections are not placeholder-only / unchanged template text
+- bug reports include some repro context
+- feature requests explain the problem/need
+- encourage system information where relevant
+
+Do not be nitpicky about optional fields. Only flag real issues (missing template/required content, placeholder-only content, obviously AI-generated wall of text, empty/nonsensical issue).
+
+## Duplicate checks
+
+Search for duplicates by trying multiple keyword combinations from the issue title/body. Prioritize:
+
+- similar title/description
+- same error/symptoms
+- same component/feature area
+
+If the issue mentions keybinds, keyboard shortcuts, or key bindings, include a note to check pinned issue #4997.
+
+## Output rules
+
+If the issue is compliant AND no duplicates are found AND no keybind note is needed, output exactly:
+
+No action required
+
+Otherwise output exactly one markdown comment body with this structure:
+
+- If non-compliant, start with:
+
+
+
+This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md).
+
+**What needs to be fixed:**
+
+- [specific reason]
+
+Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
+
+- If duplicates were found, add:
+
+---
+
+This issue might be a duplicate of existing issues. Please check:
+
+- #1234: [brief reason]
+
+- If keybind-related, add:
+
+For keybind-related issues, please also check our pinned keybinds documentation: #4997
+
+- If non-compliant, end with:
+
+If you believe this was flagged incorrectly, please let a maintainer know.
+
+Keep output concise. Do not wrap output in code fences.
diff --git a/.opencode/tool/github-issue-search.ts b/.opencode/tool/github-issue-search.ts
new file mode 100644
index 0000000000..e028446c2a
--- /dev/null
+++ b/.opencode/tool/github-issue-search.ts
@@ -0,0 +1,57 @@
+///
+import { tool } from "@opencode-ai/plugin"
+import DESCRIPTION from "./github-issue-search.txt"
+
+async function githubFetch(endpoint: string, options: RequestInit = {}) {
+ const response = await fetch(`https://api.github.com${endpoint}`, {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
+ Accept: "application/vnd.github+json",
+ "Content-Type": "application/json",
+ ...options.headers,
+ },
+ })
+ if (!response.ok) {
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
+ }
+ return response.json()
+}
+
+interface Issue {
+ title: string
+ html_url: string
+}
+
+export default tool({
+ description: DESCRIPTION,
+ args: {
+ query: tool.schema.string().describe("Search query for issue titles and descriptions"),
+ limit: tool.schema.number().describe("Maximum number of results to return").default(10),
+ offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
+ },
+ async execute(args) {
+ const owner = "anomalyco"
+ const repo = "opencode"
+
+ const page = Math.floor(args.offset / args.limit) + 1
+ const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:issue state:open`)
+ const result = await githubFetch(
+ `/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
+ )
+
+ if (result.total_count === 0) {
+ return `No issues found matching "${args.query}"`
+ }
+
+ const issues = result.items as Issue[]
+
+ if (issues.length === 0) {
+ return `No other issues found matching "${args.query}"`
+ }
+
+ const formatted = issues.map((issue) => `${issue.title}\n${issue.html_url}`).join("\n\n")
+
+ return `Found ${result.total_count} issues (showing ${issues.length}):\n\n${formatted}`
+ },
+})
diff --git a/.opencode/tool/github-issue-search.txt b/.opencode/tool/github-issue-search.txt
new file mode 100644
index 0000000000..176e916084
--- /dev/null
+++ b/.opencode/tool/github-issue-search.txt
@@ -0,0 +1,10 @@
+Use this tool to search GitHub issues by title and description.
+
+This tool searches issues in the sst/opencode repository and returns LLM-friendly results including:
+- Issue number and title
+- Author
+- State (open/closed/merged)
+- Labels
+- Description snippet
+
+Use the query parameter to search for keywords that might appear in issue titles or descriptions.
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index 3c28331bd5..8d156c03d8 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
+import { Session } from "../../session"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(true)
},
)
+ .get(
+ "/session",
+ describeRoute({
+ summary: "List sessions",
+ description:
+ "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
+ operationId: "experimental.session.list",
+ responses: {
+ 200: {
+ description: "List of sessions",
+ content: {
+ "application/json": {
+ schema: resolver(Session.GlobalInfo.array()),
+ },
+ },
+ },
+ },
+ }),
+ validator(
+ "query",
+ z.object({
+ directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
+ roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
+ start: z.coerce
+ .number()
+ .optional()
+ .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
+ cursor: z.coerce
+ .number()
+ .optional()
+ .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
+ search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
+ limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
+ archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
+ }),
+ ),
+ async (c) => {
+ const query = c.req.valid("query")
+ const limit = query.limit ?? 100
+ const sessions: Session.GlobalInfo[] = []
+ for await (const session of Session.listGlobal({
+ directory: query.directory,
+ roots: query.roots,
+ start: query.start,
+ cursor: query.cursor,
+ search: query.search,
+ limit: limit + 1,
+ archived: query.archived,
+ })) {
+ sessions.push(session)
+ }
+ const hasMore = sessions.length > limit
+ const list = hasMore ? sessions.slice(0, limit) : sessions
+ if (hasMore && list.length > 0) {
+ c.header("x-next-cursor", String(list[list.length - 1].time.updated))
+ }
+ return c.json(list)
+ },
+ )
.get(
"/resource",
describeRoute({
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 255f4dd460..f8e59b6d91 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
-import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
+import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
+import type { SQL } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
@@ -154,6 +156,24 @@ export namespace Session {
})
export type Info = z.output
+ export const ProjectInfo = z
+ .object({
+ id: z.string(),
+ name: z.string().optional(),
+ worktree: z.string(),
+ })
+ .meta({
+ ref: "ProjectSummary",
+ })
+ export type ProjectInfo = z.output
+
+ export const GlobalInfo = Info.extend({
+ project: ProjectInfo.nullable(),
+ }).meta({
+ ref: "GlobalSession",
+ })
+ export type GlobalInfo = z.output
+
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -544,6 +564,75 @@ export namespace Session {
}
}
+ export function* listGlobal(input?: {
+ directory?: string
+ roots?: boolean
+ start?: number
+ cursor?: number
+ search?: string
+ limit?: number
+ archived?: boolean
+ }) {
+ const conditions: SQL[] = []
+
+ if (input?.directory) {
+ conditions.push(eq(SessionTable.directory, input.directory))
+ }
+ if (input?.roots) {
+ conditions.push(isNull(SessionTable.parent_id))
+ }
+ if (input?.start) {
+ conditions.push(gte(SessionTable.time_updated, input.start))
+ }
+ if (input?.cursor) {
+ conditions.push(lt(SessionTable.time_updated, input.cursor))
+ }
+ if (input?.search) {
+ conditions.push(like(SessionTable.title, `%${input.search}%`))
+ }
+ if (!input?.archived) {
+ conditions.push(isNull(SessionTable.time_archived))
+ }
+
+ const limit = input?.limit ?? 100
+
+ const rows = Database.use((db) => {
+ const query =
+ conditions.length > 0
+ ? db
+ .select()
+ .from(SessionTable)
+ .where(and(...conditions))
+ : db.select().from(SessionTable)
+ return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
+ })
+
+ const ids = [...new Set(rows.map((row) => row.project_id))]
+ const projects = new Map()
+
+ if (ids.length > 0) {
+ const items = Database.use((db) =>
+ db
+ .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
+ .from(ProjectTable)
+ .where(inArray(ProjectTable.id, ids))
+ .all(),
+ )
+ for (const item of items) {
+ projects.set(item.id, {
+ id: item.id,
+ name: item.name ?? undefined,
+ worktree: item.worktree,
+ })
+ }
+ }
+
+ for (const row of rows) {
+ const project = projects.get(row.project_id) ?? null
+ yield { ...fromRow(row), project }
+ }
+ }
+
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
const rows = Database.use((db) =>
diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts
new file mode 100644
index 0000000000..05d6de04b1
--- /dev/null
+++ b/packages/opencode/test/server/global-session-list.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, test } from "bun:test"
+import { Instance } from "../../src/project/instance"
+import { Project } from "../../src/project/project"
+import { Session } from "../../src/session"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+describe("Session.listGlobal", () => {
+ test("lists sessions across projects with project metadata", async () => {
+ await using first = await tmpdir({ git: true })
+ await using second = await tmpdir({ git: true })
+
+ const firstSession = await Instance.provide({
+ directory: first.path,
+ fn: async () => Session.create({ title: "first-session" }),
+ })
+ const secondSession = await Instance.provide({
+ directory: second.path,
+ fn: async () => Session.create({ title: "second-session" }),
+ })
+
+ const sessions = [...Session.listGlobal({ limit: 200 })]
+ const ids = sessions.map((session) => session.id)
+
+ expect(ids).toContain(firstSession.id)
+ expect(ids).toContain(secondSession.id)
+
+ const firstProject = Project.get(firstSession.projectID)
+ const secondProject = Project.get(secondSession.projectID)
+
+ const firstItem = sessions.find((session) => session.id === firstSession.id)
+ const secondItem = sessions.find((session) => session.id === secondSession.id)
+
+ expect(firstItem?.project?.id).toBe(firstProject?.id)
+ expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
+ expect(secondItem?.project?.id).toBe(secondProject?.id)
+ expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
+ })
+
+ test("excludes archived sessions by default", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const archived = await Instance.provide({
+ directory: tmp.path,
+ fn: async () => Session.create({ title: "archived-session" }),
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
+ })
+
+ const sessions = [...Session.listGlobal({ limit: 200 })]
+ const ids = sessions.map((session) => session.id)
+
+ expect(ids).not.toContain(archived.id)
+
+ const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
+ const allIds = allSessions.map((session) => session.id)
+
+ expect(allIds).toContain(archived.id)
+ })
+
+ test("supports cursor pagination", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ const first = await Instance.provide({
+ directory: tmp.path,
+ fn: async () => Session.create({ title: "page-one" }),
+ })
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ const second = await Instance.provide({
+ directory: tmp.path,
+ fn: async () => Session.create({ title: "page-two" }),
+ })
+
+ const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
+ expect(page.length).toBe(1)
+ expect(page[0].id).toBe(second.id)
+
+ const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
+ const ids = next.map((session) => session.id)
+
+ expect(ids).toContain(first.id)
+ expect(ids).not.toContain(second.id)
+ })
+})
diff --git a/script/duplicate-issue.ts b/script/duplicate-issue.ts
new file mode 100644
index 0000000000..8e47f6fbb6
--- /dev/null
+++ b/script/duplicate-issue.ts
@@ -0,0 +1,79 @@
+#!/usr/bin/env bun
+
+import path from "path"
+import { pathToFileURL } from "bun"
+import { createOpencode } from "@opencode-ai/sdk"
+import { parseArgs } from "util"
+
+async function main() {
+ const { values, positionals } = parseArgs({
+ args: Bun.argv.slice(2),
+ options: {
+ file: { type: "string", short: "f" },
+ help: { type: "boolean", short: "h", default: false },
+ },
+ allowPositionals: true,
+ })
+
+ if (values.help) {
+ console.log(`
+Usage: bun script/duplicate-issue.ts [options]
+
+Options:
+ -f, --file File to attach to the prompt
+ -h, --help Show this help message
+
+Examples:
+ bun script/duplicate-issue.ts -f issue_info.txt "Check the attached file for issue details"
+`)
+ process.exit(0)
+ }
+
+ const message = positionals.join(" ")
+ if (!message) {
+ console.error("Error: message is required")
+ process.exit(1)
+ }
+
+ const opencode = await createOpencode({ port: 0 })
+
+ try {
+ const parts: Array<{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }> =
+ []
+
+ if (values.file) {
+ const resolved = path.resolve(process.cwd(), values.file)
+ const file = Bun.file(resolved)
+ if (!(await file.exists())) {
+ console.error(`Error: file not found: ${values.file}`)
+ process.exit(1)
+ }
+ parts.push({
+ type: "file",
+ url: pathToFileURL(resolved).href,
+ filename: path.basename(resolved),
+ mime: "text/plain",
+ })
+ }
+
+ parts.push({ type: "text", text: message })
+
+ const session = await opencode.client.session.create()
+ const result = await opencode.client.session
+ .prompt({
+ path: { id: session.data!.id },
+ body: {
+ agent: "duplicate-issue",
+ parts,
+ },
+ signal: AbortSignal.timeout(120_000),
+ })
+ .then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? "")
+
+ console.log(result.trim())
+ } finally {
+ opencode.server.close()
+ }
+}
+
+main()