diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9048fa895ad..b2553e4c024 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -2,7 +2,6 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { useSpring } from "@opencode-ai/ui/motion-spring" import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" -import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" import { @@ -411,7 +410,6 @@ export const PromptInput: Component = (props) => { } } - const isFocused = createFocusSignal(() => editorRef) const escBlur = () => platform.platform === "desktop" && platform.os === "macos" const pick = () => fileInputRef?.click() @@ -1014,7 +1012,6 @@ export const PromptInput: Component = (props) => { const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ editor: () => editorRef, - isFocused, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), focusEditor: () => { diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts index d8ae43d13b3..43f7d425bd1 100644 --- a/packages/app/src/components/prompt-input/attachments.test.ts +++ b/packages/app/src/components/prompt-input/attachments.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { attachmentMime } from "./files" +import { pasteMode } from "./paste" describe("attachmentMime", () => { test("keeps PDFs when the browser reports the mime", async () => { @@ -22,3 +23,22 @@ describe("attachmentMime", () => { expect(await attachmentMime(file)).toBeUndefined() }) }) + +describe("pasteMode", () => { + test("uses native paste for short single-line text", () => { + expect(pasteMode("hello world")).toBe("native") + }) + + test("uses manual paste for multiline text", () => { + expect( + pasteMode(`{ + "ok": true +}`), + ).toBe("manual") + expect(pasteMode("a\r\nb")).toBe("manual") + }) + + test("uses manual paste for large text", () => { + expect(pasteMode("x".repeat(8000))).toBe("manual") + }) +}) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index b465ea5db8c..eca508c6ce6 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -5,8 +5,7 @@ import { useLanguage } from "@/context/language" import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" import { attachmentMime } from "./files" -const LARGE_PASTE_CHARS = 8000 -const LARGE_PASTE_BREAKS = 120 +import { normalizePaste, pasteMode } from "./paste" function dataUrl(file: File, mime: string) { return new Promise((resolve) => { @@ -25,20 +24,8 @@ function dataUrl(file: File, mime: string) { }) } -function largePaste(text: string) { - if (text.length >= LARGE_PASTE_CHARS) return true - let breaks = 0 - for (const char of text) { - if (char !== "\n") continue - breaks += 1 - if (breaks >= LARGE_PASTE_BREAKS) return true - } - return false -} - type PromptAttachmentsInput = { editor: () => HTMLDivElement | undefined - isFocused: () => boolean isDialogActive: () => boolean setDraggingType: (type: "image" | "@mention" | null) => void focusEditor: () => void @@ -91,7 +78,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const handlePaste = async (event: ClipboardEvent) => { - if (!input.isFocused()) return const clipboardData = event.clipboardData if (!clipboardData) return @@ -126,16 +112,23 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { if (!plainText) return - if (largePaste(plainText)) { - if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return + const text = normalizePaste(plainText) + + const put = () => { + if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true input.focusEditor() - if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return + return input.addPart({ type: "text", content: text, start: 0, end: 0 }) + } + + if (pasteMode(text) === "manual") { + put() + return } - const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText) + const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text) if (inserted) return - input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) + put() } const handleGlobalDragOver = (event: DragEvent) => { diff --git a/packages/app/src/components/prompt-input/paste.ts b/packages/app/src/components/prompt-input/paste.ts new file mode 100644 index 00000000000..6787d503090 --- /dev/null +++ b/packages/app/src/components/prompt-input/paste.ts @@ -0,0 +1,24 @@ +const LARGE_PASTE_CHARS = 8000 +const LARGE_PASTE_BREAKS = 120 + +function largePaste(text: string) { + if (text.length >= LARGE_PASTE_CHARS) return true + let breaks = 0 + for (const char of text) { + if (char !== "\n") continue + breaks += 1 + if (breaks >= LARGE_PASTE_BREAKS) return true + } + return false +} + +export function normalizePaste(text: string) { + if (!text.includes("\r")) return text + return text.replace(/\r\n?/g, "\n") +} + +export function pasteMode(text: string) { + if (largePaste(text)) return "manual" + if (text.includes("\n") || text.includes("\r")) return "manual" + return "native" +} diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 297a7ec0212..ef075d732a6 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -159,7 +159,7 @@ async function createToolContext(agent: Agent.Info) { for (const pattern of req.patterns) { const rule = PermissionNext.evaluate(req.permission, pattern, ruleset) if (rule.action === "deny") { - throw new PermissionNext.DeniedError(ruleset) + throw new PermissionNext.DeniedError({ ruleset }) } } }, diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 23acff73379..4aec46befac 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,5 +1,9 @@ import { Layer, ManagedRuntime } from "effect" import { AccountService } from "@/account/service" import { AuthService } from "@/auth/service" +import { PermissionService } from "@/permission/service" +import { QuestionService } from "@/question/service" -export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer)) +export const runtime = ManagedRuntime.make( + Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer), +) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index fe7fd63ef12..b3d1db7eb0c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -47,11 +47,6 @@ process.on("uncaughtException", (e) => { }) }) -// Ensure the process exits on terminal hangup (eg. closing the terminal tab). -// Without this, long-running commands like `serve` block on a never-resolving -// promise and survive as orphaned processes. -process.on("SIGHUP", () => process.exit()) - let cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) .scriptName("opencode") diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts deleted file mode 100644 index 565ccf20d1a..00000000000 --- a/packages/opencode/src/permission/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { SessionID, MessageID } from "@/session/schema" -import z from "zod" -import { Log } from "../util/log" -import { Plugin } from "../plugin" -import { Instance } from "../project/instance" -import { Wildcard } from "../util/wildcard" -import { PermissionID } from "./schema" - -export namespace Permission { - const log = Log.create({ service: "permission" }) - - function toKeys(pattern: Info["pattern"], type: string): string[] { - return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern] - } - - function covered(keys: string[], approved: Map): boolean { - return keys.every((k) => { - for (const p of approved.keys()) { - if (Wildcard.match(k, p)) return true - } - return false - }) - } - - export const Info = z - .object({ - id: PermissionID.zod, - type: z.string(), - pattern: z.union([z.string(), z.array(z.string())]).optional(), - sessionID: SessionID.zod, - messageID: MessageID.zod, - callID: z.string().optional(), - message: z.string(), - metadata: z.record(z.string(), z.any()), - time: z.object({ - created: z.number(), - }), - }) - .meta({ - ref: "Permission", - }) - export type Info = z.infer - - interface PendingEntry { - info: Info - resolve: () => void - reject: (e: any) => void - } - - export const Event = { - Updated: BusEvent.define("permission.updated", Info), - Replied: BusEvent.define( - "permission.replied", - z.object({ - sessionID: SessionID.zod, - permissionID: PermissionID.zod, - response: z.string(), - }), - ), - } - - const state = Instance.state( - () => ({ - pending: new Map>(), - approved: new Map>(), - }), - async (state) => { - for (const session of state.pending.values()) { - for (const item of session.values()) { - item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata)) - } - } - }, - ) - - export function pending() { - return state().pending - } - - export function list() { - const { pending } = state() - const result: Info[] = [] - for (const session of pending.values()) { - for (const item of session.values()) { - result.push(item.info) - } - } - return result.sort((a, b) => a.id.localeCompare(b.id)) - } - - export async function ask(input: { - type: Info["type"] - message: Info["message"] - pattern?: Info["pattern"] - callID?: Info["callID"] - sessionID: Info["sessionID"] - messageID: Info["messageID"] - metadata: Info["metadata"] - }) { - const { pending, approved } = state() - log.info("asking", { - sessionID: input.sessionID, - messageID: input.messageID, - toolCallID: input.callID, - pattern: input.pattern, - }) - const approvedForSession = approved.get(input.sessionID) - const keys = toKeys(input.pattern, input.type) - if (approvedForSession && covered(keys, approvedForSession)) return - const info: Info = { - id: PermissionID.ascending(), - type: input.type, - pattern: input.pattern, - sessionID: input.sessionID, - messageID: input.messageID, - callID: input.callID, - message: input.message, - metadata: input.metadata, - time: { - created: Date.now(), - }, - } - - switch ( - await Plugin.trigger("permission.ask", info, { - status: "ask", - }).then((x) => x.status) - ) { - case "deny": - throw new RejectedError(info.sessionID, info.id, info.callID, info.metadata) - case "allow": - return - } - - if (!pending.has(input.sessionID)) pending.set(input.sessionID, new Map()) - return new Promise((resolve, reject) => { - pending.get(input.sessionID)!.set(info.id, { - info, - resolve, - reject, - }) - Bus.publish(Event.Updated, info) - }) - } - - export const Response = z.enum(["once", "always", "reject"]) - export type Response = z.infer - - export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) { - log.info("response", input) - const { pending, approved } = state() - const session = pending.get(input.sessionID) - const match = session?.get(input.permissionID) - if (!session || !match) return - session.delete(input.permissionID) - if (session.size === 0) pending.delete(input.sessionID) - Bus.publish(Event.Replied, { - sessionID: input.sessionID, - permissionID: input.permissionID, - response: input.response, - }) - if (input.response === "reject") { - match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata)) - return - } - match.resolve() - if (input.response === "always") { - if (!approved.has(input.sessionID)) approved.set(input.sessionID, new Map()) - const approvedSession = approved.get(input.sessionID)! - const approveKeys = toKeys(match.info.pattern, match.info.type) - for (const k of approveKeys) { - approvedSession.set(k, true) - } - const items = pending.get(input.sessionID) - if (!items) return - const toRespond: Info[] = [] - for (const item of items.values()) { - const itemKeys = toKeys(item.info.pattern, item.info.type) - if (covered(itemKeys, approvedSession)) { - toRespond.push(item.info) - } - } - for (const item of toRespond) { - respond({ - sessionID: item.sessionID, - permissionID: item.id, - response: input.response, - }) - } - } - } - - export class RejectedError extends Error { - constructor( - public readonly sessionID: SessionID, - public readonly permissionID: PermissionID, - public readonly toolCallID?: string, - public readonly metadata?: Record, - public readonly reason?: string, - ) { - super( - reason !== undefined - ? reason - : `The user rejected permission to use this specific tool call. You may try again with different parameters.`, - ) - } - } -} diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 3ef3a02304d..7fcd40eea0b 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -1,21 +1,20 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" +import { runtime } from "@/effect/runtime" import { Config } from "@/config/config" -import { SessionID, MessageID } from "@/session/schema" -import { PermissionID } from "./schema" -import { Instance } from "@/project/instance" -import { Database, eq } from "@/storage/db" -import { PermissionTable } from "@/session/session.sql" import { fn } from "@/util/fn" -import { Log } from "@/util/log" -import { ProjectID } from "@/project/schema" import { Wildcard } from "@/util/wildcard" +import { Effect } from "effect" import os from "os" -import z from "zod" +import * as S from "./service" +import type { + Action as ActionType, + PermissionError, + Reply as ReplyType, + Request as RequestType, + Rule as RuleType, + Ruleset as RulesetType, +} from "./service" export namespace PermissionNext { - const log = Log.create({ service: "permission" }) - function expand(pattern: string): string { if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) if (pattern === "~") return os.homedir() @@ -24,26 +23,26 @@ export namespace PermissionNext { return pattern } - export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", - }) - export type Action = z.infer - - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer + function runPromise(f: (service: S.PermissionService.Api) => Effect.Effect) { + return runtime.runPromise(S.PermissionService.use(f)) + } - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer + export const Action = S.Action + export type Action = ActionType + export const Rule = S.Rule + export type Rule = RuleType + export const Ruleset = S.Ruleset + export type Ruleset = RulesetType + export const Request = S.Request + export type Request = RequestType + export const Reply = S.Reply + export type Reply = ReplyType + export const Approval = S.Approval + export const Event = S.Event + export const Service = S.PermissionService + export const RejectedError = S.RejectedError + export const CorrectedError = S.CorrectedError + export const DeniedError = S.DeniedError export function fromConfig(permission: Config.Permission) { const ruleset: Ruleset = [] @@ -67,178 +66,16 @@ export namespace PermissionNext { return rulesets.flat() } - export const Request = z - .object({ - id: PermissionID.zod, - sessionID: SessionID.zod, - permission: z.string(), - patterns: z.string().array(), - metadata: z.record(z.string(), z.any()), - always: z.string().array(), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ - ref: "PermissionRequest", - }) - - export type Request = z.infer + export const ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input))) - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer + export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input))) - export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), - }) - - export const Event = { - Asked: BusEvent.define("permission.asked", Request), - Replied: BusEvent.define( - "permission.replied", - z.object({ - sessionID: SessionID.zod, - requestID: PermissionID.zod, - reply: Reply, - }), - ), - } - - interface PendingEntry { - info: Request - resolve: () => void - reject: (e: any) => void + export async function list() { + return runPromise((service) => service.list()) } - const state = Instance.state(() => { - const projectID = Instance.project.id - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(), - ) - const stored = row?.data ?? ([] as Ruleset) - - return { - pending: new Map(), - approved: stored, - } - }) - - export const ask = fn( - Request.partial({ id: true }).extend({ - ruleset: Ruleset, - }), - async (input) => { - const s = await state() - const { ruleset, ...request } = input - for (const pattern of request.patterns ?? []) { - const rule = evaluate(request.permission, pattern, ruleset, s.approved) - 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))) - if (rule.action === "ask") { - const id = input.id ?? PermissionID.ascending() - return new Promise((resolve, reject) => { - const info: Request = { - id, - ...request, - } - s.pending.set(id, { - info, - resolve, - reject, - }) - Bus.publish(Event.Asked, info) - }) - } - if (rule.action === "allow") continue - } - }, - ) - - export const reply = fn( - z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), - }), - async (input) => { - const s = await state() - const existing = s.pending.get(input.requestID) - if (!existing) return - s.pending.delete(input.requestID) - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - reply: input.reply, - }) - if (input.reply === "reject") { - existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError()) - // Reject all other pending permissions for this session - const sessionID = existing.info.sessionID - for (const [id, pending] of s.pending) { - if (pending.info.sessionID === sessionID) { - s.pending.delete(id) - Bus.publish(Event.Replied, { - sessionID: pending.info.sessionID, - requestID: pending.info.id, - reply: "reject", - }) - pending.reject(new RejectedError()) - } - } - return - } - if (input.reply === "once") { - existing.resolve() - return - } - if (input.reply === "always") { - for (const pattern of existing.info.always) { - s.approved.push({ - permission: existing.info.permission, - pattern, - action: "allow", - }) - } - - existing.resolve() - - const sessionID = existing.info.sessionID - for (const [id, pending] of s.pending) { - if (pending.info.sessionID !== sessionID) continue - const ok = pending.info.patterns.every( - (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow", - ) - if (!ok) continue - s.pending.delete(id) - Bus.publish(Event.Replied, { - sessionID: pending.info.sessionID, - requestID: pending.info.id, - reply: "always", - }) - pending.resolve() - } - - // TODO: we don't save the permission ruleset to disk yet until there's - // UI to manage it - // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved }) - // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run() - return - } - }, - ) - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - const merged = merge(...rulesets) - log.info("evaluate", { permission, pattern, ruleset: merged }) - const match = merged.findLast( - (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), - ) - return match ?? { action: "ask", permission, pattern: "*" } + return S.evaluate(permission, pattern, ...rulesets) } const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"] @@ -247,39 +84,10 @@ export namespace PermissionNext { const result = new Set() for (const tool of tools) { const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - - const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission)) + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) if (!rule) continue if (rule.pattern === "*" && rule.action === "deny") result.add(tool) } return result } - - /** User rejected without message - halts execution */ - export class RejectedError extends Error { - constructor() { - super(`The user rejected permission to use this specific tool call.`) - } - } - - /** User rejected with message - continues with guidance */ - export class CorrectedError extends Error { - constructor(message: string) { - super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`) - } - } - - /** Auto-rejected by config rule - halts execution */ - export class DeniedError extends Error { - constructor(public readonly ruleset: Ruleset) { - super( - `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`, - ) - } - } - - export async function list() { - const s = await state() - return Array.from(s.pending.values(), (x) => x.info) - } } diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index c3242b714a9..bfa2b495779 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -2,16 +2,16 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" +import { Newtype } from "@/util/schema" -const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID")) +export class PermissionID extends Newtype()("PermissionID", Schema.String) { + static make(id: string): PermissionID { + return this.makeUnsafe(id) + } -export type PermissionID = typeof permissionIdSchema.Type + static ascending(id?: string): PermissionID { + return this.makeUnsafe(Identifier.ascending("permission", id)) + } -export const PermissionID = permissionIdSchema.pipe( - withStatics((schema: typeof permissionIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)), - zod: Identifier.schema("permission").pipe(z.custom()), - })), -) + static readonly zod = Identifier.schema("permission") as unknown as z.ZodType +} diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts new file mode 100644 index 00000000000..2782c0aba18 --- /dev/null +++ b/packages/opencode/src/permission/service.ts @@ -0,0 +1,265 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Instance } from "@/project/instance" +import { ProjectID } from "@/project/schema" +import { MessageID, SessionID } from "@/session/schema" +import { PermissionTable } from "@/session/session.sql" +import { Database, eq } from "@/storage/db" +import { InstanceState } from "@/util/instance-state" +import { Log } from "@/util/log" +import { Wildcard } from "@/util/wildcard" +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import z from "zod" +import { PermissionID } from "./schema" + +const log = Log.create({ service: "permission" }) + +export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", +}) +export type Action = z.infer + +export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) +export type Rule = z.infer + +export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", +}) +export type Ruleset = z.infer + +export const Request = z + .object({ + id: PermissionID.zod, + sessionID: SessionID.zod, + permission: z.string(), + patterns: z.string().array(), + metadata: z.record(z.string(), z.any()), + always: z.string().array(), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ + ref: "PermissionRequest", + }) +export type Request = z.infer + +export const Reply = z.enum(["once", "always", "reject"]) +export type Reply = z.infer + +export const Approval = z.object({ + projectID: ProjectID.zod, + patterns: z.string().array(), +}) + +export const Event = { + Asked: BusEvent.define("permission.asked", Request), + Replied: BusEvent.define( + "permission.replied", + z.object({ + sessionID: SessionID.zod, + requestID: PermissionID.zod, + reply: Reply, + }), + ), +} + +export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } +} + +export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { + feedback: Schema.String, +}) { + override get message() { + return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` + } +} + +export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { + ruleset: Schema.Any, +}) { + override get message() { + return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` + } +} + +export type PermissionError = DeniedError | RejectedError | CorrectedError + +interface PendingEntry { + info: Request + deferred: Deferred.Deferred +} + +type State = { + pending: Map + approved: Ruleset +} + +export const AskInput = Request.partial({ id: true }).extend({ + ruleset: Ruleset, +}) + +export const ReplyInput = z.object({ + requestID: PermissionID.zod, + reply: Reply, + message: z.string().optional(), +}) + +export declare namespace PermissionService { + export interface Api { + readonly ask: (input: z.infer) => Effect.Effect + readonly reply: (input: z.infer) => Effect.Effect + readonly list: () => Effect.Effect + } +} + +export class PermissionService extends ServiceMap.Service()( + "@opencode/PermissionNext", +) { + static readonly layer = Layer.effect( + PermissionService, + Effect.gen(function* () { + const instanceState = yield* InstanceState.make(() => + Effect.sync(() => { + const row = Database.use((db) => + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Instance.project.id)).get(), + ) + return { + pending: new Map(), + approved: row?.data ?? [], + } + }), + ) + + const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer) { + const state = yield* InstanceState.get(instanceState) + const { ruleset, ...request } = input + let pending = false + + for (const pattern of request.patterns) { + const rule = evaluate(request.permission, pattern, ruleset, state.approved) + log.info("evaluated", { permission: request.permission, pattern, action: rule }) + if (rule.action === "deny") { + return yield* new DeniedError({ + ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + }) + } + if (rule.action === "allow") continue + pending = true + } + + if (!pending) return + + const id = request.id ?? PermissionID.ascending() + const info: Request = { + id, + ...request, + } + log.info("asking", { id, permission: info.permission, patterns: info.patterns }) + + const deferred = yield* Deferred.make() + state.pending.set(id, { info, deferred }) + void Bus.publish(Event.Asked, info) + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + state.pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer) { + const state = yield* InstanceState.get(instanceState) + const existing = state.pending.get(input.requestID) + if (!existing) return + + state.pending.delete(input.requestID) + void Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + + if (input.reply === "reject") { + yield* Deferred.fail( + existing.deferred, + input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + ) + + for (const [id, item] of state.pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + state.pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "reject", + }) + yield* Deferred.fail(item.deferred, new RejectedError()) + } + return + } + + yield* Deferred.succeed(existing.deferred, undefined) + if (input.reply === "once") return + + for (const pattern of existing.info.always) { + state.approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + + for (const [id, item] of state.pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + const ok = item.info.patterns.every( + (pattern) => evaluate(item.info.permission, pattern, state.approved).action === "allow", + ) + if (!ok) continue + state.pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "always", + }) + yield* Deferred.succeed(item.deferred, undefined) + } + + // TODO: we don't save the permission ruleset to disk yet until there's + // UI to manage it + // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved }) + // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run() + }) + + const list = Effect.fn("PermissionService.list")(function* () { + const state = yield* InstanceState.get(instanceState) + return Array.from(state.pending.values(), (item) => item.info) + }) + + return PermissionService.of({ ask, reply, list }) + }), + ) +} + +export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + const merged = rulesets.flat() + log.info("evaluate", { permission, pattern, ruleset: merged }) + const match = merged.findLast( + (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern), + ) + return match ?? { action: "ask", permission, pattern: "*" } +} diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 3945c63ce28..ddb4d9046a6 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -309,6 +309,24 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14" } + const parts = await sdk.session + .message({ + path: { + id: incoming.message.sessionID, + messageID: incoming.message.id, + }, + query: { + directory: input.directory, + }, + throwOnError: true, + }) + .catch(() => undefined) + + if (parts?.data.parts?.some((part) => part.type === "compaction")) { + output.headers["x-initiator"] = "agent" + return + } + const session = await sdk.session .get({ path: { diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 4b5ac1777a8..2d9cec5cd85 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -79,18 +79,17 @@ export class ProviderAuthService extends ServiceMap.Service - Effect.promise(async () => { - const methods = pipe( - await Plugin.list(), - filter((x) => x.auth?.provider !== undefined), - map((x) => [x.auth!.provider, x.auth!] as const), - fromEntries(), - ) - return { methods, pending: new Map() } - }), - }) + const state = yield* InstanceState.make(() => + Effect.promise(async () => { + const methods = pipe( + await Plugin.list(), + filter((x) => x.auth?.provider !== undefined), + map((x) => [x.auth!.provider, x.auth!] as const), + fromEntries(), + ) + return { methods, pending: new Map() } + }), + ) const methods = Effect.fn("ProviderAuthService.methods")(function* () { const x = yield* InstanceState.get(state) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 92b001a6f69..349073197d7 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -47,7 +47,7 @@ import { ProviderTransform } from "./transform" import { Installation } from "../installation" import { ModelID, ProviderID } from "./schema" -const DEFAULT_CHUNK_TIMEOUT = 120_000 +const DEFAULT_CHUNK_TIMEOUT = 300_000 export namespace Provider { const log = Log.create({ service: "provider" }) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index cf52979fc88..6ace981a9f1 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,167 +1,44 @@ -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID } from "@/session/schema" -import { Instance } from "@/project/instance" -import { Log } from "@/util/log" -import z from "zod" -import { QuestionID } from "./schema" +import { Effect } from "effect" +import { runtime } from "@/effect/runtime" +import * as S from "./service" +import type { QuestionID } from "./schema" +import type { SessionID, MessageID } from "@/session/schema" + +function runPromise(f: (service: S.QuestionService.Service) => Effect.Effect) { + return runtime.runPromise(S.QuestionService.use(f)) +} export namespace Question { - const log = Log.create({ service: "question" }) - - export const Option = z - .object({ - label: z.string().describe("Display text (1-5 words, concise)"), - description: z.string().describe("Explanation of choice"), - }) - .meta({ - ref: "QuestionOption", - }) - export type Option = z.infer - - export const Info = z - .object({ - question: z.string().describe("Complete question"), - header: z.string().describe("Very short label (max 30 chars)"), - options: z.array(Option).describe("Available choices"), - multiple: z.boolean().optional().describe("Allow selecting multiple choices"), - custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), - }) - .meta({ - ref: "QuestionInfo", - }) - export type Info = z.infer - - export const Request = z - .object({ - id: QuestionID.zod, - sessionID: SessionID.zod, - questions: z.array(Info).describe("Questions to ask"), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ - ref: "QuestionRequest", - }) - export type Request = z.infer - - export const Answer = z.array(z.string()).meta({ - ref: "QuestionAnswer", - }) - export type Answer = z.infer - - export const Reply = z.object({ - answers: z - .array(Answer) - .describe("User answers in order of questions (each answer is an array of selected labels)"), - }) - export type Reply = z.infer - - export const Event = { - Asked: BusEvent.define("question.asked", Request), - Replied: BusEvent.define( - "question.replied", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - answers: z.array(Answer), - }), - ), - Rejected: BusEvent.define( - "question.rejected", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - }), - ), - } - - interface PendingEntry { - info: Request - resolve: (answers: Answer[]) => void - reject: (e: any) => void - } - - const state = Instance.state(async () => ({ - pending: new Map(), - })) + export const Option = S.Option + export type Option = S.Option + export const Info = S.Info + export type Info = S.Info + export const Request = S.Request + export type Request = S.Request + export const Answer = S.Answer + export type Answer = S.Answer + export const Reply = S.Reply + export type Reply = S.Reply + export const Event = S.Event + export const RejectedError = S.RejectedError export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - const s = await state() - const id = QuestionID.ascending() - - log.info("asking", { id, questions: input.questions.length }) - - return new Promise((resolve, reject) => { - const info: Request = { - id, - sessionID: input.sessionID, - questions: input.questions, - tool: input.tool, - } - s.pending.set(id, { - info, - resolve, - reject, - }) - Bus.publish(Event.Asked, info) - }) + return runPromise((service) => service.ask(input)) } export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { - const s = await state() - const existing = s.pending.get(input.requestID) - if (!existing) { - log.warn("reply for unknown request", { requestID: input.requestID }) - return - } - s.pending.delete(input.requestID) - - log.info("replied", { requestID: input.requestID, answers: input.answers }) - - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - answers: input.answers, - }) - - existing.resolve(input.answers) + return runPromise((service) => service.reply(input)) } export async function reject(requestID: QuestionID): Promise { - const s = await state() - const existing = s.pending.get(requestID) - if (!existing) { - log.warn("reject for unknown request", { requestID }) - return - } - s.pending.delete(requestID) - - log.info("rejected", { requestID }) - - Bus.publish(Event.Rejected, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - }) - - existing.reject(new RejectedError()) - } - - export class RejectedError extends Error { - constructor() { - super("The user dismissed this question") - } + return runPromise((service) => service.reject(requestID)) } - export async function list() { - return state().then((x) => Array.from(x.pending.values(), (x) => x.info)) + export async function list(): Promise { + return runPromise((service) => service.list()) } } diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 65e9ad07cbb..38b930af11d 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -2,16 +2,16 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" -import { withStatics } from "@/util/schema" +import { Newtype } from "@/util/schema" -const questionIdSchema = Schema.String.pipe(Schema.brand("QuestionID")) +export class QuestionID extends Newtype()("QuestionID", Schema.String) { + static make(id: string): QuestionID { + return this.makeUnsafe(id) + } -export type QuestionID = typeof questionIdSchema.Type + static ascending(id?: string): QuestionID { + return this.makeUnsafe(Identifier.ascending("question", id)) + } -export const QuestionID = questionIdSchema.pipe( - withStatics((schema: typeof questionIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("question", id)), - zod: Identifier.schema("question").pipe(z.custom()), - })), -) + static readonly zod = Identifier.schema("question") as unknown as z.ZodType +} diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts new file mode 100644 index 00000000000..30a47bee9ff --- /dev/null +++ b/packages/opencode/src/question/service.ts @@ -0,0 +1,181 @@ +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { SessionID, MessageID } from "@/session/schema" +import { InstanceState } from "@/util/instance-state" +import { Log } from "@/util/log" +import z from "zod" +import { QuestionID } from "./schema" + +const log = Log.create({ service: "question" }) + +// --- Zod schemas (re-exported by facade) --- + +export const Option = z + .object({ + label: z.string().describe("Display text (1-5 words, concise)"), + description: z.string().describe("Explanation of choice"), + }) + .meta({ ref: "QuestionOption" }) +export type Option = z.infer + +export const Info = z + .object({ + question: z.string().describe("Complete question"), + header: z.string().describe("Very short label (max 30 chars)"), + options: z.array(Option).describe("Available choices"), + multiple: z.boolean().optional().describe("Allow selecting multiple choices"), + custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), + }) + .meta({ ref: "QuestionInfo" }) +export type Info = z.infer + +export const Request = z + .object({ + id: QuestionID.zod, + sessionID: SessionID.zod, + questions: z.array(Info).describe("Questions to ask"), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ ref: "QuestionRequest" }) +export type Request = z.infer + +export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) +export type Answer = z.infer + +export const Reply = z.object({ + answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"), +}) +export type Reply = z.infer + +export const Event = { + Asked: BusEvent.define("question.asked", Request), + Replied: BusEvent.define( + "question.replied", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + answers: z.array(Answer), + }), + ), + Rejected: BusEvent.define( + "question.rejected", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + }), + ), +} + +export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } +} + +// --- Effect service --- + +interface PendingEntry { + info: Request + deferred: Deferred.Deferred +} + +export namespace QuestionService { + export interface Service { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } +} + +export class QuestionService extends ServiceMap.Service()( + "@opencode/Question", +) { + static readonly layer = Layer.effect( + QuestionService, + Effect.gen(function* () { + const instanceState = yield* InstanceState.make>(() => + Effect.succeed(new Map()), + ) + + const getPending = InstanceState.get(instanceState) + + const ask = Effect.fn("QuestionService.ask")(function* (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) { + const pending = yield* getPending + const id = QuestionID.ascending() + log.info("asking", { id, questions: input.questions.length }) + + const deferred = yield* Deferred.make() + const info: Request = { + id, + sessionID: input.sessionID, + questions: input.questions, + tool: input.tool, + } + pending.set(id, { info, deferred }) + Bus.publish(Event.Asked, info) + + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { + const pending = yield* getPending + const existing = pending.get(input.requestID) + if (!existing) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return + } + pending.delete(input.requestID) + log.info("replied", { requestID: input.requestID, answers: input.answers }) + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + answers: input.answers, + }) + yield* Deferred.succeed(existing.deferred, input.answers) + }) + + const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) { + const pending = yield* getPending + const existing = pending.get(requestID) + if (!existing) { + log.warn("reject for unknown request", { requestID }) + return + } + pending.delete(requestID) + log.info("rejected", { requestID }) + Bus.publish(Event.Rejected, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + }) + yield* Deferred.fail(existing.deferred, new RejectedError()) + }) + + const list = Effect.fn("QuestionService.list")(function* () { + const pending = yield* getPending + return Array.from(pending.values(), (x) => x.info) + }) + + return QuestionService.of({ ask, reply, reject, list }) + }), + ) +} diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index b37fefae69d..540643c4918 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,41 +1,38 @@ import { Schema } from "effect" import z from "zod" -import { withStatics } from "@/util/schema" import { Identifier } from "@/id/id" +import { withStatics } from "@/util/schema" -const sessionIdSchema = Schema.String.pipe(Schema.brand("SessionID")) - -export type SessionID = typeof sessionIdSchema.Type - -export const SessionID = sessionIdSchema.pipe( - withStatics((schema: typeof sessionIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - descending: (id?: string) => schema.makeUnsafe(Identifier.descending("session", id)), - zod: Identifier.schema("session").pipe(z.custom()), +export const SessionID = Schema.String.pipe( + Schema.brand("SessionID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)), + zod: Identifier.schema("session").pipe(z.custom>()), })), ) -const messageIdSchema = Schema.String.pipe(Schema.brand("MessageID")) +export type SessionID = Schema.Schema.Type -export type MessageID = typeof messageIdSchema.Type - -export const MessageID = messageIdSchema.pipe( - withStatics((schema: typeof messageIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("message", id)), - zod: Identifier.schema("message").pipe(z.custom()), +export const MessageID = Schema.String.pipe( + Schema.brand("MessageID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("message", id)), + zod: Identifier.schema("message").pipe(z.custom>()), })), ) -const partIdSchema = Schema.String.pipe(Schema.brand("PartID")) - -export type PartID = typeof partIdSchema.Type +export type MessageID = Schema.Schema.Type -export const PartID = partIdSchema.pipe( - withStatics((schema: typeof partIdSchema) => ({ - make: (id: string) => schema.makeUnsafe(id), - ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("part", id)), - zod: Identifier.schema("part").pipe(z.custom()), +export const PartID = Schema.String.pipe( + Schema.brand("PartID"), + withStatics((s) => ({ + make: (id: string) => s.makeUnsafe(id), + ascending: (id?: string) => s.makeUnsafe(Identifier.ascending("part", id)), + zod: Identifier.schema("part").pipe(z.custom>()), })), ) + +export type PartID = Schema.Schema.Type diff --git a/packages/opencode/src/util/instance-state.ts b/packages/opencode/src/util/instance-state.ts index 5d0ffbf7930..4e5d36cf488 100644 --- a/packages/opencode/src/util/instance-state.ts +++ b/packages/opencode/src/util/instance-state.ts @@ -2,34 +2,39 @@ import { Effect, ScopedCache, Scope } from "effect" import { Instance } from "@/project/instance" -const TypeId = Symbol.for("@opencode/InstanceState") - -type Task = (key: string) => Effect.Effect - -const tasks = new Set() +type Disposer = (directory: string) => Effect.Effect +const disposers = new Set() + +const TypeId = "~opencode/InstanceState" + +/** + * Effect version of `Instance.state` — lazily-initialized, per-directory + * cached state for Effect services. + * + * Values are created on first access for a given directory and cached for + * subsequent reads. Concurrent access shares a single initialization — + * no duplicate work or races. Use `Effect.acquireRelease` in `init` if + * the value needs cleanup on disposal. + */ +export interface InstanceState { + readonly [TypeId]: typeof TypeId + readonly cache: ScopedCache.ScopedCache +} export namespace InstanceState { - export interface State { - readonly [TypeId]: typeof TypeId - readonly cache: ScopedCache.ScopedCache - } - - export const make = (input: { - lookup: (key: string) => Effect.Effect - release?: (value: A, key: string) => Effect.Effect - }): Effect.Effect, never, R | Scope.Scope> => + /** Create a new InstanceState with the given initializer. */ + export const make = ( + init: (directory: string) => Effect.Effect, + ): Effect.Effect>, never, R | Scope.Scope> => Effect.gen(function* () { const cache = yield* ScopedCache.make({ capacity: Number.POSITIVE_INFINITY, - lookup: (key) => - Effect.acquireRelease(input.lookup(key), (value) => - input.release ? input.release(value, key) : Effect.void, - ), + lookup: init, }) - const task: Task = (key) => ScopedCache.invalidate(cache, key) - tasks.add(task) - yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task))) + const disposer: Disposer = (directory) => ScopedCache.invalidate(cache, directory) + disposers.add(disposer) + yield* Effect.addFinalizer(() => Effect.sync(() => void disposers.delete(disposer))) return { [TypeId]: TypeId, @@ -37,15 +42,22 @@ export namespace InstanceState { } }) - export const get = (self: State) => ScopedCache.get(self.cache, Instance.directory) + /** Get the cached value for the current directory, initializing it if needed. */ + export const get = (self: InstanceState) => + Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory)) - export const has = (self: State) => ScopedCache.has(self.cache, Instance.directory) + /** Check whether a value exists for the current directory. */ + export const has = (self: InstanceState) => + Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory)) - export const invalidate = (self: State) => ScopedCache.invalidate(self.cache, Instance.directory) + /** Invalidate the cached value for the current directory. */ + export const invalidate = (self: InstanceState) => + Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory)) - export const dispose = (key: string) => + /** Invalidate the given directory across all InstanceState caches. */ + export const dispose = (directory: string) => Effect.all( - [...tasks].map((task) => task(key)), + [...disposers].map((disposer) => disposer(directory)), { concurrency: "unbounded" }, ) } diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts index 180f952d731..6a88dba539b 100644 --- a/packages/opencode/src/util/schema.ts +++ b/packages/opencode/src/util/schema.ts @@ -15,3 +15,39 @@ export const withStatics = >(methods: (schema: S) => M) => (schema: S): S & M => Object.assign(schema, methods(schema)) + +declare const NewtypeBrand: unique symbol +type NewtypeBrand = { readonly [NewtypeBrand]: Tag } + +/** + * Nominal wrapper for scalar types. The class itself is a valid schema — + * pass it directly to `Schema.decode`, `Schema.decodeEffect`, etc. + * + * @example + * class QuestionID extends Newtype()("QuestionID", Schema.String) { + * static make(id: string): QuestionID { + * return this.makeUnsafe(id) + * } + * } + * + * Schema.decodeEffect(QuestionID)(input) + */ +export function Newtype() { + return (tag: Tag, schema: S) => { + type Branded = NewtypeBrand + + abstract class Base { + declare readonly [NewtypeBrand]: Tag + + static makeUnsafe(value: Schema.Schema.Type): Self { + return value as unknown as Self + } + } + + Object.setPrototypeOf(Base, schema) + + return Base as unknown as (abstract new (_: never) => Branded) & { + readonly makeUnsafe: (value: Schema.Schema.Type) => Self + } & Omit, "makeUnsafe"> + } +} diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 7fd08189919..cd4775acea9 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,10 +1,32 @@ import { test, expect } from "bun:test" import os from "os" +import { Bus } from "../../src/bus" +import { runtime } from "../../src/effect/runtime" import { PermissionNext } from "../../src/permission/next" +import * as S from "../../src/permission/service" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -import { SessionID } from "../../src/session/schema" +import { MessageID, SessionID } from "../../src/session/schema" + +async function rejectAll(message?: string) { + for (const req of await PermissionNext.list()) { + await PermissionNext.reply({ + requestID: req.id, + reply: "reject", + message, + }) + } +} + +async function waitForPending(count: number) { + for (let i = 0; i < 20; i++) { + const list = await PermissionNext.list() + if (list.length === count) return list + await Bun.sleep(0) + } + return PermissionNext.list() +} // fromConfig tests @@ -511,6 +533,84 @@ test("ask - returns pending promise when action is ask", async () => { // Promise should be pending, not resolved expect(promise).toBeInstanceOf(Promise) // Don't await - just verify it returns a promise + await rejectAll() + await promise.catch(() => {}) + }, + }) +}) + +test("ask - adds request to pending list", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: { cmd: "ls" }, + always: ["ls"], + tool: { + messageID: MessageID.make("msg_test"), + callID: "call_test", + }, + ruleset: [], + }) + + const list = await PermissionNext.list() + expect(list).toHaveLength(1) + expect(list[0]).toMatchObject({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: { cmd: "ls" }, + always: ["ls"], + tool: { + messageID: MessageID.make("msg_test"), + callID: "call_test", + }, + }) + + await rejectAll() + await ask.catch(() => {}) + }, + }) +}) + +test("ask - publishes asked event", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + let seen: PermissionNext.Request | undefined + const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { + seen = event.properties + }) + + const ask = PermissionNext.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: { cmd: "ls" }, + always: ["ls"], + tool: { + messageID: MessageID.make("msg_test"), + callID: "call_test", + }, + ruleset: [], + }) + + expect(await PermissionNext.list()).toHaveLength(1) + expect(seen).toBeDefined() + expect(seen).toMatchObject({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + }) + + unsub() + await rejectAll() + await ask.catch(() => {}) }, }) }) @@ -532,6 +632,8 @@ test("reply - once resolves the pending ask", async () => { ruleset: [], }) + await waitForPending(1) + await PermissionNext.reply({ requestID: PermissionID.make("per_test1"), reply: "once", @@ -557,6 +659,8 @@ test("reply - reject throws RejectedError", async () => { ruleset: [], }) + await waitForPending(1) + await PermissionNext.reply({ requestID: PermissionID.make("per_test2"), reply: "reject", @@ -567,6 +671,36 @@ test("reply - reject throws RejectedError", async () => { }) }) +test("reply - reject with message throws CorrectedError", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + id: PermissionID.make("per_test2b"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(1) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test2b"), + reply: "reject", + message: "Use a safer command", + }) + + const err = await ask.catch((err) => err) + expect(err).toBeInstanceOf(PermissionNext.CorrectedError) + expect(err.message).toContain("Use a safer command") + }, + }) +}) + test("reply - always persists approval and resolves", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -582,6 +716,8 @@ test("reply - always persists approval and resolves", async () => { ruleset: [], }) + await waitForPending(1) + await PermissionNext.reply({ requestID: PermissionID.make("per_test3"), reply: "always", @@ -633,6 +769,8 @@ test("reply - reject cancels all pending for same session", async () => { ruleset: [], }) + await waitForPending(2) + // Catch rejections before they become unhandled const result1 = askPromise1.catch((e) => e) const result2 = askPromise2.catch((e) => e) @@ -650,6 +788,144 @@ test("reply - reject cancels all pending for same session", async () => { }) }) +test("reply - always resolves matching pending requests in same session", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const a = PermissionNext.ask({ + id: PermissionID.make("per_test5a"), + sessionID: SessionID.make("session_same"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }) + + const b = PermissionNext.ask({ + id: PermissionID.make("per_test5b"), + sessionID: SessionID.make("session_same"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(2) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test5a"), + reply: "always", + }) + + await expect(a).resolves.toBeUndefined() + await expect(b).resolves.toBeUndefined() + expect(await PermissionNext.list()).toHaveLength(0) + }, + }) +}) + +test("reply - always keeps other session pending", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const a = PermissionNext.ask({ + id: PermissionID.make("per_test6a"), + sessionID: SessionID.make("session_a"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }) + + const b = PermissionNext.ask({ + id: PermissionID.make("per_test6b"), + sessionID: SessionID.make("session_b"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(2) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test6a"), + reply: "always", + }) + + await expect(a).resolves.toBeUndefined() + expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) + + await rejectAll() + await b.catch(() => {}) + }, + }) +}) + +test("reply - publishes replied event", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + id: PermissionID.make("per_test7"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }) + + await waitForPending(1) + + let seen: + | { + sessionID: SessionID + requestID: PermissionID + reply: PermissionNext.Reply + } + | undefined + const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => { + seen = event.properties + }) + + await PermissionNext.reply({ + requestID: PermissionID.make("per_test7"), + reply: "once", + }) + + await expect(ask).resolves.toBeUndefined() + expect(seen).toEqual({ + sessionID: SessionID.make("session_test"), + requestID: PermissionID.make("per_test7"), + reply: "once", + }) + unsub() + }, + }) +}) + +test("reply - does nothing for unknown requestID", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await PermissionNext.reply({ + requestID: PermissionID.make("per_unknown"), + reply: "once", + }) + expect(await PermissionNext.list()).toHaveLength(0) + }, + }) +}) + test("ask - checks all patterns and stops on first deny", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -689,3 +965,74 @@ test("ask - allows all patterns when all match allow rules", async () => { }, }) }) + +test("ask - should deny even when an earlier pattern is ask", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ask = PermissionNext.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["echo hello", "rm -rf /"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "echo *", action: "ask" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }) + + const out = await Promise.race([ + ask.then( + () => ({ ok: true as const, err: undefined }), + (err) => ({ ok: false as const, err }), + ), + Bun.sleep(100).then(() => "timeout" as const), + ]) + + if (out === "timeout") { + await rejectAll() + await ask.catch(() => {}) + throw new Error("ask timed out instead of denying immediately") + } + + expect(out.ok).toBe(false) + expect(out.err).toBeInstanceOf(PermissionNext.DeniedError) + expect(await PermissionNext.list()).toHaveLength(0) + }, + }) +}) + +test("ask - abort should clear pending request", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const ctl = new AbortController() + const ask = runtime.runPromise( + S.PermissionService.use((svc) => + svc.ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }), + ), + { signal: ctl.signal }, + ) + + await waitForPending(1) + ctl.abort() + await ask.catch(() => {}) + + try { + expect(await PermissionNext.list()).toHaveLength(0) + } finally { + await rejectAll() + } + }, + }) +}) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index f00afb09fd5..ab5bc1d99eb 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -5,6 +5,14 @@ import { QuestionID } from "../../src/question/schema" import { tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" +/** Reject all pending questions so dangling Deferred fibers don't hang the test. */ +async function rejectAll() { + const pending = await Question.list() + for (const req of pending) { + await Question.reject(req.id) + } +} + test("ask - returns pending promise", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ @@ -24,6 +32,8 @@ test("ask - returns pending promise", async () => { ], }) expect(promise).toBeInstanceOf(Promise) + await rejectAll() + await promise.catch(() => {}) }, }) }) @@ -44,7 +54,7 @@ test("ask - adds to pending list", async () => { }, ] - Question.ask({ + const askPromise = Question.ask({ sessionID: SessionID.make("ses_test"), questions, }) @@ -52,6 +62,8 @@ test("ask - adds to pending list", async () => { const pending = await Question.list() expect(pending.length).toBe(1) expect(pending[0].questions).toEqual(questions) + await rejectAll() + await askPromise.catch(() => {}) }, }) }) @@ -98,7 +110,7 @@ test("reply - removes from pending list", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - Question.ask({ + const askPromise = Question.ask({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -119,6 +131,7 @@ test("reply - removes from pending list", async () => { requestID: pending[0].id, answers: [["Option 1"]], }) + await askPromise const pendingAfter = await Question.list() expect(pendingAfter.length).toBe(0) @@ -262,7 +275,7 @@ test("list - returns all pending requests", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - Question.ask({ + const p1 = Question.ask({ sessionID: SessionID.make("ses_test1"), questions: [ { @@ -273,7 +286,7 @@ test("list - returns all pending requests", async () => { ], }) - Question.ask({ + const p2 = Question.ask({ sessionID: SessionID.make("ses_test2"), questions: [ { @@ -286,6 +299,9 @@ test("list - returns all pending requests", async () => { const pending = await Question.list() expect(pending.length).toBe(2) + await rejectAll() + p1.catch(() => {}) + p2.catch(() => {}) }, }) }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7659d690c3a..0761a930445 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -183,7 +183,7 @@ describe("tool.read env file permissions", () => { askedForEnv = true } if (rule.action === "deny") { - throw new PermissionNext.DeniedError(agent.permission) + throw new PermissionNext.DeniedError({ ruleset: agent.permission }) } } }, diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts index e5d2129fb07..976b7d07ec7 100644 --- a/packages/opencode/test/util/instance-state.test.ts +++ b/packages/opencode/test/util/instance-state.test.ts @@ -1,11 +1,11 @@ import { afterEach, expect, test } from "bun:test" -import { Effect } from "effect" +import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect" import { Instance } from "../../src/project/instance" import { InstanceState } from "../../src/util/instance-state" import { tmpdir } from "../fixture/fixture" -async function access(state: InstanceState.State, dir: string) { +async function access(state: InstanceState, dir: string) { return Instance.provide({ directory: dir, fn: () => Effect.runPromise(InstanceState.get(state)), @@ -23,9 +23,7 @@ test("InstanceState caches values for the same instance", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: () => Effect.sync(() => ({ n: ++n })), - }) + const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n }))) const a = yield* Effect.promise(() => access(state, tmp.path)) const b = yield* Effect.promise(() => access(state, tmp.path)) @@ -45,9 +43,7 @@ test("InstanceState isolates values by directory", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: (dir) => Effect.sync(() => ({ dir, n: ++n })), - }) + const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n }))) const x = yield* Effect.promise(() => access(state, a.path)) const y = yield* Effect.promise(() => access(state, b.path)) @@ -69,13 +65,15 @@ test("InstanceState is disposed on instance reload", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: () => Effect.sync(() => ({ n: ++n })), - release: (value) => - Effect.sync(() => { - seen.push(String(value.n)) - }), - }) + const state = yield* InstanceState.make(() => + Effect.acquireRelease( + Effect.sync(() => ({ n: ++n })), + (value) => + Effect.sync(() => { + seen.push(String(value.n)) + }), + ), + ) const a = yield* Effect.promise(() => access(state, tmp.path)) yield* Effect.promise(() => Instance.reload({ directory: tmp.path })) @@ -96,13 +94,15 @@ test("InstanceState is disposed on disposeAll", async () => { await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: (dir) => Effect.sync(() => ({ dir })), - release: (value) => - Effect.sync(() => { - seen.push(value.dir) - }), - }) + const state = yield* InstanceState.make((dir) => + Effect.acquireRelease( + Effect.sync(() => ({ dir })), + (value) => + Effect.sync(() => { + seen.push(value.dir) + }), + ), + ) yield* Effect.promise(() => access(state, a.path)) yield* Effect.promise(() => access(state, b.path)) @@ -114,6 +114,129 @@ test("InstanceState is disposed on disposeAll", async () => { ) }) +test("InstanceState.get reads correct directory per-evaluation (not captured once)", async () => { + await using a = await tmpdir() + await using b = await tmpdir() + + // Regression: InstanceState.get must be lazy (Effect.suspend) so the + // directory is read per-evaluation, not captured once at the call site. + // Without this, a service built inside a ManagedRuntime Layer would + // freeze to whichever directory triggered the first layer build. + + interface TestApi { + readonly getDir: () => Effect.Effect + } + + class TestService extends ServiceMap.Service()("@test/ALS-lazy") { + static readonly layer = Layer.effect( + TestService, + Effect.gen(function* () { + const state = yield* InstanceState.make((dir) => Effect.sync(() => dir)) + // `get` is created once during layer build — must be lazy + const get = InstanceState.get(state) + + const getDir = Effect.fn("TestService.getDir")(function* () { + return yield* get + }) + + return TestService.of({ getDir }) + }), + ) + } + + const rt = ManagedRuntime.make(TestService.layer) + + try { + const resultA = await Instance.provide({ + directory: a.path, + fn: () => rt.runPromise(TestService.use((s) => s.getDir())), + }) + expect(resultA).toBe(a.path) + + // Second call with different directory must NOT return A's directory + const resultB = await Instance.provide({ + directory: b.path, + fn: () => rt.runPromise(TestService.use((s) => s.getDir())), + }) + expect(resultB).toBe(b.path) + } finally { + await rt.dispose() + } +}) + +test("InstanceState.get isolates concurrent fibers across real delays, yields, and timer callbacks", async () => { + await using a = await tmpdir() + await using b = await tmpdir() + await using c = await tmpdir() + + // Adversarial: concurrent fibers with real timer delays (macrotask + // boundaries via setTimeout/Bun.sleep), explicit scheduler yields, + // and many async steps. If ALS context leaks or gets lost at any + // point, a fiber will see the wrong directory. + + interface TestApi { + readonly getDir: () => Effect.Effect + } + + class TestService extends ServiceMap.Service()("@test/ALS-adversarial") { + static readonly layer = Layer.effect( + TestService, + Effect.gen(function* () { + const state = yield* InstanceState.make((dir) => Effect.sync(() => dir)) + + const getDir = Effect.fn("TestService.getDir")(function* () { + // Mix of async boundary types to maximise interleaving: + // 1. Real timer delay (macrotask — setTimeout under the hood) + yield* Effect.promise(() => Bun.sleep(1)) + // 2. Effect.sleep (Effect's own timer, uses its internal scheduler) + yield* Effect.sleep(Duration.millis(1)) + // 3. Explicit scheduler yields + for (let i = 0; i < 100; i++) { + yield* Effect.yieldNow + } + // 4. Microtask boundaries + for (let i = 0; i < 100; i++) { + yield* Effect.promise(() => Promise.resolve()) + } + // 5. Another Effect.sleep + yield* Effect.sleep(Duration.millis(2)) + // 6. Another real timer to force a second macrotask hop + yield* Effect.promise(() => Bun.sleep(1)) + // NOW read the directory — ALS must still be correct + return yield* InstanceState.get(state) + }) + + return TestService.of({ getDir }) + }), + ) + } + + const rt = ManagedRuntime.make(TestService.layer) + + try { + const [resultA, resultB, resultC] = await Promise.all([ + Instance.provide({ + directory: a.path, + fn: () => rt.runPromise(TestService.use((s) => s.getDir())), + }), + Instance.provide({ + directory: b.path, + fn: () => rt.runPromise(TestService.use((s) => s.getDir())), + }), + Instance.provide({ + directory: c.path, + fn: () => rt.runPromise(TestService.use((s) => s.getDir())), + }), + ]) + + expect(resultA).toBe(a.path) + expect(resultB).toBe(b.path) + expect(resultC).toBe(c.path) + } finally { + await rt.dispose() + } +}) + test("InstanceState dedupes concurrent lookups for the same directory", async () => { await using tmp = await tmpdir() let n = 0 @@ -121,14 +244,13 @@ test("InstanceState dedupes concurrent lookups for the same directory", async () await Effect.runPromise( Effect.scoped( Effect.gen(function* () { - const state = yield* InstanceState.make({ - lookup: () => - Effect.promise(async () => { - n += 1 - await Bun.sleep(10) - return { n } - }), - }) + const state = yield* InstanceState.make(() => + Effect.promise(async () => { + n += 1 + await Bun.sleep(10) + return { n } + }), + ) const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)])) expect(a).toBe(b) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2629015eb34..9ab71bd8f58 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -54,6 +54,106 @@ export type EventServerInstanceDisposed = { } } +export type PermissionRequest = { + id: string + sessionID: string + permission: string + patterns: Array + metadata: { + [key: string]: unknown + } + always: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventPermissionAsked = { + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean +} + +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: { + messageID: string + callID: string + } +} + +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type QuestionAnswer = Array + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array + } +} + +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -549,35 +649,6 @@ export type EventMessagePartRemoved = { } } -export type PermissionRequest = { - id: string - sessionID: string - permission: string - patterns: Array - metadata: { - [key: string]: unknown - } - always: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type SessionStatus = | { type: "idle" @@ -607,77 +678,6 @@ export type EventSessionIdle = { } } -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string -} - -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean -} - -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string - } -} - -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - -export type QuestionAnswer = Array - -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array - } -} - -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string - } -} - export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -962,6 +962,11 @@ export type Event = | EventInstallationUpdateAvailable | EventProjectUpdated | EventServerInstanceDisposed + | EventPermissionAsked + | EventPermissionReplied + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected | EventServerConnected | EventGlobalDisposed | EventLspClientDiagnostics @@ -972,13 +977,8 @@ export type Event = | EventMessagePartUpdated | EventMessagePartDelta | EventMessagePartRemoved - | EventPermissionAsked - | EventPermissionReplied | EventSessionStatus | EventSessionIdle - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected | EventSessionCompacted | EventFileWatcherUpdated | EventTodoUpdated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e2a1eebb0a8..2933b530f40 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7062,6 +7062,246 @@ }, "required": ["type", "properties"] }, + "PermissionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^per.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] + }, + "Event.permission.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.asked" + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["type", "properties"] + }, + "Event.permission.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "permission.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^per.*" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"] + } + }, + "required": ["type", "properties"] + }, + "QuestionOption": { + "type": "object", + "properties": { + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" + }, + "description": { + "description": "Explanation of choice", + "type": "string" + } + }, + "required": ["label", "description"] + }, + "QuestionInfo": { + "type": "object", + "properties": { + "question": { + "description": "Complete question", + "type": "string" + }, + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" + } + }, + "required": ["question", "header", "options"] + }, + "QuestionRequest": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^que.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { + "type": "string" + } + }, + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["type", "properties"] + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"] + } + }, + "required": ["type", "properties"] + }, + "Event.question.rejected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.rejected" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "requestID": { + "type": "string", + "pattern": "^que.*" + } + }, + "required": ["sessionID", "requestID"] + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -8520,96 +8760,6 @@ }, "required": ["type", "properties"] }, - "PermissionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^per.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "permission": { - "type": "string" - }, - "patterns": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "always": { - "type": "array", - "items": { - "type": "string" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["type", "properties"] - }, "SessionStatus": { "anyOf": [ { @@ -8696,156 +8846,6 @@ }, "required": ["type", "properties"] }, - "QuestionOption": { - "type": "object", - "properties": { - "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" - }, - "description": { - "description": "Explanation of choice", - "type": "string" - } - }, - "required": ["label", "description"] - }, - "QuestionInfo": { - "type": "object", - "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" - }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" - } - }, - "required": ["question", "header", "options"] - }, - "QuestionRequest": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^que.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } - }, - "tool": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { - "type": "string" - } - }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["type", "properties"] - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "Event.question.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["sessionID", "requestID", "answers"] - } - }, - "required": ["type", "properties"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": ["sessionID", "requestID"] - } - }, - "required": ["type", "properties"] - }, "Event.session.compacted": { "type": "object", "properties": { @@ -9611,6 +9611,21 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, + { + "$ref": "#/components/schemas/Event.permission.asked" + }, + { + "$ref": "#/components/schemas/Event.permission.replied" + }, + { + "$ref": "#/components/schemas/Event.question.asked" + }, + { + "$ref": "#/components/schemas/Event.question.replied" + }, + { + "$ref": "#/components/schemas/Event.question.rejected" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -9641,27 +9656,12 @@ { "$ref": "#/components/schemas/Event.message.part.removed" }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, { "$ref": "#/components/schemas/Event.session.status" }, { "$ref": "#/components/schemas/Event.session.idle" }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, { "$ref": "#/components/schemas/Event.session.compacted" },