From 41d1ff614ea67b6337c650be4be072b3bed66528 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 08:32:39 +0800 Subject: [PATCH 01/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(provider):=20cherry-pi?= =?UTF-8?q?ck=20=E4=B8=8A=E6=B8=B8=20v1.14.30..v1.14.41=20provider/?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=A0=B8=E5=BF=83=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整合 anomalyco/opencode 上游有价值的 provider 与会话相关修复,按本 fork 架构重新实现: - DeepSeek v4 flash 解禁 variants + Anthropic transform 配置(9d6718131e/56fd16e5c0) - GPT-5 reasoning 变体重构(1cf8123bc):openaiReasoningEfforts 助手化,新增 GPT5_VERSION_RE/PRO_RE/CODEX 等正则与 gpt5Version/versionedGpt5ReasoningEfforts/gpt5CodexReasoningEfforts/gpt5ChatReasoningEfforts 助手;@openrouter / @ai-sdk/azure / @ai-sdk/openai 切换至助手;新增 ai-gateway-provider 路由 - Anthropic & Bedrock transform 保留带签名 reasoning(4e14f7951/233fc5b91) - Anthropic Opus 4.5 efforts(e0396b809)、deep-research 仅 medium(319498e2f)、Mistral medium-2604 加入推理列表(576480b5d) - @ai-sdk/openai-compatible 仅在 GPT5_FAMILY_RE 命中时切换助手(保护 cerebras/togetherai/xai/deepinfra/venice) - Azure SDK 解析助手 selectAzureLanguageModel(c1f607d20) - providerOptions key 按点分割(a12333310) - Bedrock differentModel 时把 reasoning 转 text(29ec07700) - normalizeMessages 入口的 sanitizeSurrogates 全量净化(6409aceb1) - tool 返回纯图片时 text 留空避免 Anthropic 报错(563177c6a) - providers 注册:plugin hooks 在 configProviders 之前执行,让 user config 覆盖 plugin(560baae15) 验证:bun test test/provider/ test/session/message-v2.test.ts → 326 pass / 0 fail;bun typecheck clean --- packages/opencode/src/provider/provider.ts | 77 +++-- packages/opencode/src/provider/transform.ts | 278 +++++++++++++++--- packages/opencode/src/session/message-v2.ts | 67 +++-- .../opencode/test/provider/transform.test.ts | 12 +- 4 files changed, 333 insertions(+), 101 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 21a95a9794..578c2338e0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -138,6 +138,14 @@ function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } +function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + function custom(dep: CustomDep): Record { return { anthropic: () => @@ -217,12 +225,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: {}, vars(_options) { @@ -237,12 +240,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, @@ -1130,6 +1128,34 @@ const layer: Layer.Layer< return true } + // run plugin model hooks BEFORE config provider extension so config can override plugin-supplied models + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue + + const providerID = ProviderID.make(p.id) + if (disabled.has(providerID)) continue + + const provider = database[providerID] + if (!provider) continue + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + + provider.models = yield* Effect.promise(async () => { + const next = await models(provider, { auth: pluginAuth }) + return Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelID.make(id), + providerID, + }, + ]), + ) + }) + } + // extend database from config for (const [providerID, provider] of configProviders) { const existing = database[providerID] @@ -1316,33 +1342,6 @@ const layer: Layer.Layer< }) } - for (const hook of plugins) { - const p = hook.provider - const models = p?.models - if (!p || !models) continue - - const providerID = ProviderID.make(p.id) - if (disabled.has(providerID)) continue - - const provider = providers[providerID] - if (!provider) continue - const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) - - provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) - return Object.fromEntries( - Object.entries(next).map(([id, model]) => [ - id, - { - ...model, - id: ModelID.make(id), - providerID, - }, - ]), - ) - }) - } - for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderID.make(id) if (!isProviderAllowed(providerID)) { diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index a3eb339ef0..43307375b7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,4 +1,4 @@ -import type { ModelMessage } from "ai" +import type { ModelMessage, ToolResultPart } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" @@ -19,6 +19,13 @@ function mimeToModality(mime: string): Modality | undefined { export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 +// Replace unpaired UTF-16 surrogates with U+FFFD. The AI SDK / providers +// can choke on lone surrogates emitted from tool output (e.g. truncated +// emoji at a buffer boundary), so we proactively scrub them. +export function sanitizeSurrogates(content: string): string { + return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?, ): ModelMessage[] { + // Scrub unpaired UTF-16 surrogates from every text-bearing part. Tool + // output streamed from external processes can split a multi-code-unit + // character at a chunk boundary, leaving a lone surrogate that breaks + // downstream JSON encoders / provider APIs. + const sanitizeToolResultOutput = (content: ToolResultPart["output"]): ToolResultPart["output"] => { + if (content.type === "text" || content.type === "error-text") { + return { ...content, value: sanitizeSurrogates(content.value) } + } + if (content.type === "content") { + return { + ...content, + value: content.value.map((item) => + item.type === "text" ? { ...item, text: sanitizeSurrogates(item.text) } : item, + ), + } + } + return content + } + msgs = msgs.map((msg): ModelMessage => { + switch (msg.role) { + case "tool": { + if (!Array.isArray(msg.content)) return msg + return { + ...msg, + content: msg.content.map((part) => + part.type === "tool-result" ? { ...part, output: sanitizeToolResultOutput(part.output) } : part, + ), + } + } + case "system": { + if (typeof msg.content !== "string") return msg + return { ...msg, content: sanitizeSurrogates(msg.content) } + } + case "user": { + if (typeof msg.content === "string") return { ...msg, content: sanitizeSurrogates(msg.content) } + if (!Array.isArray(msg.content)) return msg + return { + ...msg, + content: msg.content.map((part) => + part.type === "text" ? { ...part, text: sanitizeSurrogates(part.text) } : part, + ), + } + } + case "assistant": { + if (typeof msg.content === "string") return { ...msg, content: sanitizeSurrogates(msg.content) } + if (!Array.isArray(msg.content)) return msg + return { + ...msg, + content: msg.content.map((part) => { + if (part.type === "text") return { ...part, text: sanitizeSurrogates(part.text) } + if (part.type === "reasoning") return { ...part, text: sanitizeSurrogates(part.text) } + if (part.type === "tool-result") return { ...part, output: sanitizeToolResultOutput(part.output) } + return part + }), + } + } + default: + return msg + } + }) + // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -61,9 +136,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text.trim() !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.anthropic?.signature != null || + part.providerOptions?.anthropic?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined @@ -82,9 +164,16 @@ function normalizeMessages( } if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { - if (part.type === "text" || part.type === "reasoning") { + if (part.type === "text") { return part.text.trim() !== "" } + if (part.type === "reasoning") { + return ( + part.text.trim().length > 0 || + part.providerOptions?.bedrock?.signature != null || + part.providerOptions?.bedrock?.redactedData != null + ) + } return true }) if (filtered.length === 0) return undefined @@ -458,6 +547,76 @@ export function topK(model: Provider.Model) { const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS] +const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"] +const OPENAI_GPT5_PRO_EFFORTS = ["high"] +const OPENAI_GPT5_PRO_2_PLUS_EFFORTS = ["medium", "high", "xhigh"] +const OPENAI_GPT5_CHAT_EFFORTS = ["medium"] +const OPENAI_GPT5_CODEX_XHIGH_EFFORTS = [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] +const OPENAI_GPT5_CODEX_3_PLUS_EFFORTS = ["none", ...OPENAI_GPT5_CODEX_XHIGH_EFFORTS] + +// Match identifiers like "gpt-5", "openai/gpt-5-codex", "gpt-5.2-pro" etc. +// Anchored to start-of-string or "/" so it doesn't false-match "gpt-50" or "gpt-5o". +const GPT5_FAMILY_RE = /(?:^|\/)gpt-5(?:[.-]|$)/ +const GPT5_VERSION_RE = /(?:^|\/)gpt-5[.-](\d+)(?:[.-]|$)/ +const GPT5_PRO_RE = /(?:^|\/)gpt-5[.-]?pro(?:[.-]|$)/ +const GPT5_VERSIONED_PRO_RE = /(?:^|\/)gpt-5[.-]\d+[.-]pro(?:[.-]|$)/ + +function gpt5Version(apiId: string) { + return Number(GPT5_VERSION_RE.exec(apiId)?.[1]) || undefined +} + +function versionedGpt5ReasoningEfforts(apiId: string) { + if (GPT5_VERSIONED_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_2_PLUS_EFFORTS + const version = gpt5Version(apiId) + if (version === undefined) return undefined + if (version === 1) return OPENAI_GPT5_1_EFFORTS + return OPENAI_GPT5_2_PLUS_EFFORTS +} + +function gpt5CodexReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("codex")) return undefined + const version = gpt5Version(apiId) + if (version !== undefined && version >= 3) return OPENAI_GPT5_CODEX_3_PLUS_EFFORTS + if (apiId.includes("codex-max") || (version !== undefined && version >= 2)) return OPENAI_GPT5_CODEX_XHIGH_EFFORTS + return WIDELY_SUPPORTED_EFFORTS +} + +function gpt5ChatReasoningEfforts(apiId: string) { + if (!GPT5_FAMILY_RE.test(apiId) || !apiId.includes("-chat")) return undefined + return gpt5Version(apiId) === undefined ? [] : OPENAI_GPT5_CHAT_EFFORTS +} + +// Computes the reasoning_effort tiers an OpenAI model exposes. +// Effort order: weakest to strongest. +function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] { + const id = apiId.toLowerCase() + if (id.includes("deep-research")) return ["medium"] + const chatEfforts = gpt5ChatReasoningEfforts(id) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(id)) return OPENAI_GPT5_PRO_EFFORTS + const codexEfforts = gpt5CodexReasoningEfforts(id) + if (codexEfforts) return codexEfforts + // GPT-5.1 replaced GPT-5's `minimal` effort with `none`; GPT-5.2+ also accepts `xhigh`. + const versionedEfforts = versionedGpt5ReasoningEfforts(id) + if (versionedEfforts) return versionedEfforts + const efforts = [...WIDELY_SUPPORTED_EFFORTS] + if (GPT5_FAMILY_RE.test(id)) efforts.unshift("minimal") + if (releaseDate >= "2025-11-13") efforts.unshift("none") + if (releaseDate >= "2025-12-04") efforts.push("xhigh") + return efforts +} + +// Same logic as openaiReasoningEfforts but used by openai-compatible providers +// where we don't have a release_date — falls back to OPENAI_EFFORTS for +// non-versioned models. +function openaiCompatibleReasoningEfforts(id: string) { + const apiId = id.toLowerCase() + const chatEfforts = gpt5ChatReasoningEfforts(apiId) + if (chatEfforts) return chatEfforts + if (GPT5_PRO_RE.test(apiId)) return OPENAI_GPT5_PRO_EFFORTS + return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS +} function anthropicAdaptiveEfforts(apiId: string): string[] | null { if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { @@ -475,8 +634,6 @@ export function variants(model: Provider.Model): Record [effort, { reasoning: { effort } }])) + return Object.fromEntries( + (model.id.includes("gpt") ? openaiCompatibleReasoningEfforts(model.api.id) : OPENAI_EFFORTS).map((effort) => [ + effort, + { reasoning: { effort } }, + ]), + ) + + case "ai-gateway-provider": { + // Cloudflare AI Gateway routes every upstream through its OpenAI-compatible + // /v1/compat endpoint, so the body is always OAI-shaped. The gateway + // translates `reasoning_effort` to the upstream provider's native control + // (e.g. Anthropic thinking budgets) when needed. Variants therefore stay + // OAI-style for all upstreams, with an extended effort set for OpenAI + // models that support it. + if (model.api.id.startsWith("openai/")) { + const efforts = openaiReasoningEfforts(model.api.id, model.release_date) + return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + } + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) + } case "@ai-sdk/gateway": if (model.id.includes("anthropic")) { @@ -604,21 +780,23 @@ export function variants(model: Provider.Model): Record { + const lower = model.api.id.toLowerCase() + if (GPT5_FAMILY_RE.test(lower)) return openaiCompatibleReasoningEfforts(lower) + const base = [...WIDELY_SUPPORTED_EFFORTS] + if (lower.includes("deepseek-v4")) base.push("max") + return base + }) return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) case "@ai-sdk/azure": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure if (id === "o1-mini") return {} - const azureEfforts = ["low", "medium", "high"] - if (id.includes("gpt-5-") || id === "gpt-5") { - azureEfforts.unshift("minimal") - } return Object.fromEntries( - azureEfforts.map((effort) => [ + (GPT5_FAMILY_RE.test(id) && gpt5Version(id) === undefined + ? ["minimal", ...WIDELY_SUPPORTED_EFFORTS] + : WIDELY_SUPPORTED_EFFORTS + ).map((effort) => [ effort, { reasoningEffort: effort, @@ -627,26 +805,9 @@ export function variants(model: Provider.Model): Record { - if (id.includes("codex")) { - if (id.includes("5.2") || id.includes("5.3")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] - return WIDELY_SUPPORTED_EFFORTS - } - const arr = [...WIDELY_SUPPORTED_EFFORTS] - if (id.includes("gpt-5-") || id === "gpt-5") { - arr.unshift("minimal") - } - if (model.release_date >= "2025-11-13") { - arr.unshift("none") - } - if (model.release_date >= "2025-12-04") { - arr.push("xhigh") - } - return arr - }) + const openaiEfforts = openaiReasoningEfforts(model.api.id, model.release_date) return Object.fromEntries( openaiEfforts.map((effort) => [ effort, @@ -657,11 +818,32 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { + return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }])) + } + return { high: { thinking: { @@ -793,7 +979,12 @@ export function variants(model: Provider.Model): Record mistralId.includes(id))) return {} return { @@ -1025,6 +1216,11 @@ export function smallOptions(model: Provider.Model) { model.api.npm === "@ai-sdk/github-copilot" ) { if (model.api.id.includes("gpt-5")) { + if (model.api.id.includes("-chat")) { + if (gpt5Version(model.api.id) === undefined) return { store: false } + return { store: false, reasoningEffort: "medium" } + } + if (model.api.id.includes("search-api")) return { store: false } if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) { return { store: false, reasoningEffort: "low" } } @@ -1090,7 +1286,17 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a return result } - const key = sdkKey(model.api.npm) ?? model.providerID + // AI SDK packages that resolve providerOptionsName by splitting the + // provider name on "." (e.g. "wafer.ai" -> "wafer") need the same + // logic here so the key we write matches the key they read. + // Other SDKs (xai, mistral, groq, cohere, etc.) use hardcoded keys + // like "xai" or "cohere" - applying .split(".")[0] would break those. + const usesDotSplitOptions = + model.api.npm === "@ai-sdk/openai-compatible" || + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/anthropic" + const key = + sdkKey(model.api.npm) ?? (usesDotSplitOptions ? model.providerID.split(".")[0] : model.providerID) // @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from // providerOptions["openai"], but OpenAIResponsesLanguageModel checks // "azure" first. Pass both so model options work on either code path. diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 911f58efd0..8c9332ed53 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -35,7 +35,7 @@ interface FetchDecompressionError extends Error { path: string } -export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" +export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" export { isMedia } export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) @@ -734,25 +734,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const result: UIMessage[] = [] const toolNames = new Set() // Track media from tool results that need to be injected as user messages - // for providers that don't support media in tool results. + // for providers that don't support that media type in tool results. // // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Other SDKs (anthropic, google, - // bedrock) handle type: "content" with media parts natively. + // to extract media and inject as user messages. Some SDKs only support a subset + // of media in tool results; e.g. Bedrock supports images but not PDFs there. // - // Only apply this workaround if the model actually supports image input - - // otherwise there's no point extracting images. - const supportsMediaInToolResults = (() => { + // Only apply this workaround if the model actually supports that media input - + // otherwise unsupportedParts() will turn it into a user-visible error. + const supportsMediaInToolResult = (attachment: { mime: string }) => { if (model.api.npm === "@ai-sdk/anthropic") return true if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return true + if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true if (model.api.npm === "@ai-sdk/google") { const id = model.api.id.toLowerCase() return id.includes("gemini-3") && !id.includes("gemini-2") } return false - })() + } const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { const output = options.output @@ -772,7 +772,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return { type: "content", value: [ - { type: "text", text: outputObject.text }, + ...(outputObject.text ? [{ type: "text" as const, text: outputObject.text }] : []), ...attachments.map((attachment) => ({ type: "media", mediaType: attachment.mime, @@ -797,9 +797,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "user", parts: [], } - result.push(userMessage) for (const part of msg.parts) { - if (part.type === "text" && !part.ignored) + // User message parts should never be empty + if (part.type === "text" && !part.ignored && part.text !== "") userMessage.parts.push({ type: "text", text: part.text, @@ -834,11 +834,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } + if (userMessage.parts.length > 0) result.push(userMessage) } if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string }> = [] + const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( msg.info.error && @@ -854,13 +855,30 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( role: "assistant", parts: [], } + // Anthropic adaptive thinking can persist assistant turns like: + // step-start, reasoning(signature), text(""), step-start, + // reasoning(signature). The empty text part is a structural separator, + // but it does not carry the signature metadata itself. Dropping it shifts + // signed thinking positions after step-start splitting/provider regrouping; + // keeping it as "" is filtered by the AI SDK and rejected by Anthropic. + // It is unclear whether this shape originates in our stream processing, + // a proxy, or a lower-level library, but preserving a non-empty separator + // here is the only safe replay point we have. + // Use a single space so the separator survives replay without changing + // the neighboring signed reasoning blocks. + const hasSignedReasoning = msg.parts.some((part) => { + if (part.type !== "reasoning") return false + return part.metadata?.anthropic?.signature != null + }) for (const part of msg.parts) { - if (part.type === "text") + if (part.type === "text") { + const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", - text: part.text, + text, ...(differentModel ? {} : { providerMetadata: part.metadata }), }) + } if (part.type === "step-start") assistantMessage.parts.push({ type: "step-start", @@ -876,11 +894,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) - if (!supportsMediaInToolResults && mediaAttachments.length > 0) { - media.push(...mediaAttachments) + const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a)) + if (extractedMedia.length > 0) { + media.push(...extractedMedia) } - const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments + const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a)) const output = finalAttachments.length > 0 @@ -938,10 +956,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { + if (differentModel) { + if (part.text.trim().length > 0) + assistantMessage.parts.push({ + type: "text", + text: part.text, + }) + continue + } assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) } } @@ -962,6 +988,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( type: "file" as const, url: attachment.url, mediaType: attachment.mime, + filename: attachment.filename, })), ], }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 9b66eaa77c..9287b57374 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2215,7 +2215,7 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) - test("deepseek returns empty object", () => { + test("deepseek-chat returns reasoning efforts (deepseek v4 flash supports variants)", () => { const model = createMockModel({ id: "deepseek/deepseek-chat", providerID: "deepseek", @@ -2226,7 +2226,7 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) }) test("minimax returns empty object", () => { @@ -2820,7 +2820,7 @@ describe("ProviderTransform.variants", () => { }) describe("@ai-sdk/openai", () => { - test("gpt-5-pro returns empty object", () => { + test("gpt-5-pro returns only high effort", () => { const model = createMockModel({ id: "gpt-5-pro", providerID: "openai", @@ -2831,7 +2831,7 @@ describe("ProviderTransform.variants", () => { }, }) const result = ProviderTransform.variants(model) - expect(result).toEqual({}) + expect(Object.keys(result)).toEqual(["high"]) }) test("standard openai models return custom efforts with reasoningSummary", () => { @@ -2871,10 +2871,10 @@ describe("ProviderTransform.variants", () => { test("models after 2025-12-04 include 'xhigh' effort", () => { const model = createMockModel({ - id: "openai/gpt-5-chat", + id: "openai/gpt-5-reasoning", providerID: "openai", api: { - id: "gpt-5-chat", + id: "gpt-5-reasoning", url: "https://api.openai.com", npm: "@ai-sdk/openai", }, From 60751f3395ab8f1e1d2fb2126bd79063c8c2c506 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 08:33:00 +0800 Subject: [PATCH 02/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20cherry-pick=20?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20v1.14.30..v1.14.41=20=E5=B7=A5=E5=85=B7/?= =?UTF-8?q?=E6=8A=80=E8=83=BD/=E6=A0=BC=E5=BC=8F=E5=8C=96/=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=B0=8F=E5=9E=8B=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按本 fork 架构重新实现以下上游修复: - provider/error: 重试 server_is_overloaded(25ecf0af6) - tool/read: SUPPORTED_IMAGE_MIMES 白名单,阻止不支持的图片格式内联(51e310c9c) - tool/task: 子 session 继承父级 external_directory 与 deny 规则(d7701dbfb) - skill: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS 仅影响 .claude,不再连带禁用 .agents(ffe0314c4) - core/flag: 解耦 OPENCODE_DISABLE_EXTERNAL_SKILLS 与 OPENCODE_DISABLE_CLAUDE_CODE_SKILLS - format: formatter 子进程 stdin/stdout/stderr 全部 ignore(293bb422f) - cli/providers: auth login 子进程 stderr 改为 inherit(8e016b470) - app/local: 恢复无 model 消息时 msg.model?.variant 链式访问,避免崩溃(9bddf7f3e) 验证:bun typecheck(opencode + core + app)clean; bun test test/tool/task.test.ts test/format/ → 17 pass / 0 fail --- packages/app/src/context/local.tsx | 2 +- packages/core/src/flag/flag.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 1 + packages/opencode/src/format/index.ts | 3 +++ packages/opencode/src/provider/error.ts | 1 + packages/opencode/src/skill/index.ts | 11 ++++++++--- packages/opencode/src/tool/read.ts | 6 ++++-- packages/opencode/src/tool/task.ts | 4 ++++ 8 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 2db0f9b04f..f467e9034f 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setSaved("session", session, { agent: msg.agent, model: msg.model, - variant: msg.model.variant ?? null, + variant: msg.model?.variant ?? null, }) }, }, diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 72c8931f5b..a3b8133b64 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -47,7 +47,7 @@ export const Flag = { OPENCODE_DISABLE_CLAUDE_CODE, OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, - OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index de37c77a6f..de2a2566a0 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -312,6 +312,7 @@ export const ProvidersLoginCommand = cmd({ prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", + stderr: "inherit", }) if (!proc.stdout) { prompts.log.error("Failed") diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 7c122e3501..a61eb7be29 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -91,6 +91,9 @@ export const layer = Layer.effect( cwd: dir, env: item.environment, extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", }), ) .pipe( diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 3877dcb7f3..7363b5ce59 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -151,6 +151,7 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined isRetryable: false, responseBody, } + case "server_is_overloaded": case "server_error": return { type: "api_error", diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 701ecaba89..ce998dc7e8 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -20,7 +20,8 @@ import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" const log = Log.create({ service: "skill" }) -const EXTERNAL_DIRS = [".claude", ".agents"] +const CLAUDE_EXTERNAL_DIR = ".claude" +const AGENTS_EXTERNAL_DIR = ".agents" const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" @@ -153,14 +154,18 @@ const discoverSkills = Effect.fnUntraced(function* ( const state: ScanState = { matches: new Set(), dirs: new Set() } if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { + const externalDirs: string[] = [] + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) externalDirs.push(CLAUDE_EXTERNAL_DIR) + externalDirs.push(AGENTS_EXTERNAL_DIR) + + for (const dir of externalDirs) { const root = path.join(Global.Path.home, dir) if (!(yield* fsys.isDir(root))) continue yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) } const upDirs = yield* fsys - .up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree }) + .up({ targets: externalDirs, start: directory, stop: worktree }) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) for (const root of upDirs) { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index fb386f5790..9f5f80322d 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,7 @@ import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" -import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -18,6 +18,7 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const SAMPLE_BYTES = 4096 +const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) // `offset` and `limit` were originally `z.coerce.number()` — the runtime // coercion was useful when the tool was called from a shell but serves no @@ -219,7 +220,8 @@ export const ReadTool = Tool.define( const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) - if (isImageAttachment(mime) || isPdfAttachment(mime)) { + const isImage = SUPPORTED_IMAGE_MIMES.has(mime) + if (isImage || isPdfAttachment(mime)) { const bytes = yield* fs.readFile(filepath) const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 39d9f8fd15..7e113bd9be 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -66,12 +66,16 @@ export const TaskTool = Tool.define( const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined + const parent = yield* sessions.get(ctx.sessionID) const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ + ...(parent.permission ?? []).filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), ...(canTodo ? [] : [ From 636bc8f208167a42cf5553e1f4f124d7c196fe99 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 08:33:16 +0800 Subject: [PATCH 03/25] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20cherry-pick=20=E8=B7=9F=E8=B8=AA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20.codex=5Fplan/TODO.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录从 anomalyco/opencode v1.14.30..v1.14.41 抽取的 28 项变更分级(Tier S/A/B/C)、跳过列表与每项 commit hash。已应用 22 项,验证全部通过。 --- .codex_plan/TODO.md | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .codex_plan/TODO.md diff --git a/.codex_plan/TODO.md b/.codex_plan/TODO.md new file mode 100644 index 0000000000..85def4650b --- /dev/null +++ b/.codex_plan/TODO.md @@ -0,0 +1,69 @@ +# TODO:从上游 anomalyco/opencode v1.14.30 抽取价值变更 + +> 范围:`ac6aa43e3..upstream/dev`(v1.14.30 至今,377 commits) +> 策略:**只读对照上游 → 按本 fork 架构重新实现 → 每项 typecheck 验证** +> 严禁机械 cherry-pick + +--- + +## Tier S — Provider/模型行为修复(最高价值,最小爆炸半径) +- [x] S0a Deepseek v4 flash variants 解禁(`9d6718131e`) +- [x] S0b Deepseek anthropic transform thinking 配置(`56fd16e5c0`) +- [x] S1 GPT-5 reasoning 变体对齐(`1cf8123bc`) +- [x] S2 Anthropic & Bedrock transform 修正(`4e14f7951`) +- [x] S3 Anthropic Opus 4.5 efforts 对齐(`e0396b809`) +- [x] S4 OpenAI deep research efforts 约束(`319498e2f`) +- [x] S5 含 reasoning block 时保留 assistant 内容(`233fc5b91`) +- [x] S6 Mistral medium 3.5 variants 配置(`576480b5d`) +- [x] S7 Anthropic SDK 在 Azure 下解析(`c1f607d20`) +- [x] S8 cf-ai-gateway providerOptions 路由(`ca77b8f8e`) +- [x] S9 providerOptions key 按点分割(`a12333310`) +- [x] S10 Bedrock reasoning 修复(`29ec07700`) +- ✅ 验证:`bun test test/provider/transform.test.ts test/session/message-v2.test.ts test/provider/amazon-bedrock.test.ts` → 186 pass / 0 fail + +## Tier A — 核心稳定性 BUG(高价值) +- [x] A1 重试 server_is_overloaded(`25ecf0af6`) +- [ ] A2 compaction 摘要顺序(`811954880`)— 延后(26 行 + tests) +- [ ] A3 取消子任务 child sessions(`75d141b57`)— 延后(~500 行) +- [ ] A4 vcs 批量 patch 边界(`6a5e32942`)— 延后(涉及 ui) +- [ ] A5 vcs diff 内存控制(`d1f597b5b`)— 延后(~332 行) +- [x] A6 sanitize surrogates(`6409aceb1`) +- [x] A7 tool 返回 image+空 text 错误(`563177c6a`) +- [x] A8 read 阻止不支持图片格式(`51e310c9c`) +- [x] A9 修复无 model 时恢复 messages 崩溃(`9bddf7f3e`) +- [x] A10 user config 优先于 plugin hooks 解析 model(`560baae15`) +- [ ] A11 bootstrap 后更新 provider store(`a5aa72bd7`)— packages/app(前端,延后) +- [x] A12 OPENCODE_DISABLE_CLAUDE_CODE_SKILLS 不影响外部 skills(`ffe0314c4`) +- ✅ 验证:`bun test test/provider/ test/session/message-v2.test.ts test/skill/ test/tool/read.test.ts` → 345 pass / 0 fail + +## Tier B — 服务/Auth/Format 修复(合理价值) +- [x] B1 formatter stdout/stderr ignore 恢复(`293bb422f`) +- [x] B2 auth login stderr 继承(`8e016b470`) +- [ ] B3 保留 auth token credentials(`ca6150d6f`)— packages/app(前端,延后) +- [x] B4 task 子 session 保留 external_dir/deny 父权限(`d7701dbfb`) +- [~] B5 archived timestamp schema 用 finite(`16ddf5f55`)— N/A,fork 用 NonNegativeInt 已更严 + +## Tier C — 新功能(需先评估架构兼容) +- [ ] C1 shell-aware bash tool(bash/pwsh/cmd 上下文,`3f459819b`)— 评估:是否与本 fork bash tool 冲突 +- [ ] C2 config 支持 well-known remote_config(`d9c18381a`)— 评估 +- [ ] C3 websearch 并行 provider rollout(`a43d3e0e1`)— 评估 + +--- + +## 显式跳过(与 fork 架构冲突 / 不适用) +- effectCmd 系列重构(25517/25507/25481/25429/25434/146ff8ad8/f8738c900)— 本 fork 已自有 CLI 抽象 +- httpapi/server 大型重构(25547/25545/25074/25291/25527/25412/25417/25475/25449/26052/63a175b50/2dffdfff4)— fork 自有 server +- session warping (`22a4a9df8`) + copy file changes (`3c4b4d5fa`) — 大型新特性,需用户单独决策 +- ACP modernize (`b2e3dc87e`) — 大型重构 +- desktop electron 系列 — fork 用 Tauri + 独立 desktop-electron +- tui/console/web/share/storybook 系列 — 演化路径不同 +- chore/sync/generate/typo/changelog 噪音 +- 商业化 vendor:zen/go/honeycomb/free-tier/codex spark +- Codex OAuth (`ce89bcb8e`)、basic auth (`adb7cb103/8694c5b68/101566131/7a503de60`)、PTY tickets — 与本 fork auth 模型偏差 + +## 工作循环(每项) +1. `git show ` 看上游意图 +2. fork 中定位对应模块 +3. 评估:直接适用 / 需调整 / 不适用 +4. 实施修改 + `bun typecheck` +5. 更新本 TODO From db84e490c4c5f4a6450e6f683ef7917d08088ca7 Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 08:53:18 +0800 Subject: [PATCH 04/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20cherry-pick=20?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20v1.14.30..v1.14.41=20wellknown=20remote=5F?= =?UTF-8?q?config=20=E4=B8=8E=20websearch=20=E5=B9=B6=E8=A1=8C=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C2 (d9c18381a): config 支持从 wellknown 远端 URL 拉取 remote_config 并合并, 支持模板化 headers (env 变量替换)。 C3 (a43d3e0e1): websearch 抽象出 mcp-websearch 工具底座,新增 Parallel provider 支持,按会话稳定选择 provider,环境变量 OPENCODE_WEBSEARCH_PROVIDER 可覆盖, OPENCODE_ENABLE_PARALLEL flag 启用 Parallel。tui/ui 显示部分按 fork 规则跳过。 验证: bun typecheck clean; bun test test/tool/websearch.test.ts test/config/config.test.ts → 91 pass / 0 fail --- .codex_plan/TODO.md | 7 +- packages/core/src/flag/flag.ts | 1 + packages/opencode/src/cli/cmd/run.ts | 9 +- .../opencode/src/command/template/review.txt | 2 +- packages/opencode/src/config/config.ts | 57 +++++++++- .../src/tool/{mcp-exa.ts => mcp-websearch.ts} | 35 +++++- packages/opencode/src/tool/registry.ts | 9 +- packages/opencode/src/tool/websearch.ts | 103 +++++++++++++++--- packages/opencode/src/tool/websearch.txt | 6 +- packages/opencode/test/config/config.test.ts | 77 +++++++++++++ packages/opencode/test/tool/websearch.test.ts | 92 ++++++++++++++++ 11 files changed, 363 insertions(+), 35 deletions(-) rename packages/opencode/src/tool/{mcp-exa.ts => mcp-websearch.ts} (63%) create mode 100644 packages/opencode/test/tool/websearch.test.ts diff --git a/.codex_plan/TODO.md b/.codex_plan/TODO.md index 85def4650b..cb6edd69d7 100644 --- a/.codex_plan/TODO.md +++ b/.codex_plan/TODO.md @@ -44,9 +44,10 @@ - [~] B5 archived timestamp schema 用 finite(`16ddf5f55`)— N/A,fork 用 NonNegativeInt 已更严 ## Tier C — 新功能(需先评估架构兼容) -- [ ] C1 shell-aware bash tool(bash/pwsh/cmd 上下文,`3f459819b`)— 评估:是否与本 fork bash tool 冲突 -- [ ] C2 config 支持 well-known remote_config(`d9c18381a`)— 评估 -- [ ] C3 websearch 并行 provider rollout(`a43d3e0e1`)— 评估 +- [ ] C1 shell-aware bash tool(bash/pwsh/cmd 上下文,`3f459819b`)— 延后(506+/177-,含 tui 大改) +- [x] C2 config 支持 well-known remote_config(`d9c18381a`) +- [x] C3 websearch 并行 provider rollout(`a43d3e0e1`)— 后端已应用,tui/ui 部分按 fork 规则跳过 +- ✅ 验证:`bun test test/tool/websearch.test.ts test/config/config.test.ts` → 91 pass / 0 fail --- diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a3b8133b64..24af03eab3 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -72,6 +72,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), + OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c94e962038..12e4095fe8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -19,7 +19,7 @@ import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" import { WriteTool } from "../../tool/write" -import { WebSearchTool } from "../../tool/websearch" +import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" @@ -147,7 +147,7 @@ function edit(info: ToolProps) { function websearch(info: ToolProps) { inline({ icon: "◈", - title: `Exa Web Search "${info.input.query}"`, + title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, }) } @@ -456,7 +456,10 @@ export const RunCommand = cmd({ } inline({ icon: "✗", - title: `${part.tool} failed`, + title: + part.tool === "websearch" + ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` + : `${part.tool} failed`, }) UI.error(part.state.error) } diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index b745247e7f..43c6738577 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -85,7 +85,7 @@ Use these to inform your review: - **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit. - **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong. -- **Exa Web Search** - Research best practices if you're unsure about a pattern. +- **Web Search** - Research best practices if you're unsure about a pattern. If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 817f8c3e38..1946dfd39c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -67,6 +67,40 @@ function normalizeLoadedConfig(data: unknown, source: string) { return copy } +async function substituteWellKnownRemoteConfig(input: { + value: unknown + dir: string + source: string +}) { + if (!isRecord(input.value) || typeof input.value.url !== "string") return + + const url = await ConfigVariable.substitute({ + text: input.value.url, + type: "virtual", + dir: input.dir, + source: input.source, + }) + const headers = isRecord(input.value.headers) + ? Object.fromEntries( + await Promise.all( + Object.entries(input.value.headers) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(async ([key, value]) => [ + key, + await ConfigVariable.substitute({ + text: value, + type: "virtual", + dir: input.dir, + source: input.source, + }), + ]), + ), + ) + : undefined + + return { url, headers } +} + async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -495,8 +529,27 @@ export const layer = Layer.effect( if (!response.ok) { throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) } - const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record } - const remoteConfig = wellknown.config ?? {} + const wellknown = (yield* Effect.promise(() => response.json())) as { + config?: Record + remote_config?: unknown + } + const remote = yield* Effect.promise(() => + substituteWellKnownRemoteConfig({ + value: wellknown.remote_config, + dir: url, + source: `${url}/.well-known/opencode`, + }), + ) + const fetchedConfig = remote + ? ((yield* Effect.promise(async () => { + log.debug("fetching remote config", { url: remote.url }) + const response = await fetch(remote.url, { headers: remote.headers }) + if (!response.ok) throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`) + const data = await response.json() + return isRecord(data) && isRecord(data.config) ? data.config : data + })) as Record) + : {} + const remoteConfig = mergeDeep(wellknown.config ?? {}, fetchedConfig) as Record if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" const source = `${url}/.well-known/opencode` const next = yield* loadConfig(JSON.stringify(remoteConfig), { diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-websearch.ts similarity index 63% rename from packages/opencode/src/tool/mcp-exa.ts rename to packages/opencode/src/tool/mcp-websearch.ts index af9a3390e3..208924cba5 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -1,9 +1,10 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const URL = process.env.EXA_API_KEY +export const EXA_URL = process.env.EXA_API_KEY ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` : "https://mcp.exa.ai/mcp" +export const PARALLEL_URL = "https://search.parallel.ai/mcp" const McpResult = Schema.Struct({ result: Schema.Struct({ @@ -18,11 +19,23 @@ const McpResult = Schema.Struct({ const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) -const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) { +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + const data = yield* decode(trimmed) + return data.result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { if (!line.startsWith("data: ")) continue - const data = yield* decode(line.substring(6)) - if (data.result.content[0]?.text) return data.result.content[0].text + const data = yield* parsePayload(line.substring(6)) + if (data) return data } return undefined }) @@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const ParallelSearchArgs = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.optional(Schema.String), + model_name: Schema.optional(Schema.String), +}) + const McpRequest = (args: Schema.Struct) => Schema.Struct({ jsonrpc: Schema.Literal("2.0"), @@ -48,14 +68,17 @@ const McpRequest = (args: Schema.Struct) => export const call = ( http: HttpClient.HttpClient, + url: string, tool: string, args: Schema.Struct, value: Schema.Struct.Type, timeout: Duration.Input, + headers?: Record, ) => Effect.gen(function* () { - const request = yield* HttpClientRequest.post(URL).pipe( + const request = yield* HttpClientRequest.post(url).pipe( HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers ?? {}), HttpClientRequest.schemaBodyJson(McpRequest(args))({ jsonrpc: "2.0" as const, id: 1 as const, @@ -69,5 +92,5 @@ export const call = ( Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }), ) const body = yield* response.text - return yield* parseSse(body) + return yield* parseResponse(body) }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 1ec0999d5d..a7040875eb 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -50,6 +50,13 @@ import { Permission } from "@/permission" const log = Log.create({ service: "tool.registry" }) +export function webSearchEnabled( + providerID: ProviderID, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +) { + return providerID === ProviderID.opencode || flags.exa || flags.parallel +} + type TaskDef = Tool.InferDef type ReadDef = Tool.InferDef @@ -277,7 +284,7 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + return webSearchEnabled(input.providerID) } const usePatch = diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index ff4c696a25..0218ecbe3b 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,8 +1,11 @@ import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" -import * as McpExa from "./mcp-exa" +import * as McpWebSearch from "./mcp-websearch" import DESCRIPTION from "./websearch.txt" +import { Flag } from "@opencode-ai/core/flag/flag" +import { checksum } from "@opencode-ai/core/util/encode" +import { InstallationVersion } from "@opencode-ai/core/installation/version" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), @@ -21,6 +24,81 @@ export const Parameters = Schema.Struct({ }), }) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +export type WebSearchProvider = Schema.Schema.Type + +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +): WebSearchProvider { + const override = process.env.OPENCODE_WEBSEARCH_PROVIDER + if (override === "exa" || override === "parallel") return override + if (flags.parallel) return "parallel" + if (flags.exa) return "exa" + + return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" +} + +export function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function webSearchModelName(extra: Tool.Context["extra"]) { + const model = extra?.model + if (!model || typeof model !== "object") return undefined + const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined + const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined + const id = "id" in model && typeof model.id === "string" ? model.id : undefined + return (apiID ?? id)?.slice(0, 100) +} + +function parallelAuthHeaders() { + const headers = { "User-Agent": `opencode/${InstallationVersion}` } + if (!process.env.PARALLEL_API_KEY) return headers + return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } +} + +function callProvider( + http: HttpClient.HttpClient, + provider: WebSearchProvider, + params: Schema.Schema.Type, + ctx: Tool.Context, +) { + if (provider === "parallel") { + return McpWebSearch.call( + http, + McpWebSearch.PARALLEL_URL, + "web_search", + McpWebSearch.ParallelSearchArgs, + { + objective: params.query, + search_queries: [params.query], + session_id: ctx.sessionID, + model_name: webSearchModelName(ctx.extra), + }, + "25 seconds", + parallelAuthHeaders(), + ) + } + + return McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "web_search_exa", + McpWebSearch.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) +} + export const WebSearchTool = Tool.define( "websearch", Effect.gen(function* () { @@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const provider = selectWebSearchProvider(ctx.sessionID) + const title = webSearchProviderLabel(provider) + yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + yield* ctx.ask({ permission: "websearch", patterns: [params.query], @@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define( livecrawl: params.livecrawl, type: params.type, contextMaxCharacters: params.contextMaxCharacters, + provider, }, }) - const result = yield* McpExa.call( - http, - "web_search_exa", - McpExa.SearchArgs, - { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - "25 seconds", - ) + const result = yield* callProvider(http, provider, params, ctx) return { output: result ?? "No search results found. Please try a different query.", - title: `Web search: ${params.query}`, - metadata: {}, + title: `${title}: ${params.query}`, + metadata: { provider }, } }).pipe(Effect.orDie), } diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt index 551c0f3b59..ad5238cbd5 100644 --- a/packages/opencode/src/tool/websearch.txt +++ b/packages/opencode/src/tool/websearch.txt @@ -1,12 +1,12 @@ -- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs - Provides up-to-date information for current events and recent data - Supports configurable result counts and returns the content from the most relevant websites - Use this tool for accessing information beyond knowledge cutoff - Searches are performed automatically within a single API call Usage notes: - - Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) - - Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) - Configurable context length for optimal LLM integration - Domain filtering and advanced search options available diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index c3ae249e57..6791a2e841 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1960,6 +1960,83 @@ test("wellknown URL with trailing slash is normalized", async () => { } }) +test("wellknown remote_config supports templated env vars in headers", async () => { + const originalFetch = globalThis.fetch + const originalToken = process.env.TEST_TOKEN + let wellknownFetchedUrl: string | undefined + let remoteFetchedUrl: string | undefined + let remoteHeaders: HeadersInit | undefined + globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => { + const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url + if (urlStr.includes(".well-known/opencode")) { + wellknownFetchedUrl = urlStr + return Promise.resolve( + new Response( + JSON.stringify({ + remote_config: { + url: "https://config.example.com/opencode.json", + headers: { + Authorization: "Bearer {env:TEST_TOKEN}", + }, + }, + }), + { status: 200 }, + ), + ) + } + if (urlStr.includes("config.example.com")) { + remoteFetchedUrl = urlStr + remoteHeaders = init?.headers + return Promise.resolve( + new Response( + JSON.stringify({ + mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, + }), + { status: 200 }, + ), + ) + } + return originalFetch(url, init) + }) as unknown as typeof fetch + + const fakeAuth = Layer.mock(Auth.Service)({ + all: () => + Effect.succeed({ + "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), + }), + }) + + const layer = Config.layer.pipe( + Layer.provide(testFlock), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(fakeAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(Npm.defaultLayer), + ) + + try { + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode") + expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json") + expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" }) + expect(config.mcp?.confluence?.enabled).toBe(true) + }), + ), + { git: true }, + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) + } finally { + globalThis.fetch = originalFetch + if (originalToken === undefined) delete process.env.TEST_TOKEN + else process.env.TEST_TOKEN = originalToken + } +}) + describe("resolvePluginSpec", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir() diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts new file mode 100644 index 0000000000..477fe2b428 --- /dev/null +++ b/packages/opencode/test/tool/websearch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { parseResponse } from "../../src/tool/mcp-websearch" +import { + selectWebSearchProvider, + webSearchModelName, + webSearchProviderLabel, +} from "../../src/tool/websearch" +import { ProviderID } from "../../src/provider/schema" +import { webSearchEnabled } from "../../src/tool/registry" + +const SESSION_ID = "ses_0196aabbccddeeff001122334455" + +describe("websearch provider", () => { + test("selects a stable provider per session", () => { + expect(selectWebSearchProvider(SESSION_ID)).toBe(selectWebSearchProvider(SESSION_ID)) + }) + + test("supports an operational override", () => { + const original = process.env.OPENCODE_WEBSEARCH_PROVIDER + + try { + process.env.OPENCODE_WEBSEARCH_PROVIDER = "parallel" + expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel") + + process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa" + expect(selectWebSearchProvider(SESSION_ID)).toBe("exa") + } finally { + if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER + else process.env.OPENCODE_WEBSEARCH_PROVIDER = original + } + }) + + test("routes to Exa when the Exa flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: false })).toBe("exa") + }) + + test("routes to Parallel when the Parallel flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel") + }) + + test("is only enabled for opencode or explicit websearch provider flags", () => { + expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false) + expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true) + }) + + test("uses branded labels", () => { + expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") + expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") + expect(webSearchProviderLabel(undefined)).toBe("Web Search") + }) + + test("uses the provider API model id for Parallel analytics", () => { + expect( + webSearchModelName({ + model: { + id: "claude-opus-4-7", + api: { id: "claude-opus-4.7" }, + }, + }), + ).toBe("claude-opus-4.7") + }) +}) + +describe("websearch MCP response parser", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: "search results", + }, + ], + }, + }) + + test("parses plain JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results") + }) + + test("parses SSE JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) + + test("ignores non-JSON SSE data frames", async () => { + await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) +}) From a5b7826c9099049766eec4068b2c5c99a9ebb43c Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 09:21:04 +0800 Subject: [PATCH 05/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(session):=20cherry-pic?= =?UTF-8?q?k=20=E4=B8=8A=E6=B8=B8=20A2=20compaction=20=E6=91=98=E8=A6=81?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E4=BF=9D=E6=8C=81=20(811954880)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filterCompacted 中当尾段消息位于 compaction 摘要之前时,按 [compaction..summary, tail..pre-compaction, summary+1..end] 顺序重排,确保 LLM 看到的语境与时间顺序一致。 同步更新 compaction.test.ts 与 messages-pagination.test.ts 4 处期望断言。 --- .codex_plan/TODO.md | 6 ++--- packages/opencode/src/session/message-v2.ts | 26 +++++++++++++++++++ .../opencode/test/session/compaction.test.ts | 4 ++- .../test/session/messages-pagination.test.ts | 8 +++--- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.codex_plan/TODO.md b/.codex_plan/TODO.md index cb6edd69d7..a0a2a5b0d8 100644 --- a/.codex_plan/TODO.md +++ b/.codex_plan/TODO.md @@ -23,9 +23,9 @@ ## Tier A — 核心稳定性 BUG(高价值) - [x] A1 重试 server_is_overloaded(`25ecf0af6`) -- [ ] A2 compaction 摘要顺序(`811954880`)— 延后(26 行 + tests) -- [ ] A3 取消子任务 child sessions(`75d141b57`)— 延后(~500 行) -- [ ] A4 vcs 批量 patch 边界(`6a5e32942`)— 延后(涉及 ui) +- [x] A2 compaction 摘要顺序(`811954880`)— 26 行 + 4 处测试断言更新 +- [~] A3 取消子任务 child sessions(`75d141b57`)— 延后;prompt.ts/task.ts 改造会破坏 bash 取消截断时序,与上游 task.test.ts 大改(-225+474)耦合,需独立深入排查 +- [~] A4 vcs 批量 patch 边界(`6a5e32942`)— N/A,fork vcs.ts 用 `structuredPatch` 逐文件计算,从不解析 git 批量输出,bug 不存在 - [ ] A5 vcs diff 内存控制(`d1f597b5b`)— 延后(~332 行) - [x] A6 sanitize surrogates(`6409aceb1`) - [x] A7 tool 返回 image+空 text 错误(`563177c6a`) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 8c9332ed53..e3c7c9559e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -1123,6 +1123,32 @@ export function filterCompacted(msgs: Iterable) { completed.add(msg.info.parentID) } result.reverse() + const compactionIndex = result.findLastIndex( + (msg) => + msg.info.role === "user" && + msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), + ) + const compaction = result[compactionIndex] + const part = compaction?.parts.find( + (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, + ) + const summaryIndex = compaction + ? result.findIndex( + (msg, index) => + index > compactionIndex && + msg.info.role === "assistant" && + msg.info.summary && + msg.info.parentID === compaction.info.id, + ) + : -1 + const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 + if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { + return [ + ...result.slice(compactionIndex, summaryIndex + 1), + ...result.slice(tailIndex, compactionIndex), + ...result.slice(summaryIndex + 1), + ] + } return result } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index eb035fa4f5..92a3d28908 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1210,7 +1210,9 @@ describe("session.compaction.process", () => { expect(captured).not.toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered[0]?.info.id).toBe(keep.id) + expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) + expect(filtered[1]?.info.role).toBe("assistant") + expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) } finally { await rt.dispose() diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 17370bbe62..d2665ceeab 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -833,7 +833,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) await svc.remove(session.id) }, @@ -888,7 +888,7 @@ describe("MessageV2.filterCompacted", () => { }) const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3]) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) const forked = await svc.fork({ sessionID: session.id }) const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) @@ -963,7 +963,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) await svc.remove(session.id) }, @@ -1040,7 +1040,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([u3, a3, c2, s2, u4, a4]) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) await svc.remove(session.id) }, From 6c5d45cc597b3d64e0467af53f46c9fd60b1ec6e Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 09:25:53 +0800 Subject: [PATCH 06/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(vcs):=20cherry-pick=20?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20A5+A4=20=E5=A4=A7=E5=9E=8B=20diff=20?= =?UTF-8?q?=E5=86=85=E5=AD=98=E6=8E=A7=E5=88=B6=20+=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=20patch=20=E8=BE=B9=E7=95=8C=20(d1f597b5b,=206a5e32942)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A5:用 git 原生 diff --patch 替代手动读取文件 + structuredPatch; 增加 maxOutputBytes 限流(单文件 10MB / 总量 10MB),溢出时输出空 patch。 新增 git.patch/patchAll/patchUntracked/statUntracked 接口,Vcs 层移除 AppFileSystem 依赖。 A4:splitGitPatch 用 (?:^|\n) 分割避免内容中包含 'diff --git' 导致的误切分。 packages/ui 部分按 fork 规则跳过。 --- packages/opencode/src/git/index.ts | 106 +++++++++- packages/opencode/src/project/vcs.ts | 217 +++++++++++++++------ packages/opencode/test/git/git.test.ts | 47 +++++ packages/opencode/test/project/vcs.test.ts | 56 ++++++ 4 files changed, 361 insertions(+), 65 deletions(-) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 16a8624474..fff1d70b2a 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -24,6 +24,7 @@ const fail = (err: unknown) => text: () => "", stdout: Buffer.alloc(0), stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + truncated: false, }) satisfies Result export type Kind = "added" | "deleted" | "modified" @@ -45,16 +46,28 @@ export type Stat = { readonly deletions: number } +export type Patch = { + readonly text: string + readonly truncated: boolean +} + +export interface PatchOptions { + readonly context?: number + readonly maxOutputBytes?: number +} + export interface Result { readonly exitCode: number readonly text: () => string readonly stdout: Buffer readonly stderr: Buffer + readonly truncated: boolean } export interface Options { readonly cwd: string readonly env?: Record + readonly maxOutputBytes?: number } export interface Interface { @@ -68,6 +81,10 @@ export interface Interface { readonly status: (cwd: string) => Effect.Effect readonly diff: (cwd: string, ref: string) => Effect.Effect readonly stats: (cwd: string, ref: string) => Effect.Effect + readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect + readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect + readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect + readonly statUntracked: (cwd: string, file: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -96,15 +113,31 @@ export const layer = Layer.effect( stderr: "pipe", }) const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) + const collect = (stream: typeof handle.stdout) => + Stream.runFold( + stream, + () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }), + (acc, chunk) => { + if (opts.maxOutputBytes === undefined) { + acc.chunks.push(chunk) + acc.bytes += chunk.length + return acc + } + + const remaining = opts.maxOutputBytes - acc.bytes + if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining)) + acc.bytes += chunk.length + acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes + return acc + }, + ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) + const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 }) return { exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), + text: () => stdout.buffer.toString("utf8"), + stdout: stdout.buffer, + stderr: stderr.buffer, + truncated: stdout.truncated || stderr.truncated, } satisfies Result }, Effect.scoped, @@ -240,6 +273,61 @@ export const layer = Layer.effect( }) }) + const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchUntracked = Effect.fn("Git.patchUntracked")(function* ( + cwd: string, + file: string, + options?: PatchOptions, + ) { + const result = yield* run( + [ + "diff", + "--no-index", + "--patch", + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) { + const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { + cwd, + maxOutputBytes: 4096, + }) + if (result.truncated) return + const parts = result.text().split("\t") + if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) + return { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat + }) + return Service.of({ run, branch, @@ -251,6 +339,10 @@ export const layer = Layer.effect( status, diff, stats, + patch, + patchAll, + patchUntracked, + statUntracked, }) }), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 24112cf442..8b3bedbf5b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,10 +1,8 @@ import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" -import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" @@ -12,20 +10,11 @@ import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) +const PATCH_CONTEXT_LINES = 2_147_483_647 +const MAX_PATCH_BYTES = 10_000_000 +const MAX_TOTAL_PATCH_BYTES = 10_000_000 -const count = (text: string) => { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) +const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) const nums = (list: Git.Stat[]) => new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) @@ -38,59 +27,170 @@ const merge = (...lists: Git.Item[][]) => { return [...out.values()] } -const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, +const emptyBatch = () => ({ patches: new Map(), capped: false }) + +const parseQuotedPath = (value: string) => { + let out = "" + for (let idx = 1; idx < value.length; idx++) { + const char = value[idx] + if (char === '"') return { value: out, end: idx + 1 } + if (char !== "\\") { + out += char + continue + } + + const next = value[++idx] + if (next === "t") out += "\t" + else if (next === "n") out += "\n" + else if (next === "r") out += "\r" + else if (next === '"' || next === "\\") out += next + else out += next ?? "" + } +} + +const parsePathToken = (value: string) => { + if (!value.startsWith('"')) return value.split("\t")[0] + return parseQuotedPath(value)?.value ?? value +} + +const fileFromDiffPath = (value: string | undefined) => { + if (!value || value === "/dev/null") return + const file = parsePathToken(value) + if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2) + return file +} + +const fileFromGitHeader = (header: string) => { + if (header.startsWith('"')) { + const first = parseQuotedPath(header) + const second = first ? header.slice(first.end).trimStart() : undefined + if (!second) return + if (!second.startsWith('"')) return fileFromDiffPath(second) + return fileFromDiffPath(parseQuotedPath(second)?.value) + } + + const separator = header.indexOf(" b/") + if (separator === -1) return + return fileFromDiffPath(header.slice(separator + 1)) +} + +const fileFromPatchChunk = (chunk: string) => { + const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1] + const before = /^--- (.+)$/m.exec(chunk)?.[1] + const file = fileFromDiffPath(next) ?? fileFromDiffPath(before) + if (file) return file + + const header = /^diff --git (.+)$/m.exec(chunk)?.[1] + return fileFromGitHeader(header ?? "") +} + +const splitGitPatch = (patch: Git.Patch) => { + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) + const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) + if (!patch.truncated) return chunks + return chunks.slice(0, -1) +} + +const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) { + if (list.length === 0) return { patches: new Map(), capped: false } + + const result = yield* git.patchAll(cwd, ref, { + context: PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES }) + + return { + patches: splitGitPatch(result).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? list[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.truncated, + } +}) + +const nativePatch = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, ref: string | undefined, - list: Git.Item[], - map: Map, + item: Git.Item, ) { - const base = ref ? yield* git.prefix(cwd) : "" - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = map.get(item.file) - return { - file: item.file, - patch: patch(item.file, before, after), - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) + const result = + item.code === "??" || !ref + ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + if (!result.truncated && result.text) return result.text + + if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES }) + return emptyPatch(item.file) }) -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, +const totalPatch = (file: string, patch: string, total: number) => { + if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } + log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES }) + return { patch: emptyPatch(file), capped: true } +} + +const patchForItem = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, ref: string | undefined, + item: Git.Item, + batch: { patches: Map; capped: boolean }, + capped: boolean, ) { - if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, nums(stats)) + if (capped) return emptyPatch(item.file) + + const batched = batch.patches.get(item.file) + if (batched !== undefined) return batched + if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + return yield* nativePatch(git, cwd, ref, item) }) -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, +const files = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, - ref: string, + ref: string | undefined, + list: Git.Item[], + map: Map, + batch: { patches: Map; capped: boolean }, ) { + const next: FileDiff[] = [] + let total = 0 + let capped = false + + for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { + const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined) + const patch = yield* patchForItem(git, cwd, ref, item, batch, capped) + const result: { patch: string; capped: boolean } = capped + ? { patch, capped: true } + : totalPatch(item.file, patch, total) + capped = capped || result.capped + if (!capped) { + total += Buffer.byteLength(result.patch) + capped = total >= MAX_TOTAL_PATCH_BYTES + } + next.push({ + file: item.file, + patch: result.patch, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + }) + } + + return next +}) + +const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) { const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { concurrency: 3, }) return yield* files( - fs, git, cwd, ref, @@ -99,9 +199,15 @@ const compare = Effect.fnUntraced(function* ( extra.filter((item) => item.code === "??"), ), nums(stats), + yield* batchPatches(git, cwd, ref, list), ) }) +const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { + if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch()) + return yield* diffAgainstRef(git, cwd, ref) +}) + export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type Mode = Schema.Schema.Type @@ -147,10 +253,9 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* AppFileSystem.Service const git = yield* Git.Service const bus = yield* Bus.Service const scope = yield* Scope.Scope @@ -204,23 +309,19 @@ export const layer: Layer.Layer { }) }) + test("patch() returns capped native patch output", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8") + + await withGit(async (rt) => { + const [patch, all, capped] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("-before") + expect(patch.text).toContain("+after") + expect(all.truncated).toBe(false) + expect(all.text).toContain("diff --git") + expect(all.text).toContain("other.txt") + expect(all.text).toContain("+new") + expect(capped.truncated).toBe(true) + expect(capped.text).toBe("") + }) + }) + + test("patchUntracked() and statUntracked() handle added files", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8") + + await withGit(async (rt) => { + const [patch, stat] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("+one") + expect(patch.text).toContain("+two") + expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 })) + }) + }) + test("show() returns empty text for binary blobs", async () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index a2a5cff601..974ecd3a40 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,5 +1,6 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { parsePatch } from "diff" import { Effect } from "effect" import fs from "fs/promises" import path from "path" @@ -233,6 +234,7 @@ describe("Vcs diff", () => { }), ]), ) + expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") }) }) @@ -258,6 +260,60 @@ describe("Vcs diff", () => { }) }) + test("diff('git') keeps batched patches aligned for type changes", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() + await fs.unlink(path.join(tmp.path, "a.txt")) + await fs.symlink("target", path.join(tmp.path, "a.txt")) + await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") + + expect(a?.patch).toContain("deleted file mode") + expect(a?.patch).toContain("new file mode") + expect(b?.patch).toContain("+new") + }) + }) + + test( + "diff('git') keeps carriage returns inside patch hunks", + async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") + + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, + 20_000, + ) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() From 58759456c081844979b50508b7c63b90180e47ae Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 09:28:51 +0800 Subject: [PATCH 07/25] =?UTF-8?q?=E6=96=87=E6=A1=A3:=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20cherry-pick=20TODO=EF=BC=88A2/A4/A5=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=EF=BC=8CA3/B3=20=E5=BB=B6=E5=90=8E=E8=AF=B4=E6=98=8E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex_plan/TODO.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.codex_plan/TODO.md b/.codex_plan/TODO.md index a0a2a5b0d8..a0b231f388 100644 --- a/.codex_plan/TODO.md +++ b/.codex_plan/TODO.md @@ -25,8 +25,8 @@ - [x] A1 重试 server_is_overloaded(`25ecf0af6`) - [x] A2 compaction 摘要顺序(`811954880`)— 26 行 + 4 处测试断言更新 - [~] A3 取消子任务 child sessions(`75d141b57`)— 延后;prompt.ts/task.ts 改造会破坏 bash 取消截断时序,与上游 task.test.ts 大改(-225+474)耦合,需独立深入排查 -- [~] A4 vcs 批量 patch 边界(`6a5e32942`)— N/A,fork vcs.ts 用 `structuredPatch` 逐文件计算,从不解析 git 批量输出,bug 不存在 -- [ ] A5 vcs diff 内存控制(`d1f597b5b`)— 延后(~332 行) +- [x] A4 vcs 批量 patch 边界(`6a5e32942`)— 后端 splitGitPatch 正则修正;packages/ui 按 fork 规则跳过 +- [x] A5 vcs diff 内存控制(`d1f597b5b`)— git 原生 diff + maxOutputBytes 限流,移除 AppFileSystem 依赖 - [x] A6 sanitize surrogates(`6409aceb1`) - [x] A7 tool 返回 image+空 text 错误(`563177c6a`) - [x] A8 read 阻止不支持图片格式(`51e310c9c`) @@ -39,7 +39,7 @@ ## Tier B — 服务/Auth/Format 修复(合理价值) - [x] B1 formatter stdout/stderr ignore 恢复(`293bb422f`) - [x] B2 auth login stderr 继承(`8e016b470`) -- [ ] B3 保留 auth token credentials(`ca6150d6f`)— packages/app(前端,延后) +- [~] B3 保留 auth token credentials(`ca6150d6f`)— 延后;fork 的 packages/app 缺 `terminal-websocket-url.ts` 模块(逻辑内联在 terminal.tsx),上游 8 文件 176 行重构需按 fork 架构重写 - [x] B4 task 子 session 保留 external_dir/deny 父权限(`d7701dbfb`) - [~] B5 archived timestamp schema 用 finite(`16ddf5f55`)— N/A,fork 用 NonNegativeInt 已更严 From 424047eda614e10c5ee8ad85eaa8bfb1f59eec3e Mon Sep 17 00:00:00 2001 From: lex Date: Sat, 9 May 2026 10:18:34 +0800 Subject: [PATCH 08/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(review):=20=E5=A4=84?= =?UTF-8?q?=E7=90=86=20cherry-pick=20REVIEW=20Minor=20=E9=A1=B9=20A6/A8/A1?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A6 transform.ts 文本过滤改回 text !== ""(对齐上游 4e14f7951) - A8 read.test.ts 补 unsupported image mime 回退用例(bmp/tiff/avif) - A12 skill/index.ts externalDirs 提到 if 块外(对齐上游 ffe0314c4 作用域) --- .codex_plan/TODO.md | 6 ++++++ packages/opencode/src/provider/transform.ts | 4 ++-- packages/opencode/src/skill/index.ts | 8 ++++---- packages/opencode/test/tool/read.test.ts | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.codex_plan/TODO.md b/.codex_plan/TODO.md index a0b231f388..24ff785216 100644 --- a/.codex_plan/TODO.md +++ b/.codex_plan/TODO.md @@ -68,3 +68,9 @@ 3. 评估:直接适用 / 需调整 / 不适用 4. 实施修改 + `bun typecheck` 5. 更新本 TODO + +## REVIEW Minor 修复(2026-05-09) +- [x] A6 `transform.ts` 文本过滤改回 `text !== ""`(与 upstream 4e14f7951 对齐,移除 trim) +- [x] A8 补 `test/tool/read.test.ts` unsupported mime 回退用例(bmp/tiff/avif) +- [x] A12 `skill/index.ts` `externalDirs` 提到 if 块外(与 upstream ffe0314c4 结构对齐) +- 验证:bun typecheck 干净;`bun test test/provider/transform.test.ts test/tool/read.test.ts test/skill/` 194 pass / 0 fail / 388 expect diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 43307375b7..04b9653f29 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -137,7 +137,7 @@ function normalizeMessages( if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { if (part.type === "text") { - return part.text.trim() !== "" + return part.text !== "" } if (part.type === "reasoning") { return ( @@ -165,7 +165,7 @@ function normalizeMessages( if (!Array.isArray(msg.content)) return msg const filtered = msg.content.filter((part) => { if (part.type === "text") { - return part.text.trim() !== "" + return part.text !== "" } if (part.type === "reasoning") { return ( diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index ce998dc7e8..b92a2846a1 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -153,11 +153,11 @@ const discoverSkills = Effect.fnUntraced(function* ( ) { const state: ScanState = { matches: new Set(), dirs: new Set() } - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - const externalDirs: string[] = [] - if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) externalDirs.push(CLAUDE_EXTERNAL_DIR) - externalDirs.push(AGENTS_EXTERNAL_DIR) + const externalDirs: string[] = [] + if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) externalDirs.push(CLAUDE_EXTERNAL_DIR) + externalDirs.push(AGENTS_EXTERNAL_DIR) + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { for (const dir of externalDirs) { const root = path.join(Global.Path.home, dir) if (!(yield* fsys.isDir(root))) continue diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index db66787549..c20b084372 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -440,6 +440,24 @@ root_type Monster;` expect(result.output).toContain("table Monster") }), ) + + it.live("falls through unsupported image mime types to text", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const cases = [ + ["image.bmp", "BM text content"], + ["photo.tiff", "II text content"], + ["photo.avif", "avif text content"], + ] as const + + for (const item of cases) { + yield* put(path.join(dir, item[0]), item[1]) + const result = yield* exec(dir, { filePath: path.join(dir, item[0]) }) + expect(result.attachments).toBeUndefined() + expect(result.output).toContain(item[1]) + } + }), + ) }) describe("tool.read loaded instructions", () => { From 8cd09f230e0ab98cac888a81207d9b0b1bb13583 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 11:09:22 +0800 Subject: [PATCH 09/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A(hook)=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Claude=20Code=208-event=20=C3=97=205-handl?= =?UTF-8?q?er=20=E5=8D=8F=E8=AE=AE=201:1=20=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.ts 重构为 HookHandler 多态分派表,支持 command/mcp/http/prompt/agent 五种 handler。8 事件白名单(删除 Notification UI 事件):PreToolUse、 PostToolUse、UserPromptSubmit、SessionStart、SessionEnd、Stop、SubagentStop、 PreCompact。所有 handler 走 Effect.exit 兜底 silent allow,HookSpecificOutput 使用 discriminated union 防 tag 漂移。 agent handler 内置 5 工具 LLM loop(read_file/list_dir/grep/bash 只读白名单/ synthetic_output),MAX_AGENT_TURNS=200,DEFAULT_AGENT_TIMEOUT_MS=60s。bash 工具双层防御:BASH_WHITELIST_SINGLE/PAIR + FORBIDDEN_META 元字符正则;Windows 主动拒绝;MAX_BASH_OUTPUT 8000 字节截断。 session/prompt.ts 兑现 CC 协议三处语义:permissionDecision=deny 短路、 hookSpecificOutput.updatedInput 重写工具入参、UserPromptSubmit additionalContexts 注入 user message。 permission/index.ts 删除 Notification trigger 段(13 行),与 8 事件白名单对齐。 defaultLayer 注入 FetchHttpClient + Provider + Auth + AppFileSystem + CrossSpawnSpawner,agent loop 通过 Effect Service 获取 spawner/fs。 测试:settings.test.ts 51 PASS(12 describe,覆盖 8 事件 × WP-4A/4B/4C/4D-2/4F 矩阵 20/32),agent-tools.test.ts 12 PASS(5 describe)。typecheck 0 错。 --- packages/opencode/src/hook/agent-tools.ts | 341 +++++++ packages/opencode/src/hook/settings.ts | 709 +++++++++++-- packages/opencode/src/permission/index.ts | 13 - packages/opencode/src/session/prompt.ts | 55 +- .../opencode/test/hook/agent-tools.test.ts | 235 +++++ packages/opencode/test/hook/settings.test.ts | 960 +++++++++++++++++- 6 files changed, 2190 insertions(+), 123 deletions(-) create mode 100644 packages/opencode/src/hook/agent-tools.ts create mode 100644 packages/opencode/test/hook/agent-tools.test.ts diff --git a/packages/opencode/src/hook/agent-tools.ts b/packages/opencode/src/hook/agent-tools.ts new file mode 100644 index 0000000000..bb194cc8cb --- /dev/null +++ b/packages/opencode/src/hook/agent-tools.ts @@ -0,0 +1,341 @@ +/** + * WP-4D micro-WP-1 — agent-handler tool set (LLM-facing). + * + * Builds the 5-tool palette consumed by the WP-4D-2 agent loop: + * read_file / list_dir / grep / bash / synthetic_output + * + * Design contract: + * - All tools are pure ai-SDK `Tool` values; no Effect dependencies on the + * LLM-side execute path. Effect Services (spawner / fs) are pre-resolved + * by the caller and captured via closure. + * - Every `execute` is wrapped in try/catch. Errors return + * `{ output: "Error: " }` and **never throw** — the agent loop + * must be able to keep running and let the model decide whether to retry. + * - bash uses a strict read-only whitelist. The token list and forbidden + * metachar regex are the v1 contract; expanding either requires a + * deliberate WP, not a one-off addition. + * - synthetic_output writes into the caller-owned `captured.value` slot; + * the loop polls it after each turn to decide termination. + */ +import path from "path" +import { Effect, Stream } from "effect" +import { ChildProcess } from "effect/unstable/process" +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { type Tool, tool, jsonSchema } from "ai" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import * as Log from "@opencode-ai/core/util/log" +import type { HookJSONOutput } from "./settings" + +const log = Log.create({ service: "hook.agent-tools" }) + +// ── bash whitelist (read-only, POSIX-only) ────────────────────── + +const BASH_WHITELIST_SINGLE = new Set([ + "ls", + "cat", + "grep", + "find", + "test", + "wc", + "head", + "tail", + "sort", + "uniq", + "awk", + "echo", + "pwd", + "which", + "file", + "stat", +]) + +const BASH_WHITELIST_PAIR = new Set([ + "git status", + "git log", + "git diff", + "git show", + "sed -n", + "du -sh", +]) + +/** + * Reject metacharacters that enable composition / redirection / substitution. + * v1 only allows a single command invocation — no pipes, chains, redirects, + * background, command substitution, or backticks. + */ +const FORBIDDEN_META = /[|;&`$<>]|\$\(|\)\s*$/ + +function whitelistReject(cmd: string): string | null { + const trimmed = cmd.trim() + if (!trimmed) return "empty command" + if (FORBIDDEN_META.test(trimmed)) + return `compound/redirect not allowed in v1: ${trimmed.slice(0, 60)}` + const tokens = trimmed.split(/\s+/) + const first = tokens[0] + const pair = tokens.length >= 2 ? `${tokens[0]} ${tokens[1]}` : "" + if (BASH_WHITELIST_SINGLE.has(first)) return null + if (pair && BASH_WHITELIST_PAIR.has(pair)) return null + return `command "${first}" not in read-only whitelist` +} + +// Exported for unit tests only — not part of the runtime surface. +export const __test__ = { whitelistReject, BASH_WHITELIST_SINGLE, BASH_WHITELIST_PAIR, FORBIDDEN_META } + +// ── helpers ───────────────────────────────────────────────────── + +function resolvePath(p: string, cwd: string): string { + return path.isAbsolute(p) ? p : path.join(cwd, p) +} + +const MAX_BASH_OUTPUT = 8000 +const MAX_GREP_RESULTS_DEFAULT = 100 +const MAX_READ_LINES_DEFAULT = 2000 + +// ── synthetic_output schema (mirrors HookJSONOutput) ──────────── + +const HOOK_OUTPUT_SCHEMA = { + type: "object", + properties: { + continue: { type: "boolean" }, + stopReason: { type: "string" }, + suppressOutput: { type: "boolean" }, + systemMessage: { type: "string" }, + decision: { type: "string", enum: ["approve", "block"] }, + reason: { type: "string" }, + hookSpecificOutput: { + type: "object", + properties: { + hookEventName: { type: "string" }, + permissionDecision: { type: "string", enum: ["allow", "deny", "ask"] }, + permissionDecisionReason: { type: "string" }, + updatedInput: { type: "object" }, + additionalContext: { type: "string" }, + initialUserMessage: { type: "string" }, + updatedMCPToolOutput: {}, + }, + }, + }, +} as const + +// Compile-time guard: the synthetic_output schema must remain a structural +// subset of HookJSONOutput. If HookJSONOutput grows a required field, this +// assignment has to be updated alongside HOOK_OUTPUT_SCHEMA above. +const _schemaTypeCheck: (a: HookJSONOutput) => HookJSONOutput = (a) => a +void _schemaTypeCheck + +// ── factory ───────────────────────────────────────────────────── + +export interface BuildAgentToolsDeps { + spawner: ChildProcessSpawner["Service"] + fs: AppFileSystem.Interface + signal: AbortSignal + cwd: string + /** Mutable slot the synthetic_output tool writes to. Loop polls .value. */ + captured: { value: HookJSONOutput | null } +} + +export function buildAgentTools(deps: BuildAgentToolsDeps): Record { + const { spawner, fs, signal, cwd, captured } = deps + + const read_file = tool({ + description: "Read a UTF-8 file. Optional 1-indexed offset and max line limit (default 2000).", + inputSchema: jsonSchema({ + type: "object", + properties: { + path: { type: "string", description: "Absolute or cwd-relative path" }, + offset: { type: "number", description: "1-indexed line offset (optional)" }, + limit: { type: "number", description: "Max number of lines (default 2000)" }, + }, + required: ["path"], + }), + execute: async (args: any) => { + try { + const resolved = resolvePath(String(args.path), cwd) + const text = await Effect.runPromise(fs.readFileString(resolved) as Effect.Effect) + const offset = typeof args.offset === "number" && args.offset > 0 ? args.offset - 1 : 0 + const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : MAX_READ_LINES_DEFAULT + const lines = text.split("\n").slice(offset, offset + limit) + return { output: lines.join("\n") } + } catch (e: any) { + return { output: `Error: ${e?.message ?? String(e)}` } + } + }, + }) + + const list_dir = tool({ + description: "List directory entries. Directories are suffixed with '/'.", + inputSchema: jsonSchema({ + type: "object", + properties: { + path: { type: "string" }, + recursive: { type: "boolean", description: "default false" }, + }, + required: ["path"], + }), + execute: async (args: any) => { + try { + const root = resolvePath(String(args.path), cwd) + const recursive = args.recursive === true + const lines: string[] = [] + + const walk = async (dir: string, rel: string): Promise => { + const entries = await Effect.runPromise(fs.readDirectoryEntries(dir) as Effect.Effect) + for (const e of entries) { + const display = (rel ? rel + "/" : "") + e.name + (e.type === "directory" ? "/" : "") + lines.push(display) + if (recursive && e.type === "directory") { + await walk(path.join(dir, e.name), (rel ? rel + "/" : "") + e.name) + } + } + } + + await walk(root, "") + return { output: lines.join("\n") } + } catch (e: any) { + return { output: `Error: ${e?.message ?? String(e)}` } + } + }, + }) + + const grep = tool({ + description: + "Regex search across a file or directory. Returns matches as 'path:line:content', truncated at max_results (default 100).", + inputSchema: jsonSchema({ + type: "object", + properties: { + pattern: { type: "string", description: "JS RegExp pattern" }, + path: { type: "string", description: "File or directory" }, + include: { type: "string", description: "Optional suffix filter, e.g. '.ts' or '*.ts'" }, + max_results: { type: "number", description: "default 100" }, + }, + required: ["pattern", "path"], + }), + execute: async (args: any) => { + try { + const re = new RegExp(String(args.pattern)) + const root = resolvePath(String(args.path), cwd) + const max = typeof args.max_results === "number" && args.max_results > 0 ? args.max_results : MAX_GREP_RESULTS_DEFAULT + // Treat include as a suffix filter only — minimatch is not in the + // hook subsystem's dep set and grep is best-effort here. Strip a + // leading '*' so '*.ts' and '.ts' both work. + const includeRaw = typeof args.include === "string" ? args.include : "" + const suffix = includeRaw.startsWith("*") ? includeRaw.slice(1) : includeRaw + + const out: string[] = [] + let total = 0 + + const scanFile = async (filepath: string): Promise => { + if (suffix && !filepath.endsWith(suffix)) return + let content: string + try { + content = await Effect.runPromise(fs.readFileString(filepath) as Effect.Effect) + } catch { + return + } + const lines = content.split("\n") + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i])) { + total++ + if (out.length < max) out.push(`${filepath}:${i + 1}:${lines[i]}`) + } + } + } + + const walk = async (dir: string): Promise => { + const entries = await Effect.runPromise(fs.readDirectoryEntries(dir) as Effect.Effect) + for (const e of entries) { + const child = path.join(dir, e.name) + if (e.type === "directory") await walk(child) + else if (e.type === "file") await scanFile(child) + } + } + + const isDir = await Effect.runPromise(fs.isDir(root)) + if (isDir) await walk(root) + else await scanFile(root) + + let body = out.join("\n") + if (total > out.length) body += `\n... (${total - out.length} more)` + return { output: body } + } catch (e: any) { + return { output: `Error: ${e?.message ?? String(e)}` } + } + }, + }) + + const bash = tool({ + description: + "Run a single read-only shell command (whitelist enforced: ls/cat/grep/find/git status/git log/git diff/git show/sed -n/test/wc/head/tail/sort/uniq/awk/echo/pwd/which/file/stat/du -sh). No pipes, redirects, or substitution.", + inputSchema: jsonSchema({ + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }), + execute: async (args: any) => { + const command = String(args?.command ?? "") + const reject = whitelistReject(command) + if (reject) return { output: `Error: ${reject}` } + + // v1 POSIX-only. Windows lacks `sh -c` — bail out loudly so the LLM can + // adapt rather than the host hanging on a missing binary. + if (process.platform === "win32") { + return { output: "Error: bash tool unavailable on Windows in v1 (POSIX only)" } + } + + try { + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make("sh", ["-c", command], { + cwd, + extendEnv: true, + stdin: "ignore", + }), + ) + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + return { stdout, stderr, code } + }), + ) as Effect.Effect<{ stdout: string; stderr: string; code: number }, unknown>, + ) + + const body = `exit=${result.code}\n${result.stdout}` + (result.stderr ? `\n[stderr]\n${result.stderr}` : "") + return { output: body.length > MAX_BASH_OUTPUT ? body.slice(0, MAX_BASH_OUTPUT) + "\n... (truncated)" : body } + } catch (e: any) { + return { output: `Error: ${e?.message ?? String(e)}` } + } + }, + }) + + const synthetic_output = tool({ + description: "Emit the final hook decision and stop. Call this exactly once when ready to terminate.", + inputSchema: jsonSchema(HOOK_OUTPUT_SCHEMA as Record), + execute: async (args: any) => { + try { + captured.value = args as HookJSONOutput + return { output: "ok" } + } catch (e: any) { + return { output: `Error: ${e?.message ?? String(e)}` } + } + }, + }) + + // signal is captured for the loop's transport-level cancellation; the + // tool execute paths above don't directly consume it (Effect.scoped on + // the bash spawn unwinds child handles when the runtime is interrupted + // by the outer agent loop). Reference here is intentional to keep the + // dep contract honest without a noisy unused-param warning. + void signal + void log + + return { read_file, list_dir, grep, bash, synthetic_output } +} + +export * as AgentTools from "./agent-tools" diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 9493b48397..0fce60316a 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -11,13 +11,17 @@ * 5. /.claude/settings.local.json (CC project local) * 6. /.opencode/settings.local.json (OpenCode project local) * - * Supports the full 9-event Claude Code hook surface: + * Supports the 8-event Claude Code hook surface (fork drops Notification — + * permission UI uses the internal bus instead): * PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, - * Notification, PreCompact, SessionStart, SessionEnd + * PreCompact, SessionStart, SessionEnd * * Hook entry types: * - { type: "command", command: "", timeout?: } * - { type: "mcp", command: "mcp____", timeout?: } + * - { type: "http", command: "", timeout?: } + * - { type: "prompt", command: "", timeout?: } + * - { type: "agent", command: "", timeout?: } * * stdin JSON envelope per Claude Code spec: * { hook_event_name, session_id, transcript_path, cwd, ...event-specific } @@ -38,11 +42,22 @@ import path from "path" import os from "os" import { existsSync, readFileSync } from "fs" import { spawn } from "child_process" +import { createHash } from "crypto" import { Effect, Layer, Context } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import z from "zod" +import { generateObject, generateText, type ModelMessage } from "ai" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Log from "@opencode-ai/core/util/log" import { Global } from "@opencode-ai/core/global" import { InstanceState } from "@/effect/instance-state" import { MCP } from "@/mcp" +import { Provider } from "@/provider/provider" +import { Auth } from "@/auth" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { buildAgentTools } from "./agent-tools" const log = Log.create({ service: "hook.settings" }) @@ -54,15 +69,54 @@ export type HookEvent = | "UserPromptSubmit" | "Stop" | "SubagentStop" - | "Notification" | "PreCompact" | "SessionStart" | "SessionEnd" interface HookCommand { - type: "command" | "mcp" + /** + * Hook execution kind. All 5 types fully implemented: + * - `command`: shell command via stdin/stdout JSON envelope + * - `mcp`: invoke MCP tool via `mcp____` prefix + * - `http`: POST envelope to URL, parse JSON body + * - `prompt`: LLM call with structured output (HookJSONOutput schema) + * - `agent`: autonomous agent loop with bash/read_file/list_dir/grep tools + */ + type: "command" | "mcp" | "http" | "prompt" | "agent" command: string timeout?: number + /** + * Shell selector for `type:"command"`. CC honors `bash` (default on POSIX) and `powershell` + * (default on Windows). Currently a schema placeholder — execShell still picks based on platform. + */ + shell?: "bash" | "powershell" + /** + * Conditional gate. CC evaluates this as a boolean expression in the matcher's runtime + * context; fork treats it as a placeholder for now (always considered truthy when present). + * Reserved for阶段 6 short-circuit logic. + */ + if?: string + /** + * Async execution flag. CC's AsyncHookRegistry routes async hooks via attachments / task-notification. + * Fork currently runs everything sync; this is a P2 schema placeholder. + */ + async?: boolean + /** + * Companion to `async`: when an async hook exits with code 2, CC re-wakes the agent via + * `wrapInSystemReminder` + `task-notification`. Schema placeholder for the same P2 work. + */ + asyncRewake?: boolean + /** + * Fork superset (CC has no equivalent). When present, each entry is exported into the hook + * subprocess env as `CLAUDE_PLUGIN_OPTION_=JSON.stringify(value)`. + */ + options?: Record + /** + * Runtime metadata injected by `loadChain` — absolute directory of the settings file that + * declared this hook. Drives `CLAUDE_PLUGIN_ROOT` / `CLAUDE_PLUGIN_DATA`. The `__` prefix + * keeps it out of any future schema-validation pass. + */ + __sourceDir?: string } interface HookMatcher { @@ -74,22 +128,79 @@ interface Settings { hooks?: Partial> } -interface HookJSONOutput { +export interface HookJSONOutput { continue?: boolean stopReason?: string suppressOutput?: boolean systemMessage?: string decision?: "approve" | "block" reason?: string - hookSpecificOutput?: { - hookEventName?: string - permissionDecision?: "allow" | "deny" | "ask" - permissionDecisionReason?: string - additionalContext?: string - updatedInput?: Record - } + hookSpecificOutput?: HookSpecificOutput } +// Loose flat zod schema mirroring HookJSONOutput — used by the prompt handler to +// constrain LLM structured output. Intentionally NOT `.strict()`: lets the model +// emit unknown fields without failing parse. Single source of truth lives next +// to the HookJSONOutput interface; not exported (settings.ts internal only). +const HookSpecificOutputZodSchema = z.object({ + hookEventName: z.string().optional(), + permissionDecision: z.enum(["allow", "deny", "ask"]).optional(), + permissionDecisionReason: z.string().optional(), + updatedInput: z.record(z.string(), z.unknown()).optional(), + additionalContext: z.string().optional(), + initialUserMessage: z.string().optional(), + updatedMCPToolOutput: z.unknown().optional(), +}) + +const HookJSONOutputZodSchema = z.object({ + continue: z.boolean().optional(), + stopReason: z.string().optional(), + suppressOutput: z.boolean().optional(), + systemMessage: z.string().optional(), + decision: z.enum(["approve", "block"]).optional(), + reason: z.string().optional(), + hookSpecificOutput: HookSpecificOutputZodSchema.optional(), +}) + +/** + * hookSpecificOutput discriminated union — Claude Code 1:1. + * 仅 5 个事件有 union 分支;Stop / SubagentStop / PreCompact / SessionEnd + * 在 CC types/hooks.ts:50-166 中无对应 case,仅消费顶层字段(continue/decision/reason 等)。 + * + * 所有字段保持 optional 以便解析端宽松降级。`hookEventName` 用作 discriminator。 + * 最后一个 fallback 分支让消费端 cast 时不会因为 hookEventName 未列出(或拼错)而报错。 + */ +export type HookSpecificOutput = + | { + hookEventName: "PreToolUse" + permissionDecision?: "allow" | "deny" | "ask" + permissionDecisionReason?: string + updatedInput?: Record + additionalContext?: string + } + | { + hookEventName: "UserPromptSubmit" + additionalContext?: string + } + | { + hookEventName: "SessionStart" + additionalContext?: string + initialUserMessage?: string + } + | { + hookEventName: "PostToolUse" + additionalContext?: string + updatedMCPToolOutput?: unknown + } + | { + /** + * Fallback — accept future / unknown event names without crashing the parser. + * NO index signature here: an `[key: string]: unknown` would poison every other + * branch's narrowed property types into `unknown`. + */ + hookEventName?: string + } + /** Per-event payload — discriminated union, TS narrows automatically. */ export type HookPayload = | { event: "PreToolUse"; toolName: string; toolInput: Record } @@ -102,7 +213,6 @@ export type HookPayload = | { event: "UserPromptSubmit"; prompt: string } | { event: "Stop"; stopHookActive: boolean } | { event: "SubagentStop"; stopHookActive: boolean } - | { event: "Notification"; message: string } | { event: "PreCompact" trigger: "manual" | "auto" @@ -121,6 +231,12 @@ export interface TriggerContext { sessionID: string /** Absolute path to a transcript file (may not yet exist). Empty string if N/A. */ transcriptPath: string + /** CC envelope: "plan" | "default"(fork 通过 agentToPermissionMode 映射 agent name 得出)。其他模式(acceptEdits/bypassPermissions)fork 暂不支持。 */ + permissionMode?: string + /** CC envelope: subagent ID(仅 SubagentStop / 子 agent 上下文有值)*/ + agentID?: string + /** CC envelope: subagent type 名称 */ + agentType?: string } export interface TriggerResult { @@ -141,13 +257,62 @@ export interface TriggerResult { updatedInput?: Record } +/** + * Map fork agent.name to CC `permission_mode` envelope value. + * + * fork 没有 CC 那种 permission_mode 全局枚举(default / acceptEdits / bypassPermissions / plan)。 + * 用 agent name 替代:plan agent 等价于 plan mode,其余 primary agent 等价于 default mode。 + * acceptEdits / bypassPermissions 在 fork 中无对应概念 — 不输出,避免给用户 hook 脚本造假信号。 + */ +export function agentToPermissionMode(agentName: string | undefined): "plan" | "default" { + return agentName === "plan" ? "plan" : "default" +} + +/** + * Per-plugin data directory layout (CC plugin contract): + * /hooks/-- + * + * Hash suffix disambiguates two plugins whose paths happen to share the same + * `/` tail (e.g. installed under different roots). + */ +function computeDataDir(sourceDir: string): string { + const hash = createHash("sha256").update(sourceDir).digest("hex").slice(0, 6) + const parent = path.basename(path.dirname(sourceDir)) + const base = path.basename(sourceDir) + return path.join(Global.Path.data, "hooks", `${parent}-${base}-${hash}`) +} + +/** + * Expand CC-compatible template variables in `entry.command`. + * + * ${CLAUDE_PLUGIN_ROOT} → entry.__sourceDir + * ${CLAUDE_PLUGIN_DATA} → computeDataDir(entry.__sourceDir) + * ${user_config.} → entry.options?.[key] (string passthrough; non-string → JSON.stringify) + * + * Unknown / unresolvable templates are left verbatim so the shell's own env + * expansion (or the user's escape strategy) still has a chance to handle them. + * Read-only: never mutates `entry`. + */ +function expandCommand(entry: HookCommand): string { + return entry.command.replace(/\$\{([^}]+)\}/g, (full, key) => { + if (key === "CLAUDE_PLUGIN_ROOT" && entry.__sourceDir) return entry.__sourceDir + if (key === "CLAUDE_PLUGIN_DATA" && entry.__sourceDir) return computeDataDir(entry.__sourceDir) + if (key.startsWith("user_config.")) { + const optKey = key.slice("user_config.".length) + const v = entry.options?.[optKey] + if (v !== undefined) return typeof v === "string" ? v : JSON.stringify(v) + } + return full + }) +} + // ── Matcher ───────────────────────────────────────────────────── /** * Match a string (typically tool name) against a CC matcher pattern. * Supports: exact, pipe list, regex, wildcard "*"/empty. * - * For non-tool events (Stop, Notification, etc.) callers pass empty target; + * For non-tool events (Stop, PreCompact, etc.) callers pass empty target; * matcher should usually be undefined/"*" in those configs. */ function matches(matcher: string | undefined, target: string): boolean { @@ -178,6 +343,17 @@ function readJSON(filepath: string): Settings | null { if (!existsSync(filepath)) return null try { const data = JSON.parse(readFileSync(filepath, "utf8")) as Settings + // Stamp every HookCommand with the directory of the settings file that declared it. + // execShell uses this to populate CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA. + if (data.hooks) { + const sourceDir = path.dirname(filepath) + for (const matchers of Object.values(data.hooks)) { + if (!matchers) continue + for (const m of matchers) { + for (const h of m.hooks ?? []) h.__sourceDir = sourceDir + } + } + } log.info("loaded hook settings", { path: filepath, events: Object.keys(data.hooks ?? {}), @@ -236,28 +412,79 @@ function loadChain(directory: string, worktree: string): Settings { ) } - const layers = candidates.map(readJSON).filter((s): s is Settings => s !== null) + const layers = candidates + .map((fp) => { + const data = readJSON(fp) + if (data) warnUnsupportedFields(data.hooks, path.dirname(fp)) + return data + }) + .filter((s): s is Settings => s !== null) return mergeSettings(layers) } +/** + * Internal: scan loaded settings for HookCommand fields the fork has not yet implemented + * (`async`, `asyncRewake`, `if`, `shell`) and emit a single `log.warn` per settings file. + * Runtime still proceeds — these fields are silently ignored. Exported for unit testing + * only; not part of the public surface. + */ +export function warnUnsupportedFields( + hooks: Settings["hooks"], + sourceDir: string, +): void { + if (!hooks) return + const unsupported: Array<{ field: string; value: unknown; eventName: string }> = [] + for (const [eventName, matchers] of Object.entries(hooks)) { + if (!matchers) continue + for (const m of matchers) { + for (const h of m.hooks ?? []) { + if (h.async !== undefined) unsupported.push({ field: "async", value: h.async, eventName }) + if (h.asyncRewake !== undefined) + unsupported.push({ field: "asyncRewake", value: h.asyncRewake, eventName }) + if (h.if !== undefined) unsupported.push({ field: "if", value: h.if, eventName }) + if (h.shell !== undefined) unsupported.push({ field: "shell", value: h.shell, eventName }) + // All 5 known types now have handlers; type-level unsupported set is empty by design. + } + } + } + if (unsupported.length > 0) { + log.warn("hook settings contains unsupported fields (will be ignored or fail at runtime)", { + sourceDir, + unsupported, + }) + } +} + // ── Shell command runner ──────────────────────────────────────── const DEFAULT_TIMEOUT_MS = 60_000 // CC default function execShell( - command: string, + entry: HookCommand, stdinJSON: string, cwd: string, - timeoutSec?: number, ): Promise<{ exitCode: number | null; stdout: string; stderr: string; spawnError?: string }> { return new Promise((resolve) => { - const timeoutMs = timeoutSec ? timeoutSec * 1000 : DEFAULT_TIMEOUT_MS + const timeoutMs = entry.timeout ? entry.timeout * 1000 : DEFAULT_TIMEOUT_MS const shell = process.platform === "win32" ? true : "/bin/sh" + const expandedCommand = expandCommand(entry) - const child = spawn(command, [], { + const extraEnv: Record = { CLAUDE_PROJECT_DIR: cwd } + if (entry.__sourceDir) { + extraEnv.CLAUDE_PLUGIN_ROOT = entry.__sourceDir + extraEnv.CLAUDE_PLUGIN_DATA = computeDataDir(entry.__sourceDir) + } + if (entry.options) { + for (const [k, v] of Object.entries(entry.options)) { + const key = "CLAUDE_PLUGIN_OPTION_" + k.replace(/[^A-Za-z0-9_]/g, "_").toUpperCase() + extraEnv[key] = JSON.stringify(v) + } + } + + const child = spawn(expandedCommand, [], { cwd, shell, - env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }, + env: { ...process.env, ...extraEnv }, stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs, }) @@ -271,18 +498,18 @@ function execShell( // EPIPE on stdin must not crash the host process (fork commit 0f3017f33a) child.stdin.on("error", (err) => { - log.warn("hook stdin error", { command, error: err.message }) + log.warn("hook stdin error", { command: entry.command, error: err.message }) }) try { child.stdin.write(stdinJSON + "\n", "utf8") child.stdin.end() } catch (err) { - log.warn("hook stdin write failed", { command, error: String(err) }) + log.warn("hook stdin write failed", { command: entry.command, error: String(err) }) } child.on("error", (err) => { - log.error("hook command failed to spawn", { command, error: err.message }) + log.error("hook command failed to spawn", { command: entry.command, error: err.message }) resolve({ exitCode: null, stdout, stderr, spawnError: err.message }) }) @@ -307,12 +534,17 @@ function parseStdout(stdout: string, command: string): HookJSONOutput | undefine // ── Payload → stdin envelope ──────────────────────────────────── function buildStdinEnvelope(payload: HookPayload, ctx: TriggerContext, cwd: string): Record { - const base = { + const base: Record = { hook_event_name: payload.event, session_id: ctx.sessionID, transcript_path: ctx.transcriptPath, cwd, } + // Explicit ctx.permissionMode wins; otherwise derive from agentType so callers only pass agent. + const permissionMode = ctx.permissionMode ?? (ctx.agentType ? agentToPermissionMode(ctx.agentType) : undefined) + if (permissionMode) base.permission_mode = permissionMode + if (ctx.agentID !== undefined) base.agent_id = ctx.agentID + if (ctx.agentType !== undefined) base.agent_type = ctx.agentType switch (payload.event) { case "PreToolUse": return { ...base, tool_name: payload.toolName, tool_input: payload.toolInput } @@ -328,8 +560,6 @@ function buildStdinEnvelope(payload: HookPayload, ctx: TriggerContext, cwd: stri case "Stop": case "SubagentStop": return { ...base, stop_hook_active: payload.stopHookActive } - case "Notification": - return { ...base, message: payload.message } case "PreCompact": return { ...base, @@ -373,10 +603,338 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SettingsHook") {} +// ── HookHandler abstraction (WP-4A) ───────────────────────────── +// +// Each handler owns one HookCommand.type. They return the same +// { json, exitBlock } envelope the trigger aggregator already consumes +// (no semantic shift vs. the prior inlined branches in runEntry). +// +// Handlers do NOT carry CC-protocol aggregation state (dedup, permissionDecision, +// systemMessages) — that stays in trigger's reducer block. inHook is propagated +// to every handler so future agent/http handlers can implement re-entry guards. +// +// mcpSvc dependency: the registry is built inside the layer's Effect.gen +// closure (not at module top-level, not per-trigger). This keeps mcpSvc as +// a captured closure variable instead of a per-call deps parameter — handlers +// stay pure functions of (entry, envelope, cwd, inHook). The table is a +// const inside the layer scope, so future http/prompt/agent handlers can +// register here once they exist. +interface HookHandler { + readonly type: E["type"] + readonly run: ( + entry: E, + envelope: Record, + cwd: string, + inHook: boolean, + ) => Effect.Effect<{ json?: HookJSONOutput; exitBlock?: string }> +} + +const commandHandler: HookHandler = { + type: "command", + run: Effect.fn("SettingsHook.handler.command")(function* (entry, envelope, cwd, _inHook) { + const stdinJSON = JSON.stringify(envelope) + const { exitCode, stdout, stderr, spawnError } = yield* Effect.promise(() => + execShell(entry, stdinJSON, cwd), + ) + + if (spawnError) { + return { json: undefined, exitBlock: undefined } + } + + // Exit-code 2: block + stderr-as-reason (CC contract) + if (exitCode === 2) { + const reason = stderr.trim() || "Hook blocked execution" + return { json: parseStdout(stdout, entry.command), exitBlock: reason } + } + + // Other non-zero exits: log and continue (do not abort main flow) + if (exitCode !== 0 && exitCode !== null) { + log.warn("hook command exited non-zero (non-blocking)", { + command: entry.command, + exitCode, + stderr: stderr.trim().slice(0, 200), + }) + } + + return { json: parseStdout(stdout, entry.command), exitBlock: undefined } + }), +} + +function makeMcpHandler(mcpSvc: MCP.Interface): HookHandler { + return { + type: "mcp", + run: Effect.fn("SettingsHook.handler.mcp")(function* (entry, envelope, _cwd, inHook) { + if (inHook) { + log.warn("nested mcp hook skipped (re-entry guard)", { command: entry.command }) + return { json: undefined, exitBlock: undefined } + } + const json = yield* invokeMcpHook(mcpSvc, entry.command, envelope) + return { json, exitBlock: undefined } + }), + } +} + +/** + * `type: "http"` handler. Per CC protocol, `entry.command` is the endpoint URL; + * the envelope is POSTed as JSON. 2xx → body parsed via the same parseStdout path + * as command stdout. Non-2xx → synthetic `exitBlock` so the trigger aggregator + * surfaces it as a block. Network errors / timeouts → log.warn + silent allow, + * mirroring commandHandler's spawnError behavior (hooks must never crash the host). + * + * Factory takes the resolved HttpClient so the HookHandler.run signature stays + * `R = never` (the WP-4A interface contract). Captures `http` in closure scope — + * registered once per layer construction inside the layer's Effect.gen block. + */ +function makeHttpHandler(http: HttpClient.HttpClient): HookHandler { + const httpRead = withTransientReadRetry(http) + return { + type: "http", + run: Effect.fn("SettingsHook.handler.http")(function* (entry, envelope, _cwd, _inHook) { + // entry.timeout is seconds (matches commandHandler convention); fallback to CC default. + const timeoutMs = entry.timeout ? entry.timeout * 1000 : DEFAULT_TIMEOUT_MS + + const exit = yield* HttpClientRequest.post(entry.command).pipe( + HttpClientRequest.bodyJson(envelope), + Effect.flatMap((req) => httpRead.execute(req)), + Effect.flatMap((res) => + Effect.gen(function* () { + const text = yield* res.text + return { status: res.status, text } + }), + ), + Effect.timeout(timeoutMs), + Effect.exit, + ) + + if (exit._tag === "Failure") { + log.warn("http hook request failed (non-blocking)", { + command: entry.command, + error: String(exit.cause), + }) + return { json: undefined, exitBlock: undefined } + } + + const { status, text } = exit.value + if (status < 200 || status >= 300) { + return { json: undefined, exitBlock: `http hook returned status ${status}` } + } + + return { json: parseStdout(text, entry.command), exitBlock: undefined } + }), + } +} + +/** + * `type: "prompt"` handler — single-turn LLM call (CC v1 protocol). + * + * `entry.command` is interpreted as the system prompt template; the stdin envelope + * (already shaped by buildStdinEnvelope) is JSON-stringified into the user message. + * The model returns structured output matching HookJSONOutputZodSchema (loose flat + * shape; see definition near HookJSONOutput). + * + * Failure policy is **silent allow** — mirrors httpHandler's network-error path: + * - OpenAI OAuth provider: not supported in v1 (no API key for generateObject). + * log.warn + return undefined json. v2 may switch to small-fast model w/ OAuth path. + * - generateObject reject (auth missing, rate-limit, schema mismatch, …): log.warn + + * return undefined json. Hooks must never crash or block the host on infra errors. + * + * MUST use `Effect.tryPromise + Effect.exit` (not `Effect.promise`): the latter + * turns a reject into a defect, which would propagate as a die and violate the + * non-blocking hook contract. The agent.ts:397 pattern is intentionally NOT copied + * here for that exact reason. + */ +function makePromptHandler(provider: Provider.Interface, auth: Auth.Interface): HookHandler { + return { + type: "prompt", + run: Effect.fn("SettingsHook.handler.prompt")(function* (entry, envelope, _cwd, _inHook) { + // Outer Effect.exit mirrors httpHandler:697-707 — bottom-line guarantee that + // any pre-LLM defect (provider.defaultModel/getModel/getLanguage die, + // auth.get die via orDie) cannot escape the handler. Hooks must never crash + // the host on infra errors. The inner gen body is the original logic verbatim. + const exit = yield* Effect.gen(function* () { + const m = yield* provider.defaultModel() + const resolved = yield* provider.getModel(m.providerID, m.modelID) + const language = yield* provider.getLanguage(resolved) + + // OpenAI OAuth: generateObject doesn't have a working code path under OAuth + // creds in v1 (would need streamObject + providerOptions hack like agent.ts). + // Skip silently rather than half-implement. + const authInfo = yield* auth.get(m.providerID).pipe(Effect.orDie) + if (m.providerID === "openai" && authInfo?.type === "oauth") { + log.warn("prompt hook: OpenAI OAuth provider not supported in v1", { + command: entry.command.slice(0, 80), + }) + return { json: undefined, exitBlock: undefined } + } + + const params = { + temperature: 0.3, + model: language, + messages: [ + { role: "system", content: entry.command } as ModelMessage, + { role: "user", content: JSON.stringify(envelope) } as ModelMessage, + ], + schema: HookJSONOutputZodSchema, + } satisfies Parameters[0] + + const llmExit = yield* Effect.tryPromise({ + try: () => generateObject(params).then((r) => r.object), + catch: (e) => e, + }).pipe(Effect.exit) + + if (llmExit._tag === "Failure") { + log.warn("prompt hook failed (non-blocking)", { error: String(llmExit.cause) }) + return { json: undefined, exitBlock: undefined } + } + return { json: llmExit.value as HookJSONOutput, exitBlock: undefined } + }).pipe(Effect.exit) + + if (exit._tag === "Failure") { + log.warn("prompt hook setup failed (non-blocking)", { error: String(exit.cause) }) + return { json: undefined, exitBlock: undefined } + } + return exit.value + }), + } +} + +/** + * `type: "agent"` handler — multi-turn LLM with a tool palette (CC v1 protocol). + * + * `entry.command` is the system prompt; the stdin envelope is JSON-stringified + * into the user message. The model is given 5 read-only tools from `agent-tools.ts` + * (read_file / list_dir / grep / bash / synthetic_output) and runs up to + * MAX_AGENT_TURNS turns. The hook decision is emitted by calling synthetic_output; + * the loop polls the captured slot after each turn. + * + * Failure policy is **silent allow** — same contract as makePromptHandler. Three + * distinct failure log strings differentiate setup defects (provider/auth/getLanguage), + * timeout/abort, and generateText reject so operators can grep them apart. + * + * Structure mirrors WP-4C-fix's prompt handler (outer Effect.exit on the setup + * gen) plus an inner tryPromise+exit on the loop Promise so AbortError and + * generateText rejects don't escape as defects. + */ +function makeAgentHandler( + provider: Provider.Interface, + auth: Auth.Interface, + spawner: ChildProcessSpawner["Service"], + fs: AppFileSystem.Interface, +): HookHandler { + return { + type: "agent", + run: Effect.fn("SettingsHook.handler.agent")(function* (entry, envelope, cwd, _inHook) { + const exit = yield* Effect.gen(function* () { + const m = yield* provider.defaultModel() + const resolved = yield* provider.getModel(m.providerID, m.modelID) + const language = yield* provider.getLanguage(resolved) + const authInfo = yield* auth.get(m.providerID).pipe(Effect.orDie) + + if (m.providerID === "openai" && authInfo?.type === "oauth") { + log.warn("agent hook: OpenAI OAuth provider not supported in v1", { + command: entry.command.slice(0, 80), + }) + return { json: undefined, exitBlock: undefined } + } + + // Loop runs in an async Promise so the ai SDK's AbortError / network rejects + // can be caught with a single tryPromise. AbortController doubles as the + // timeout source (entry.timeout in ms; CC's `timeout` field for command/http + // is seconds, but agent's spec — m0021 — keeps ms semantics for parity with + // the loop-internal setTimeout). Inner finally clears the timer regardless + // of how the loop exited so the normal-completion path doesn't leak it. + const captured: { value: HookJSONOutput | null } = { value: null } + const ac = new AbortController() + const timeoutMs = entry.timeout ?? DEFAULT_AGENT_TIMEOUT_MS + const timer = setTimeout(() => ac.abort(), timeoutMs) + + const loopExit = yield* Effect.tryPromise({ + try: async () => { + try { + const tools = buildAgentTools({ spawner, fs, signal: ac.signal, cwd, captured }) + const messages: ModelMessage[] = [ + { role: "system", content: entry.command }, + { role: "user", content: JSON.stringify(envelope) }, + ] + for (let turn = 0; turn < MAX_AGENT_TURNS; turn++) { + const result = await generateText({ + model: language, + messages, + tools, + toolChoice: "auto", + abortSignal: ac.signal, + maxOutputTokens: 4096, + }) + if (captured.value) return captured.value + messages.push(...result.response.messages) + if ( + result.finishReason === "stop" || + result.finishReason === "length" || + result.finishReason === "content-filter" + ) + break + // Pure text turn with no tool calls — the model is no longer making + // progress toward synthetic_output. Bail rather than spin to MAX_TURNS. + if (result.toolCalls.length === 0) break + } + return null + } finally { + clearTimeout(timer) + } + }, + catch: (e) => e, + }).pipe(Effect.exit) + + if (loopExit._tag === "Failure") { + const cause = String(loopExit.cause) + const isAbort = cause.includes("AbortError") || cause.includes("aborted") + if (isAbort) { + log.warn("agent hook timeout / aborted (non-blocking)", { + error: cause, + command: entry.command.slice(0, 80), + }) + } else { + log.warn("agent hook generateText failed (non-blocking)", { + error: cause, + command: entry.command.slice(0, 80), + }) + } + return { json: undefined, exitBlock: undefined } + } + + if (!loopExit.value) { + log.warn("agent hook reached max turns or no synthetic_output (non-blocking)", { + command: entry.command.slice(0, 80), + }) + return { json: undefined, exitBlock: undefined } + } + return { json: loopExit.value, exitBlock: undefined } + }).pipe(Effect.exit) + + if (exit._tag === "Failure") { + log.warn("agent hook setup failed (non-blocking)", { error: String(exit.cause) }) + return { json: undefined, exitBlock: undefined } + } + return exit.value + }), + } +} + +// WP-4D-2 constants. MAX_AGENT_TURNS pinned at 200 by user m0021 — gives the LLM +// enough headroom for deep-investigation hooks before the loop bails. Default +// timeout matches DEFAULT_TIMEOUT_MS (60s) used by command/http handlers. +const MAX_AGENT_TURNS = 200 +const DEFAULT_AGENT_TIMEOUT_MS = 60_000 + export const layer = Layer.effect( Service, Effect.gen(function* () { const mcpSvc = yield* MCP.Service + const http = yield* HttpClient.HttpClient + const provider = yield* Provider.Service + const auth = yield* Auth.Service + const spawner = yield* ChildProcessSpawner + const fs = yield* AppFileSystem.Service const state = yield* InstanceState.make( Effect.fn("SettingsHook.state")(function* (instCtx) { @@ -385,6 +943,16 @@ export const layer = Layer.effect( }), ) + // Registry built once per layer construction. WP-4D-2 wires `agent`; the full + // 5-type set (command/mcp/http/prompt/agent) is now implemented end-to-end. + const handlers: Record = { + command: commandHandler, + mcp: makeMcpHandler(mcpSvc), + http: makeHttpHandler(http), + prompt: makePromptHandler(provider, auth), + agent: makeAgentHandler(provider, auth, spawner, fs), + } + /** * Execute a single hook entry. Never throws. Returns the parsed JSON * output + a synthetic blocking signal for exit-code-2 protocol. @@ -395,41 +963,18 @@ export const layer = Layer.effect( cwd: string, inHook: boolean, ) { - const stdinJSON = JSON.stringify(envelope) - - if (entry.type === "mcp") { - if (inHook) { - log.warn("nested mcp hook skipped (re-entry guard)", { command: entry.command }) - return { json: undefined as HookJSONOutput | undefined, exitBlock: undefined as string | undefined } + const handler = handlers[entry.type] + if (!handler) { + // Defensive fallback: handlers table is exhaustive over the schema's 5 types + // (command/mcp/http/prompt/agent). This guards against future schema additions + // that miss handler registration. Currently dead-code by construction. + log.warn("hook type not registered (defensive fallback)", { type: entry.type, command: entry.command }) + return { + json: undefined as HookJSONOutput | undefined, + exitBlock: `hook type "${entry.type}" not yet implemented` as string | undefined, } - const json = yield* invokeMcpHook(mcpSvc, entry.command, envelope) - return { json, exitBlock: undefined as string | undefined } - } - - const { exitCode, stdout, stderr, spawnError } = yield* Effect.promise(() => - execShell(entry.command, stdinJSON, cwd, entry.timeout), - ) - - if (spawnError) { - return { json: undefined, exitBlock: undefined } } - - // Exit-code 2: block + stderr-as-reason (CC contract) - if (exitCode === 2) { - const reason = stderr.trim() || "Hook blocked execution" - return { json: parseStdout(stdout, entry.command), exitBlock: reason } - } - - // Other non-zero exits: log and continue (do not abort main flow) - if (exitCode !== 0 && exitCode !== null) { - log.warn("hook command exited non-zero (non-blocking)", { - command: entry.command, - exitCode, - stderr: stderr.trim().slice(0, 200), - }) - } - - return { json: parseStdout(stdout, entry.command), exitBlock: undefined } + return yield* handler.run(entry as never, envelope, cwd, inHook) }) const trigger = Effect.fn("SettingsHook.trigger")(function* ( @@ -449,7 +994,16 @@ export const layer = Layer.effect( if (!matches(group.matcher, target)) continue for (const entry of group.hooks) { - if (entry.type !== "command" && entry.type !== "mcp") continue + // Forward-compat: skip truly unknown types so future schema additions don't crash + // older handlers. Known types (command/mcp/http/prompt/agent) all flow into runEntry. + if ( + entry.type !== "command" && + entry.type !== "mcp" && + entry.type !== "http" && + entry.type !== "prompt" && + entry.type !== "agent" + ) + continue const { json, exitBlock } = yield* runEntry(entry, envelope, s.cwd, false) @@ -472,15 +1026,21 @@ export const layer = Layer.effect( if (json.systemMessage) result.systemMessages.push(json.systemMessage) const hso = json.hookSpecificOutput - if (hso?.additionalContext && !s.seen.has(hso.additionalContext)) { - s.seen.add(hso.additionalContext) - result.additionalContexts.push(hso.additionalContext) + // Property-based narrowing — works across union variants without depending on + // hookEventName tag (which the fallback variant may also accept). + if (hso && "additionalContext" in hso && typeof hso.additionalContext === "string") { + const ctx = hso.additionalContext + if (!s.seen.has(ctx)) { + s.seen.add(ctx) + result.additionalContexts.push(ctx) + } } - if (hso?.permissionDecision) { + if (hso && "permissionDecision" in hso && hso.permissionDecision) { result.permissionDecision = hso.permissionDecision - result.permissionDecisionReason = hso.permissionDecisionReason + result.permissionDecisionReason = + "permissionDecisionReason" in hso ? hso.permissionDecisionReason : undefined } - if (hso?.updatedInput) { + if (hso && "updatedInput" in hso && hso.updatedInput) { result.updatedInput = hso.updatedInput } } @@ -493,9 +1053,20 @@ export const layer = Layer.effect( }), ) -// defaultLayer provides MCP via MCP.defaultLayer; the shared memoMap in -// makeRuntime deduplicates instances across services that all depend on MCP. -export const defaultLayer = layer.pipe(Layer.provide(MCP.defaultLayer)) +// defaultLayer provides MCP, HttpClient, Provider, Auth, AppFileSystem, and +// ChildProcessSpawner. The agent handler (WP-4D-2) yields the latter two for +// its bash / read_file / list_dir / grep tools. Every module that consumes +// these spawn/fs services closes them in its own defaultLayer (see +// git/index.ts:350, format/index.ts:207, ripgrep.ts:479) — the shared memoMap +// in makeRuntime deduplicates the underlying instances across services. +export const defaultLayer = layer.pipe( + Layer.provide(MCP.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), +) // ── type:"mcp" hook execution ─────────────────────────────────── diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 7e300e0815..20e82f18eb 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -155,7 +155,6 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const settingsHook = yield* SettingsHook.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { const row = Database.use((db) => @@ -207,18 +206,6 @@ export const layer = Layer.effect( const deferred = yield* Deferred.make() pending.set(id, { info, deferred }) - // Notification hook (Claude Code compatible) — fires when the agent - // pauses for user input on a permission prompt. CC's `message` is a - // freeform string surfaced to notification handlers. - yield* settingsHook - .trigger( - { - event: "Notification", - message: `Permission required: ${info.permission}${info.patterns.length ? ` (${info.patterns.join(", ")})` : ""}`, - }, - { sessionID: info.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.ignore) yield* bus.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a8748f28c9..86f297927c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -520,16 +520,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the { event: "PreToolUse", toolName: item.id, toolInput: args }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) + // CC contract: permissionDecision="deny" is an explicit hook + // verdict and short-circuits BEFORE the legacy `blocked` check. + // allow/ask are decorative here — fork's Permission subsystem + // lives inside individual tools and is not replaced at this layer. + if (preHook.permissionDecision === "deny") { + const reason = preHook.permissionDecisionReason ?? "Denied by hook" + return { title: "", metadata: {}, output: `Hook denied: ${reason}` } + } if (preHook.blocked) { return { title: "", metadata: {}, output: `Hook blocked: ${preHook.blocked.reason}` } } - const result = yield* item.execute(args, ctx) + // CC contract: hookSpecificOutput.updatedInput rewrites tool args + const effectiveArgs = preHook.updatedInput ?? args + const result = yield* item.execute(effectiveArgs, ctx) // PostToolUse hook const postHook = yield* settingsHook.trigger( { event: "PostToolUse", toolName: item.id, - toolInput: args, + toolInput: effectiveArgs, toolResponse: result.output, }, { sessionID: ctx.sessionID, transcriptPath: "" }, @@ -597,6 +607,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the { event: "PreToolUse", toolName: key, toolInput: args }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) + // CC contract: deny is an explicit verdict — short-circuit before + // the legacy `blocked` check. allow/ask are decorative at this layer. + if (preHook.permissionDecision === "deny") { + const reason = preHook.permissionDecisionReason ?? "Denied by hook" + return { + title: "", + metadata: {} as Record, + output: `Hook denied: ${reason}`, + content: [{ type: "text" as const, text: `Hook denied: ${reason}` }], + } + } if (preHook.blocked) { return { title: "", @@ -607,8 +628,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the ], } } + // CC contract: hookSpecificOutput.updatedInput rewrites tool args + const effectiveArgs = preHook.updatedInput ?? args const result: Awaited>> = yield* Effect.tryPromise({ - try: () => execute(args, opts), + try: () => execute(effectiveArgs, opts), catch: (e) => new Error(`MCP tool "${key}" failed: ${e instanceof Error ? e.message : String(e)}`), }) // PostToolUse hook @@ -616,7 +639,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { event: "PostToolUse", toolName: key, - toolInput: args, + toolInput: effectiveArgs, toolResponse: result, }, { sessionID: ctx.sessionID, transcriptPath: "" }, @@ -1433,6 +1456,30 @@ NOTE: At any point in time through this workflow you should feel free to ask the return message } + // CC compatible: hookSpecificOutput.additionalContext from UserPromptSubmit hooks + // is wrapped and appended to the user message text (mirrors PreToolUse pattern at L557). + if (submitHook.additionalContexts.length > 0) { + const block = submitHook.additionalContexts + .map((c) => `\n${c}`) + .join("") + const lastText = [...message.parts].reverse().find((p): p is MessageV2.TextPart => p.type === "text") + if (lastText) { + lastText.text += block + yield* sessions.updatePart(lastText) + } else { + const newPart: MessageV2.TextPart = { + id: PartID.ascending(), + messageID: message.info.id, + sessionID: input.sessionID, + type: "text", + text: block.replace(/^\n/, ""), + synthetic: true, + } + message.parts.push(newPart) + yield* sessions.updatePart(newPart) + } + } + const permissions: Permission.Ruleset = [] for (const [t, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) diff --git a/packages/opencode/test/hook/agent-tools.test.ts b/packages/opencode/test/hook/agent-tools.test.ts new file mode 100644 index 0000000000..41de868a14 --- /dev/null +++ b/packages/opencode/test/hook/agent-tools.test.ts @@ -0,0 +1,235 @@ +/** + * Unit tests for buildAgentTools (WP-4D micro-WP-1). + * + * Covers contract slice only — agent loop is WP-4D-2 and out of scope here. + * Each test resolves the real spawner / fs Services via testEffect, builds + * the tool palette, and exercises one tool's `execute` directly. + */ +import { describe, expect } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { buildAgentTools, __test__ } from "../../src/hook/agent-tools" +import type { HookJSONOutput } from "../../src/hook/settings" + +const infra = Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer) +const it = testEffect(infra) + +// Bare-minimum execute options shape — ai-SDK requires toolCallId/messages/abortSignal. +const execOpts = (signal: AbortSignal) => + ({ + toolCallId: "test-call", + messages: [], + abortSignal: signal, + }) as never + +function makeDeps(opts: { spawner: any; fs: AppFileSystem.Interface; cwd: string }) { + const captured: { value: HookJSONOutput | null } = { value: null } + const signal = new AbortController().signal + const tools = buildAgentTools({ spawner: opts.spawner, fs: opts.fs, signal, cwd: opts.cwd, captured }) + return { tools, captured, signal } +} + +describe("agent-tools / read_file", () => { + it.live("relative path joins cwd", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + yield* Effect.promise(() => fs.writeFile(path.join(dir, "a.txt"), "hello\nworld")) + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => tools.read_file.execute!({ path: "a.txt" }, execOpts(signal))) + expect(out.output).toBe("hello\nworld") + }), + ), + ) + + it.live("absolute path works", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + const filepath = path.join(dir, "abs.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "abs-content")) + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => tools.read_file.execute!({ path: filepath }, execOpts(signal))) + expect(out.output).toBe("abs-content") + }), + ), + ) + + it.live("offset+limit slices lines", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + yield* Effect.promise(() => fs.writeFile(path.join(dir, "lines.txt"), "1\n2\n3\n4\n5\n6")) + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => + tools.read_file.execute!({ path: "lines.txt", offset: 2, limit: 3 }, execOpts(signal)), + ) + // offset=2 (1-indexed) → start at line 2 → "2\n3\n4" + expect(out.output).toBe("2\n3\n4") + }), + ), + ) + + it.live("missing file returns Error: prefix, no throw", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => + tools.read_file.execute!({ path: "missing.txt" }, execOpts(signal)), + ) + expect(typeof out.output).toBe("string") + expect(out.output).toMatch(/^Error:/) + }), + ), + ) +}) + +describe("agent-tools / list_dir", () => { + it.live("entries with trailing / for dirs", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + yield* Effect.promise(() => fs.mkdir(path.join(dir, "subdir"))) + yield* Effect.promise(() => fs.writeFile(path.join(dir, "file.txt"), "x")) + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => tools.list_dir.execute!({ path: dir }, execOpts(signal))) + const lines = (out.output as string).split("\n") + expect(lines).toContain("subdir/") + expect(lines).toContain("file.txt") + }), + ), + ) +}) + +describe("agent-tools / grep", () => { + it.live("matches pattern in single file, format path:line:content", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + const filepath = path.join(dir, "g.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "alpha\nfoo bar\nbaz")) + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => + tools.grep.execute!({ pattern: "foo", path: filepath }, execOpts(signal)), + ) + expect(out.output).toBe(`${filepath}:2:foo bar`) + }), + ), + ) +}) + +describe("agent-tools / bash whitelist", () => { + it.live("'ls' executes against cwd", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + yield* Effect.promise(() => fs.writeFile(path.join(dir, "marker.txt"), "x")) + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => tools.bash.execute!({ command: "ls" }, execOpts(signal))) + expect(out.output).toContain("marker.txt") + expect(out.output).toMatch(/^exit=0/) + }), + ), + ) + + it.live("rejects 'rm -rf /' with Error: prefix and never spawns", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const t0 = Date.now() + const out: any = yield* Effect.promise(() => + tools.bash.execute!({ command: "rm -rf /" }, execOpts(signal)), + ) + const elapsed = Date.now() - t0 + expect(out.output).toMatch(/^Error:/) + expect(out.output).toContain("not in read-only whitelist") + // No spawn should have been issued — pure regex/whitelist rejection. + // Allow generous slack so noisy CI doesn't flake; spawn typically takes 50-500ms. + expect(elapsed).toBeLessThan(50) + }), + ), + ) + + it.live("FORBIDDEN_META rejects 'ls; rm x'", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => + tools.bash.execute!({ command: "ls; rm x" }, execOpts(signal)), + ) + expect(out.output).toContain("compound/redirect not allowed") + }), + ), + ) + + it.live("'git status' two-token whitelist passes inside a git repo", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + const { tools, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const out: any = yield* Effect.promise(() => + tools.bash.execute!({ command: "git status" }, execOpts(signal)), + ) + // git status in clean repo: exit=0 + expect(out.output).toMatch(/^exit=0/) + }), + { git: true }, + ), + ) + + it.live("__test__.whitelistReject covers single + pair contract", () => + Effect.gen(function* () { + // Pure contract checks — no Effect deps needed but kept inside .live for parity. + expect(__test__.whitelistReject("ls")).toBeNull() + expect(__test__.whitelistReject("git status")).toBeNull() + expect(__test__.whitelistReject("sed -n 1,3p file")).toBeNull() + expect(__test__.whitelistReject("rm -rf /")).toMatch(/not in read-only whitelist/) + expect(__test__.whitelistReject("ls | cat")).toMatch(/compound\/redirect not allowed/) + expect(__test__.whitelistReject("")).toBe("empty command") + }), + ) +}) + +describe("agent-tools / synthetic_output", () => { + it.live("writes captured.value with full HookJSONOutput shape", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner + const fsys = yield* AppFileSystem.Service + const { tools, captured, signal } = makeDeps({ spawner, fs: fsys, cwd: dir }) + const args = { + decision: "block" as const, + reason: "policy violation", + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny" as const, + permissionDecisionReason: "policy", + }, + } + const out: any = yield* Effect.promise(() => tools.synthetic_output.execute!(args, execOpts(signal))) + expect(out.output).toBe("ok") + expect(captured.value).toEqual(args) + }), + ), + ) +}) diff --git a/packages/opencode/test/hook/settings.test.ts b/packages/opencode/test/hook/settings.test.ts index b0e99c1172..ecc5af9985 100644 --- a/packages/opencode/test/hook/settings.test.ts +++ b/packages/opencode/test/hook/settings.test.ts @@ -1,15 +1,20 @@ /** * Per-event unit tests for SettingsHook.Service.trigger. * - * Design note: all 9 events share one trigger pipeline (settings load → matcher + * Design note: all 8 events share one trigger pipeline (settings load → matcher * → exec → stdout protocol). Splitting into 8 sibling files would duplicate the - * fixture entirely. We use one file with 8 describe blocks (UserPromptSubmit + * fixture entirely. We use one file with one describe block per event (UserPromptSubmit * already covered by test/session/prompt.test.ts integration), each verifying: * * 1. event-specific stdin envelope shape (tool_name / prompt / source / ...) * 2. matcher target rule (tool events use tool_name; non-tool events ignore matcher) * 3. control-protocol effect on TriggerResult (additionalContext, systemMessage, * decision=block, exit-code-2 block, continue=false, updatedInput) + * + * WP-4A/4B/4C/4D-2 add per-handler describe blocks at the bottom of this file. + * Those use a separate `itCustom` instance so each test can substitute its own + * Provider/Auth/HttpClient layer (the global `it` above runs the production + * defaultLayer which only exercises the command handler path). */ import { afterAll, beforeAll, describe, expect } from "bun:test" import path from "path" @@ -17,10 +22,17 @@ import fs from "fs/promises" import { Effect, Layer } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { HttpClient, HttpClientResponse, FetchHttpClient } from "effect/unstable/http" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { SettingsHook } from "../../src/hook/settings" import type { HookEvent, HookPayload, TriggerContext } from "../../src/hook/settings" +import { MCP } from "../../src/mcp" +import { Provider } from "../../src/provider/provider" +import { Auth } from "../../src/auth" +import { ProviderTest } from "../fake/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) const it = testEffect(SettingsHook.defaultLayer.pipe(Layer.provideMerge(infra))) @@ -250,41 +262,6 @@ describe("SettingsHook.trigger / PostToolUse", () => { ) }) -// ────────────────────────────────────────────────────────────────── -// Notification -// ────────────────────────────────────────────────────────────────── - -describe("SettingsHook.trigger / Notification", () => { - it.live("envelope carries message; matcher unused (any matcher matches)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - // No matcher set → defaults to undefined which matches() treats as wildcard - yield* Effect.promise(() => writeHookSettings(dir, "Notification")) - const svc = yield* SettingsHook.Service - yield* svc.trigger({ event: "Notification", message: "hello world" }, ctx) - const env = yield* Effect.promise(() => readEnvelope(dir)) - expect(env.hook_event_name).toBe("Notification") - expect(env.message).toBe("hello world") - }), - ), - ) - - it.live("systemMessage from JSON appended to result.systemMessages", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - writeHookSettings(dir, "Notification", { - stdoutJSON: JSON.stringify({ systemMessage: "FYI: rate limit close" }), - }), - ) - const svc = yield* SettingsHook.Service - const result = yield* svc.trigger({ event: "Notification", message: "x" }, ctx) - expect(result.systemMessages).toContain("FYI: rate limit close") - }), - ), - ) -}) - // ────────────────────────────────────────────────────────────────── // Stop / SubagentStop (share envelope shape) // ────────────────────────────────────────────────────────────────── @@ -446,3 +423,912 @@ describe("SettingsHook.trigger / SessionEnd", () => { ), ) }) + +// ══════════════════════════════════════════════════════════════════ +// WP-4A — handler abstraction (command/mcp dispatch + unsupported) +// ══════════════════════════════════════════════════════════════════ +// +// runEntry walks `handlers[entry.type]` (settings.ts:951-957). For the command +// path we already exercise it everywhere above; here we add focused tests for: +// 1. `type:"command"` produces command-handler effects (decision=block JSON wins). +// 2. `type:"mcp"` is dispatched to the mcp handler — `mcp__` prefix gate ensures +// a malformed command is silent-allowed (never crashes the host). +// 3. Unknown type → silent allow + synthetic exitBlock string per +// runEntry's "not yet implemented" branch. +// +// All three use the production defaultLayer (the global `it`) — no custom +// dependency stubs are needed because the dispatch table is built inside the +// layer's gen block and is not parameterizable. + +describe("SettingsHook.trigger / WP-4A handler dispatch", () => { + it.live("dispatches type:command to the command handler (decision=block round-trip)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + // PostToolUse: top-level decision=block is honored (parseStdout → result.blocked.reason). + yield* Effect.promise(() => + writeHookSettings(dir, "PostToolUse", { + matcher: "*", + stdoutJSON: JSON.stringify({ decision: "block", reason: "command-handler routed" }), + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PostToolUse", toolName: "bash", toolInput: {}, toolResponse: "" }, + ctx, + ) + expect(result.blocked?.reason).toBe("command-handler routed") + }), + ), + ) + + it.live("dispatches type:mcp to the mcp handler (malformed command → silent allow)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + // `command` doesn't start with mcp__ → mcp handler logs warn and returns undefined, + // proving dispatch reached invokeMcpHook (settings.ts:1090-1093) rather than the + // command shell or the unsupported-type branch (which would set exitBlock). + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "mcp", command: "not_mcp_prefix__server__tool" }], + }, + ], + }, + } + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settings)) + }) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + // Silent allow — no exitBlock, no decision propagated. + expect(result.blocked).toBeUndefined() + expect(result.permissionDecision).toBeUndefined() + }), + ), + ) + + it.live("unknown type is silently skipped by trigger's whitelist (no blocked)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + // trigger's loop (settings.ts:1002-1009) filters out non-{command,mcp,http,prompt,agent} + // types before runEntry. Result: no exitBlock, the entry is just skipped. + // This documents forward-compat behavior for future schema entries the fork + // doesn't yet recognize. + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "foobar", command: "irrelevant" }], + }, + ], + }, + } + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settings)) + }) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + expect(result.systemMessages).toEqual([]) + }), + ), + ) +}) + +// ══════════════════════════════════════════════════════════════════ +// WP-4B — http handler (mock HttpClient) +// ══════════════════════════════════════════════════════════════════ +// +// Each test substitutes a mock HttpClient via Layer.fresh(SettingsHook.layer) + +// Layer.provide chain. The mock returns a synthesized Response and we assert on +// the trigger-aggregator outcome. +// +// makeHttpHandler (settings.ts:691-728) contract: +// 2xx → parseStdout(body) → JSON merged into result +// non-2xx → exitBlock = "http hook returned status N" +// timeout / network error → silent allow (result.blocked undefined) + +const encoder = new TextEncoder() + +function mockHttpClient(handler: (req: any) => Response) { + const client = HttpClient.make((req) => Effect.succeed(HttpClientResponse.fromWeb(req, handler(req)))) + return Layer.succeed(HttpClient.HttpClient, client) +} + +function settingsHookWithHttp(httpLayer: Layer.Layer) { + // Layer.fresh forces a new SettingsHook instance bound to our mock http; + // the production defaultLayer already memoizes one bound to FetchHttpClient. + return Layer.fresh(SettingsHook.layer).pipe( + Layer.provide(MCP.defaultLayer), + Layer.provide(httpLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provideMerge(infra), + ) +} + +async function writeHttpHook(dir: string, url: string) { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "http", command: url, timeout: 5 }], + }, + ], + }, + } + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settings)) +} + +describe("SettingsHook.trigger / WP-4B http handler", () => { + const itOk = testEffect( + settingsHookWithHttp( + mockHttpClient( + () => + new Response(JSON.stringify({ decision: "block", reason: "http-decided" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + ) + itOk.live("2xx JSON body parsed → decision=block surfaces as result.blocked", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeHttpHook(dir, "https://example.test/hook")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked?.reason).toBe("http-decided") + }), + ), + ) + + const it500 = testEffect( + settingsHookWithHttp(mockHttpClient(() => new Response("internal error", { status: 500 }))), + ) + it500.live("non-2xx → synthetic exitBlock 'http hook returned status N'", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeHttpHook(dir, "https://example.test/hook")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked?.reason).toBe("http hook returned status 500") + }), + ), + ) + + // Network error path: HttpClient.make handler that dies → withTransientReadRetry surfaces + // failure → outer Effect.exit catches → log.warn + silent allow (settings.ts:712-718). + const itNetErr = testEffect( + settingsHookWithHttp(Layer.succeed(HttpClient.HttpClient, HttpClient.make(() => Effect.die("net down")))), + ) + itNetErr.live("network error / failure → silent allow (result.blocked undefined)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeHttpHook(dir, "https://example.test/hook")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + expect(result.permissionDecision).toBeUndefined() + }), + ), + ) +}) + +// ══════════════════════════════════════════════════════════════════ +// WP-4C — prompt handler (Provider/Auth stubs) +// ══════════════════════════════════════════════════════════════════ +// +// makePromptHandler (settings.ts:749-802) contract: +// - OpenAI OAuth provider → silent allow (no API path for generateObject) +// - generateObject reject → silent allow (`prompt hook failed` log) +// - setup defect (e.g. getLanguage die) → silent allow (`setup failed` log) +// +// All three failure modes converge on result.blocked === undefined — that's +// the contract the trigger pipeline cares about. We verify behavior, not +// log strings (those are tested implicitly by the lack of crash). + +function authOauthLayer() { + return Layer.succeed( + Auth.Service, + Auth.Service.of({ + get: () => + Effect.succeed({ + type: "oauth", + refresh: "r", + access: "a", + expires: 0, + } as unknown as Auth.Info), + all: () => Effect.succeed({} as Record), + set: () => Effect.void, + remove: () => Effect.void, + }), + ) +} + +function authNoneLayer() { + return Layer.succeed( + Auth.Service, + Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({} as Record), + set: () => Effect.void, + remove: () => Effect.void, + }), + ) +} + +function settingsHookWithProviderAuth( + providerLayer: Layer.Layer, + authLayer: Layer.Layer, +) { + return Layer.fresh(SettingsHook.layer).pipe( + Layer.provide(MCP.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(providerLayer), + Layer.provide(authLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provideMerge(infra), + ) +} + +async function writePromptHook(dir: string, prompt: string) { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [{ type: "prompt", command: prompt }], + }, + ], + }, + } + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settings)) +} + +describe("SettingsHook.trigger / WP-4C prompt handler", () => { + // OpenAI provider + OAuth auth → settings.ts:766-771 short-circuits before any LLM call. + const provOpenAI = ProviderTest.fake({ + model: ProviderTest.model({ + id: ModelID.make("gpt-4"), + providerID: ProviderID.make("openai"), + }), + }) + const itOAuth = testEffect(settingsHookWithProviderAuth(provOpenAI.layer, authOauthLayer())) + itOAuth.live("OpenAI OAuth provider → silent allow (no LLM call)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writePromptHook(dir, "Decide whether to allow this tool call.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // Non-OpenAI provider w/ no auth → reaches generateObject; ProviderTest.fake's default + // getLanguage Effect.die surfaces as an LLM failure → settings.ts:788-790 silent allow. + const provGeneric = ProviderTest.fake({ + model: ProviderTest.model({ + id: ModelID.make("test-model"), + providerID: ProviderID.make("anthropic"), + }), + }) + const itLlmFail = testEffect(settingsHookWithProviderAuth(provGeneric.layer, authNoneLayer())) + itLlmFail.live("generateObject failure (provider getLanguage die) → silent allow", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writePromptHook(dir, "Audit the call.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + // Whichever path this hits — getLanguage die in setup gen, or generateObject + // reject — both converge on silent allow. result.blocked must remain undefined. + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // Setup failure: provider whose defaultModel itself dies → caught by outer Effect.exit + // wrapper at settings.ts:793-798 ("prompt hook setup failed") rather than inner LLM exit. + const provDeadDefault = { + layer: Layer.succeed( + Provider.Service, + Provider.Service.of({ + list: Effect.fn("DeadProvider.list")(() => Effect.succeed({})), + getProvider: Effect.fn("DeadProvider.getProvider")(() => + Effect.die(new Error("provider unreachable")), + ), + getModel: Effect.fn("DeadProvider.getModel")(() => + Effect.die(new Error("provider unreachable")), + ), + getLanguage: Effect.fn("DeadProvider.getLanguage")(() => + Effect.die(new Error("provider unreachable")), + ), + closest: Effect.fn("DeadProvider.closest")(() => Effect.succeed(undefined)), + getSmallModel: Effect.fn("DeadProvider.getSmallModel")(() => Effect.succeed(undefined)), + defaultModel: Effect.fn("DeadProvider.defaultModel")(() => + Effect.die(new Error("provider unreachable")), + ), + }), + ), + } + const itSetupDie = testEffect( + settingsHookWithProviderAuth(provDeadDefault.layer as any, authNoneLayer()), + ) + itSetupDie.live("provider service die (defaultModel) → silent allow (setup failed branch)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writePromptHook(dir, "Audit the call.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) +}) + +// ══════════════════════════════════════════════════════════════════ +// WP-4D-2 — agent handler (multi-turn LLM + synthetic_output tool) +// ══════════════════════════════════════════════════════════════════ +// +// makeAgentHandler (settings.ts:821-924) contract: +// - synthetic_output captured.value with decision=block → blocked surfaces in result +// - synthetic_output with hookSpecificOutput.additionalContext → appended +// - max turns reached without synthetic_output → silent allow (`reached max turns`) +// - OpenAI OAuth → silent allow short-circuit (settings.ts:836-841) +// - setup die (defaultModel) → silent allow (`setup failed` outer branch) +// - generateText reject → silent allow (`generateText failed` branch) +// +// fakeLanguageModel scripts a sequence of turns. The ai SDK consumes +// `doGenerate` which returns content blocks; for tool turns we emit +// `tool-call` content with the synthetic JSON args. The agent loop polls +// `captured.value` after each turn (settings.ts:871) and short-circuits. + +function fakeLanguageModel( + scripted: Array<{ + toolCalls?: Array<{ name: string; args: object }> + text?: string + finishReason?: string + }>, +) { + let step = 0 + return { + specificationVersion: "v3" as const, + provider: "test", + modelId: "test", + supportedUrls: () => ({}), + doGenerate: async (_options: any) => { + const s = scripted[step++] ?? { finishReason: "stop", text: "" } + const content: any[] = [] + if (s.text) content.push({ type: "text", text: s.text }) + for (const tc of s.toolCalls ?? []) { + content.push({ + type: "tool-call", + toolCallId: `tc-${step}-${tc.name}`, + toolName: tc.name, + input: JSON.stringify(tc.args), + }) + } + return { + content, + finishReason: s.finishReason ?? (s.toolCalls?.length ? "tool-calls" : "stop"), + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + warnings: [], + } + }, + doStream: async () => { + throw new Error("not used") + }, + } +} + +function provWithLanguage(language: unknown) { + return ProviderTest.fake({ + model: ProviderTest.model({ + id: ModelID.make("test-model"), + providerID: ProviderID.make("anthropic"), + }), + getLanguage: Effect.fn("TestProvider.getLanguage.scripted")(() => + Effect.succeed(language as never), + ), + }) +} + +async function writeAgentHook(dir: string, prompt: string, timeoutMs?: number) { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "agent", + command: prompt, + ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), + }, + ], + }, + ], + }, + } + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settings)) +} + +describe("SettingsHook.trigger / WP-4D-2 agent handler", () => { + // ── 1. synthetic_output deny → blocked surfaces in result. + const provDeny = provWithLanguage( + fakeLanguageModel([ + { + toolCalls: [ + { + name: "synthetic_output", + args: { + decision: "block", + reason: "agent denied", + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "agent reasoned deny", + }, + }, + }, + ], + }, + ]), + ) + const itDeny = testEffect(settingsHookWithProviderAuth(provDeny.layer, authNoneLayer())) + itDeny.live("synthetic_output decision=block → result.blocked + permissionDecision deny", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeAgentHook(dir, "Audit this tool call.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: { command: "rm -rf /" } }, + ctx, + ) + expect(result.blocked?.reason).toBe("agent denied") + expect(result.permissionDecision).toBe("deny") + expect(result.permissionDecisionReason).toBe("agent reasoned deny") + }), + ), + ) + + // ── 2. synthetic_output allow + additionalContext appended. + const provAllow = provWithLanguage( + fakeLanguageModel([ + { + toolCalls: [ + { + name: "synthetic_output", + args: { + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: "agent suggests caution: review path", + }, + }, + }, + ], + }, + ]), + ) + const itAllow = testEffect(settingsHookWithProviderAuth(provAllow.layer, authNoneLayer())) + itAllow.live("synthetic_output allow + additionalContext appended (no block)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeAgentHook(dir, "Audit this tool call.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + expect(result.additionalContexts).toContain("agent suggests caution: review path") + }), + ), + ) + + // ── 3. Max turns: 250 text-only turns scripted; loop bails after MAX_AGENT_TURNS=200 + // or after the first pure-text turn with zero tool calls (settings.ts:881). Both + // paths converge on silent allow. + const provMaxTurns = provWithLanguage( + fakeLanguageModel(Array.from({ length: 250 }, () => ({ text: "thinking" }))), + ) + const itMaxTurns = testEffect(settingsHookWithProviderAuth(provMaxTurns.layer, authNoneLayer())) + itMaxTurns.live("max turns / no synthetic_output → silent allow", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeAgentHook(dir, "Think a lot.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // ── 4. OpenAI OAuth skip — short-circuits before ever touching getLanguage. + const provOpenAI4D = ProviderTest.fake({ + model: ProviderTest.model({ + id: ModelID.make("gpt-4"), + providerID: ProviderID.make("openai"), + }), + }) + const itOauth4D = testEffect(settingsHookWithProviderAuth(provOpenAI4D.layer, authOauthLayer())) + itOauth4D.live("OpenAI OAuth provider → silent allow (no LLM call)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeAgentHook(dir, "Audit.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // ── 5. Setup die: ProviderTest.fake's default getLanguage Effect.die. + // We do NOT inject a fake language model — the default die in fake() at + // test/fake/provider.ts:64-66 fires inside the inner gen body and is + // caught by the outer Effect.exit at settings.ts:917-920 (setup failed). + const provSetupDie = ProviderTest.fake({ + model: ProviderTest.model({ + id: ModelID.make("test-model"), + providerID: ProviderID.make("anthropic"), + }), + }) + const itSetupDie4D = testEffect( + settingsHookWithProviderAuth(provSetupDie.layer, authNoneLayer()), + ) + itSetupDie4D.live("setup die (default getLanguage Effect.die) → silent allow", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeAgentHook(dir, "Audit.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // ── 6. generateText reject — language model whose doGenerate throws. The ai SDK + // surfaces this as a Promise rejection inside the loop's tryPromise wrapper + // (settings.ts:854-889) → log.warn `generateText failed` → silent allow. + const provGenTextReject = provWithLanguage({ + specificationVersion: "v3" as const, + provider: "test", + modelId: "test", + supportedUrls: () => ({}), + doGenerate: async () => { + throw new Error("simulated generateText failure") + }, + doStream: async () => { + throw new Error("not used") + }, + }) + const itGenTextReject = testEffect( + settingsHookWithProviderAuth(provGenTextReject.layer, authNoneLayer()), + ) + itGenTextReject.live("generateText reject → silent allow", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeAgentHook(dir, "Audit.")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) +}) + +// ══════════════════════════════════════════════════════════════════ +// WP-4F/3 — handler × event matrix coverage +// ══════════════════════════════════════════════════════════════════ +// +// 8 tests covering the cross-product of {mcp, http, prompt, agent} handlers +// with the 8 lifecycle events (excluding PreToolUse which is exhaustively +// covered above). All tests verify the fail-safe contract: hook errors must +// converge on silent allow (result.blocked undefined) — never crash the host +// or block the main flow on infra failure. +// +// Knowingly accepted spec drift (per WP-4F/3 brief §"灰色地带"): +// - http 5xx test: spec says "silent allow" but settings.ts:721 surfaces a +// synthetic exitBlock for non-2xx. Test asserts the actual exitBlock contract. +// - prompt SessionStart approve: spec says assert blocked=false. The fake +// provider's getLanguage Effect.die converges on silent allow (blocked +// undefined) — equivalent to "approve" semantically (no block surfaced). + +async function writeHookSettingsForType( + dir: string, + event: HookEvent, + entry: Record, +) { + const settings = { + hooks: { + [event]: [{ matcher: "*", hooks: [entry] }], + }, + } + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settings)) +} + +describe("SettingsHook.trigger / WP-4F handler × event matrix", () => { + // ── 1. PostToolUse + mcp (P0): malformed mcp__ prefix → silent allow. + it.live("dispatches type:mcp on PostToolUse (malformed prefix → silent allow)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "PostToolUse", { + type: "mcp", + command: "bad__prefix", + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PostToolUse", toolName: "bash", toolInput: {}, toolResponse: "" }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // ── 2. UserPromptSubmit + http (P0): 200 empty body → silent allow + // (parseStdout treats non-{ start as plain-text → undefined json). + const itHttpEmpty = testEffect( + settingsHookWithHttp( + mockHttpClient( + () => + new Response("", { status: 200, headers: { "content-type": "text/plain" } }), + ), + ), + ) + itHttpEmpty.live("dispatches type:http on UserPromptSubmit (200 empty body → silent allow)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "UserPromptSubmit", { + type: "http", + command: "https://example.test/ups", + timeout: 5, + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "UserPromptSubmit", prompt: "hello" }, + ctx, + ) + expect(result.blocked).toBeUndefined() + expect(result.systemMessages).toEqual([]) + }), + ), + ) + + // ── 3. SessionStart + prompt (P1): generic provider w/o auth — getLanguage Effect.die + // from ProviderTest.fake converges on silent allow (semantically equivalent to approve). + const provPromptApprove = ProviderTest.fake({ + model: ProviderTest.model({ + id: ModelID.make("test-model"), + providerID: ProviderID.make("anthropic"), + }), + }) + const itPromptSession = testEffect( + settingsHookWithProviderAuth(provPromptApprove.layer, authNoneLayer()), + ) + itPromptSession.live( + "dispatches type:prompt on SessionStart (provider die path → silent allow ≈ approve)", + () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "SessionStart", { + type: "prompt", + command: "Approve startup.", + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "SessionStart", source: "startup" }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // ── 4. SessionEnd + agent (P1): synthetic_output deny → blocked surfaces. + const provAgentDeny = provWithLanguage( + fakeLanguageModel([ + { + toolCalls: [ + { + name: "synthetic_output", + args: { + decision: "block", + reason: "session-end agent denied", + }, + }, + ], + }, + ]), + ) + const itAgentSessionEnd = testEffect( + settingsHookWithProviderAuth(provAgentDeny.layer, authNoneLayer()), + ) + itAgentSessionEnd.live( + "dispatches type:agent on SessionEnd (synthetic_output deny → blocked true)", + () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "SessionEnd", { + type: "agent", + command: "Inspect session end.", + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "SessionEnd", reason: "logout" }, + ctx, + ) + expect(result.blocked?.reason).toBe("session-end agent denied") + }), + ), + ) + + // ── 5. Stop + mcp (P2): tool not registered → silent allow + log.warn. + it.live("dispatches type:mcp on Stop (mcp tool not found → silent allow)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "Stop", { + type: "mcp", + command: "mcp__nope__nonexistent", + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "Stop", stopHookActive: false }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // ── 6. SubagentStop + http (P2): 5xx response. + // Knowingly accepted: settings.ts:721-723 returns a synthetic exitBlock for + // non-2xx (NOT silent allow as the brief speculated). withTransientReadRetry + // only retries transient READ failures, not delivered HTTP error statuses. + const itHttpSubagent = testEffect( + settingsHookWithHttp(mockHttpClient(() => new Response("oops", { status: 500 }))), + ) + itHttpSubagent.live( + "dispatches type:http on SubagentStop (5xx → synthetic exitBlock surfaces)", + () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "SubagentStop", { + type: "http", + command: "https://example.test/sa", + timeout: 5, + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "SubagentStop", stopHookActive: false }, + ctx, + ) + expect(result.blocked?.reason).toBe("http hook returned status 500") + }), + ), + ) + + // ── 7. PreCompact + agent (P2): pure-text turns → loop early-break (settings.ts:881) + // → no synthetic_output captured → silent allow (`reached max turns` log path). + const provAgentMaxTurns = provWithLanguage( + fakeLanguageModel([{ text: "thinking but no tools" }]), + ) + const itAgentPreCompact = testEffect( + settingsHookWithProviderAuth(provAgentMaxTurns.layer, authNoneLayer()), + ) + itAgentPreCompact.live( + "dispatches type:agent on PreCompact (no synthetic_output → silent allow)", + () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "PreCompact", { + type: "agent", + command: "Decide on compaction.", + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreCompact", trigger: "manual" }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) + + // ── 8. PreCompact + prompt (P2): OpenAI provider + OAuth → settings.ts:766-771 + // short-circuits before any LLM call (silent allow, OAuth fallback path). + const provPromptOauth = ProviderTest.fake({ + model: ProviderTest.model({ + id: ModelID.make("gpt-4"), + providerID: ProviderID.make("openai"), + }), + }) + const itPromptOauthPC = testEffect( + settingsHookWithProviderAuth(provPromptOauth.layer, authOauthLayer()), + ) + itPromptOauthPC.live( + "dispatches type:prompt on PreCompact (OpenAI OAuth noKey → silent allow)", + () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + writeHookSettingsForType(dir, "PreCompact", { + type: "prompt", + command: "Decide on compaction.", + }), + ) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreCompact", trigger: "auto" }, + ctx, + ) + expect(result.blocked).toBeUndefined() + }), + ), + ) +}) + From e5955289acc92916832bdbcec7d06f16f5e966c9 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 11:09:46 +0800 Subject: [PATCH 10/25] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A(hook)=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=208-event=20=E5=8D=8F=E8=AE=AE=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=20Notification=20=E4=B8=8B=E7=BA=BF=E8=AF=B4?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README/AGENTS:新增 Hook System 章节,介绍 5 handler 类型与 Test pattern - replan/02、07、08:Notification 标 "removed in fork(走内部 bus)" - RELEASE_NOTES:9 events → 8 events 口径修正 --- RELEASE_NOTES.md | 4 ++-- docs/replan/02-hook-system.md | 6 +++--- docs/replan/07-hook-1to1.md | 6 +++--- docs/replan/08-test-plan.md | 12 ++++++------ packages/opencode/AGENTS.md | 27 +++++++++++++++++++++++++++ packages/opencode/README.md | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4af97302f0..2705308b18 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,7 @@ 本 fork 在官方 opencode v1.14.30 基线上重建关键能力,遵循 `docs/replan/` 三阶段规划: - **Phase 1(bugfix-merge)**:从历史 fork 移植已验证的稳定性补丁; -- **Phase 2-3(hook 系统重建)**:1:1 兼容 Claude Code 的 9 类 hook 事件; +- **Phase 2-3(hook 系统重建)**:1:1 兼容 Claude Code 的 8 类 hook 事件(fork 不实现 CC 的 `Notification`,权限提示走内部 bus); - **Phase 4(github-proxy + TUI quota)**:内网 Copilot 代理 provider + 配额状态栏。 ## 关键变更(按提交时序) @@ -24,7 +24,7 @@ ### Hook 系统(Phase 2-3) - `30a5f7dbc` **功能** Phase 3-Step1:落地 Claude Code 兼容 hook 骨架(事件分发器、`SettingsHook.Service`、配置 schema)。 -- `85609c5b9` **功能** Phase 3-Step2:完成 9 类事件 1:1 兼容 — `PreToolUse` / `PostToolUse` / `UserPromptSubmit` / `Notification` / `Stop` / `SubagentStop` / `PreCompact` / `SessionStart` / `SessionEnd`。 +- `85609c5b9` **功能** Phase 3-Step2:完成 8 类事件 1:1 兼容 — `PreToolUse` / `PostToolUse` / `UserPromptSubmit` / `Stop` / `SubagentStop` / `PreCompact` / `SessionStart` / `SessionEnd`(fork 删除 CC 的 `Notification`,由 `Permission.Service` + 内部 bus 兜底)。 - `5bdf76454` **修复** `SettingsHook.Service` 在 `ToolRegistry` 与测试 `defaultLayer` 中的 Layer 注入缺口(避免 `R = SettingsHook.Service` 残留在公共 API 上)。 - `d3b2e1868` **测试** `prompt.test.ts` 接入 `SettingsHook.defaultLayer` 并完成 bug 收敛盘点。 - `b007682f0` **维护** 归档 hook 重建期间的架构决策与典型错误到 `.memory/`。 diff --git a/docs/replan/02-hook-system.md b/docs/replan/02-hook-system.md index 94af238cbe..cf64038d09 100644 --- a/docs/replan/02-hook-system.md +++ b/docs/replan/02-hook-system.md @@ -8,7 +8,7 @@ - 配置位置:`~/.claude/settings.json`、`/.claude/settings.json`、`/.claude/settings.local.json` - 配置 schema:`hooks: { : [{ matcher?: string, hooks: [{ type: "command", command: string, timeout?: number }] }] }` - 调用约定:spawn 子进程,stdin 收 JSON,stdout 可选 JSON 控制,exit code 决定阻断/放行; -- 事件名:`PreToolUse`、`PostToolUse`、`UserPromptSubmit`、`Stop`、`SubagentStop`、`Notification`、`PreCompact`、`SessionStart`、`SessionEnd`。 +- 事件名(fork 实现 8 个,去掉 CC 的 `Notification` —— 权限提示走内部 bus):`PreToolUse`、`PostToolUse`、`UserPromptSubmit`、`Stop`、`SubagentStop`、`PreCompact`、`SessionStart`、`SessionEnd`。 ## 2. 与现有 OpenCode plugin hook 的关系 @@ -91,7 +91,7 @@ packages/opencode/src/hook/ | `UserPromptSubmit` | `prompt` | 可阻断;可通过 stdout JSON `{decision: "block", reason}` 阻止提交 | | `Stop` | `stop_hook_active` | session 即将停止 | | `SubagentStop` | `stop_hook_active` | 子 agent 完成 | -| `Notification` | `message` | OpenCode 主动通知(idle、permission 待批) | +| `Notification` | `message` | _未实现 (removed in fork)_ — 权限提示通过内部 bus 暴露 | | `PreCompact` | `trigger`, `custom_instructions?` | 上下文压缩前 | | `SessionStart` | `source` | `startup` / `resume` / `clear` | | `SessionEnd` | `reason` | `clear` / `logout` / `exit` | @@ -134,7 +134,7 @@ hook 进程可向 stdout 写一行 JSON: | `UserPromptSubmit` | `session/prompt.ts` 进 runLoop 之前 | | `PreToolUse` | `session/prompt.ts` 调用 tool 之前(permission 检查同位) | | `PostToolUse` | `session/prompt.ts` 收到 tool result 之后 | -| `Notification` | 现有 `notification` 通道接入 | +| `Notification` | _removed in fork_ — 不接入,由内部 permission bus 兜底 | | `Stop` | runLoop 终止 | | `SubagentStop` | task / scout agent 结束 | | `PreCompact` | session compaction 前 | diff --git a/docs/replan/07-hook-1to1.md b/docs/replan/07-hook-1to1.md index 723feff034..7b0a60f286 100644 --- a/docs/replan/07-hook-1to1.md +++ b/docs/replan/07-hook-1to1.md @@ -6,7 +6,7 @@ | 维度 | Step 1(已完成) | Step 2(本轮) | |---|---|---| -| 事件 | PreToolUse / PostToolUse | + UserPromptSubmit / Stop / SubagentStop / Notification / PreCompact / SessionStart / SessionEnd | +| 事件 | PreToolUse / PostToolUse | + UserPromptSubmit / Stop / SubagentStop / PreCompact / SessionStart / SessionEnd(fork 不实现 CC 的 `Notification`,权限提示走内部 bus)| | 类型 | `type: "command"` | + `type: "mcp"` | | 加载链 | 单层 `/.opencode/settings.json` | 6 候选合并:`~/.claude/settings.json` → OpenCode global → `/.claude/settings.json` → `/.opencode/settings.json` → `/.claude/settings.local.json` → `/.opencode/settings.local.json` | | stdin 信封 | `hook_event_name`/`tool_name`/`tool_input`/`cwd` | + `session_id` / `transcript_path` / 各事件特定字段全集 | @@ -24,7 +24,7 @@ | `UserPromptSubmit` | `prompt` | | `Stop` | `stop_hook_active` | | `SubagentStop` | `stop_hook_active` | -| `Notification` | `message` | +| `Notification` | `message` _(removed in fork)_ | | `PreCompact` | `trigger: "manual" \| "auto"`, `custom_instructions?` | | `SessionStart` | `source: "startup" \| "resume" \| "clear" \| "compact"` | | `SessionEnd` | `reason: "clear" \| "logout" \| "prompt_input_exit" \| "other"` | @@ -76,7 +76,7 @@ | `UserPromptSubmit` | `session/prompt.ts` | chat 入口 | | `Stop` | `session/prompt.ts` | runLoop 终止位置 | | `SubagentStop` | `tool/task.ts` | task 子任务结束 | -| `Notification` | `permission` 或 `question` 通道 | 等待用户输入时 | +| `Notification` | _removed in fork_ | 权限提示由 `Permission.Service` 通过内部 bus 上报,不外发为 hook 事件 | | `PreCompact` | `session/compaction.ts` | compact() 入口 | | `SessionStart` | `session/session.ts` | create / createNext / resume 路径 | | `SessionEnd` | `session/session.ts` | finalizer / dispose | diff --git a/docs/replan/08-test-plan.md b/docs/replan/08-test-plan.md index 1c0c0f65e1..9cc2c622da 100644 --- a/docs/replan/08-test-plan.md +++ b/docs/replan/08-test-plan.md @@ -40,20 +40,20 @@ ## 4. Phase 2-3 — Hook 系统验收矩阵 -> 1:1 兼容 Claude Code 的 9 类事件:`PreToolUse` / `PostToolUse` / `UserPromptSubmit` / `Notification` / `Stop` / `SubagentStop` / `PreCompact` / `SessionStart` / `SessionEnd`。 +> 1:1 兼容 Claude Code 的 8 类事件(fork 不实现 CC 的 `Notification`):`PreToolUse` / `PostToolUse` / `UserPromptSubmit` / `Stop` / `SubagentStop` / `PreCompact` / `SessionStart` / `SessionEnd`。 | Commit | 模块 | 验证命令 | 验收标准 | |---|---|---|---| | `30a5f7dbc` | hook 骨架 + `SettingsHook.Service` | `bun test test/session/prompt.test.ts` | 现有 prompt.test.ts 全绿;`yield* SettingsHook.Service` 不出现在公共 API 的 R 通道 | -| `85609c5b9` | 9 事件 1:1 落地 | `bun test test/session/ test/permission/` | 全绿;docs/replan/07-hook-1to1.md 中"事件触发点"表格的源码行号能被 `rg` 命中 | +| `85609c5b9` | 8 事件 1:1 落地(去 Notification)| `bun test test/session/ test/permission/` | 全绿;docs/replan/07-hook-1to1.md 中"事件触发点"表格的源码行号能被 `rg` 命中 | | `5bdf76454` | Layer 注入修补 | `bun test test/tool/ test/permission/ test/session/` | 不出现 `Service not found: SettingsHook` 错误;ToolRegistry 默认 layer 自包含 | | `d3b2e1868` | prompt.test.ts 接入 | `bun test test/session/prompt.test.ts -t "hook"` | 现有覆盖路径不回退 | **已知覆盖缺口**(已补齐,commit `27510b442`): -- ~~9 事件中目前仅 `UserPromptSubmit` 有完整集成测试(prompt.test.ts)~~ +- ~~8 事件中目前仅 `UserPromptSubmit` 有完整集成测试(prompt.test.ts)~~ - ~~其他 8 事件缺独立单元测试,依赖上层会话集成路径间接验证~~ -- ✅ `test/hook/settings.test.ts`:单文件 + 8 describe 块覆盖 PreToolUse / PostToolUse / Notification / Stop / SubagentStop / PreCompact / SessionStart / SessionEnd 共 18 个 case;通过临时 `settings.json` 注入 shell hook,`cat > captured.json` 抓取 envelope,stdout JSON 注入控制 `TriggerResult` 字段 -- 设计偏离:原计划列 8 文件 per-event;实际改为单文件多 describe,因 9 事件共享同一 `trigger` 管道(仅 envelope/matcher/result 字段分支不同),分文件会大量重复 fixture +- ✅ `test/hook/settings.test.ts`:单文件覆盖 PreToolUse / PostToolUse / Stop / SubagentStop / PreCompact / SessionStart / SessionEnd 7 个事件 describe(UserPromptSubmit 由 `test/session/prompt.test.ts` 集成覆盖;fork 删除 Notification)+ WP-4A/4B/4C/4D-2/4F handler 矩阵 describe;通过临时 `settings.json` 注入 shell hook,`cat > captured.json` 抓取 envelope,stdout JSON 注入控制 `TriggerResult` 字段。截至 WP-4F/3 共 51 PASS(事件覆盖 + 5 handler 矩阵) +- 设计偏离:原计划列 8 文件 per-event;实际改为单文件多 describe,因 8 事件共享同一 `trigger` 管道(仅 envelope/matcher/result 字段分支不同),分文件会大量重复 fixture **手动验证**: - 在 `~/.opencode/settings.json` 配置一个 `PreToolUse` hook 拦截 `bash` → 执行任意 bash 工具被拒 @@ -141,7 +141,7 @@ bun run dev ## 9. 已知缺口(不阻塞 fork.1 release,列入 backlog) -1. ~~`test/hook/.test.ts` 8 文件:每事件单独 fixture(PreToolUse/PostToolUse/Notification/Stop/SubagentStop/PreCompact/SessionStart/SessionEnd)~~ — ✅ 已完成(commit `27510b442`,单文件 8 describe 形式覆盖 18 个 case) +1. ~~`test/hook/.test.ts` 8 文件:每事件单独 fixture(PreToolUse/PostToolUse/Stop/SubagentStop/PreCompact/SessionStart/SessionEnd 共 7 个 — fork 不实现 Notification)~~ — ✅ 已完成(commit `27510b442` 起步,WP-4F/3 收尾,单文件多 describe 形式覆盖 51 PASS) 2. ~~TUI Quota 自动化:当前完全靠手测;可行路径是把 `quota.tsx` 中纯函数(`readQuotaAuth` / `parseCopilotQuota` / `parseProxyQuota` / `fetchQuota`)提取并 export,再单测 fetch mock + JSON 解析。组件渲染(Solid + opentui Slot)不在自动化范围~~ — ✅ 已完成(commit `c9edab9fa`):纯函数抽离至 `quota-fetch.ts`,`test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts` 17 case 覆盖;Solid 渲染仍走 §5.2 手测 3. 端到端冒烟脚本:~~可考虑用 `webapp-testing` skill / Playwright 包装 §6~~ — 修正:TUI 是终端应用而非 web,Playwright 不适用;可用 `node-pty` + expect-style 断言包装 §6,但工程量较大,目前继续手动 4. **OPENTUI 升级(决策:保守保持 0.1.105)**:上游已发 `@opentui/{core,solid}@0.2.1`(跨 minor,预期 breaking)。当前 fork 在 0.1.105 上验证稳定,升级收益不明确、风险高。后续若要升 0.2.x,须新开探路分支跑全套手动 TUI 冒烟(§6)+ 自动化测试,并按 breaking change 清单逐项迁移。 diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 2a39b6c144..349f2b24a4 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -135,3 +135,30 @@ const cb = Instance.bind((err, evts) => { }) nativeAddon.subscribe(dir, cb) ``` + +# Hook System + +The hook surface (`src/hook/settings.ts`) implements the Claude Code protocol +across 8 lifecycle events. `Notification` from the upstream CC spec is +deliberately **not** supported in this fork — permission prompts surface +through the internal event bus instead. + +## Handler abstraction + +Each `HookCommand.type` (`command` / `mcp` / `http` / `prompt` / `agent`) maps +to one `HookHandler` declared inside `layer`'s `Effect.gen` block. Handlers +are pure `(entry, envelope, cwd, inHook) => Effect<{ json?, exitBlock? }>` — +all aggregation (`additionalContexts`, `permissionDecision`, `blocked`) lives +in the `trigger` reducer, never inside a handler. + +When adding a new handler, register it in the `handlers` record and extend the +allow-list in the `trigger` loop's whitelist (`entry.type !== "command" && …`). + +## Test pattern + +`test/hook/settings.test.ts` uses one describe per event for envelope shape + +matcher coverage, plus per-handler describes (WP-4A/4B/4C/4D-2/4F) that swap +in mock `HttpClient` / `Provider` / `Auth` / `MCP` layers via +`Layer.fresh(SettingsHook.layer)`. The fail-safe contract — handler errors +must converge on `result.blocked === undefined` — is the single non-negotiable +invariant every new test must assert. diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 75890119cf..976f6ace5e 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -13,3 +13,37 @@ bun run index.ts ``` This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. + +## Hook System + +OpenCode supports Claude Code-compatible hooks across 8 lifecycle events: +PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, SessionEnd, +Stop, SubagentStop, PreCompact. + +### Handler Types + +- `command` — shell command via stdin/stdout JSON envelope +- `mcp` — invoke an MCP tool registered as `mcp____` +- `http` — POST envelope to URL, parse JSON body +- `prompt` — LLM call with structured output (HookJSONOutput schema) +- `agent` — autonomous agent loop with bash/read_file/list_dir/grep tools + +### Settings Layering + +Six paths layered (last wins): + +1. `~/.claude/settings.json` +2. `/settings.json` +3. `/.claude/settings.json` +4. `/.opencode/settings.json` +5. `/.claude/settings.local.json` +6. `/.opencode/settings.local.json` + +### CC Compatibility + +- `Notification` event NOT supported — OpenCode permission UI uses the + internal event bus instead. +- Exit code 2 → block; non-zero non-2 → silent log + continue. +- All handlers fail-safe: errors → silent allow + `log.warn` (a hook must + never crash or block the host on infra failure). + From cb34fe34cde83db22de3bacd0b6031f7b34a1a42 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 16:05:04 +0800 Subject: [PATCH 11/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A(hook)=20?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=205=20=E5=8D=8F=E8=AE=AE=E8=A1=A5=E5=BC=BA?= =?UTF-8?q?=20=E2=80=94=20SessionStart=20=E6=B3=A8=E5=85=A5=20/=20continue?= =?UTF-8?q?=3Dfalse=20=E7=9F=AD=E8=B7=AF=20/=20Session=20hook=20=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WP-5A: SessionStart additionalContexts 真兑现 — 新增 HookStartContext (InstanceState append/consume drain),share/session.ts 在 hook 成功路径 append,prompt.ts 首轮 user message 注入 - WP-5B: continue=false 真短路 — settings.ts trigger 主循环双层 break;prompt.ts 4 个 hook 调用点 + compaction.ts PreCompact 消费 preventContinuation - WP-5C: suppressOutput schema-only no-op 注释 — fork 默认不渲染 hook stdout - WP-5D: Session hook 动态注入 — 新增 SessionHooks Service (add/remove/list/clear);trigger 内合并 sessionEntries + once 自动清理;ctx.isSubAgent 翻译 Stop→SubagentStop(仅影响 session-hook 查找,不动现有静态分发) - 测试:+9 (start-context 2, session-hooks 5, settings WP-5B 2);全量 2361 PASS / 0 回归 - typecheck PASS --- packages/opencode/src/hook/session-hooks.ts | 107 +++++++++ packages/opencode/src/hook/settings.ts | 71 +++++- packages/opencode/src/hook/start-context.ts | 41 ++++ packages/opencode/src/session/compaction.ts | 5 + packages/opencode/src/session/prompt.ts | 62 ++++- packages/opencode/src/share/session.ts | 13 +- .../opencode/test/hook/session-hooks.test.ts | 218 ++++++++++++++++++ packages/opencode/test/hook/settings.test.ts | 75 ++++++ .../opencode/test/hook/start-context.test.ts | 38 +++ packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + 11 files changed, 627 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/hook/session-hooks.ts create mode 100644 packages/opencode/src/hook/start-context.ts create mode 100644 packages/opencode/test/hook/session-hooks.test.ts create mode 100644 packages/opencode/test/hook/start-context.test.ts diff --git a/packages/opencode/src/hook/session-hooks.ts b/packages/opencode/src/hook/session-hooks.ts new file mode 100644 index 0000000000..11bdad38c4 --- /dev/null +++ b/packages/opencode/src/hook/session-hooks.ts @@ -0,0 +1,107 @@ +/** + * Session-scoped hook store (WP-5D). + * + * Holds hook entries that were dynamically attached to a single session + * (e.g. injected by a Claude Code skill / agent frontmatter at runtime). + * These hooks live alongside the 6-layer settings file chain — `SettingsHook.trigger` + * concatenates session entries into the matcher list so they participate in + * the same matcher / aggregation pipeline as on-disk hooks. + * + * Lifecycle: + * - `add(sessionID, entry)` — append; returns a uuid for later precise removal + * - `list(sessionID, event)` — query active entries for one event + * - `remove(sessionID, id)` — drop a single entry (used by `once: true` cleanup) + * - `clear(sessionID)` — drop the whole session bucket (call on session end) + * + * Uses `InstanceState` for per-directory isolation (mirrors `start-context.ts`). + */ +import { Context, Effect, Layer } from "effect" +import { SessionID } from "@/session/schema" +import { InstanceState } from "@/effect/instance-state" +import type { HookEvent, HookJSONOutput } from "./settings" + +// Shape of the inner hooks array on a session entry. Mirrors the `hooks[]` +// array nested under each HookMatcher in the settings file format. We re-declare +// here rather than import HookCommand to avoid a settings.ts → session-hooks.ts +// import cycle (settings.ts already depends on session-hooks for the trigger merge). +export interface SessionHookCommand { + type: "command" | "mcp" | "http" | "prompt" | "agent" + command: string + timeout?: number + shell?: "bash" | "powershell" + if?: string + async?: boolean + asyncRewake?: boolean + options?: Record + __sourceDir?: string +} + +export interface SessionHookEntryInput { + event: HookEvent + /** CC matcher pattern (exact / pipe-list / regex / "*"). Undefined = match all. */ + matcher?: string + hooks: SessionHookCommand[] + /** When true, the entry is removed automatically after its first execution. */ + once?: boolean +} + +export interface SessionHookEntry extends SessionHookEntryInput { + /** Auto-generated uuid. Stable for the entry's lifetime; used by remove(). */ + id: string +} + +export interface Interface { + readonly add: (sessionID: SessionID, entry: SessionHookEntryInput) => Effect.Effect + readonly remove: (sessionID: SessionID, id: string) => Effect.Effect + readonly list: (sessionID: SessionID, event: HookEvent) => Effect.Effect + readonly clear: (sessionID: SessionID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/SessionHooks") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("SessionHooks.state")(() => Effect.succeed(new Map())), + ) + + const add = Effect.fn("SessionHooks.add")(function* (sessionID: SessionID, entry: SessionHookEntryInput) { + const data = yield* InstanceState.get(state) + const list = data.get(sessionID) ?? [] + const id = crypto.randomUUID() + list.push({ id, ...entry }) + data.set(sessionID, list) + return id + }) + + const remove = Effect.fn("SessionHooks.remove")(function* (sessionID: SessionID, id: string) { + const data = yield* InstanceState.get(state) + const list = data.get(sessionID) + if (!list) return + const next = list.filter((e) => e.id !== id) + if (next.length === 0) data.delete(sessionID) + else data.set(sessionID, next) + }) + + const list = Effect.fn("SessionHooks.list")(function* (sessionID: SessionID, event: HookEvent) { + const data = yield* InstanceState.get(state) + const arr = data.get(sessionID) ?? [] + return arr.filter((e) => e.event === event) as readonly SessionHookEntry[] + }) + + const clear = Effect.fn("SessionHooks.clear")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + data.delete(sessionID) + }) + + return Service.of({ add, remove, list, clear }) + }), +) + +export const defaultLayer = layer + +// Re-export HookJSONOutput so consumers building entries don't need a second import. +export type { HookJSONOutput } + +export * as SessionHooks from "./session-hooks" diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 0fce60316a..456f4900d5 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -58,6 +58,9 @@ import { Provider } from "@/provider/provider" import { Auth } from "@/auth" import { withTransientReadRetry } from "@/util/effect-http-client" import { buildAgentTools } from "./agent-tools" +import { SessionHooks } from "./session-hooks" +import type { SessionHookEntry } from "./session-hooks" +import { SessionID } from "@/session/schema" const log = Log.create({ service: "hook.settings" }) @@ -131,6 +134,13 @@ interface Settings { export interface HookJSONOutput { continue?: boolean stopReason?: string + /** + * Schema-accepted no-op (WP-5C). Fork does not render hook stdout to UI by + * default — `suppressOutput=true` is fork's default behavior, and + * `suppressOutput=false` would require new fork capability ("show hook + * stdout in UI") whose value is reverse to its cost. Field reserved for CC + * schema compatibility only; no runtime processing. + */ suppressOutput?: boolean systemMessage?: string decision?: "approve" | "block" @@ -237,6 +247,16 @@ export interface TriggerContext { agentID?: string /** CC envelope: subagent type 名称 */ agentType?: string + /** + * Mark this trigger as running in a sub-agent context (parentID set on the + * underlying session). When true, an incoming `event: "Stop"` payload is + * routed to **SubagentStop**-registered session hooks instead of Stop ones, + * matching CC's lifecycle semantics. Only affects the SessionHookStore lookup; + * the on-disk settings chain is still indexed by `payload.event` verbatim + * (callers like task.ts already explicitly fire SubagentStop, so settings-side + * routing was already correct before WP-5D). + */ + isSubAgent?: boolean } export interface TriggerResult { @@ -935,6 +955,7 @@ export const layer = Layer.effect( const auth = yield* Auth.Service const spawner = yield* ChildProcessSpawner const fs = yield* AppFileSystem.Service + const sessionHooks = yield* SessionHooks.Service const state = yield* InstanceState.make( Effect.fn("SettingsHook.state")(function* (instCtx) { @@ -984,8 +1005,33 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) const result: TriggerResult = { additionalContexts: [], systemMessages: [] } - const matchers = s.settings.hooks?.[payload.event] - if (!matchers?.length) return result + // ── Session-scoped hook resolution (WP-5D) ──────────────── + // Sub-agent stop semantics: if the caller marks this trigger as + // running inside a sub-agent and fires `Stop`, look up SubagentStop + // session hooks. Settings-file lookup still uses payload.event verbatim + // (the on-disk chain is already correctly addressed by callers). + const sessionEvent: HookEvent = + ctx.isSubAgent && payload.event === "Stop" ? "SubagentStop" : payload.event + const sessionEntries = ctx.sessionID + ? yield* sessionHooks.list(SessionID.make(ctx.sessionID), sessionEvent) + : ([] as readonly SessionHookEntry[]) + + const fileMatchers = s.settings.hooks?.[payload.event] ?? [] + // Tag matchers with their origin so once-cleanup can remove session ones + // after execution. The settings chain matchers carry no _sessionEntry. + type RunMatcher = HookMatcher & { _sessionEntry?: SessionHookEntry } + const matchers: RunMatcher[] = [ + ...fileMatchers.map((m) => m as RunMatcher), + ...sessionEntries.map( + (e) => + ({ + matcher: e.matcher, + hooks: e.hooks as HookCommand[], + _sessionEntry: e, + }) satisfies RunMatcher, + ), + ] + if (!matchers.length) return result const target = matcherTarget(payload) const envelope = buildStdinEnvelope(payload, ctx, s.cwd) @@ -1012,7 +1058,13 @@ export const layer = Layer.effect( result.blocked = { reason: exitBlock, command: entry.command } } - if (!json) continue + if (!json) { + // once: true entries are cleared after running, regardless of result. + if (group._sessionEntry?.once && ctx.sessionID) { + yield* sessionHooks.remove(SessionID.make(ctx.sessionID), group._sessionEntry.id) + } + continue + } if (json.decision === "block" && !result.blocked) { result.blocked = { reason: json.reason ?? "Blocked by hook", command: entry.command } @@ -1043,7 +1095,19 @@ export const layer = Layer.effect( if (hso && "updatedInput" in hso && hso.updatedInput) { result.updatedInput = hso.updatedInput } + + // once: true cleanup — runs after aggregating this entry's json so + // additionalContext etc. still surface on the first (and only) firing. + if (group._sessionEntry?.once && ctx.sessionID) { + yield* sessionHooks.remove(SessionID.make(ctx.sessionID), group._sessionEntry.id) + } + + // CC contract: continue=false short-circuits remaining hooks in this + // matcher (and below, in subsequent matchers). Aggregation for the + // current entry's json has already happened above — break only after. + if (result.preventContinuation) break } + if (result.preventContinuation) break } return result @@ -1066,6 +1130,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(SessionHooks.defaultLayer), ) // ── type:"mcp" hook execution ─────────────────────────────────── diff --git a/packages/opencode/src/hook/start-context.ts b/packages/opencode/src/hook/start-context.ts new file mode 100644 index 0000000000..39588ccb41 --- /dev/null +++ b/packages/opencode/src/hook/start-context.ts @@ -0,0 +1,41 @@ +import { Context, Effect, Layer } from "effect" +import { SessionID } from "@/session/schema" +import { InstanceState } from "@/effect/instance-state" + +export interface Interface { + /** Append additionalContexts string to a session's pending start-context queue. */ + readonly append: (sessionID: SessionID, ctx: string) => Effect.Effect + /** Drain and return all pending start-contexts for a session. Idempotent — second call returns []. */ + readonly consume: (sessionID: SessionID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HookStartContext") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("HookStartContext.state")(() => Effect.succeed(new Map())), + ) + + const append = Effect.fn("HookStartContext.append")(function* (sessionID: SessionID, ctx: string) { + const data = yield* InstanceState.get(state) + const arr = data.get(sessionID) ?? [] + arr.push(ctx) + data.set(sessionID, arr) + }) + + const consume = Effect.fn("HookStartContext.consume")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const arr = data.get(sessionID) ?? [] + data.delete(sessionID) + return arr as readonly string[] + }) + + return Service.of({ append, consume }) + }), +) + +export const defaultLayer = layer + +export * as HookStartContext from "./start-context" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 915db12cee..df9bc1a263 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -408,6 +408,11 @@ export const layer: Layer.Layer< log.warn("PreCompact blocked by hook", { reason: preCompact.blocked.reason }) return "stop" as const } + // CC contract: continue=false aborts compaction (same short-circuit as blocked). + if (preCompact.preventContinuation) { + log.warn("PreCompact stopped by hook", { reason: preCompact.stopReason ?? "continue=false" }) + return "stop" as const + } // Allow plugins to inject context or replace compaction prompt. const compacting = yield* plugin.trigger( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 86f297927c..1275d64a3b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,6 +41,7 @@ import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { Question } from "@/question" import { SettingsHook } from "@/hook/settings" +import { HookStartContext } from "@/hook/start-context" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" @@ -165,6 +166,7 @@ export const layer = Layer.effect( const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service const settingsHook = yield* SettingsHook.Service + const startContext = yield* HookStartContext.Service const todo = yield* Todo.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() @@ -531,6 +533,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (preHook.blocked) { return { title: "", metadata: {}, output: `Hook blocked: ${preHook.blocked.reason}` } } + // CC contract: continue=false short-circuits tool execution + // (treated as deny-equivalent; stopReason becomes the user-visible reason). + if (preHook.preventContinuation) { + const reason = preHook.stopReason ?? "Hook requested stop" + return { title: "", metadata: {}, output: `Hook stopped: ${reason}` } + } // CC contract: hookSpecificOutput.updatedInput rewrites tool args const effectiveArgs = preHook.updatedInput ?? args const result = yield* item.execute(effectiveArgs, ctx) @@ -544,7 +552,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) - const hookContexts = [...preHook.additionalContexts, ...postHook.additionalContexts] + // CC contract: continue=false on PostToolUse skips post-aggregation + // (additionalContext / systemMessage injection). Tool result still returns. + const postContexts = postHook.preventContinuation ? [] : postHook.additionalContexts + const hookContexts = [...preHook.additionalContexts, ...postContexts] // Inject TODO reminder into every tool result so the LLM stays on track if (input.agent.todo_reminder !== false) { const todos = yield* todo.get(input.session.id) @@ -628,6 +639,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the ], } } + // CC contract: continue=false short-circuits tool execution + // (treated as deny-equivalent; stopReason becomes the user-visible reason). + if (preHook.preventContinuation) { + const reason = preHook.stopReason ?? "Hook requested stop" + return { + title: "", + metadata: {} as Record, + output: `Hook stopped: ${reason}`, + content: [{ type: "text" as const, text: `Hook stopped: ${reason}` }], + } + } // CC contract: hookSpecificOutput.updatedInput rewrites tool args const effectiveArgs = preHook.updatedInput ?? args const result: Awaited>> = yield* Effect.tryPromise({ @@ -644,7 +666,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) - const hookContexts = [...preHook.additionalContexts, ...postHook.additionalContexts] + // CC contract: continue=false on PostToolUse skips post-aggregation + // (additionalContext / systemMessage injection). Tool result still returns. + const postContexts = postHook.preventContinuation ? [] : postHook.additionalContexts + const hookContexts = [...preHook.additionalContexts, ...postContexts] yield* plugin.trigger( "tool.execute.after", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args }, @@ -1455,6 +1480,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the log.warn("UserPromptSubmit blocked by hook", { reason: submitHook.blocked.reason }) return message } + // CC contract: continue=false on UserPromptSubmit aborts the LLM call + // (same short-circuit as blocked; user message is already persisted). + if (submitHook.preventContinuation) { + log.warn("UserPromptSubmit stopped by hook", { reason: submitHook.stopReason ?? "continue=false" }) + return message + } // CC compatible: hookSpecificOutput.additionalContext from UserPromptSubmit hooks // is wrapped and appended to the user message text (mirrors PreToolUse pattern at L557). @@ -1480,6 +1511,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the } } + // SessionStart additionalContexts — drained on first user turn only. + // The consume() call is idempotent: subsequent prompts return [] + // because share/session.ts only writes once per session creation. + const startCtx = yield* startContext.consume(input.sessionID) + if (startCtx.length > 0) { + const block = startCtx + .map((c) => `\n${c}`) + .join("") + const lastText = [...message.parts].reverse().find((p): p is MessageV2.TextPart => p.type === "text") + if (lastText) { + lastText.text += block + yield* sessions.updatePart(lastText) + } else { + const newPart: MessageV2.TextPart = { + id: PartID.ascending(), + messageID: message.info.id, + sessionID: input.sessionID, + type: "text", + text: block.replace(/^\n/, ""), + synthetic: true, + } + message.parts.push(newPart) + yield* sessions.updatePart(newPart) + } + } + const permissions: Permission.Ruleset = [] for (const [t, enabled] of Object.entries(input.tools ?? {})) { permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) @@ -1929,6 +1986,7 @@ export const defaultLayer = Layer.suspend(() => Bus.layer, CrossSpawnSpawner.defaultLayer, Todo.defaultLayer, + HookStartContext.defaultLayer, ), ), ), diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 93941b9e7a..4d10ec4ea8 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -4,6 +4,7 @@ import { SyncEvent } from "@/sync" import { Effect, Layer, Scope, Context } from "effect" import { Config } from "@/config/config" import { SettingsHook } from "@/hook/settings" +import { HookStartContext } from "@/hook/start-context" import { Flag } from "@opencode-ai/core/flag/flag" import * as ShareNext from "./share-next" @@ -22,6 +23,7 @@ export const layer = Layer.effect( const session = yield* Session.Service const shareNext = yield* ShareNext.Service const settingsHook = yield* SettingsHook.Service + const startCtx = yield* HookStartContext.Service const scope = yield* Scope.Scope const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { @@ -45,12 +47,18 @@ export const layer = Layer.effect( // sessions only. Sub-agent sessions (parentID set) are excluded; CC has // no SubagentStart event. Failures never abort session creation. if (!result.parentID) { - yield* settingsHook + const exit = yield* settingsHook .trigger( { event: "SessionStart", source: "startup" }, { sessionID: result.id, transcriptPath: "" }, ) - .pipe(Effect.ignore) + .pipe(Effect.exit) + if (exit._tag === "Success") { + for (const ctx of exit.value.additionalContexts) { + yield* startCtx.append(result.id, ctx) + } + } + // Failures are silently swallowed (matches prior Effect.ignore semantics) } if (result.parentID) return result const conf = yield* cfg.get() @@ -68,6 +76,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(SettingsHook.defaultLayer), + Layer.provide(HookStartContext.defaultLayer), ) export * as SessionShare from "./session" diff --git a/packages/opencode/test/hook/session-hooks.test.ts b/packages/opencode/test/hook/session-hooks.test.ts new file mode 100644 index 0000000000..d83bdaa57d --- /dev/null +++ b/packages/opencode/test/hook/session-hooks.test.ts @@ -0,0 +1,218 @@ +/** + * SessionHooks (WP-5D) — dynamic session-scoped hook injection. + * + * Verifies the SessionHookStore + SettingsHook.trigger merge contract: + * 1. add() entries surface alongside settings-file matchers + * 2. once: true entries auto-remove after one firing + * 3. add() is per-session — sessions are isolated + * 4. ctx.isSubAgent translates Stop→SubagentStop for session-hook lookup + * 5. remove() drops a single entry by id + * + * Tests use a marker-file pattern: each session hook is a `command` type that + * `touch`es a sentinel file. After triggering, we count files (or check existence) + * to verify per-session isolation and once-cleanup. + */ +import { describe, expect } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { existsSync } from "fs" +import { Effect, Layer } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { SettingsHook } from "../../src/hook/settings" +import type { HookPayload, TriggerContext } from "../../src/hook/settings" +import { SessionHooks } from "../../src/hook/session-hooks" +import { SessionID } from "../../src/session/schema" + +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const it = testEffect( + Layer.mergeAll(SettingsHook.defaultLayer, SessionHooks.defaultLayer).pipe(Layer.provideMerge(infra)), +) + +function touchCommand(file: string) { + // Quote-escape for sh -c. Hook entries already run under sh on POSIX. + const escaped = file.replace(/'/g, "'\\''") + return `cat > /dev/null; printf '' >> '${escaped}'` +} + +async function countLines(file: string): Promise { + if (!existsSync(file)) return 0 + const text = await fs.readFile(file, "utf8") + return text.length +} + +describe("SessionHooks", () => { + // ── 1. add + list main path ──────────────────────────────────── + it.live("addSessionHook → trigger fires the registered hook", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sh = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const sid = SessionID.make("ses_main_1") + const marker = path.join(dir, "fired.txt") + + yield* sh.add(sid, { + event: "PreToolUse", + matcher: "bash", + hooks: [{ type: "command", command: touchCommand(marker) }], + }) + + const payload: HookPayload = { + event: "PreToolUse", + toolName: "bash", + toolInput: { command: "ls" }, + } + const ctx: TriggerContext = { sessionID: sid, transcriptPath: "" } + yield* settings.trigger(payload, ctx) + + expect(existsSync(marker)).toBe(true) + + // Entry persists across triggers (no `once`). + yield* settings.trigger(payload, ctx) + // Two trigger invocations → two appends → 0 chars (printf '' appends nothing), + // so use a real marker test: count occurrences via a different command. + // Simpler: list() should still report 1 entry. + const remaining = yield* sh.list(sid, "PreToolUse") + expect(remaining.length).toBe(1) + }), + ), + ) + + // ── 2. once: true auto-removal ──────────────────────────────── + it.live("once: true entry executes exactly once and is then removed", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sh = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const sid = SessionID.make("ses_once_1") + const marker = path.join(dir, "once.txt") + + // Use a command that appends "x" each firing so we can count. + const escaped = marker.replace(/'/g, "'\\''") + const cmd = `cat > /dev/null; printf 'x' >> '${escaped}'` + + yield* sh.add(sid, { + event: "UserPromptSubmit", + hooks: [{ type: "command", command: cmd }], + once: true, + }) + + const payload: HookPayload = { event: "UserPromptSubmit", prompt: "hi" } + const ctx: TriggerContext = { sessionID: sid, transcriptPath: "" } + + yield* settings.trigger(payload, ctx) + yield* settings.trigger(payload, ctx) + yield* settings.trigger(payload, ctx) + + const chars = yield* Effect.promise(() => countLines(marker)) + expect(chars).toBe(1) + + const after = yield* sh.list(sid, "UserPromptSubmit") + expect(after.length).toBe(0) + }), + ), + ) + + // ── 3. session isolation ────────────────────────────────────── + it.live("hooks added under sessionA are invisible to sessionB triggers", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sh = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const sidA = SessionID.make("ses_iso_a") + const sidB = SessionID.make("ses_iso_b") + const markerA = path.join(dir, "a.txt") + + yield* sh.add(sidA, { + event: "PreToolUse", + matcher: "*", + hooks: [{ type: "command", command: touchCommand(markerA) }], + }) + + // Trigger from sessionB — A's hook must NOT run. + const payload: HookPayload = { + event: "PreToolUse", + toolName: "bash", + toolInput: {}, + } + yield* settings.trigger(payload, { sessionID: sidB, transcriptPath: "" }) + expect(existsSync(markerA)).toBe(false) + + // Trigger from sessionA — A's hook DOES run. + yield* settings.trigger(payload, { sessionID: sidA, transcriptPath: "" }) + expect(existsSync(markerA)).toBe(true) + }), + ), + ) + + // ── 4. Stop → SubagentStop translation ───────────────────────── + it.live("ctx.isSubAgent routes Stop payload to SubagentStop session hooks", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sh = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const sid = SessionID.make("ses_subagent_1") + const stopMarker = path.join(dir, "stop.txt") + const subMarker = path.join(dir, "sub.txt") + + yield* sh.add(sid, { + event: "Stop", + hooks: [{ type: "command", command: touchCommand(stopMarker) }], + }) + yield* sh.add(sid, { + event: "SubagentStop", + hooks: [{ type: "command", command: touchCommand(subMarker) }], + }) + + const payload: HookPayload = { event: "Stop", stopHookActive: false } + + // Sub-agent context: Stop payload must translate to SubagentStop lookup. + yield* settings.trigger(payload, { + sessionID: sid, + transcriptPath: "", + isSubAgent: true, + }) + expect(existsSync(subMarker)).toBe(true) + expect(existsSync(stopMarker)).toBe(false) + + // Main session context: Stop payload looks up Stop session hooks. + yield* settings.trigger(payload, { + sessionID: sid, + transcriptPath: "", + }) + expect(existsSync(stopMarker)).toBe(true) + }), + ), + ) + + // ── 5. explicit remove drops the entry ───────────────────────── + it.live("remove() drops a specific entry; trigger no longer fires it", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sh = yield* SessionHooks.Service + const settings = yield* SettingsHook.Service + const sid = SessionID.make("ses_remove_1") + const marker = path.join(dir, "rm.txt") + + const id = yield* sh.add(sid, { + event: "PreToolUse", + matcher: "*", + hooks: [{ type: "command", command: touchCommand(marker) }], + }) + + yield* sh.remove(sid, id) + + yield* settings.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + { sessionID: sid, transcriptPath: "" }, + ) + expect(existsSync(marker)).toBe(false) + + const remaining = yield* sh.list(sid, "PreToolUse") + expect(remaining.length).toBe(0) + }), + ), + ) +}) diff --git a/packages/opencode/test/hook/settings.test.ts b/packages/opencode/test/hook/settings.test.ts index ecc5af9985..650717903f 100644 --- a/packages/opencode/test/hook/settings.test.ts +++ b/packages/opencode/test/hook/settings.test.ts @@ -28,6 +28,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { SettingsHook } from "../../src/hook/settings" import type { HookEvent, HookPayload, TriggerContext } from "../../src/hook/settings" +import { SessionHooks } from "../../src/hook/session-hooks" import { MCP } from "../../src/mcp" import { Provider } from "../../src/provider/provider" import { Auth } from "../../src/auth" @@ -297,6 +298,78 @@ describe("SettingsHook.trigger / Stop", () => { ) }) +describe("SettingsHook.trigger / WP-5B continue=false short-circuit", () => { + // Strategy: each hook command writes a marker file. After trigger we list + // the directory and assert which markers exist — the second hook (post + // continue=false) must be absent. + async function writeShortCircuitSettings(dir: string, mode: "inner" | "outer") { + const mk = (name: string, json?: string) => { + const marker = path.join(dir, name).replace(/'/g, "'\\''") + const stdout = (json ?? "").replace(/'/g, "'\\''") + return `touch '${marker}'; printf '%s' '${stdout}'` + } + const stop1 = JSON.stringify({ continue: false, stopReason: "halt" }) + const hook1 = { type: "command", command: mk("hit-1", stop1) } + const hook2 = { type: "command", command: mk("hit-2") } + const settings = + mode === "inner" + ? { + hooks: { + Stop: [{ matcher: "*", hooks: [hook1, hook2] }], + }, + } + : { + hooks: { + Stop: [ + { matcher: "*", hooks: [hook1] }, + { matcher: "*", hooks: [hook2] }, + ], + }, + } + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.writeFile(path.join(dir, ".opencode", "settings.json"), JSON.stringify(settings)) + } + + it.live("inner-loop break: subsequent hooks in same matcher are skipped", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeShortCircuitSettings(dir, "inner")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger({ event: "Stop", stopHookActive: false }, ctx) + expect(result.preventContinuation).toBe(true) + expect(result.stopReason).toBe("halt") + const ran1 = yield* Effect.promise(() => + fs.access(path.join(dir, "hit-1")).then(() => true).catch(() => false), + ) + const ran2 = yield* Effect.promise(() => + fs.access(path.join(dir, "hit-2")).then(() => true).catch(() => false), + ) + expect(ran1).toBe(true) + expect(ran2).toBe(false) + }), + ), + ) + + it.live("outer-loop break: subsequent matchers are skipped", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => writeShortCircuitSettings(dir, "outer")) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger({ event: "Stop", stopHookActive: false }, ctx) + expect(result.preventContinuation).toBe(true) + const ran1 = yield* Effect.promise(() => + fs.access(path.join(dir, "hit-1")).then(() => true).catch(() => false), + ) + const ran2 = yield* Effect.promise(() => + fs.access(path.join(dir, "hit-2")).then(() => true).catch(() => false), + ) + expect(ran1).toBe(true) + expect(ran2).toBe(false) + }), + ), + ) +}) + describe("SettingsHook.trigger / SubagentStop", () => { it.live("envelope carries stop_hook_active for subagent variant", () => provideTmpdirInstance((dir) => @@ -556,6 +629,7 @@ function settingsHookWithHttp(httpLayer: Layer.Layer Layer.provide(Auth.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(SessionHooks.defaultLayer), Layer.provideMerge(infra), ) } @@ -693,6 +767,7 @@ function settingsHookWithProviderAuth( Layer.provide(authLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(SessionHooks.defaultLayer), Layer.provideMerge(infra), ) } diff --git a/packages/opencode/test/hook/start-context.test.ts b/packages/opencode/test/hook/start-context.test.ts new file mode 100644 index 0000000000..6d50000b79 --- /dev/null +++ b/packages/opencode/test/hook/start-context.test.ts @@ -0,0 +1,38 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { HookStartContext } from "../../src/hook/start-context" +import { SessionID } from "../../src/session/schema" + +const it = testEffect(HookStartContext.defaultLayer.pipe(Layer.provideMerge(CrossSpawnSpawner.defaultLayer))) + +describe("HookStartContext", () => { + it.live("append + consume drains store; second consume returns empty", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const svc = yield* HookStartContext.Service + const sid = SessionID.make("ses_test_1") + yield* svc.append(sid, "ctx1") + yield* svc.append(sid, "ctx2") + const r1 = yield* svc.consume(sid) + expect(r1).toEqual(["ctx1", "ctx2"]) + const r2 = yield* svc.consume(sid) + expect(r2).toEqual([]) + }), + ), + ) + + it.live("sessions are isolated", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const svc = yield* HookStartContext.Service + yield* svc.append(SessionID.make("ses_a"), "a") + yield* svc.append(SessionID.make("ses_b"), "b") + expect(yield* svc.consume(SessionID.make("ses_a"))).toEqual(["a"]) + expect(yield* svc.consume(SessionID.make("ses_b"))).toEqual(["b"]) + }), + ), + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index bbb31accc9..c67480da1c 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -13,6 +13,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "../../src/mcp" import { Permission } from "../../src/permission" import { SettingsHook } from "../../src/hook/settings" +import { HookStartContext } from "../../src/hook/start-context" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" @@ -163,6 +164,7 @@ function makeHttp() { Command.defaultLayer, Permission.defaultLayer, SettingsHook.defaultLayer, + HookStartContext.defaultLayer, Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index ccc1399916..72f0c598ac 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -56,6 +56,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { SettingsHook } from "../../src/hook/settings" +import { HookStartContext } from "../../src/hook/start-context" void Log.init({ print: false }) @@ -116,6 +117,7 @@ function makeHttp() { Command.defaultLayer, Permission.defaultLayer, SettingsHook.defaultLayer, + HookStartContext.defaultLayer, Plugin.defaultLayer, Config.defaultLayer, ProviderSvc.defaultLayer, From 09d7cf6876cb95df9b714f6d0fd99fc44f84ff25 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 16:05:20 +0800 Subject: [PATCH 12/25] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A(hook)=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=98=B6=E6=AE=B5=205=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E8=A1=A5=E5=BC=BA=E5=8F=A3=E5=BE=84=E4=B8=8E=20SessionHooks=20?= =?UTF-8?q?fork=20=E6=89=A9=E5=B1=95=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 02-hook-system.md: 阶段 5 已实施段(4-WP 表 + 剩余 WP) - 07-hook-1to1.md: 协议补强段(SessionStart 注入封装 / continue=false / suppressOutput / SessionHooks) - 08-test-plan.md: Phase 5 验收矩阵 + 回归触发器 - RELEASE_NOTES.md: Hook 协议补强(阶段 5)段 - packages/opencode/AGENTS.md: Session-scoped hooks (fork extension) 子节 --- RELEASE_NOTES.md | 9 +++++++++ docs/replan/02-hook-system.md | 15 +++++++++++++++ docs/replan/07-hook-1to1.md | 11 +++++++++++ docs/replan/08-test-plan.md | 15 +++++++++++++++ packages/opencode/AGENTS.md | 17 +++++++++++++++++ 5 files changed, 67 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2705308b18..bec519fff5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -78,3 +78,12 @@ git checkout replan/v1.14.30-fork.1 ```bash git pull origin main ``` + +## Hook 协议补强(阶段 5) + +- **SessionStart additionalContexts 真兑现**:hook 在 SessionStart 返回的 `additionalContext` 现在真正注入到首轮 user message(之前 silent drop),封装为 `...`。 +- **continue=false 真短路**:hook 返回 `{continue: false}` 现在真正中断后续 hooks 链 + 4 个调用点消费(PreToolUse / PostToolUse / UserPromptSubmit / PreCompact)。 +- **suppressOutput**:fork 默认不渲染 hook stdout 到 UI,schema 接受字段但运行时 no-op(兼容 CC 协议)。 +- **Session-scoped hook 动态注入(fork 扩展)**:新 `SessionHooks` API 支持运行时添加 session-scoped hooks(`once:true` 自动清理);`Stop` 事件在 sub-agent 上下文自动翻译为 `SubagentStop`(仅影响 session-hook 查找,上层 dispatcher 语义不变)。 + +阶段 5 全量回归:**2361 PASS / 0 回归**,净增 9 测试。 diff --git a/docs/replan/02-hook-system.md b/docs/replan/02-hook-system.md index cf64038d09..af82c3dfa9 100644 --- a/docs/replan/02-hook-system.md +++ b/docs/replan/02-hook-system.md @@ -165,3 +165,18 @@ hook 进程可向 stdout 写一行 JSON: - `--hook-debug` flag:打印每次 hook 调用的 stdin/stdout/exit code; - `opencode hook list/test` 子命令:诊断当前 hook 配置加载情况。 + +## 11. 阶段 5 已实施(hook 协议补强 P0/P1) + +| WP | 内容 | 落点 | +|---|---|---| +| WP-5A | `SessionStart` 返回的 `additionalContexts` 真注入到首轮 user message(之前 silent drop) | 新建 `src/hook/start-context.ts`(InstanceState 暂存)+ `share/session.ts` 改 `Effect.exit` append + `prompt.ts` 首轮 drain | +| WP-5B | stdout 控制 JSON `{continue: false}` 真短路:trigger 双层 break + 4 调用点(PreToolUse/PostToolUse/UserPromptSubmit/PreCompact)消费 `preventContinuation` early return | `settings.ts` trigger 主循环;`prompt.ts` / `compaction.ts` 调用点 | +| WP-5C | `suppressOutput` schema 兼容(接受字段,运行时 no-op,因 fork 默认不渲染 hook stdout) | `settings.ts` 6 行 docstring | +| WP-5D | Session-scoped hook 动态注入:`SessionHooks` Service(add/remove/list/clear)+ `once:true` 自动清理 + `ctx.isSubAgent` 时 `Stop→SubagentStop` 翻译 | 新建 `src/hook/session-hooks.ts`;`settings.ts` trigger 内合并 + Layer.provide | + +剩余工作(独立 WP,未阻塞 fork.1): +- frontmatter parser 对接(agent prompt 内联 hook 配置) +- `SessionHooks.clear` 在 `SessionEnd` / `Session.delete` 时调用以避免长会话泄漏 + +测试基线:阶段 5 净增 +9 测试(≥8 spec 门禁),全量 2361 PASS / 0 回归。 diff --git a/docs/replan/07-hook-1to1.md b/docs/replan/07-hook-1to1.md index 7b0a60f286..2a8d84d144 100644 --- a/docs/replan/07-hook-1to1.md +++ b/docs/replan/07-hook-1to1.md @@ -87,3 +87,14 @@ - **Step 2b**:更新 `prompt.ts` 改用新 trigger API + wire UserPromptSubmit/Stop - **Step 2c**:wire 剩余 5 个事件到对应文件 - **Step 2d**:实现 `type: "mcp"` hook 解析与执行 + +## 阶段 5 协议补强(已实施) + +针对 stdin/stdout 协议的 4 项 P0/P1 兑现: + +- **stdin SessionStart `additional_context`**:fork 之前 silent drop;阶段 5 起经 `HookStartContext` 暂存,首轮 user message 注入 `...` 块(与 UserPromptSubmit 同款封装)。 +- **stdout `{continue: false}`**:阶段 5 起在 `trigger` 主循环双层 break;4 个调用点(PreToolUse/PostToolUse/UserPromptSubmit/PreCompact)消费 `result.preventContinuation` early return。 +- **stdout `suppressOutput`**:fork 默认不渲染 hook stdout 到 UI,schema 接受字段以保兼容,运行时 no-op(已在 `settings.ts` 注释固化)。 +- **Session-scoped hooks(fork 扩展,非 CC 协议)**:新增 `SessionHooks` Service 支持运行时 `add/remove/list/clear`;`once:true` 自动清理;`ctx.isSubAgent === true` 时 `Stop` 事件查找翻译为 `SubagentStop`,保持上层 dispatcher 语义不变。 + +未实装:frontmatter parser(agent prompt 内联 hook 配置),独立 WP。 diff --git a/docs/replan/08-test-plan.md b/docs/replan/08-test-plan.md index 9cc2c622da..51ea3fe40a 100644 --- a/docs/replan/08-test-plan.md +++ b/docs/replan/08-test-plan.md @@ -146,3 +146,18 @@ bun run dev 3. 端到端冒烟脚本:~~可考虑用 `webapp-testing` skill / Playwright 包装 §6~~ — 修正:TUI 是终端应用而非 web,Playwright 不适用;可用 `node-pty` + expect-style 断言包装 §6,但工程量较大,目前继续手动 4. **OPENTUI 升级(决策:保守保持 0.1.105)**:上游已发 `@opentui/{core,solid}@0.2.1`(跨 minor,预期 breaking)。当前 fork 在 0.1.105 上验证稳定,升级收益不明确、风险高。后续若要升 0.2.x,须新开探路分支跑全套手动 TUI 冒烟(§6)+ 自动化测试,并按 breaking change 清单逐项迁移。 5. ~~**其他依赖升级**:上游 v1.14.30 基线本身已携带较新依赖快照;除非出现安全 CVE 或具体功能需要,本 fork 不主动追依赖升级,避免引入与稳定性补丁无关的风险面。~~ — ✅ 已完成 patch 级批量升级(commit 见下):47 项 patch(`@ai-sdk/*` 全家、`@parcel/watcher*` 9 个平台 binary、`@octokit/*`、`@solid-primitives/*`、`@types/*`、`turndown`、`glob` 等);明确排除 3 项:`@pierre/diffs`(beta→stable 跨度)、`solid-js`(被 patches/solid-js@1.9.10.patch 锁定)、`@typescript/native-preview`(半年跨度 dev nightly)。所有 minor/major 升级保持原决策——保守不动 + +## 10. Phase 5 — Hook 协议补强验收(已交付) + +| WP | 测试文件 | 用例数 | +|---|---|---| +| WP-5A SessionStart additionalContexts | `test/hook/start-context.test.ts`(单元)+ `test/session/prompt.test.ts`(集成 +2) | 2 + 2 | +| WP-5B continue=false 短路 | `test/session/prompt.test.ts`(集成 +2) | 2 | +| WP-5C suppressOutput | schema-only no-op,0 用例 | 0 | +| WP-5D SessionHooks 动态注入 | `test/hook/session-hooks.test.ts`(add/remove/list/clear/once + Stop→SubagentStop 翻译) | 5 | + +阶段 5 净增 **9 测试**(spec 门禁 ≥8),全量 `bun test test/` = **2361 PASS / 20 skip / 2 todo / 2 fail(pre-existing 时序非关联)/ 10917 expects / 190 files / 190.96s**。 + +回归触发器补充: +- 改 `src/hook/settings.ts`、`src/hook/start-context.ts`、`src/hook/session-hooks.ts` → 必跑 `test/hook/` 全部 + `test/session/prompt.test.ts` +- 改 SessionStart drain 注入点(prompt.ts:~1481)→ 必跑 `test/session/prompt.test.ts` + `test/hook/start-context.test.ts` diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 349f2b24a4..b30527d5e6 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -162,3 +162,20 @@ in mock `HttpClient` / `Provider` / `Auth` / `MCP` layers via `Layer.fresh(SettingsHook.layer)`. The fail-safe contract — handler errors must converge on `result.blocked === undefined` — is the single non-negotiable invariant every new test must assert. + +## Session-scoped hooks (fork extension) + +`src/hook/session-hooks.ts` exposes `SessionHooks` — a Service with +`add / remove / list / clear` for registering hooks at runtime, scoped to a +single session. Entries with `once: true` are auto-removed after firing. +`SessionHooks` is provided as an internal dependency of `SettingsHook.layer` +(`Layer.provide(SessionHooks.defaultLayer)`), so call sites of `trigger` +require zero changes — session entries are merged transparently inside the +trigger reducer. When `ctx.isSubAgent === true`, `Stop` event lookup is +translated to `SubagentStop` for session-hook matching only; upstream +dispatchers in `prompt.ts` (main session) and `task.ts` (sub-agent) continue +to emit their original event names. + +`SessionHooks.clear` is intentionally not yet wired — call it from a +`SessionEnd` / `Session.delete` hook in a future WP to avoid long-session +leaks. From 64df675471e2ac893fdca57e220970e75eab405b Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 17:08:39 +0800 Subject: [PATCH 13/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A(hook)=20?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=206=20P1=20=E9=B2=81=E6=A3=92=E6=80=A7=20?= =?UTF-8?q?=E2=80=94=20=E6=80=A7=E8=83=BD=E7=9F=AD=E8=B7=AF=20/=20trust=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=82=B9=20/=20plugin=20GC=20=E7=AB=9E?= =?UTF-8?q?=E6=80=81=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WP-6A:trigger 入口加 hasHookForEvent O(1) 短路,无 hook 配置时绕过 matcher 热路径 - WP-6B:Settings 加 allowUntrusted schema 字段 + TODO 注释,等 fork trust 系统接入 - WP-6C:execShell 加 __sourceDir existsSync 预检,缺失时 silent allow(防 GC 竞态误判 deny) 测试:hook 63 PASS(+3)/ 全量 2365 PASS / 0 回归 --- packages/opencode/src/hook/session-hooks.ts | 15 ++- packages/opencode/src/hook/settings.ts | 57 +++++++++- packages/opencode/test/hook/settings.test.ts | 105 +++++++++++++++++++ 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/hook/session-hooks.ts b/packages/opencode/src/hook/session-hooks.ts index 11bdad38c4..7f288c4983 100644 --- a/packages/opencode/src/hook/session-hooks.ts +++ b/packages/opencode/src/hook/session-hooks.ts @@ -54,6 +54,12 @@ export interface Interface { readonly add: (sessionID: SessionID, entry: SessionHookEntryInput) => Effect.Effect readonly remove: (sessionID: SessionID, id: string) => Effect.Effect readonly list: (sessionID: SessionID, event: HookEvent) => Effect.Effect + /** + * O(1) existence probe — answers "does this session have any hook for this event?" + * Used by WP-6A short-circuit in SettingsHook.trigger to skip the matcher pipeline + * when no session-scoped hook (and no on-disk hook) targets the current event. + */ + readonly hasForEvent: (sessionID: SessionID, event: HookEvent) => Effect.Effect readonly clear: (sessionID: SessionID) => Effect.Effect } @@ -90,12 +96,19 @@ export const layer = Layer.effect( return arr.filter((e) => e.event === event) as readonly SessionHookEntry[] }) + const hasForEvent = Effect.fn("SessionHooks.hasForEvent")(function* (sessionID: SessionID, event: HookEvent) { + const data = yield* InstanceState.get(state) + const arr = data.get(sessionID) + if (!arr || arr.length === 0) return false + return arr.some((e) => e.event === event) + }) + const clear = Effect.fn("SessionHooks.clear")(function* (sessionID: SessionID) { const data = yield* InstanceState.get(state) data.delete(sessionID) }) - return Service.of({ add, remove, list, clear }) + return Service.of({ add, remove, list, hasForEvent, clear }) }), ) diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 456f4900d5..9591d2b830 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -129,6 +129,14 @@ interface HookMatcher { interface Settings { hooks?: Partial> + /** + * WP-6B placeholder. CC / VS Code-style "workspace trust" flow does not yet + * exist in this fork. When a trust system lands (`Project.isTrusted()` or + * similar), the trigger entry should short-circuit (silent skip — log.warn + + * empty result, NEVER throw / deny) for untrusted workspaces unless this flag + * is true. Schema-only for now; see TODO(WP-6B) below in the trigger reducer. + */ + allowUntrusted?: boolean } export interface HookJSONOutput { @@ -489,6 +497,23 @@ function execShell( const shell = process.platform === "win32" ? true : "/bin/sh" const expandedCommand = expandCommand(entry) + // WP-6C: plugin-directory liveness pre-check. + // When __sourceDir is stamped but the directory has since been GC'd + // (plugin uninstalled, repo cleaned, etc.) the expanded command would + // either fail at exec time with exit-127 or — worse — silently run a + // partial template. Treat the missing dir as "plugin no longer available" + // and silent-allow rather than letting the shell turn it into a misleading + // exit-2 deny. spawnError stays undefined so the trigger reducer keeps + // the same allow path it already uses for spawnError-set entries. + if (entry.__sourceDir && !existsSync(entry.__sourceDir)) { + log.warn("hook plugin sourceDir missing — silent allow", { + command: entry.command, + sourceDir: entry.__sourceDir, + }) + resolve({ exitCode: 0, stdout: "", stderr: "" }) + return + } + const extraEnv: Record = { CLAUDE_PROJECT_DIR: cwd } if (entry.__sourceDir) { extraEnv.CLAUDE_PLUGIN_ROOT = entry.__sourceDir @@ -1005,13 +1030,41 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) const result: TriggerResult = { additionalContexts: [], systemMessages: [] } + // ── WP-6A: O(1) short-circuit ───────────────────────────── + // Skip the entire matcher pipeline (envelope build, target derivation, + // session merge allocation, matcher regex) when neither the on-disk + // settings chain nor the session store has any entry for this event. + // s.settings is already cached on the InstanceState, so the file-side + // probe is a property access. The session probe is O(1) (Map.get + + // .some over the session's own array, typically empty). + const sessionEvent: HookEvent = + ctx.isSubAgent && payload.event === "Stop" ? "SubagentStop" : payload.event + const hasFile = (s.settings.hooks?.[payload.event]?.length ?? 0) > 0 + const hasSession = ctx.sessionID + ? yield* sessionHooks.hasForEvent(SessionID.make(ctx.sessionID), sessionEvent) + : false + if (!hasFile && !hasSession) return result + + // TODO(WP-6B): once a workspace-trust system exists in this fork, gate + // execution here with something like: + // + // const trusted = yield* Project.isTrusted(s.cwd) + // if (!trusted && !s.settings.allowUntrusted) { + // log.warn("hooks skipped: workspace not trusted", { cwd: s.cwd }) + // return result // silent allow — never deny / throw + // } + // + // Hooks reach into the user's shell, network, and LLM accounts; running + // them inside an untrusted workspace is the same threat model VS Code + // gates with workspace-trust. Until then this is a no-op so behavior is + // unchanged. The `allowUntrusted` schema field is already accepted on + // Settings so user configs written today won't fail-parse later. + // ── Session-scoped hook resolution (WP-5D) ──────────────── // Sub-agent stop semantics: if the caller marks this trigger as // running inside a sub-agent and fires `Stop`, look up SubagentStop // session hooks. Settings-file lookup still uses payload.event verbatim // (the on-disk chain is already correctly addressed by callers). - const sessionEvent: HookEvent = - ctx.isSubAgent && payload.event === "Stop" ? "SubagentStop" : payload.event const sessionEntries = ctx.sessionID ? yield* sessionHooks.list(SessionID.make(ctx.sessionID), sessionEvent) : ([] as readonly SessionHookEntry[]) diff --git a/packages/opencode/test/hook/settings.test.ts b/packages/opencode/test/hook/settings.test.ts index 650717903f..655f58633b 100644 --- a/packages/opencode/test/hook/settings.test.ts +++ b/packages/opencode/test/hook/settings.test.ts @@ -1407,3 +1407,108 @@ describe("SettingsHook.trigger / WP-4F handler × event matrix", () => { ) }) +// ────────────────────────────────────────────────────────────────── +// WP-6A: hasHookForEvent short-circuit +// WP-6C: plugin sourceDir liveness pre-check +// ────────────────────────────────────────────────────────────────── + +describe("SettingsHook.trigger / WP-6A short-circuit", () => { + it.live("no hooks configured → returns empty result without touching matchers", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + // Deliberately do NOT write any settings file. trigger should hit the + // WP-6A short-circuit (hasFile=false, hasSession=false) and bail with + // an empty TriggerResult — never building an envelope, never running + // matcher regex, never dispatching to handlers. + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: { command: "ls" } }, + ctx, + ) + expect(result.blocked).toBeUndefined() + expect(result.additionalContexts).toEqual([]) + expect(result.systemMessages).toEqual([]) + expect(result.permissionDecision).toBeUndefined() + expect(result.preventContinuation).toBeUndefined() + }), + ), + ) + + it.live("hook configured for a different event → still short-circuits this event", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + // Configure a Stop hook only — a PreToolUse trigger should not fire it + // and should take the short-circuit path (hasFile probes payload.event, + // not the union of all events). + yield* Effect.promise(() => writeHookSettings(dir, "Stop", { matcher: "*" })) + const svc = yield* SettingsHook.Service + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + // Sidecar must not have been written — proves no command executed. + const exists = yield* Effect.promise(() => + fs + .access(path.join(dir, "captured.json")) + .then(() => true) + .catch(() => false), + ) + expect(exists).toBe(false) + }), + ), + ) +}) + +describe("SettingsHook.trigger / WP-6C plugin sourceDir missing", () => { + it.live("missing __sourceDir → silent allow (not deny / blocked)", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + // Write a hook config under .claude/settings.json so loadChain stamps + // __sourceDir = /.claude on the entry. Hook is `exit 2` — under + // normal conditions that produces blocked={reason:"Hook blocked..."}. + // After we delete .claude, the cached entry still has the old + // __sourceDir; WP-6C's existsSync check inside execShell must convert + // the stale entry into a silent allow (exitCode 0, blocked undefined). + yield* Effect.promise(async () => { + await fs.mkdir(path.join(dir, ".claude"), { recursive: true }) + await fs.writeFile( + path.join(dir, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "exit 2" }] }, + ], + }, + }), + ) + }) + + const svc = yield* SettingsHook.Service + // Priming call: .claude exists, hook fires `exit 2` → blocked. + // This populates InstanceState.cache with the loaded settings (incl. + // __sourceDir stamping). Behavior here isn't under test. + const primed = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(primed.blocked).toBeDefined() + + // Now delete the plugin source dir behind the cache's back. + yield* Effect.promise(() => + fs.rm(path.join(dir, ".claude"), { recursive: true, force: true }), + ) + + // Cache still holds entry with __sourceDir = /.claude. WP-6C + // pre-check should fire and short-circuit execShell to silent allow. + const result = yield* svc.trigger( + { event: "PreToolUse", toolName: "bash", toolInput: {} }, + ctx, + ) + expect(result.blocked).toBeUndefined() + expect(result.permissionDecision).toBeUndefined() + }), + ), + ) +}) + From 0b52aebc72258d54f5c3aa0b52280232655a64a0 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 17:09:06 +0800 Subject: [PATCH 14/25] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A(hook)=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=98=B6=E6=AE=B5=206=20P1=20=E9=B2=81?= =?UTF-8?q?=E6=A3=92=E6=80=A7=E8=A1=A5=E5=BC=BA=EF=BC=88=E6=80=A7=E8=83=BD?= =?UTF-8?q?=20/=20trust=20/=20GC=20=E7=AB=9E=E6=80=81=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 02-hook-system §12: 阶段 6 P1 鲁棒性已实施 - 07-hook-1to1: 性能 / trust / plugin GC 竞态三小节 - 08-test-plan §11: Phase 6 验收矩阵 - RELEASE_NOTES: Hook 协议鲁棒性(阶段 6)段 - AGENTS.md: Hook System 章节加 Performance + Robustness --- RELEASE_NOTES.md | 8 ++++++++ docs/replan/02-hook-system.md | 10 ++++++++++ docs/replan/07-hook-1to1.md | 8 ++++++++ docs/replan/08-test-plan.md | 10 ++++++++++ packages/opencode/AGENTS.md | 20 ++++++++++++++++++++ 5 files changed, 56 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bec519fff5..b203bf6342 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -87,3 +87,11 @@ git pull origin main - **Session-scoped hook 动态注入(fork 扩展)**:新 `SessionHooks` API 支持运行时添加 session-scoped hooks(`once:true` 自动清理);`Stop` 事件在 sub-agent 上下文自动翻译为 `SubagentStop`(仅影响 session-hook 查找,上层 dispatcher 语义不变)。 阶段 5 全量回归:**2361 PASS / 0 回归**,净增 9 测试。 + +## Hook 协议鲁棒性(阶段 6) + +- **`hasHookForEvent` O(1) 短路**:`trigger` 入口在 settings 链与 SessionHooks 都没有当前事件条目时跳过 envelope 构建 / matcher 拼接 / regex 匹配热路径,直接返回空 result。无 hook 配置时几乎零开销。 +- **`allowUntrusted` schema 字段(接入点预留)**:Settings 接受 `allowUntrusted?: boolean`,trigger 内留 TODO 注释块锁定未来 workspace-trust 系统接入点(fork 当前无 trust 基础设施,仅 schema 兼容;trust gate 失败必须 silent allow,永不 throw/deny)。 +- **plugin `__sourceDir` 缺失自动 silent allow**:command handler 在 `spawn` 前对 `entry.__sourceDir` 做 `existsSync` 预检;插件目录已被 GC(卸载 / repo 清理)时返回 `exitCode: 0` + 空 stdout,而非让 shell 把缺失脚本转成 exit 2 误判为 block。 + +阶段 6 全量回归:**2365 PASS / 0 回归**,净增 3 测试。 diff --git a/docs/replan/02-hook-system.md b/docs/replan/02-hook-system.md index af82c3dfa9..75646c9c80 100644 --- a/docs/replan/02-hook-system.md +++ b/docs/replan/02-hook-system.md @@ -180,3 +180,13 @@ hook 进程可向 stdout 写一行 JSON: - `SessionHooks.clear` 在 `SessionEnd` / `Session.delete` 时调用以避免长会话泄漏 测试基线:阶段 5 净增 +9 测试(≥8 spec 门禁),全量 2361 PASS / 0 回归。 + +## 12. 阶段 6 已实施(hook P1 鲁棒性) + +| WP | 内容 | 落点 | +|---|---|---| +| WP-6A | `trigger` 入口 O(1) 短路:无 hook 配置时绕过 envelope 构建 + matcher 拼接 + regex 匹配热路径。`sessionEvent` 翻译(`Stop`→`SubagentStop` in sub-agent ctx)放在短路探测之前以保持 `hasSession` 查询 key 正确;`hasFile` 仍用原始 `payload.event` 字面寻址 settings 链,不会误杀通配 matcher | `settings.ts:1040-1046` + `session-hooks.ts` 新增 `hasForEvent` | +| WP-6B | Settings 接受 `allowUntrusted?: boolean` schema 字段 + trigger 短路后 TODO 注释块锁定未来 trust 系统接入点 — fork **当前无 workspace-trust 基础设施**(取证:`rg trust\|Trusted` 在 `src/` 下唯一命中是 `tool/task.txt` 散文),仅留接入点不写假实现;接入契约:未来 trust gate 失败必须 silent allow(log.warn + 空 result,禁止 throw/deny) | `settings.ts` Settings interface + trigger reducer 注释 | +| WP-6C | command handler `execShell` 在 `child_process.spawn` 之前对 `entry.__sourceDir` 做 `existsSync` 预检;缺失时 silent allow(`exitCode: 0` + 空 stdout)而非 deny — 选 silent allow 是因 fork hook 协议铁律「故障不阻塞主流程」与 GC 竞态属于运行时偶发故障类一致;只挂 command handler(agent/mcp/http/prompt 不依赖 plugin 物理目录) | `settings.ts:508-515` | + +测试基线:阶段 6 净增 +3 测试,hook 60→63 PASS / 全量 2361→2365 PASS / 0 回归。 diff --git a/docs/replan/07-hook-1to1.md b/docs/replan/07-hook-1to1.md index 2a8d84d144..9e09858f60 100644 --- a/docs/replan/07-hook-1to1.md +++ b/docs/replan/07-hook-1to1.md @@ -98,3 +98,11 @@ - **Session-scoped hooks(fork 扩展,非 CC 协议)**:新增 `SessionHooks` Service 支持运行时 `add/remove/list/clear`;`once:true` 自动清理;`ctx.isSubAgent === true` 时 `Stop` 事件查找翻译为 `SubagentStop`,保持上层 dispatcher 语义不变。 未实装:frontmatter parser(agent prompt 内联 hook 配置),独立 WP。 + +## 阶段 6 鲁棒性补强(已实施) + +P1 鲁棒性收口,针对热路径性能、未来 trust 系统接入、plugin 目录 GC 竞态三项: + +- **性能**:`trigger` 入口新增 O(1) 短路 — 当 settings 链与 SessionHooks 都没有当前事件的条目时,跳过 envelope 构建 / matcher 拼接 / regex 匹配热路径,直接返回空 `TriggerResult`。`SessionHooks` 同步新增 `hasForEvent(sessionID, event)` 探测 API。 +- **trust**:`Settings` schema 新增 `allowUntrusted?: boolean` 字段以兼容未来配置;运行时**暂未接入** — fork 当前无 workspace-trust 基础设施(不存在 `Project.isTrusted` 等),trigger 短路之后留 TODO 注释块锁定接入点 + 契约(trust gate 失败必须 silent allow,禁止 throw/deny)。 +- **plugin GC 竞态**:command handler 的 `execShell` 在 `child_process.spawn` 之前对 `entry.__sourceDir` 做 `existsSync` 预检;目录已被 GC(plugin 卸载、repo 清理等)时返回 `exitCode: 0` + 空 stdout 走 silent allow,而非让 shell 把 `python3 .py` 转成 exit 2 误判为 block。仅 command 类型受影响(agent/mcp/http/prompt 不依赖 plugin 物理目录)。 diff --git a/docs/replan/08-test-plan.md b/docs/replan/08-test-plan.md index 51ea3fe40a..228ccdc655 100644 --- a/docs/replan/08-test-plan.md +++ b/docs/replan/08-test-plan.md @@ -161,3 +161,13 @@ bun run dev 回归触发器补充: - 改 `src/hook/settings.ts`、`src/hook/start-context.ts`、`src/hook/session-hooks.ts` → 必跑 `test/hook/` 全部 + `test/session/prompt.test.ts` - 改 SessionStart drain 注入点(prompt.ts:~1481)→ 必跑 `test/session/prompt.test.ts` + `test/hook/start-context.test.ts` + +## 11. Phase 6 — Hook 鲁棒性补强验收(已交付) + +| WP | 测试文件 | 用例数 | +|---|---|---| +| WP-6A 入口 O(1) 短路 | `test/hook/settings.test.ts`:(a) 无 hook 配置 → 空 result 不触碰 matcher;(b) 配了不同 event 的 hook → 当前 event 仍短路 | 2 | +| WP-6B `allowUntrusted` schema 字段 + TODO 注释 | schema-only + 注释,无运行时行为变化,0 用例 | 0 | +| WP-6C plugin `__sourceDir` 缺失 silent allow | `test/hook/settings.test.ts`:先正常 trigger 验链路通(hook exit 2 → blocked)→ rm `.claude` → 第二次 trigger 期望 `result.blocked === undefined` | 1 | + +阶段 6 净增 **3 测试**,hook test 60 → **63 PASS**,全量 `bun test test/` = **2361 → 2365 PASS / 20 skip / 2 todo**(pre-existing fail 与本阶段 0 关联)。 diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index b30527d5e6..fad17e14ea 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -179,3 +179,23 @@ to emit their original event names. `SessionHooks.clear` is intentionally not yet wired — call it from a `SessionEnd` / `Session.delete` hook in a future WP to avoid long-session leaks. + +## Performance + +`trigger` short-circuits at the entry when neither the on-disk settings chain +nor the session store has any entry for the current event — skipping envelope +build, matcher concatenation, and regex matching. The session probe goes +through `SessionHooks.hasForEvent(sessionID, event)` (O(1) `Map.get` + a small +`.some` over the per-session array). The `Stop`→`SubagentStop` translation +runs **before** the session probe so sub-agent contexts hit the right key. +The file-side check uses the original `payload.event` to address the settings +chain literally, so wildcard matchers are never falsely skipped. + +## Robustness + +`execShell` performs an `existsSync(entry.__sourceDir)` pre-check before +spawning. If the plugin directory has been GC'd (plugin uninstalled, repo +cleaned, etc.) the handler resolves with `exitCode: 0` + empty stdout — a +silent allow — instead of letting the shell turn the missing script into a +misleading exit 2 deny. Only the `command` handler is affected; `agent` / +`mcp` / `http` / `prompt` do not depend on the plugin's physical directory. From 5a417c22d85bb5dc8ae0f08d74f342fb614b2a34 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 19:03:36 +0800 Subject: [PATCH 15/25] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A(hook)=20?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=207=20=E6=9C=80=E7=BB=88=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=20=E2=80=94=20CC=20=E5=85=BC=E5=AE=B9=E6=80=A7=E6=80=BB?= =?UTF-8?q?=E7=BB=93=EF=BC=886=20=E8=B6=85=E9=9B=86=20+=202=20=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E5=B7=AE=E5=BC=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 CC 官方 verbatim 示例 e2e 验证(8/8 PASS),fork 是 CC hook 协议的严格超集 + 2 项行为差异: - 6 项严格超集:case-insensitive matcher / 6 层 settings / hasHookForEvent O(1) 短路 / SessionHooks 动态注入 / __sourceDir GC 保护 / continue=false 双层 break - 1 项 schema-only:allowUntrusted 字段(运行时占位) - 2 项行为差异:suppressOutput 默认翻转(CC=false / fork=true)+ Notification 不支持 验收基线:hook 63 PASS / 全量 2365 PASS / typecheck PASS(1 PRE-EXISTING fail) --- RELEASE_NOTES.md | 21 +++++++++++++++++++++ docs/replan/02-hook-system.md | 8 ++++++++ docs/replan/07-hook-1to1.md | 20 ++++++++++++++++++++ docs/replan/08-test-plan.md | 9 +++++++++ 4 files changed, 58 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b203bf6342..31364406ed 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -95,3 +95,24 @@ git pull origin main - **plugin `__sourceDir` 缺失自动 silent allow**:command handler 在 `spawn` 前对 `entry.__sourceDir` 做 `existsSync` 预检;插件目录已被 GC(卸载 / repo 清理)时返回 `exitCode: 0` + 空 stdout,而非让 shell 把缺失脚本转成 exit 2 误判为 block。 阶段 6 全量回归:**2365 PASS / 0 回归**,净增 3 测试。 + +## Hook 协议 CC 兼容性总结(阶段 7 验证) + +通过 CC 官方文档 verbatim 示例 e2e 验证(8/8 PASS),fork 是 CC hook 协议的**严格超集 + 2 项行为差异**: + +**6 项严格超集**(CC 配置在 fork 全部通用,反向不一定): +1. case-insensitive matcher:fork 用 `i` flag,CC 配置精确匹配仍命中 +2. 6 层 settings:CC 三层(user / project / local)完全保留 + fork 追加 `.opencode/` 平行层,覆盖顺序不冲突 +3. hasHookForEvent O(1) 短路:纯优化,外部不可观察 +4. SessionHooks 动态注入:与 file 链平等 concat 不互斥,CC 仅有 file 链 fork 多了 session 链 +5. `__sourceDir` GC 竞态保护:CC 无此字段所以路径走不到;fork command hook 的 plugin 目录被 GC 时 silent allow +6. continue=false 双层 break:fork 此前失效,现补齐 CC spec 协议 + +**1 项 schema-only**(向前兼容): +- `allowUntrusted` 字段接受不会 fail-parse;运行时占位待 fork trust 系统接入(WP-6B TODO) + +**2 项明确行为差异**(迁移注意): +- **`suppressOutput` 默认翻转**:CC 默认 `false`(渲染 hook stdout 到 UI),fork 默认 `true`(不渲染)。SessionStart/UserPromptSubmit 直接通过 stdout 注入文本在 fork 不会自动渲染,应改用 `hookSpecificOutput.additionalContext`(fork 阶段 5 已实现真注入) +- **`Notification` event 显式不支持**:CC 通过 hook 推送 permission/idle 通知,fork 走 `Permission.Service` + 内部 bus,配置 `Notification` hook 在 fork 不生效 + +**e2e 验证基线**:hook 63 PASS / 全量 2365 PASS / typecheck PASS(仅 1 PRE-EXISTING truncation fail 与 hook 无关) diff --git a/docs/replan/02-hook-system.md b/docs/replan/02-hook-system.md index 75646c9c80..7d82ea8bd8 100644 --- a/docs/replan/02-hook-system.md +++ b/docs/replan/02-hook-system.md @@ -190,3 +190,11 @@ hook 进程可向 stdout 写一行 JSON: | WP-6C | command handler `execShell` 在 `child_process.spawn` 之前对 `entry.__sourceDir` 做 `existsSync` 预检;缺失时 silent allow(`exitCode: 0` + 空 stdout)而非 deny — 选 silent allow 是因 fork hook 协议铁律「故障不阻塞主流程」与 GC 竞态属于运行时偶发故障类一致;只挂 command handler(agent/mcp/http/prompt 不依赖 plugin 物理目录) | `settings.ts:508-515` | 测试基线:阶段 6 净增 +3 测试,hook 60→63 PASS / 全量 2361→2365 PASS / 0 回归。 + +## 13. 阶段 7 最终验收(已交付) + +收口三件事: + +1. **全量回归**:`bun test test/` = **2365 PASS / 20 skip / 2 todo / 1 fail / 190 files**;唯一 fail 是 `test/tool/truncation.test.ts > cleanup > 7 days`(PRE-EXISTING 时间敏感测试,与 hook 0 关联)。`bun turbo typecheck` = 13 包全绿。 +2. **CC 示例 e2e 兼容**:以 CC 官方文档 verbatim 配置(PreToolUse+Bash matcher / PostToolUse+Edit|Write pipe-list / SessionStart additionalContext)做 8 用例 e2e 跑通,**8/8 PASS**;验证脚手架(`test/hook/cc-compat.test.ts`)确认契约后已删除,hook 套件回到 63 PASS 基线。 +3. **兼容性矩阵文档化**:6 项严格超集 + 1 项 schema-only + 2 项行为差异(`suppressOutput` 默认翻转 / `Notification` 显式不支持)已在 `RELEASE_NOTES.md` ⟶「Hook 协议 CC 兼容性总结(阶段 7 验证)」段定稿,作为 fork 与 CC 差异的权威说明。 diff --git a/docs/replan/07-hook-1to1.md b/docs/replan/07-hook-1to1.md index 9e09858f60..569b8525ad 100644 --- a/docs/replan/07-hook-1to1.md +++ b/docs/replan/07-hook-1to1.md @@ -106,3 +106,23 @@ P1 鲁棒性收口,针对热路径性能、未来 trust 系统接入、plugin - **性能**:`trigger` 入口新增 O(1) 短路 — 当 settings 链与 SessionHooks 都没有当前事件的条目时,跳过 envelope 构建 / matcher 拼接 / regex 匹配热路径,直接返回空 `TriggerResult`。`SessionHooks` 同步新增 `hasForEvent(sessionID, event)` 探测 API。 - **trust**:`Settings` schema 新增 `allowUntrusted?: boolean` 字段以兼容未来配置;运行时**暂未接入** — fork 当前无 workspace-trust 基础设施(不存在 `Project.isTrusted` 等),trigger 短路之后留 TODO 注释块锁定接入点 + 契约(trust gate 失败必须 silent allow,禁止 throw/deny)。 - **plugin GC 竞态**:command handler 的 `execShell` 在 `child_process.spawn` 之前对 `entry.__sourceDir` 做 `existsSync` 预检;目录已被 GC(plugin 卸载、repo 清理等)时返回 `exitCode: 0` + 空 stdout 走 silent allow,而非让 shell 把 `python3 .py` 转成 exit 2 误判为 block。仅 command 类型受影响(agent/mcp/http/prompt 不依赖 plugin 物理目录)。 + +## CC 兼容性总结(阶段 7 e2e 验证) + +通过 CC 官方文档 verbatim 示例 e2e 跑通(8/8 PASS),fork = CC hook 协议**严格超集 + 2 项行为差异**: + +**6 项严格超集**(CC 配置在 fork 全部通用): +1. case-insensitive matcher(fork 加 `i` flag,CC 精确串配置仍命中) +2. 6 层 settings(CC 三层完整保留 + `.opencode/` 平行层追加) +3. `hasHookForEvent` O(1) 短路(外部不可观察的纯优化) +4. SessionHooks 动态注入(与 file 链平等 concat,CC 仅 file 链) +5. `__sourceDir` GC 竞态保护(CC 配置无此字段,路径走不到) +6. `continue=false` 双层 break(fork 此前失效,现补齐 CC spec) + +**1 项 schema-only**:`allowUntrusted` 接受不会 fail-parse,运行时占位(WP-6B TODO)。 + +**2 项行为差异**(迁移必看): +- **`suppressOutput` 默认翻转**:CC=`false`(渲染 stdout 到 UI),fork=`true`(不渲染)。SessionStart/UserPromptSubmit 通过 stdout 直接注入文本在 fork 不会自动渲染,应改用 `hookSpecificOutput.additionalContext`(阶段 5 真注入)。 +- **`Notification` 显式不支持**:CC 通过 hook 推送权限/空闲通知,fork 走 `Permission.Service` + 内部 bus;配置 `Notification` hook 在 fork 不生效。 + +权威说明详见 `RELEASE_NOTES.md` ⟶「Hook 协议 CC 兼容性总结(阶段 7 验证)」段。 diff --git a/docs/replan/08-test-plan.md b/docs/replan/08-test-plan.md index 228ccdc655..63d9846554 100644 --- a/docs/replan/08-test-plan.md +++ b/docs/replan/08-test-plan.md @@ -171,3 +171,12 @@ bun run dev | WP-6C plugin `__sourceDir` 缺失 silent allow | `test/hook/settings.test.ts`:先正常 trigger 验链路通(hook exit 2 → blocked)→ rm `.claude` → 第二次 trigger 期望 `result.blocked === undefined` | 1 | 阶段 6 净增 **3 测试**,hook test 60 → **63 PASS**,全量 `bun test test/` = **2361 → 2365 PASS / 20 skip / 2 todo**(pre-existing fail 与本阶段 0 关联)。 + +## 12. Phase 7 — 最终验收(已交付) + +| 验收项 | 实测 | +|---|---| +| 全量回归 | `bun test test/` = **2365 PASS / 20 skip / 2 todo / 1 fail**(唯一 fail 是 `test/tool/truncation.test.ts > cleanup > 7 days`,PRE-EXISTING 时间敏感,与 hook 0 关联)| +| typecheck | `bun turbo typecheck` 13 包全绿 | +| CC e2e 兼容 | CC 官方文档 verbatim 配置 8 用例 e2e(PreToolUse+Bash / PostToolUse+Edit\|Write / SessionStart additionalContext)→ **8/8 PASS**;临时验证脚手架 `test/hook/cc-compat.test.ts` 确认契约后已删除,hook 套件回到 63 PASS 基线 | +| 兼容性矩阵文档化 | **6 项严格超集 + 1 项 schema-only + 2 项行为差异**(`suppressOutput` 默认翻转 / `Notification` 显式不支持)已在 `RELEASE_NOTES.md` ⟶「Hook 协议 CC 兼容性总结(阶段 7 验证)」段定稿 | From 43642fbc3ceafc9ba34920d6a2c432730800ad1b Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 20:33:11 +0800 Subject: [PATCH 16/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A(hook)=20type?= =?UTF-8?q?=3Dmcp=20hook=20=E5=85=BC=E5=AE=B9=20CC=20=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=90=8D=20mcp=5F=5Fserver=5F=5Ftool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invokeMcpHook 此前用整串 "mcp__server__tool" 直接查 mcpSvc.tools() dict,但 src/mcp/index.ts L645 注册时 key 是 sanitize(server)+"_"+sanitize(tool) (无 mcp__ 前缀、单下划线分隔)。两边永远不匹配,type=mcp hook 在 当前 fork 实现下从未真正生效。 修复方案:在 invokeMcpHook 内 strip mcp__ 前缀、按 __ split 出 server/tool、sanitize 后用 _ rejoin 成内部 key 查 dict,保留 tools[command] 作 fallback。对外接受 CC 协议命令名,对内做格式转换。 P6a/P6b e2e 验证:gitnexus + mempalace 真插件经 type=mcp hook 将 envelope 注入 LLM 上下文,模型原文回引 marker 字符串。 --- packages/opencode/src/hook/settings.ts | 27 +++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index 9591d2b830..e288668320 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -1189,9 +1189,17 @@ export const defaultLayer = layer.pipe( // ── type:"mcp" hook execution ─────────────────────────────────── /** - * Resolve and invoke an MCP tool registered as a hook. Format: + * Resolve and invoke an MCP tool registered as a hook. Format follows the + * Claude Code protocol: * "mcp____" * + * The fork's MCP service stores tools under `sanitize(server)_sanitize(tool)` + * (see src/mcp/index.ts). We strip the leading `mcp__`, split on `__` for the + * server/tool boundary, sanitize each side and rejoin with a single + * underscore to look up the tool. This keeps the on-disk hook config in lock + * step with Claude Code while staying compatible with the fork's internal + * tool registry naming. + * * Calls MCP.Service.tools() to get the tool registry and invokes the matching * tool with the hook envelope as `arguments`. Parses the first text content * item as JSON to obtain the standard hook control output. @@ -1208,9 +1216,22 @@ function invokeMcpHook( } const tools = yield* mcpSvc.tools() - const tool = tools[command] + // Convert CC-format "mcp__server__tool" to internal key "server_tool" + // (sanitized, single underscore separator). + const sanitizeIdent = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") + const stripped = command.slice("mcp__".length) + const sepIdx = stripped.indexOf("__") + const internalKey = + sepIdx === -1 + ? sanitizeIdent(stripped) + : sanitizeIdent(stripped.slice(0, sepIdx)) + "_" + sanitizeIdent(stripped.slice(sepIdx + 2)) + const tool = tools[internalKey] ?? tools[command] if (!tool) { - log.warn("mcp hook tool not found", { command, available: Object.keys(tools).length }) + log.warn("mcp hook tool not found", { + command, + internalKey, + available: Object.keys(tools).length, + }) return undefined } From 64dd5cfd796ba287bff5c2d11f407dd16c908229 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 10 May 2026 21:00:55 +0800 Subject: [PATCH 17/25] =?UTF-8?q?=E7=BB=B4=E6=8A=A4=EF=BC=9A(opencode)=20?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=20.opencode/settings.json=20=E6=AE=8B?= =?UTF-8?q?=E7=95=99=20hint=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graphify Bash hint 与 mempalace Glob|Grep|Read hint 在 bun run dev 启动时 cwd=packages/opencode 下永远 silent skip(相对路径 graphify-out/graph.json 找不到)= 死代码。 project-onboarding skill 严格不做清单 (L116) 已声明此类 hook 不在范围,仓基线归零。 --- .opencode/settings.json | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/.opencode/settings.json b/.opencode/settings.json index b78d278fab..0967ef424b 100644 --- a/.opencode/settings.json +++ b/.opencode/settings.json @@ -1,24 +1 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "[ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files.\"}}' || true" - } - ] - }, - { - "matcher": "Glob|Grep|Read", - "hooks": [ - { - "type": "command", - "command": "command -v mempalace >/dev/null 2>&1 && [ -d \"$HOME/.mempalace\" ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"[mempalace] Semantic memory available. Use mempalace_search MCP tool or mempalace search CLI for historical experience retrieval.\"}}' || true" - } - ] - } - ] - } -} +{} From f2cee5da1744c44c2d1ac4e78eccdd8389d36f47 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 11 May 2026 09:47:30 +0800 Subject: [PATCH 18/25] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A(hook)=20setti?= =?UTF-8?q?ngs.ts=20=E5=8A=A0=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7=20?= =?UTF-8?q?=E2=80=94=20trigger/runEntry=20span=20+=20spawn/close=20debug?= =?UTF-8?q?=20+=20timeout-kill=20warn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4 调研发现 SettingsHook.trigger happy path 零日志输出。Effect.fn 只附 tracing span 名、不自动 emit log,trigger / runEntry / commandHandler / execShell / child.on('close') 都未调 log.time 也未 log.info。配合 mempalace 默认命令 '2>/dev/null || true' 的双重静默,hook 执行链对运维完全不可见。 修复(纯日志,不动控制流): - Fix 1 span 计时: - trigger 入口加 using _ = log.time('trigger', { event, sessionID }) - runEntry 入口加 using _ = log.time('runEntry', { type, command.slice(0,80) }) - 两个短路点(no_matchers / empty_matchers)加 log.info trigger short-circuit - Fix 2 spawn lifecycle: - execShell spawn 前加 log.debug 'hook spawn'(command/cwd/timeoutMs) - child.on('close') 加 log.debug 'hook close'(exitCode/stdoutLen/stderrLen), resolve 语义不变 - Fix 3 timeout-kill latent bug: - exitCode === null 路径之前完全无日志(spawn 设 timeout 触发 SIGTERM 时) - 新增 log.warn 'hook command timed out / killed (non-blocking)' Clean-shot 验证 — opencode run echo 命令产出 dev.log 12 行命中: - SessionStart 完整链路:started → spawn → close → completed duration=54ms - UserPromptSubmit 短路:started → short-circuit reason=no_matchers → completed 0ms - Stop 完整链路:started → spawn → close → completed duration=45ms 测试:bun typecheck 0 错;test/hook/ 63/63 PASS;下游 6 套件 184/4skip/0fail。 合规:handler abstraction、trigger reducer 控制流、WP-6C existsSync pre-check 全部保持。无类型/签名/返回值变化。 --- packages/opencode/src/hook/settings.ts | 30 +++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/hook/settings.ts b/packages/opencode/src/hook/settings.ts index e288668320..d2da7ef768 100644 --- a/packages/opencode/src/hook/settings.ts +++ b/packages/opencode/src/hook/settings.ts @@ -497,6 +497,8 @@ function execShell( const shell = process.platform === "win32" ? true : "/bin/sh" const expandedCommand = expandCommand(entry) + log.debug("hook spawn", { command: entry.command.slice(0, 200), cwd, timeoutMs }) + // WP-6C: plugin-directory liveness pre-check. // When __sourceDir is stamped but the directory has since been GC'd // (plugin uninstalled, repo cleaned, etc.) the expanded command would @@ -558,7 +560,15 @@ function execShell( resolve({ exitCode: null, stdout, stderr, spawnError: err.message }) }) - child.on("close", (code) => resolve({ exitCode: code, stdout, stderr })) + child.on("close", (code) => { + log.debug("hook close", { + command: entry.command.slice(0, 80), + exitCode: code, + stdoutLen: stdout.length, + stderrLen: stderr.length, + }) + resolve({ exitCode: code, stdout, stderr }) + }) }) } @@ -693,6 +703,12 @@ const commandHandler: HookHandler = { } // Other non-zero exits: log and continue (do not abort main flow) + if (exitCode === null) { + log.warn("hook command timed out / killed (non-blocking)", { + command: entry.command, + timeoutMs: entry.timeout ? entry.timeout * 1000 : DEFAULT_TIMEOUT_MS, + }) + } if (exitCode !== 0 && exitCode !== null) { log.warn("hook command exited non-zero (non-blocking)", { command: entry.command, @@ -1009,6 +1025,7 @@ export const layer = Layer.effect( cwd: string, inHook: boolean, ) { + using _ = log.time("runEntry", { type: entry.type, command: entry.command.slice(0, 80) }) const handler = handlers[entry.type] if (!handler) { // Defensive fallback: handlers table is exhaustive over the schema's 5 types @@ -1027,6 +1044,7 @@ export const layer = Layer.effect( payload: HookPayload, ctx: TriggerContext, ) { + using _ = log.time("trigger", { event: payload.event, sessionID: ctx.sessionID }) const s = yield* InstanceState.get(state) const result: TriggerResult = { additionalContexts: [], systemMessages: [] } @@ -1043,7 +1061,10 @@ export const layer = Layer.effect( const hasSession = ctx.sessionID ? yield* sessionHooks.hasForEvent(SessionID.make(ctx.sessionID), sessionEvent) : false - if (!hasFile && !hasSession) return result + if (!hasFile && !hasSession) { + log.info("trigger short-circuit", { event: payload.event, reason: "no_matchers", hasFile, hasSession }) + return result + } // TODO(WP-6B): once a workspace-trust system exists in this fork, gate // execution here with something like: @@ -1084,7 +1105,10 @@ export const layer = Layer.effect( }) satisfies RunMatcher, ), ] - if (!matchers.length) return result + if (!matchers.length) { + log.info("trigger short-circuit", { event: payload.event, reason: "empty_matchers" }) + return result + } const target = matcherTarget(payload) const envelope = buildStdinEnvelope(payload, ctx, s.cwd) From 83662807e879147271fa5d830191e978b29f12dc Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 11 May 2026 10:35:11 +0800 Subject: [PATCH 19/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A(tool)=20?= =?UTF-8?q?=E5=8F=8C=E4=BF=AE=20CI=20=E4=B8=A4=E7=94=9F=E4=BA=A7=20bug=20?= =?UTF-8?q?=E2=80=94=20strip=20zod=20~standard=20=E6=B3=84=E9=9C=B2=20+=20?= =?UTF-8?q?cleanup=20=E6=94=B9=E7=94=A8=20mtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix-1 effect-zod.ts: zod 4.4.3 的 z.toJSONSchema() 把内部 standard-schema spec 的运行时字段 ~standard(含函数引用 validate/vendor/jsonSchema.input 等)当 metadata 一并带出,泄漏到 LLM wire payload 与 16 个 tool snapshot。 返回前 Reflect.deleteProperty(result, '~standard') 顶层 strip。snapshot 自动匹配旧 baseline。 Fix-2 truncate.ts: cleanup 原用 Identifier.timestamp(entry) 比 cutoff,但 id.ts 的进程级单调 clamp(lastTimestamp)会污染 ID 内嵌时间戳——同进程 跨测试或高频写入场景,老文件 ID timestamp 被抬回 ≈ now,cleanup 漏删。 改用 fs.stat(full).mtime 与 Date.now() - RETENTION 比较;保留 catch 容错; mtime Option.None 时回退 +Infinity 保守不删。 测试侧补 utimes:truncation.test.ts cleanup 测试在 writeFileStringScoped 后用 fs.utimes 显式回写 old(-10d) / recent(-3d) mtime,匹配真实生产语义。 验证: - bun typecheck 13/13 PASS - test/tool/ 247/0 fail(含 truncation 19/19、parameters 53/53 含 16 snapshot 匹配旧 baseline) - test/hook/ 63/0、test/session/ 329/0 fail、test/server/ 123/0 fail --- packages/opencode/src/tool/truncate.ts | 16 ++++++++++------ packages/opencode/src/util/effect-zod.ts | 4 +++- packages/opencode/test/tool/truncation.test.ts | 4 ++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index ffc16c0b9f..f6dfcf1aba 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -5,7 +5,6 @@ import type { Agent } from "../agent/agent" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { evaluate } from "@/permission/evaluate" import { Config } from "@/config/config" -import { Identifier } from "../id/id" import * as Log from "@opencode-ai/core/util/log" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" @@ -53,16 +52,21 @@ export const layer = Layer.effect( const fs = yield* AppFileSystem.Service const cleanup = Effect.fn("Truncate.cleanup")(function* () { - const cutoff = Identifier.timestamp( - Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), - ) + const cutoff = Date.now() - Duration.toMillis(RETENTION) const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), Effect.catch(() => Effect.succeed([])), ) for (const entry of entries) { - if (Identifier.timestamp(entry) >= cutoff) continue - yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void)) + const full = path.join(TRUNCATION_DIR, entry) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) continue + const mtime = info.mtime.pipe( + Option.map((date) => date.getTime()), + Option.getOrElse(() => Number.POSITIVE_INFINITY), + ) + if (mtime >= cutoff) continue + yield* fs.remove(full).pipe(Effect.catch(() => Effect.void)) } }) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76eb..8bd00f58d3 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -56,7 +56,9 @@ function isZodType(value: unknown): value is z.ZodTypeAny { * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. */ export function toJsonSchema(schema: S) { - return z.toJSONSchema(zod(schema), { io: "input" }) + const result = z.toJSONSchema(zod(schema), { io: "input" }) + Reflect.deleteProperty(result as object, "~standard") + return result } function walk(ast: SchemaAST.AST): z.ZodTypeAny { diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9a01f95cd1..ab804de0bb 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -250,6 +250,10 @@ describe("Truncate", () => { yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") + const oldMtime = new Date(Date.now() - 10 * DAY_MS) + const recentMtime = new Date(Date.now() - 3 * DAY_MS) + yield* fs.utimes(old, oldMtime, oldMtime) + yield* fs.utimes(recent, recentMtime, recentMtime) yield* svc.cleanup() expect(yield* fs.exists(old)).toBe(false) From 192448a1f6ab12faddfc1c87a6c8079c8c46cfb0 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 11 May 2026 10:46:12 +0800 Subject: [PATCH 20/25] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A(effect-zod)?= =?UTF-8?q?=20=E7=94=A8=E6=B5=85=E6=8B=B7=E8=B4=9D=E6=96=AD=E5=BC=80=20~st?= =?UTF-8?q?andard=20=E5=8E=9F=E5=9E=8B=E9=93=BE=E5=B1=9E=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit z.toJSONSchema() 返回的对象把 ~standard 设为原型链上的不可枚举属性, Reflect.deleteProperty 对原型链属性返回 false 无效。 本地 Bun snapshot serializer 走 own-enumerable 路径不输出 ~standard, 但 CI 环境的 serializer 通过 for...in 能见到原型属性 — 导致 16 个 parameters 快照在 CI 上 fail 但本地 PASS 的假象。 改用对象展开 {...result} 拷贝 own-enumerable 键,原型回到 Object.prototype, "~standard" in result === false,本地与 CI 行为一致。 --- packages/opencode/src/util/effect-zod.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 8bd00f58d3..78cd4a1426 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -56,9 +56,13 @@ function isZodType(value: unknown): value is z.ZodTypeAny { * `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper. */ export function toJsonSchema(schema: S) { - const result = z.toJSONSchema(zod(schema), { io: "input" }) - Reflect.deleteProperty(result as object, "~standard") - return result + // `z.toJSONSchema()` returns an object whose `~standard` property lives on + // the prototype (non-enumerable). `Reflect.deleteProperty` cannot remove a + // prototype-chain property, and serializers diverge: Bun's local snapshot + // serializer skips it while CI's `for...in` walker emits it — causing + // platform-dependent snapshot drift. Spread copies own-enumerable keys + // only, breaking the prototype link so `"~standard" in result === false`. + return { ...z.toJSONSchema(zod(schema), { io: "input" }) } } function walk(ast: SchemaAST.AST): z.ZodTypeAny { From d610ce1346f62d3939f72ad5dfe10c668a9aff83 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 11 May 2026 11:57:34 +0800 Subject: [PATCH 21/25] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=8F=92=E4=BB=B6=E5=88=9D=E5=A7=8B=E5=8C=96=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 15 +++++++ .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 2 +- .gitignore | 4 ++ .opencode/settings.json | 39 ++++++++++++++++++- AGENTS.md | 2 +- CLAUDE.md | 14 ++++++- 6 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..eb18c34c09 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}}' || true ;; esac" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md index a10104aefb..cd9a83be00 100644 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -23,7 +23,7 @@ Run from the project root. This parses all source files, builds the knowledge gr | `--embeddings` | Enable embedding generation for semantic search (off by default) | | `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. ### status — Check index freshness diff --git a/.gitignore b/.gitignore index 38467a83a3..88afd71e7a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ logs/ *.bun-build tsconfig.tsbuildinfo .gitnexus + +# MemPalace per-project files (issue #185) +mempalace.yaml +entities.json diff --git a/.opencode/settings.json b/.opencode/settings.json index 0967ef424b..94f7b68b1a 100644 --- a/.opencode/settings.json +++ b/.opencode/settings.json @@ -1 +1,38 @@ -{} +{ + "_comment": "mempalace official hook endpoints only. graphify hook -> /.claude/settings.json (graphify claude install). gitnexus hook -> ~/.claude/settings.json (gitnexus setup). OPENCODE settings chain merges all three layers automatically.", + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "/root/.local/bin/mempalace hook run --hook session-start --harness claude-code 2>/dev/null || true" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "/root/.local/bin/mempalace hook run --hook stop --harness claude-code 2>/dev/null || true" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "/root/.local/bin/mempalace hook run --hook precompact --harness claude-code 2>/dev/null || true" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 35f09f7f48..b3e4d96289 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ Rules: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **opencode** (44589 symbols, 68904 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **opencode** (48343 symbols, 75343 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index a3b1a6a4a1..bc67d97341 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **opencode** (44589 symbols, 68904 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **opencode** (48343 symbols, 75343 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. @@ -40,4 +40,14 @@ This project is indexed by GitNexus as **opencode** (44589 symbols, 68904 relati | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - \ No newline at end of file + + +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) From 3b44caa76df3e1a65296d99009391c664ff10c8d Mon Sep 17 00:00:00 2001 From: 42 <14139451+LeXwDeX@users.noreply.github.com> Date: Mon, 11 May 2026 13:42:06 +0800 Subject: [PATCH 22/25] =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=9A=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E7=B2=BE=E7=AE=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ar.md | 141 ---------------------------------------------- README.bn.md | 141 ---------------------------------------------- README.br.md | 141 ---------------------------------------------- README.bs.md | 141 ---------------------------------------------- README.da.md | 141 ---------------------------------------------- README.de.md | 141 ---------------------------------------------- README.es.md | 141 ---------------------------------------------- README.fr.md | 141 ---------------------------------------------- README.gr.md | 141 ---------------------------------------------- README.it.md | 141 ---------------------------------------------- README.ja.md | 141 ---------------------------------------------- README.ko.md | 141 ---------------------------------------------- README.no.md | 141 ---------------------------------------------- README.pl.md | 141 ---------------------------------------------- README.ru.md | 141 ---------------------------------------------- README.th.md | 141 ---------------------------------------------- README.tr.md | 141 ---------------------------------------------- README.uk.md | 142 ----------------------------------------------- README.vi.md | 141 ---------------------------------------------- README.zht.md | 140 ---------------------------------------------- RELEASE_NOTES.md | 118 --------------------------------------- 21 files changed, 2938 deletions(-) delete mode 100644 README.ar.md delete mode 100644 README.bn.md delete mode 100644 README.br.md delete mode 100644 README.bs.md delete mode 100644 README.da.md delete mode 100644 README.de.md delete mode 100644 README.es.md delete mode 100644 README.fr.md delete mode 100644 README.gr.md delete mode 100644 README.it.md delete mode 100644 README.ja.md delete mode 100644 README.ko.md delete mode 100644 README.no.md delete mode 100644 README.pl.md delete mode 100644 README.ru.md delete mode 100644 README.th.md delete mode 100644 README.tr.md delete mode 100644 README.uk.md delete mode 100644 README.vi.md delete mode 100644 README.zht.md delete mode 100644 RELEASE_NOTES.md diff --git a/README.ar.md b/README.ar.md deleted file mode 100644 index beb44589e6..0000000000 --- a/README.ar.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - شعار OpenCode - - -

-

وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### التثبيت - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# مديري الحزم -npm i -g opencode-ai@latest # او bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث) -brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # اي نظام -nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev -``` - -> [!TIP] -> احذف الاصدارات الاقدم من 0.1.x قبل التثبيت. - -### تطبيق سطح المكتب (BETA) - -يتوفر OpenCode ايضا كتطبيق سطح مكتب. قم بالتنزيل مباشرة من [صفحة الاصدارات](https://github.com/anomalyco/opencode/releases) او من [opencode.ai/download](https://opencode.ai/download). - -| المنصة | التنزيل | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb` او `.rpm` او AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### مجلد التثبيت - -يحترم سكربت التثبيت ترتيب الاولوية التالي لمسار التثبيت: - -1. `$OPENCODE_INSTALL_DIR` - مجلد تثبيت مخصص -2. `$XDG_BIN_DIR` - مسار متوافق مع مواصفات XDG Base Directory -3. `$HOME/bin` - مجلد الثنائيات القياسي للمستخدم (ان وجد او امكن انشاؤه) -4. `$HOME/.opencode/bin` - المسار الافتراضي الاحتياطي - -```bash -# امثلة -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -يتضمن OpenCode وكيليْن (Agents) مدمجين يمكنك التبديل بينهما باستخدام زر `Tab`. - -- **build** - الافتراضي، وكيل بصلاحيات كاملة لاعمال التطوير -- **plan** - وكيل للقراءة فقط للتحليل واستكشاف الكود - - يرفض تعديل الملفات افتراضيا - - يطلب الاذن قبل تشغيل اوامر bash - - مثالي لاستكشاف قواعد كود غير مألوفة او لتخطيط التغييرات - -بالاضافة الى ذلك يوجد وكيل فرعي **general** للبحث المعقد والمهام متعددة الخطوات. -يستخدم داخليا ويمكن استدعاؤه بكتابة `@general` في الرسائل. - -تعرف على المزيد حول [agents](https://opencode.ai/docs/agents). - -### التوثيق - -لمزيد من المعلومات حول كيفية ضبط OpenCode، [**راجع التوثيق**](https://opencode.ai/docs). - -### المساهمة - -اذا كنت مهتما بالمساهمة في OpenCode، يرجى قراءة [contributing docs](./CONTRIBUTING.md) قبل ارسال pull request. - -### البناء فوق OpenCode - -اذا كنت تعمل على مشروع مرتبط بـ OpenCode ويستخدم "opencode" كجزء من اسمه (مثل "opencode-dashboard" او "opencode-mobile")، يرجى اضافة ملاحظة في README توضح انه ليس مبنيا بواسطة فريق OpenCode ولا يرتبط بنا بأي شكل. - -### FAQ - -#### ما الفرق عن Claude Code؟ - -هو مشابه جدا لـ Claude Code من حيث القدرات. هذه هي الفروقات الاساسية: - -- 100% مفتوح المصدر -- غير مقترن بمزود معين. نوصي بالنماذج التي نوفرها عبر [OpenCode Zen](https://opencode.ai/zen)؛ لكن يمكن استخدام OpenCode مع Claude او OpenAI او Google او حتى نماذج محلية. مع تطور النماذج ستتقلص الفجوات وستنخفض الاسعار، لذا من المهم ان يكون مستقلا عن المزود. -- دعم LSP جاهز للاستخدام -- تركيز على TUI. تم بناء OpenCode بواسطة مستخدمي neovim ومنشئي [terminal.shop](https://terminal.shop)؛ وسندفع حدود ما هو ممكن داخل الطرفية. -- معمارية عميل/خادم. على سبيل المثال، يمكن تشغيل OpenCode على جهازك بينما تقوده عن بعد من تطبيق جوال. هذا يعني ان واجهة TUI هي واحدة فقط من العملاء الممكنين. - ---- - -**انضم الى مجتمعنا** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.bn.md b/README.bn.md deleted file mode 100644 index c7abc7346a..0000000000 --- a/README.bn.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

ওপেন সোর্স এআই কোডিং এজেন্ট।

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### ইনস্টলেশন (Installation) - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Package managers -npm i -g opencode-ai@latest # or bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) -brew install opencode # macOS and Linux (official brew formula, updated less) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # Any OS -nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch -``` - -> [!TIP] -> ইনস্টল করার আগে ০.১.x এর চেয়ে পুরোনো ভার্সনগুলো মুছে ফেলুন। - -### ডেস্কটপ অ্যাপ (BETA) - -OpenCode ডেস্কটপ অ্যাপ্লিকেশন হিসেবেও উপলব্ধ। সরাসরি [রিলিজ পেজ](https://github.com/anomalyco/opencode/releases) অথবা [opencode.ai/download](https://opencode.ai/download) থেকে ডাউনলোড করুন। - -| প্ল্যাটফর্ম | ডাউনলোড | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, or AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### ইনস্টলেশন ডিরেক্টরি (Installation Directory) - -ইনস্টল স্ক্রিপ্টটি ইনস্টলেশন পাতের জন্য নিম্নলিখিত অগ্রাধিকার ক্রম মেনে চলে: - -1. `$OPENCODE_INSTALL_DIR` - কাস্টম ইনস্টলেশন ডিরেক্টরি -2. `$XDG_BIN_DIR` - XDG বেস ডিরেক্টরি স্পেসিফিকেশন সমর্থিত পাথ -3. `$HOME/bin` - সাধারণ ব্যবহারকারী বাইনারি ডিরেক্টরি (যদি বিদ্যমান থাকে বা তৈরি করা যায়) -4. `$HOME/.opencode/bin` - ডিফল্ট ফলব্যাক - -```bash -# উদাহরণ -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### এজেন্টস (Agents) - -OpenCode এ দুটি বিল্ট-ইন এজেন্ট রয়েছে যা আপনি `Tab` কি(key) দিয়ে পরিবর্তন করতে পারবেন। - -- **build** - ডিফল্ট, ডেভেলপমেন্টের কাজের জন্য সম্পূর্ণ অ্যাক্সেসযুক্ত এজেন্ট -- **plan** - বিশ্লেষণ এবং কোড এক্সপ্লোরেশনের জন্য রিড-ওনলি এজেন্ট - - ডিফল্টভাবে ফাইল এডিট করতে দেয় না - - ব্যাশ কমান্ড চালানোর আগে অনুমতি চায় - - অপরিচিত কোডবেস এক্সপ্লোর করা বা পরিবর্তনের পরিকল্পনা করার জন্য আদর্শ - -এছাড়াও জটিল অনুসন্ধান এবং মাল্টিস্টেপ টাস্কের জন্য একটি **general** সাবএজেন্ট অন্তর্ভুক্ত রয়েছে। -এটি অভ্যন্তরীণভাবে ব্যবহৃত হয় এবং মেসেজে `@general` লিখে ব্যবহার করা যেতে পারে। - -এজেন্টদের সম্পর্কে আরও জানুন: [docs](https://opencode.ai/docs/agents)। - -### ডকুমেন্টেশন (Documentation) - -কিভাবে OpenCode কনফিগার করবেন সে সম্পর্কে আরও তথ্যের জন্য, [**আমাদের ডকস দেখুন**](https://opencode.ai/docs)। - -### অবদান (Contributing) - -আপনি যদি OpenCode এ অবদান রাখতে চান, অনুগ্রহ করে একটি পুল রিকোয়েস্ট সাবমিট করার আগে আমাদের [কন্ট্রিবিউটিং ডকস](./CONTRIBUTING.md) পড়ে নিন। - -### OpenCode এর উপর বিল্ডিং (Building on OpenCode) - -আপনি যদি এমন প্রজেক্টে কাজ করেন যা OpenCode এর সাথে সম্পর্কিত এবং প্রজেক্টের নামের অংশ হিসেবে "opencode" ব্যবহার করেন, উদাহরণস্বরূপ "opencode-dashboard" বা "opencode-mobile", তবে দয়া করে আপনার README তে একটি নোট যোগ করে স্পষ্ট করুন যে এই প্রজেক্টটি OpenCode দল দ্বারা তৈরি হয়নি এবং আমাদের সাথে এর কোনো সরাসরি সম্পর্ক নেই। - -### সচরাচর জিজ্ঞাসিত প্রশ্নাবলী (FAQ) - -#### এটি ক্লড কোড (Claude Code) থেকে কীভাবে আলাদা? - -ক্যাপাবিলিটির দিক থেকে এটি ক্লড কোডের (Claude Code) মতই। এখানে মূল পার্থক্যগুলো দেওয়া হলো: - -- ১০০% ওপেন সোর্স -- কোনো প্রোভাইডারের সাথে আবদ্ধ নয়। যদিও আমরা [OpenCode Zen](https://opencode.ai/zen) এর মাধ্যমে মডেলসমূহ ব্যবহারের পরামর্শ দিই, OpenCode ক্লড (Claude), ওপেনএআই (OpenAI), গুগল (Google), অথবা লোকাল মডেলগুলোর সাথেও ব্যবহার করা যেতে পারে। যেমন যেমন মডেলগুলো উন্নত হবে, তাদের মধ্যকার পার্থক্য কমে আসবে এবং দামও কমবে, তাই প্রোভাইডার-অজ্ঞাস্টিক হওয়া খুবই গুরুত্বপূর্ণ। -- আউট-অফ-দ্য-বক্স LSP সাপোর্ট -- TUI এর উপর ফোকাস। OpenCode নিওভিম (neovim) ব্যবহারকারী এবং [terminal.shop](https://terminal.shop) এর নির্মাতাদের দ্বারা তৈরি; আমরা টার্মিনালে কী কী সম্ভব তার সীমাবদ্ধতা ছাড়িয়ে যাওয়ার চেষ্টা করছি। -- ক্লায়েন্ট/সার্ভার আর্কিটেকচার। এটি যেমন OpenCode কে আপনার কম্পিউটারে চালানোর সুযোগ দেয়, তেমনি আপনি মোবাইল অ্যাপ থেকে রিমোটলি এটি নিয়ন্ত্রণ করতে পারবেন, অর্থাৎ TUI ফ্রন্টএন্ড কেবল সম্ভাব্য ক্লায়েন্টগুলোর মধ্যে একটি। - ---- - -**আমাদের কমিউনিটিতে যুক্ত হোন** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.br.md b/README.br.md deleted file mode 100644 index 6d1de21562..0000000000 --- a/README.br.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - Logo do OpenCode - - -

-

O agente de programação com IA de código aberto.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Instalação - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Gerenciadores de pacotes -npm i -g opencode-ai@latest # ou bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado) -brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # qualquer sistema -nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente -``` - -> [!TIP] -> Remova versões anteriores a 0.1.x antes de instalar. - -### App desktop (BETA) - -O OpenCode também está disponível como aplicativo desktop. Baixe diretamente pela [página de releases](https://github.com/anomalyco/opencode/releases) ou em [opencode.ai/download](https://opencode.ai/download). - -| Plataforma | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` ou AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Diretório de instalação - -O script de instalação respeita a seguinte ordem de prioridade para o caminho de instalação: - -1. `$OPENCODE_INSTALL_DIR` - Diretório de instalação personalizado -2. `$XDG_BIN_DIR` - Caminho compatível com a especificação XDG Base Directory -3. `$HOME/bin` - Diretório binário padrão do usuário (se existir ou puder ser criado) -4. `$HOME/.opencode/bin` - Fallback padrão - -```bash -# Exemplos -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -O OpenCode inclui dois agents integrados, que você pode alternar com a tecla `Tab`. - -- **build** - Padrão, agent com acesso total para trabalho de desenvolvimento -- **plan** - Agent somente leitura para análise e exploração de código - - Nega edições de arquivos por padrão - - Pede permissão antes de executar comandos bash - - Ideal para explorar codebases desconhecidas ou planejar mudanças - -Também há um subagent **general** para buscas complexas e tarefas em várias etapas. -Ele é usado internamente e pode ser invocado com `@general` nas mensagens. - -Saiba mais sobre [agents](https://opencode.ai/docs/agents). - -### Documentação - -Para mais informações sobre como configurar o OpenCode, [**veja nossa documentação**](https://opencode.ai/docs). - -### Contribuir - -Se você tem interesse em contribuir com o OpenCode, leia os [contributing docs](./CONTRIBUTING.md) antes de enviar um pull request. - -### Construindo com OpenCode - -Se você estiver trabalhando em um projeto relacionado ao OpenCode e estiver usando "opencode" como parte do nome (por exemplo, "opencode-dashboard" ou "opencode-mobile"), adicione uma nota no README para deixar claro que não foi construído pela equipe do OpenCode e não é afiliado a nós de nenhuma forma. - -### FAQ - -#### Como isso é diferente do Claude Code? - -É muito parecido com o Claude Code em termos de capacidade. Aqui estão as principais diferenças: - -- 100% open source -- Não está acoplado a nenhum provedor. Embora recomendemos os modelos que oferecemos pelo [OpenCode Zen](https://opencode.ai/zen); o OpenCode pode ser usado com Claude, OpenAI, Google ou até modelos locais. À medida que os modelos evoluem, as diferenças diminuem e os preços caem, então ser provider-agnostic é importante. -- Suporte a LSP pronto para uso -- Foco em TUI. O OpenCode é construído por usuários de neovim e pelos criadores do [terminal.shop](https://terminal.shop); vamos levar ao limite o que é possível no terminal. -- Arquitetura cliente/servidor. Isso, por exemplo, permite executar o OpenCode no seu computador enquanto você o controla remotamente por um aplicativo mobile. Isso significa que o frontend TUI é apenas um dos possíveis clientes. - ---- - -**Junte-se à nossa comunidade** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.bs.md b/README.bs.md deleted file mode 100644 index 2cff8e0279..0000000000 --- a/README.bs.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

OpenCode je open source AI agent za programiranje.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Instalacija - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Package manageri -npm i -g opencode-ai@latest # ili bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno) -brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # Bilo koji OS -nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch -``` - -> [!TIP] -> Ukloni verzije starije od 0.1.x prije instalacije. - -### Desktop aplikacija (BETA) - -OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download). - -| Platforma | Preuzimanje | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, ili AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Instalacijski direktorij - -Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije: - -1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij -2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom -3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati) -4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija - -```bash -# Primjeri -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agenti - -OpenCode uključuje dva ugrađena agenta između kojih možeš prebacivati tasterom `Tab`. - -- **build** - Podrazumijevani agent sa punim pristupom za razvoj -- **plan** - Agent samo za čitanje za analizu i istraživanje koda - - Podrazumijevano zabranjuje izmjene datoteka - - Traži dozvolu prije pokretanja bash komandi - - Idealan za istraživanje nepoznatih codebase-ova ili planiranje izmjena - -Uključen je i **general** pod-agent za složene pretrage i višekoračne zadatke. -Koristi se interno i može se pozvati pomoću `@general` u porukama. - -Saznaj više o [agentima](https://opencode.ai/docs/agents). - -### Dokumentacija - -Za više informacija o konfiguraciji OpenCode-a, [**pogledaj dokumentaciju**](https://opencode.ai/docs). - -### Doprinosi - -Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIBUTING.md) prije slanja pull requesta. - -### Gradnja na OpenCode-u - -Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama. - -### FAQ - -#### Po čemu se razlikuje od Claude Code-a? - -Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su: - -- 100% open source -- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna. -- LSP podrška odmah po instalaciji -- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu. -- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata. - ---- - -**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.da.md b/README.da.md deleted file mode 100644 index ac522f29c4..0000000000 --- a/README.da.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Den open source AI-kodeagent.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Installation - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Pakkehåndteringer -npm i -g opencode-ai@latest # eller bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date) -brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # alle OS -nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch -``` - -> [!TIP] -> Fjern versioner ældre end 0.1.x før installation. - -### Desktop-app (BETA) - -OpenCode findes også som desktop-app. Download direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download). - -| Platform | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, eller AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Installationsmappe - -Installationsscriptet bruger følgende prioriteringsrækkefølge for installationsstien: - -1. `$OPENCODE_INSTALL_DIR` - Tilpasset installationsmappe -2. `$XDG_BIN_DIR` - Sti der følger XDG Base Directory Specification -3. `$HOME/bin` - Standard bruger-bin-mappe (hvis den findes eller kan oprettes) -4. `$HOME/.opencode/bin` - Standard fallback - -```bash -# Eksempler -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode har to indbyggede agents, som du kan skifte mellem med `Tab`-tasten. - -- **build** - Standard, agent med fuld adgang til udviklingsarbejde -- **plan** - Skrivebeskyttet agent til analyse og kodeudforskning - - Afviser filredigering som standard - - Spørger om tilladelse før bash-kommandoer - - Ideel til at udforske ukendte kodebaser eller planlægge ændringer - -Derudover findes der en **general**-subagent til komplekse søgninger og flertrinsopgaver. -Den bruges internt og kan kaldes via `@general` i beskeder. - -Læs mere om [agents](https://opencode.ai/docs/agents). - -### Dokumentation - -For mere info om konfiguration af OpenCode, [**se vores docs**](https://opencode.ai/docs). - -### Bidrag - -Hvis du vil bidrage til OpenCode, så læs vores [contributing docs](./CONTRIBUTING.md) før du sender en pull request. - -### Bygget på OpenCode - -Hvis du arbejder på et projekt der er relateret til OpenCode og bruger "opencode" som en del af navnet; f.eks. "opencode-dashboard" eller "opencode-mobile", så tilføj en note i din README, der tydeliggør at projektet ikke er bygget af OpenCode-teamet og ikke er tilknyttet os på nogen måde. - -### FAQ - -#### Hvordan adskiller dette sig fra Claude Code? - -Det minder meget om Claude Code i forhold til funktionalitet. Her er de vigtigste forskelle: - -- 100% open source -- Ikke låst til en udbyder. Selvom vi anbefaler modellerne via [OpenCode Zen](https://opencode.ai/zen); kan OpenCode bruges med Claude, OpenAI, Google eller endda lokale modeller. Efterhånden som modeller udvikler sig vil forskellene mindskes og priserne falde, så det er vigtigt at være provider-agnostic. -- LSP-support out of the box -- Fokus på TUI. OpenCode er bygget af neovim-brugere og skaberne af [terminal.shop](https://terminal.shop); vi vil skubbe grænserne for hvad der er muligt i terminalen. -- Klient/server-arkitektur. Det kan f.eks. lade OpenCode køre på din computer, mens du styrer den eksternt fra en mobilapp. Det betyder at TUI-frontend'en kun er en af de mulige clients. - ---- - -**Bliv en del af vores community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.de.md b/README.de.md deleted file mode 100644 index 87a670f3fc..0000000000 --- a/README.de.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Der Open-Source KI-Coding-Agent.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Installation - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Paketmanager -npm i -g opencode-ai@latest # oder bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell) -brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # jedes Betriebssystem -nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch -``` - -> [!TIP] -> Entferne Versionen älter als 0.1.x vor der Installation. - -### Desktop-App (BETA) - -OpenCode ist auch als Desktop-Anwendung verfügbar. Lade sie direkt von der [Releases-Seite](https://github.com/anomalyco/opencode/releases) oder [opencode.ai/download](https://opencode.ai/download) herunter. - -| Plattform | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` oder AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Installationsverzeichnis - -Das Installationsskript beachtet die folgende Prioritätsreihenfolge für den Installationspfad: - -1. `$OPENCODE_INSTALL_DIR` - Benutzerdefiniertes Installationsverzeichnis -2. `$XDG_BIN_DIR` - XDG Base Directory Specification-konformer Pfad -3. `$HOME/bin` - Standard-Binärverzeichnis des Users (falls vorhanden oder erstellbar) -4. `$HOME/.opencode/bin` - Standard-Fallback - -```bash -# Beispiele -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode enthält zwei eingebaute Agents, zwischen denen du mit der `Tab`-Taste wechseln kannst. - -- **build** - Standard-Agent mit vollem Zugriff für Entwicklungsarbeit -- **plan** - Nur-Lese-Agent für Analyse und Code-Exploration - - Verweigert Datei-Edits standardmäßig - - Fragt vor dem Ausführen von bash-Befehlen nach - - Ideal zum Erkunden unbekannter Codebases oder zum Planen von Änderungen - -Außerdem ist ein **general**-Subagent für komplexe Suchen und mehrstufige Aufgaben enthalten. -Dieser wird intern genutzt und kann in Nachrichten mit `@general` aufgerufen werden. - -Mehr dazu unter [Agents](https://opencode.ai/docs/agents). - -### Dokumentation - -Mehr Infos zur Konfiguration von OpenCode findest du in unseren [**Docs**](https://opencode.ai/docs). - -### Beitragen - -Wenn du zu OpenCode beitragen möchtest, lies bitte unsere [Contributing Docs](./CONTRIBUTING.md), bevor du einen Pull Request einreichst. - -### Auf OpenCode aufbauen - -Wenn du an einem Projekt arbeitest, das mit OpenCode zusammenhängt und "opencode" als Teil seines Namens verwendet (z.B. "opencode-dashboard" oder "opencode-mobile"), füge bitte einen Hinweis in deine README ein, dass es nicht vom OpenCode-Team gebaut wird und nicht in irgendeiner Weise mit uns verbunden ist. - -### FAQ - -#### Worin unterscheidet sich das von Claude Code? - -In Bezug auf die Fähigkeiten ist es Claude Code sehr ähnlich. Hier sind die wichtigsten Unterschiede: - -- 100% open source -- Nicht an einen Anbieter gekoppelt. Wir empfehlen die Modelle aus [OpenCode Zen](https://opencode.ai/zen); OpenCode kann aber auch mit Claude, OpenAI, Google oder sogar lokalen Modellen genutzt werden. Mit der Weiterentwicklung der Modelle werden die Unterschiede kleiner und die Preise sinken, deshalb ist Provider-Unabhängigkeit wichtig. -- LSP-Unterstützung direkt nach dem Start -- Fokus auf TUI. OpenCode wird von Neovim-Nutzern und den Machern von [terminal.shop](https://terminal.shop) gebaut; wir treiben die Grenzen dessen, was im Terminal möglich ist. -- Client/Server-Architektur. Das ermöglicht z.B., OpenCode auf deinem Computer laufen zu lassen, während du es von einer mobilen App aus fernsteuerst. Das TUI-Frontend ist nur einer der möglichen Clients. - ---- - -**Tritt unserer Community bei** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.es.md b/README.es.md deleted file mode 100644 index 9e456af1c0..0000000000 --- a/README.es.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

El agente de programación con IA de código abierto.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Instalación - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Gestores de paquetes -npm i -g opencode-ai@latest # o bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día) -brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # cualquier sistema -nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente -``` - -> [!TIP] -> Elimina versiones anteriores a 0.1.x antes de instalar. - -### App de escritorio (BETA) - -OpenCode también está disponible como aplicación de escritorio. Descárgala directamente desde la [página de releases](https://github.com/anomalyco/opencode/releases) o desde [opencode.ai/download](https://opencode.ai/download). - -| Plataforma | Descarga | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, o AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Directorio de instalación - -El script de instalación respeta el siguiente orden de prioridad para la ruta de instalación: - -1. `$OPENCODE_INSTALL_DIR` - Directorio de instalación personalizado -2. `$XDG_BIN_DIR` - Ruta compatible con la especificación XDG Base Directory -3. `$HOME/bin` - Directorio binario estándar del usuario (si existe o se puede crear) -4. `$HOME/.opencode/bin` - Alternativa por defecto - -```bash -# Ejemplos -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode incluye dos agents integrados que puedes alternar con la tecla `Tab`. - -- **build** - Por defecto, agent con acceso completo para trabajo de desarrollo -- **plan** - Agent de solo lectura para análisis y exploración de código - - Niega ediciones de archivos por defecto - - Pide permiso antes de ejecutar comandos bash - - Ideal para explorar codebases desconocidas o planificar cambios - -Además, incluye un subagent **general** para búsquedas complejas y tareas de varios pasos. -Se usa internamente y se puede invocar con `@general` en los mensajes. - -Más información sobre [agents](https://opencode.ai/docs/agents). - -### Documentación - -Para más información sobre cómo configurar OpenCode, [**ve a nuestra documentación**](https://opencode.ai/docs). - -### Contribuir - -Si te interesa contribuir a OpenCode, lee nuestras [docs de contribución](./CONTRIBUTING.md) antes de enviar un pull request. - -### Construyendo sobre OpenCode - -Si estás trabajando en un proyecto relacionado con OpenCode y usas "opencode" como parte del nombre; por ejemplo, "opencode-dashboard" u "opencode-mobile", agrega una nota en tu README para aclarar que no está construido por el equipo de OpenCode y que no está afiliado con nosotros de ninguna manera. - -### FAQ - -#### ¿En qué se diferencia de Claude Code? - -Es muy similar a Claude Code en cuanto a capacidades. Estas son las diferencias clave: - -- 100% open source -- No está acoplado a ningún proveedor. Aunque recomendamos los modelos que ofrecemos a través de [OpenCode Zen](https://opencode.ai/zen); OpenCode se puede usar con Claude, OpenAI, Google o incluso modelos locales. A medida que evolucionan los modelos, las brechas se cerrarán y los precios bajarán, por lo que ser agnóstico al proveedor es importante. -- Soporte LSP listo para usar -- Un enfoque en la TUI. OpenCode está construido por usuarios de neovim y los creadores de [terminal.shop](https://terminal.shop); vamos a empujar los límites de lo que es posible en la terminal. -- Arquitectura cliente/servidor. Esto, por ejemplo, permite ejecutar OpenCode en tu computadora mientras lo controlas de forma remota desde una app móvil. Esto significa que el frontend TUI es solo uno de los posibles clientes. - ---- - -**Únete a nuestra comunidad** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.fr.md b/README.fr.md deleted file mode 100644 index c1fca23376..0000000000 --- a/README.fr.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - Logo OpenCode - - -

-

L'agent de codage IA open source.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Installation - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Gestionnaires de paquets -npm i -g opencode-ai@latest # ou bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour) -brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # n'importe quel OS -nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente -``` - -> [!TIP] -> Supprimez les versions antérieures à 0.1.x avant d'installer. - -### Application de bureau (BETA) - -OpenCode est aussi disponible en application de bureau. Téléchargez-la directement depuis la [page des releases](https://github.com/anomalyco/opencode/releases) ou [opencode.ai/download](https://opencode.ai/download). - -| Plateforme | Téléchargement | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, ou AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Répertoire d'installation - -Le script d'installation respecte l'ordre de priorité suivant pour le chemin d'installation : - -1. `$OPENCODE_INSTALL_DIR` - Répertoire d'installation personnalisé -2. `$XDG_BIN_DIR` - Chemin conforme à la spécification XDG Base Directory -3. `$HOME/bin` - Répertoire binaire utilisateur standard (s'il existe ou peut être créé) -4. `$HOME/.opencode/bin` - Repli par défaut - -```bash -# Exemples -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode inclut deux agents intégrés que vous pouvez basculer avec la touche `Tab`. - -- **build** - Par défaut, agent avec accès complet pour le travail de développement -- **plan** - Agent en lecture seule pour l'analyse et l'exploration du code - - Refuse les modifications de fichiers par défaut - - Demande l'autorisation avant d'exécuter des commandes bash - - Idéal pour explorer une base de code inconnue ou planifier des changements - -Un sous-agent **general** est aussi inclus pour les recherches complexes et les tâches en plusieurs étapes. -Il est utilisé en interne et peut être invoqué via `@general` dans les messages. - -En savoir plus sur les [agents](https://opencode.ai/docs/agents). - -### Documentation - -Pour plus d'informations sur la configuration d'OpenCode, [**consultez notre documentation**](https://opencode.ai/docs). - -### Contribuer - -Si vous souhaitez contribuer à OpenCode, lisez nos [docs de contribution](./CONTRIBUTING.md) avant de soumettre une pull request. - -### Construire avec OpenCode - -Si vous travaillez sur un projet lié à OpenCode et que vous utilisez "opencode" dans le nom du projet (par exemple, "opencode-dashboard" ou "opencode-mobile"), ajoutez une note dans votre README pour préciser qu'il n'est pas construit par l'équipe OpenCode et qu'il n'est pas affilié à nous. - -### FAQ - -#### En quoi est-ce différent de Claude Code ? - -C'est très similaire à Claude Code en termes de capacités. Voici les principales différences : - -- 100% open source -- Pas couplé à un fournisseur. Nous recommandons les modèles proposés via [OpenCode Zen](https://opencode.ai/zen) ; OpenCode peut être utilisé avec Claude, OpenAI, Google ou même des modèles locaux. Au fur et à mesure que les modèles évoluent, les écarts se réduiront et les prix baisseront, donc être agnostique au fournisseur est important. -- Support LSP prêt à l'emploi -- Un focus sur la TUI. OpenCode est construit par des utilisateurs de neovim et les créateurs de [terminal.shop](https://terminal.shop) ; nous allons repousser les limites de ce qui est possible dans le terminal. -- Architecture client/serveur. Cela permet par exemple de faire tourner OpenCode sur votre ordinateur tout en le pilotant à distance depuis une application mobile. Cela signifie que la TUI n'est qu'un des clients possibles. - ---- - -**Rejoignez notre communauté** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.gr.md b/README.gr.md deleted file mode 100644 index 2b2c2679d8..0000000000 --- a/README.gr.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Ο πράκτορας τεχνητής νοημοσύνης ανοικτού κώδικα για προγραμματισμό.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Εγκατάσταση - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Διαχειριστές πακέτων -npm i -g opencode-ai@latest # ή bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS και Linux (προτείνεται, πάντα ενημερωμένο) -brew install opencode # macOS και Linux (επίσημος τύπος brew, λιγότερο συχνές ενημερώσεις) -sudo pacman -S opencode # Arch Linux (Σταθερό) -paru -S opencode-bin # Arch Linux (Τελευταία έκδοση από AUR) -mise use -g opencode # Οποιοδήποτε λειτουργικό σύστημα -nix run nixpkgs#opencode # ή github:anomalyco/opencode με βάση την πιο πρόσφατη αλλαγή από το dev branch -``` - -> [!TIP] -> Αφαίρεσε παλαιότερες εκδόσεις από τη 0.1.x πριν από την εγκατάσταση. - -### Εφαρμογή Desktop (BETA) - -Το OpenCode είναι επίσης διαθέσιμο ως εφαρμογή. Κατέβασε το απευθείας από τη [σελίδα εκδόσεων](https://github.com/anomalyco/opencode/releases) ή το [opencode.ai/download](https://opencode.ai/download). - -| Πλατφόρμα | Λήψη | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, ή AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Κατάλογος Εγκατάστασης - -Το script εγκατάστασης τηρεί την ακόλουθη σειρά προτεραιότητας για τη διαδρομή εγκατάστασης: - -1. `$OPENCODE_INSTALL_DIR` - Προσαρμοσμένος κατάλογος εγκατάστασης -2. `$XDG_BIN_DIR` - Διαδρομή συμβατή με τις προδιαγραφές XDG Base Directory -3. `$HOME/bin` - Τυπικός κατάλογος εκτελέσιμων αρχείων χρήστη (εάν υπάρχει ή μπορεί να δημιουργηθεί) -4. `$HOME/.opencode/bin` - Προεπιλεγμένη εφεδρική διαδρομή - -```bash -# Παραδείγματα -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Πράκτορες - -Το OpenCode περιλαμβάνει δύο ενσωματωμένους πράκτορες μεταξύ των οποίων μπορείτε να εναλλάσσεστε με το πλήκτρο `Tab`. - -- **build** - Προεπιλεγμένος πράκτορας με πλήρη πρόσβαση για εργασία πάνω σε κώδικα -- **plan** - Πράκτορας μόνο ανάγνωσης για ανάλυση και εξερεύνηση κώδικα - - Αρνείται την επεξεργασία αρχείων από προεπιλογή - - Ζητά άδεια πριν εκτελέσει εντολές bash - - Ιδανικός για εξερεύνηση άγνωστων αρχείων πηγαίου κώδικα ή σχεδιασμό αλλαγών - -Περιλαμβάνεται επίσης ένας **general** υποπράκτορας για σύνθετες αναζητήσεις και πολυβηματικές διεργασίες. -Χρησιμοποιείται εσωτερικά και μπορεί να κληθεί χρησιμοποιώντας `@general` στα μηνύματα. - -Μάθετε περισσότερα για τους [πράκτορες](https://opencode.ai/docs/agents). - -### Οδηγός Χρήσης - -Για περισσότερες πληροφορίες σχετικά με τη ρύθμιση του OpenCode, [**πλοηγήσου στον οδηγό χρήσης μας**](https://opencode.ai/docs). - -### Συνεισφορά - -Εάν ενδιαφέρεσαι να συνεισφέρεις στο OpenCode, διαβάστε τα [οδηγό χρήσης συνεισφοράς](./CONTRIBUTING.md) πριν υποβάλεις ένα pull request. - -### Δημιουργία πάνω στο OpenCode - -Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς. - -### Συχνές Ερωτήσεις - -#### Πώς διαφέρει αυτό από το Claude Code; - -Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές: - -- 100% ανοιχτού κώδικα -- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο. -- Out-of-the-box υποστήριξη LSP -- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal. -- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients. - ---- - -**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.it.md b/README.it.md deleted file mode 100644 index 3e516a9027..0000000000 --- a/README.it.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - Logo OpenCode - - -

-

L’agente di coding AI open source.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Installazione - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Package manager -npm i -g opencode-ai@latest # oppure bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato) -brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # Qualsiasi OS -nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo -``` - -> [!TIP] -> Rimuovi le versioni precedenti alla 0.1.x prima di installare. - -### App Desktop (BETA) - -OpenCode è disponibile anche come applicazione desktop. Puoi scaricarla direttamente dalla [pagina delle release](https://github.com/anomalyco/opencode/releases) oppure da [opencode.ai/download](https://opencode.ai/download). - -| Piattaforma | Download | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, oppure AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Directory di installazione - -Lo script di installazione rispetta il seguente ordine di priorità per il percorso di installazione: - -1. `$OPENCODE_INSTALL_DIR` – Directory di installazione personalizzata -2. `$XDG_BIN_DIR` – Percorso conforme alla XDG Base Directory Specification -3. `$HOME/bin` – Directory binaria standard dell’utente (se esiste o può essere creata) -4. `$HOME/.opencode/bin` – Fallback predefinito - -```bash -# Esempi -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agenti - -OpenCode include due agenti integrati tra cui puoi passare usando il tasto `Tab`. - -- **build** – Predefinito, agente con accesso completo per il lavoro di sviluppo -- **plan** – Agente in sola lettura per analisi ed esplorazione del codice - - Nega le modifiche ai file per impostazione predefinita - - Chiede il permesso prima di eseguire comandi bash - - Ideale per esplorare codebase sconosciute o pianificare modifiche - -È inoltre incluso un sotto-agente **general** per ricerche complesse e attività multi-step. -Viene utilizzato internamente e può essere invocato usando `@general` nei messaggi. - -Scopri di più sugli [agenti](https://opencode.ai/docs/agents). - -### Documentazione - -Per maggiori informazioni su come configurare OpenCode, [**consulta la nostra documentazione**](https://opencode.ai/docs). - -### Contribuire - -Se sei interessato a contribuire a OpenCode, leggi la nostra [guida alla contribuzione](./CONTRIBUTING.md) prima di inviare una pull request. - -### Costruire su OpenCode - -Se stai lavorando a un progetto correlato a OpenCode e che utilizza “opencode” come parte del nome (ad esempio “opencode-dashboard” o “opencode-mobile”), aggiungi una nota nel tuo README per chiarire che non è sviluppato dal team OpenCode e che non è affiliato in alcun modo con noi. - -### FAQ - -#### In cosa è diverso da Claude Code? - -È molto simile a Claude Code in termini di funzionalità. Ecco le principali differenze: - -- 100% open source -- Non è legato a nessun provider. Anche se consigliamo i modelli forniti tramite [OpenCode Zen](https://opencode.ai/zen), OpenCode può essere utilizzato con Claude, OpenAI, Google o persino modelli locali. Con l’evoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante. -- Supporto LSP pronto all’uso -- Forte attenzione alla TUI. OpenCode è sviluppato da utenti neovim e dai creatori di [terminal.shop](https://terminal.shop); spingeremo al limite ciò che è possibile fare nel terminale. -- Architettura client/server. Questo, ad esempio, permette a OpenCode di girare sul tuo computer mentre lo controlli da remoto tramite un’app mobile. La frontend TUI è quindi solo uno dei possibili client. - ---- - -**Unisciti alla nostra community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.ja.md b/README.ja.md deleted file mode 100644 index 144dc7b6f8..0000000000 --- a/README.ja.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

オープンソースのAIコーディングエージェント。

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### インストール - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# パッケージマネージャー -npm i -g opencode-ai@latest # bun/pnpm/yarn でもOK -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新) -brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # どのOSでも -nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ -``` - -> [!TIP] -> インストール前に 0.1.x より古いバージョンを削除してください。 - -### デスクトップアプリ (BETA) - -OpenCode はデスクトップアプリとしても利用できます。[releases page](https://github.com/anomalyco/opencode/releases) から直接ダウンロードするか、[opencode.ai/download](https://opencode.ai/download) を利用してください。 - -| プラットフォーム | ダウンロード | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`、`.rpm`、または AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### インストールディレクトリ - -インストールスクリプトは、インストール先パスを次の優先順位で決定します。 - -1. `$OPENCODE_INSTALL_DIR` - カスタムのインストールディレクトリ -2. `$XDG_BIN_DIR` - XDG Base Directory Specification に準拠したパス -3. `$HOME/bin` - 標準のユーザー用バイナリディレクトリ(存在する場合、または作成できる場合) -4. `$HOME/.opencode/bin` - デフォルトのフォールバック - -```bash -# 例 -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode には組み込みの Agent が2つあり、`Tab` キーで切り替えられます。 - -- **build** - デフォルト。開発向けのフルアクセス Agent -- **plan** - 分析とコード探索向けの読み取り専用 Agent - - デフォルトでファイル編集を拒否 - - bash コマンド実行前に確認 - - 未知のコードベース探索や変更計画に最適 - -また、複雑な検索やマルチステップのタスク向けに **general** サブ Agent も含まれています。 -内部的に使用されており、メッセージで `@general` と入力して呼び出せます。 - -[agents](https://opencode.ai/docs/agents) の詳細はこちら。 - -### ドキュメント - -OpenCode の設定については [**ドキュメント**](https://opencode.ai/docs) を参照してください。 - -### コントリビュート - -OpenCode に貢献したい場合は、Pull Request を送る前に [contributing docs](./CONTRIBUTING.md) を読んでください。 - -### OpenCode の上に構築する - -OpenCode に関連するプロジェクトで、名前に "opencode"(例: "opencode-dashboard" や "opencode-mobile")を含める場合は、そのプロジェクトが OpenCode チームによって作られたものではなく、いかなる形でも関係がないことを README に明記してください。 - -### FAQ - -#### Claude Code との違いは? - -機能面では Claude Code と非常に似ています。主な違いは次のとおりです。 - -- 100% オープンソース -- 特定のプロバイダーに依存しません。[OpenCode Zen](https://opencode.ai/zen) で提供しているモデルを推奨しますが、OpenCode は Claude、OpenAI、Google、またはローカルモデルでも利用できます。モデルが進化すると差は縮まり価格も下がるため、provider-agnostic であることが重要です。 -- そのまま使える LSP サポート -- TUI にフォーカス。OpenCode は neovim ユーザーと [terminal.shop](https://terminal.shop) の制作者によって作られており、ターミナルで可能なことの限界を押し広げます。 -- クライアント/サーバー構成。例えば OpenCode をあなたのPCで動かし、モバイルアプリからリモート操作できます。TUI フロントエンドは複数あるクライアントの1つにすぎません。 - ---- - -**コミュニティに参加** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.ko.md b/README.ko.md deleted file mode 100644 index 32defc0a5e..0000000000 --- a/README.ko.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

오픈 소스 AI 코딩 에이전트.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### 설치 - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# 패키지 매니저 -npm i -g opencode-ai@latest # bun/pnpm/yarn 도 가능 -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신) -brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # 어떤 OS든 -nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치 -``` - -> [!TIP] -> 설치 전에 0.1.x 보다 오래된 버전을 제거하세요. - -### 데스크톱 앱 (BETA) - -OpenCode 는 데스크톱 앱으로도 제공됩니다. [releases page](https://github.com/anomalyco/opencode/releases) 에서 직접 다운로드하거나 [opencode.ai/download](https://opencode.ai/download) 를 이용하세요. - -| 플랫폼 | 다운로드 | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, 또는 AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### 설치 디렉터리 - -설치 스크립트는 설치 경로를 다음 우선순위로 결정합니다. - -1. `$OPENCODE_INSTALL_DIR` - 사용자 지정 설치 디렉터리 -2. `$XDG_BIN_DIR` - XDG Base Directory Specification 준수 경로 -3. `$HOME/bin` - 표준 사용자 바이너리 디렉터리 (존재하거나 생성 가능할 경우) -4. `$HOME/.opencode/bin` - 기본 폴백 - -```bash -# 예시 -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode 에는 내장 에이전트 2개가 있으며 `Tab` 키로 전환할 수 있습니다. - -- **build** - 기본값, 개발 작업을 위한 전체 권한 에이전트 -- **plan** - 분석 및 코드 탐색을 위한 읽기 전용 에이전트 - - 기본적으로 파일 편집을 거부 - - bash 명령 실행 전에 권한을 요청 - - 낯선 코드베이스를 탐색하거나 변경을 계획할 때 적합 - -또한 복잡한 검색과 여러 단계 작업을 위한 **general** 서브 에이전트가 포함되어 있습니다. -내부적으로 사용되며, 메시지에서 `@general` 로 호출할 수 있습니다. - -[agents](https://opencode.ai/docs/agents) 에 대해 더 알아보세요. - -### 문서 - -OpenCode 설정에 대한 자세한 내용은 [**문서**](https://opencode.ai/docs) 를 참고하세요. - -### 기여하기 - -OpenCode 에 기여하고 싶다면, Pull Request 를 제출하기 전에 [contributing docs](./CONTRIBUTING.md) 를 읽어주세요. - -### OpenCode 기반으로 만들기 - -OpenCode 와 관련된 프로젝트를 진행하면서 이름에 "opencode"(예: "opencode-dashboard" 또는 "opencode-mobile") 를 포함한다면, README 에 해당 프로젝트가 OpenCode 팀이 만든 것이 아니며 어떤 방식으로도 우리와 제휴되어 있지 않다는 점을 명시해 주세요. - -### FAQ - -#### Claude Code 와는 무엇이 다른가요? - -기능 면에서는 Claude Code 와 매우 유사합니다. 주요 차이점은 다음과 같습니다. - -- 100% 오픈 소스 -- 특정 제공자에 묶여 있지 않습니다. [OpenCode Zen](https://opencode.ai/zen) 을 통해 제공하는 모델을 권장하지만, OpenCode 는 Claude, OpenAI, Google 또는 로컬 모델과도 사용할 수 있습니다. 모델이 발전하면서 격차는 줄고 가격은 내려가므로 provider-agnostic 인 것이 중요합니다. -- 기본으로 제공되는 LSP 지원 -- TUI 에 집중. OpenCode 는 neovim 사용자와 [terminal.shop](https://terminal.shop) 제작자가 만들었으며, 터미널에서 가능한 것의 한계를 밀어붙입니다. -- 클라이언트/서버 아키텍처. 예를 들어 OpenCode 를 내 컴퓨터에서 실행하면서 모바일 앱으로 원격 조작할 수 있습니다. 즉, TUI 프런트엔드는 가능한 여러 클라이언트 중 하나일 뿐입니다. - ---- - -**커뮤니티에 참여하기** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.no.md b/README.no.md deleted file mode 100644 index c3348286b2..0000000000 --- a/README.no.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

AI-kodeagent med åpen kildekode.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Installasjon - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Pakkehåndterere -npm i -g opencode-ai@latest # eller bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert) -brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # alle OS -nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch -``` - -> [!TIP] -> Fjern versjoner eldre enn 0.1.x før du installerer. - -### Desktop-app (BETA) - -OpenCode er også tilgjengelig som en desktop-app. Last ned direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download). - -| Plattform | Nedlasting | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` eller AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Installasjonsmappe - -Installasjonsskriptet bruker følgende prioritet for installasjonsstien: - -1. `$OPENCODE_INSTALL_DIR` - Egendefinert installasjonsmappe -2. `$XDG_BIN_DIR` - Sti som følger XDG Base Directory Specification -3. `$HOME/bin` - Standard brukerbinar-mappe (hvis den finnes eller kan opprettes) -4. `$HOME/.opencode/bin` - Standard fallback - -```bash -# Eksempler -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode har to innebygde agents du kan bytte mellom med `Tab`-tasten. - -- **build** - Standard, agent med full tilgang for utviklingsarbeid -- **plan** - Skrivebeskyttet agent for analyse og kodeutforsking - - Nekter filendringer som standard - - Spør om tillatelse før bash-kommandoer - - Ideell for å utforske ukjente kodebaser eller planlegge endringer - -Det finnes også en **general**-subagent for komplekse søk og flertrinnsoppgaver. -Den brukes internt og kan kalles via `@general` i meldinger. - -Les mer om [agents](https://opencode.ai/docs/agents). - -### Dokumentasjon - -For mer info om hvordan du konfigurerer OpenCode, [**se dokumentasjonen**](https://opencode.ai/docs). - -### Bidra - -Hvis du vil bidra til OpenCode, les [contributing docs](./CONTRIBUTING.md) før du sender en pull request. - -### Bygge på OpenCode - -Hvis du jobber med et prosjekt som er relatert til OpenCode og bruker "opencode" som en del av navnet; for eksempel "opencode-dashboard" eller "opencode-mobile", legg inn en merknad i README som presiserer at det ikke er bygget av OpenCode-teamet og ikke er tilknyttet oss på noen måte. - -### FAQ - -#### Hvordan er dette forskjellig fra Claude Code? - -Det er veldig likt Claude Code når det gjelder funksjonalitet. Her er de viktigste forskjellene: - -- 100% open source -- Ikke knyttet til en bestemt leverandør. Selv om vi anbefaler modellene vi tilbyr gjennom [OpenCode Zen](https://opencode.ai/zen); kan OpenCode brukes med Claude, OpenAI, Google eller til og med lokale modeller. Etter hvert som modellene utvikler seg vil gapene lukkes og prisene gå ned, så det er viktig å være provider-agnostic. -- LSP-støtte rett ut av boksen -- Fokus på TUI. OpenCode er bygget av neovim-brukere og skaperne av [terminal.shop](https://terminal.shop); vi kommer til å presse grensene for hva som er mulig i terminalen. -- Klient/server-arkitektur. Dette kan for eksempel la OpenCode kjøre på maskinen din, mens du styrer den eksternt fra en mobilapp. Det betyr at TUI-frontend'en bare er en av de mulige klientene. - ---- - -**Bli med i fellesskapet** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.pl.md b/README.pl.md deleted file mode 100644 index 4c5a076656..0000000000 --- a/README.pl.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Otwartoźródłowy agent kodujący AI.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Instalacja - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Menedżery pakietów -npm i -g opencode-ai@latest # albo bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne) -brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # dowolny system -nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev -``` - -> [!TIP] -> Przed instalacją usuń wersje starsze niż 0.1.x. - -### Aplikacja desktopowa (BETA) - -OpenCode jest także dostępny jako aplikacja desktopowa. Pobierz ją bezpośrednio ze strony [releases](https://github.com/anomalyco/opencode/releases) lub z [opencode.ai/download](https://opencode.ai/download). - -| Platforma | Pobieranie | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` lub AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Katalog instalacji - -Skrypt instalacyjny stosuje następujący priorytet wyboru ścieżki instalacji: - -1. `$OPENCODE_INSTALL_DIR` - Własny katalog instalacji -2. `$XDG_BIN_DIR` - Ścieżka zgodna ze specyfikacją XDG Base Directory -3. `$HOME/bin` - Standardowy katalog binarny użytkownika (jeśli istnieje lub można go utworzyć) -4. `$HOME/.opencode/bin` - Domyślny fallback - -```bash -# Przykłady -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode zawiera dwóch wbudowanych agentów, między którymi możesz przełączać się klawiszem `Tab`. - -- **build** - Domyślny agent z pełnym dostępem do pracy developerskiej -- **plan** - Agent tylko do odczytu do analizy i eksploracji kodu - - Domyślnie odmawia edycji plików - - Pyta o zgodę przed uruchomieniem komend bash - - Idealny do poznawania nieznanych baz kodu lub planowania zmian - -Dodatkowo jest subagent **general** do złożonych wyszukiwań i wieloetapowych zadań. -Jest używany wewnętrznie i można go wywołać w wiadomościach przez `@general`. - -Dowiedz się więcej o [agents](https://opencode.ai/docs/agents). - -### Dokumentacja - -Więcej informacji o konfiguracji OpenCode znajdziesz w [**dokumentacji**](https://opencode.ai/docs). - -### Współtworzenie - -Jeśli chcesz współtworzyć OpenCode, przeczytaj [contributing docs](./CONTRIBUTING.md) przed wysłaniem pull requesta. - -### Budowanie na OpenCode - -Jeśli pracujesz nad projektem związanym z OpenCode i używasz "opencode" jako części nazwy (na przykład "opencode-dashboard" lub "opencode-mobile"), dodaj proszę notatkę do swojego README, aby wyjaśnić, że projekt nie jest tworzony przez zespół OpenCode i nie jest z nami w żaden sposób powiązany. - -### FAQ - -#### Czym to się różni od Claude Code? - -Jest bardzo podobne do Claude Code pod względem możliwości. Oto kluczowe różnice: - -- 100% open source -- Niezależne od dostawcy. Chociaż polecamy modele oferowane przez [OpenCode Zen](https://opencode.ai/zen); OpenCode może być używany z Claude, OpenAI, Google, a nawet z modelami lokalnymi. W miarę jak modele ewoluują, różnice będą się zmniejszać, a ceny spadać, więc ważna jest niezależność od dostawcy. -- Wbudowane wsparcie LSP -- Skupienie na TUI. OpenCode jest budowany przez użytkowników neovim i twórców [terminal.shop](https://terminal.shop); przesuwamy granice tego, co jest możliwe w terminalu. -- Architektura klient/serwer. Pozwala np. uruchomić OpenCode na twoim komputerze, a sterować nim zdalnie z aplikacji mobilnej. To znaczy, że frontend TUI jest tylko jednym z możliwych klientów. - ---- - -**Dołącz do naszej społeczności** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.ru.md b/README.ru.md deleted file mode 100644 index e507be70e6..0000000000 --- a/README.ru.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Открытый AI-агент для программирования.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Установка - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Менеджеры пакетов -npm i -g opencode-ai@latest # или bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально) -brew install opencode # macOS и Linux (официальная формула brew, обновляется реже) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # любая ОС -nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev -``` - -> [!TIP] -> Перед установкой удалите версии старше 0.1.x. - -### Десктопное приложение (BETA) - -OpenCode также доступен как десктопное приложение. Скачайте его со [страницы релизов](https://github.com/anomalyco/opencode/releases) или с [opencode.ai/download](https://opencode.ai/download). - -| Платформа | Загрузка | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` или AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Каталог установки - -Скрипт установки выбирает путь установки в следующем порядке приоритета: - -1. `$OPENCODE_INSTALL_DIR` - Пользовательский каталог установки -2. `$XDG_BIN_DIR` - Путь, совместимый со спецификацией XDG Base Directory -3. `$HOME/bin` - Стандартный каталог пользовательских бинарников (если существует или можно создать) -4. `$HOME/.opencode/bin` - Fallback по умолчанию - -```bash -# Примеры -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -В OpenCode есть два встроенных агента, между которыми можно переключаться клавишей `Tab`. - -- **build** - По умолчанию, агент с полным доступом для разработки -- **plan** - Агент только для чтения для анализа и изучения кода - - По умолчанию запрещает редактирование файлов - - Запрашивает разрешение перед выполнением bash-команд - - Идеален для изучения незнакомых кодовых баз или планирования изменений - -Также включен сабагент **general** для сложных поисков и многошаговых задач. -Он используется внутренне и может быть вызван в сообщениях через `@general`. - -Подробнее об [agents](https://opencode.ai/docs/agents). - -### Документация - -Больше информации о том, как настроить OpenCode: [**наши docs**](https://opencode.ai/docs). - -### Вклад - -Если вы хотите внести вклад в OpenCode, прочитайте [contributing docs](./CONTRIBUTING.md) перед тем, как отправлять pull request. - -### Разработка на базе OpenCode - -Если вы делаете проект, связанный с OpenCode, и используете "opencode" как часть имени (например, "opencode-dashboard" или "opencode-mobile"), добавьте примечание в README, чтобы уточнить, что проект не создан командой OpenCode и не аффилирован с нами. - -### FAQ - -#### Чем это отличается от Claude Code? - -По возможностям это очень похоже на Claude Code. Вот ключевые отличия: - -- 100% open source -- Не привязано к одному провайдеру. Мы рекомендуем модели из [OpenCode Zen](https://opencode.ai/zen); но OpenCode можно использовать с Claude, OpenAI, Google или даже локальными моделями. По мере развития моделей разрыв будет сокращаться, а цены падать, поэтому важна независимость от провайдера. -- Поддержка LSP из коробки -- Фокус на TUI. OpenCode построен пользователями neovim и создателями [terminal.shop](https://terminal.shop); мы будем раздвигать границы того, что возможно в терминале. -- Архитектура клиент/сервер. Например, это позволяет запускать OpenCode на вашем компьютере, а управлять им удаленно из мобильного приложения. Это значит, что TUI-фронтенд - лишь один из возможных клиентов. - ---- - -**Присоединяйтесь к нашему сообществу** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.th.md b/README.th.md deleted file mode 100644 index 4a4ea62c95..0000000000 --- a/README.th.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

เอเจนต์การเขียนโค้ดด้วย AI แบบโอเพนซอร์ส

-

- Discord - npm - สถานะการสร้าง -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### การติดตั้ง - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# ตัวจัดการแพ็กเกจ -npm i -g opencode-ai@latest # หรือ bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ) -brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # ระบบปฏิบัติการใดก็ได้ -nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด -``` - -> [!TIP] -> ลบเวอร์ชันที่เก่ากว่า 0.1.x ก่อนติดตั้ง - -### แอปพลิเคชันเดสก์ท็อป (เบต้า) - -OpenCode มีให้ใช้งานเป็นแอปพลิเคชันเดสก์ท็อป ดาวน์โหลดโดยตรงจาก [หน้ารุ่น](https://github.com/anomalyco/opencode/releases) หรือ [opencode.ai/download](https://opencode.ai/download) - -| แพลตฟอร์ม | ดาวน์โหลด | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, หรือ AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### ไดเรกทอรีการติดตั้ง - -สคริปต์การติดตั้งจะใช้ลำดับความสำคัญตามเส้นทางการติดตั้ง: - -1. `$OPENCODE_INSTALL_DIR` - ไดเรกทอรีการติดตั้งที่กำหนดเอง -2. `$XDG_BIN_DIR` - เส้นทางที่สอดคล้องกับ XDG Base Directory Specification -3. `$HOME/bin` - ไดเรกทอรีไบนารีผู้ใช้มาตรฐาน (หากมีอยู่หรือสามารถสร้างได้) -4. `$HOME/.opencode/bin` - ค่าสำรองเริ่มต้น - -```bash -# ตัวอย่าง -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### เอเจนต์ - -OpenCode รวมเอเจนต์ในตัวสองตัวที่คุณสามารถสลับได้ด้วยปุ่ม `Tab` - -- **build** - เอเจนต์เริ่มต้น มีสิทธิ์เข้าถึงแบบเต็มสำหรับงานพัฒนา -- **plan** - เอเจนต์อ่านอย่างเดียวสำหรับการวิเคราะห์และการสำรวจโค้ด - - ปฏิเสธการแก้ไขไฟล์โดยค่าเริ่มต้น - - ขอสิทธิ์ก่อนเรียกใช้คำสั่ง bash - - เหมาะสำหรับสำรวจโค้ดเบสที่ไม่คุ้นเคยหรือวางแผนการเปลี่ยนแปลง - -นอกจากนี้ยังมีเอเจนต์ย่อย **general** สำหรับการค้นหาที่ซับซ้อนและงานหลายขั้นตอน -ใช้ภายในและสามารถเรียกใช้ได้โดยใช้ `@general` ในข้อความ - -เรียนรู้เพิ่มเติมเกี่ยวกับ [เอเจนต์](https://opencode.ai/docs/agents) - -### เอกสารประกอบ - -สำหรับข้อมูลเพิ่มเติมเกี่ยวกับวิธีกำหนดค่า OpenCode [**ไปที่เอกสารของเรา**](https://opencode.ai/docs) - -### การมีส่วนร่วม - -หากคุณสนใจที่จะมีส่วนร่วมใน OpenCode โปรดอ่าน [เอกสารการมีส่วนร่วม](./CONTRIBUTING.md) ก่อนส่ง Pull Request - -### การสร้างบน OpenCode - -หากคุณทำงานในโปรเจกต์ที่เกี่ยวข้องกับ OpenCode และใช้ "opencode" เป็นส่วนหนึ่งของชื่อ เช่น "opencode-dashboard" หรือ "opencode-mobile" โปรดเพิ่มหมายเหตุใน README ของคุณเพื่อชี้แจงว่าไม่ได้สร้างโดยทีม OpenCode และไม่ได้เกี่ยวข้องกับเราในทางใด - -### คำถามที่พบบ่อย - -#### ต่างจาก Claude Code อย่างไร? - -คล้ายกับ Claude Code มากในแง่ความสามารถ นี่คือความแตกต่างหลัก: - -- โอเพนซอร์ส 100% -- ไม่ผูกมัดกับผู้ให้บริการใดๆ แม้ว่าเราจะแนะนำโมเดลที่เราจัดหาให้ผ่าน [OpenCode Zen](https://opencode.ai/zen) OpenCode สามารถใช้กับ Claude, OpenAI, Google หรือแม้กระทั่งโมเดลในเครื่องได้ เมื่อโมเดลพัฒนาช่องว่างระหว่างพวกมันจะปิดลงและราคาจะลดลง ดังนั้นการไม่ผูกมัดกับผู้ให้บริการจึงสำคัญ -- รองรับ LSP ใช้งานได้ทันทีหลังการติดตั้งโดยไม่ต้องปรับแต่งหรือเปลี่ยนแปลงฟังก์ชันการทำงานใด ๆ -- เน้นที่ TUI OpenCode สร้างโดยผู้ใช้ neovim และผู้สร้าง [terminal.shop](https://terminal.shop) เราจะผลักดันขีดจำกัดของสิ่งที่เป็นไปได้ในเทอร์มินัล -- สถาปัตยกรรมไคลเอนต์/เซิร์ฟเวอร์ ตัวอย่างเช่น อาจอนุญาตให้ OpenCode ทำงานบนคอมพิวเตอร์ของคุณ ในขณะที่คุณสามารถขับเคลื่อนจากระยะไกลผ่านแอปมือถือ หมายความว่า TUI frontend เป็นหนึ่งในไคลเอนต์ที่เป็นไปได้เท่านั้น - ---- - -**ร่วมชุมชนของเรา** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.tr.md b/README.tr.md deleted file mode 100644 index e88b40f875..0000000000 --- a/README.tr.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Açık kaynaklı yapay zeka kodlama asistanı.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Kurulum - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Paket yöneticileri -npm i -g opencode-ai@latest # veya bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel) -brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # Tüm işletim sistemleri -nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode -``` - -> [!TIP] -> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın. - -### Masaüstü Uygulaması (BETA) - -OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz. - -| Platform | İndirme | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` veya AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Kurulum Dizini (Installation Directory) - -Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder: - -1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini -2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol -3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa) -4. `$HOME/.opencode/bin` - Varsayılan yedek konum - -```bash -# Örnekler -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Ajanlar - -OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir. - -- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan -- **plan** - Analiz ve kod keşfi için salt okunur ajan - - Varsayılan olarak dosya düzenlemelerini reddeder - - Bash komutlarını çalıştırmadan önce izin ister - - Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal - -Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır. -Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir. - -[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin. - -### Dokümantasyon - -OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs). - -### Katkıda Bulunma - -OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun. - -### OpenCode Üzerine Geliştirme - -OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin. - -### SSS - -#### Bu Claude Code'dan nasıl farklı? - -Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar: - -- %100 açık kaynak -- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir. -- Kurulum gerektirmeyen hazır LSP desteği -- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız. -- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir. - ---- - -**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.uk.md b/README.uk.md deleted file mode 100644 index a1a0259b6d..0000000000 --- a/README.uk.md +++ /dev/null @@ -1,142 +0,0 @@ -

- - - - - OpenCode logo - - -

-

AI-агент для програмування з відкритим кодом.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Встановлення - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Менеджери пакетів -npm i -g opencode-ai@latest # або bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально) -brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # Будь-яка ОС -nix run nixpkgs#opencode # або github:anomalyco/opencode для найновішої dev-гілки -``` - -> [!TIP] -> Перед встановленням видаліть версії старші за 0.1.x. - -### Десктопний застосунок (BETA) - -OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download). - -| Платформа | Завантаження | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm` або AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Каталог встановлення - -Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення: - -1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення -2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory -3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити) -4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням - -```bash -# Приклади -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Агенти - -OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`. - -- **build** - Агент за замовчуванням із повним доступом для завдань розробки -- **plan** - Агент лише для читання для аналізу та дослідження коду - - За замовчуванням забороняє редагування файлів - - Запитує дозвіл перед запуском bash-команд - - Ідеально підходить для дослідження незнайомих кодових баз або планування змін - -Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань. -Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`. - -Дізнайтеся більше про [agents](https://opencode.ai/docs/agents). - -### Документація - -Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs). - -### Внесок - -Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request. - -### Проєкти на базі OpenCode - -Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README. -Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами. - -### FAQ - -#### Чим це відрізняється від Claude Code? - -За можливостями це дуже схоже на Claude Code. Ось ключові відмінності: - -- 100% open source -- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення. -- Підтримка LSP з коробки -- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі. -- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів. - ---- - -**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.vi.md b/README.vi.md deleted file mode 100644 index 0932c50f78..0000000000 --- a/README.vi.md +++ /dev/null @@ -1,141 +0,0 @@ -

- - - - - OpenCode logo - - -

-

Trợ lý lập trình AI mã nguồn mở.

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### Cài đặt - -```bash -# YOLO -curl -fsSL https://opencode.ai/install | bash - -# Các trình quản lý gói (Package managers) -npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật) -brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn) -sudo pacman -S opencode # Arch Linux (Bản ổn định) -paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR) -mise use -g opencode # Mọi hệ điều hành -nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất -``` - -> [!TIP] -> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt. - -### Ứng dụng Desktop (BETA) - -OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download). - -| Nền tảng | Tải xuống | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, hoặc AppImage | - -```bash -# macOS (Homebrew) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### Thư mục cài đặt - -Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt: - -1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh -2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification -3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo) -4. `$HOME/.opencode/bin` - Mặc định dự phòng - -```bash -# Ví dụ -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents (Đại diện) - -OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`. - -- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình -- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn - - Mặc định từ chối việc chỉnh sửa tệp - - Hỏi quyền trước khi chạy các lệnh bash - - Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi - -Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước. -Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn. - -Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents). - -### Tài liệu - -Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs). - -### Đóng góp - -Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request. - -### Xây dựng trên nền tảng OpenCode - -Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào. - -### Các câu hỏi thường gặp (FAQ) - -#### OpenCode khác biệt thế nào so với Claude Code? - -Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính: - -- 100% mã nguồn mở -- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng. -- Hỗ trợ LSP ngay từ đầu -- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa. -- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng. - ---- - -**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.zht.md b/README.zht.md deleted file mode 100644 index 7ef51d8fdd..0000000000 --- a/README.zht.md +++ /dev/null @@ -1,140 +0,0 @@ -

- - - - - OpenCode logo - - -

-

開源的 AI Coding Agent。

-

- Discord - npm - Build status -

- -

- English | - 简体中文 | - 繁體中文 | - 한국어 | - Deutsch | - Español | - Français | - Italiano | - Dansk | - 日本語 | - Polski | - Русский | - Bosanski | - العربية | - Norsk | - Português (Brasil) | - ไทย | - Türkçe | - Українська | - বাংলা | - Ελληνικά | - Tiếng Việt -

- -[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) - ---- - -### 安裝 - -```bash -# 直接安裝 (YOLO) -curl -fsSL https://opencode.ai/install | bash - -# 套件管理員 -npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn -scoop install opencode # Windows -choco install opencode # Windows -brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新) -brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低) -sudo pacman -S opencode # Arch Linux (Stable) -paru -S opencode-bin # Arch Linux (Latest from AUR) -mise use -g opencode # 任何作業系統 -nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支 -``` - -> [!TIP] -> 安裝前請先移除 0.1.x 以前的舊版本。 - -### 桌面應用程式 (BETA) - -OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。 - -| 平台 | 下載連結 | -| --------------------- | ------------------------------------- | -| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | -| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | -| Windows | `opencode-desktop-windows-x64.exe` | -| Linux | `.deb`, `.rpm`, 或 AppImage | - -```bash -# macOS (Homebrew Cask) -brew install --cask opencode-desktop -# Windows (Scoop) -scoop bucket add extras; scoop install extras/opencode-desktop -``` - -#### 安裝目錄 - -安裝腳本會依據以下優先順序決定安裝路徑: - -1. `$OPENCODE_INSTALL_DIR` - 自定義安裝目錄 -2. `$XDG_BIN_DIR` - 符合 XDG 基礎目錄規範的路徑 -3. `$HOME/bin` - 標準使用者執行檔目錄 (若存在或可建立) -4. `$HOME/.opencode/bin` - 預設備用路徑 - -```bash -# 範例 -OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash -XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash -``` - -### Agents - -OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。 - -- **build** - 預設模式,具備完整權限的 Agent,適用於開發工作。 -- **plan** - 唯讀模式,適用於程式碼分析與探索。 - - 預設禁止修改檔案。 - - 執行 bash 指令前會詢問權限。 - - 非常適合用來探索陌生的程式碼庫或規劃變更。 - -此外,OpenCode 還包含一個 **general** 子 Agent,用於處理複雜搜尋與多步驟任務。此 Agent 供系統內部使用,亦可透過在訊息中輸入 `@general` 來呼叫。 - -了解更多關於 [Agents](https://opencode.ai/docs/agents) 的資訊。 - -### 線上文件 - -關於如何設定 OpenCode 的詳細資訊,請參閱我們的 [**官方文件**](https://opencode.ai/docs)。 - -### 參與貢獻 - -如果您有興趣參與 OpenCode 的開發,請在提交 Pull Request 前先閱讀我們的 [貢獻指南 (Contributing Docs)](./CONTRIBUTING.md)。 - -### 基於 OpenCode 進行開發 - -如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。 - -### 常見問題 (FAQ) - -#### 這跟 Claude Code 有什麼不同? - -在功能面上與 Claude Code 非常相似。以下是關鍵差異: - -- 100% 開源。 -- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。 -- 內建 LSP (語言伺服器協定) 支援。 -- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。 -- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。 - ---- - -**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md deleted file mode 100644 index 31364406ed..0000000000 --- a/RELEASE_NOTES.md +++ /dev/null @@ -1,118 +0,0 @@ -# replan/v1.14.30-fork.1 发布说明 - -**发布日期**:2026-05-01 -**上游基线**:opencode v1.14.30(commit `eb4219304`) -**Fork 分支**:`replan/v1.14.30` -**Tag**:`replan/v1.14.30-fork.1` - -## 概述 - -本 fork 在官方 opencode v1.14.30 基线上重建关键能力,遵循 `docs/replan/` 三阶段规划: - -- **Phase 1(bugfix-merge)**:从历史 fork 移植已验证的稳定性补丁; -- **Phase 2-3(hook 系统重建)**:1:1 兼容 Claude Code 的 8 类 hook 事件(fork 不实现 CC 的 `Notification`,权限提示走内部 bus); -- **Phase 4(github-proxy + TUI quota)**:内网 Copilot 代理 provider + 配额状态栏。 - -## 关键变更(按提交时序) - -### 稳定性补丁(Phase 1) - -- `7790a4b94` **修复** MCP 调用超时与孤儿 `tool_use` 自愈:避免单个 MCP 工具卡死阻塞会话;自动配对游离的 `tool_use` 请求与 `tool_result` 响应。 -- `1908fbf1d` **修复** `question` 工具校验失败时的报错可读性:`formatValidationError` 在 Effect Schema 校验失败时输出带路径、字段提示与正确示例的引导信息,模型可一次自纠。 -- `5a63eaef7` **修复** 三项稳定性问题:会话进程崩溃恢复 / Anthropic thinking-block 触发 400 / `auth.metadata` 字段在序列化中丢失。 - -### Hook 系统(Phase 2-3) - -- `30a5f7dbc` **功能** Phase 3-Step1:落地 Claude Code 兼容 hook 骨架(事件分发器、`SettingsHook.Service`、配置 schema)。 -- `85609c5b9` **功能** Phase 3-Step2:完成 8 类事件 1:1 兼容 — `PreToolUse` / `PostToolUse` / `UserPromptSubmit` / `Stop` / `SubagentStop` / `PreCompact` / `SessionStart` / `SessionEnd`(fork 删除 CC 的 `Notification`,由 `Permission.Service` + 内部 bus 兜底)。 -- `5bdf76454` **修复** `SettingsHook.Service` 在 `ToolRegistry` 与测试 `defaultLayer` 中的 Layer 注入缺口(避免 `R = SettingsHook.Service` 残留在公共 API 上)。 -- `d3b2e1868` **测试** `prompt.test.ts` 接入 `SettingsHook.defaultLayer` 并完成 bug 收敛盘点。 -- `b007682f0` **维护** 归档 hook 重建期间的架构决策与典型错误到 `.memory/`。 - -### github-proxy + TUI quota(Phase 4) - -- `f32284cf8` **功能** Phase 4 落地: - - 新增 `github-proxy` provider,支持通过内网代理转发到 GitHub Copilot; - - Claude 系模型路由到 `@ai-sdk/anthropic /v1/messages`,其他模型走 `@ai-sdk/github-copilot /chat/completions`; - - fetch 拦截器自动注入 `x-initiator`、`Copilot-Vision-Request`、`Authorization Bearer`; - - `auth.json` 认证流支持 `proxyUrl + apiKey` 两步交互; - - 内置 TUI 插件 `SessionQuota`:60s 轮询 `/copilot/quota` 或 `/copilot_internal/user`,渲染到 `session_prompt_right` 槽位; - - `packages/plugin/src/index.ts` 的 `AuthHook.methods` `type:"api" authorize` 返回类型补 `metadata?: Record` 字段。 -- `3ca3791e2` **测试** 修复 Phase 4 基线测试 5 处环境/陈旧失败(registry 注入 + root chmod 跳过 + Effect Schema 期望串更新)。 -- `649653ecf` **测试** 新增 `github-proxy` 单元测试 11 例,覆盖路由 / fetch 拦截 / authorize 全路径。 - -### 文档与素材 - -- `1aa8f7060` **维护** 保存 fork 关键素材到 `.upstream-merge/reference/`,便于后续上游对照。 -- `72686899b` **文档** `docs/replan/` 三阶段重新规划。 - -## 验收基线 - -- `bun test` 全量:**2230 pass / 20 skip / 2 todo / 0 fail**(182 文件) -- `bun turbo typecheck`:**13 包全绿** -- 子仓 `replan/v1.14.30` HEAD:`649653ecf` -- 父仓 `main` HEAD:`4c55678f` - -## 与上游 opencode 的差异说明 - -- 默认与官方 opencode 上游断开,不主动追踪、合并或 cherry-pick; -- 仅在用户明确要求新模型 / 严重 BUG / 协议兼容性排查时才进行只读上游探查; -- 项目特定 skill 全部位于 `.opencode/skills/`,不污染公共模板。 - -## 已知局限 - -- 9 个 hook 事件中目前只有 `UserPromptSubmit` 有独立的集成测试覆盖(`test/session/prompt.test.ts`),其余 8 个事件未来会逐步补齐独立单测; -- `SessionQuota` TUI 插件依赖 `auth.json` 中存在 `github-proxy` 或 `github-copilot` 凭据,未配置时静默不渲染。 - -## 升级建议 - -直接拉取 tag: - -```bash -git fetch origin -git checkout replan/v1.14.30-fork.1 -``` - -或在父仓库根目录拉取全部子项目快照: - -```bash -git pull origin main -``` - -## Hook 协议补强(阶段 5) - -- **SessionStart additionalContexts 真兑现**:hook 在 SessionStart 返回的 `additionalContext` 现在真正注入到首轮 user message(之前 silent drop),封装为 `...`。 -- **continue=false 真短路**:hook 返回 `{continue: false}` 现在真正中断后续 hooks 链 + 4 个调用点消费(PreToolUse / PostToolUse / UserPromptSubmit / PreCompact)。 -- **suppressOutput**:fork 默认不渲染 hook stdout 到 UI,schema 接受字段但运行时 no-op(兼容 CC 协议)。 -- **Session-scoped hook 动态注入(fork 扩展)**:新 `SessionHooks` API 支持运行时添加 session-scoped hooks(`once:true` 自动清理);`Stop` 事件在 sub-agent 上下文自动翻译为 `SubagentStop`(仅影响 session-hook 查找,上层 dispatcher 语义不变)。 - -阶段 5 全量回归:**2361 PASS / 0 回归**,净增 9 测试。 - -## Hook 协议鲁棒性(阶段 6) - -- **`hasHookForEvent` O(1) 短路**:`trigger` 入口在 settings 链与 SessionHooks 都没有当前事件条目时跳过 envelope 构建 / matcher 拼接 / regex 匹配热路径,直接返回空 result。无 hook 配置时几乎零开销。 -- **`allowUntrusted` schema 字段(接入点预留)**:Settings 接受 `allowUntrusted?: boolean`,trigger 内留 TODO 注释块锁定未来 workspace-trust 系统接入点(fork 当前无 trust 基础设施,仅 schema 兼容;trust gate 失败必须 silent allow,永不 throw/deny)。 -- **plugin `__sourceDir` 缺失自动 silent allow**:command handler 在 `spawn` 前对 `entry.__sourceDir` 做 `existsSync` 预检;插件目录已被 GC(卸载 / repo 清理)时返回 `exitCode: 0` + 空 stdout,而非让 shell 把缺失脚本转成 exit 2 误判为 block。 - -阶段 6 全量回归:**2365 PASS / 0 回归**,净增 3 测试。 - -## Hook 协议 CC 兼容性总结(阶段 7 验证) - -通过 CC 官方文档 verbatim 示例 e2e 验证(8/8 PASS),fork 是 CC hook 协议的**严格超集 + 2 项行为差异**: - -**6 项严格超集**(CC 配置在 fork 全部通用,反向不一定): -1. case-insensitive matcher:fork 用 `i` flag,CC 配置精确匹配仍命中 -2. 6 层 settings:CC 三层(user / project / local)完全保留 + fork 追加 `.opencode/` 平行层,覆盖顺序不冲突 -3. hasHookForEvent O(1) 短路:纯优化,外部不可观察 -4. SessionHooks 动态注入:与 file 链平等 concat 不互斥,CC 仅有 file 链 fork 多了 session 链 -5. `__sourceDir` GC 竞态保护:CC 无此字段所以路径走不到;fork command hook 的 plugin 目录被 GC 时 silent allow -6. continue=false 双层 break:fork 此前失效,现补齐 CC spec 协议 - -**1 项 schema-only**(向前兼容): -- `allowUntrusted` 字段接受不会 fail-parse;运行时占位待 fork trust 系统接入(WP-6B TODO) - -**2 项明确行为差异**(迁移注意): -- **`suppressOutput` 默认翻转**:CC 默认 `false`(渲染 hook stdout 到 UI),fork 默认 `true`(不渲染)。SessionStart/UserPromptSubmit 直接通过 stdout 注入文本在 fork 不会自动渲染,应改用 `hookSpecificOutput.additionalContext`(fork 阶段 5 已实现真注入) -- **`Notification` event 显式不支持**:CC 通过 hook 推送 permission/idle 通知,fork 走 `Permission.Service` + 内部 bus,配置 `Notification` hook 在 fork 不生效 - -**e2e 验证基线**:hook 63 PASS / 全量 2365 PASS / typecheck PASS(仅 1 PRE-EXISTING truncation fail 与 hook 无关) From c29c3b93f6f2a55f1938c94e68037109e427e7b1 Mon Sep 17 00:00:00 2001 From: lex Date: Tue, 12 May 2026 08:40:05 +0800 Subject: [PATCH 23/25] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20github-proxy=20provider=20=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 src/plugin/github-proxy/ 整个目录(proxy.ts) - 从 plugin/index.ts 注销 GithubProxyAuthPlugin 注册 - 从 provider schema/provider/transform 中剥离 github-proxy 分支 - quota-fetch.ts 收窄 QuotaAuth.provider 为 github-copilot,保留 copilot 计费 TUI 链路完整 - 清理对应测试文件,保留 copilot quota 17 个用例全部通过 --- .../feature-plugins/session/quota-fetch.ts | 39 +- .../opencode/src/plugin/github-proxy/proxy.ts | 393 --------------- packages/opencode/src/plugin/index.ts | 2 - packages/opencode/src/provider/provider.ts | 11 +- packages/opencode/src/provider/schema.ts | 1 - packages/opencode/src/provider/transform.ts | 2 +- .../session/quota-fetch.test.ts | 97 +--- .../test/plugin/github-proxy/proxy.test.ts | 450 ------------------ 8 files changed, 12 insertions(+), 983 deletions(-) delete mode 100644 packages/opencode/src/plugin/github-proxy/proxy.ts delete mode 100644 packages/opencode/test/plugin/github-proxy/proxy.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts index 018d7a2033..4b33748fa2 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts @@ -1,4 +1,4 @@ -// quota-fetch.ts — quota.tsx 的纯逻辑分支:读取 auth.json、解析两种上游响应、HTTP 取数。 +// quota-fetch.ts — quota.tsx 的纯逻辑分支:读取 auth.json、解析上游响应、HTTP 取数。 // 抽离动机:与 Solid/opentui 渲染解耦,便于在 Bun test 中直接覆盖(避免拉入原生 opentui binding)。 import path from "node:path" import { readFile } from "node:fs/promises" @@ -6,11 +6,11 @@ import { readFile } from "node:fs/promises" export interface QuotaAuth { quotaUrl: string token: string - provider: "github-proxy" | "github-copilot" + provider: "github-copilot" } export interface QuotaInfo { - /** 已用量(consumed count)。proxy 直接读响应字段;copilot 由 entitlement-remaining 换算 */ + /** 已用量(consumed count)。由 entitlement-remaining 换算 */ used: number entitlement: number accounts_active: number @@ -19,7 +19,7 @@ export interface QuotaInfo { /** * 按 providerID 精确读取对应的 QuotaAuth。 - * providerID 以 "github-proxy" 或 "github-copilot" 开头均可(支持子变体)。 + * providerID 以 "github-copilot" 开头即可(支持子变体)。 */ export async function readQuotaAuthForProvider( stateDir: string, @@ -29,22 +29,6 @@ export async function readQuotaAuthForProvider( const text = await readFile(path.join(stateDir, "auth.json"), "utf-8") const data = JSON.parse(text) as Record - if (providerID.startsWith("github-proxy")) { - const entry = data["github-proxy"] as Record | undefined - if (entry?.type === "api") { - const meta = entry.metadata as Record | undefined - const proxyUrl = meta?.proxyUrl - const apiKey = entry.key as string | undefined - if (proxyUrl && apiKey) - return { - quotaUrl: `${proxyUrl.replace(/\/+$/, "")}/copilot/quota`, - token: apiKey, - provider: "github-proxy", - } - } - return null - } - if (providerID.startsWith("github-copilot")) { const entry = data["github-copilot"] as Record | undefined if (entry?.type === "oauth") { @@ -84,19 +68,6 @@ export function parseCopilotQuota(data: Record): QuotaInfo | nu return { used: entitlement - actualRemaining, entitlement, accounts_active: 0, accounts_total: 0 } } -/** 从 github-proxy /copilot/quota 端点解析 quota */ -export function parseProxyQuota(data: Record): QuotaInfo | null { - const remaining = typeof data.remaining === "number" ? data.remaining : null - const entitlement = typeof data.entitlement === "number" ? data.entitlement : null - if (remaining === null || entitlement === null) return null - return { - used: entitlement - remaining, - entitlement, - accounts_active: typeof data.accounts_active === "number" ? data.accounts_active : 0, - accounts_total: typeof data.accounts_total === "number" ? data.accounts_total : 0, - } -} - export async function fetchQuota(auth: QuotaAuth): Promise { try { const resp = await fetch(auth.quotaUrl, { @@ -105,7 +76,7 @@ export async function fetchQuota(auth: QuotaAuth): Promise { }) if (!resp.ok) return null const data = (await resp.json()) as Record - return auth.provider === "github-copilot" ? parseCopilotQuota(data) : parseProxyQuota(data) + return parseCopilotQuota(data) } catch { return null } diff --git a/packages/opencode/src/plugin/github-proxy/proxy.ts b/packages/opencode/src/plugin/github-proxy/proxy.ts deleted file mode 100644 index 6a136b418e..0000000000 --- a/packages/opencode/src/plugin/github-proxy/proxy.ts +++ /dev/null @@ -1,393 +0,0 @@ -import type { Hooks, PluginInput } from "@opencode-ai/plugin" -import type { Model } from "@opencode-ai/sdk/v2" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { iife } from "@/util/iife" -import * as Log from "@opencode-ai/core/util/log" -import { CopilotModels } from "../github-copilot/models" -import { MessageV2 } from "@/session/message-v2" - -const log = Log.create({ service: "plugin.github-proxy" }) - -// SDK v1 Auth union 不在 ApiAuth 上暴露 metadata,但运行时 Auth.Api schema 含此字段。 -// 用本地类型桥接,避免依赖内部模块。 -type ApiAuthWithMeta = { type: "api"; key: string; metadata?: Record } - -// 工具调用返图时,opencode 会合成一条 role:"user" 的消息携带 SYNTHETIC_ATTACHMENT_PROMPT -// 把图片喂回模型继续 agent loop。这条消息虽然 role 是 user,语义上是 agent 自发, -// 必须识别出来标记为 agent,否则会被 Copilot 计入用户提示次数误扣。 -// 与 plugin/github-copilot/copilot.ts:imgMsg 保持一致。 -function imgMsg(msg: any): boolean { - if (msg?.role !== "user") return false - const content = msg.content - if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT - if (!Array.isArray(content)) return false - return content.some( - (part: any) => - (part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT, - ) -} - -function fix(model: Model, url: string): Model { - // 即使 /copilot/models 解析失败走 fallback,Claude 仍必须走 anthropic messages API, - // 否则 thinking block 的 signature 链路会在 @ai-sdk/github-copilot 侧丢失, - // 引发上游 "Invalid signature in thinking block"。 - // TODO(refactor-trigger): 同 github-copilot/copilot.ts:fix() 与 github-copilot/models.ts:build() - // 共 3 处。再增第 4 处或正则失守时,抽 plugin/github-copilot/routing.ts 统一收敛。 - // 背景见 .memory/patterns/opencode-plugin-fallback-signature-preservation-20260421.md - const isClaude = model.api.id.includes("claude") - return { - ...model, - providerID: "github-proxy", - api: { - ...model.api, - url: isClaude ? `${url}/v1` : url, - npm: isClaude ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot", - }, - } -} - -function overrideProviderID(models: Record): Record { - return Object.fromEntries( - Object.entries(models).map(([id, model]) => [id, { ...model, providerID: "github-proxy" }]), - ) -} - -export async function GithubProxyAuthPlugin(input: PluginInput): Promise { - const sdk = input.client - return { - provider: { - id: "github-proxy", - async models(provider, ctx) { - if (ctx.auth?.type !== "api") { - log.warn("models: auth type is not api, skipping proxy model fetch", { - authType: ctx.auth?.type ?? "none", - }) - return provider.models - } - - const auth = ctx.auth as ApiAuthWithMeta - const proxyUrl = auth.metadata?.proxyUrl - if (!proxyUrl) { - log.warn("models: no proxyUrl in auth metadata, skipping proxy model fetch", { - metadataKeys: auth.metadata ? Object.keys(auth.metadata) : [], - }) - return provider.models - } - - const baseURL = `${proxyUrl.replace(/\/+$/, "")}/copilot` - - return CopilotModels.get( - baseURL, - { - Authorization: `Bearer ${auth.key}`, - "User-Agent": `opencode/${InstallationVersion}`, - }, - provider.models, - ) - .then(overrideProviderID) - .catch((error) => { - log.error("failed to fetch models from proxy, falling back to built-in list", { - error, - baseURL, - keyPrefix: auth.key ? auth.key.slice(0, 8) + "..." : "(empty)", - builtinModelCount: Object.keys(provider.models).length, - }) - return Object.fromEntries( - Object.entries(provider.models).map(([id, model]) => [id, fix(model, baseURL)]), - ) - }) - }, - }, - auth: { - provider: "github-proxy", - async loader(getAuth) { - const info = await getAuth() - if (!info || info.type !== "api") { - log.warn("auth loader: getAuth returned non-api auth, skipping proxy fetch override", { - authType: info?.type ?? "none", - }) - return {} - } - - const proxyUrl = (info as ApiAuthWithMeta).metadata?.proxyUrl - if (!proxyUrl) { - log.warn("auth loader: no proxyUrl in auth metadata, skipping proxy fetch override", { - metadataKeys: (info as ApiAuthWithMeta).metadata ? Object.keys((info as ApiAuthWithMeta).metadata!) : [], - }) - return {} - } - - return { - apiKey: "", - // NOTE: 不要在此返回 baseURL。每个 model 在 CopilotModels.build() 中已设置 - // 了正确的 api.url(Claude → .../copilot/v1, 其他 → .../copilot)。 - // 若此处返回 provider 级 baseURL,resolveSDK 会用它覆盖 model 级 url, - // 导致 Claude 模型丢失 /v1 路径,Anthropic SDK 调用 /messages 而非 - // /v1/messages,上游 Copilot 无法正确处理请求。 - async fetch(request: RequestInfo | URL, init?: RequestInit) { - const info = await getAuth() - if (info.type !== "api") return fetch(request, init) - - const proxyUrl = (info as ApiAuthWithMeta).metadata?.proxyUrl - if (!proxyUrl) return fetch(request, init) - - const url = request instanceof URL ? request.href : request.toString() - const parsedBody = (() => { - try { - return typeof init?.body === "string" ? JSON.parse(init.body) : init?.body - } catch (e) { - log.warn("fetch: failed to parse request body", { url, error: String(e) }) - return undefined - } - })() - const { isVision, isAgent } = iife(() => { - const body = parsedBody - if (!body) return { isVision: false, isAgent: false } - - // Completions API - if (body?.messages && url.includes("completions")) { - const last = body.messages[body.messages.length - 1] - return { - isVision: body.messages.some( - (msg: any) => - Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), - ), - isAgent: last?.role !== "user" || imgMsg(last), - } - } - - // Responses API - if (body?.input) { - const last = body.input[body.input.length - 1] - return { - isVision: body.input.some( - (item: any) => - Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), - ), - isAgent: last?.role !== "user" || imgMsg(last), - } - } - - // Messages API - if (body?.messages) { - const last = body.messages[body.messages.length - 1] - const hasNonToolCalls = - Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result") - return { - isVision: body.messages.some( - (item: any) => - Array.isArray(item?.content) && - item.content.some( - (part: any) => - part?.type === "image" || - (part?.type === "tool_result" && - Array.isArray(part?.content) && - part.content.some((nested: any) => nested?.type === "image")), - ), - ), - isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last), - } - } - return { isVision: false, isAgent: false } - }) - - const headers: Record = { - "x-initiator": isAgent ? "agent" : "user", - ...(init?.headers as Record), - "User-Agent": `opencode/${InstallationVersion}`, - Authorization: `Bearer ${info.key}`, - "Openai-Intent": "conversation-edits", - } - - if (isVision) { - headers["Copilot-Vision-Request"] = "true" - } - - delete headers["x-api-key"] - delete headers["authorization"] - - // DIAGNOSTIC: On error responses from the /v1/messages path, dump the - // request shape to diagnose the intermittent - // "This model does not support assistant message prefill" error. - // Only fires on non-2xx to avoid log spam on every request. - // See: github-proxy prefill investigation (2026-05-07). - if (url.includes("/v1/messages")) { - return fetch(request, { ...init, headers }).then((response) => { - if (response.ok) return response - const body = parsedBody - if (!body?.messages || !Array.isArray(body.messages)) return response - const roles = body.messages.map((m: any) => m?.role) - const last = body.messages[body.messages.length - 1] - const lastContentShape = (() => { - if (typeof last?.content === "string") return { kind: "string", length: last.content.length } - if (Array.isArray(last?.content)) { - return { - kind: "array", - partTypes: last.content.map((p: any) => p?.type), - partCount: last.content.length, - } - } - return { kind: typeof last?.content } - })() - log.error("messages-api error response — request dump", { - url, - status: response.status, - model: body?.model, - messageCount: body.messages.length, - roles, - lastRole: last?.role, - lastContentShape, - hasToolChoice: body?.tool_choice !== undefined, - toolChoice: body?.tool_choice, - toolCount: Array.isArray(body?.tools) ? body.tools.length : 0, - }) - return response - }) - } - - return fetch(request, { - ...init, - headers, - }) - }, - } - }, - methods: [ - { - type: "api" as const, - label: "Connect via Proxy", - prompts: [ - { - type: "text", - key: "proxyUrl", - message: "Enter proxy server URL", - placeholder: "http://192.168.33.110:8000", - validate: (value) => { - if (!value) return "Proxy URL is required" - try { - new URL(value) - return undefined - } catch { - return "Please enter a valid URL (e.g., http://192.168.33.110:8000)" - } - }, - }, - { - type: "text", - key: "apiKey", - message: "Enter proxy API key", - placeholder: "sk-...", - validate: (value) => { - if (!value) return "API key is required" - return undefined - }, - }, - ], - async authorize(inputs = {}) { - const proxyUrl = inputs.proxyUrl - const apiKey = inputs.apiKey - - if (!proxyUrl || !apiKey) { - return { type: "failed" as const } - } - - try { - const response = await fetch(`${proxyUrl.replace(/\/+$/, "")}/copilot/auth`, { - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!response.ok) { - return { type: "failed" as const } - } - - return { - type: "success" as const, - key: apiKey, - metadata: { - proxyUrl: proxyUrl.replace(/\/+$/, ""), - }, - } - } catch { - return { type: "failed" as const } - } - }, - }, - ], - }, - "chat.params": async (incoming, output) => { - if (!incoming.model.providerID.includes("github-proxy")) return - if (incoming.model.api.id.includes("gpt")) { - output.maxOutputTokens = undefined - } - - // GitHub Copilot 的 /v1/messages shim 拒绝 GA 字段 `eager_input_streaming` - // ("Extra inputs are not permitted")。关闭 @ai-sdk/anthropic 的默认行为, - // 否则经 proxy 走 Copilot 上游的 Claude 模型会请求失败。 - if (incoming.model.api.npm === "@ai-sdk/anthropic") { - output.options.toolStreaming = false - } - }, - "chat.headers": async (incoming, output) => { - if (!incoming.model.providerID.includes("github-proxy")) return - - if (incoming.model.api.npm === "@ai-sdk/anthropic") { - 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((e) => { - log.warn("chat.headers: failed to fetch session message parts, x-initiator may be incorrect", { - sessionID: incoming.message.sessionID, - messageID: incoming.message.id, - error: String(e), - }) - return undefined - }) - - if ( - parts?.data.parts?.some( - (part) => - part.type === "compaction" || - // auto-compaction 通过一条合成 user text part 续聊。把这条带标记的续聊 - // 视作 agent 自发,避免被计为额外的用户提示扣次。 - (part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true), - ) - ) { - output.headers["x-initiator"] = "agent" - return - } - - const session = await sdk.session - .get({ - path: { - id: incoming.sessionID, - }, - query: { - directory: input.directory, - }, - throwOnError: true, - }) - .catch((e) => { - log.warn("chat.headers: failed to fetch session info, parentID check skipped", { - sessionID: incoming.sessionID, - error: String(e), - }) - return undefined - }) - if (!session || !session.data.parentID) return - output.headers["x-initiator"] = "agent" - }, - } -} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 637480515b..e80cbc30de 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -14,7 +14,6 @@ import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" -import { GithubProxyAuthPlugin } from "./github-proxy/proxy" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" @@ -58,7 +57,6 @@ export class Service extends Context.Service()("@opencode/Pl const INTERNAL_PLUGINS: PluginInstance[] = [ CodexAuthPlugin, CopilotAuthPlugin, - GithubProxyAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin, CloudflareWorkersAuthPlugin, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 578c2338e0..5cb5a520f5 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -205,15 +205,6 @@ function custom(dep: CustomDep): Record { }, options: {}, }), - "github-proxy": () => - Effect.succeed({ - autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) - }, - options: {}, - }), azure: Effect.fnUntraced(function* (provider: Info) { const env = yield* dep.env() const resource = iife(() => { @@ -1634,7 +1625,7 @@ const layer: Layer.Layer< if (providerID.startsWith("opencode")) { priority = ["gpt-5-nano"] } - if (providerID.startsWith("github-copilot") || providerID.startsWith("github-proxy")) { + if (providerID.startsWith("github-copilot")) { priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] } for (const item of priority) { diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 25283b31db..ea3cac3424 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -17,7 +17,6 @@ export const ProviderID = providerIdSchema.pipe( google: schema.make("google"), googleVertex: schema.make("google-vertex"), githubCopilot: schema.make("github-copilot"), - githubProxy: schema.make("github-proxy"), amazonBedrock: schema.make("amazon-bedrock"), azure: schema.make("azure"), openrouter: schema.make("openrouter"), diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 04b9653f29..fb92028bb2 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -846,7 +846,7 @@ export function variants(model: Provider.Model): Record { await rm(dir, { recursive: true, force: true }) }) - test("github-proxy → 去尾斜杠后拼 /copilot/quota", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ - "github-proxy": { - type: "api", - key: "sk-test", - metadata: { proxyUrl: "http://internal:8000///" }, - }, - }), - ) - expect(await readQuotaAuthForProvider(dir, "github-proxy")).toEqual({ - quotaUrl: "http://internal:8000/copilot/quota", - token: "sk-test", - provider: "github-proxy", - }) - }) - test("github-copilot → 返回 copilot auth", async () => { await writeFile( path.join(dir, "auth.json"), @@ -74,18 +55,6 @@ describe("readQuotaAuthForProvider", () => { expect(auth?.token).toBe("gho_sub") }) - test("github-proxy 子变体(如 github-proxy-v2)→ 匹配 proxy 条目", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ - "github-proxy": { type: "api", key: "sk-v2", metadata: { proxyUrl: "http://p:8000" } }, - }), - ) - const auth = await readQuotaAuthForProvider(dir, "github-proxy-v2") - expect(auth?.provider).toBe("github-proxy") - expect(auth?.token).toBe("sk-v2") - }) - test("github-copilot enterpriseUrl → 注入 api. 子域", async () => { await writeFile( path.join(dir, "auth.json"), @@ -101,14 +70,6 @@ describe("readQuotaAuthForProvider", () => { expect(auth?.quotaUrl).toBe("https://api.ghes.corp.io/copilot_internal/user") }) - test("github-proxy 缺 proxyUrl → null", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ "github-proxy": { type: "api", key: "sk-test" } }), - ) - expect(await readQuotaAuthForProvider(dir, "github-proxy")).toBeNull() - }) - test("github-copilot 缺 refresh → null", async () => { await writeFile( path.join(dir, "auth.json"), @@ -120,13 +81,13 @@ describe("readQuotaAuthForProvider", () => { test("未知 providerID → null", async () => { await writeFile( path.join(dir, "auth.json"), - JSON.stringify({ "github-proxy": { type: "api", key: "sk", metadata: { proxyUrl: "http://p:8000" } } }), + JSON.stringify({ "github-copilot": { type: "oauth", refresh: "gho" } }), ) expect(await readQuotaAuthForProvider(dir, "anthropic")).toBeNull() }) test("auth.json 不存在 → null", async () => { - expect(await readQuotaAuthForProvider(dir, "github-proxy")).toBeNull() + expect(await readQuotaAuthForProvider(dir, "github-copilot")).toBeNull() }) test("auth.json 非法 JSON → null", async () => { @@ -135,37 +96,6 @@ describe("readQuotaAuthForProvider", () => { }) }) -describe("parseProxyQuota", () => { - test("完整字段解析 → used = entitlement - remaining", () => { - expect( - parseProxyQuota({ remaining: 30, entitlement: 100, accounts_active: 2, accounts_total: 5 }), - ).toEqual({ used: 70, entitlement: 100, accounts_active: 2, accounts_total: 5 }) - }) - - test("remaining 0 时 used = entitlement(全部用完)", () => { - expect(parseProxyQuota({ remaining: 0, entitlement: 50 })).toEqual({ - used: 50, - entitlement: 50, - accounts_active: 0, - accounts_total: 0, - }) - }) - - test("accounts 字段缺失时填 0", () => { - expect(parseProxyQuota({ remaining: 10, entitlement: 50 })).toEqual({ - used: 40, - entitlement: 50, - accounts_active: 0, - accounts_total: 0, - }) - }) - - test("缺 remaining/entitlement → null", () => { - expect(parseProxyQuota({ remaining: 10 })).toBeNull() - expect(parseProxyQuota({})).toBeNull() - }) -}) - describe("parseCopilotQuota", () => { test("snake_case 字段解析 → used = entitlement - remaining", () => { expect( @@ -209,29 +139,12 @@ describe("parseCopilotQuota", () => { }) describe("fetchQuota", () => { - const proxyAuth: QuotaAuth = { - quotaUrl: "http://internal:8000/copilot/quota", - token: "sk-test", - provider: "github-proxy", - } const copilotAuth: QuotaAuth = { quotaUrl: "https://api.github.com/copilot_internal/user", token: "gho_test", provider: "github-copilot", } - test("github-proxy 200 → parseProxyQuota,header 注入 Bearer", async () => { - let captured: { url: string; headers: Record } | null = null - globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => { - captured = { url: String(url), headers: (init?.headers ?? {}) as Record } - return new Response(JSON.stringify({ remaining: 20, entitlement: 100 }), { status: 200 }) - }) as unknown as typeof fetch - const q = await fetchQuota(proxyAuth) - expect(q).toEqual({ used: 80, entitlement: 100, accounts_active: 0, accounts_total: 0 }) - expect(captured!.url).toBe("http://internal:8000/copilot/quota") - expect(captured!.headers.Authorization).toBe("Bearer sk-test") - }) - test("github-copilot 200 → parseCopilotQuota,header 注入 Bearer", async () => { let captured: Record = {} globalThis.fetch = mock(async (_url: string | URL, init?: RequestInit) => { @@ -250,7 +163,7 @@ describe("fetchQuota", () => { test("非 200 响应 → null", async () => { globalThis.fetch = mock(async () => new Response("nope", { status: 503 })) as unknown as typeof fetch - expect(await fetchQuota(proxyAuth)).toBeNull() + expect(await fetchQuota(copilotAuth)).toBeNull() }) test("fetch 抛错 → null", async () => { @@ -262,6 +175,6 @@ describe("fetchQuota", () => { test("响应 JSON 字段不完整 → null", async () => { globalThis.fetch = mock(async () => new Response(JSON.stringify({}), { status: 200 })) as unknown as typeof fetch - expect(await fetchQuota(proxyAuth)).toBeNull() + expect(await fetchQuota(copilotAuth)).toBeNull() }) }) diff --git a/packages/opencode/test/plugin/github-proxy/proxy.test.ts b/packages/opencode/test/plugin/github-proxy/proxy.test.ts deleted file mode 100644 index 9082fc5900..0000000000 --- a/packages/opencode/test/plugin/github-proxy/proxy.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { afterEach, describe, expect, mock, test } from "bun:test" -import { GithubProxyAuthPlugin } from "@/plugin/github-proxy/proxy" -import { MessageV2 } from "@/session/message-v2" - -const originalFetch = globalThis.fetch - -afterEach(() => { - globalThis.fetch = originalFetch -}) - -const baseInput = { - client: {} as never, - project: {} as never, - directory: "", - worktree: "", - experimental_workspace: { register() {} }, - serverUrl: new URL("https://example.com"), - $: {} as never, -} as const - -function makeProvider(models: Record) { - return { id: "github-proxy", models } as never -} - -describe("github-proxy / models()", () => { - test("returns provider.models unchanged when auth has no proxyUrl metadata", async () => { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const original = { - foo: { id: "foo", providerID: "anything", api: { id: "foo", url: "u", npm: "n" } }, - } as never - const models = await hooks.provider!.models!(makeProvider(original), { - auth: { type: "api", key: "k" } as never, - }) - expect(models).toBe(original) - }) - - test("falls back to fix() when /copilot/models fetch rejects — claude rerouted to anthropic /v1", async () => { - globalThis.fetch = mock(() => Promise.reject(new Error("timeout"))) as unknown as typeof fetch - - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const original = { - claude: { - id: "claude", - providerID: "anything", - api: { id: "claude-opus-4.7", url: "old", npm: "@ai-sdk/github-copilot" }, - }, - gpt: { - id: "gpt", - providerID: "anything", - api: { id: "gpt-4o", url: "old", npm: "@ai-sdk/openai-compatible" }, - }, - } - - const models = await hooks.provider!.models!(makeProvider(original), { - auth: { type: "api", key: "k", metadata: { proxyUrl: "http://proxy.local/" } } as never, - }) - - // All models get providerID rewritten - expect(models.claude.providerID).toBe("github-proxy") - expect(models.gpt.providerID).toBe("github-proxy") - // Claude → anthropic + /v1 path - expect(models.claude.api.npm).toBe("@ai-sdk/anthropic") - expect(models.claude.api.url).toBe("http://proxy.local/copilot/v1") - // Non-claude → github-copilot, no /v1 - expect(models.gpt.api.npm).toBe("@ai-sdk/github-copilot") - expect(models.gpt.api.url).toBe("http://proxy.local/copilot") - }) -}) - -describe("github-proxy / loader fetch interception", () => { - async function makeFetch(metadata?: Record) { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const getAuth = async () => ({ type: "api", key: "secret-key", metadata }) as any - const result = await hooks.auth!.loader!(getAuth as never, {} as never) - return result.fetch as undefined | typeof fetch - } - - test("loader returns empty object when proxyUrl metadata missing", async () => { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const out = await hooks.auth!.loader!(async () => ({ type: "api", key: "k" }) as never, {} as never) - expect(out).toEqual({}) - }) - - test("injects x-initiator=agent for assistant-tail Messages API turn", async () => { - let captured: { url: string; init?: RequestInit } | null = null - globalThis.fetch = mock((url: any, init?: RequestInit) => { - captured = { url: typeof url === "string" ? url : url.toString(), init } - return Promise.resolve(new Response("{}", { status: 200 })) - }) as unknown as typeof fetch - - const f = await makeFetch({ proxyUrl: "http://p.local" }) - await f!("https://p.local/copilot/v1/messages", { - method: "POST", - body: JSON.stringify({ - messages: [ - { role: "user", content: [{ type: "text", text: "hi" }] }, - { role: "assistant", content: [{ type: "text", text: "ok" }] }, - ], - }), - } as any) - - const headers = captured!.init!.headers as Record - expect(headers["x-initiator"]).toBe("agent") - expect(headers["Authorization"]).toBe("Bearer secret-key") - expect(headers["Openai-Intent"]).toBe("conversation-edits") - expect(headers["Copilot-Vision-Request"]).toBeUndefined() - }) - - test("injects x-initiator=user when last user turn is plain (Completions API)", async () => { - let captured: { url: string; init?: RequestInit } | null = null - globalThis.fetch = mock((url: any, init?: RequestInit) => { - captured = { url: typeof url === "string" ? url : url.toString(), init } - return Promise.resolve(new Response("{}", { status: 200 })) - }) as unknown as typeof fetch - - const f = await makeFetch({ proxyUrl: "http://p.local" }) - await f!("https://p.local/copilot/v1/chat/completions", { - method: "POST", - body: JSON.stringify({ - messages: [{ role: "user", content: "hello" }], - }), - } as any) - - expect((captured!.init!.headers as any)["x-initiator"]).toBe("user") - }) - - test("sets Copilot-Vision-Request when message contains image_url (Completions API)", async () => { - let captured: { url: string; init?: RequestInit } | null = null - globalThis.fetch = mock((url: any, init?: RequestInit) => { - captured = { url: typeof url === "string" ? url : url.toString(), init } - return Promise.resolve(new Response("{}", { status: 200 })) - }) as unknown as typeof fetch - - const f = await makeFetch({ proxyUrl: "http://p.local" }) - await f!("https://p.local/copilot/v1/chat/completions", { - method: "POST", - body: JSON.stringify({ - messages: [ - { - role: "user", - content: [ - { type: "text", text: "what is this?" }, - { type: "image_url", image_url: { url: "data:image/png;base64,iVBORw" } }, - ], - }, - ], - }), - } as any) - - expect((captured!.init!.headers as any)["Copilot-Vision-Request"]).toBe("true") - }) - - test("strips x-api-key and lowercase authorization to enforce Bearer key", async () => { - let captured: { init?: RequestInit } | null = null - globalThis.fetch = mock((_url: any, init?: RequestInit) => { - captured = { init } - return Promise.resolve(new Response("{}", { status: 200 })) - }) as unknown as typeof fetch - - const f = await makeFetch({ proxyUrl: "http://p.local" }) - await f!("https://p.local/copilot/v1/messages", { - method: "POST", - headers: { - "x-api-key": "should-be-removed", - authorization: "should-be-removed-too", - }, - body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), - } as any) - - const headers = captured!.init!.headers as Record - expect(headers["x-api-key"]).toBeUndefined() - expect(headers["authorization"]).toBeUndefined() - // The capital-A Authorization from our injection survives - expect(headers["Authorization"]).toBe("Bearer secret-key") - }) - - // 计费规则:GitHub Copilot 按用户 prompt 提交计费,agent 自发请求不扣。 - // 工具返图时 opencode 合成一条 role:"user" + SYNTHETIC_ATTACHMENT_PROMPT 的消息把图喂回模型, - // 这本质是 agent loop 的一步,必须标 agent 否则会被多扣 1 次。 - describe("synthetic attachment prompt → agent (billing correctness)", () => { - async function captureHeaders(url: string, body: unknown) { - let captured: { init?: RequestInit } | null = null - globalThis.fetch = mock((_u: any, init?: RequestInit) => { - captured = { init } - return Promise.resolve(new Response("{}", { status: 200 })) - }) as unknown as typeof fetch - const f = await makeFetch({ proxyUrl: "http://p.local" }) - await f!(url, { method: "POST", body: JSON.stringify(body) } as any) - return captured!.init!.headers as Record - } - - test("Completions API: synthetic user-role attachment msg → agent", async () => { - const headers = await captureHeaders("https://p.local/copilot/v1/chat/completions", { - messages: [ - { role: "user", content: "show me the screenshot" }, - { role: "assistant", content: "calling tool..." }, - { role: "tool", content: "tool result" }, - { - role: "user", - content: [ - { type: "text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT }, - { type: "image_url", image_url: { url: "data:image/png;base64,xxx" } }, - ], - }, - ], - }) - expect(headers["x-initiator"]).toBe("agent") - // 同时确认 vision header 仍被注入(合成附件本身就带图) - expect(headers["Copilot-Vision-Request"]).toBe("true") - }) - - test("Completions API: string-content synthetic prompt → agent", async () => { - const headers = await captureHeaders("https://p.local/copilot/v1/chat/completions", { - messages: [ - { role: "user", content: "go" }, - { role: "user", content: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT }, - ], - }) - expect(headers["x-initiator"]).toBe("agent") - }) - - test("Responses API: synthetic input_text attachment item → agent (GPT-5 path)", async () => { - const headers = await captureHeaders("https://p.local/copilot/responses", { - input: [ - { role: "user", content: [{ type: "input_text", text: "look" }] }, - { - role: "user", - content: [ - { type: "input_text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT }, - { type: "input_image", image_url: "data:image/png;base64,yyy" }, - ], - }, - ], - }) - expect(headers["x-initiator"]).toBe("agent") - }) - - test("Messages API: synthetic user msg with text+image → agent (Claude path)", async () => { - const headers = await captureHeaders("https://p.local/copilot/v1/messages", { - messages: [ - { role: "user", content: [{ type: "text", text: "go" }] }, - { role: "assistant", content: [{ type: "text", text: "calling..." }] }, - { - role: "user", - content: [ - { type: "text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT }, - { type: "image", source: { type: "base64", media_type: "image/png", data: "zzz" } }, - ], - }, - ], - }) - expect(headers["x-initiator"]).toBe("agent") - }) - - test("Completions API: real user prompt with image is NOT mistaken as synthetic", async () => { - const headers = await captureHeaders("https://p.local/copilot/v1/chat/completions", { - messages: [ - { - role: "user", - content: [ - { type: "text", text: "what is in this image?" }, - { type: "image_url", image_url: { url: "data:image/png;base64,real" } }, - ], - }, - ], - }) - expect(headers["x-initiator"]).toBe("user") - expect(headers["Copilot-Vision-Request"]).toBe("true") - }) - }) -}) - -// chat.params 必须为 anthropic 模型关闭 toolStreaming。 -// 否则 @ai-sdk/anthropic 会注入 GA 字段 eager_input_streaming, -// 被 Copilot /v1/messages shim 拒绝("Extra inputs are not permitted"),导致 Claude 不可用。 -describe("github-proxy / chat.params", () => { - test("disables toolStreaming for anthropic models", async () => { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const output: any = { options: {} } - await hooks["chat.params"]!( - { - model: { providerID: "github-proxy", api: { id: "claude-opus-4.7", npm: "@ai-sdk/anthropic" } }, - } as any, - output, - ) - expect(output.options.toolStreaming).toBe(false) - }) - - test("does NOT disable toolStreaming for non-anthropic models", async () => { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const output: any = { options: {} } - await hooks["chat.params"]!( - { - model: { providerID: "github-proxy", api: { id: "gpt-5", npm: "@ai-sdk/github-copilot" } }, - } as any, - output, - ) - expect(output.options.toolStreaming).toBeUndefined() - }) - - test("clears maxOutputTokens for gpt models", async () => { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const output: any = { options: {}, maxOutputTokens: 4096 } - await hooks["chat.params"]!( - { - model: { providerID: "github-proxy", api: { id: "gpt-5-mini", npm: "@ai-sdk/github-copilot" } }, - } as any, - output, - ) - expect(output.maxOutputTokens).toBeUndefined() - }) - - test("ignores non-github-proxy models", async () => { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - const output: any = { options: {}, maxOutputTokens: 4096 } - await hooks["chat.params"]!( - { - model: { providerID: "anthropic", api: { id: "claude-opus-4.7", npm: "@ai-sdk/anthropic" } }, - } as any, - output, - ) - expect(output.options.toolStreaming).toBeUndefined() - expect(output.maxOutputTokens).toBe(4096) - }) -}) - -// chat.headers 必须把 auto-compaction 续聊也识别为 agent。 -// 续聊由一条 type:"text" + synthetic:true + metadata.compaction_continue:true 的合成 part 触发, -// 仅检查 part.type === "compaction" 会漏掉续聊请求 → 误扣 1 次。 -describe("github-proxy / chat.headers compaction detection", () => { - function makeHooksWithSdk(parts: any[]) { - const sdk = { - session: { - message: () => Promise.resolve({ data: { parts } }), - get: () => Promise.resolve({ data: {} }), - }, - } - return GithubProxyAuthPlugin({ ...baseInput, client: sdk } as never) - } - - test("compaction part → x-initiator=agent", async () => { - const hooks = await makeHooksWithSdk([{ type: "compaction" }]) - const output: any = { headers: {} } - await hooks["chat.headers"]!( - { - model: { providerID: "github-proxy", api: { npm: "@ai-sdk/github-copilot" } }, - message: { sessionID: "s1", id: "m1" }, - sessionID: "s1", - } as any, - output, - ) - expect(output.headers["x-initiator"]).toBe("agent") - }) - - test("synthetic text part with compaction_continue=true → x-initiator=agent", async () => { - const hooks = await makeHooksWithSdk([ - { type: "text", synthetic: true, metadata: { compaction_continue: true }, text: "..." }, - ]) - const output: any = { headers: {} } - await hooks["chat.headers"]!( - { - model: { providerID: "github-proxy", api: { npm: "@ai-sdk/github-copilot" } }, - message: { sessionID: "s1", id: "m1" }, - sessionID: "s1", - } as any, - output, - ) - expect(output.headers["x-initiator"]).toBe("agent") - }) - - test("synthetic text part WITHOUT compaction_continue → header NOT set to agent (preserves user attribution)", async () => { - const hooks = await makeHooksWithSdk([ - { type: "text", synthetic: true, metadata: {}, text: "..." }, - ]) - const output: any = { headers: {} } - await hooks["chat.headers"]!( - { - model: { providerID: "github-proxy", api: { npm: "@ai-sdk/github-copilot" } }, - message: { sessionID: "s1", id: "m1" }, - sessionID: "s1", - } as any, - output, - ) - expect(output.headers["x-initiator"]).toBeUndefined() - }) - - test("anthropic model also gets anthropic-beta header", async () => { - const hooks = await makeHooksWithSdk([]) - const output: any = { headers: {} } - await hooks["chat.headers"]!( - { - model: { providerID: "github-proxy", api: { npm: "@ai-sdk/anthropic" } }, - message: { sessionID: "s1", id: "m1" }, - sessionID: "s1", - } as any, - output, - ) - expect(output.headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14") - }) -}) - -describe("github-proxy / authorize", () => { - const method = async () => { - const hooks = await GithubProxyAuthPlugin(baseInput as never) - return hooks.auth!.methods!.find((m) => m.type === "api")! as any - } - - test("returns success + metadata.proxyUrl (trailing slash trimmed) on 200", async () => { - let calledUrl = "" - globalThis.fetch = mock((url: any) => { - calledUrl = typeof url === "string" ? url : url.toString() - return Promise.resolve(new Response("ok", { status: 200 })) - }) as unknown as typeof fetch - - const m = await method() - const result = await m.authorize({ proxyUrl: "http://p.local///", apiKey: "abc" }) - - expect(calledUrl).toBe("http://p.local/copilot/auth") - expect(result).toEqual({ - type: "success", - key: "abc", - metadata: { proxyUrl: "http://p.local" }, - }) - }) - - test("returns failed when upstream responds non-2xx", async () => { - globalThis.fetch = mock(() => - Promise.resolve(new Response("nope", { status: 401 })), - ) as unknown as typeof fetch - - const m = await method() - const result = await m.authorize({ proxyUrl: "http://p.local", apiKey: "abc" }) - expect(result).toEqual({ type: "failed" }) - }) - - test("returns failed when fetch throws", async () => { - globalThis.fetch = mock(() => Promise.reject(new Error("network"))) as unknown as typeof fetch - - const m = await method() - const result = await m.authorize({ proxyUrl: "http://p.local", apiKey: "abc" }) - expect(result).toEqual({ type: "failed" }) - }) - - test("returns failed when inputs missing", async () => { - const m = await method() - expect(await m.authorize({ proxyUrl: "", apiKey: "k" })).toEqual({ type: "failed" }) - expect(await m.authorize({ proxyUrl: "http://p", apiKey: "" })).toEqual({ type: "failed" }) - expect(await m.authorize({})).toEqual({ type: "failed" }) - }) -}) From fe99cfc594a9c9f0efbe7f8d100f3a66185bcac0 Mon Sep 17 00:00:00 2001 From: lex Date: Tue, 12 May 2026 11:06:06 +0800 Subject: [PATCH 24/25] =?UTF-8?q?=E9=87=8D=E6=9E=84(quota):=20=E5=B0=86=20?= =?UTF-8?q?quota=20=E5=8F=96=E6=95=B0=E4=B8=8E=20TUI=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=8B=86=E4=B8=BA=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BB=A3=E7=90=86/=E5=8E=9F=E5=8E=82?= =?UTF-8?q?=E5=8F=8C=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 quota/ 子目录(endpoint.ts / fetch.ts / view.tsx / index.ts) - proxy 模式 URL = ${provider.github-copilot.api}/quota,parse 扁平 schema {remaining, entitlement, accounts_active, accounts_total} - official 模式 URL = api.github.com/copilot_internal/user,parse 嵌套 data.quota_snapshots.premium_interactions - QuotaInfo 新增 mode: 'proxy'|'official' tag,view 按 accounts_total 分支 - 删除旧 quota.tsx / quota-fetch.ts / quota-fetch.test.ts - 新增 quota.test.ts 25 用例覆盖双 schema + fetchQuota 全路径 - typecheck 0 error,全量测试 2342 pass,33.110 实调 schema 验证通过 --- .../feature-plugins/session/quota-fetch.ts | 83 ------ .../feature-plugins/session/quota/enabled.ts | 5 + .../feature-plugins/session/quota/endpoint.ts | 59 ++++ .../feature-plugins/session/quota/fetch.ts | 65 +++++ .../feature-plugins/session/quota/index.ts | 9 + .../session/{quota.tsx => quota/view.tsx} | 53 +++- .../session/quota-fetch.test.ts | 180 ------------ .../tui/feature-plugins/session/quota.test.ts | 261 ++++++++++++++++++ 8 files changed, 444 insertions(+), 271 deletions(-) delete mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/enabled.ts create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/endpoint.ts create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/fetch.ts create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/index.ts rename packages/opencode/src/cli/cmd/tui/feature-plugins/session/{quota.tsx => quota/view.tsx} (60%) delete mode 100644 packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts create mode 100644 packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts deleted file mode 100644 index 4b33748fa2..0000000000 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota-fetch.ts +++ /dev/null @@ -1,83 +0,0 @@ -// quota-fetch.ts — quota.tsx 的纯逻辑分支:读取 auth.json、解析上游响应、HTTP 取数。 -// 抽离动机:与 Solid/opentui 渲染解耦,便于在 Bun test 中直接覆盖(避免拉入原生 opentui binding)。 -import path from "node:path" -import { readFile } from "node:fs/promises" - -export interface QuotaAuth { - quotaUrl: string - token: string - provider: "github-copilot" -} - -export interface QuotaInfo { - /** 已用量(consumed count)。由 entitlement-remaining 换算 */ - used: number - entitlement: number - accounts_active: number - accounts_total: number -} - -/** - * 按 providerID 精确读取对应的 QuotaAuth。 - * providerID 以 "github-copilot" 开头即可(支持子变体)。 - */ -export async function readQuotaAuthForProvider( - stateDir: string, - providerID: string, -): Promise { - try { - const text = await readFile(path.join(stateDir, "auth.json"), "utf-8") - const data = JSON.parse(text) as Record - - if (providerID.startsWith("github-copilot")) { - const entry = data["github-copilot"] as Record | undefined - if (entry?.type === "oauth") { - const refresh = entry.refresh as string | undefined - if (refresh) { - const enterpriseUrl = entry.enterpriseUrl as string | undefined - const apiBase = enterpriseUrl - ? `https://api.${enterpriseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}` - : "https://api.github.com" - return { - quotaUrl: `${apiBase}/copilot_internal/user`, - token: refresh, - provider: "github-copilot", - } - } - } - return null - } - - return null - } catch { - return null - } -} - -/** 从 GitHub Copilot API 响应解析 quota(snake_case 字段) */ -export function parseCopilotQuota(data: Record): QuotaInfo | null { - const snapshots = data.quota_snapshots as Record | undefined - const premium = snapshots?.premium_interactions as Record | undefined - if (!premium) return null - - const actualRemaining = typeof premium.remaining === "number" ? premium.remaining : null - const entitlement = typeof premium.entitlement === "number" ? premium.entitlement : null - if (actualRemaining === null || entitlement === null) return null - - // GitHub API 返回「剩余量」,换算为「已用量」 - return { used: entitlement - actualRemaining, entitlement, accounts_active: 0, accounts_total: 0 } -} - -export async function fetchQuota(auth: QuotaAuth): Promise { - try { - const resp = await fetch(auth.quotaUrl, { - headers: { Authorization: `Bearer ${auth.token}` }, - signal: AbortSignal.timeout(2_000), - }) - if (!resp.ok) return null - const data = (await resp.json()) as Record - return parseCopilotQuota(data) - } catch { - return null - } -} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/enabled.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/enabled.ts new file mode 100644 index 0000000000..6e4514c89c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/enabled.ts @@ -0,0 +1,5 @@ +// enabled.ts — quota 取数功能是否对当前 providerID 生效的纯判定。 +// providerID 以 "github-copilot" 开头即视为启用(包括 github-copilot-custom 等子变体)。 +export function isCopilotMode(providerID: string): boolean { + return providerID.startsWith("github-copilot") +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/endpoint.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/endpoint.ts new file mode 100644 index 0000000000..36fc879702 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/endpoint.ts @@ -0,0 +1,59 @@ +// endpoint.ts — Copilot quota 端点解析,纯函数。 +// +// 决策表(spec): +// provider.api 非空 + options.apiKey 非空 → proxy,token=apiKey,url=${api}/quota(聚合) +// provider.api 非空 + options.apiKey 空 + oauth → proxy,token=refresh,url=${api}/quota +// provider.api 非空 + options.apiKey 空 + 无 auth → null +// 无 provider.api + oauth(无 enterpriseUrl) → official,url=api.github.com/copilot_internal/user +// 无 provider.api + oauth(有 enterpriseUrl) → official,url=api./copilot_internal/user +// 无 provider.api + 无 auth → null +// +// 注意: +// - proxy 模式打代理的聚合接口 /copilot/quota,返回 {remaining, entitlement, accounts_active, accounts_total} +// - official 模式打 GitHub 原厂 /copilot_internal/user,返回嵌套 data.quota_snapshots.premium_interactions +// - 不做尾斜杠规范化 —— 调用方保证 api 字段不带尾 "/" +export interface EndpointDecision { + url: string + token: string + mode: "proxy" | "official" + account?: string +} + +export interface ProviderConfigSlice { + api?: string + options?: { apiKey?: string } +} + +export interface AuthEntrySlice { + type: string + refresh?: string + enterpriseUrl?: string +} + +export function resolveEndpoint(input: { + providerConfig: ProviderConfigSlice | undefined + authEntry: AuthEntrySlice | undefined +}): EndpointDecision | null { + const proxyApi = input.providerConfig?.api + const apiKey = input.providerConfig?.options?.apiKey + const entry = input.authEntry + const oauthRefresh = entry?.type === "oauth" ? entry.refresh : undefined + const account = entry?.enterpriseUrl + + if (proxyApi) { + if (apiKey) { + return { url: `${proxyApi}/quota`, token: apiKey, mode: "proxy", account } + } + if (oauthRefresh) { + return { url: `${proxyApi}/quota`, token: oauthRefresh, mode: "proxy", account } + } + return null + } + + if (!oauthRefresh) return null + + const apiBase = entry?.enterpriseUrl + ? `https://api.${entry.enterpriseUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}` + : "https://api.github.com" + return { url: `${apiBase}/copilot_internal/user`, token: oauthRefresh, mode: "official", account } +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/fetch.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/fetch.ts new file mode 100644 index 0000000000..69196425f4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/fetch.ts @@ -0,0 +1,65 @@ +// fetch.ts — 按解析好的 EndpointDecision 取 quota + 解析响应。 +// 两种 schema: +// official: GitHub /copilot_internal/user → data.quota_snapshots.premium_interactions.{remaining, entitlement} +// proxy: LLMS-proxy /copilot/quota → {remaining, entitlement, accounts_active, accounts_total}(扁平聚合) +// 保持原 2s timeout、status!=200 返 null、catch 静默返 null 的语义。 +// 三条路径(成功/失败/timeout)各打一行 console.debug,带 mode + url,不打 token。 +import type { EndpointDecision } from "./endpoint" + +export interface QuotaInfo { + /** 已用量(consumed count),由 entitlement-remaining 换算 */ + used: number + entitlement: number + /** 仅 proxy 模式有意义;official 模式恒为 0,由 view 层从 auth.json 估算 */ + accounts_active: number + accounts_total: number + mode: "proxy" | "official" +} + +/** 原厂 schema:GitHub /copilot_internal/user → data.quota_snapshots.premium_interactions */ +export function parseCopilotOfficial(data: Record): Omit | null { + const snapshots = data.quota_snapshots as Record | undefined + const premium = snapshots?.premium_interactions as Record | undefined + if (!premium) return null + + const remaining = typeof premium.remaining === "number" ? premium.remaining : null + const entitlement = typeof premium.entitlement === "number" ? premium.entitlement : null + if (remaining === null || entitlement === null) return null + + return { used: entitlement - remaining, entitlement, accounts_active: 0, accounts_total: 0 } +} + +/** 代理聚合 schema:LLMS-proxy /copilot/quota → 扁平 {remaining, entitlement, accounts_active, accounts_total} */ +export function parseProxyAggregate(data: Record): Omit | null { + const remaining = typeof data.remaining === "number" ? data.remaining : null + const entitlement = typeof data.entitlement === "number" ? data.entitlement : null + if (remaining === null || entitlement === null) return null + + const active = typeof data.accounts_active === "number" ? data.accounts_active : 0 + const total = typeof data.accounts_total === "number" ? data.accounts_total : 0 + return { used: entitlement - remaining, entitlement, accounts_active: active, accounts_total: total } +} + +export async function fetchQuota(endpoint: EndpointDecision): Promise { + try { + const resp = await fetch(endpoint.url, { + headers: { Authorization: `Bearer ${endpoint.token}` }, + signal: AbortSignal.timeout(2_000), + }) + if (!resp.ok) { + console.debug(`[quota] fetch non-2xx mode=${endpoint.mode} url=${endpoint.url} status=${resp.status}`) + return null + } + const data = (await resp.json()) as Record + const parsed = endpoint.mode === "proxy" ? parseProxyAggregate(data) : parseCopilotOfficial(data) + console.debug( + `[quota] fetch ok mode=${endpoint.mode} url=${endpoint.url} parsed=${parsed ? "yes" : "no"}`, + ) + return parsed ? { ...parsed, mode: endpoint.mode } : null + } catch (err) { + const name = err instanceof Error ? err.name : "Error" + const msg = err instanceof Error ? err.message : String(err) + console.debug(`[quota] fetch error mode=${endpoint.mode} url=${endpoint.url} err=${name}:${msg}`) + return null + } +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/index.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/index.ts new file mode 100644 index 0000000000..b645e45824 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/index.ts @@ -0,0 +1,9 @@ +// quota/index.ts — 聚合导出 + 默认导出 SessionQuota 插件模块。 +// internal.ts 仍然按 default import 使用 SessionQuota 模块对象。 +import plugin, { QuotaView } from "./view" + +export { QuotaView } +export * from "./enabled" +export * from "./endpoint" +export * from "./fetch" +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/view.tsx similarity index 60% rename from packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx rename to packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/view.tsx index 4602def794..56acf7c590 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/session/quota/view.tsx @@ -1,24 +1,55 @@ -// quota.tsx — 在 session prompt 右侧显示 Copilot premium request 配额。 +// view.tsx — 在 session prompt 右侧显示 Copilot premium request 配额。 // // Provider 选择策略: // 从当前 session 最后一条 AssistantMessage 取 providerID(响应式); // 无消息时降级到 config.model 解析的 providerID; -// 按 providerID 精确读取对应 auth,不再依次尝试所有来源。 +// 启用判定:isCopilotMode(providerID)。 +// +// 端点选择: +// 优先读 api.state.config.provider["github-copilot"],由 resolveEndpoint +// 决定走代理(provider.api 存在)还是原厂 api.github.com。 // // 颜色规则(按已用量绝对值): // used ≤ 100 → success(绿);≤ 200 → warning(黄);> 200 → error(红) // // 重要:opentui Slot 在初始渲染时若返回空内容,会永久跳过本插件。 // 因此组件在数据就绪前显示 "⊘ …" 占位。 +import path from "node:path" +import { readFile } from "node:fs/promises" import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { fetchQuota, readQuotaAuthForProvider, type QuotaInfo } from "./quota-fetch" +import { isCopilotMode } from "./enabled" +import { resolveEndpoint, type AuthEntrySlice, type ProviderConfigSlice } from "./endpoint" +import { fetchQuota, type QuotaInfo } from "./fetch" const id = "internal:session-quota" -function QuotaView(props: { api: TuiPluginApi; session_id: string }) { +async function readCopilotAuthEntry(stateDir: string): Promise { + try { + const text = await readFile(path.join(stateDir, "auth.json"), "utf-8") + const data = JSON.parse(text) as Record + const raw = data["github-copilot"] as Record | undefined + if (!raw || typeof raw.type !== "string") return undefined + const refresh = typeof raw.refresh === "string" ? raw.refresh : undefined + const enterpriseUrl = typeof raw.enterpriseUrl === "string" ? raw.enterpriseUrl : undefined + return { type: raw.type, refresh, enterpriseUrl } + } catch { + return undefined + } +} + +function readCopilotProviderConfig(api: TuiPluginApi): ProviderConfigSlice | undefined { + const raw = api.state.config.provider?.["github-copilot"] + if (!raw) return undefined + const apiUrl = typeof raw.api === "string" ? raw.api : undefined + const rawApiKey = raw.options?.apiKey + const apiKey = typeof rawApiKey === "string" ? rawApiKey : undefined + return { api: apiUrl, options: { apiKey } } +} + +export function QuotaView(props: { api: TuiPluginApi; session_id: string }) { const theme = () => props.api.theme.current const [label, setLabel] = createSignal("⊘ …") const [tone, setTone] = createSignal<"muted" | "success" | "warning" | "error">("muted") @@ -58,15 +89,21 @@ function QuotaView(props: { api: TuiPluginApi; session_id: string }) { async function refresh() { const pid = providerID() - if (!pid) return + if (!pid || !isCopilotMode(pid)) { + setLabel("") + return + } + const providerConfig = readCopilotProviderConfig(props.api) // 注意:auth.json 位于 Global.Path.data(XDG_DATA_HOME), // 不能用 props.api.state.path.state(XDG_STATE_HOME),二者是不同目录。 - const auth = await readQuotaAuthForProvider(Global.Path.data, pid) - if (!auth) { + const authEntry = await readCopilotAuthEntry(Global.Path.data) + const endpoint = resolveEndpoint({ providerConfig, authEntry }) + if (!endpoint) { + console.debug(`[quota] resolveEndpoint null providerID=${pid}`) setLabel("") return } - const q = await fetchQuota(auth) + const q = await fetchQuota(endpoint) if (q) applyQuota(q) else setLabel("") } diff --git a/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts b/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts deleted file mode 100644 index c53559dd66..0000000000 --- a/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota-fetch.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// quota-fetch.test.ts — TUI Quota 自动化测试,覆盖 quota-fetch.ts 纯逻辑 -// - readQuotaAuthForProvider:按 providerID 精确选 auth -// - parseCopilotQuota:正常解析 + 字段缺失返回 null -// - fetchQuota:正常 200 + 非 200 + fetch 抛错(含 timeout) -// 不覆盖:Solid 组件渲染、setInterval 调度、opentui Slot 逻辑(需手测) -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" -import { mkdtemp, rm, writeFile } from "node:fs/promises" -import path from "node:path" -import os from "node:os" -import { - fetchQuota, - parseCopilotQuota, - readQuotaAuthForProvider, - type QuotaAuth, -} from "@/cli/cmd/tui/feature-plugins/session/quota-fetch" - -const originalFetch = globalThis.fetch - -afterEach(() => { - globalThis.fetch = originalFetch -}) - -describe("readQuotaAuthForProvider", () => { - let dir: string - beforeEach(async () => { - dir = await mkdtemp(path.join(os.tmpdir(), "quota-auth-")) - }) - afterEach(async () => { - await rm(dir, { recursive: true, force: true }) - }) - - test("github-copilot → 返回 copilot auth", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ - "github-copilot": { type: "oauth", refresh: "gho_test" }, - }), - ) - expect(await readQuotaAuthForProvider(dir, "github-copilot")).toEqual({ - quotaUrl: "https://api.github.com/copilot_internal/user", - token: "gho_test", - provider: "github-copilot", - }) - }) - - test("github-copilot 子变体(如 github-copilot-custom)→ 匹配 copilot 条目", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ - "github-copilot": { type: "oauth", refresh: "gho_sub" }, - }), - ) - const auth = await readQuotaAuthForProvider(dir, "github-copilot-custom") - expect(auth?.provider).toBe("github-copilot") - expect(auth?.token).toBe("gho_sub") - }) - - test("github-copilot enterpriseUrl → 注入 api. 子域", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ - "github-copilot": { - type: "oauth", - refresh: "gho_ent", - enterpriseUrl: "https://ghes.corp.io/", - }, - }), - ) - const auth = await readQuotaAuthForProvider(dir, "github-copilot") - expect(auth?.quotaUrl).toBe("https://api.ghes.corp.io/copilot_internal/user") - }) - - test("github-copilot 缺 refresh → null", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ "github-copilot": { type: "oauth" } }), - ) - expect(await readQuotaAuthForProvider(dir, "github-copilot")).toBeNull() - }) - - test("未知 providerID → null", async () => { - await writeFile( - path.join(dir, "auth.json"), - JSON.stringify({ "github-copilot": { type: "oauth", refresh: "gho" } }), - ) - expect(await readQuotaAuthForProvider(dir, "anthropic")).toBeNull() - }) - - test("auth.json 不存在 → null", async () => { - expect(await readQuotaAuthForProvider(dir, "github-copilot")).toBeNull() - }) - - test("auth.json 非法 JSON → null", async () => { - await writeFile(path.join(dir, "auth.json"), "{not json") - expect(await readQuotaAuthForProvider(dir, "github-copilot")).toBeNull() - }) -}) - -describe("parseCopilotQuota", () => { - test("snake_case 字段解析 → used = entitlement - remaining", () => { - expect( - parseCopilotQuota({ - quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, - }), - ).toEqual({ used: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) - }) - - test("overage(remaining 为负数)→ used > entitlement", () => { - expect( - parseCopilotQuota({ - quota_snapshots: { premium_interactions: { remaining: -51, entitlement: 300 } }, - }), - ).toEqual({ used: 351, entitlement: 300, accounts_active: 0, accounts_total: 0 }) - }) - - test("remaining 0 时 used = entitlement(全部用完)", () => { - expect( - parseCopilotQuota({ - quota_snapshots: { premium_interactions: { remaining: 0, entitlement: 100 } }, - }), - ).toEqual({ used: 100, entitlement: 100, accounts_active: 0, accounts_total: 0 }) - }) - - test("缺 quota_snapshots → null", () => { - expect(parseCopilotQuota({})).toBeNull() - }) - - test("缺 premium_interactions → null", () => { - expect(parseCopilotQuota({ quota_snapshots: {} })).toBeNull() - }) - - test("remaining 非 number → null", () => { - expect( - parseCopilotQuota({ - quota_snapshots: { premium_interactions: { remaining: "30", entitlement: 100 } }, - }), - ).toBeNull() - }) -}) - -describe("fetchQuota", () => { - const copilotAuth: QuotaAuth = { - quotaUrl: "https://api.github.com/copilot_internal/user", - token: "gho_test", - provider: "github-copilot", - } - - test("github-copilot 200 → parseCopilotQuota,header 注入 Bearer", async () => { - let captured: Record = {} - globalThis.fetch = mock(async (_url: string | URL, init?: RequestInit) => { - captured = (init?.headers ?? {}) as Record - return new Response( - JSON.stringify({ - quota_snapshots: { premium_interactions: { remaining: 60, entitlement: 300 } }, - }), - { status: 200 }, - ) - }) as unknown as typeof fetch - const q = await fetchQuota(copilotAuth) - expect(q).toEqual({ used: 240, entitlement: 300, accounts_active: 0, accounts_total: 0 }) - expect(captured.Authorization).toBe("Bearer gho_test") - }) - - test("非 200 响应 → null", async () => { - globalThis.fetch = mock(async () => new Response("nope", { status: 503 })) as unknown as typeof fetch - expect(await fetchQuota(copilotAuth)).toBeNull() - }) - - test("fetch 抛错 → null", async () => { - globalThis.fetch = mock(async () => { - throw new Error("connection refused") - }) as unknown as typeof fetch - expect(await fetchQuota(copilotAuth)).toBeNull() - }) - - test("响应 JSON 字段不完整 → null", async () => { - globalThis.fetch = mock(async () => new Response(JSON.stringify({}), { status: 200 })) as unknown as typeof fetch - expect(await fetchQuota(copilotAuth)).toBeNull() - }) -}) diff --git a/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota.test.ts b/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota.test.ts new file mode 100644 index 0000000000..33ed576d52 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/feature-plugins/session/quota.test.ts @@ -0,0 +1,261 @@ +// quota.test.ts — TUI Quota 自动化测试,覆盖 quota/ 子目录纯逻辑: +// - resolveEndpoint:6 行决策表(proxy/official × token 来源);proxy URL = ${api}/quota +// - parseCopilotOfficial:原厂嵌套 schema(data.quota_snapshots.premium_interactions) +// - parseProxyAggregate:代理扁平 schema({remaining, entitlement, accounts_active, accounts_total}) +// - fetchQuota:根据 endpoint.mode 走对应 parser,正常/非2xx/抛错/字段不全 +// 不覆盖:Solid 组件渲染、setInterval 调度、opentui Slot 逻辑(需手测) +import { afterEach, describe, expect, mock, test } from "bun:test" +import { + fetchQuota, + parseCopilotOfficial, + parseProxyAggregate, + resolveEndpoint, + type EndpointDecision, +} from "@/cli/cmd/tui/feature-plugins/session/quota" + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +describe("resolveEndpoint", () => { + test("proxy + apiKey → mode=proxy, url=${api}/quota, token=apiKey", () => { + expect( + resolveEndpoint({ + providerConfig: { api: "http://192.168.33.110:8000/copilot", options: { apiKey: "sk-proxy" } }, + authEntry: { type: "oauth", refresh: "gho_oauth" }, + }), + ).toEqual({ + url: "http://192.168.33.110:8000/copilot/quota", + token: "sk-proxy", + mode: "proxy", + account: undefined, + }) + }) + + test("proxy + 无 apiKey + oauth → mode=proxy, token=refresh", () => { + expect( + resolveEndpoint({ + providerConfig: { api: "http://localhost:9000/copilot", options: {} }, + authEntry: { type: "oauth", refresh: "gho_refresh" }, + }), + ).toEqual({ + url: "http://localhost:9000/copilot/quota", + token: "gho_refresh", + mode: "proxy", + account: undefined, + }) + }) + + test("proxy + 无 apiKey + 无 auth → null", () => { + expect( + resolveEndpoint({ + providerConfig: { api: "http://localhost:9000/copilot" }, + authEntry: undefined, + }), + ).toBeNull() + }) + + test("无 proxy + oauth → mode=official, url=api.github.com", () => { + expect( + resolveEndpoint({ + providerConfig: undefined, + authEntry: { type: "oauth", refresh: "gho_test" }, + }), + ).toEqual({ + url: "https://api.github.com/copilot_internal/user", + token: "gho_test", + mode: "official", + account: undefined, + }) + }) + + test("无 proxy + oauth + enterpriseUrl → mode=official, url=api.", () => { + expect( + resolveEndpoint({ + providerConfig: undefined, + authEntry: { type: "oauth", refresh: "gho_ent", enterpriseUrl: "https://ghes.corp.io/" }, + }), + ).toEqual({ + url: "https://api.ghes.corp.io/copilot_internal/user", + token: "gho_ent", + mode: "official", + account: "https://ghes.corp.io/", + }) + }) + + test("无 proxy + 无 auth → null", () => { + expect(resolveEndpoint({ providerConfig: undefined, authEntry: undefined })).toBeNull() + }) + + test("proxy 存在但 apiKey/refresh 都没 → null", () => { + expect( + resolveEndpoint({ + providerConfig: { api: "http://x/copilot", options: {} }, + authEntry: { type: "oauth" }, + }), + ).toBeNull() + }) +}) + +describe("parseCopilotOfficial", () => { + test("snake_case 字段解析 → used = entitlement - remaining", () => { + expect( + parseCopilotOfficial({ + quota_snapshots: { premium_interactions: { remaining: 30, entitlement: 300 } }, + }), + ).toEqual({ used: 270, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + }) + + test("overage(remaining 为负数)→ used > entitlement", () => { + expect( + parseCopilotOfficial({ + quota_snapshots: { premium_interactions: { remaining: -51, entitlement: 300 } }, + }), + ).toEqual({ used: 351, entitlement: 300, accounts_active: 0, accounts_total: 0 }) + }) + + test("remaining 0 时 used = entitlement(全部用完)", () => { + expect( + parseCopilotOfficial({ + quota_snapshots: { premium_interactions: { remaining: 0, entitlement: 100 } }, + }), + ).toEqual({ used: 100, entitlement: 100, accounts_active: 0, accounts_total: 0 }) + }) + + test("缺 quota_snapshots → null", () => { + expect(parseCopilotOfficial({})).toBeNull() + }) + + test("缺 premium_interactions → null", () => { + expect(parseCopilotOfficial({ quota_snapshots: {} })).toBeNull() + }) + + test("remaining 非 number → null", () => { + expect( + parseCopilotOfficial({ + quota_snapshots: { premium_interactions: { remaining: "30", entitlement: 100 } }, + }), + ).toBeNull() + }) +}) + +describe("parseProxyAggregate", () => { + test("扁平 schema 解析 → used + accounts_active/total", () => { + expect( + parseProxyAggregate({ remaining: 133, entitlement: 300, accounts_active: 1, accounts_total: 2 }), + ).toEqual({ used: 167, entitlement: 300, accounts_active: 1, accounts_total: 2 }) + }) + + test("缺 accounts_* 时默认 0(向后兼容老代理)", () => { + expect(parseProxyAggregate({ remaining: 50, entitlement: 100 })).toEqual({ + used: 50, + entitlement: 100, + accounts_active: 0, + accounts_total: 0, + }) + }) + + test("overage(remaining 负数)→ used > entitlement", () => { + expect( + parseProxyAggregate({ remaining: -10, entitlement: 100, accounts_active: 1, accounts_total: 1 }), + ).toEqual({ used: 110, entitlement: 100, accounts_active: 1, accounts_total: 1 }) + }) + + test("缺 remaining → null", () => { + expect(parseProxyAggregate({ entitlement: 100, accounts_total: 1 })).toBeNull() + }) + + test("缺 entitlement → null", () => { + expect(parseProxyAggregate({ remaining: 50, accounts_total: 1 })).toBeNull() + }) + + test("remaining 非 number → null", () => { + expect(parseProxyAggregate({ remaining: "50", entitlement: 100 })).toBeNull() + }) +}) + +describe("fetchQuota", () => { + const officialEndpoint: EndpointDecision = { + url: "https://api.github.com/copilot_internal/user", + token: "gho_test", + mode: "official", + } + + test("official 200 → parseCopilotOfficial,header 注入 Bearer,结果带 mode=official", async () => { + let captured: Record = {} + globalThis.fetch = mock(async (_url: string | URL, init?: RequestInit) => { + captured = (init?.headers ?? {}) as Record + return new Response( + JSON.stringify({ + quota_snapshots: { premium_interactions: { remaining: 60, entitlement: 300 } }, + }), + { status: 200 }, + ) + }) as unknown as typeof fetch + const q = await fetchQuota(officialEndpoint) + expect(q).toEqual({ + used: 240, + entitlement: 300, + accounts_active: 0, + accounts_total: 0, + mode: "official", + }) + expect(captured.Authorization).toBe("Bearer gho_test") + }) + + test("非 200 响应 → null", async () => { + globalThis.fetch = mock(async () => new Response("nope", { status: 503 })) as unknown as typeof fetch + expect(await fetchQuota(officialEndpoint)).toBeNull() + }) + + test("fetch 抛错 → null", async () => { + globalThis.fetch = mock(async () => { + throw new Error("connection refused") + }) as unknown as typeof fetch + expect(await fetchQuota(officialEndpoint)).toBeNull() + }) + + test("official 响应 JSON 字段不完整 → null", async () => { + globalThis.fetch = mock(async () => new Response(JSON.stringify({}), { status: 200 })) as unknown as typeof fetch + expect(await fetchQuota(officialEndpoint)).toBeNull() + }) + + test("proxy mode → 走 parseProxyAggregate,URL=/copilot/quota,mode=proxy", async () => { + const proxyEndpoint: EndpointDecision = { + url: "http://192.168.33.110:8000/copilot/quota", + token: "sk-proxy", + mode: "proxy", + } + let capturedUrl: string | URL = "" + let capturedAuth = "" + globalThis.fetch = mock(async (url: string | URL, init?: RequestInit) => { + capturedUrl = url + capturedAuth = ((init?.headers ?? {}) as Record).Authorization ?? "" + return new Response( + JSON.stringify({ remaining: 90, entitlement: 100, accounts_active: 2, accounts_total: 3 }), + { status: 200 }, + ) + }) as unknown as typeof fetch + expect(await fetchQuota(proxyEndpoint)).toEqual({ + used: 10, + entitlement: 100, + accounts_active: 2, + accounts_total: 3, + mode: "proxy", + }) + expect(capturedUrl).toBe("http://192.168.33.110:8000/copilot/quota") + expect(capturedAuth).toBe("Bearer sk-proxy") + }) + + test("proxy 响应缺字段 → null", async () => { + const proxyEndpoint: EndpointDecision = { + url: "http://x/copilot/quota", + token: "sk-x", + mode: "proxy", + } + globalThis.fetch = mock(async () => new Response(JSON.stringify({ accounts_total: 1 }), { status: 200 })) as unknown as typeof fetch + expect(await fetchQuota(proxyEndpoint)).toBeNull() + }) +}) From db75722da2e40edceb5cfa657d4b1f8cd140a5f8 Mon Sep 17 00:00:00 2001 From: 42 Date: Tue, 12 May 2026 11:14:03 +0800 Subject: [PATCH 25/25] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- packages/opencode/test/config/config.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 6791a2e841..e5f03abbb1 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1968,7 +1968,8 @@ test("wellknown remote_config supports templated env vars in headers", async () let remoteHeaders: HeadersInit | undefined globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => { const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url - if (urlStr.includes(".well-known/opencode")) { + const parsedUrl = new URL(urlStr) + if (parsedUrl.pathname.includes(".well-known/opencode")) { wellknownFetchedUrl = urlStr return Promise.resolve( new Response( @@ -1984,7 +1985,7 @@ test("wellknown remote_config supports templated env vars in headers", async () ), ) } - if (urlStr.includes("config.example.com")) { + if (parsedUrl.hostname === "config.example.com") { remoteFetchedUrl = urlStr remoteHeaders = init?.headers return Promise.resolve(