diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b88d031e2b6..d53d12efdfe 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -187,6 +187,7 @@ export namespace Agent { "*": "allow", bash, // kilocode_change doom_loop: "ask", + recall: "ask", // kilocode_change external_directory: { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 8c770fdb65b..a76fb23e579 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -73,32 +73,63 @@ export const SessionListCommand = cmd({ command: "list", describe: "list sessions", builder: (yargs: Argv) => { - return yargs - .option("max-count", { - alias: "n", - describe: "limit to N most recent sessions", - type: "number", - }) - .option("format", { - describe: "output format", - type: "string", - choices: ["table", "json"], - default: "table", - }) + // kilocode_change start + return ( + yargs + .option("max-count", { + alias: "n", + describe: "limit to N most recent sessions", + type: "number", + }) + .option("format", { + describe: "output format", + type: "string", + choices: ["table", "json"], + default: "table", + }) + // kilocode_change end + // kilocode_change start + .option("all", { + alias: "a", + describe: "list sessions from all projects", + type: "boolean", + default: false, + }) + .option("search", { + alias: "s", + describe: "filter sessions by title", + type: "string", + }) + ) + // kilocode_change end }, + // kilocode_change start handler: async (args) => { await bootstrap(process.cwd(), async () => { - const sessions = [...Session.list({ roots: true, limit: args.maxCount })] + // kilocode_change end + // kilocode_change start + const sessions = args.all + ? [...Session.listGlobal({ roots: true, limit: args.maxCount, search: args.search })] + : [...Session.list({ roots: true, limit: args.maxCount, search: args.search })] + // kilocode_change end + // kilocode_change start if (sessions.length === 0) { return } + // kilocode_change end + // kilocode_change start let output: string if (args.format === "json") { - output = formatSessionJSON(sessions) + output = args.all + ? formatGlobalSessionJSON(sessions as Session.GlobalInfo[]) + : formatSessionJSON(sessions as Session.Info[]) } else { - output = formatSessionTable(sessions) + output = args.all + ? formatGlobalSessionTable(sessions as Session.GlobalInfo[]) + : formatSessionTable(sessions as Session.Info[]) + // kilocode_change end } const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" @@ -144,6 +175,7 @@ function formatSessionTable(sessions: Session.Info[]): string { return lines.join(EOL) } +// kilocode_change start function formatSessionJSON(sessions: Session.Info[]): string { const jsonData = sessions.map((session) => ({ id: session.id, @@ -155,3 +187,45 @@ function formatSessionJSON(sessions: Session.Info[]): string { })) return JSON.stringify(jsonData, null, 2) } +// kilocode_change end + +// kilocode_change start +function formatGlobalSessionTable(sessions: Session.GlobalInfo[]): string { + const lines: string[] = [] + + const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length)) + const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length)) + const maxProjectWidth = Math.max( + 10, + ...sessions.map((s) => (s.project?.name ?? s.project?.worktree ?? "unknown").length), + ) + + const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Project${" ".repeat(maxProjectWidth - 7)} Updated` + lines.push(header) + lines.push("─".repeat(header.length)) + for (const session of sessions) { + const truncatedTitle = Locale.truncate(session.title, maxTitleWidth) + const project = Locale.truncate(session.project?.name ?? session.project?.worktree ?? "unknown", maxProjectWidth) + const timeStr = Locale.todayTimeOrDateTime(session.time.updated) + const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${project.padEnd(maxProjectWidth)} ${timeStr}` + lines.push(line) + } + + return lines.join(EOL) +} + +function formatGlobalSessionJSON(sessions: Session.GlobalInfo[]): string { + const jsonData = sessions.map((session) => ({ + id: session.id, + title: session.title, + updated: session.time.updated, + created: session.time.created, + projectId: session.projectID, + directory: session.directory, + project: session.project + ? { id: session.project.id, name: session.project.name, worktree: session.project.worktree } + : null, + })) + return JSON.stringify(jsonData, null, 2) +} +// kilocode_change end diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 843b4a41853..61b64d1a789 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -48,6 +48,16 @@ import { initializeTUIDependencies } from "@kilocode/kilo-gateway/tui" // kiloco import { TuiConfigProvider } from "./context/tui-config" import { TuiConfig } from "@/config/tui" +// kilocode_change start +function isAllowEverything(permission: unknown): boolean { + if (typeof permission !== "object" || permission === null) return false + const wildcard = (permission as Record)["*"] + if (typeof wildcard === "string") return wildcard === "allow" + if (typeof wildcard === "object" && wildcard !== null) return (wildcard as Record)["*"] === "allow" + return false +} +// kilocode_change end + async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY if (!process.stdin.isTTY) return "dark" @@ -709,6 +719,27 @@ function App() { dialog.clear() }, }, + // kilocode_change start + { + get title() { + return isAllowEverything(sync.data.config.permission) ? "Disable auto-approve mode" : "Enable auto-approve mode" + }, + value: "permission.allow_everything", + category: "System", + onSelect: async (dialog) => { + const enabled = isAllowEverything(sync.data.config.permission) + const result = await sdk.client.permission.allowEverything({ enable: !enabled }) + if (result.error) { + toast.show({ + variant: "error", + message: `Failed to ${!enabled ? "enable" : "disable"} auto-approve mode`, + }) + return + } + dialog.clear() + }, + }, + // kilocode_change end ]) // kilocode_change start - Initialize TUI dependencies for kilo-gateway diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb3..8ad7d001494 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,15 +2,15 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createSignal, createResource, onMount, Show } from "solid-js" +import { createMemo, createSignal, createResource, onMount } from "solid-js" // kilocode_change import { Locale } from "@/util/locale" import { useKeybind } from "../context/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" -import { useKV } from "../context/kv" import { createDebouncedSignal } from "../util/signal" import { Spinner } from "./spinner" +import path from "path" // kilocode_change export function DialogSessionList() { const dialog = useDialog() @@ -19,23 +19,44 @@ export function DialogSessionList() { const keybind = useKeybind() const { theme } = useTheme() const sdk = useSDK() - const kv = useKV() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) + const [global, setGlobal] = createSignal(true) // kilocode_change - show all worktrees by default - const [searchResults] = createResource(search, async (query) => { - if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) - return result.data ?? [] - }) + // kilocode_change start - always fetch from experimental endpoint (returns GlobalSession with worktree info) + const [searchResults, searchActions] = createResource( + () => search(), + async (query) => { + const result = await sdk.client.experimental.session.list( + { + search: query || undefined, + roots: true, + worktrees: true, + limit: 30, + }, + { throwOnError: true }, + ) + return result.data ?? [] + }, + ) + // kilocode_change end const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + // kilocode_change start - client-side worktree filtering when global is off + const sessions = createMemo(() => { + const all = searchResults() ?? [] + if (global()) return all + const root = sync.data.path.worktree + if (!root || root === "/") return all + return all.filter((s) => s.directory === root || s.directory.startsWith(root + path.sep)) + }) + // kilocode_change end const options = createMemo(() => { const today = new Date().toDateString() + const all = global() // kilocode_change return sessions() .filter((x) => x.parentID === undefined) .toSorted((a, b) => b.time.updated - a.time.updated) @@ -50,6 +71,7 @@ export function DialogSessionList() { const isWorking = status?.type === "busy" return { title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, + description: all && x.worktreeName ? `(${x.worktreeName})` : undefined, // kilocode_change - worktree label bg: isDeleting ? theme.error : undefined, value: x.id, category, @@ -65,7 +87,7 @@ export function DialogSessionList() { return ( { if (toDelete() === option.value) { - sdk.client.session.delete({ + // kilocode_change start + await sdk.client.session.delete({ sessionID: option.value, }) + // kilocode_change end setToDelete(undefined) + void searchActions.refetch() // kilocode_change return } setToDelete(option.value) @@ -97,11 +122,30 @@ export function DialogSessionList() { }, { keybind: keybind.all.session_rename?.[0], - title: "rename", + title: "rename", // kilocode_change + // kilocode_change start onTrigger: async (option) => { - dialog.replace(() => ) + const item = sessions().find((x) => x.id === option.value) + dialog.replace(() => ( + { + void searchActions.refetch() + }} + /> + )) }, }, + { + keybind: { name: "a", ctrl: true, meta: false, shift: false, leader: false }, + title: global() ? "current" : "all", + onTrigger: async () => { + setToDelete(undefined) + setGlobal((v) => !v) + }, + }, + // kilocode_change end ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx index 141340d5562..c519f41279d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-rename.tsx @@ -6,6 +6,8 @@ import { useSDK } from "../context/sdk" interface DialogSessionRenameProps { session: string + title?: string // kilocode_change + onConfirm?: () => void // kilocode_change } export function DialogSessionRename(props: DialogSessionRenameProps) { @@ -17,12 +19,16 @@ export function DialogSessionRename(props: DialogSessionRenameProps) { return ( { - sdk.client.session.update({ - sessionID: props.session, - title: value, - }) + // kilocode_change start + sdk.client.session + .update({ + sessionID: props.session, + title: value, + }) + .then(() => props.onConfirm?.()) + // kilocode_change end dialog.clear() }} onCancel={() => dialog.clear()} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index aa4766bf959..c7d1dd8c3d1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -392,6 +392,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + + // kilocode_change start + case "global.config.updated": { + sdk.client.config.get().then((x) => { + if (x.data) setStore("config", reconcile(x.data)) + }) + break + } + // kilocode_change end } }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 667761cd890..272d308a31c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -149,6 +149,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { }) const { theme } = useTheme() + const keybind = useKeybind() // kilocode_change return ( @@ -426,6 +427,13 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { {current.title} + {/* // kilocode_change start - explain config file edits always require approval */} + + + Config file edits always require approval + + + {/* // kilocode_change end */} ) diff --git a/packages/opencode/src/kilocode/permission/routes.ts b/packages/opencode/src/kilocode/permission/routes.ts new file mode 100644 index 00000000000..3321c2f1090 --- /dev/null +++ b/packages/opencode/src/kilocode/permission/routes.ts @@ -0,0 +1,78 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { Config } from "@/config/config" +import { PermissionNext } from "@/permission/next" +import { Session } from "@/session" +import { errors } from "../../server/error" +import { lazy } from "../../util/lazy" + +export const PermissionKilocodeRoutes = lazy(() => + new Hono().post( + "/allow-everything", + describeRoute({ + summary: "Allow everything", + description: "Enable or disable allowing all permissions without prompts.", + operationId: "permission.allowEverything", + responses: { + 200: { + description: "Success", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + enable: z.boolean(), + requestID: z.string().optional(), + sessionID: z.string().optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const rules: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "allow" }] + + if (!body.enable) { + if (body.sessionID) { + const session = await Session.get(body.sessionID) + await Session.setPermission({ + sessionID: body.sessionID, + permission: (session.permission ?? []).filter( + (rule) => !(rule.permission === "*" && rule.pattern === "*" && rule.action === "allow"), + ), + }) + await PermissionNext.allowEverything({ enable: false, sessionID: body.sessionID }) + return c.json(true) + } + + await Config.updateGlobal({ permission: { "*": { "*": null } } }, { dispose: false }) + await PermissionNext.allowEverything({ enable: false }) + return c.json(true) + } + + if (body.sessionID) { + const session = await Session.get(body.sessionID) + await Session.setPermission({ + sessionID: body.sessionID, + permission: [...(session.permission ?? []), ...rules], + }) + } else { + await Config.updateGlobal({ permission: PermissionNext.toConfig(rules) }, { dispose: false }) + } + + await PermissionNext.allowEverything({ + enable: true, + requestID: body.requestID, + sessionID: body.sessionID, + }) + + return c.json(true) + }, + ), +) diff --git a/packages/opencode/src/kilocode/worktree-family.ts b/packages/opencode/src/kilocode/worktree-family.ts new file mode 100644 index 00000000000..0248c171410 --- /dev/null +++ b/packages/opencode/src/kilocode/worktree-family.ts @@ -0,0 +1,35 @@ +// kilocode_change - new file +import { Instance } from "../project/instance" +import { Project } from "../project/project" +import { Filesystem } from "../util/filesystem" +import { git } from "../util/git" + +export namespace WorktreeFamily { + export async function list() { + if (Instance.project.vcs !== "git") { + return [Filesystem.resolve(Instance.directory)] + } + + const listed = await git(["worktree", "list", "--porcelain"], { + cwd: Instance.worktree, + }) + + if (listed.exitCode === 0) { + const dirs = listed + .text() + .split("\n") + .map((line) => line.trim()) + .flatMap((line) => { + if (!line.startsWith("worktree ")) return [] + return [Filesystem.resolve(line.slice("worktree ".length).trim())] + }) + + if (dirs.length > 0) { + return [...new Set(dirs)] + } + } + + const dirs = [Instance.worktree, ...(await Project.sandboxes(Instance.project.id))] + return [...new Set(dirs.map((dir) => Filesystem.resolve(dir)))] + } +} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index b68fafd1f8a..34b2cbee759 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -179,6 +179,7 @@ export namespace PermissionNext { return { pending, approved: stored, + session: {} as Record, // kilocode_change } }) @@ -189,11 +190,12 @@ export namespace PermissionNext { async (input) => { const s = await state() const { ruleset, ...request } = input + const local = s.session[request.sessionID] ?? [] // kilocode_change // kilocode_change start — force "ask" for config file edits const protected_ = ConfigProtection.isRequest(request) // kilocode_change end for (const pattern of request.patterns ?? []) { - const rule = evaluate(request.permission, pattern, ruleset, s.approved) + const rule = evaluate(request.permission, pattern, ruleset, s.approved, local) // kilocode_change log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) @@ -355,6 +357,58 @@ export namespace PermissionNext { }, ) + // kilocode_change start + export const allowEverything = fn( + z.object({ + enable: z.boolean(), + requestID: Identifier.schema("permission").optional(), + sessionID: Identifier.schema("session").optional(), + }), + async (input) => { + const s = await state() + + if (!input.enable) { + if (input.sessionID) { + delete s.session[input.sessionID] + return + } + const idx = s.approved.findLastIndex((r) => r.permission === "*" && r.pattern === "*" && r.action === "allow") + if (idx >= 0) s.approved.splice(idx, 1) + return + } + + const rule = { permission: "*", pattern: "*", action: "allow" } as const + if (input.sessionID) s.session[input.sessionID] = [rule] + else s.approved.push(rule) + + if (input.requestID) { + const existing = s.pending[input.requestID] + if (existing && (!input.sessionID || existing.info.sessionID === input.sessionID)) { + delete s.pending[input.requestID] + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: "once", + }) + existing.resolve() + } + } + + for (const [id, entry] of Object.entries(s.pending)) { + if (input.sessionID && entry.info.sessionID !== input.sessionID) continue + if (ConfigProtection.isRequest(entry.info)) continue + delete s.pending[id] + Bus.publish(Event.Replied, { + sessionID: entry.info.sessionID, + requestID: entry.info.id, + reply: "once", + }) + entry.resolve() + } + }, + ) + // kilocode_change end + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { const merged = merge(...rulesets) log.info("evaluate", { permission, pattern, ruleset: merged }) @@ -405,4 +459,11 @@ export namespace PermissionNext { const s = await state() return Object.values(s.pending).map((x) => x.info) } + + // kilocode_change start + export async function pending(id: string): Promise { + const s = await state() + return s.pending[id]?.info + } + // kilocode_change end } diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 9f1227b6d94..c16704456b6 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -13,8 +13,11 @@ import { lazy } from "../../util/lazy" import { Snapshot } from "../../snapshot" // kilocode_change import { Review } from "../../kilocode/review/review" // kilocode_change import { WorktreeDiff } from "../../kilocode/review/worktree-diff" // kilocode_change +import { WorktreeFamily } from "../../kilocode/worktree-family" // kilocode_change import { Log } from "../../util/log" // kilocode_change import { WorkspaceRoutes } from "./workspace" +import { Filesystem } from "../../util/filesystem" // kilocode_change +import path from "path" // kilocode_change export const ExperimentalRoutes = lazy(() => new Hono() @@ -326,7 +329,14 @@ export const ExperimentalRoutes = lazy(() => validator( "query", z.object({ + // kilocode_change start + projectID: z.string().optional().meta({ description: "Filter sessions by project ID" }), directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), + worktrees: z.coerce + .boolean() + .optional() + .meta({ description: "Restrict sessions to the current repo worktree family or current directory" }), + // kilocode_change end roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), start: z.coerce .number() @@ -343,10 +353,19 @@ export const ExperimentalRoutes = lazy(() => ), async (c) => { const query = c.req.valid("query") - const limit = query.limit ?? 100 + const limit = query.limit ?? 100 // kilocode_change + // kilocode_change start + const projectID = query.worktrees && !query.projectID ? Instance.project.id : query.projectID + // kilocode_change end + const directories = query.worktrees ? await WorktreeFamily.list() : undefined // kilocode_change + // kilocode_change start - sort longest-first so most specific worktree matches first + const sorted = directories ? [...directories].sort((a, b) => b.length - a.length) : undefined + // kilocode_change end const sessions: Session.GlobalInfo[] = [] for await (const session of Session.listGlobal({ + projectID, // kilocode_change directory: query.directory, + directories, // kilocode_change roots: query.roots, start: query.start, cursor: query.cursor, @@ -354,6 +373,13 @@ export const ExperimentalRoutes = lazy(() => limit: limit + 1, archived: query.archived, })) { + // kilocode_change start - resolve worktree folder name for each session + if (sorted) { + const root = sorted.find((d) => Filesystem.contains(d, session.directory)) + sessions.push({ ...session, worktreeName: path.basename(root ?? session.directory) }) + continue + } + // kilocode_change end sessions.push(session) } const hasMore = sessions.length > limit diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7f3b12b1171..992fef087e2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -48,6 +48,7 @@ import { errors } from "./error" import { CommitMessageRoutes } from "./routes/commit-message" // kilocode_change import { EnhancePromptRoutes } from "./routes/enhance-prompt" // kilocode_change import { KilocodeRoutes } from "./routes/kilocode" // kilocode_change +import { PermissionKilocodeRoutes } from "../kilocode/permission/routes" // kilocode_change import { Filesystem } from "@/util/filesystem" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" @@ -277,6 +278,7 @@ export namespace Server { .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) .route("/permission", PermissionRoutes()) + .route("/permission", PermissionKilocodeRoutes()) // kilocode_change .route("/question", QuestionRoutes()) .route("/provider", ProviderRoutes()) .route("/telemetry", TelemetryRoutes()) // kilocode_change diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b2912e8223e..b64225c5d0d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -117,6 +117,25 @@ export namespace Session { return `${title} (fork #1)` } + // kilocode_change start + function family(id: string) { + const row = Database.use((db) => + db.select({ worktree: ProjectTable.worktree }).from(ProjectTable).where(eq(ProjectTable.id, id)).get(), + ) + const root = row?.worktree ? Filesystem.resolve(row.worktree) : undefined + if (!root || root === "/") return [id] + const ids = Database.use((db) => + db + .select({ id: ProjectTable.id }) + .from(ProjectTable) + .where(eq(ProjectTable.worktree, root)) + .all() + .map((item) => item.id), + ) + return ids.length ? ids : [id] + } + // kilocode_change end + export const Info = z .object({ id: Identifier.schema("session"), @@ -185,6 +204,7 @@ export namespace Session { export const GlobalInfo = Info.extend({ project: ProjectInfo.nullable(), + worktreeName: z.string().optional(), // kilocode_change - basename of the specific worktree directory }).meta({ ref: "GlobalSession", }) @@ -621,8 +641,11 @@ export namespace Session { } } + // kilocode_change start export function* listGlobal(input?: { + projectID?: string directory?: string + directories?: string[] roots?: boolean start?: number cursor?: number @@ -630,7 +653,18 @@ export namespace Session { limit?: number archived?: boolean }) { - const conditions: SQL[] = [] + const conditions: SQL[] = [] // kilocode_change + + // kilocode_change start + if (input?.projectID) { + const ids = family(input.projectID) + if (ids.length === 1 && ids[0] === input.projectID) { + conditions.push(eq(SessionTable.project_id, input.projectID)) + } else { + conditions.push(inArray(SessionTable.project_id, ids)) + } + } + // kilocode_change end if (input?.directory) { // kilocode_change start: vscode uri.fsPath gives lowercase drive letter on Windows; resolve() canonicalises to match stored path @@ -653,7 +687,9 @@ export namespace Session { conditions.push(isNull(SessionTable.time_archived)) } - const limit = input?.limit ?? 100 + const limit = input?.limit ?? 100 // kilocode_change + // kilocode_change start + const dirs = [...new Set((input?.directories ?? []).map((dir) => Filesystem.resolve(dir)))] const rows = Database.use((db) => { const query = @@ -663,10 +699,19 @@ export namespace Session { .from(SessionTable) .where(and(...conditions)) : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + const sorted = query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)) + return dirs.length ? sorted.all() : sorted.limit(limit).all() }) - const ids = [...new Set(rows.map((row) => row.project_id))] + const list = + dirs.length > 0 + ? rows.filter((row) => { + const dir = Filesystem.resolve(row.directory) + return dirs.some((root) => Filesystem.contains(root, dir)) + }) + : rows + + const ids = [...new Set(list.slice(0, limit).map((row) => row.project_id))] const projects = new Map() if (ids.length > 0) { @@ -685,11 +730,14 @@ export namespace Session { }) } } + // kilocode_change end - for (const row of rows) { + // kilocode_change start + for (const row of list.slice(0, limit)) { const project = projects.get(row.project_id) ?? null yield { ...fromRow(row), project } } + // kilocode_change end } export const children = fn(Identifier.schema("session"), async (parentID) => { diff --git a/packages/opencode/src/tool/recall.ts b/packages/opencode/src/tool/recall.ts new file mode 100644 index 00000000000..52697a33547 --- /dev/null +++ b/packages/opencode/src/tool/recall.ts @@ -0,0 +1,150 @@ +// kilocode_change - new file +import z from "zod" +import { Tool } from "./tool" +import { Instance } from "../project/instance" +import { Locale } from "../util/locale" +import { Filesystem } from "../util/filesystem" // kilocode_change +import { WorktreeFamily } from "../kilocode/worktree-family" // kilocode_change +import DESCRIPTION from "./recall.txt" + +export const RecallTool = Tool.define("kilo_local_recall", { + description: DESCRIPTION, + parameters: z.object({ + mode: z.enum(["search", "read"]).describe("'search' to find sessions by title, 'read' to get a session transcript"), + query: z.string().optional().describe("Search query to match against session titles (required for search mode)"), + sessionID: z.string().optional().describe("Session ID to read the transcript of (required for read mode)"), + limit: z.number().optional().describe("Maximum number of search results to return (default: 20, max: 50)"), + }), + async execute(params, ctx) { + if (params.mode === "search") { + return search(params, ctx) + } + return read(params, ctx) + }, +}) + +async function search(params: { query?: string; limit?: number }, ctx: Tool.Context) { + if (!params.query) { + throw new Error("The 'query' parameter is required when mode is 'search'") + } + + await ctx.ask({ + permission: "recall", + patterns: ["search"], + always: ["search"], + metadata: { + mode: "search", + query: params.query, + }, + }) + + const limit = Math.min(params.limit ?? 20, 50) + const dirs = await WorktreeFamily.list() // kilocode_change + const { Session } = await import("../session/index") // kilocode_change + + const results: Array<{ + id: string + title: string + directory: string + updated: string + }> = [] + + for (const session of Session.listGlobal({ + projectID: Instance.project.id, // kilocode_change + directories: dirs, // kilocode_change + search: params.query, + roots: true, + limit, + })) { + results.push({ + id: session.id, + title: session.title, + directory: session.directory, + updated: Locale.todayTimeOrDateTime(session.time.updated), + }) + } + + if (results.length === 0) { + return { + title: `Search: "${params.query}" (no results)`, + output: `No sessions found matching "${params.query}".`, + metadata: {}, + } + } + + const lines = results.map((r) => `- **${r.title}**\n ID: ${r.id} | Updated: ${r.updated} | Dir: ${r.directory}`) + + return { + title: `Search: "${params.query}" (${results.length} results)`, + output: lines.join("\n"), + metadata: {}, + } +} + +async function read(params: { sessionID?: string }, ctx: Tool.Context) { + if (!params.sessionID) { + throw new Error("The 'sessionID' parameter is required when mode is 'read'") + } + + const { Session } = await import("../session/index") // kilocode_change + const session = await Session.get(params.sessionID).catch(() => { + throw new Error(`Session "${params.sessionID}" not found. Use search mode first to find valid session IDs.`) + }) + const dirs = await WorktreeFamily.list() // kilocode_change + // kilocode_change start + const dir = Filesystem.resolve(session.directory) + if (!dirs.some((root) => Filesystem.contains(root, dir))) { + throw new Error( + `Session "${params.sessionID}" belongs to a different workspace and cannot be read from this directory.`, + ) + } + // kilocode_change end + + const cross = session.projectID !== Instance.project.id + if (cross) { + await ctx.ask({ + permission: "recall", + patterns: [session.directory], + always: [session.directory], + metadata: { + sessionID: session.id, + title: session.title, + directory: session.directory, + }, + }) + } + + const msgs = await Session.messages({ sessionID: session.id }) + const lines: string[] = [ + `# Session: ${session.title}`, + `Directory: ${session.directory}`, + `Created: ${Locale.todayTimeOrDateTime(session.time.created)}`, + "", + ] + + for (const msg of msgs) { + if (msg.info.role === "user") { + lines.push("## User") + for (const part of msg.parts) { + if (part.type === "text") lines.push(part.text) + } + lines.push("") + } + if (msg.info.role === "assistant") { + lines.push("## Assistant") + for (const part of msg.parts) { + if (part.type === "text") lines.push(part.text) + if (part.type === "tool" && part.state.status === "completed") { + lines.push(`[Tool: ${part.tool}] ${part.state.title}`) + } + } + lines.push("") + } + } + + return { + title: `Read: ${session.title}`, + output: lines.join("\n"), + metadata: {}, + } +} diff --git a/packages/opencode/src/tool/recall.txt b/packages/opencode/src/tool/recall.txt new file mode 100644 index 00000000000..fa73e81dc33 --- /dev/null +++ b/packages/opencode/src/tool/recall.txt @@ -0,0 +1,12 @@ +Search and read past conversations from the current project on this machine, including its git worktrees. Use this to recall previous work, find how something was implemented before, or retrieve context from another worktree in the same repo. + +Two modes: +1. **Search** - Find sessions by title keyword in the current project and its worktrees. Returns a list of matching sessions with their title, directory, and last updated time. Use this first to locate relevant conversations. +2. **Read** - Retrieve the full transcript of a specific session by ID. Returns the conversation messages (user prompts and assistant responses) so you can understand what was discussed and done. + +Usage notes: + - Search matches against session titles using case-insensitive substring matching + - Results are limited to the current project/worktree family + - Reading a session from a different project is rejected + - Use search mode first to find session IDs, then read mode to get the full conversation + - Session transcripts can be large; prefer searching first to narrow down which session to read diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 255e5481ed6..d55f5509991 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -29,6 +29,7 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { ApplyPatchTool } from "./apply_patch" +import { RecallTool } from "./recall" // kilocode_change import { Glob } from "../util/glob" import { pathToFileURL } from "url" @@ -118,6 +119,7 @@ export namespace ToolRegistry { CodeSearchTool, ...(config.experimental?.codebase_search === true ? [CodebaseSearchTool] : []), // kilocode_change SkillTool, + RecallTool, // kilocode_change ApplyPatchTool, ...(Flag.KILO_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index add3332048c..dcfd3b37c00 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -606,6 +606,61 @@ test("reply - always persists approval and resolves", async () => { }) }) +// kilocode_change start +test("allowEverything - session-scoped enable stays within one session", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const first = PermissionNext.ask({ + id: "permission_session_allow", + sessionID: "session_allowed", + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.allowEverything({ + enable: true, + requestID: "permission_session_allow", + sessionID: "session_allowed", + }) + + await expect(first).resolves.toBeUndefined() + + const allowed = await PermissionNext.ask({ + sessionID: "session_allowed", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + expect(allowed).toBeUndefined() + + const blocked = PermissionNext.ask({ + id: "permission_session_blocked", + sessionID: "session_blocked", + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_session_blocked", + reply: "reject", + }) + + await expect(blocked).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) +}) +// kilocode_change end + test("reply - reject cancels all pending for same session", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/packages/opencode/test/server/experimental-session-list.test.ts b/packages/opencode/test/server/experimental-session-list.test.ts new file mode 100644 index 00000000000..c26ff3917a4 --- /dev/null +++ b/packages/opencode/test/server/experimental-session-list.test.ts @@ -0,0 +1,92 @@ +// kilocode_change - new file +import { afterEach, describe, expect, mock, test } from "bun:test" +import { $ } from "bun" +import path from "path" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +mock.module("@/kilo-sessions/remote-sender", () => ({ + RemoteSender: { + create() { + return { + handle() {}, + dispose() {}, + } + }, + }, +})) + +Log.init({ print: false }) + +afterEach(async () => { + await resetDatabase() +}) + +describe("experimental.session.list", () => { + test("filters sessions by repo worktree family even when project IDs drift", async () => { + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree") + + try { + await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() + await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id") + + const share = Config.get + Config.get = async () => ({ share: "manual" }) as Awaited> + + try { + const { Server } = await import("../../src/server/server") + const { Session } = await import("../../src/session/index") + const root = await Instance.provide({ + directory: first.path, + fn: async () => ({ + app: Server.App(), + project: await Server.App().request("/project/current", { + headers: { "x-kilo-directory": first.path }, + }), + session: await Session.create({ title: "root-session" }), + }), + }) + + const branch = await Instance.provide({ + directory: worktree, + fn: async () => Session.create({ title: "worktree-session" }), + }) + + await Instance.provide({ + directory: second.path, + fn: async () => Session.create({ title: "other-project-session" }), + }) + + const app = root.app + const project = await root.project.json() + const response = await app.request( + `/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true&worktrees=true`, + { + headers: { "x-kilo-directory": first.path }, + }, + ) + + expect(response.status).toBe(200) + const body = await response.json() + const ids = body.map((item: { id: string }) => item.id) + const dirs = body.map((item: { directory: string }) => item.directory) + + expect(root.session.projectID).not.toBe(branch.projectID) + expect(project.id).toBe(root.session.projectID) + expect(ids).toContain(root.session.id) + expect(ids).toContain(branch.id) + expect(dirs).toContain(worktree) + expect(body.some((item: { title: string }) => item.title === "other-project-session")).toBe(false) + } finally { + Config.get = share + } + } finally { + await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow() + } + }) +}) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 05d6de04b1b..bb2d640bc2e 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,14 +1,33 @@ -import { describe, expect, test } from "bun:test" +// kilocode_change - new file +import { $ } from "bun" +import { afterEach, describe, expect, mock, test } from "bun:test" +import path from "path" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" -import { Session } from "../../src/session" import { Log } from "../../src/util/log" +import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" +mock.module("@/kilo-sessions/remote-sender", () => ({ + RemoteSender: { + create() { + return { + handle() {}, + dispose() {}, + } + }, + }, +})) + Log.init({ print: false }) +afterEach(async () => { + await resetDatabase() +}) + describe("Session.listGlobal", () => { test("lists sessions across projects with project metadata", async () => { + const { Session } = await import("../../src/session/index") await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) @@ -40,6 +59,7 @@ describe("Session.listGlobal", () => { }) test("excludes archived sessions by default", async () => { + const { Session } = await import("../../src/session/index") await using tmp = await tmpdir({ git: true }) const archived = await Instance.provide({ @@ -64,6 +84,7 @@ describe("Session.listGlobal", () => { }) test("supports cursor pagination", async () => { + const { Session } = await import("../../src/session/index") await using tmp = await tmpdir({ git: true }) const first = await Instance.provide({ @@ -78,12 +99,48 @@ describe("Session.listGlobal", () => { const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })] expect(page.length).toBe(1) - expect(page[0].id).toBe(second.id) + expect(page[0]!.id).toBe(second.id) - const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })] + 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) }) + + test("filters by project family across worktrees when project IDs drift", async () => { + const { Session } = await import("../../src/session/index") + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree") + + try { + await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() + await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id") + + const root = await Instance.provide({ + directory: first.path, + fn: async () => Session.create({ title: "root-session" }), + }) + const branch = await Instance.provide({ + directory: worktree, + fn: async () => Session.create({ title: "worktree-session" }), + }) + const other = await Instance.provide({ + directory: second.path, + fn: async () => Session.create({ title: "other-session" }), + }) + + const sessions = [...Session.listGlobal({ projectID: root.projectID, roots: true, limit: 200 })] + const ids = sessions.map((session) => session.id) + + expect(root.projectID).not.toBe(branch.projectID) + expect(ids).toContain(root.id) + expect(ids).toContain(branch.id) + expect(ids).not.toContain(other.id) + expect(sessions.find((session) => session.id === branch.id)?.directory).toBe(worktree) + } finally { + await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow() + } + }) }) diff --git a/packages/opencode/test/server/permission-allow-everything.test.ts b/packages/opencode/test/server/permission-allow-everything.test.ts new file mode 100644 index 00000000000..b7f39e7eba1 --- /dev/null +++ b/packages/opencode/test/server/permission-allow-everything.test.ts @@ -0,0 +1,78 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { PermissionNext } from "../../src/permission/next" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Session } from "../../src/session" +import { tmpdir } from "../fixture/fixture" + +describe("permission.allowEverything endpoint", () => { + test("disables session-scoped allow-all without touching global config", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.App() + const session = await Session.create({ + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + await PermissionNext.allowEverything({ + enable: true, + sessionID: session.id, + }) + + const response = await app.request("/permission/allow-everything", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-kilo-directory": tmp.path, + }, + body: JSON.stringify({ enable: false, sessionID: session.id }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toBe(true) + + const next = await Session.get(session.id) + expect(next.permission ?? []).toEqual([]) + + const pending = PermissionNext.ask({ + id: "permission_session_disable", + sessionID: session.id, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_session_disable", + reply: "reject", + }) + + await expect(pending).rejects.toBeInstanceOf(PermissionNext.RejectedError) + + const other = await Session.create({}) + const blocked = PermissionNext.ask({ + id: "permission_other_session", + sessionID: other.id, + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + ruleset: [], + }) + + await PermissionNext.reply({ + requestID: "permission_other_session", + reply: "reject", + }) + + await expect(blocked).rejects.toBeInstanceOf(PermissionNext.RejectedError) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/recall.test.ts b/packages/opencode/test/tool/recall.test.ts new file mode 100644 index 00000000000..677923a55e2 --- /dev/null +++ b/packages/opencode/test/tool/recall.test.ts @@ -0,0 +1,148 @@ +// kilocode_change - new file +import { afterEach, describe, expect, mock, test } from "bun:test" +import { $ } from "bun" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Config } from "../../src/config/config" +import { RecallTool } from "../../src/tool/recall" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" +import type { Tool } from "../../src/tool/tool" + +mock.module("@/kilo-sessions/remote-sender", () => ({ + RemoteSender: { + create() { + return { + handle() {}, + dispose() {}, + } + }, + }, +})) + +const ctx: Tool.Context = { + sessionID: "ses_test", + messageID: "msg_test", + callID: "call_test", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +afterEach(async () => { + await resetDatabase() +}) + +describe("tool.recall", () => { + test("search is limited to the current project worktrees", async () => { + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree") + + try { + await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() + await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id") + + const share = Config.get + Config.get = async () => ({ share: "manual" }) as Awaited> + + try { + const { Session } = await import("../../src/session/index") + await Instance.provide({ + directory: first.path, + fn: async () => Session.create({ title: "search-target root" }), + }) + await Instance.provide({ + directory: worktree, + fn: async () => Session.create({ title: "search-target worktree" }), + }) + await Instance.provide({ + directory: second.path, + fn: async () => Session.create({ title: "search-target other" }), + }) + + const result = await Instance.provide({ + directory: first.path, + fn: async () => { + const tool = await RecallTool.init() + return tool.execute({ mode: "search", query: "search-target" }, ctx) + }, + }) + + expect(result.output).toContain("search-target root") + expect(result.output).toContain("search-target worktree") + expect(result.output).not.toContain("search-target other") + } finally { + Config.get = share + } + } finally { + await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow() + } + }) + + test("read rejects sessions from another project", async () => { + await using first = await tmpdir({ git: true }) + await using second = await tmpdir({ git: true }) + + const share = Config.get + Config.get = async () => ({ share: "manual" }) as Awaited> + + try { + const { Session } = await import("../../src/session/index") + const session = await Instance.provide({ + directory: second.path, + fn: async () => Session.create({ title: "other-project-session" }), + }) + + const err = await Instance.provide({ + directory: first.path, + fn: async () => { + const tool = await RecallTool.init() + return tool.execute({ mode: "read", sessionID: session.id }, ctx).catch((error) => error as Error) + }, + }) + + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("belongs to a different workspace") + } finally { + Config.get = share + } + }) + + test("read allows sessions from sibling worktrees when project IDs drift", async () => { + await using first = await tmpdir({ git: true }) + const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree") + + try { + await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() + await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id") + + const share = Config.get + Config.get = async () => ({ share: "manual" }) as Awaited> + + try { + const { Session } = await import("../../src/session/index") + const session = await Instance.provide({ + directory: worktree, + fn: async () => Session.create({ title: "worktree readable" }), + }) + + const result = await Instance.provide({ + directory: first.path, + fn: async () => { + const tool = await RecallTool.init() + return tool.execute({ mode: "read", sessionID: session.id }, ctx) + }, + }) + + expect(result.output).toContain("# Session: worktree readable") + } finally { + Config.get = share + } + } finally { + await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow() + } + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index fa72a0bb006..84596af2689 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -104,6 +104,8 @@ import type { PartUpdateErrors, PartUpdateResponses, PathGetResponses, + PermissionAllowEverythingErrors, + PermissionAllowEverythingResponses, PermissionListResponses, PermissionReplyErrors, PermissionReplyResponses, @@ -1096,6 +1098,8 @@ export class Session extends HeyApiClient { parameters?: { directory?: string workspace?: string + projectID?: string + worktrees?: boolean roots?: boolean start?: number cursor?: number @@ -1112,6 +1116,8 @@ export class Session extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "projectID" }, + { in: "query", key: "worktrees" }, { in: "query", key: "roots" }, { in: "query", key: "start" }, { in: "query", key: "cursor" }, @@ -2620,6 +2626,51 @@ export class Permission extends HeyApiClient { }) } + /** + * Allow everything + * + * Enable or disable allowing all permissions without prompts. + */ + public allowEverything( + parameters?: { + directory?: string + workspace?: string + enable?: boolean + requestID?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "enable" }, + { in: "body", key: "requestID" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + PermissionAllowEverythingResponses, + PermissionAllowEverythingErrors, + ThrowOnError + >({ + url: "/permission/allow-everything", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * List pending permissions * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 44dfca17bb5..0b16c3d6826 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1780,6 +1780,7 @@ export type GlobalSession = { diff?: string } project: ProjectSummary | null + worktreeName?: string } export type McpResource = { @@ -2866,6 +2867,14 @@ export type ExperimentalSessionListData = { */ directory?: string workspace?: string + /** + * Filter sessions by project ID + */ + projectID?: string + /** + * Restrict sessions to the current repo worktree family or current directory + */ + worktrees?: boolean /** * Only return root sessions (no parentID) */ @@ -4077,6 +4086,43 @@ export type PermissionSaveAlwaysRulesResponses = { export type PermissionSaveAlwaysRulesResponse = PermissionSaveAlwaysRulesResponses[keyof PermissionSaveAlwaysRulesResponses] +export type PermissionAllowEverythingData = { + body?: { + enable: boolean + requestID?: string + sessionID?: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/permission/allow-everything" +} + +export type PermissionAllowEverythingErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PermissionAllowEverythingError = PermissionAllowEverythingErrors[keyof PermissionAllowEverythingErrors] + +export type PermissionAllowEverythingResponses = { + /** + * Success + */ + 200: boolean +} + +export type PermissionAllowEverythingResponse = + PermissionAllowEverythingResponses[keyof PermissionAllowEverythingResponses] + export type PermissionListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 65994bb0b9e..dd5a9164a34 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1833,6 +1833,22 @@ "type": "string" } }, + { + "in": "query", + "name": "projectID", + "schema": { + "type": "string" + }, + "description": "Filter sessions by project ID" + }, + { + "in": "query", + "name": "worktrees", + "schema": { + "type": "boolean" + }, + "description": "Restrict sessions to the current repo worktree family or current directory" + }, { "in": "query", "name": "roots", @@ -14253,6 +14269,9 @@ "type": "null" } ] + }, + "worktreeName": { + "type": "string" } }, "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] diff --git a/script/check-opencode-annotations.ts b/script/check-opencode-annotations.ts index 175f2b2159c..b953fd44d8b 100644 --- a/script/check-opencode-annotations.ts +++ b/script/check-opencode-annotations.ts @@ -37,6 +37,11 @@ const base = baseIdx !== -1 ? args[baseIdx + 1] : "origin/main" function run(cmd: string, args: string[]) { const result = spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8" }) + if (result.status !== 0) { + const msg = result.stderr?.trim() || result.stdout?.trim() || "unknown error" + console.error(`Command failed: ${cmd} ${args.join(" ")}\n${msg}`) + process.exit(1) + } return result.stdout?.trim() ?? "" }