diff --git a/.changeset/steady-worktree-routing.md b/.changeset/steady-worktree-routing.md new file mode 100644 index 00000000000..c32d6e3e49c --- /dev/null +++ b/.changeset/steady-worktree-routing.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Preserve worktree routing for Kilo HTTP API clients and keep inherited task-subagent restrictions active. diff --git a/.opencode-version b/.opencode-version index 5ceca955de6..dfbc2e65373 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.14.42 +v1.14.46 diff --git a/.opencode/tui.json b/.opencode/tui.json index bb472d8e23b..f52ed17f410 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -1,4 +1,3 @@ { - "$schema": "https://opencode.ai/tui.json", - "keybinds": {} -} + "$schema": "https://opencode.ai/tui.json" +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index 3938c57547f..c5e90ba9d23 100644 --- a/bun.lock +++ b/bun.lock @@ -509,6 +509,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/node": "catalog:", "@types/npm-package-arg": "6.1.4", "@types/npmcli__arborist": "6.3.3", "@types/semver": "^7.5.8", diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/core/src/effect-zod.ts similarity index 100% rename from packages/opencode/src/util/effect-zod.ts rename to packages/core/src/effect-zod.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 262d8919cf0..95c8e01634e 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,13 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels where new experiments default to ON (unstable / internal users). +// Stable channels (`prod`, `latest`) stay opt-in. +const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) +function unstableDefault(key: string) { + return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) +} + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -47,6 +55,9 @@ export const Flag = { KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"), KILO_DISABLE_CLAUDE_CODE_SKILLS, KILO_DISABLE_EXTERNAL_SKILLS: truthy("KILO_DISABLE_EXTERNAL_SKILLS"), // kilocode_change + // Default-on for dev/beta/local; opt-in for stable. Set + // KILO_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. + KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"], KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"], KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"], diff --git a/packages/opencode/src/util/schema.ts b/packages/core/src/schema.ts similarity index 100% rename from packages/opencode/src/util/schema.ts rename to packages/core/src/schema.ts diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 45539070c38..3d76f7b1a35 100644 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -26,5 +26,6 @@ "typescript": "catalog:", "vite": "catalog:", "vite-plugin-solid": "catalog:" - } + }, + "peerDependencies": {} } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index a936de20ed1..e124accafc4 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -329,4 +329,5 @@ class KiloBackendSessionManager( } private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() + private fun Double.safeInt() = toLong().safeInt() } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt index e837d2693b9..1a4ae93fb6c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt @@ -54,9 +54,9 @@ class SessionModelSerializationTest { }""" val obj = json.decodeFromString(src) assertNotNull(obj.summary) - assertEquals(10, obj.summary!!.additions) - assertEquals(5, obj.summary!!.deletions) - assertEquals(3, obj.summary!!.files) + assertEquals(10.0, obj.summary!!.additions) + assertEquals(5.0, obj.summary!!.deletions) + assertEquals(3.0, obj.summary!!.files) } @Test diff --git a/packages/kilo-ui/src/components/session-diff.test.ts b/packages/kilo-ui/src/components/session-diff.test.ts index b576d28ffdc..0f6a39b668b 100644 --- a/packages/kilo-ui/src/components/session-diff.test.ts +++ b/packages/kilo-ui/src/components/session-diff.test.ts @@ -35,6 +35,14 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("two\n") }) + test("handles legacy snapshots without a file path", () => { + const view = normalize({ additions: 0, deletions: 0 }) + + expect(view.file).toBe("") + expect(text(view, "deletions")).toBe("") + expect(text(view, "additions")).toBe("") + }) + test("preserves real line numbers from hunk headers without padding", () => { const diff = { file: "a.ts", diff --git a/packages/kilo-ui/src/components/session-diff.ts b/packages/kilo-ui/src/components/session-diff.ts index de8528d076d..c471f47bae4 100644 --- a/packages/kilo-ui/src/components/session-diff.ts +++ b/packages/kilo-ui/src/components/session-diff.ts @@ -53,15 +53,18 @@ function reconstruct(patch: string) { type DiffText = { before: string; after: string; patch: string } +function name(diff: ReviewDiff) { + return diff.file ?? "" +} + function contents(diff: ReviewDiff): DiffText { if (typeof diff.patch === "string") { return { ...reconstruct(diff.patch), patch: diff.patch } } const before = "before" in diff && typeof diff.before === "string" ? diff.before : "" const after = "after" in diff && typeof diff.after === "string" ? diff.after : "" - const patch = formatPatch( - structuredPatch(diff.file, diff.file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }), - ) + const file = name(diff) + const patch = formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) return { before, after, patch } } @@ -69,9 +72,9 @@ function fileDiffFor(diff: ReviewDiff, view: DiffText): FileDiffMetadata { const hit = cache.get(view.patch) if (hit) return hit const fromPatch = typeof diff.patch === "string" ? processFile(diff.patch, { cacheKey: diff.patch }) : undefined + const file = name(diff) const value = - fromPatch ?? - parseDiffFromFile({ name: diff.file, contents: view.before }, { name: diff.file, contents: view.after }) + fromPatch ?? parseDiffFromFile({ name: file, contents: view.before }, { name: file, contents: view.after }) cache.set(view.patch, value) return value } @@ -79,7 +82,7 @@ function fileDiffFor(diff: ReviewDiff, view: DiffText): FileDiffMetadata { export function normalize(diff: ReviewDiff): ViewDiff { const view = contents(diff) return { - file: diff.file, + file: name(diff), patch: view.patch, before: view.before, after: view.after, diff --git a/packages/kilo-vscode/src/diff/sources/session.ts b/packages/kilo-vscode/src/diff/sources/session.ts index 4df81854a17..b70f4f715d7 100644 --- a/packages/kilo-vscode/src/diff/sources/session.ts +++ b/packages/kilo-vscode/src/diff/sources/session.ts @@ -68,7 +68,7 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile { // parse it, so short-circuit to empty strings. const view = raw.patch === "" ? null : normalize(raw) return { - file: raw.file, + file: raw.file ?? "", before: view ? text(view, "deletions") : "", after: view ? text(view, "additions") : "", patch: raw.patch, diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index f7e2a0e4d1a..36a436fce3a 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -145,7 +145,7 @@ async function resolveOverrideRef( */ function toDiffFile(entry: WorktreeDiffEntry): DiffFile { return { - file: entry.file, + file: entry.file ?? "", before: entry.before ?? "", after: entry.after ?? "", patch: entry.patch, diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index caee432af03..82fcd1b8e53 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -32,24 +32,13 @@ function isNotFound(err: unknown) { // This provides a second detection channel for server death independent of the SSE heartbeat. const HEALTH_POLL_INTERVAL_MS = 10_000 -/** - * Reject all pending network-offline waits for a given directory. - * The network namespace is not yet in the SDK KiloClient type (pending SDK regeneration), - * so we access it via a type assertion. - */ +/** Reject all pending network-offline waits for a given directory. */ async function drainNetworkWaits(client: KiloClient, dir: string) { - const net = (client as any).network as - | { - list: (p: { directory: string }) => Promise<{ data?: { id: string }[]; error?: unknown }> - reject: (p: { requestID: string; directory: string }) => Promise<{ error?: unknown }> - } - | undefined - if (!net) return - const { data: waits, error: err } = await net.list({ directory: dir }) + const { data: waits, error: err } = await client.network.list({ directory: dir }) if (err) throw new Error(`Failed to list network waits for ${dir}: ${String(err)}`) if (!waits) return for (const w of waits) { - const { error } = await net.reject({ requestID: w.id, directory: dir }) + const { error } = await client.network.reject({ requestID: w.id, directory: dir }) if (error) throw new Error(`Failed to reject network wait ${w.id}: ${String(error)}`) } } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index c667615beae..96362f1bd54 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -83,8 +83,9 @@ export const VscodeSessionTurn: Component = (props) => { const seen = new Set() return (rawDiffs as SnapshotFileDiff[]) .reduceRight((result, diff) => { - if (seen.has(diff.file)) return result - seen.add(diff.file) + const file = diff.file ?? "" + if (seen.has(file)) return result + seen.add(file) result.push(diff) return result }, []) diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index c0fda7a3e31..0ae88ab3798 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -144,5 +144,6 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", "typescript": "catalog:" - } + }, + "peerDependencies": {} } diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql index bdeed6bce18..c865526a88e 100644 --- a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -1 +1 @@ -ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; \ No newline at end of file +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0; diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1e4e3b9a3b1..23468ae8598 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -60,6 +60,7 @@ "@types/bun": "catalog:", "@types/cross-spawn": "catalog:", "@types/mime-types": "3.0.1", + "@types/node": "catalog:", "@types/npm-package-arg": "6.1.4", "@types/semver": "^7.5.8", "@types/turndown": "5.0.5", diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 947eef5a156..01af9da6ce7 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -59,10 +59,10 @@ Rules: ## Schema → Zod interop -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: ```ts -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e755457e614..e20605c3bc3 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims. Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors. Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@/util/effect-zod`. +schema via `@opencode-ai/core/effect-zod`. The long-term driver is `specs/effect/http-api.md` — once the HTTP server moves to `@effect/platform`, every Schema-first DTO can flow through @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes two explicit escape hatches for +The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for cases the pure-Schema path cannot express. Each one stays in the codebase only as long as its upstream or local dependency requires it — inline comments document when each can be deleted. @@ -389,7 +389,7 @@ piecewise. ## Notes -- Use `@/util/effect-zod` for all Schema → Zod conversion. +- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md new file mode 100644 index 00000000000..55e4c7268d4 --- /dev/null +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -0,0 +1,204 @@ +# OpenAPI Translation Cleanup Plan + +## Goal + +Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface. + +The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation. + +## Current Culprit + +`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility. + +The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime. + +## Non-Negotiables + +- Do not break the generated JavaScript SDK without an explicit versioned migration plan. +- Runtime route schemas are the source of truth for accepted params, payloads, and responses. +- `/doc`, generated SDK types, and runtime validation must agree for every endpoint. +- Prefer endpoint or schema annotations over post-generation spec surgery. +- Remove one category of rewrite at a time, with focused compatibility checks. + +## PR Checklist + +Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started. + +Current combined PR scope: + +- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes. +- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs. + +### PR 1: Add OpenAPI/Runtime Query Drift Tests + +- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` Import `OpenApi.fromApi` and `PublicApi`. +- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`. +- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes. +- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema. +- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params. +- `[x]` Keep this part test-only. + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 2: Delete Spec-Only Workspace Query Injection + +- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- `[x]` Delete `InstanceQueryParameters`. +- `[x]` Delete the `isInstanceRoute` constant. +- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation. +- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`. +- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes. +- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only. + +Notes: + +- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params. +- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing. + +Expected code shape: + +```ts +for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`) +``` + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `[x]` `./packages/sdk/js/script/build.ts` from repo root. +- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers + +- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time. +- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`. +- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`. +- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet. + +Concrete first targets: + +- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. +- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter. +- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. + +Verification: + +- Focused HTTP tests for changed query fields. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK request param types before deleting each override. +- `bun typecheck` from `packages/opencode`. + +### PR 4: Move Path Parameter Patterns Into ID Schemas + +- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. +- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. +- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Delete one path override only after generated OpenAPI is unchanged for that param. + +Concrete first targets: + +- `sessionID` +- `messageID` +- `partID` +- `permissionID` +- `ptyID` + +Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated path param types and patterns. +- `bun typecheck` from `packages/opencode`. + +### PR 5: Replace Built-In Error Rewrites With Declared API Errors + +- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`. +- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there. +- Update handlers to fail with the declared API errors at the boundary. +- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible. +- Do this group by group, starting with one small route group. + +Concrete first targets: + +- `groups/config.ts` `PATCH /config` bad request. +- `groups/session.ts` endpoints that already translate domain not-found errors. +- `groups/file.ts` if any handler currently relies on built-in error shape. + +Verification: + +- Focused HTTP tests asserting response body shape for changed error paths. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect SDK error union diff. +- `bun typecheck` from `packages/opencode`. + +### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It + +- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`. +- Decide whether SDK should expose auth in generated operation metadata. +- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code. +- If removing it, update SDK generation expectations and docs in the same PR. + +Verification: + +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated client call signatures and error unions. +- Do not merge if auth churn changes normal SDK call ergonomics unintentionally. + +### PR 7: Tackle Component Shape Rewrites One At A Time + +- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`. +- For each rewrite, make a tiny PR that removes or narrows only that rewrite. +- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first. + +Concrete first targets: + +- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially. +- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source. +- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK type-name and optionality diffs. + +## Upstream Middleware Query Support + +Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation. + +Target in `effect-smol`: + +- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation. +- Make runtime request decoding include middleware query schemas. +- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware. + +Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`. + +## Suggested PR Order + +1. Add drift detection tests only. +2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas. +3. Convert query type overrides into route/schema-level helpers where possible. +4. Convert path parameter overrides into schema annotations or upstream fixes. +5. Replace built-in error response rewrites with explicit declared API errors by route group. +6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable. + +## Verification Checklist Per PR + +- Focused HTTP tests for changed routes. +- OpenAPI drift tests. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK diff for public API churn. +- `bun typecheck` from `packages/opencode`. diff --git a/packages/opencode/specs/v2/tui-command-shim.md b/packages/opencode/specs/v2/tui-command-shim.md new file mode 100644 index 00000000000..5afade2a966 --- /dev/null +++ b/packages/opencode/specs/v2/tui-command-shim.md @@ -0,0 +1,67 @@ +# TUI Command Shim Removal + +Problem: + +- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization +- v2 should expose only the keymap command API +- tests and fixtures should not encode legacy command behavior as expected behavior + +## Remove Public Types + +In `packages/plugin/src/tui.ts`, remove: + +- `TuiCommand` +- `TuiCommandApi` +- `TuiPluginApi.command` + +Keep `api.keymap` as the only TUI command registration and execution surface. + +## Remove Runtime Shim + +Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`. + +In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `createTuiApi(...)` + +In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `pluginApi(...)` + +## Migration Target + +Plugin authors should replace old calls with keymap calls: + +```ts +api.keymap.registerLayer({ + commands: [ + { + name: "plugin.command", + title: "Plugin Command", + namespace: "palette", + slashName: "plugin", + run() { + api.ui.dialog.clear() + }, + }, + ], + bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }], +}) +``` + +Direct replacements: + +- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })` +- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)` +- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")` +- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure + +## Verification + +After removal, run from package directories: + +- `bun typecheck` in `packages/plugin` +- `bun typecheck` in `packages/opencode` +- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 9d7e8b8fee3..89444d1bc72 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -349,33 +349,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -415,10 +389,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((error) => { @@ -864,33 +835,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -930,10 +875,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((err) => { @@ -1659,6 +1601,70 @@ function toLocations(toolName: string, input: Record): { path: stri } } +function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { + if (part.state.status !== "completed") return [] + + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + content.push(...imageContents(part.state.attachments ?? [])) + return content +} + +function completedToolRawOutput(part: ToolPart) { + if (part.state.status !== "completed") return {} + return { + output: part.state.output, + metadata: part.state.metadata, + ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), + } +} + +function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { + return attachments.flatMap((attachment): ToolCallContent[] => { + const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? attachment.mime + if (!mime.startsWith("image/")) return [] + const data = match?.[2] + if (data === undefined) return [] + return [ + { + type: "content" as const, + content: { + type: "image" as const, + mimeType: mime, + data, + }, + }, + ] + }) +} + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 9b4572eb674..5bc2372e9f1 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -23,8 +23,8 @@ import { Plugin } from "@/plugin" import { Skill } from "../skill" import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@/util/effect-zod" -import { withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" import * as KiloAgent from "@/kilocode/agent" // kilocode_change type ReferenceEntry = NonNullable[string] diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts new file mode 100644 index 00000000000..1174ec31ade --- /dev/null +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -0,0 +1,33 @@ +import type { Permission } from "../permission" +import type { Agent } from "./agent" + +/** + * Build the `permission` ruleset for a subagent's session when it's spawned + * via the task tool. Combines: + * + * 1. The parent **agent's** deny rules — Plan Mode and other agent-level + * restrictions live on the agent ruleset, not on the session, so a + * subagent that only inherited the parent SESSION's permission would + * silently bypass them. (#26514) + * 2. The parent **session's** deny rules and external_directory rules — + * same forwarding the original code already did. + * 3. Default `todowrite` and `task` denies if the subagent's own ruleset + * doesn't already permit them. + */ +export function deriveSubagentSessionPermission(input: { + parentSessionPermission: Permission.Ruleset + parentAgent: Agent.Info | undefined + subagent: Agent.Info +}): Permission.Ruleset { + const canTask = input.subagent.permission.some((rule) => rule.permission === "task") + const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") + const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + return [ + ...parentAgentDenies, + ...input.parentSessionPermission.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), + ] +} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 65833c4d2dd..55803660ca1 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index d4ca631b1c0..6c07774634a 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,10 +23,16 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; additions: number; deletions: number }[] | undefined) { +function diff( + kind: string, + // kilocode_change start - retain Kilo summary counts while accepting upstream's optional file and patch values + diffs: { file?: string; patch?: string; additions: number; deletions: number }[] | undefined, + // kilocode_change end +) { return diffs?.map((item, i) => ({ ...item, - file: redact(`${kind}-file`, String(i), item.file), + file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), + patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts new file mode 100644 index 00000000000..63b3fb44876 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -0,0 +1,34 @@ +/** + * Aggregate Promise.allSettled results into a single Error that names every + * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap + * boundaries so a single 4xx doesn't drown its parallel siblings as + * unhandled rejections — every failure surfaces in one labeled message. + */ +export type LabeledSettled = { + name: string + result: PromiseSettledResult +} + +export function aggregateFailures(labeled: LabeledSettled[]): Error | null { + const failed = labeled.filter( + (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected", + ) + if (failed.length === 0) return null + + const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") + const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` + const err = new Error(summary) + err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } + return err +} + +function reasonMessage(reason: unknown): string { + if (reason instanceof Error) return reason.message + if (typeof reason === "string") return reason + if (reason && typeof reason === "object") { + const obj = reason as { message?: unknown; name?: unknown } + if (typeof obj.message === "string") return obj.message + if (typeof obj.name === "string") return obj.name + } + return String(reason) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index fceb88585a9..70c51d557ab 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -39,6 +39,7 @@ import type { IndexingStatus } from "@kilocode/kilo-indexing/status" // kilocode import { KiloIndexing } from "@/kilocode/indexing" // kilocode_change import path from "path" import { useKV } from "./kv" +import { aggregateFailures } from "./aggregate-failures" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -296,14 +297,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } - // kilocode_change start case "suggestion.accepted": case "suggestion.dismissed": case "suggestion.shown": { handleSuggestionEvent(event, store, setStore) break } - // kilocode_change end case "session.network.restored": { const requests = store.network[event.properties.sessionID] @@ -590,17 +589,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) const globalConfigPromise = sdk.client.global.config.get({ throwOnError: true }) // kilocode_change - const blockingRequests: Promise[] = [ - providersPromise, - providerListPromise, - agentsPromise, - configPromise, - globalConfigPromise, // kilocode_change - projectPromise, - ...(args.continue ? [sessionListPromise] : []), + const blockingRequests: { name: string; promise: Promise }[] = [ + { name: "config.providers", promise: providersPromise }, + { name: "provider.list", promise: providerListPromise }, + { name: "app.agents", promise: agentsPromise }, + { name: "config.get", promise: configPromise }, + { name: "global.config", promise: globalConfigPromise }, // kilocode_change + { name: "project.sync", promise: projectPromise }, + ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ] - await Promise.all(blockingRequests) + await Promise.allSettled(blockingRequests.map((r) => r.promise)) + .then((settled) => { + // Surface every failed endpoint in one labeled message instead of + // letting the first rejection drown its siblings as unhandled + // rejections. + const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) + if (failure) throw failure + }) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) @@ -798,10 +804,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => strip(x.info)) // kilocode_change - for (const message of messages.data!) { + const infos: (typeof draft.message)[string] = [] + for (const message of messages.data ?? []) { + infos.push(strip(message.info)) // kilocode_change draft.part[message.info.id] = message.parts } + draft.message[sessionID] = infos draft.session_diff[sessionID] = diff.data ?? [] }), ) diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index fbe5ce7f9ff..bebb1fc6aa6 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index b003bfe44e8..8ce51e65f50 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import { createCommandShim } from "./command-shim" type RouteEntry = { key: symbol @@ -151,7 +152,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.session.length }, diff(sessionID) { - return sync.data.session_diff[sessionID] ?? [] + return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => + item.file === undefined ? [] : [{ ...item, file: item.file }], + ) }, todo(sessionID) { return sync.data.todo[sessionID] ?? [] @@ -209,6 +212,8 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { formatSequence(parts) { return Keymap.formatKeySequence(parts, input.tuiConfig) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts new file mode 100644 index 00000000000..734cb772f04 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts @@ -0,0 +1,109 @@ +// Legacy `api.command` bridge for v1 plugins; remove in v2. +import type { TuiCommand, TuiPluginApi } from "@kilocode/plugin/tui" +import { TuiKeybind } from "../config/keybind" +import type { DialogContext } from "../ui/dialog" + +const COMMAND_PALETTE_SHOW = "command.palette.show" +const warned = new Set() + +type Warn = (api: string, replacement: string) => void +type LegacyDialog = TuiPluginApi["ui"]["dialog"] +type CommandShimDialog = DialogContext | LegacyDialog +type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] + +function warnCommandShim(api: string, replacement: string) { + // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. + console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) +} + +function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { + if (!("stack" in dialog)) return dialog + return { + replace(render, onClose) { + dialog.replace(render, onClose) + }, + clear() { + dialog.clear() + }, + setSize(size) { + dialog.setSize(size) + }, + get size() { + return dialog.size + }, + get depth() { + return dialog.stack.length + }, + get open() { + return dialog.stack.length > 0 + }, + } +} + +function warnOnce(api: string, replacement: string, warn: Warn) { + if (warned.has(api)) return + warned.add(api) + warn(api, replacement) +} + +function toCommand(item: TuiCommand, dialog: LegacyDialog) { + return { + namespace: "palette", + name: item.value, + title: item.title, + desc: item.description, + category: item.category, + suggested: item.suggested, + hidden: item.hidden, + enabled: item.enabled, + slashName: item.slash?.name, + slashAliases: item.slash?.aliases, + run() { + return item.onSelect?.(dialog) + }, + } +} + +function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) { + return commands.flatMap((item) => + item.keybind + ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + ? keybinds + .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title })) + : [ + { + key: item.keybind, + cmd: item.value, + desc: item.title, + }, + ] + : [], + ) +} + +export function createCommandShim( + keymap: TuiPluginApi["keymap"], + dialog: CommandShimDialog, + keybinds: LegacyKeybinds, +): TuiPluginApi["command"] { + const shimDialog = createCommandShimDialog(dialog) + return { + register(cb) { + warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim) + const commands = cb() + return keymap.registerLayer({ + commands: commands.map((item) => toCommand(item, shimDialog)), + bindings: toBindings(commands, keybinds), + }) + }, + trigger(value) { + warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim) + keymap.dispatchCommand(value) + }, + show() { + warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim) + keymap.dispatchCommand(COMMAND_PALETTE_SHOW) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index e430d11e53c..3d4f998c30f 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +import { createCommandShim } from "./command-shim" ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) @@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, keymap, route, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 3a19f1901f3..ce380504eb3 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index a7ec3922c08..d4bcb1d96a8 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -3,8 +3,8 @@ export * as ConfigAgent from "./agent" import path from "path" // kilocode_change import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index c8fce771410..b992aa83c3e 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -5,8 +5,8 @@ import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import * as ConfigMarkdown from "./markdown" import { ConfigModelID } from "./model-id" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 80e7850bdb4..4c695b1eac4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -26,8 +26,8 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -47,7 +47,7 @@ import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" // kilocode_change start -import { ZodOverride } from "@/util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { KilocodeConfig } from "../kilocode/config/config" import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" import { KilocodeGlobalConfigStamp } from "@/kilocode/config/global-stamp" @@ -386,7 +386,7 @@ export const Info = Schema.Struct({ })), ) -// Uses the shared `DeepMutable` from `@/util/schema`. See the definition +// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from // effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 0d4f20df917..485e334167a 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 8c1f09a2471..222a750057c 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,8 +1,8 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index 49c34b66398..a5299ea9557 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Layout = Schema.Literals(["auto", "stretch"]) .annotate({ identifier: "LayoutConfig" }) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e41..accfbee3b2f 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,8 +1,8 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 858d7693ccf..1a359ecd909 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema, SchemaGetter } from "effect" // kilocode_change -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" const LocalCanonical = Schema.Struct({ // kilocode_change diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 3ad9e035ce6..26fa2e0b346 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" // The original Zod schema carried an external $ref pointing at the models.dev // JSON schema. That external reference is not a named SDK component — it is a diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 9351047894a..f964ed4e150 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -3,7 +3,7 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" import z from "zod" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" type ZodSchema = z.ZodType diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index c024c1a491b..04f0ff2cafe 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,7 +1,7 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Action = Schema.NullOr(Schema.Literals(["ask", "allow", "deny"])) // kilocode_change - nullable allows null as a delete sentinel .annotate({ identifier: "PermissionActionConfig" }) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 9667dbb59ab..b1e3ec6f42e 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 0836a74111a..b53d93edfa2 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,7 +1,8 @@ import { Schema } from "effect" import { PROMPTS, AI_SDK_PROVIDERS } from "@kilocode/kilo-gateway" // kilocode_change -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), @@ -53,7 +54,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(ModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1e..36a8faff7ed 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,8 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const Git = Schema.Struct({ repository: Schema.String.annotate({ diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index b0116728a6c..3c5051749a9 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f29d854f50a..f707e922eee 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 5a0850a2495..dd4c325490f 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( Schema.brand("WorkspaceID"), diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ba5a778fac6..e78d728e040 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,7 +1,7 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4d5fc499fd1..af910c35a6b 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -15,8 +15,8 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 6b7b44c5741..484900101da 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index a61eb7be29a..c9ab433f118 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,8 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index d67afa76bef..31a21e90f77 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -130,6 +130,20 @@ export namespace KiloSessions { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise } + function transport(info: Session.Info): SDK.Session { + return { + ...info, + summary: info.summary + ? { + ...info.summary, + diffs: info.summary.diffs?.filter( + (diff): diff is typeof diff & { file: string } => diff.file !== undefined, + ), + } + : undefined, + } + } + async function getClient(): Promise { return withInFlightCache(clientKey, ttlMs, async () => { const token = await kilocodeToken() @@ -246,7 +260,7 @@ export namespace KiloSessions { if (!session) return await ingest.sync(sessionID, [ { type: "kilo_meta", data: await meta(sessionID) }, - { type: "session", data: session }, + { type: "session", data: transport(session) }, ]) }) yield* watch(MessageV2.Event.Updated, async (evt) => { @@ -682,7 +696,7 @@ export namespace KiloSessions { }, { type: "session", - data: session, + data: transport(session), }, ...messages.map((x) => ({ type: "message" as const, diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 43e5c543e1e..0811826b9ae 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -12,7 +12,7 @@ import { QuestionID } from "@/question/schema" import { ModelID, ProviderID } from "@/provider/schema" import * as Log from "@opencode-ai/core/util/log" import z from "zod" -import { zodObject } from "@/util/effect-zod" +import { zodObject } from "@opencode-ai/core/effect-zod" import { Effect } from "effect" type Provide = typeof import("@/project/with-instance").provide diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index 9339b1f026e..035e76b15c9 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -6,8 +6,8 @@ import { Identifier } from "@/id/id" import { Instance, type InstanceContext } from "@/project/instance" import { SessionID } from "@/session/schema" import { Shell } from "@/shell/shell" -import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@/util/schema" -import { zod, ZodOverride } from "@/util/effect-zod" +import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { spawn, type ChildProcess } from "child_process" diff --git a/packages/opencode/src/kilocode/indexing-event.ts b/packages/opencode/src/kilocode/indexing-event.ts index c15bbaf8513..7778a4e86c6 100644 --- a/packages/opencode/src/kilocode/indexing-event.ts +++ b/packages/opencode/src/kilocode/indexing-event.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { INDEXING_STATUS_STATES } from "@kilocode/kilo-indexing/status" import { BusEvent } from "@/bus/bus-event" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" export const IndexingStatusState = Schema.Literals(INDEXING_STATUS_STATES).annotate({ identifier: "IndexingStatusState", diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 9b887c47928..ae244235527 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -9,7 +9,7 @@ import { createKilo, type KiloProvider, AI_SDK_PROVIDERS, PROMPTS } from "@kilocode/kilo-gateway" import { DEFAULT_HEADERS } from "@/kilocode/const" import { ProviderID, ModelID } from "@/provider/schema" -import { optionalOmitUndefined } from "@/util/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Effect, Schema } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" import { mapValues, omit, pickBy } from "remeda" diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index b3e51d31919..eca8be488bc 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -4,11 +4,11 @@ import { createTwoFilesPatch } from "diff" import fs from "node:fs/promises" import path from "node:path" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { FileIgnore } from "@/file/ignore" import { Snapshot } from "@/snapshot" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" export namespace WorktreeDiff { export const Item = Schema.Struct({ diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/agent-builder.ts b/packages/opencode/src/kilocode/server/httpapi/groups/agent-builder.ts index bdafba64dc7..07a606c7d0f 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/agent-builder.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/agent-builder.ts @@ -1,6 +1,9 @@ import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -44,6 +47,7 @@ export const AgentBuilderApi = HttpApi.make("agent-builder") HttpApiGroup.make("agent-builder") .add( HttpApiEndpoint.post("preview", AgentBuilderPaths.preview, { + query: WorkspaceRoutingQuery, payload: AgentBuilderInput, success: described(AgentBuilderOutput, "Agent markdown preview"), error: HttpApiError.BadRequest, @@ -57,6 +61,7 @@ export const AgentBuilderApi = HttpApi.make("agent-builder") ), HttpApiEndpoint.put("save", AgentBuilderPaths.save, { params: { id: AgentBuilderID }, + query: WorkspaceRoutingQuery, payload: AgentBuilderSaveInput, success: described(AgentBuilderOutput, "Saved agent markdown"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts index 28e2be8a52e..e5b0dbc2dc7 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/background-process.ts @@ -4,7 +4,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/background-process" @@ -26,6 +29,7 @@ export const BackgroundProcessApi = HttpApi.make("background-process") HttpApiGroup.make("background-process") .add( HttpApiEndpoint.get("list", BackgroundProcessPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(BackgroundProcess.Info), "List of background processes"), }).annotateMerge( OpenApi.annotations({ @@ -36,6 +40,7 @@ export const BackgroundProcessApi = HttpApi.make("background-process") ), HttpApiEndpoint.get("get", BackgroundProcessPaths.get, { params: { processID: BackgroundProcess.ID }, + query: WorkspaceRoutingQuery, success: described(BackgroundProcess.Info, "Background process info"), error: HttpApiError.NotFound, }).annotateMerge( @@ -47,6 +52,7 @@ export const BackgroundProcessApi = HttpApi.make("background-process") ), HttpApiEndpoint.get("logs", BackgroundProcessPaths.logs, { params: { processID: BackgroundProcess.ID }, + query: WorkspaceRoutingQuery, success: described(BackgroundProcess.Logs, "Background process logs"), error: HttpApiError.NotFound, }).annotateMerge( @@ -58,6 +64,7 @@ export const BackgroundProcessApi = HttpApi.make("background-process") ), HttpApiEndpoint.post("stop", BackgroundProcessPaths.stop, { params: { processID: BackgroundProcess.ID }, + query: WorkspaceRoutingQuery, success: described(BackgroundProcess.Info, "Stopped background process"), error: HttpApiError.NotFound, }).annotateMerge( @@ -69,6 +76,7 @@ export const BackgroundProcessApi = HttpApi.make("background-process") ), HttpApiEndpoint.post("restart", BackgroundProcessPaths.restart, { params: { processID: BackgroundProcess.ID }, + query: WorkspaceRoutingQuery, success: described(BackgroundProcess.Info, "Restarted background process"), error: HttpApiError.NotFound, }).annotateMerge( @@ -80,6 +88,7 @@ export const BackgroundProcessApi = HttpApi.make("background-process") ), HttpApiEndpoint.post("stopSession", BackgroundProcessPaths.stopSession, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Stopped session background processes"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts b/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts index db40cd0d238..cd1c611f661 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/commit-message.ts @@ -2,7 +2,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/commit-message" @@ -26,6 +29,7 @@ export const CommitMessageApi = HttpApi.make("commit-message") HttpApiGroup.make("commit-message") .add( HttpApiEndpoint.post("generate", root, { + query: WorkspaceRoutingQuery, payload: CommitMessagePayload, success: described(CommitMessageResponse, "Generated commit message"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts index 4dcae7262ce..97713e967b9 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/config-console.ts @@ -3,7 +3,11 @@ import { ConfigPlugin } from "@/config/plugin" import { KilocodeKeybinds } from "@/kilocode/tui/keybinds" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -40,13 +44,19 @@ const Source = Schema.Struct({ reason: Schema.optional(Schema.String), }) -export const ConfigOverlayQuery = Schema.Struct({ scope: Schema.optional(Scoped) }) +export const ConfigOverlayQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + scope: Schema.optional(Scoped), +}) export const ConfigOverlayPatch = Schema.Struct({ scope: Schema.optional(Scoped), set: Schema.optional(UnknownRecord), unset: Schema.optional(Schema.Array(Schema.Array(Schema.String))), }) -export const ConfigRulesQuery = Schema.Struct({ scope: Schema.optional(ProjectScope) }) +export const ConfigRulesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + scope: Schema.optional(ProjectScope), +}) const ConfigRulesFile = Schema.Struct({ name: Schema.String, path: Schema.String, @@ -88,7 +98,10 @@ export const ConfigModelStateResponse = Schema.Struct({ variant: Schema.Record(Schema.String, Schema.String), }).annotate({ identifier: "ConfigModelStateResponse" }) -export const TuiConfigQuery = Schema.Struct({ scope: Schema.optional(TuiScoped) }) +export const TuiConfigQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + scope: Schema.optional(TuiScoped), +}) const TuiConfigShape = { $schema: Schema.optional(Schema.String), theme: Schema.optional(Schema.String), @@ -140,6 +153,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.get("sources", ConfigConsolePaths.sources, { + query: WorkspaceRoutingQuery, success: described(ConfigSourcesResponse, "Config source inventory"), }).annotateMerge( OpenApi.annotations({ @@ -149,6 +163,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.get("effective", ConfigConsolePaths.effective, { + query: WorkspaceRoutingQuery, success: described(Config.Info, "Effective config info"), }).annotateMerge( OpenApi.annotations({ @@ -158,6 +173,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.patch("overlayUpdate", ConfigConsolePaths.overlay, { + query: WorkspaceRoutingQuery, payload: ConfigOverlayPatch, success: described(Config.Info, "Effective configuration after patch"), }).annotateMerge( @@ -179,6 +195,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.put("rulesUpdate", ConfigConsolePaths.rules, { + query: WorkspaceRoutingQuery, payload: ConfigRulesPatch, success: described(ConfigRulesResponse, "Project rules after update"), }).annotateMerge( @@ -189,6 +206,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.get("modelState", ConfigConsolePaths.modelState, { + query: WorkspaceRoutingQuery, success: described(ConfigModelStateResponse, "Model state"), }).annotateMerge( OpenApi.annotations({ @@ -198,6 +216,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.patch("modelStateUpdate", ConfigConsolePaths.modelState, { + query: WorkspaceRoutingQuery, payload: ConfigModelStatePatch, success: described(ConfigModelStateResponse, "Updated model state"), }).annotateMerge( @@ -208,6 +227,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.get("tuiConfigGet", ConfigConsolePaths.tuiConfig, { + query: WorkspaceRoutingQuery, success: described(TuiConfigResponse, "Effective TUI configuration"), }).annotateMerge( OpenApi.annotations({ @@ -217,6 +237,7 @@ export const ConfigConsoleApi = HttpApi.make("config-console") }), ), HttpApiEndpoint.get("tuiKeybindList", ConfigConsolePaths.tuiKeybinds, { + query: WorkspaceRoutingQuery, success: described(TuiKeybindListResponse, "TUI keybind metadata"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/enhance-prompt.ts b/packages/opencode/src/kilocode/server/httpapi/groups/enhance-prompt.ts index 726783e9b1f..7894a2dbb27 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/enhance-prompt.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/enhance-prompt.ts @@ -2,7 +2,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/enhance-prompt" @@ -20,6 +23,7 @@ export const EnhancePromptApi = HttpApi.make("enhance-prompt") HttpApiGroup.make("enhance-prompt") .add( HttpApiEndpoint.post("enhance", root, { + query: WorkspaceRoutingQuery, payload: EnhancePromptPayload, success: described(EnhancePromptResponse, "Enhanced prompt text"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/indexing.ts b/packages/opencode/src/kilocode/server/httpapi/groups/indexing.ts index 67dbe024803..96917cf7a5c 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/indexing.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/indexing.ts @@ -2,7 +2,10 @@ import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable import { IndexingStatusInfo } from "@/kilocode/indexing-event" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" export { IndexingStatusInfo, IndexingStatusState } from "@/kilocode/indexing-event" @@ -18,6 +21,7 @@ export const IndexingApi = HttpApi.make("indexing") HttpApiGroup.make("indexing") .add( HttpApiEndpoint.get("status", IndexingPaths.status, { + query: WorkspaceRoutingQuery, success: described(IndexingStatusInfo, "Indexing status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts index 79112d93b27..15853c95dc4 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts @@ -2,7 +2,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/kilo" @@ -240,6 +244,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") HttpApiGroup.make("kilo") .add( HttpApiEndpoint.get("profile", KiloGatewayPaths.profile, { + query: WorkspaceRoutingQuery, success: described(ProfileWithBalance, "Profile data"), error: [HttpApiError.BadRequest, HttpApiError.Unauthorized], }).annotateMerge( @@ -250,6 +255,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.get("modes", KiloGatewayPaths.modes, { + query: WorkspaceRoutingQuery, success: described(OrganizationModes, "Organization modes list"), }).annotateMerge( OpenApi.annotations({ @@ -259,6 +265,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.post("fim", KiloGatewayPaths.fim, { + query: WorkspaceRoutingQuery, payload: FimBody, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), error: [HttpApiError.BadRequest, HttpApiError.Unauthorized], @@ -270,6 +277,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.post("edit", KiloGatewayPaths.edit, { + query: WorkspaceRoutingQuery, payload: EditBody, success: described(EditResponse, "Next Edit completion"), error: [HttpApiError.BadRequest, HttpApiError.Unauthorized], @@ -283,6 +291,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.post("audioTranscriptions", KiloGatewayPaths.audioTranscriptions, { + query: WorkspaceRoutingQuery, payload: AudioTranscriptionsBody, success: described(TranscriptionResponse, "Transcription response"), error: [HttpApiError.BadRequest, HttpApiError.Unauthorized], @@ -294,6 +303,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.get("notifications", KiloGatewayPaths.notifications, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Notification), "Notifications list"), error: [HttpApiError.BadRequest, HttpApiError.Unauthorized], }).annotateMerge( @@ -304,6 +314,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.post("organization", KiloGatewayPaths.organization, { + query: WorkspaceRoutingQuery, payload: OrganizationBody, success: described(Schema.Boolean, "Organization updated successfully"), error: [HttpApiError.BadRequest, HttpApiError.Unauthorized], @@ -315,6 +326,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.get("clawStatus", KiloGatewayPaths.clawStatus, { + query: WorkspaceRoutingQuery, success: described(ClawStatus, "Instance status"), error: [HttpApiError.Unauthorized, HttpApiError.ServiceUnavailable], }).annotateMerge( @@ -325,6 +337,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.get("clawChatCredentials", KiloGatewayPaths.clawChatCredentials, { + query: WorkspaceRoutingQuery, success: described(ClawChatCredentials, "Kilo Chat credentials or null"), error: HttpApiError.Unauthorized, }).annotateMerge( @@ -339,6 +352,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") ), HttpApiEndpoint.get("cloudSessions", KiloGatewayPaths.cloudSessions, { query: { + ...WorkspaceRoutingQueryFields, cursor: Schema.optional(Schema.String), limit: Schema.optional(Schema.String), gitUrl: Schema.optional(Schema.String), @@ -354,6 +368,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") ), HttpApiEndpoint.get("cloudSession", KiloGatewayPaths.cloudSession, { params: { id: Schema.String }, + query: WorkspaceRoutingQuery, success: described(CloudSessionData, "Cloud session data"), error: [HttpApiError.Unauthorized, HttpApiError.NotFound], }).annotateMerge( @@ -364,6 +379,7 @@ export const KiloGatewayApi = HttpApi.make("kilo") }), ), HttpApiEndpoint.post("cloudSessionImport", KiloGatewayPaths.cloudSessionImport, { + query: WorkspaceRoutingQuery, payload: CloudSessionImportBody, success: described(CloudSessionData.fields.info, "Imported session info"), error: [HttpApiError.BadRequest, HttpApiError.Unauthorized, HttpApiError.NotFound], diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 1a4d223d2fe..20c13ae84b9 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -2,7 +2,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/kilocode" @@ -26,6 +29,7 @@ export const KilocodeApi = HttpApi.make("kilocode") HttpApiGroup.make("kilocode") .add( HttpApiEndpoint.post("heapSnapshot", KilocodePaths.heapSnapshot, { + query: WorkspaceRoutingQuery, success: described(Schema.String, "Heap snapshot file path"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -36,6 +40,7 @@ export const KilocodeApi = HttpApi.make("kilocode") }), ), HttpApiEndpoint.post("removeSkill", KilocodePaths.removeSkill, { + query: WorkspaceRoutingQuery, payload: RemoveSkillPayload, success: described(Schema.Boolean, "Skill removed"), error: HttpApiError.BadRequest, @@ -47,6 +52,7 @@ export const KilocodeApi = HttpApi.make("kilocode") }), ), HttpApiEndpoint.post("removeAgent", KilocodePaths.removeAgent, { + query: WorkspaceRoutingQuery, payload: RemoveAgentPayload, success: described(Schema.Boolean, "Agent removed"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/network.ts b/packages/opencode/src/kilocode/server/httpapi/groups/network.ts index 02391ba3166..acc7a8f42a4 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/network.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/network.ts @@ -4,7 +4,10 @@ import { QuestionID } from "@/question/schema" import { SessionNetwork } from "@/session/network" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/network" @@ -20,6 +23,7 @@ export const NetworkApi = HttpApi.make("network") HttpApiGroup.make("network") .add( HttpApiEndpoint.get("list", NetworkPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(SessionNetwork.Wait), "List of pending network reconnect requests"), }).annotateMerge( OpenApi.annotations({ @@ -30,6 +34,7 @@ export const NetworkApi = HttpApi.make("network") ), HttpApiEndpoint.post("reply", NetworkPaths.reply, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Network wait resumed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -41,6 +46,7 @@ export const NetworkApi = HttpApi.make("network") ), HttpApiEndpoint.post("reject", NetworkPaths.reject, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Network wait rejected successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/remote.ts b/packages/opencode/src/kilocode/server/httpapi/groups/remote.ts index 35d8fde73ba..3be254f8a87 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/remote.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/remote.ts @@ -2,7 +2,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/remote" @@ -23,6 +26,7 @@ export const RemoteApi = HttpApi.make("remote") HttpApiGroup.make("remote") .add( HttpApiEndpoint.post("enable", RemotePaths.enable, { + query: WorkspaceRoutingQuery, success: described(RemoteStatus, "Remote connection enabled"), error: HttpApiError.Unauthorized, }).annotateMerge( @@ -33,6 +37,7 @@ export const RemoteApi = HttpApi.make("remote") }), ), HttpApiEndpoint.post("disable", RemotePaths.disable, { + query: WorkspaceRoutingQuery, success: described(RemoteStatus, "Remote connection disabled"), }).annotateMerge( OpenApi.annotations({ @@ -42,6 +47,7 @@ export const RemoteApi = HttpApi.make("remote") }), ), HttpApiEndpoint.get("status", RemotePaths.status, { + query: WorkspaceRoutingQuery, success: described(RemoteStatus, "Remote connection status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts b/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts index 4633a5fa156..d2cade79110 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/session-import.ts @@ -2,7 +2,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/kilocode/session-import" @@ -227,6 +230,7 @@ export const SessionImportApi = HttpApi.make("session-import") HttpApiGroup.make("session-import") .add( HttpApiEndpoint.post("project", SessionImportPaths.project, { + query: WorkspaceRoutingQuery, payload: ProjectSchema, success: described(ResultSchema, "Project import result"), error: HttpApiError.BadRequest, @@ -238,6 +242,7 @@ export const SessionImportApi = HttpApi.make("session-import") }), ), HttpApiEndpoint.post("session", SessionImportPaths.session, { + query: WorkspaceRoutingQuery, payload: SessionSchema, success: described(ResultSchema, "Session import result"), error: HttpApiError.BadRequest, @@ -249,6 +254,7 @@ export const SessionImportApi = HttpApi.make("session-import") }), ), HttpApiEndpoint.post("message", SessionImportPaths.message, { + query: WorkspaceRoutingQuery, payload: MessageSchema, success: described(ResultSchema, "Message import result"), error: HttpApiError.BadRequest, @@ -260,6 +266,7 @@ export const SessionImportApi = HttpApi.make("session-import") }), ), HttpApiEndpoint.post("part", SessionImportPaths.part, { + query: WorkspaceRoutingQuery, payload: PartSchema, success: described(ResultSchema, "Part import result"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/suggestion.ts b/packages/opencode/src/kilocode/server/httpapi/groups/suggestion.ts index c36998c4740..f63cfaf9e53 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/suggestion.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/suggestion.ts @@ -3,7 +3,10 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e import { Suggestion } from "@/kilocode/suggestion" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/suggestion" @@ -27,6 +30,7 @@ export const SuggestionApi = HttpApi.make("suggestion") HttpApiGroup.make("suggestion") .add( HttpApiEndpoint.get("list", SuggestionPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Suggestion.RequestSchema), "List of pending suggestions"), }).annotateMerge( OpenApi.annotations({ @@ -37,6 +41,7 @@ export const SuggestionApi = HttpApi.make("suggestion") ), HttpApiEndpoint.post("accept", SuggestionPaths.accept, { params: { requestID: SuggestionRequestID }, + query: WorkspaceRoutingQuery, payload: SuggestionAcceptPayload, success: described(Schema.Boolean, "Suggestion accepted successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -49,6 +54,7 @@ export const SuggestionApi = HttpApi.make("suggestion") ), HttpApiEndpoint.post("dismiss", SuggestionPaths.dismiss, { params: { requestID: SuggestionRequestID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Suggestion dismissed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/telemetry.ts b/packages/opencode/src/kilocode/server/httpapi/groups/telemetry.ts index ae1e01ef5c7..5e885630fbd 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/telemetry.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/telemetry.ts @@ -2,7 +2,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization" import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, +} from "@/server/routes/instance/httpapi/middleware/workspace-routing" import { described } from "@/server/routes/instance/httpapi/groups/metadata" const root = "/telemetry" @@ -28,6 +31,7 @@ export const TelemetryApi = HttpApi.make("telemetry") HttpApiGroup.make("telemetry") .add( HttpApiEndpoint.post("capture", TelemetryPaths.capture, { + query: WorkspaceRoutingQuery, payload: TelemetryCapturePayload, success: described(Schema.Boolean, "Event captured"), error: HttpApiError.BadRequest, @@ -39,6 +43,7 @@ export const TelemetryApi = HttpApi.make("telemetry") }), ), HttpApiEndpoint.post("setEnabled", TelemetryPaths.setEnabled, { + query: WorkspaceRoutingQuery, payload: TelemetrySetEnabledPayload, success: described(Schema.Boolean, "State updated"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 7ff0b20d726..44863ac01a5 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -124,6 +124,11 @@ export namespace KiloSessionPrompt { return input.agent.permission } + export function mergeToolPermissions(input: { existing: Permission.Ruleset; toggles: Permission.Ruleset }) { + const names = new Set(input.toggles.map((rule) => rule.permission)) + return [...input.existing.filter((rule) => !names.has(rule.permission)), ...input.toggles] + } + export const askPermission = Effect.fn("KiloSessionPrompt.askPermission")(function* (input: { permission: Pick agents: Pick diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts index 62c579cb3c0..44c23c6da16 100644 --- a/packages/opencode/src/kilocode/suggestion/index.ts +++ b/packages/opencode/src/kilocode/suggestion/index.ts @@ -2,7 +2,7 @@ import { Bus } from "../../bus" import { BusEvent } from "../../bus/bus-event" import { Identifier } from "../../id/id" import { SessionID } from "../../session/schema" -import { ZodOverride } from "../../util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" import { Telemetry } from "@kilocode/kilo-telemetry" import z from "zod" diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 90514a80e05..c7fae25e18c 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -59,6 +59,18 @@ export namespace KiloTask { return [{ permission: "task", pattern: "*", action: "deny" }, ...rules] } + export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset { + const result: Permission.Ruleset = [] + const seen = new Set() + for (const rule of rulesets.flat()) { + const key = `${rule.permission}\u0000${rule.pattern}\u0000${rule.action}` + if (seen.has(key)) continue + seen.add(key) + result.push(rule) + } + return result + } + type Model = { providerID: ProviderID; modelID: ModelID } type Saved = Model & { variant?: string } type Choice = { model: Model; variant?: string; sticky?: boolean; direct?: boolean } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 201da1a9eaa..dadd02a5c8f 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -14,8 +14,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" import { TsClient } from "../kilocode/ts-client" // kilocode_change -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod, ZodOverride } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) diff --git a/packages/opencode/src/markdown.d.ts b/packages/opencode/src/markdown.d.ts new file mode 100644 index 00000000000..eb3e3b92d66 --- /dev/null +++ b/packages/opencode/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 6f3bbc266bd..31cbbb828aa 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -15,6 +15,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -39,13 +40,13 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod as effectZod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 -// kilocode_change start — inject --rm for Docker containers to prevent stopped container accumulation +// kilocode_change start - inject --rm for Docker containers to prevent stopped container accumulation export function ensureDockerRm(cmd: string, args: string[]): string[] { const isDocker = cmd === "docker" || cmd === "podman" if (!isDocker) return args @@ -59,6 +60,15 @@ export function ensureDockerRm(cmd: string, args: string[]): string[] { } // kilocode_change end +const TolerantToolSchema = ToolSchema.extend({ + outputSchema: z.unknown().optional(), +}) + +const TolerantListToolsResultSchema = z.looseObject({ + tools: z.array(TolerantToolSchema), + nextCursor: z.string().optional(), +}) + export const Resource = Schema.Struct({ name: Schema.String, uri: Schema.String, @@ -142,6 +152,38 @@ function remoteURL(key: string, value: string) { log.warn("invalid remote mcp url", { key }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + +function listTools(key: string, client: MCPClient, timeout: number) { + return Effect.tryPromise({ + try: () => client.listTools(undefined, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => result.tools), + Effect.catch((error) => { + if (!isOutputSchemaValidationError(error)) return Effect.fail(error) + + log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) + return Effect.tryPromise({ + try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => + result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ) + }), + ) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -174,11 +216,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number } function defs(key: string, client: MCPClient, timeout?: number) { - return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => result.tools), + return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( Effect.catch((err) => { log.error("failed to get tools from client", { key, error: err }) return Effect.succeed(undefined) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index c5ee7406378..cbc14bb208c 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -8,9 +8,9 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index e8bdb2ea20e..725030935db 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 21a8eb19fd7..66e6319a52a 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index 7708b8de1ee..c6cff94fde6 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 02173453dbf..092444c444c 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, zodObject } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 @@ -230,9 +230,12 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Mirrors Snapshot.FileDiff (see #26574). The current producer always + // populates patch, but loosening matches the sibling schema so a + // future code path that omits it can't crash /instance/vcs/diff. + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) @@ -241,8 +244,8 @@ export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ file: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "VcsFileStatus" }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 71cdbc30898..297e3ee8846 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,9 @@ import type { AuthOAuthResult, Hooks } from "@kilocode/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" diff --git a/packages/opencode/src/provider/model-status.ts b/packages/opencode/src/provider/model-status.ts new file mode 100644 index 00000000000..468b59ce39d --- /dev/null +++ b/packages/opencode/src/provider/model-status.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) +export type CatalogModelStatus = typeof CatalogModelStatus.Type + +export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +export type ModelStatus = typeof ModelStatus.Type + +export * as ProviderModelStatus from "./model-status" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index e3ef43fd60f..07db0e29369 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { CatalogModelStatus } from "./model-status" // kilocode_change start import { Config } from "../config/config" import { ModelCache } from "./model-cache" @@ -98,7 +99,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(CatalogModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 93a513f5e71..74b283aaa9b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" @@ -24,10 +24,11 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { ModelStatus } from "./model-status" // kilocode_change start import { KILO_BUNDLED_PROVIDERS, @@ -925,7 +926,7 @@ export const Model = Schema.Struct({ capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, @@ -965,6 +966,16 @@ export const ConfigProvidersResult = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ConfigProvidersResult = Types.DeepMutable> +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify(provider, (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }), + ) +} + export function defaultModelIDs }>(providers: Record) { return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) } @@ -1183,7 +1194,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, @@ -1342,7 +1353,7 @@ const layer: Layer.Layer< const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], + toPublicInfo(database[plugin.auth!.provider]), ), ) const opts = options ?? {} diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index aa75b0c2198..82169e4bbad 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 766d5eb8f7e..fd29d6982d0 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -11,8 +11,8 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" import { SessionID } from "@/session/schema" // kilocode_change const log = Log.create({ service: "pty" }) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 6b4d779f261..0f1d6996df5 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index b5e5747c513..0978e520837 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket" import { WorkspaceID } from "@/control-plane/schema" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PtyID } from "@/pty/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" const DEFAULT_TTL = Duration.seconds(60) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 3440fb67d2f..d062eafe965 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,9 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" import { KiloQuestion } from "@/kilocode/question" // kilocode_change diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 7dade9cdfa9..c18eca3e23e 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class QuestionID extends Newtype()( "QuestionID", diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 52a719df21c..05160f55aaa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" +import { WorkspaceRoutingQuery } from "./middleware/workspace-routing" const log = Log.create({ service: "server" }) @@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { + query: WorkspaceRoutingQuery, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index c8afcc557fc..9ca3f574c51 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" // kilocode_change import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -22,6 +22,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { + query: WorkspaceRoutingQuery, success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ @@ -31,6 +32,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.patch("update", root, { + query: WorkspaceRoutingQuery, payload: Config.Info, success: described(Config.Info, "Successfully updated config"), error: HttpApiError.BadRequest, @@ -43,6 +45,7 @@ export const ConfigApi = HttpApi.make("config") ), // kilocode_change start HttpApiEndpoint.get("warnings", `${root}/warnings`, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Warning), "Config warnings"), }).annotateMerge( OpenApi.annotations({ @@ -53,6 +56,7 @@ export const ConfigApi = HttpApi.make("config") ), // kilocode_change end HttpApiEndpoint.get("providers", `${root}/providers`, { + query: WorkspaceRoutingQuery, success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 381959bb48c..6d148e3559c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -5,13 +5,18 @@ import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { WorktreeDiff } from "@/kilocode/review/worktree-diff" // kilocode_change import { Worktree } from "@/worktree" -import { NonNegativeInt } from "@/util/schema" -import { Schema, SchemaGetter } from "effect" +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" +import { QueryBoolean } from "./query" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -45,21 +50,16 @@ const ToolListItem = Schema.Struct({ }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, provider: ProviderID, model: ModelID, }) -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, // kilocode_change start projectID: Schema.optional(Schema.String), - directory: Schema.optional(Schema.String), worktrees: Schema.optional(QueryBoolean), // kilocode_change end roots: Schema.optional(QueryBoolean), @@ -71,9 +71,11 @@ export const SessionListQuery = Schema.Struct({ }) // kilocode_change start export const WorktreeDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, base: Schema.optional(Schema.String), }) export const WorktreeDiffFileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, base: Schema.optional(Schema.String), file: Schema.String, }) @@ -99,6 +101,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { + query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -109,6 +112,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { + query: WorkspaceRoutingQuery, success: described(ConsoleOrgList, "Switchable Console orgs"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -119,6 +123,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { + query: WorkspaceRoutingQuery, payload: ConsoleSwitchPayload, success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, @@ -142,6 +147,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { + query: WorkspaceRoutingQuery, success: described(ToolIDs, "Tool IDs"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -153,6 +159,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ @@ -162,6 +169,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), error: HttpApiError.BadRequest, @@ -173,6 +181,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), error: HttpApiError.BadRequest, @@ -184,6 +193,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { + query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), error: HttpApiError.BadRequest, @@ -241,6 +251,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383e..c636e583d7b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,18 +5,25 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, path: Schema.String, }) export const FindTextQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, pattern: Schema.String, }) export const FindFileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -26,6 +33,7 @@ export const FindFileQuery = Schema.Struct({ }) export const FindSymbolQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, }) @@ -93,6 +101,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05f..ea8db35035d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -20,6 +24,7 @@ const PathInfo = Schema.Struct({ }).annotate({ identifier: "Path" }) export const VcsDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, mode: Vcs.Mode, }) @@ -54,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ @@ -63,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("path", InstancePaths.path, { + query: WorkspaceRoutingQuery, success: PathInfo, }).annotateMerge( OpenApi.annotations({ @@ -73,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { + query: WorkspaceRoutingQuery, success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Vcs.FileStatus), "VCS status"), }).annotateMerge( OpenApi.annotations({ @@ -102,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + query: WorkspaceRoutingQuery, success: described( Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), "Raw VCS diff", @@ -114,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + query: WorkspaceRoutingQuery, payload: Vcs.ApplyInput, success: described(Vcs.ApplyResult, "VCS patch applied"), error: ApiVcsApplyError, @@ -125,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ @@ -134,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ @@ -143,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ @@ -152,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ @@ -161,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index b30714c196a..c7ed4a9b95d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ @@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp") }), ), HttpApiEndpoint.post("add", McpPaths.status, { + query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, @@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthStartResponse, "OAuth flow started"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, payload: AuthCallbackPayload, success: described(MCP.Status, "OAuth authentication completed"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(MCP.Status, "OAuth authentication completed"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthRemoveResponse, "OAuth credentials removed"), error: HttpApiError.NotFound, }).annotateMerge( @@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ @@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index e45d0987ce2..f2f41eae511 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" import { ApiNotFoundError } from "../errors" // kilocode_change @@ -32,6 +32,7 @@ export const PermissionApi = HttpApi.make("permission") HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ @@ -42,6 +43,7 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], // kilocode_change @@ -55,6 +57,7 @@ export const PermissionApi = HttpApi.make("permission") // kilocode_change start HttpApiEndpoint.post("saveAlwaysRules", `${root}/:requestID/always-rules`, { params: { requestID: PermissionID }, + query: WorkspaceRoutingQuery, payload: SaveAlwaysRulesBody, success: described(Schema.Boolean, "Always-rules saved"), error: [ApiNotFoundError], @@ -66,6 +69,7 @@ export const PermissionApi = HttpApi.make("permission") }), ), HttpApiEndpoint.post("allowEverything", `${root}/allow-everything`, { + query: WorkspaceRoutingQuery, payload: AllowEverythingBody, success: described(Schema.Boolean, "Success"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 1a2084547d0..f95199eb011 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project") HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ @@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ @@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 4a9bbffc549..49792898df3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider") HttpApiGroup.make("provider") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Provider.ListResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ @@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider") }), ), HttpApiEndpoint.get("auth", `${root}/auth`, { + query: WorkspaceRoutingQuery, success: described(ProviderAuth.Methods, "Provider auth methods"), }).annotateMerge( OpenApi.annotations({ @@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.AuthorizeInput, success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), error: HttpApiError.BadRequest, @@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.CallbackInput, success: described(Schema.Boolean, "OAuth callback processed successfully"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index ad513e0ad41..1391d2a919a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,13 +5,20 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" export const Params = Schema.Struct({ ptyID: PtyID }) -export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const CursorQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + cursor: Schema.optional(Schema.String), +}) export const ShellItem = Schema.Struct({ path: Schema.String, name: Schema.String, @@ -34,6 +41,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiGroup.make("pty") .add( HttpApiEndpoint.get("shells", PtyPaths.shells, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(ShellItem), "List of shells"), }).annotateMerge( OpenApi.annotations({ @@ -43,6 +51,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.get("list", PtyPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Pty.Info), "List of sessions"), }).annotateMerge( OpenApi.annotations({ @@ -52,6 +61,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.post("create", PtyPaths.create, { + query: WorkspaceRoutingQuery, payload: Pty.CreateInput, success: described(Pty.Info, "Created session"), error: HttpApiError.BadRequest, @@ -64,6 +74,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Pty.Info, "Session info"), error: ApiNotFoundError, }).annotateMerge( @@ -75,6 +86,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.put("update", PtyPaths.update, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -87,6 +99,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session removed"), error: ApiNotFoundError, }).annotateMerge( @@ -98,6 +111,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( @@ -126,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Connected session"), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts new file mode 100644 index 00000000000..d5b10d18003 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -0,0 +1,8 @@ +import { Schema, SchemaGetter } from "effect" + +export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de2d4fca8ec..35cd3314b54 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question") HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ @@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Question answered successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Question rejected successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 3637eb78b14..e1618edc4ec 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,23 +10,22 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { Schema, SchemaGetter, Struct } from "effect" +import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" +import { QueryBoolean } from "./query" const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) export const ListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -34,8 +33,12 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const DiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), +}) export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) @@ -119,6 +122,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.get("status", SessionPaths.status, { + query: WorkspaceRoutingQuery, success: described(StatusMap, "Get session status"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -130,6 +134,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Get session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -141,6 +146,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Session.Info), "List of children"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -152,6 +158,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("todo", SessionPaths.todo, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Todo.Info), "Todo list"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -186,6 +193,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(MessageV2.WithParts, "Message"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -196,6 +204,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.post("create", SessionPaths.create, { + query: WorkspaceRoutingQuery, payload: [HttpApiSchema.NoContent, Session.CreateInput], success: described(Session.Info, "Successfully created session"), error: HttpApiError.BadRequest, @@ -208,6 +217,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -219,6 +229,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -231,6 +242,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: [HttpApiSchema.NoContent, ForkPayload], // kilocode_change - carry upstream bodyless full-session fork support success: described(Session.Info, "200"), error: [HttpApiError.BadRequest, ApiNotFoundError], // kilocode_change - carry upstream malformed payload response @@ -243,6 +255,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("abort", SessionPaths.abort, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Aborted session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -254,6 +267,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("init", SessionPaths.init, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: InitPayload, success: described(Schema.Boolean, "200"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -267,6 +281,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully shared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -278,6 +293,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully unshared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -289,6 +305,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("summarize", SessionPaths.summarize, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -301,6 +318,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("prompt", SessionPaths.prompt, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -313,6 +331,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(HttpApiSchema.NoContent, "Prompt accepted"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -326,6 +345,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("command", SessionPaths.command, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -338,6 +358,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("shell", SessionPaths.shell, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ShellPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -350,6 +371,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: RevertPayload, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -363,6 +385,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -374,6 +397,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { params: { sessionID: SessionID, permissionID: PermissionID }, + query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -387,6 +411,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -399,6 +424,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -409,6 +435,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, payload: MessageV2.Part, success: described(MessageV2.Part, "Successfully updated part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -420,6 +447,7 @@ export const SessionApi = HttpApi.make("session") ), // kilocode_change start HttpApiEndpoint.post("viewed", SessionPaths.viewed, { + query: WorkspaceRoutingQuery, payload: ViewedPayload, success: described(Schema.Boolean, "Viewed sessions updated"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 442e6565547..38a93240eb7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,10 +1,10 @@ -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiGroup.make("sync") .add( HttpApiEndpoint.post("start", SyncPaths.start, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Workspace sync started"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("replay", SyncPaths.replay, { + query: WorkspaceRoutingQuery, payload: ReplayPayload, success: described(ReplayResponse, "Replayed sync events"), error: HttpApiError.BadRequest, @@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("steal", SyncPaths.steal, { + query: WorkspaceRoutingQuery, payload: SessionPayload, success: described(SessionPayload, "Session stolen into workspace"), error: HttpApiError.BadRequest, @@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("history", SyncPaths.history, { + query: WorkspaceRoutingQuery, payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 3676fc1964c..3cf3de5b8eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiGroup.make("tui") .add( HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + query: WorkspaceRoutingQuery, payload: TuiEvent.PromptAppend.properties, success: described(Schema.Boolean, "Prompt processed successfully"), error: HttpApiError.BadRequest, @@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Help dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Theme dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openModels", TuiPaths.openModels, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Model dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt submitted successfully"), }).annotateMerge( OpenApi.annotations({ @@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt cleared successfully"), }).annotateMerge( OpenApi.annotations({ @@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(Schema.Boolean, "Command executed successfully"), error: HttpApiError.BadRequest, @@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + query: WorkspaceRoutingQuery, payload: TuiEvent.ToastShow.properties, success: described(Schema.Boolean, "Toast notification shown successfully"), }).annotateMerge( @@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("publish", TuiPaths.publish, { + query: WorkspaceRoutingQuery, payload: TuiPublishPayload, success: described(Schema.Boolean, "Event published successfully"), error: HttpApiError.BadRequest, @@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + query: WorkspaceRoutingQuery, payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { + query: WorkspaceRoutingQuery, success: described(TuiRequestPayload, "Next TUI request"), }).annotateMerge( OpenApi.annotations({ @@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + query: WorkspaceRoutingQuery, payload: Schema.Unknown, success: described(Schema.Boolean, "Response submitted successfully"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b10..060c6c8a83e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "V2SessionMessagesQuery" }) export const MessageGroup = HttpApiGroup.make("v2.message") .add( HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { params: { sessionID: SessionID }, - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Message order for the first page. Use desc for newest first or asc for oldest first.", - }), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionMessagesQuery" }), + query: MessagesQuery, success: Schema.Struct({ items: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3b..231f1915bb3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,67 +1,39 @@ -import { WorkspaceID } from "@/control-plane/schema" import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" import { SessionV2 } from "@/v2/session" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" +import { QueryBoolean } from "../query" + +export const SessionsQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.", + }), + ), +}).annotate({ identifier: "V2SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - directory: Schema.String.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - workspace: WorkspaceID.pipe(Schema.optional), - roots: Schema.Literals(["true", "false"]) - .pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), - ) - .pipe(Schema.optional), - start: Schema.NumberFromString.pipe(Schema.optional), - search: Schema.String.pipe(Schema.optional), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), - path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), - roots: Schema.optional(Schema.Never), - start: Schema.optional(Schema.Never), - search: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionsQuery" }), + query: SessionsQuery, success: Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ @@ -82,6 +54,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: Schema.Struct({ prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), @@ -98,6 +71,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -110,6 +84,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -122,6 +97,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 1a9732800db..1c40ae3cb8d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" @@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace") HttpApiGroup.make("workspace") .add( HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ @@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.Info), "Workspaces"), }).annotateMerge( OpenApi.annotations({ @@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("create", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, payload: CreatePayload, success: described(Workspace.Info, "Workspace created"), error: HttpApiError.BadRequest, @@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, { + query: WorkspaceRoutingQuery, success: described(HttpApiSchema.NoContent, "Workspace list synced"), }).annotateMerge( OpenApi.annotations({ @@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("status", WorkspacePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), }).annotateMerge( OpenApi.annotations({ @@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace") ), HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { params: { id: Workspace.Info.fields.id }, + query: WorkspaceRoutingQuery, success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + query: WorkspaceRoutingQuery, payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), error: [ApiWorkspaceWarpError, ApiVcsApplyError], diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 5c20f5c5131..67d81d2a51d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -48,7 +48,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h // kilocode_change end return { - providers: Object.values(providers), + providers: Object.values(providers).map(Provider.toPublicInfo), default: defaults, } }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 51c170ffeb7..f90d23893a8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -7,7 +7,7 @@ import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { Filesystem } from "@/util/filesystem" // kilocode_change import { Review } from "@/kilocode/review/review" // kilocode_change import { WorktreeDiff } from "@/kilocode/review/worktree-diff" // kilocode_change diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 3f2bf3618b7..ac3d0d563a2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -42,7 +42,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" (item, id) => Object.keys(item.models).length > 0 || id in connected || failedSet.has(id), ) return { - all: Object.values(validProviders), + all: Object.values(validProviders).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)), connected: Object.keys(connected), failed, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 3485d80fd63..92e37142b40 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message return handlers.handle( "messages", Effect.fn(function* (ctx) { + if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 558e34dd184..275fa2956c4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type const decodeCursor = Schema.decodeUnknownSync(SessionCursor) +function hasCursorFilter(query: { + readonly order?: unknown + readonly path?: unknown + readonly roots?: unknown + readonly start?: unknown + readonly search?: unknown +}) { + return ( + query.order !== undefined || + query.path !== undefined || + query.roots !== undefined || + query.start !== undefined || + query.search !== undefined + ) +} + +function hasCursorRoutingMismatch( + query: { readonly directory?: string; readonly workspace?: string }, + decoded: SessionCursor | undefined, +) { + if (!decoded) return false + if (query.directory !== undefined && query.directory !== decoded.directory) return true + return query.workspace !== undefined && query.workspace !== decoded.workspaceID +} + const sessionCursor = { encode( session: SessionV2.Info, @@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { + if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), }) + if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({}) const order = decoded?.order ?? ctx.query.order ?? "desc" const filters = decoded ?? { directory: ctx.query.directory, diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 5a018f16da6..6f3c33a647a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -6,39 +6,13 @@ import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError } from "effect/unstable/httpapi" -import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError" const log = Log.create({ service: "server" }) -function badRequestResponse() { - return HttpServerResponse.jsonUnsafe( - { - data: {}, - errors: [], - success: false, - }, - { status: 400 }, - ) -} - -function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) { - if (response.status !== 400 || response.body._tag !== "Empty") return response - return badRequestResponse() -} - // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => effect.pipe( - Effect.catch((error) => { - if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse()) - return Effect.fail(error) - }), - Effect.map(normalizeEmptyBadRequest), Effect.catchCause((cause) => { - const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect)) - if (schemaError) return Effect.succeed(badRequestResponse()) - const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false if (HttpServerError.isHttpServerError(reason.defect)) return false diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 017a70b91aa..0343ccfc87f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,11 +9,23 @@ import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Data, Effect, Layer } from "effect" +import { Context, Data, Effect, Layer, Schema } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" +// Query fields this middleware reads from the URL. Spread into every +// endpoint query schema in groups that apply WorkspaceRoutingMiddleware, +// otherwise HttpApi rejects requests carrying these params with 400. +// HttpApiMiddleware in effect-smol cannot declare query params today — +// remove this once upstream supports middleware-declared query schemas. +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + +export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields) + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index be91038c541..dfd30bafd50 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -53,49 +53,40 @@ type OpenApiResponse = { content?: Record } -// Instance routes use middleware for directory/workspace resolution, but HttpApi -// doesn't surface middleware query params in the spec. Inject them explicitly. -const InstanceQueryParameters = [ - { - name: "directory", - in: "query", - required: false, - schema: { type: "string" }, - }, - { - name: "workspace", - in: "query", - required: false, - schema: { type: "string" }, - }, -] satisfies OpenApiParameter[] - // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) -const QueryParameterSchemas = { +const QueryParameterSchemas: Record = { + "GET /experimental/session start": { type: "number" }, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "GET /experimental/session worktrees": { type: "boolean" }, // kilocode_change "GET /kilo/cloud-sessions cursor": { type: "string" }, // kilocode_change + "GET /kilo/cloud-sessions limit": { type: "number" }, // kilocode_change + "GET /experimental/session cursor": { type: "number" }, + "GET /experimental/session limit": { type: "number" }, + "GET /session start": { type: "number" }, + "GET /session limit": { type: "number" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, -} satisfies Record + "GET /api/session limit": { type: "number" }, + "GET /api/session start": { type: "number" }, + "GET /api/session/{sessionID}/message limit": { type: "number" }, +} -const PathParameterSchemas = { +const PathParameterSchemas: Record = { sessionID: { type: "string", pattern: "^ses.*" }, messageID: { type: "string", pattern: "^msg.*" }, partID: { type: "string", pattern: "^prt.*" }, permissionID: { type: "string", pattern: "^per.*" }, ptyID: { type: "string", pattern: "^pty.*" }, -} satisfies Record +} -const LegacyComponentDescriptions = { +const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for the kilo serve command", // kilocode_change LayoutConfig: "@deprecated Always uses stretch layout.", -} satisfies Record +} function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec @@ -126,7 +117,6 @@ function matchLegacyOpenApi(input: Record) { delete spec.components?.securitySchemes for (const [path, item] of Object.entries(spec.paths ?? {})) { - const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue @@ -187,14 +177,8 @@ function matchLegacyOpenApi(input: Record) { }, } } - if (!isInstanceRoute) continue - operation.parameters = [ - ...InstanceQueryParameters, - ...(operation.parameters ?? []).filter( - (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), - ), - ] - for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) + const route = `${method.toUpperCase()} ${path}` + for (const param of operation.parameters ?? []) normalizeParameter(param, route) } } matchLegacyKiloOpenApi(input) // kilocode_change @@ -302,7 +286,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) { function normalizeComponentDescriptions(spec: OpenApiSpec) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { - const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + const description = LegacyComponentDescriptions[name] if (description) { schema.description = description continue @@ -448,7 +432,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { } } // Simplest fix: generate the raw spec (without transform) to get correct schemas - const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi) const rawSchemas = raw.components?.schemas if (!rawSchemas) return for (const name of selfRefs) { @@ -517,15 +501,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) { return } if (param.in === "query") { - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + const override = QueryParameterSchemas[`${route} ${param.name}`] if (override) { param.schema = override return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } if (QueryBooleanParameters.has(param.name)) { param.schema = { anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], @@ -537,7 +517,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name in PathParameterSchemas) return PathParameterSchemas[name] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "processID" && route.includes(" /background-process/")) return { type: "string", pattern: "^bgp.*" } // kilocode_change diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 38be168c2d3..5c56352ea2f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -26,8 +26,8 @@ import { SessionNetwork } from "./network" // kilocode_change import { CodexAuthExpiredError } from "@/kilocode/provider/codex-refresh" // kilocode_change import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change import { Effect, Schema, Types } from "effect" -import { zod, ZodOverride } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -146,8 +146,8 @@ export type ReasoningPart = Types.DeepMutable 0) { - session.permission = permissions - yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) + // kilocode_change start - preserve inherited task restrictions while refreshing prompt tool toggles + const merged = KiloSessionPrompt.mergeToolPermissions({ + existing: session.permission ?? [], + toggles: permissions, + }) + session.permission = merged + yield* sessions.setPermission({ sessionID: session.id, permission: merged }) + // kilocode_change end } // kilocode_change start — unblock tools waiting on user input so any in-flight diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index f5fb9849258..9f41bf190bc 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -4,8 +4,8 @@ import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" import { SyncEvent } from "../sync" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 487cbcd34a7..d0e6cd4cb7b 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index c823f9cb611..3d4617c0ec1 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -35,8 +35,8 @@ import { KiloSession, kiloSessionFork } from "@/kilocode/session" import { SessionExport } from "@/kilocode/session-export" // kilocode_change end import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "session" }) @@ -140,9 +140,9 @@ function sessionPath(worktree: string, cwd: string) { } const Summary = Schema.Struct({ - additions: NonNegativeInt, - deletions: NonNegativeInt, - files: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, + files: Schema.Finite, diffs: optionalOmitUndefined(Schema.Array(Snapshot.SummaryFileDiff)), // kilocode_change - lightweight diff without patch }) @@ -362,7 +362,7 @@ export const getUsage = (input: { }) => { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 - return value + return Math.max(0, value) } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 30c713c8801..20977d2245e 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -3,8 +3,8 @@ import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" import { QuestionID } from "@/question/schema" // kilocode_change -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index da172b4ac3a..0d6b97b2e4b 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,8 +2,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" @@ -134,10 +134,11 @@ export const layer = Layer.effect( .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) const next = diffs.map((item) => { + if (item.file === undefined) return item const file = unquoteGitPath(item.file) // kilocode_change start — scrub oversized diffs from stored session_diff - const oversized = Buffer.byteLength(item.patch) > Snapshot.MAX_DIFF_SIZE + const oversized = item.patch !== undefined && Buffer.byteLength(item.patch) > Snapshot.MAX_DIFF_SIZE if (file === item.file && !oversized) return item return { ...item, diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 32a83704647..9b7daf7f0c9 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,8 +1,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index b7a78f326e0..0c66a81f0a7 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -88,6 +88,20 @@ function api(resource: string): Api { } } +// kilocode_change start - preserve the share transport contract when stored legacy summary diffs omit file details +function transport(info: Session.Info): SDK.Session { + return { + ...info, + summary: info.summary + ? { + ...info.summary, + diffs: info.summary.diffs?.filter((diff): diff is typeof diff & { file: string } => diff.file !== undefined), + } + : undefined, + } +} +// kilocode_change end + const legacyApi = api("share") const consoleApi = api("shares") @@ -183,7 +197,7 @@ export const layer = Layer.effect( yield* watch(Session.Event.Updated, (evt) => Effect.gen(function* () { const info = evt.properties.info - yield* sync(info.id, [{ type: "session", data: info }]) + yield* sync(info.id, [{ type: "session", data: transport(info) }]) }), ) yield* watch(MessageV2.Event.Updated, (evt) => @@ -287,7 +301,7 @@ export const layer = Layer.effect( ) yield* sync(sessionID, [ - { type: "session", data: info }, + { type: "session", data: transport(info) }, ...messages.map((item) => ({ type: "message" as const, data: item.info })), ...messages.flatMap((item) => item.parts.map((part) => ({ type: "part" as const, data: part }))), { type: "session_diff", data: diffs }, diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index b02bb9aaa04..a1c1517e7c4 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,8 +2,8 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context, Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -19,6 +19,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import { rm } from "fs/promises" // kilocode_change import { BUILTIN_SKILLS } from "../kilocode/skills/builtin" // kilocode_change +import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -30,6 +31,15 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const KILO_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -246,6 +256,16 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + if (Flag.KILO_EXPERIMENTAL_CUSTOMIZE_SKILL) { + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s }), diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md new file mode 100644 index 00000000000..aa067d7880e --- /dev/null +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -0,0 +1,354 @@ + + +# Customizing opencode + +opencode validates its own config strictly and refuses to start when a field +is wrong. The shapes below are the accepted shapes. When in doubt, fetch +`https://opencode.ai/config.json` (the JSON Schema) and validate against it. + +Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"` +so the user's editor catches mistakes as they type. + +## Where files live + +| Scope | Path | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | + +Configs from each scope are deep-merged. Project overrides global. Unknown +top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. + +## opencode.json + +Every field is optional. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "username": "string", + "model": "provider/model-id", + "small_model": "provider/model-id", + "default_agent": "agent-name", + "shell": "/bin/zsh", + "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", + "share": "manual" | "auto" | "disabled", + "autoupdate": true | false | "notify", + "snapshot": true, + "instructions": ["AGENTS.md", "docs/style.md"], + + "skills": { + "paths": [".opencode/skills", "/abs/path/to/skills"], + "urls": ["https://example.com/.well-known/skills/"] + }, + + "agent": { + "my-agent": { + "model": "anthropic/claude-sonnet-4-6", + "mode": "subagent", + "description": "...", + "permission": { "edit": "deny" } + } + }, + + "command": { + "deploy": { "description": "...", "prompt": "..." } + }, + + "provider": { + "anthropic": { "options": { "apiKey": "..." } } + }, + "disabled_providers": ["openai"], + "enabled_providers": ["anthropic"], + + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": {} + }, + "remote-thing": { + "type": "remote", + "url": "https://...", + "headers": { "Authorization": "Bearer ..." } + } + }, + + "plugin": [ + "opencode-gemini-auth", + "opencode-foo@1.2.3", + "./local-plugin.ts", + ["opencode-bar", { "option": "value" }] + ], + + "permission": { + "edit": "deny", + "bash": { "git *": "allow", "*": "ask" } + }, + + "formatter": false, + "lsp": false, + + "experimental": { + "primary_tools": ["edit"], + "mcp_timeout": 30000 + }, + + "tool_output": { "max_lines": 200, "max_bytes": 8192 }, + + "compaction": { "auto": true, "tail_turns": 15 } +} +``` + +Shape notes worth being explicit about: + +- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. +- `skills` is an object with `paths` and/or `urls`, not an array. +- `agent` is an object keyed by agent name, not an array. +- `plugin` is an array of strings or `[name, options]` tuples, not an object. +- `mcp[name].command` is an array of strings, never a single string. `type` is required. +- `permission` is either a string action or an object keyed by tool name. + +## Skills + +opencode's skill loader scans for `**/SKILL.md` inside skill directories. The +file is named `SKILL.md` exactly, and lives in its own folder named after the +skill: + +``` +.opencode/skills/my-skill/SKILL.md +``` + +Frontmatter: + +```markdown +--- +name: my-skill +description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. +--- + +# My Skill + +(skill body in markdown: instructions, examples, references) +``` + +- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- Optional: `license`, `compatibility`, `metadata` (string-string map). + +Register skills from non-default locations via `skills.paths` (scanned +recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of +skills). + +## Agents + +Two ways to define an agent. Use the file form for anything non-trivial. + +### Inline (in `opencode.json`) + +```json +{ + "agent": { + "my-reviewer": { + "description": "Reviews PRs for style violations.", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-6", + "permission": { "edit": "deny", "bash": "ask" }, + "prompt": "You are a strict PR reviewer..." + } + } +} +``` + +### File + +``` +.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md +``` + +```markdown +--- +description: Reviews PRs for style violations. +mode: subagent +model: anthropic/claude-sonnet-4-6 +permission: + edit: deny + bash: ask +--- + +You are a strict PR reviewer. Focus on... +``` + +The file body becomes the agent's `prompt`. Do not also put `prompt:` in the +frontmatter. + +`mode` is one of `"primary"`, `"subagent"`, `"all"`. + +Allowed top-level frontmatter fields: `name, model, variant, description, mode, +hidden, color, steps, options, permission, disable, temperature, top_p`. Any +unknown field is silently routed into `options`. + +To disable a built-in agent: `agent: { build: { disable: true } }`, or in a +file, `disable: true` in frontmatter. + +`default_agent` must point to a non-hidden, primary-mode agent. + +### Built-in agents + +opencode ships with `build`, `plan`, `general`, `explore`, plus optionally +`scout` (gated on `KILO_EXPERIMENTAL_SCOUT`). Hidden internal agents: +`compaction`, `title`, `summary`. To override a built-in's fields, define the +same key in `agent: { : { ... } }`. + +## Plugins + +`plugin:` is an array. Each entry is one of: + +```json +"plugin": [ + "opencode-gemini-auth", // npm spec, latest + "opencode-foo@1.2.3", // npm spec, pinned + "./local-plugin.ts", // file path, relative to the declaring config + "file:///abs/path/plugin.js", // file URL + ["opencode-bar", { "key": "val" }] // tuple form with options +] +``` + +Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in +`.opencode/plugin/` or `.opencode/plugins/`. + +A plugin module exports `default` (or any named export) of type +`Plugin = (input: PluginInput, options?) => Promise`. The export is a +function, not a plain object literal, and the function returns an object +(return `{}` if there is nothing to register). + +```ts +import type { Plugin } from "@kilocode/plugin" + +export default (async ({ client, project, directory, $ }) => { + return { + config: (cfg) => { + // cfg is the live merged config; mutate fields here. + }, + "tool.execute.before": async (input, output) => { + // mutate output.args before the tool runs + }, + } +}) satisfies Plugin +``` + +Hook surface (mutate `output` in place; return `void`): + +- `event(input)`: every bus event +- `config(cfg)`: once on init with the merged config +- `chat.message`, `chat.params`, `chat.headers` +- `tool.execute.before`, `tool.execute.after` +- `tool.definition` +- `command.execute.before` +- `shell.env` +- `permission.ask` +- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, + `experimental.session.compacting`, `experimental.compaction.autocontinue`, + `experimental.text.complete` + +Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, +`auth: { ... }`, `provider: { ... }`. + +## MCP servers + +`mcp:` is an object keyed by server name. Each server is discriminated by +`type`: + +```json +{ + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": { "BROWSER": "chromium" } + }, + "github": { + "type": "remote", + "url": "https://...", + "enabled": true, + "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + }, + "old-server": { "enabled": false } + } +} +``` + +`command` is an array of strings. `type` is required. Use `enabled: false` to +disable a server inherited from a parent config. + +## Permissions + +```json +"permission": { + "edit": "deny", + "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, + "external_directory": { "~/secrets/**": "deny", "*": "allow" } +} +``` + +Actions: `"allow"`, `"ask"`, `"deny"`. + +Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an +object `{ pattern: action }`. Within an object, **insertion order matters**. +opencode evaluates the LAST matching rule, so put broad rules first and narrow +rules last. + +`permission: "allow"` (a string at the top level) is shorthand for "allow +everything" and is rarely what the user wants. + +Known permission keys: `read, edit, glob, grep, list, bash, task, +external_directory, todowrite, question, webfetch, websearch, codesearch, +repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +action, not a per-pattern object. + +`external_directory` patterns are filesystem paths (use `~/`, absolute paths, +or globs like `~/projects/**`). + +Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on +the `plan` agent's permission ruleset (`edit: deny *`). + +## Escape hatches + +When a user's config is broken and opencode won't start, these env vars help: + +- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` + and start from globals only. Run from the project directory, opencode loads, + the user edits the broken file, then they restart without the flag. +- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config. +- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: + inject inline JSON as a final local-scope merge. +- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `KILO_PURE=1`: skip external plugins entirely. +- `KILO_DISABLE_EXTERNAL_SKILLS=1`, + `KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under + `~/.claude/` and `~/.agents/`. + +## When proposing edits + +- Validate against the schema before writing. If you are unsure of a field's + exact shape, fetch `https://opencode.ai/config.json` rather than guessing. +- Preserve `$schema` and any existing fields the user did not ask to change. +- For agent, skill, and plugin definitions, prefer creating new files in the + correct location over inlining everything in `opencode.json`. +- If the user's existing config is malformed, point them at the env-var escape + hatch above so they can edit from inside opencode without breaking their + session. +- opencode hard-fails on invalid config by design. There is no graceful + degradation, so get the shape right the first time. diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index b629bdc6c04..2f54c568e1a 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -14,8 +14,8 @@ import { Flag } from "@opencode-ai/core/flag/flag" // kilocode_change import { DiffFull } from "../kilocode/snapshot/diff-full" // kilocode_change import { KiloSnapshotTrack } from "../kilocode/snapshot/track" // kilocode_change import type { MessageID, SessionID } from "../session/schema" // kilocode_change -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod } from "@opencode-ai/core/effect-zod" export const Patch = Schema.Struct({ hash: Schema.String, @@ -24,10 +24,13 @@ export const Patch = Schema.Struct({ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ - file: Schema.String, - patch: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + // Optional because legacy/imported `summary_diffs` on disk may omit + // file details and patch text. Required Schema rejected the whole + // session response and broke session loading on Desktop. + file: Schema.optional(Schema.String), + patch: Schema.optional(Schema.String), + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "SnapshotFileDiff" }) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5b2df1e899a..bc4d8b8f17f 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 6f5f157cb8e..8a50c8f16d8 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -9,7 +9,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e714b86ae0c..e4e2e75b73a 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 42f45a9a033..01ae0c985cf 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,6 +1,6 @@ import { lstat } from "fs/promises" // kilocode_change import { Effect, Option, Schema, Scope } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import * as path from "path" import type { Readable } from "stream" // kilocode_change import { createInterface } from "readline" @@ -229,7 +229,7 @@ export const ReadTool = Tool.define( yield* ctx.ask({ permission: "read", - patterns: [filepath], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: {}, }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index f5fdb190d69..5b28aa9d7c3 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -19,7 +19,7 @@ import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@kilocode/plugin" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@/util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 9ce7bece2b0..b6c263a4ce3 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 000d844419e..3640e971a24 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { ShellID } from "./id" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index c198c8ea499..533316b538e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,6 +4,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" +import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" // kilocode_change @@ -70,52 +71,55 @@ export const TaskTool = Tool.define( const canTask = KiloTask.nestedTask() // kilocode_change - Kilo disallows subagents spawning subagents const canTodo = next.permission.some((rule) => rule.permission === "todowrite") + const taskID = params.task_id + const session = taskID + ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined + if (session && session.parentID !== ctx.sessionID) { + return yield* Effect.fail(new Error(`Cannot resume session ${taskID}: not a child of the current session`)) // kilocode_change - prevent cross-session task resume + } const parent = yield* sessions.get(ctx.sessionID) + const parentAgent = parent.agent + ? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined // kilocode_change start — inherit edit/bash/MCP restrictions from calling agent const caller = yield* agent.get(ctx.agent) const rules = KiloTask.inherited({ caller, session: parent, mcp: cfg.mcp }) // kilocode_change end - - const taskID = params.task_id - const session = taskID - ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) - : undefined + // kilocode_change start - refresh current parent restrictions when resuming an existing task session + if (session) { + session.permission = KiloTask.merge( + session.permission ?? [], + deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + parentAgent, + subagent: next, + }), + KiloTask.permissions(rules), + ) + yield* sessions.setPermission({ sessionID: session.id, permission: session.permission }) + } + // kilocode_change end 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 - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(canTask - ? [] - : [ - { - permission: id, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(cfg.experimental?.primary_tools?.map((item) => ({ + // kilocode_change start - dedupe inherited restrictions before child prompt toggles persist + permission: KiloTask.merge( + deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + parentAgent, + subagent: next, + }), + cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, permission: item, - })) ?? []), - // kilocode_change start — deny task + propagate caller restrictions - ...KiloTask.permissions(rules), - // kilocode_change end - ], + })) ?? [], + KiloTask.permissions(rules), + ), + // kilocode_change end })) const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d9b92a23cc8..d87e1dcdb55 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts index 8d0ce9174fa..9ae5ee5a943 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/opencode/src/v2/auth.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index c7c13890f23..83ca437efec 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,6 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index db66199a59b..56357ab4006 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,5 @@ -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" @@ -114,7 +115,7 @@ export class Info extends Schema.Class("Model.Info")({ released: DateTimeUtcFromMillis, }), cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 7c768bd551a..fa211bd8c4e 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" @@ -118,12 +118,12 @@ export namespace Step { finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), snapshot: Schema.String.pipe(Schema.optional), @@ -305,7 +305,7 @@ export namespace Tool { export const RetryError = Schema.Struct({ message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), + statusCode: Schema.Finite.pipe(Schema.optional), isRetryable: Schema.Boolean, responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), responseBody: Schema.String.pipe(Schema.optional), @@ -320,7 +320,7 @@ export const Retried = EventV2.define({ aggregate: "sessionID", schema: { ...Base, - attempt: NonNegativeInt, + attempt: Schema.Finite, error: RetryError, }, }) diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index bb86f039b23..b3da6009f69 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -10,7 +10,7 @@ import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" -import { optionalOmitUndefined } from "@/util/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index ea8c0367b9a..db491685bc7 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -1,8 +1,13 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@kilocode/sdk/v2" -import { Instance } from "../../src/project/instance" +import type { + Event, + EventMessagePartUpdated, + ToolStateCompleted, + ToolStatePending, + ToolStateRunning, +} from "@kilocode/sdk/v2" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -36,6 +41,14 @@ function isToolCallUpdate( return update.sessionUpdate === "tool_call_update" } +function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { + return sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter(isToolCallUpdate) + .find((u) => u.toolCallId === callID && u.status === "completed") +} + function toolEvent( sessionId: string, cwd: string, @@ -78,6 +91,46 @@ function toolEvent( return { directory: cwd, payload } } +function completedToolEvent( + sessionId: string, + cwd: string, + opts: { + callID: string + tool: string + input: Record + output: string + attachments?: ToolStateCompleted["attachments"] + }, +): GlobalEventEnvelope { + const state: ToolStateCompleted = { + status: "completed", + input: opts.input, + output: opts.output, + title: opts.tool, + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + ...(opts.attachments && { attachments: opts.attachments }), + } + const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, + type: "message.part.updated", + properties: { + sessionID: sessionId, + time: Date.now(), + part: { + id: `part_${opts.callID}`, + sessionID: sessionId, + messageID: `msg_${opts.callID}`, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }, + } + return { directory: cwd, payload } +} + function createEventStream() { const queue: GlobalEventEnvelope[] = [] const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] @@ -618,6 +671,130 @@ describe("acp.agent event subscription", () => { }) }) + test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("image-data").toString("base64") + + controller.push( + completedToolEvent(sessionId, cwd, { + callID: "call_image", + tool: "read", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + attachments: [ + { + id: "part_image", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${data}`, + }, + { + id: "part_text", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,Zm9v", + }, + ], + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image read successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/png", data }, + }) + expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) + expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) + + stop() + }, + }) + }) + + test("replays completed tool image attachments as ACP tool content blocks", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("replay-image").toString("base64") + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + id: "part_replay", + sessionID: sessionId, + messageID: "msg_replay", + type: "tool", + callID: "call_replay_image", + tool: "webfetch", + state: { + status: "completed", + input: { url: "https://example.com/image.png" }, + output: "Image fetched successfully", + title: "webfetch", + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + attachments: [ + { + id: "part_replay_image", + sessionID: sessionId, + messageID: "msg_replay", + type: "file", + mime: "image/jpeg", + filename: "image.jpg", + url: `data:image/jpeg;base64,${data}`, + }, + ], + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image fetched successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/jpeg", data }, + }) + + stop() + }, + }) + }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() await WithInstance.provide({ diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts new file mode 100644 index 00000000000..5ba6b54834b --- /dev/null +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -0,0 +1,141 @@ +/** + * Reproducer for opencode issue #26514: + * + * In Plan Mode (the `plan` agent), the main agent's edit/write tools are + * blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`). + * However, when the plan agent spawns a subagent via the `task` tool, the + * subagent retains full file modification capabilities — a security bypass. + * + * This test replicates the permission ruleset that would govern a + * `general` subagent when launched from a `plan` parent session, mirroring + * the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime + * subagent agent permissions, evaluated as in `session/prompt.ts`). + * + * The expected (secure) behavior is that the subagent inherits the plan + * mode read-only restriction and `edit`/`write` resolve to `deny`. On + * origin/dev this assertion fails because the parent **agent** permissions + * are not propagated to the subagent — only the parent **session** + * permissions are passed through, and Plan Mode's restrictions live on the + * agent, not the session. + */ +import { test, expect, afterEach } from "bun:test" +import { Effect } from "effect" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Agent } from "../../src/agent/agent" +import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" +import { Permission } from "../../src/permission" + +afterEach(async () => { + await disposeAllInstances() +}) + +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} + +// `deriveSubagentSessionPermission` is imported from production. The test +// exercises the actual helper that task.ts uses to build the subagent's +// session permission, so any regression in that helper trips this test. + +test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const generalAgent = await load(tmp.path, (svc) => svc.get("general")) + + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] + + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) + + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { + // Sibling check: even though `explore` is intrinsically read-only, the + // bug surface is the same. Including this case to document that the fix + // should propagate the parent **agent** permissions, not just deny edit + // when the subagent happens to already deny it. + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const explore = await load(tmp.path, (svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) + + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { + // The most damaging case: a user-defined subagent with default + // permissions (allow-by-default, like `general`). The subagent must NOT + // be able to edit when the parent agent is `plan`. + await using tmp = await tmpdir({ + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + expect(planAgent).toBeDefined() + expect(my).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: my!, + }) + const effective = Permission.merge(my!.permission, subagentSessionPermission) + + // BUG: on origin/dev edit resolves to "allow" because the plan + // agent's `edit: deny *` rule never reaches the subagent. + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }, + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts new file mode 100644 index 00000000000..c9b3551d9a2 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -0,0 +1,55 @@ +/** + * Regression test for the TUI bootstrap aggregation helper. Replaces the + * pre-fix Promise.all behavior where the first rejection drowned every + * sibling endpoint's failure as an unhandled rejection. + */ +import { describe, expect, test } from "bun:test" +import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" + +describe("aggregateFailures", () => { + test("returns null when every result is fulfilled", () => { + expect( + aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { name: "providers", result: { status: "fulfilled", value: 2 } }, + ]), + ).toBeNull() + }) + + test("names the failed endpoint when one rejects", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { + name: "providers", + result: { status: "rejected", reason: new Error("Service unavailable") }, + }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("1 of 2") + expect(err!.message).toContain("providers: Service unavailable") + }) + + test("names every failed endpoint when multiple reject", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } }, + { name: "providers", result: { status: "fulfilled", value: 1 } }, + { name: "agents", result: { status: "rejected", reason: { message: "boom" } } }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("2 of 3") + expect(err!.message).toContain("config: 400 Bad Request") + expect(err!.message).toContain("agents: boom") + }) + + test("attaches structured failure list under .cause", () => { + const reason = new Error("nope") + const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) + const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } + expect(cause.failures).toEqual([{ name: "providers", reason }]) + }) + + test("falls back to String() for opaque reasons", () => { + const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) + expect(err!.message).toContain("x: 42") + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx new file mode 100644 index 00000000000..47cf45f7f15 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -0,0 +1,130 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" +import { ToastProvider } from "../../../../src/cli/cmd/tui/ui/toast" // kilocode_change + +export const worktree = "/tmp/opencode" +export const directory = `${worktree}/packages/opencode` + +export async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +export function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }) +} + +export function eventSource(): EventSource { + return { subscribe: async () => () => {} } +} + +type FetchHandler = (url: URL) => Response | Promise | undefined + +export function createFetch(override?: FetchHandler) { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + const overridden = await override?.(url) + if (overridden) return overridden + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + case "/network": // kilocode_change + case "/background-process": // kilocode_change + return json([]) + case "/config": + case "/experimental/resource": + case "/global/config": // kilocode_change + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +type Ctx = { kv: ReturnType; sync: ReturnType } + +export async function mount(override?: FetchHandler) { + const calls = createFetch(override) + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const ctx: Ctx = { kv: useKV(), sync: useSync() } + onMount(() => { + sync = ctx.sync + kv = ctx.kv + done() + }) + return + } + + const app = await testRender(() => ( + + + + {/* kilocode_change start */} + + {/* kilocode_change end */} + + + + + + + + {/* kilocode_change start */} + + {/* kilocode_change end */} + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} diff --git a/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx new file mode 100644 index 00000000000..108afc162fa --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx @@ -0,0 +1,53 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Reproducer for #26560 — TUI crashes with + * `TypeError: undefined is not an object (evaluating 'f.data.map')` + * when entering a session whose messages endpoint returns a non-2xx. + * The failure path is `sync.tsx#sync.session.sync` reading + * `messages.data!` while the SDK leaves `data` undefined on error. + */ +import { describe, expect, test } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import { WithInstance } from "../../../../src/project/with-instance" // kilocode_change +import { disposeAllInstances, tmpdir } from "../../../fixture/fixture" +import { directory, json, mount } from "./sync-fixture" + +const sessionID = "ses_undef" + +describe("tui sync (#26560)", () => { + test("entering a session whose messages endpoint errors does not crash sync", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const sessionPayload = { + id: sessionID, + title: "broken", + time: { created: 0, updated: 0 }, + version: "1.14.42", + directory, + project_id: "proj_test", + } + const { app, sync } = await WithInstance.provide({ + directory: tmp.path, + fn: () => + mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(sessionPayload) + if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 }) + if (url.pathname === `/session/${sessionID}/todo`) return json([]) + if (url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/session") return json([sessionPayload]) + return undefined + }), + }) // kilocode_change + + try { + await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() + } finally { + app.renderer.destroy() + await disposeAllInstances() // kilocode_change + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 606e8aebdc4..24bf49a9277 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -1,136 +1,9 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" -import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" -import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" -import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" -import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" -import { ToastProvider } from "../../../../src/cli/cmd/tui/ui/toast" // kilocode_change import { WithInstance } from "../../../../src/project/with-instance" // kilocode_change import { disposeAllInstances, tmpdir } from "../../../fixture/fixture" - -const worktree = "/tmp/opencode" -const directory = `${worktree}/packages/opencode` - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -function eventSource(): EventSource { - return { - subscribe: async () => () => {}, - } -} - -function createFetch() { - const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)) - if (url.pathname === "/session") session.push(url) - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/global/config": // kilocode_change - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} - -async function mount() { - const calls = createFetch() - let sync!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - - - - {/* kilocode_change start */} - - {/* kilocode_change end */} - - - - { - sync = ctx.sync - kv = ctx.kv - done() - }} - /> - - - - {/* kilocode_change start */} - - {/* kilocode_change end */} - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } -} - -function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { - const kv = useKV() - const sync = useSync() - - onMount(() => { - props.onReady({ kv, sync }) - }) - - return -} +import { mount } from "./sync-fixture" describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { diff --git a/packages/opencode/test/kilocode/background-process-tool.test.ts b/packages/opencode/test/kilocode/background-process-tool.test.ts index 89b4cff3199..2b6ee7861f1 100644 --- a/packages/opencode/test/kilocode/background-process-tool.test.ts +++ b/packages/opencode/test/kilocode/background-process-tool.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" import { Params } from "@/kilocode/tool/background-process" -import { toJsonSchema } from "@/util/effect-zod" +import { toJsonSchema } from "@opencode-ai/core/effect-zod" const accepts = (input: unknown) => Result.isSuccess(Schema.decodeUnknownResult(Params)(input)) diff --git a/packages/opencode/test/kilocode/server/httpapi-public.test.ts b/packages/opencode/test/kilocode/server/httpapi-public.test.ts index 979ed653bc7..44a4921fcdf 100644 --- a/packages/opencode/test/kilocode/server/httpapi-public.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-public.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from "bun:test" import { OpenApi } from "effect/unstable/httpapi" import { AgentBuilderPaths } from "../../../src/kilocode/server/httpapi/groups/agent-builder" +import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/groups/background-process" +import { ConfigConsolePaths } from "../../../src/kilocode/server/httpapi/groups/config-console" +import { IndexingPaths } from "../../../src/kilocode/server/httpapi/groups/indexing" import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway" +import { NetworkPaths } from "../../../src/kilocode/server/httpapi/groups/network" +import { TelemetryPaths } from "../../../src/kilocode/server/httpapi/groups/telemetry" +import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental" +import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session" import { PublicApi } from "../../../src/server/routes/instance/httpapi/public" type Schema = { @@ -14,10 +21,13 @@ type Schema = { } type Parameter = { + in?: string name?: string schema?: Schema } +type Method = "get" | "post" | "patch" | "put" + type Body = { content?: Record } @@ -43,6 +53,77 @@ describe("Kilo PublicApi OpenAPI contract", () => { }) }) + test("keeps workspace routing queries on background process routes", () => { + const spec = OpenApi.fromApi(PublicApi) + const routes = [ + { method: "get", path: BackgroundProcessPaths.list }, + { method: "get", path: BackgroundProcessPaths.get }, + { method: "get", path: BackgroundProcessPaths.logs }, + { method: "post", path: BackgroundProcessPaths.stop }, + { method: "post", path: BackgroundProcessPaths.restart }, + { method: "post", path: BackgroundProcessPaths.stopSession }, + ] satisfies Array<{ method: Method; path: string }> + + for (const route of routes) { + const path = route.path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") + const params = spec.paths[path]?.[route.method]?.parameters as Parameter[] | undefined + const query = params?.filter((param) => param.in === "query").map((param) => param.name) + expect(query, `${route.method.toUpperCase()} ${route.path}`).toEqual(["directory", "workspace"]) + } + }) + + test("keeps directory routing queries on Kilo Console routes", () => { + const spec = OpenApi.fromApi(PublicApi) + const routes = [ + { method: "get", path: ExperimentalPaths.worktreeDiff }, + { method: "get", path: ExperimentalPaths.worktreeDiffSummary }, + { method: "get", path: ExperimentalPaths.worktreeDiffFile }, + { method: "post", path: SessionPaths.viewed }, + { method: "get", path: ConfigConsolePaths.overlay }, + { method: "patch", path: ConfigConsolePaths.overlay }, + { method: "get", path: IndexingPaths.status }, + ] satisfies Array<{ method: Method; path: string }> + + for (const route of routes) { + const params = spec.paths[route.path]?.[route.method]?.parameters as Parameter[] | undefined + const query = params?.filter((param) => param.in === "query").map((param) => param.name) + expect(query, `${route.method.toUpperCase()} ${route.path}`).toContain("directory") + expect(query, `${route.method.toUpperCase()} ${route.path}`).toContain("workspace") + } + }) + + test("keeps workspace routing queries on all Kilo-owned routed endpoints", () => { + const spec = OpenApi.fromApi(PublicApi) + const routes = [ + { method: "post", path: AgentBuilderPaths.preview }, + { method: "put", path: AgentBuilderPaths.save }, + { method: "post", path: "/commit-message" }, + { method: "post", path: "/enhance-prompt" }, + { method: "get", path: NetworkPaths.list }, + { method: "post", path: NetworkPaths.reply }, + { method: "post", path: NetworkPaths.reject }, + { method: "post", path: TelemetryPaths.capture }, + { method: "post", path: TelemetryPaths.setEnabled }, + { method: "get", path: ConfigConsolePaths.sources }, + { method: "get", path: ConfigConsolePaths.effective }, + { method: "get", path: ConfigConsolePaths.rules }, + { method: "put", path: ConfigConsolePaths.rules }, + { method: "get", path: ConfigConsolePaths.modelState }, + { method: "patch", path: ConfigConsolePaths.modelState }, + { method: "get", path: ConfigConsolePaths.tuiConfig }, + { method: "get", path: ConfigConsolePaths.tuiKeybinds }, + { method: "patch", path: ConfigConsolePaths.tuiConfig }, + ] satisfies Array<{ method: Method; path: string }> + + for (const route of routes) { + const path = route.path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") + const params = spec.paths[path]?.[route.method]?.parameters as Parameter[] | undefined + const query = params?.filter((param) => param.in === "query").map((param) => param.name) + expect(query, `${route.method.toUpperCase()} ${route.path}`).toContain("directory") + expect(query, `${route.method.toUpperCase()} ${route.path}`).toContain("workspace") + } + }) + test("keeps personal organization resets nullable", () => { const spec = OpenApi.fromApi(PublicApi) const body = spec.paths[KiloGatewayPaths.organization]?.post?.requestBody as Body | undefined diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index 803088daed0..e8216daa5bf 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, Exit, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Config } from "../../src/config/config" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" @@ -10,6 +10,7 @@ import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Provider } from "../../src/provider/provider" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" +import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" import { Truncate } from "../../src/tool/truncate" import { ToolRegistry } from "../../src/tool/registry" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" @@ -192,4 +193,108 @@ describe("Kilo task nesting", () => { }, ), ) + + test("preserves inherited restrictions while refreshing prompt tool toggles", () => { + const permission = KiloSessionPrompt.mergeToolPermissions({ + existing: [ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "*", action: "deny" }, + ], + toggles: [ + { permission: "task", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "*", action: "allow" }, + ], + }) + + expect(permission).toEqual([ + { permission: "bash", pattern: "*", action: "deny" }, + { permission: "task", pattern: "*", action: "deny" }, + { permission: "edit", pattern: "*", action: "allow" }, + ]) + }) + + it.live("refreshes inherited restrictions when resuming a task child", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + yield* sessions.setPermission({ + sessionID: chat.id, + permission: [{ permission: "bash", pattern: "*", action: "deny" }], + }) + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + const tool = yield* TaskTool + const def = yield* tool.init() + + const exec = () => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "explore", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + yield* exec() + const first = yield* sessions.get(child.id) + const count = first.permission?.filter((rule) => rule.permission === "bash").length + yield* exec() + + const resumed = yield* sessions.get(child.id) + expect(resumed.permission).toEqual( + expect.arrayContaining([{ permission: "bash", pattern: "*", action: "deny" }]), + ) + expect(count).toBeGreaterThan(0) + expect(resumed.permission?.filter((rule) => rule.permission === "bash")).toHaveLength(count ?? 0) + }), + ), + ) + + it.live("rejects task_id from a different parent session", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const foreign = yield* sessions.create({ title: "Foreign parent" }) + const child = yield* sessions.create({ parentID: foreign.id, title: "Foreign child" }) + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "explore", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(yield* sessions.children(chat.id)).toHaveLength(0) + }), + ), + ) }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 10547c9f082..5afc85e3b5d 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index" // Per-client state for controlling mock behavior interface MockClientState { - tools: Array<{ name: string; description?: string; inputSchema: object }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number + requestCalls: number listToolsShouldFail: boolean listToolsError: string listPromptsShouldFail: boolean @@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState { state = { tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], listToolsCalls: 0, + requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", listPromptsShouldFail: false, @@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { tools: this._state?.tools ?? [] } } + async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) { + if (this._state) this._state.requestCalls++ + if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] }) + throw new Error(`unsupported request: ${request.method}`) + } + async listPrompts() { if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") @@ -205,6 +213,11 @@ function withInstance( } } +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== @@ -433,6 +446,59 @@ test( ), ) +test( + "falls back when MCP output schema refs fail SDK tool discovery", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "stitch-like-server" + const serverState = getOrCreateClientState("stitch-like-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" + serverState.tools = [ + { + name: "render_screen", + description: "renders a screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + + const addResult = yield* mcp.add("stitch-like-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") + + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) + }), + ), +) + +test( + "does not fall back for non-schema MCP tool discovery errors", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "broken-server" + const serverState = getOrCreateClientState("broken-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "transport closed" + + const addResult = yield* mcp.add("broken-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "broken-server")).toBe("failed") + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(0) + }), + ), +) + // ======================================================================== // Test: disabled server via config // ======================================================================== diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 75eacbba5ed..71830cfab77 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -23,6 +23,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["KILO_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["KILO_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +// Tests assert exact skill counts from disk discovery; the built-in +// customize-opencode skill is opt-in for stable channels and on by default +// for unstable channels (including "local" where CI runs). Disable it here +// so disk-discovery tests aren't off-by-one. +process.env["KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts new file mode 100644 index 00000000000..e6fa645e714 --- /dev/null +++ b/packages/opencode/test/provider/model-status.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProvider } from "@/config/provider" +import { CatalogModelStatus, ModelStatus } from "@/provider/model-status" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" + +describe("provider model status schemas", () => { + test("keeps catalog status separate from normalized provider status", () => { + expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated") + expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow() + expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active") + }) + + test("accepts active status across public provider schemas", () => { + expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active") + expect( + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }).status, + ).toBeUndefined() + expect( + Schema.decodeUnknownSync(Provider.Model)({ + id: "test-model", + providerID: "test-provider", + api: { + id: "test-model", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + }).status, + ).toBe("active") + }) +}) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index e6707417940..b295a5f29a9 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -48,4 +48,41 @@ describe("config HttpApi", () => { lsp: false, }) }) + + test("serves config with active provider model status", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }, + }) + + const response = await app().request("/config", { + headers: { + "x-kilo-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }) + }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 1f8d8af0da8..0606a7caaba 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -39,6 +39,10 @@ import { kiloScenarios } from "../../kilocode/server/httpapi-exercise-scenarios" void (await import("@opencode-ai/core/util/log")).init({ print: false }) +function cursor(input: Record) { + return Buffer.from(JSON.stringify(input)).toString("base64url") +} + const scenarios: Scenario[] = [ http.protected .get("/global/health", "global.health") @@ -515,7 +519,10 @@ const scenarios: Scenario[] = [ yield* ctx.worktreeRemove(ctx.state.directory) }), ), - http.protected.get("/experimental/session", "experimental.session.list").json(200, array), + http.protected + .get("/experimental/session", "experimental.session.list") + .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) + .json(200, array), http.protected.get("/experimental/resource", "experimental.resource.list").json(), http.protected .post("/sync/history", "sync.history.list") @@ -593,6 +600,64 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session", "v2.session.list.filters") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + order: "asc", + path: ".", + roots: "false", + start: "0", + search: "missing", + directory: ctx.directory ?? "", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ + id: "ses_httpapi_missing", + time: 0, + order: "desc", + direction: "next", + directory: ctx.directory, + }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor.invalid") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }), + search: "not-allowed-with-cursor", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ @@ -615,6 +680,53 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.params") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + order: "asc", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + order: "asc", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .at((ctx) => ({ diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts new file mode 100644 index 00000000000..6cb36c3bf31 --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -0,0 +1,189 @@ +// Regression coverage for issue #26526's claim that promptAsync's +// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not — +// forkIn preserves Context.Reference values via standard fiber inheritance. +// +// The companion claim that the streaming prompt handler "captures and +// provides" those services is true and load-bearing: Stream.fromEffect's +// body runs detached from the request fiber's context, so the explicit +// Effect.provideService calls there are required, not defensive duplication. + +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.KILO_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + InstanceLayer.layer, + Project.defaultLayer, + workspaceLayer, + ), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const setupWorkspace = (kind: string) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id))) + registerAdapter(projectID, kind, localAdapter(dir)) + const workspace = yield* Workspace.Service.use((svc) => + svc.create({ type: kind, branch: null, extra: null, projectID }), + ) + return { dir, workspace } + }) + +type Capture = { directory?: string; workspaceID?: string } + +const captureInstance = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { directory: instance?.directory, workspaceID } satisfies Capture +}) + +describe("HttpApi handler context inheritance", () => { + // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits + // the request's Context — including InstanceRef and WorkspaceRef provided + // by InstanceContextMiddleware — without any explicit re-provide. + it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-fork") + const capture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/fork-probe", + Effect.gen(function* () { + const scope = yield* Scope.Scope + yield* Effect.gen(function* () { + yield* Deferred.succeed(capture, yield* captureInstance) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return HttpServerResponse.empty({ status: 204 }) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.post( + `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, + ) + expect(response.status).toBe(204) + + const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) + expect(observed.directory).toBe(dir) + expect(observed.workspaceID).toBe(workspace.id) + }), + ) + + // Mirrors handlers/session.ts:255 prompt — the streaming handler reads + // InstanceRef/WorkspaceRef in the request fiber and re-provides them to + // the Stream.fromEffect body. This test locks in why the explicit + // provides are required: without them the stream body sees undefined. + it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-stream") + const withoutCapture = yield* Deferred.make() + const withCapture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/stream-probe-without", + Effect.gen(function* () { + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withoutCapture, yield* captureInstance) + return "" + }), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + yield* HttpRouter.add( + "POST", + "/stream-probe-with", + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withCapture, yield* captureInstance) + return "" + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` + const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) + yield* responseWithout.text + const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`) + yield* responseWith.text + + const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds")) + expect(without.directory).toBeUndefined() + expect(without.workspaceID).toBeUndefined() + + const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds")) + expect(withProvide.directory).toBe(dir) + expect(withProvide.workspaceID).toBe(workspace.id) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index cac8f23d30a..b0ec5894ce3 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -22,6 +22,54 @@ function app() { return Server.Default().app } +function providerListHasFetch(list: unknown) { + if (!Array.isArray(list)) return false + return list.some((item: unknown) => { + if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false + if (item.id !== "google") return false + if (typeof item.options !== "object" || item.options === null) return false + return "fetch" in item.options + }) +} + +function hasProviderWithFetch(input: unknown, key: "all" | "providers") { + if (typeof input !== "object" || input === null) return false + if (key === "all") return "all" in input && providerListHasFetch(input.all) + return "providers" in input && providerListHasFetch(input.providers) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function providerList(input: unknown, key: "all" | "providers") { + if (!isRecord(input)) return [] + if (!Array.isArray(input[key])) return [] + return input[key] +} + +function providerByID(input: unknown, key: "all" | "providers", id: string) { + return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id) +} + +function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider) || !isRecord(provider.models)) return false + return Object.values(provider.models).some((model) => { + if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false + return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some( + (cost) => typeof cost === "number" && cost > 0, + ) + }) +} + +function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider)) return false + if (provider.name === "mutated-provider") return true + return isRecord(provider.options) && provider.options.mutatedByPlugin === true +} + function requestAuthorize(input: { app: ReturnType providerID: string @@ -77,6 +125,73 @@ function writeProviderAuthPlugin(dir: string) { }) } +function writeFunctionOptionsPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), + [ + "export default {", + ' id: "test.provider-function-options",', + " server: async () => ({", + " auth: {", + ' provider: "google",', + " loader: async (_getAuth, provider) => {", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return {", + ' apiKey: "",', + " fetch: async (input, init) => fetch(input, init),", + " }", + " },", + " methods: [{ type: 'api', label: 'API key' }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + +function writeProviderModelsMutationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "google",', + " models: async (provider) => {", + " const models = Object.fromEntries(", + " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),", + " )", + ' provider.name = "mutated-provider"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return models", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -138,4 +253,74 @@ describeProvider("provider HttpApi", () => { }), ), ) + + it.live("serves provider lists when auth loaders add runtime fetch options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + const previous = process.env.KILO_AUTH_CONTENT + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeFunctionOptionsPlugin(dir) + yield* Effect.sync(() => { + process.env.KILO_AUTH_CONTENT = JSON.stringify({ + google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, + }) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env.KILO_AUTH_CONTENT + if (previous !== undefined) process.env.KILO_AUTH_CONTENT = previous + }), + ) + const headers = { "x-kilo-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderWithFetch(providerBody, "all")).toBe(false) + expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) + }), + ) + + it.live("keeps provider.models hook input mutations out of provider state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeProviderModelsMutationPlugin(dir) + + const headers = { "x-kilo-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) + expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 00000000000..959c5fc3e85 --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { SessionID } from "../../src/session/schema" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { + FilePaths, + FileQuery, + FindFileQuery, + FindTextQuery, +} from "../../src/server/routes/instance/httpapi/groups/file" +import { + ExperimentalPaths, + SessionListQuery as ExperimentalSessionListQuery, + ToolListQuery, +} from "../../src/server/routes/instance/httpapi/groups/experimental" +import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { + ListQuery as SessionListQuery, + MessagesQuery, + SessionPaths, +} from "../../src/server/routes/instance/httpapi/groups/session" +import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" +import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" +import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES + +type Method = "get" | "post" | "put" | "delete" | "patch" +type QuerySchema = { readonly fields: Record } +type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } +type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } + +const openApiDriftRoutes = [ + { method: "get", path: SessionPaths.list, query: SessionListQuery }, + { method: "get", path: SessionPaths.messages, query: MessagesQuery }, + { method: "get", path: FilePaths.findFile, query: FindFileQuery }, + { method: "get", path: FilePaths.findText, query: FindTextQuery }, + { method: "get", path: FilePaths.list, query: FileQuery }, + { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, + { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, + { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, + { method: "get", path: "/api/session", query: V2SessionsQuery }, + { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, +] satisfies Array<{ method: Method; path: string; query: QuerySchema }> + +const numericSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, + { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, + { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, + { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, + { + method: "get", + path: SessionPaths.messages, + name: "limit", + schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } }, + { method: "get", path: "/api/session", name: "start", schema: { type: "number" } }, + { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, +] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> + +function app() { + return Server.Default().app +} + +function request(url: string, init?: RequestInit) { + return Effect.promise(async () => app().request(url, init)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +function openApiPath(path: string) { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +function queryParameters(operation: OpenApiOperation | undefined) { + return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) +} + +function queryParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) +} + +function assertAdvertisedQueryParamsAreRuntimeFields(input: { + readonly method: Method + readonly operation: OpenApiOperation | undefined + readonly path: string + readonly query: QuerySchema +}) { + const runtimeFields = new Set(Object.keys(input.query.fields)) + const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) + + expect( + advertisedOnly, + `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, + ).toEqual([]) +} + +afterEach(async () => { + Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +// Regression for the "OpenAPI advertises ?directory&workspace, runtime +// rejects them" drift class. Each affected route must accept both params +// without 400. +describe("httpapi query schema drift", () => { + const routingParams = (dir: string) => + `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` + + const expectNotSchemaRejection = (status: number, url: string) => { + expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) + } + + it.effect( + "boolean query schema accepts only true and false strings", + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(QueryBoolean) + const encode = Schema.encodeUnknownSync(QueryBoolean) + + expect(decode("true")).toBe(true) + expect(decode("false")).toBe(false) + expect(encode(true)).toBe("true") + expect(encode(false)).toBe("false") + + for (const input of ["1", "yes", "True", "", true, false]) { + expect(() => decode(input)).toThrow() + } + }), + ) + + it.effect( + "OpenAPI workspace query params are declared by runtime query schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const route of openApiDriftRoutes) { + assertAdvertisedQueryParamsAreRuntimeFields({ + ...route, + operation: spec.paths[openApiPath(route.path)]?.[route.method], + }) + } + }), + ) + + it.effect( + "OpenAPI numeric query params preserve generated SDK call shapes", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of numericSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(expected.schema) + } + }), + ) + + it.effect( + "drift assertion catches spec-only workspace query params", + Effect.sync(() => { + expect(() => + assertAdvertisedQueryParamsAreRuntimeFields({ + method: "get", + operation: { + parameters: [ + { name: "directory", in: "query" }, + { name: "workspace", in: "query" }, + ], + }, + path: "/fixture", + query: { fields: {} }, + }), + ).toThrow("advertises query params not accepted by runtime schema") + }), + ) + + it.live( + "session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "session messages accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/file accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find/file?query=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/text accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find?pattern=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file read accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/file?path=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental tool list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "vcs diff accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index ec1efff51c2..3022670e881 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -394,8 +394,16 @@ describe("HttpApi SDK", () => { const missing = yield* capture(() => sdk.session.get({ sessionID })) const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + // Result-tuple path: error body is preserved as-is so existing + // consumers reading `result.error.name` / `JSON.stringify(error)` + // keep working byte-for-byte. expect(missing.error).toEqual(expected) - expect(thrown).toEqual(expected) + // throwOnError path: SDK wraps the body in a real Error with the + // server's message, with the original parsed body preserved under + // `.cause.body`. + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe(expected.data.message) + expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected) return { status: missing.status, error: missing.error, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index ad0108a0fcc..3f45e9e4381 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -297,6 +297,36 @@ describe("session HttpApi", () => { ), ) + it.live( + "serves sessions with migrated summary diffs missing file details", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const session = yield* createSession(tmp.path, { title: "legacy diff" }) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ), + ) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-kilo-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + ), + ) + it.live( "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => @@ -363,8 +393,15 @@ describe("session HttpApi", () => { headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, + { + headers: { "x-kilo-directory": tmp.path }, + }, + ) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index b2af253d5b3..ac62dc18ed9 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -122,7 +122,7 @@ describe("sync HttpApi", () => { } }) - test("returns structured validation errors", async () => { + test.todo("returns structured validation errors", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await ExperimentalHttpApiServer.webHandler().handler( new Request(`http://localhost${SyncPaths.history}`, { diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts new file mode 100644 index 00000000000..77ad1bc279a --- /dev/null +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -0,0 +1,97 @@ +// Regression: a stored step-finish part with a negative token count made the +// messages endpoint 400. Some providers reported `outputTokens` excluding +// reasoning while also reporting `reasoningTokens` separately, so the +// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to +// negative. The pre-fix `safe()` clamp only guarded against non-finite. The +// strict `NonNegativeInt` schema then made every load of the message list +// fail to encode, killing Desktop boot for every user with such a row. +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID } from "../../src/session/schema" +import * as Database from "@/storage/db" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function seedNegativeTokenSession(directory: string) { + return Effect.promise(async () => + WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }), + ) +} + +describe("messages endpoint tolerates legacy negative token counts", () => { + it.live( + "returns 200 even when a step-finish part has tokens.output < 0", + Effect.acquireRelease( + Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const sessionID = yield* seedNegativeTokenSession(tmp.path) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + ), + ), + ) +}) diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts new file mode 100644 index 00000000000..31195dd0213 --- /dev/null +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -0,0 +1,74 @@ +/** + * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true` + * path used to throw raw values (empty strings or POJOs from JSON-decoded + * error bodies). The TUI catches those and `e.message`/`e.stack` are + * undefined, so users see `[object Object]` or a blank crash. + * + * Both cases must throw a real `Error` instance with a non-empty `.message` + * extracted from the response body, plus `.status` and `.body` attached. + */ +import { afterEach, describe, expect, test } from "bun:test" +import { createKiloClient } from "@kilocode/sdk/v2" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createKiloClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v2 SDK error shape", () => { + test("404 with NamedError body throws a real Error carrying the server message", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(err.message).toContain("Session not found") + expect(cause.status).toBe(404) + expect(cause.body).toMatchObject({ + name: "NotFoundError", + data: { message: expect.stringContaining("Session not found") }, + }) + }) + + test("400 with empty body throws a real Error naming the status", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + // POST /sync/history with `aggregate: -1` triggers schema validation + // that returns an empty 400 body (verified via plan-mode probe). + await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { status?: number } + expect(err.message.length).toBeGreaterThan(0) + expect(cause.status).toBe(400) + }) +}) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts new file mode 100644 index 00000000000..8079fc5ce43 --- /dev/null +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -0,0 +1,81 @@ +/** + * Regression test for the same bug class as #26574 (sibling of #26566 and + * #26553). The Desktop app calls GET /session//diff; before #26574 + * the response was Schema-encoded against `Snapshot.FileDiff` with + * `patch: Schema.String` (required), so any session whose stored + * `summary_diffs` had a row without `patch` returned HTTP 400 and the + * session never loaded. + * + * This test inserts a session row with a missing-patch diff entry and + * asserts that GET /session//diff returns 200 with the row intact. + */ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Server } from "@/server/server" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { WithInstance } from "@/project/with-instance" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +describe("session diff with missing patch (#26574)", () => { + it.live("GET /session//diff returns 200 when summary_diffs row has no patch", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })), + (t) => Effect.promise(() => t[Symbol.asyncDispose]()), + ) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Effect.runPromise( + Effect.provide( + Session.Service.use((s) => s.create({ title: "missing-patch" })), + Session.defaultLayer, + ), + ) + + // Mimic legacy/imported on-disk shape: a diff entry with no + // `patch` text. Pre-fix the typed response encoder rejects + // this and returns 400. + await Effect.runPromise( + Effect.provide( + Storage.Service.use((s) => + s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), + ), + Storage.defaultLayer, + ), + ) + + const headers = { "x-kilo-directory": tmp.path } + const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { + headers, + }) + expect(response.status).toBe(200) + const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> + expect(body).toHaveLength(1) + expect(body[0]?.file).toBe("legacy.txt") + expect(body[0]?.additions).toBe(1) + expect(body[0]?.patch).toBeUndefined() + }, + }), + ) + }), + ) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index a1feb1725f8..1ef8b490a21 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" @@ -165,4 +164,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts directory query used by workspace routing", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const res = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + + await svc.remove(session.id) + }, + }), + ) + }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index b28ddbb4f49..e4b6c790935 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -84,6 +84,26 @@ describe("Session.Info", () => { expect(Session.Info.zod.parse(input)).toEqual(input) }) + test("accepts migrated summary diffs without file details", () => { + const input = { + id: sessionID, + slug: "legacy-diff", + projectID, + directory: "/tmp/proj", + title: "Legacy diff", + version: "0.1.0", + summary: { + additions: 1, + deletions: 0, + files: 1, + diffs: [{ additions: 1, deletions: 0 }], + }, + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 41113bb7444..36aa502cc97 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -239,7 +239,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Array<{ file: string }> = [] + let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts new file mode 100644 index 00000000000..2d30646976f --- /dev/null +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { migrate } from "drizzle-orm/bun-sqlite/migrator" +import { readFileSync, readdirSync } from "fs" +import path from "path" + +const target = "20260507164347_add_workspace_time" + +function migrations() { + return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + timestamp: Number(entry.name.split("_")[0]), + sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + })) + .sort((a, b) => a.timestamp - b.timestamp) +} + +describe("workspace time migration", () => { + test("migrates existing workspace rows", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + const entries = migrations() + const index = entries.findIndex((entry) => entry.name === target) + + expect(index).toBeGreaterThan(0) + + migrate(db, entries.slice(0, index)) + sqlite.run( + "INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["project_1", "/tmp/project", "git", "project", 1, 1, "[]"], + ) + sqlite.run( + "INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"], + ) + + expect(() => migrate(db, entries.slice(index))).not.toThrow() + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + }) +}) diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 3d43f6c7ad9..9d0f701a127 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "../../src/util/effect-zod" +import { toJsonSchema } from "@opencode-ai/core/effect-zod" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index c8096e98c51..68fdb6bd63f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -155,11 +155,24 @@ describe("tool.read external_directory permission", () => { yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") expect(read).toBeDefined() - expect(read!.patterns).toEqual([full(target)]) + expect(read!.patterns).toEqual([path.relative(dir, full(target))]) }), ) } + it.live("uses worktree-relative path for read permission so user rules match like edit/write", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "secret.ts"), "shh") + + const { items, next } = asks() + yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual([path.join("src", "secret.ts")]) + }), + ) + it.live("asks for directory-scoped external_directory permission when reading external directory", () => Effect.gen(function* () { const outer = yield* tmpdirScoped() diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e647..ab3923d8e0a 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index c6839ef5a86..78a4cc9a94c 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -78,6 +78,42 @@ export type TuiKeys = { export type TuiKeymap = Keymap +/** + * Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. + */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: (dialog?: TuiDialogStack) => void | Promise +} + +/** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ +export type TuiCommandApi = { + /** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */ + register: (cb: () => TuiCommand[]) => () => void + /** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */ + trigger: (value: string) => void + /** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */ + show: () => void +} + export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" onClose: () => void @@ -468,6 +504,13 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + /** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ + command?: TuiCommandApi keys: TuiKeys keymap: TuiKeymap route: { diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index 57ca3ddca9a..736a2611d15 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { KiloClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "./error-interceptor.js" export { type Config as KiloClientConfig, KiloClient } function pick(value: string | null, fallback?: string) { @@ -60,5 +61,6 @@ export function createKiloClient(config?: Config & { directory?: string }) { const client = createClient(config) client.interceptors.request.use((request) => rewrite(request, config?.directory)) + client.interceptors.error.use(wrapClientError) return new KiloClient({ client }) } diff --git a/packages/sdk/js/src/error-interceptor.ts b/packages/sdk/js/src/error-interceptor.ts new file mode 100644 index 00000000000..58cb44527ff --- /dev/null +++ b/packages/sdk/js/src/error-interceptor.ts @@ -0,0 +1,52 @@ +/** + * Wrap whatever the generated client decoded from a non-2xx error body + * into a real `Error` so downstream formatters (TUI, plugins) get a + * useful `.message` instead of `[object Object]` or blank. The original + * parsed body and status live under `.cause` for callers that need + * structured fields. + * + * Only fires when the caller used `{ throwOnError: true }`. Callers that + * read `result.error` directly (the result-tuple path) get the parsed + * body unchanged so existing field-level reads (`.error.name`, + * `JSON.stringify(error)`, etc.) are byte-for-byte identical to before. + */ +export function wrapClientError( + error: unknown, + response: Response | undefined, + request: Request | undefined, + opts: { throwOnError?: boolean } | undefined, +): unknown { + if (!opts?.throwOnError) return error + if (error instanceof Error) return error + + // NamedError-shaped responses (the common case for opencode 4xx) come + // through as POJOs — extract a useful message first, then wrap. + if (typeof error === "object" && error !== null && Object.keys(error).length > 0) { + const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown } + const message = + (typeof obj.data?.message === "string" && obj.data.message) || + (typeof obj.message === "string" && obj.message) || + (typeof obj.name === "string" && obj.name) || + describe(request, response) + return new Error(message, { cause: { body: error, status: response?.status } }) + } + + if (typeof error === "string" && error.length > 0) { + return new Error(error, { cause: { body: error, status: response?.status } }) + } + + // Empty body / network failure / undefined / null / empty object. + const reason = response ? "(empty response body)" : "network error (no response)" + // kilocode_change + return new Error(`kilo server ${describe(request, response)}: ${reason}`, { + cause: { body: error, status: response?.status }, + }) +} + +function describe(request: Request | undefined, response: Response | undefined) { + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const status = response?.status + const statusText = response?.statusText + return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}` +} diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 18cbd3e94a2..ef993023bbb 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1065,7 +1065,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options?: { [key: string]: unknown } @@ -3012,7 +3012,7 @@ export type ProviderListResponses = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 1a708a283ac..5cd9efeb790 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { KiloClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "../error-interceptor.js" export { type Config as KiloClientConfig, KiloClient } function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { @@ -93,24 +94,6 @@ export function createKiloClient(config?: Config & { directory?: string; experim return response }) - // The generated client falls back to throwing a literal `{}` when the server - // responds with an empty / unparseable error body, which surfaces as a bare - // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so - // downstream formatters get a useful message — but pass through any parsed - // JSON error body unchanged so existing consumers can still inspect fields. - client.interceptors.error.use((error, response, request) => { - const isEmpty = - error === undefined || - error === null || - error === "" || - (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) - if (!isEmpty) return error - const method = request?.method ?? "?" - const url = request?.url ?? "?" - if (!response) return new Error(`kilo server ${method} ${url}: network error (no response)`) - const status = response.status - const statusText = response.statusText ? " " + response.statusText : "" - return new Error(`kilo server ${method} ${url} → ${status}${statusText}: (empty response body)`) - }) + client.interceptors.error.use(wrapClientError) return new KiloClient({ client }) } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1def4a0b479..5e3fd7db189 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4820,6 +4820,13 @@ export class Session3 extends HeyApiClient { parameters?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + cursor?: string }, options?: Options, ) { @@ -4830,6 +4837,13 @@ export class Session3 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "cursor" }, ], }, ], @@ -4988,6 +5002,9 @@ export class Session3 extends HeyApiClient { sessionID: string directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + cursor?: string }, options?: Options, ) { @@ -4999,6 +5016,9 @@ export class Session3 extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a0fb916a27b..75ae17ec944 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -282,8 +282,8 @@ export type BackgroundProcessInfo = { } export type SnapshotFileDiff = { - file: string - patch: string + file?: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -804,7 +804,7 @@ export type Part = | CompactionPart export type SnapshotSummaryFileDiff = { - file: string + file?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -1246,7 +1246,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" provider?: { npm?: string api?: string @@ -1603,8 +1603,8 @@ export type WorktreeResetInput = { } export type WorktreeDiffItem = { - file: string - patch: string + file?: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -1740,7 +1740,7 @@ export type VcsFileStatus = { export type VcsFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -6991,6 +6991,16 @@ export type V2SessionListData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters. + */ + cursor?: string } url: "/api/session" } @@ -7108,6 +7118,12 @@ export type V2SessionMessagesData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string } url: "/api/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6f845c28f5c..d90f7cd4097 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -115,18 +115,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -446,18 +446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -490,18 +490,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -532,18 +532,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -595,18 +595,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -656,18 +656,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -717,18 +717,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -771,18 +771,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -858,18 +858,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -922,18 +922,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "provider", @@ -992,18 +992,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1046,18 +1046,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1092,18 +1092,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1153,18 +1153,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1217,18 +1217,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1281,18 +1281,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "base", @@ -1347,18 +1347,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "base", @@ -1413,18 +1413,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "base", @@ -1483,18 +1483,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "projectID", @@ -1611,18 +1611,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1659,18 +1659,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "pattern", @@ -1775,18 +1775,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1859,18 +1859,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1915,18 +1915,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1971,18 +1971,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -2023,18 +2023,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2071,18 +2071,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2116,18 +2116,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2160,18 +2160,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2204,18 +2204,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2252,18 +2252,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "mode", @@ -2309,18 +2309,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2353,18 +2353,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2431,18 +2431,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2479,18 +2479,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2527,18 +2527,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2591,18 +2591,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2639,18 +2639,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2687,18 +2687,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2733,18 +2733,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2815,28 +2815,28 @@ "operationId": "mcp.auth.start", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "name", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "name", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -2896,28 +2896,28 @@ "operationId": "mcp.auth.remove", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "name", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "name", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -2967,28 +2967,28 @@ "operationId": "mcp.auth.callback", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "name", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "name", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -3055,28 +3055,28 @@ "operationId": "mcp.auth.authenticate", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "name", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "name", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -3127,28 +3127,28 @@ "operationId": "mcp.connect", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "name", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "name", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -3179,28 +3179,28 @@ "operationId": "mcp.disconnect", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "name", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "name", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -3233,18 +3233,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3281,18 +3281,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3325,18 +3325,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3367,28 +3367,28 @@ "operationId": "project.update", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "projectID", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "projectID", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -3481,18 +3481,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3542,18 +3542,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3588,18 +3588,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3672,30 +3672,30 @@ "tags": ["pty"], "operationId": "pty.get", "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "ptyID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^pty.*" }, - "required": true + "required": false } ], "responses": { @@ -3733,30 +3733,30 @@ "tags": ["pty"], "operationId": "pty.update", "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "ptyID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^pty.*" }, - "required": true + "required": false } ], "responses": { @@ -3834,30 +3834,30 @@ "tags": ["pty"], "operationId": "pty.remove", "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "ptyID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^pty.*" }, - "required": true + "required": false } ], "responses": { @@ -3898,30 +3898,30 @@ "tags": ["pty"], "operationId": "pty.connectToken", "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "ptyID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^pty.*" }, - "required": true + "required": false } ], "responses": { @@ -3986,18 +3986,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4031,30 +4031,30 @@ "tags": ["question"], "operationId": "question.reply", "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "requestID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^que.*" }, - "required": true + "required": false } ], "responses": { @@ -4125,30 +4125,30 @@ "tags": ["question"], "operationId": "question.reject", "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "requestID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^que.*" }, - "required": true + "required": false } ], "responses": { @@ -4202,18 +4202,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4247,30 +4247,30 @@ "tags": ["permission"], "operationId": "permission.reply", "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "requestID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^per.*" }, - "required": true + "required": false } ], "responses": { @@ -4341,30 +4341,30 @@ "tags": ["permission"], "operationId": "permission.saveAlwaysRules", "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "requestID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^per.*" }, - "required": true + "required": false } ], "responses": { @@ -4432,18 +4432,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4519,18 +4519,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4592,18 +4592,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4641,28 +4641,28 @@ "operationId": "provider.oauth.authorize", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "providerID", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "providerID", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -4726,28 +4726,28 @@ "operationId": "provider.oauth.callback", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "providerID", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "providerID", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -4811,18 +4811,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -4914,18 +4914,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -5014,18 +5014,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -5069,30 +5069,30 @@ "tags": ["session"], "operationId": "session.get", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -5140,30 +5140,30 @@ "tags": ["session"], "operationId": "session.delete", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -5212,30 +5212,30 @@ "tags": ["session"], "operationId": "session.update", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -5312,30 +5312,30 @@ "tags": ["session"], "operationId": "session.children", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -5389,30 +5389,30 @@ "tags": ["session"], "operationId": "session.todo", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -5466,30 +5466,30 @@ "tags": ["session"], "operationId": "session.diff", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false }, { "name": "messageID", @@ -5532,30 +5532,30 @@ "tags": ["session"], "operationId": "session.messages", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false }, { "name": "limit", @@ -5638,30 +5638,30 @@ "tags": ["session"], "operationId": "session.prompt", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -5820,22 +5820,6 @@ "tags": ["session"], "operationId": "session.message", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5853,6 +5837,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5914,22 +5914,6 @@ "tags": ["session"], "operationId": "session.deleteMessage", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5947,6 +5931,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5997,30 +5997,30 @@ "tags": ["session"], "operationId": "session.fork", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6085,30 +6085,30 @@ "tags": ["session"], "operationId": "session.abort", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6159,30 +6159,30 @@ "tags": ["session"], "operationId": "session.init", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6255,30 +6255,30 @@ "tags": ["session"], "operationId": "session.share", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6326,30 +6326,30 @@ "tags": ["session"], "operationId": "session.unshare", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6399,30 +6399,30 @@ "tags": ["session"], "operationId": "session.summarize", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6495,30 +6495,30 @@ "tags": ["session"], "operationId": "session.prompt_async", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6658,30 +6658,30 @@ "tags": ["session"], "operationId": "session.command", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6807,30 +6807,30 @@ "tags": ["session"], "operationId": "session.shell", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -6929,30 +6929,30 @@ "tags": ["session"], "operationId": "session.revert", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -7021,30 +7021,30 @@ "tags": ["session"], "operationId": "session.unrevert", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -7094,22 +7094,6 @@ "tags": ["session"], "operationId": "permission.respond", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7127,6 +7111,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7195,22 +7195,6 @@ "tags": ["session"], "operationId": "part.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7237,6 +7221,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7284,22 +7284,6 @@ "tags": ["session"], "operationId": "part.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7326,6 +7310,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7386,18 +7386,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7455,18 +7455,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7500,18 +7500,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7605,18 +7605,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7683,18 +7683,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7774,18 +7774,84 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters." + }, + "required": false } ], "responses": { @@ -7825,30 +7891,30 @@ "tags": ["v2"], "operationId": "v2.session.prompt", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -7897,30 +7963,30 @@ "tags": ["v2"], "operationId": "v2.session.compact", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -7943,30 +8009,30 @@ "tags": ["v2"], "operationId": "v2.session.wait", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -7989,30 +8055,30 @@ "tags": ["v2"], "operationId": "v2.session.context", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -8045,30 +8111,56 @@ "tags": ["v2 messages"], "operationId": "v2.session.messages", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "sessionID", - "in": "path", + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", "schema": { "type": "string", - "pattern": "^ses.*" + "enum": ["asc", "desc"] }, - "required": true + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + "required": false } ], "responses": { @@ -8111,18 +8203,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8182,18 +8274,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8227,18 +8319,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8272,18 +8364,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8317,18 +8409,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8362,18 +8454,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8407,18 +8499,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8452,18 +8544,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8523,18 +8615,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8595,18 +8687,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8672,18 +8764,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8754,18 +8846,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8807,18 +8899,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8859,18 +8951,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8920,18 +9012,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8966,18 +9058,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -9057,18 +9149,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -9094,18 +9186,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -9150,30 +9242,30 @@ "tags": ["workspace"], "operationId": "experimental.workspace.remove", "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "id", - "in": "path", - "schema": { - "type": "string", - "pattern": "^wrk.*" }, - "required": true + "required": false } ], "responses": { @@ -9216,18 +9308,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -9299,18 +9391,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -9443,32 +9535,32 @@ "tags": ["agent-builder"], "operationId": "agentBuilder.save", "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "id", - "in": "path", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" }, - "required": true + "required": false } ], "responses": { @@ -9604,18 +9696,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -9649,30 +9741,30 @@ "tags": ["background-process"], "operationId": "backgroundProcess.get", "parameters": [ + { + "name": "processID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^bgp.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "processID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^bgp.*" }, - "required": true + "required": false } ], "responses": { @@ -9712,30 +9804,30 @@ "tags": ["background-process"], "operationId": "backgroundProcess.logs", "parameters": [ + { + "name": "processID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^bgp.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "processID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^bgp.*" }, - "required": true + "required": false } ], "responses": { @@ -9775,30 +9867,30 @@ "tags": ["background-process"], "operationId": "backgroundProcess.stop", "parameters": [ + { + "name": "processID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^bgp.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "processID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^bgp.*" }, - "required": true + "required": false } ], "responses": { @@ -9838,30 +9930,30 @@ "tags": ["background-process"], "operationId": "backgroundProcess.restart", "parameters": [ + { + "name": "processID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^bgp.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "processID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^bgp.*" }, - "required": true + "required": false } ], "responses": { @@ -9901,30 +9993,30 @@ "tags": ["background-process"], "operationId": "backgroundProcess.stopSession", "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "sessionID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^ses.*" }, - "required": true + "required": false } ], "responses": { @@ -9958,18 +10050,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10046,18 +10138,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -10098,18 +10190,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10171,18 +10263,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10215,18 +10307,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10259,18 +10351,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -10311,18 +10403,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10376,18 +10468,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10418,18 +10510,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10490,18 +10582,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10532,18 +10624,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -10727,18 +10819,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10771,18 +10863,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10851,18 +10943,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10895,18 +10987,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -10999,18 +11091,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11152,18 +11244,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11272,18 +11364,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11417,18 +11509,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11518,18 +11610,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11611,18 +11703,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11689,18 +11781,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11801,18 +11893,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -11861,18 +11953,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "cursor", @@ -11970,28 +12062,28 @@ "operationId": "kilo.cloud.session.get", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "id", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "id", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -12127,18 +12219,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12231,18 +12323,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12286,18 +12378,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12357,18 +12449,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12428,18 +12520,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12473,30 +12565,30 @@ "tags": ["network"], "operationId": "network.reply", "parameters": [ + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "requestID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^que.*" }, - "required": true + "required": false } ], "responses": { @@ -12548,29 +12640,29 @@ "operationId": "network.reject", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "requestID", + "in": "path", "schema": { - "type": "string" - } + "type": "string", + "pattern": "^que.*" + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "requestID", - "in": "path", + "name": "workspace", + "in": "query", "schema": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -12624,18 +12716,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12679,18 +12771,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12734,18 +12826,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12789,18 +12881,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -12898,18 +12990,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -13051,18 +13143,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -13282,18 +13374,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -13574,18 +13666,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -13620,28 +13712,28 @@ "operationId": "suggestion.accept", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "requestID", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "requestID", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -13711,28 +13803,28 @@ "operationId": "suggestion.dismiss", "parameters": [ { - "name": "directory", - "in": "query", - "required": false, + "name": "requestID", + "in": "path", "schema": { "type": "string" - } + }, + "required": true }, { - "name": "workspace", + "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { - "name": "requestID", - "in": "path", + "name": "workspace", + "in": "query", "schema": { "type": "string" }, - "required": true + "required": false } ], "responses": { @@ -13786,18 +13878,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -13861,18 +13953,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -13929,30 +14021,30 @@ "tags": ["pty"], "operationId": "pty.connect", "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } - }, - { - "name": "ptyID", - "in": "path", - "schema": { - "type": "string", - "pattern": "^pty.*" }, - "required": true + "required": false } ], "responses": { @@ -14771,19 +14863,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { @@ -15472,31 +15562,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -15680,12 +15764,10 @@ "type": "string" }, "start": { - "type": "integer", - "minimum": 0 + "type": "number" }, "end": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["value", "start", "end"], @@ -16068,31 +16150,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -16313,19 +16389,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "additions", "deletions"], + "required": ["additions", "deletions"], "additionalProperties": false }, "PermissionAction": { @@ -16382,16 +16456,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -17554,7 +17625,7 @@ }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": ["alpha", "beta", "deprecated", "active"] }, "provider": { "type": "object", @@ -18562,12 +18633,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -18592,18 +18661,7 @@ "type": "string" } }, - "required": [ - "file", - "patch", - "additions", - "deletions", - "before", - "after", - "tracked", - "generatedLike", - "summarized", - "stamp" - ], + "required": ["additions", "deletions", "before", "after", "tracked", "generatedLike", "summarized", "stamp"], "additionalProperties": false }, "ProjectSummary": { @@ -18650,16 +18708,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -18980,12 +19035,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -19005,19 +19058,17 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "VcsApplyError": { @@ -20688,16 +20739,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -21282,27 +21330,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -22020,8 +22063,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" @@ -23959,27 +24001,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -24567,8 +24604,7 @@ "type": "string" }, "statusCode": { - "type": "integer", - "minimum": 0 + "type": "number" }, "isRetryable": { "type": "boolean" @@ -24612,8 +24648,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 7bd43ceda18..4ebddf6e975 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -12,7 +12,8 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff +type SnapshotDiff = SnapshotFileDiff & { file: string } +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff // kilocode_change start - expose patch text extraction without building FileDiffMetadata on the UI thread export type DiffText = { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 766cb82f9a3..ac3592278e5 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -62,7 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } function diff(value: unknown): value is ReviewDiff { @@ -108,7 +113,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: ReviewDiff[] + diffs: RawReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise lineCommentMention?: LineCommentEditorProps["mention"] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 429ec1ed733..ff11fd8f680 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -90,6 +90,12 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +type SummaryDiff = SnapshotFileDiff & { file: string } + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + const hidden = new Set(["todowrite"]) function partState(part: PartType, showReasoningSummaries: boolean) { @@ -170,7 +176,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: SnapshotFileDiff[] = [] + const emptyDiffs: SummaryDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -251,7 +257,8 @@ export function SessionTurn( const seen = new Set() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff)