Merge remote-tracking branch 'origin/main' into puddle-barometer

This commit is contained in:
kirillk
2026-07-28 11:21:22 -04:00
533 changed files with 17512 additions and 19035 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep Nix builds on the Bun version required by the repository.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix settings changes sometimes failing to save and apply in VS Code.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show prompt input toggle tooltips instantly on hover instead of after a delay.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Allow sending prompts while a session is busy and show queued prompts with a remove action.
+30
View File
@@ -0,0 +1,30 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Changes from opencode v1.17.5 to v1.17.9 upstream:
- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities.
- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port.
- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start.
- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions.
- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense)
- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang)
- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties.
- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx)
- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled.
- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure.
- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly.
- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run.
- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first.
- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes.
- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China)
- Core Bugfixes: Pass configured custom headers to Copilot model requests.
- Core Improvements: MCP servers can now receive the current workspace as a client root.
- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps.
- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte)
- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective.
- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version.
- TUI Bugfixes: Only show the background subagent shortcut when the server supports it.
- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- <file>`. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Emit each agent event once from `kilo run --format json`.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep message and response copy buttons working after switching focus away from VS Code.
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stabilize cross-platform CLI subprocess tests under constrained CI runners
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
+108 -32
View File
@@ -6,20 +6,22 @@
* Batching bounds each `kilo run` context (a replay window can yield dozens
* of docs-worthy PRs with large diffs). Each batch gets its own CLI session
* and writes its own summary file; results are merged into
* docs-sync-out/edit-summary.json. A batch that fails is skipped with a
* warning — its PRs show up in the rolling PR body as skipped, so nothing
* fails silently.
* docs-sync-out/edit-summary.json. A batch that fails or is deferred by the
* wall-clock budget is recorded as action "pending" so the watermark holds
* back and the next run re-collects those PRs.
*
* Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider).
* Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider).
* Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15).
* Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
const BATCH_SIZE = 5
const ATTEMPTS = 2
const ATTEMPTS = 3
const OUT_DIR = "docs-sync-out"
export const SUMMARY_FILE = ".docs-sync-summary.json"
@@ -28,6 +30,10 @@ const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
const model = process.env.EDIT_MODEL
if (!model) throw new Error("EDIT_MODEL is required")
const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50
const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15
const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000
const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8"))
const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8"))
const priority = new Map(triage.map((e) => [e.url, e]))
@@ -36,7 +42,18 @@ const ordered = [...worthy].sort((a, b) => {
return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1)
})
function editBatch(batch, index) {
/** @type {Map<string, string>} url → pending cause for failed/deferred batches */
const pendingCauses = new Map()
function formatCause(result) {
const bits = []
if (result.timedOut) bits.push("timed out")
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
return bits.join("; ") || "no diagnostic"
}
function editBatch(batch, index, budgetDeadline) {
const batchFile = `${OUT_DIR}/edit-batch-${index}.json`
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
@@ -54,31 +71,59 @@ function editBatch(batch, index) {
Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).`
let lastCause = "edit pass failed"
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
// Message positional first: --file is multi-value and would otherwise
// consume a trailing message as a file path ("File not found").
execFileSync(
"kilo",
["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
// stdout streams live to the Actions log; stderr is piped so failure
// warnings can include the tail of the actual CLI error.
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] },
const left = remainingMs(budgetDeadline)
if (left < BATCH_TIMEOUT_MS) {
lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)`
console.warn(
`batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`,
)
if (fs.existsSync(summaryFile)) return true
// Tolerate the agent dropping the docs-sync-out/ prefix.
const alt = path.basename(summaryFile)
if (fs.existsSync(alt)) {
fs.renameSync(alt, summaryFile)
return true
break
}
const result = runKilo({
args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
streamStdout: true,
label: `edit batch ${index} attempt ${attempt}`,
})
if (fs.existsSync(summaryFile)) return true
// Tolerate the agent dropping the docs-sync-out/ prefix.
const alt = path.basename(summaryFile)
if (fs.existsSync(alt)) {
fs.renameSync(alt, summaryFile)
return true
}
// Exit 0 is not success: missing summary is a failure logged WITH the
// captured stderrTail and exit code on every attempt.
const cause = formatCause(result)
lastCause = `edit batch ${index}: ${cause}`
console.warn(
`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` +
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
)
if (attempt < ATTEMPTS) {
const wait = backoffMsForAttempt(attempt)
// Skip the wait when the remaining budget cannot fit another attempt.
const afterWait = remainingMs(budgetDeadline) - wait
if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) {
console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
sleepSync(wait)
} else if (wait > 0) {
console.warn(
`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
)
}
console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`)
} catch (err) {
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
}
}
console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`)
console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`)
for (const d of batch) pendingCauses.set(d.url, lastCause)
return false
}
@@ -88,12 +133,34 @@ for (let i = 0; i < ordered.length; i += BATCH_SIZE) {
}
console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`)
const budgetDeadline = deadline(EDIT_BUDGET_MINUTES)
let deferredFrom = -1
for (let i = 0; i < batches.length; i++) {
editBatch(batches[i], i)
const left = remainingMs(budgetDeadline)
if (left < BATCH_TIMEOUT_MS) {
deferredFrom = i
const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0)
console.warn(
`stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`,
)
const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)`
for (let j = i; j < batches.length; j++) {
for (const d of batches[j]) pendingCauses.set(d.url, cause)
}
break
}
editBatch(batches[i], i, budgetDeadline)
}
if (deferredFrom >= 0) {
console.warn(
`edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`,
)
}
// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR
// body accounts for it; failed batches show up as skipped.
// body accounts for it; failed/deferred batches show up as pending (not skipped).
const merged = []
const seen = new Set()
for (let i = 0; i < batches.length; i++) {
@@ -108,15 +175,24 @@ for (let i = 0; i < batches.length; i++) {
const url = String(e?.url ?? "")
if (!url.startsWith("http") || seen.has(url)) continue
seen.add(url)
merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") })
merged.push({
pr: Number(e.pr) || 0,
url,
action: String(e.action ?? "skipped"),
reason: String(e.reason ?? ""),
})
}
}
for (const d of ordered) {
if (seen.has(d.url)) continue
merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" })
const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR"
merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause })
}
// upsert-pr.mjs consumes the merged summary from the repo root; the file is
// removed there before committing so it never lands in the docs PR.
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2))
console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`)
const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length
const skipped = merged.filter((e) => e.action === "skipped").length
const pending = merged.filter((e) => e.action === "pending").length
console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`)
+95
View File
@@ -6,6 +6,7 @@
* gh CLI.
*/
import { spawnSync } from "node:child_process"
import fs from "node:fs"
const API = "https://api.github.com"
@@ -111,3 +112,97 @@ export function appendSummary(markdown) {
const summary = process.env.GITHUB_STEP_SUMMARY
if (summary) fs.appendFileSync(summary, markdown + "\n")
}
/**
* Absolute deadline timestamp (ms since epoch) for a wall-clock budget.
* Used by triage/edit to stop before the job timeout rather than silently
* truncating.
*/
export function deadline(minutes) {
return Date.now() + Number(minutes) * 60 * 1000
}
/** Remaining milliseconds until a deadline; never negative. */
export function remainingMs(deadlineMs) {
return Math.max(0, Number(deadlineMs) - Date.now())
}
/**
* Backoff schedule between kilo-run attempts. Production waits 60s then 300s
* (observed outage lasted ~11 min; batch 8 recovered on attempt 2). When
* DOCS_SYNC_BACKOFF_MS is set it replaces EVERY wait (`0` disables waiting);
* the workflow never sets it — only selftests do.
*/
export function backoffMsForAttempt(attempt) {
// attempt is 1-based; wait happens after attempt N before attempt N+1.
const override = process.env.DOCS_SYNC_BACKOFF_MS
if (override !== undefined && override !== "") {
const n = Number(override)
return Number.isFinite(n) && n >= 0 ? n : 0
}
// After attempt 1 → 60s; after attempt 2 → 300s; nothing after the last.
if (attempt === 1) return 60_000
if (attempt === 2) return 300_000
return 0
}
/**
* Blocking sleep used between kilo-run retries. Prefer this over async sleep
* so edit/triage stay synchronous around spawnSync.
*/
export function sleepSync(ms) {
const n = Number(ms)
if (!Number.isFinite(n) || n <= 0) return
const end = Date.now() + n
// Atomics.wait is the portable Node sync sleep (no busy loop).
const sab = new SharedArrayBuffer(4)
const view = new Int32Array(sab)
while (Date.now() < end) {
const left = end - Date.now()
if (left <= 0) break
Atomics.wait(view, 0, 0, Math.min(left, 2_147_483_647))
}
}
const STDERR_TAIL_LINES = 20
const STDERR_TAIL_CHARS = 4_000
function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) {
const s = String(text ?? "").trim()
if (!s) return ""
const lastLines = s.split("\n").slice(-lines).join("\n")
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
}
/**
* Run `kilo` via spawnSync so stderr is always recoverable — including when
* the child exits 0 after writing a diagnostic (execFileSync cannot return
* piped stderr on exit 0; that path lost every diagnostic on run 30122603016).
*
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
* (triage parses it). stderr is always buffered.
*/
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
const result = spawnSync("kilo", args, {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
timeout: timeoutMs,
stdio: ["ignore", streamStdout ? "inherit" : "pipe", "pipe"],
})
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
const exitCode =
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
const stderrTail = tailText(result.stderr)
const stdout = streamStdout ? "" : String(result.stdout ?? "")
// ok is "process finished without OS-level failure". Callers still treat a
// missing summary / unparseable output as failure even when ok is true —
// exit 0 is not success for the docs-sync bot.
const ok = !result.error && result.status === 0
if (result.error && !timedOut) {
console.warn(`${label}: spawn error: ${result.error.message}`)
}
return { ok, stdout, stderrTail, exitCode, timedOut }
}
+77 -33
View File
@@ -6,56 +6,100 @@
* origin/main (preserves any human commits on the branch)
* - otherwise -> fresh branch from origin/main (bot force-pushes later)
*
* Outputs: branch, mode (update|fresh), pr_number (empty when fresh).
* Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh).
*/
import { execFileSync } from "node:child_process"
import { pathToFileURL } from "node:url"
import { api, appendOutput, repo, searchIssues } from "./lib.mjs"
export const DEFAULT_BRANCH = "docs/auto-sync"
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
let mode = "fresh"
let prNumber = ""
let branch = DEFAULT_BRANCH
if (prs.length > 0) {
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
branch = pr.head?.ref ?? DEFAULT_BRANCH
prNumber = String(pr.number)
git(["fetch", "origin", "main", branch])
git(["checkout", branch])
/**
* Merge origin/main into the current branch. On a genuine conflict, abort the
* merge, switch to a dated fallback branch from origin/main, and return
* mode=conflict so human commits on the rolling branch stay untouched. Any
* other merge failure (missing identity, corrupt ref, fetch issues) is
* rethrown so the job fails loudly.
*/
export function mergeOrFallback({ branch, git = defaultGit }) {
try {
git(["merge", "origin/main", "--no-edit"])
mode = "update"
} catch {
return { branch, mode: "update" }
} catch (err) {
// Conflict ⇔ unmerged index entries (or MERGE_HEAD still present).
// Identity failures and similar abort before a merge is started, so
// merge --abort would itself fail — those must rethrow.
let unmerged = ""
try {
unmerged = git(["ls-files", "--unmerged"])
} catch {
// ls-files itself failing is not a conflict signal
}
let mergeInProgress = false
try {
git(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
mergeInProgress = true
} catch {
mergeInProgress = false
}
const isConflict = unmerged.length > 0 || mergeInProgress
if (!isConflict) throw err
console.warn(`merge of origin/main into ${branch} conflicted.`)
console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.")
console.warn(
"Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.",
)
git(["merge", "--abort"])
branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
const fallback = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
try {
git(["fetch", "origin", `+refs/heads/${fallback}:refs/remotes/origin/${fallback}`])
} catch {
console.log(`dated branch ${fallback} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", fallback, "origin/main"])
return { branch: fallback, mode: "conflict" }
}
}
async function main() {
const git = defaultGit
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
let mode = "fresh"
let prNumber = ""
let branch = DEFAULT_BRANCH
if (prs.length > 0) {
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
branch = pr.head?.ref ?? DEFAULT_BRANCH
prNumber = String(pr.number)
git(["fetch", "origin", "main", branch])
git(["checkout", branch])
;({ branch, mode } = mergeOrFallback({ branch, git }))
} else {
// Keep the remote-tracking ref current so the later --force-with-lease
// push (stale branch left over from a merged/closed PR) is safe.
try {
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
} catch {
console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`)
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", branch, "origin/main"])
mode = "conflict"
}
} else {
// Keep the remote-tracking ref current so the later --force-with-lease
// push (stale branch left over from a merged/closed PR) is safe.
try {
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
} catch {
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
}
git(["checkout", "-B", branch, "origin/main"])
appendOutput("branch", branch)
appendOutput("mode", mode)
appendOutput("pr_number", prNumber)
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
}
appendOutput("branch", branch)
appendOutput("mode", mode)
appendOutput("pr_number", prNumber)
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+964
View File
@@ -0,0 +1,964 @@
// kilocode_change - new file
/**
* Offline self-check for the docs-sync failure paths (S4).
* Plain node:assert, no network, no LLM, no new dependency.
* Run: node .github/docs-sync/selftest.mjs
*/
import assert from "node:assert/strict"
import { execFileSync, spawnSync } from "node:child_process"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs"
import { applyCap } from "./watermark.mjs"
import {
computeUncovered,
computeProcessedThrough,
routeRows,
dropLegacySkipped,
noDiffReport,
renderBody,
extractSectionRows,
} from "./upsert-pr.mjs"
const HERE = path.dirname(fileURLToPath(import.meta.url))
const EDIT_SCRIPT = path.join(HERE, "edit.mjs")
const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs")
const COLLECT_SCRIPT = path.join(HERE, "collect.mjs")
const temps = []
function mktemp(prefix) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
temps.push(dir)
return dir
}
function cleanup() {
for (const dir of temps.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
// best-effort
}
}
}
function writeExecutable(filePath, body) {
fs.writeFileSync(filePath, body, { mode: 0o755 })
}
function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) {
const dir = mktemp("docs-sync-kilo-")
const kiloPath = path.join(dir, "kilo")
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage"
const script = `#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const mode = ${JSON.stringify(mode)};
const callLog = ${JSON.stringify(callLog ?? "")};
const stderrText = ${JSON.stringify(stderrText)};
if (callLog) {
fs.appendFileSync(callLog, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + "\\n");
}
if (mode === "stderr-exit0") {
process.stderr.write(stderrText + "\\n");
process.exit(0);
}
if (mode === "record") {
process.stderr.write("recorded\\n");
process.exit(0);
}
// Parse -f chunk/batch file from args for triage stubs
const args = process.argv.slice(2);
const fIdx = args.indexOf("-f");
const fileArg = fIdx >= 0 ? args[fIdx + 1] : null;
let chunk = [];
if (fileArg && fs.existsSync(fileArg)) {
try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; }
}
if (mode === "partial-triage") {
// Classify only a proper subset (first URL) of the chunk.
const owned = chunk.slice(0, Math.max(0, chunk.length - 1));
const entries = owned.map((d) => ({
pr: d.number,
url: d.url,
docs_worthy: false,
reason: "genuine not worthy",
target_sections: [],
priority: "medium",
}));
if (entries.length === 0 && chunk.length > 0) {
// single-PR chunk: still leave one missing by emitting empty-ish foreign-only
process.stdout.write("[]\\n");
} else {
process.stdout.write(JSON.stringify(entries) + "\\n");
}
process.exit(0);
}
if (mode === "mixed-triage") {
// Half docs_worthy true, half fail (no output for second half — but we return
// only some entries so backfill marks the rest pending). Actually: return
// docs_worthy:true for first half of chunk URLs so worthy > 0.
const half = Math.ceil(chunk.length / 2);
const entries = chunk.slice(0, half).map((d) => ({
pr: d.number,
url: d.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}));
process.stdout.write(JSON.stringify(entries) + "\\n");
process.exit(0);
}
process.stderr.write("unknown stub mode\\n");
process.exit(1);
`
writeExecutable(kiloPath, script)
return dir
}
function gitIn(cwd, args, env = {}) {
return execFileSync("git", args, {
cwd,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).toString().trim()
}
function makeGitRunner(cwd, env = {}) {
return (args) => gitIn(cwd, args, env)
}
function initRepoWithIdentity(dir) {
gitIn(dir, ["init", "-b", "main"])
gitIn(dir, ["config", "user.name", "docs-sync-selftest"])
gitIn(dir, ["config", "user.email", "docs-sync-selftest@example.com"])
gitIn(dir, ["config", "commit.gpgsign", "false"])
}
// ---------------------------------------------------------------------------
// Case 1 — Defect A: mergeOrFallback
// ---------------------------------------------------------------------------
function case1_mergeOrFallback() {
console.log("case 1: Defect A (mergeOrFallback)")
// 1a — identity configured + clean merge → mode=update
{
const dir = mktemp("docs-sync-merge-clean-")
initRepoWithIdentity(dir)
fs.writeFileSync(path.join(dir, "a.txt"), "base\n")
gitIn(dir, ["add", "a.txt"])
gitIn(dir, ["commit", "-m", "base"])
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
fs.writeFileSync(path.join(dir, "b.txt"), "on branch\n")
gitIn(dir, ["add", "b.txt"])
gitIn(dir, ["commit", "-m", "branch commit"])
// Advance main without conflict
gitIn(dir, ["checkout", "main"])
fs.writeFileSync(path.join(dir, "c.txt"), "on main\n")
gitIn(dir, ["add", "c.txt"])
gitIn(dir, ["commit", "-m", "main advance"])
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
gitIn(dir, ["checkout", DEFAULT_BRANCH])
const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) })
assert.equal(result.mode, "update")
assert.equal(result.branch, DEFAULT_BRANCH)
// merge brought c.txt in
assert.ok(fs.existsSync(path.join(dir, "c.txt")))
}
// 1b — genuine conflict → mode=conflict, abort succeeds, original branch untouched
{
const dir = mktemp("docs-sync-merge-conflict-")
initRepoWithIdentity(dir)
fs.writeFileSync(path.join(dir, "conflict.txt"), "base\n")
gitIn(dir, ["add", "conflict.txt"])
gitIn(dir, ["commit", "-m", "base"])
const baseSha = gitIn(dir, ["rev-parse", "HEAD"])
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
fs.writeFileSync(path.join(dir, "conflict.txt"), "branch side\n")
gitIn(dir, ["add", "conflict.txt"])
gitIn(dir, ["commit", "-m", "branch edit"])
const branchShaBefore = gitIn(dir, ["rev-parse", "HEAD"])
gitIn(dir, ["checkout", "main"])
fs.writeFileSync(path.join(dir, "conflict.txt"), "main side\n")
gitIn(dir, ["add", "conflict.txt"])
gitIn(dir, ["commit", "-m", "main edit"])
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
gitIn(dir, ["checkout", DEFAULT_BRANCH])
const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) })
assert.equal(result.mode, "conflict")
assert.ok(result.branch.startsWith(`${DEFAULT_BRANCH}-`))
// Original rolling branch tip unchanged
const branchShaAfter = gitIn(dir, ["rev-parse", DEFAULT_BRANCH])
assert.equal(branchShaAfter, branchShaBefore)
// No merge in progress
let mergeHead = true
try {
gitIn(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])
} catch {
mergeHead = false
}
assert.equal(mergeHead, false)
void baseSha
}
// 1c — identity-less / non-conflict merge failure → throws (does not fake conflict)
{
const dir = mktemp("docs-sync-merge-noid-")
initRepoWithIdentity(dir)
fs.writeFileSync(path.join(dir, "a.txt"), "base\n")
gitIn(dir, ["add", "a.txt"])
gitIn(dir, ["commit", "-m", "base"])
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
fs.writeFileSync(path.join(dir, "b.txt"), "branch\n")
gitIn(dir, ["add", "b.txt"])
gitIn(dir, ["commit", "-m", "branch"])
gitIn(dir, ["checkout", "main"])
fs.writeFileSync(path.join(dir, "c.txt"), "main\n")
gitIn(dir, ["add", "c.txt"])
gitIn(dir, ["commit", "-m", "main"])
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
gitIn(dir, ["checkout", DEFAULT_BRANCH])
// Strip identity so merge cannot create a commit
gitIn(dir, ["config", "--unset", "user.name"])
gitIn(dir, ["config", "--unset", "user.email"])
const env = {
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
GIT_CONFIG_NOSYSTEM: "1",
}
const git = (args) =>
execFileSync("git", ["-c", "user.useConfigOnly=true", ...args], {
cwd: dir,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).toString().trim()
assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => {
// Must throw the original merge error, not a merge --abort failure
const msg = String(err?.stderr ?? err?.message ?? err)
assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`)
return true
})
}
}
// ---------------------------------------------------------------------------
// Helpers to run edit.mjs / triage.mjs as child processes
// ---------------------------------------------------------------------------
function setupEditCwd(worthy, triage) {
const cwd = mktemp("docs-sync-edit-")
fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true })
fs.writeFileSync(path.join(cwd, "docs-sync-out", "worthy.json"), JSON.stringify(worthy, null, 2))
fs.writeFileSync(path.join(cwd, "docs-sync-out", "triage.json"), JSON.stringify(triage, null, 2))
return cwd
}
function setupTriageCwd(digest) {
const cwd = mktemp("docs-sync-triage-")
fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true })
fs.writeFileSync(path.join(cwd, "docs-sync-out", "digest.json"), JSON.stringify(digest, null, 2))
return cwd
}
function runNodeScript(scriptPath, { cwd, env = {}, kiloDir }) {
const pathEnv = [kiloDir, process.env.PATH].filter(Boolean).join(path.delimiter)
const result = spawnSync(process.execPath, [scriptPath], {
cwd,
env: {
...process.env,
...env,
PATH: pathEnv,
DOCS_SYNC_BACKOFF_MS: env.DOCS_SYNC_BACKOFF_MS ?? "0",
},
encoding: "utf8",
timeout: 60_000,
})
return {
status: result.status,
stdout: String(result.stdout ?? ""),
stderr: String(result.stderr ?? ""),
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
error: result.error,
}
}
function samplePr(n, { merged_at, repo = "Kilo-Org/cloud" } = {}) {
return {
repo,
number: n,
title: `feat: sample ${n}`,
url: `https://github.com/${repo}/pull/${n}`,
author: "dev",
merged_at: merged_at ?? "2026-07-20T12:00:00.000Z",
labels: [],
body: "body",
files: [],
files_total: 1,
patch_excerpt: "",
}
}
// ---------------------------------------------------------------------------
// Case 2 — Defect B: edit.mjs with stub kilo (exit 0 + stderr)
// ---------------------------------------------------------------------------
function case2_defectB() {
console.log("case 2: Defect B (edit.mjs stderr-on-exit-0)")
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: ["overview"],
priority: "high",
}))
const cwd = setupEditCwd(worthy, triage)
const stderrText = "event stream disconnected DIAG-CASE2"
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
const started = Date.now()
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
// Enough budget for 3 attempts × tiny timeout
EDIT_BUDGET_MINUTES: "5",
EDIT_BATCH_TIMEOUT_MINUTES: "1",
},
})
const elapsed = Date.now() - started
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
// Backoff collapsed — 3 attempts without 60s+300s waits
assert.ok(elapsed < 15_000, `backoff should collapse with DOCS_SYNC_BACKOFF_MS=0; elapsed=${elapsed}ms`)
assert.match(result.output, /stderr tail:/)
assert.match(result.output, /DIAG-CASE2|event stream disconnected/)
assert.match(result.output, /attempt 1/)
assert.match(result.output, /attempt 2/)
// 3 attempts
assert.match(result.output, /attempt 3|failed after up to 3 attempts/)
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
assert.equal(summary.length, 5)
for (const e of summary) {
assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`)
}
const uncovered = computeUncovered({ worthy, summary, triage })
assert.equal(uncovered.length, 5)
for (const u of uncovered) {
assert.ok(u.reason, "uncovered reason present")
}
}
// ---------------------------------------------------------------------------
// Case 3 — watermark invariant
// ---------------------------------------------------------------------------
function case3_watermark() {
console.log("case 3: watermark invariant")
const now = "2026-07-27T12:00:00.000Z"
const nowMs = Date.parse(now)
const prA = samplePr(10, { merged_at: "2026-07-20T10:00:00.000Z" })
const prB = samplePr(11, { merged_at: "2026-07-22T15:30:00.000Z" })
const prC = samplePr(12, { merged_at: "2026-07-25T08:00:00.000Z" })
const digest = [prA, prB, prC]
// all covered → processed-through === now
{
const worthy = [prA, prB]
const summary = [
{ pr: 10, url: prA.url, action: "updated packages/kilo-docs/pages/x.md", reason: "" },
{ pr: 11, url: prB.url, action: "skipped", reason: "already documented" },
]
const triage = [
{ pr: 10, url: prA.url, docs_worthy: true, pending: false, reason: "ok" },
{ pr: 11, url: prB.url, docs_worthy: true, pending: false, reason: "ok" },
]
const uncovered = computeUncovered({ worthy, summary, triage })
assert.equal(uncovered.length, 0)
const through = computeProcessedThrough({ uncovered, digest, now })
assert.equal(through, now)
}
// one uncovered → merged_at − 1 ms, strictly < now
{
const worthy = [prA, prB]
const summary = [
{ pr: 10, url: prA.url, action: "updated x", reason: "" },
{ pr: 11, url: prB.url, action: "pending", reason: "edit batch 0: exit 0" },
]
const uncovered = computeUncovered({ worthy, summary, triage: [] })
assert.equal(uncovered.length, 1)
assert.equal(uncovered[0].url, prB.url)
const through = computeProcessedThrough({ uncovered, digest, now })
const expected = new Date(Date.parse(prB.merged_at) - 1).toISOString()
assert.equal(through, expected)
assert.ok(Date.parse(through) < nowMs)
}
// several uncovered → earliest merge time wins
{
const worthy = [prA, prB, prC]
const summary = [
{ pr: 10, url: prA.url, action: "pending", reason: "fail" },
{ pr: 12, url: prC.url, action: "pending", reason: "fail" },
]
// prB missing from summary entirely
const uncovered = computeUncovered({ worthy, summary, triage: [] })
assert.ok(uncovered.length >= 2)
const through = computeProcessedThrough({ uncovered, digest, now })
// earliest among A, B, C that are uncovered — A is earliest
const times = uncovered
.map((u) => digest.find((d) => d.url === u.url)?.merged_at)
.filter(Boolean)
.map((t) => Date.parse(t))
const earliest = Math.min(...times)
assert.equal(through, new Date(earliest - 1).toISOString())
}
// summary missing/truncated while worthy non-empty → every worthy URL held back
{
const worthy = [prA, prB]
const uncovered = computeUncovered({ worthy, summary: [], triage: [] })
assert.equal(uncovered.length, 2)
const through = computeProcessedThrough({ uncovered, digest, now })
assert.equal(through, new Date(Date.parse(prA.merged_at) - 1).toISOString())
}
// noDiffReport three arms
{
const uncovered = [{ url: prA.url, reason: "edit batch failed" }]
const arm1 = noDiffReport({ uncovered, sinceOverride: true })
assert.ok(arm1.summary.includes(prA.url))
assert.ok(arm1.warning, "override + uncovered → warning present")
const arm2 = noDiffReport({ uncovered: [], sinceOverride: true })
assert.equal(arm2.warning, null, "override + empty uncovered → warning absent")
const arm3 = noDiffReport({ uncovered, sinceOverride: false })
assert.equal(arm3.warning, null, "scheduled + uncovered → warning absent")
}
// triage pending:true backfill rows land in uncovered (consumption)
{
const triage = [
{
pr: 99,
url: "https://github.com/Kilo-Org/cloud/pull/99",
docs_worthy: false,
pending: true,
reason: "not classified by triage",
},
]
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
assert.equal(uncovered.length, 1)
assert.equal(uncovered[0].url, triage[0].url)
const through = computeProcessedThrough({
uncovered,
digest: [{ url: triage[0].url, merged_at: "2026-07-21T00:00:00.000Z" }],
now,
})
assert.equal(through, new Date(Date.parse("2026-07-21T00:00:00.000Z") - 1).toISOString())
}
// fallback field (post-plan repair)
{
const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/50", reason: "missing" }]
const fallback = "2026-07-17T00:00:00.000Z"
// unresolved merged_at + parseable fallback → hold at fallback, warn
const prevWarn = console.warn
const warnings = []
console.warn = (...a) => warnings.push(a.join(" "))
try {
const through = computeProcessedThrough({ uncovered, digest: [], now, fallback })
assert.equal(through, new Date(fallback).toISOString())
assert.ok(Date.parse(through) < nowMs)
assert.ok(warnings.some((w) => w.includes("::warning::")))
} finally {
console.warn = prevWarn
}
// unresolved + unparseable/missing fallback → throws
assert.throws(() => computeProcessedThrough({ uncovered, digest: [], now }), /fallback|SINCE|refusing/i)
assert.throws(
() => computeProcessedThrough({ uncovered, digest: [], now, fallback: "not-a-date" }),
/fallback|SINCE|refusing/i,
)
// resolved merged_at ignores fallback
const throughResolved = computeProcessedThrough({
uncovered: [{ url: prA.url, reason: "x" }],
digest: [prA],
now,
fallback: "2020-01-01T00:00:00.000Z",
})
assert.equal(throughResolved, new Date(Date.parse(prA.merged_at) - 1).toISOString())
// empty uncovered ignores fallback
const throughEmpty = computeProcessedThrough({
uncovered: [],
digest: [],
now,
fallback: "2020-01-01T00:00:00.000Z",
})
assert.equal(throughEmpty, now)
}
}
// ---------------------------------------------------------------------------
// Case 4 — routing and round trip
// ---------------------------------------------------------------------------
function case4_routing() {
console.log("case 4: routing and round trip")
const summary = [
{ pr: 1, url: "https://github.com/Kilo-Org/cloud/pull/1", action: "updated pages/a.md", reason: "" },
{ pr: 2, url: "https://github.com/Kilo-Org/cloud/pull/2", action: "skipped", reason: "already documented" },
{ pr: 3, url: "https://github.com/Kilo-Org/cloud/pull/3", action: "pending", reason: "edit batch 1: exit 0" },
]
const triage = [
{
pr: 4,
url: "https://github.com/Kilo-Org/cloud/pull/4",
docs_worthy: false,
pending: false,
reason: "chore only",
},
{
pr: 5,
url: "https://github.com/Kilo-Org/cloud/pull/5",
docs_worthy: false,
pending: true,
reason: "triage failed to classify this PR",
},
]
const worthy = [
{ number: 1, url: summary[0].url },
{ number: 2, url: summary[1].url },
{ number: 3, url: summary[2].url },
]
const uncovered = computeUncovered({ worthy, summary, triage })
const { changesRows, pendingRows, skippedRows } = routeRows({ summary, triage, uncovered })
// pending appears in neither Changes nor Considered
const changesText = changesRows.join("\n")
const skippedText = skippedRows.join("\n")
assert.ok(changesText.includes("pull/1"), "success in Changes")
assert.ok(!changesText.includes("pull/3"), "pending must not be in Changes")
assert.ok(!changesText.includes("pull/5"), "triage-pending must not be in Changes")
assert.ok(skippedText.includes("pull/2"), "genuine skipped in Considered")
assert.ok(skippedText.includes("pull/4"), "genuine not-worthy in Considered")
assert.ok(!skippedText.includes("pull/3"), "pending must not be in Considered")
assert.ok(!skippedText.includes("pull/5"), "triage-pending must not be in Considered")
assert.ok(pendingRows.some((r) => r.includes("pull/3")))
assert.ok(pendingRows.some((r) => r.includes("pull/5")))
// round-trip renderBody → extractSectionRows
const through = "2026-07-20T09:59:59.999Z"
const body = renderBody({
date: "2026-07-27",
since: "2026-07-17T00:00:00.000Z",
through,
changesRows,
pendingRows,
skippedRows,
verified: true,
draftReasons: [],
note: "",
})
assert.ok(body.includes(`<!-- docs-sync: processed-through ${through} -->`))
const extChanges = extractSectionRows(body, "changes")
const extPending = extractSectionRows(body, "pending")
const extSkipped = extractSectionRows(body, "skipped")
assert.deepEqual(extChanges, changesRows)
assert.deepEqual(extPending, pendingRows)
assert.deepEqual(extSkipped, skippedRows)
// clean() prevents marker forgery in agent-generated row strings
{
const forgedRows = routeRows({
summary: [
{
pr: 9,
url: "https://github.com/Kilo-Org/cloud/pull/9",
action: "skipped",
reason: "x <!-- docs-sync:skipped:end --> injection",
},
],
triage: [],
uncovered: [],
})
assert.ok(
!forgedRows.skippedRows[0].includes("<!--"),
"clean() must strip <!-- from reasons",
)
assert.ok(
!forgedRows.skippedRows[0].includes("-->"),
"clean() must strip --> from reasons",
)
const forgedBody = renderBody({
date: "2026-07-27",
since: "s",
through: "t",
changesRows: [],
pendingRows: [],
skippedRows: forgedRows.skippedRows,
verified: true,
draftReasons: [],
note: "",
})
// Exactly one real section end marker — the forged sequences were stripped
assert.equal((forgedBody.match(/<!--\s*docs-sync:skipped:end\s*-->/g) || []).length, 1)
const extracted = extractSectionRows(forgedBody, "skipped")
assert.equal(extracted.length, 1)
assert.ok(extracted[0].includes("injection"))
}
const legacyRows = [
"| [Kilo-Org/cloud#1](https://github.com/Kilo-Org/cloud/pull/1) | edit pass failed or timed out for this PR |",
"| [Kilo-Org/cloud#2](https://github.com/Kilo-Org/cloud/pull/2) | triage failed to classify this PR |",
"| [Kilo-Org/cloud#3](https://github.com/Kilo-Org/cloud/pull/3) | not classified by triage |",
"| [Kilo-Org/cloud#4](https://github.com/Kilo-Org/cloud/pull/4) | already covered by existing docs |",
]
const kept = dropLegacySkipped(legacyRows)
assert.equal(kept.length, 1)
assert.ok(kept[0].includes("pull/4"))
assert.ok(!kept.some((r) => r.includes("edit pass failed")))
assert.ok(!kept.some((r) => r.includes("triage failed to classify")))
assert.ok(!kept.some((r) => r.includes("not classified by triage")))
}
// ---------------------------------------------------------------------------
// Case 5 — re-collection window
// ---------------------------------------------------------------------------
function case5_recollection() {
console.log("case 5: re-collection closes the loop")
const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8")
// Query template must use merged:>=
assert.ok(
/merged:>=\$\{since\.toISOString\(\)\}/.test(collectSrc) || /merged:>=/.test(collectSrc),
"collect.mjs must search merged:>=since",
)
assert.match(collectSrc, /merged:>=/)
const mergedAt = "2026-07-22T15:30:00.000Z"
const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/11", reason: "pending" }]
const digest = [{ url: uncovered[0].url, merged_at: mergedAt }]
const now = "2026-07-27T12:00:00.000Z"
const since = computeProcessedThrough({ uncovered, digest, now })
// held-back since is strictly before the uncovered PR's merged_at
assert.ok(Date.parse(since) < Date.parse(mergedAt), `since ${since} must be < merged_at ${mergedAt}`)
// And the query window merged:>=since therefore includes that PR
assert.ok(Date.parse(mergedAt) >= Date.parse(since))
}
// ---------------------------------------------------------------------------
// Case 6 — budgets
// ---------------------------------------------------------------------------
function case6_budgets() {
console.log("case 6: budgets")
// --- edit budget ---
{
// 12 PRs = 3 batches of 5; budget too small for even one batch unit
const prs = Array.from({ length: 12 }, (_, i) => samplePr(100 + i))
const worthy = prs
const triage = prs.map((p) => ({
pr: p.number,
url: p.url,
docs_worthy: true,
reason: "needs docs",
target_sections: [],
priority: "medium",
}))
const cwd = setupEditCwd(worthy, triage)
const callLog = path.join(cwd, "kilo-calls.log")
const kiloDir = makeStubKiloDir({ mode: "record", callLog })
// EDIT_BUDGET_MINUTES must be positive (0 falls through to default 50).
// BATCH_TIMEOUT default would be 15m; set both tiny so left < BATCH_TIMEOUT immediately.
const result = runNodeScript(EDIT_SCRIPT, {
cwd,
kiloDir,
env: {
EDIT_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
EDIT_BUDGET_MINUTES: "0.0001",
EDIT_BATCH_TIMEOUT_MINUTES: "15",
},
})
assert.equal(result.status, 0, result.output)
assert.match(result.output, /deferring \d+ PRs/)
assert.match(result.output, /deferred \d+ PRs due to wall-clock budget/)
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : ""
const callCount = calls ? calls.split("\n").filter(Boolean).length : 0
assert.equal(callCount, 0, `kilo must not be invoked for deferred edit batches; got ${callCount}`)
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
assert.ok(summary.every((e) => e.action === "pending"))
const uncovered = computeUncovered({ worthy, summary, triage })
assert.equal(uncovered.length, 12)
assert.ok(summary.every((e) => e.action !== "skipped"))
}
// --- triage budget ---
{
// CHUNK_SIZE=25; 30 PRs = 2 chunks; budget too small for a 10m chunk
const digest = Array.from({ length: 30 }, (_, i) => samplePr(200 + i))
const cwd = setupTriageCwd(digest)
const callLog = path.join(cwd, "kilo-calls.log")
const kiloDir = makeStubKiloDir({ mode: "record", callLog })
const summaryFile = path.join(cwd, "step-summary.md")
fs.writeFileSync(summaryFile, "")
const result = runNodeScript(TRIAGE_SCRIPT, {
cwd,
kiloDir,
env: {
TRIAGE_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
TRIAGE_BUDGET_MINUTES: "0.0001",
GITHUB_STEP_SUMMARY: summaryFile,
},
})
assert.equal(result.status, 0, result.output)
assert.match(result.output, /deferring \d+ PRs/)
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : ""
const callCount = calls ? calls.split("\n").filter(Boolean).length : 0
assert.equal(callCount, 0, `kilo must not be invoked for deferred triage chunks; got ${callCount}`)
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
assert.equal(triage.length, 30)
assert.ok(triage.every((e) => e.pending === true))
assert.ok(triage.every((e) => e.docs_worthy === false))
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
assert.equal(uncovered.length, 30)
}
}
// ---------------------------------------------------------------------------
// Case 7 — applyCap both arms
// ---------------------------------------------------------------------------
function case7_cap() {
console.log("case 7: applyCap")
const now = new Date("2026-07-27T12:00:00.000Z")
const old = new Date("2026-06-01T00:00:00.000Z")
const prevLog = console.log
const prevWarn = console.warn
const logs = []
const warnings = []
console.log = (...a) => logs.push(a.join(" "))
console.warn = (...a) => warnings.push(a.join(" "))
try {
// explicit:false + older than 14 days → clamped AND reported
const a = applyCap(old, now, { explicit: false })
assert.equal(a.clamped, true)
assert.ok(a.since.getTime() > old.getTime())
const cap = new Date(now.getTime() - 14 * 24 * 3600 * 1000)
assert.equal(a.since.toISOString(), cap.toISOString())
assert.ok(warnings.some((w) => w.includes("::warning::") && w.includes("clamped")))
// explicit:true + older than 14 days → unchanged, skip reported
logs.length = 0
warnings.length = 0
const b = applyCap(old, now, { explicit: true })
assert.equal(b.clamped, false)
assert.equal(b.since.toISOString(), old.toISOString())
assert.ok(logs.some((l) => /cap skipped|INPUT_SINCE/i.test(l)))
} finally {
console.log = prevLog
console.warn = prevWarn
}
}
// ---------------------------------------------------------------------------
// Case 8 — triage.mjs outputs
// ---------------------------------------------------------------------------
function case8_triage() {
console.log("case 8: triage pass outputs")
// 8a Run A: SINCE_OVERRIDE=true + everything pending → warning present
{
const digest = [samplePr(301), samplePr(302), samplePr(303)]
const cwd = setupTriageCwd(digest)
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end before idle" })
const summaryFile = path.join(cwd, "step-summary.md")
fs.writeFileSync(summaryFile, "")
const result = runNodeScript(TRIAGE_SCRIPT, {
cwd,
kiloDir,
env: {
TRIAGE_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
TRIAGE_BUDGET_MINUTES: "30",
SINCE_OVERRIDE: "true",
GITHUB_STEP_SUMMARY: summaryFile,
},
})
assert.equal(result.status, 0, result.output)
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
assert.equal(triage.length, 3)
assert.ok(triage.every((e) => e.pending === true))
const summary = fs.readFileSync(summaryFile, "utf8")
assert.match(summary, /triage pending/)
for (const d of digest) {
assert.ok(summary.includes(d.url), `summary lists ${d.url}`)
}
assert.match(result.output, /::warning::.*since-override/)
}
// 8a Run B: SINCE_OVERRIDE unset → warning absent
{
const digest = [samplePr(311), samplePr(312)]
const cwd = setupTriageCwd(digest)
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end" })
const summaryFile = path.join(cwd, "step-summary.md")
fs.writeFileSync(summaryFile, "")
const result = runNodeScript(TRIAGE_SCRIPT, {
cwd,
kiloDir,
env: {
TRIAGE_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
TRIAGE_BUDGET_MINUTES: "30",
GITHUB_STEP_SUMMARY: summaryFile,
},
})
assert.equal(result.status, 0, result.output)
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
assert.ok(triage.every((e) => e.pending === true))
assert.ok(fs.readFileSync(summaryFile, "utf8").includes("triage pending"))
assert.ok(!/::warning::.*since-override/.test(result.output), "override warning must be absent when unset")
}
// 8a Run C: SINCE_OVERRIDE=true with MIXED stub (worthy > 0) → warning ABSENT
{
const digest = [samplePr(321), samplePr(322), samplePr(323), samplePr(324)]
const cwd = setupTriageCwd(digest)
const kiloDir = makeStubKiloDir({ mode: "mixed-triage" })
const summaryFile = path.join(cwd, "step-summary.md")
fs.writeFileSync(summaryFile, "")
const result = runNodeScript(TRIAGE_SCRIPT, {
cwd,
kiloDir,
env: {
TRIAGE_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
TRIAGE_BUDGET_MINUTES: "30",
SINCE_OVERRIDE: "true",
GITHUB_STEP_SUMMARY: summaryFile,
},
})
assert.equal(result.status, 0, result.output)
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
const worthy = triage.filter((e) => e.docs_worthy === true).length
const pending = triage.filter((e) => e.pending === true).length
assert.ok(worthy > 0, "mixed stub must produce worthy > 0")
assert.ok(pending > 0, "mixed stub must leave some pending")
assert.ok(
!/::warning::.*since-override/.test(result.output),
"override warning must be ABSENT when worthy > 0 (Upsert will run)",
)
}
// 8b — partial classification → missing URLs pending:true + computeUncovered
{
// One chunk of 4 PRs; stub classifies first 3 only
const digest = [samplePr(401), samplePr(402), samplePr(403), samplePr(404)]
const cwd = setupTriageCwd(digest)
const kiloDir = makeStubKiloDir({ mode: "partial-triage" })
const summaryFile = path.join(cwd, "step-summary.md")
fs.writeFileSync(summaryFile, "")
const result = runNodeScript(TRIAGE_SCRIPT, {
cwd,
kiloDir,
env: {
TRIAGE_MODEL: "test/model",
DOCS_SYNC_BACKOFF_MS: "0",
TRIAGE_BUDGET_MINUTES: "30",
GITHUB_STEP_SUMMARY: summaryFile,
},
})
assert.equal(result.status, 0, result.output)
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
assert.equal(triage.length, 4)
const missing = triage.filter((e) => e.reason === "not classified by triage")
assert.ok(missing.length >= 1, "backfill must mark unclassified URLs")
assert.ok(missing.every((e) => e.pending === true))
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
for (const m of missing) {
assert.ok(
uncovered.some((u) => u.url === m.url),
`${m.url} must appear in computeUncovered`,
)
}
}
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
function main() {
const cases = [
case1_mergeOrFallback,
case2_defectB,
case3_watermark,
case4_routing,
case5_recollection,
case6_budgets,
case7_cap,
case8_triage,
]
let failed = 0
for (const fn of cases) {
try {
fn()
console.log(` ok: ${fn.name}`)
} catch (err) {
failed++
console.error(` FAIL: ${fn.name}`)
console.error(err)
} finally {
cleanup()
}
}
if (failed > 0) {
console.error(`\nselftest: ${failed} case(s) failed`)
process.exit(1)
}
console.log("\nselftest: all cases passed")
}
main()
+122 -44
View File
@@ -6,52 +6,82 @@
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
* single triage call over that volume truncates its JSON output, so the
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
* its own `kilo run` call. A chunk that fails twice is degraded to
* "unclassified" entries (docs_worthy=false) instead of failing the run —
* the PR body then shows those PRs as skipped, visible to reviewers.
* its own `kilo run` call. A chunk that fails, is only partially classified,
* or is deferred by the wall-clock budget is marked pending:true (still
* docs_worthy:false so filter-worthy excludes it) so the watermark holds
* back and the next run re-collects those PRs.
*
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow;
* the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script.
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by
* the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script.
* Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS.
*/
import { execFileSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { parseTriageEntries } from "./extract-json.mjs"
import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
const CHUNK_SIZE = 25
const ATTEMPTS = 2
const ATTEMPTS = 3
const OUT_DIR = "docs-sync-out"
const CHUNK_TIMEOUT_MS = 10 * 60 * 1000
const HERE = path.dirname(fileURLToPath(import.meta.url))
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
const model = process.env.TRIAGE_MODEL
if (!model) throw new Error("TRIAGE_MODEL is required")
const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
function triageChunk(chunk, index) {
function formatCause(result) {
const bits = []
if (result.timedOut) bits.push("timed out")
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
return bits.join("; ") || "no diagnostic"
}
function pendingEntry(d, reason) {
return {
pr: d.number,
url: d.url,
docs_worthy: false,
pending: true,
reason,
target_sections: [],
priority: "medium",
}
}
function triageChunk(chunk, index, budgetDeadline) {
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
let lastCause = "triage failed to classify this PR"
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
let raw
try {
// Message positional first: --file is multi-value and would otherwise
// consume a trailing message as a file path ("File not found").
raw = execFileSync(
"kilo",
["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] },
const left = remainingMs(budgetDeadline)
if (left < CHUNK_TIMEOUT_MS) {
lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}`
console.warn(
`chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`,
)
} catch (err) {
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
continue
break
}
fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
const entries = parseTriageEntries(raw)
const result = runKilo({
args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
streamStdout: false,
label: `triage chunk ${index} attempt ${attempt}`,
})
const raw = result.stdout
if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
const entries = raw ? parseTriageEntries(raw) : null
if (entries) {
// An entry for a PR outside this chunk must not win the shared dedupe
// against the chunk that actually owns it — drop foreign entries.
@@ -62,18 +92,35 @@ function triageChunk(chunk, index) {
}
if (owned.length > 0) return owned
}
console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`)
// Exit 0 is not success: unparseable output is a failure logged WITH
// the captured stderrTail and exit code on every attempt.
const cause = formatCause(result)
lastCause = `triage chunk ${index}: ${cause}`
console.warn(
`chunk ${index} attempt ${attempt}: no valid JSON in output` +
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
)
if (attempt < ATTEMPTS) {
const wait = backoffMsForAttempt(attempt)
const afterWait = remainingMs(budgetDeadline) - wait
if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) {
console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
sleepSync(wait)
} else if (wait > 0) {
console.warn(
`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
)
}
}
}
console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`)
return chunk.map((d) => ({
pr: d.number,
url: d.url,
docs_worthy: false,
reason: "triage failed to classify this PR",
target_sections: [],
priority: "medium",
}))
console.warn(
`::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`,
)
return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`))
}
const chunks = []
@@ -82,30 +129,61 @@ for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
}
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES)
const merged = []
const seen = new Set()
for (let i = 0; i < chunks.length; i++) {
for (const e of triageChunk(chunks[i], i)) {
const left = remainingMs(budgetDeadline)
if (left < CHUNK_TIMEOUT_MS) {
const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0)
console.warn(
`stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`,
)
const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)`
for (let j = i; j < chunks.length; j++) {
for (const d of chunks[j]) {
if (seen.has(d.url)) continue
seen.add(d.url)
merged.push(pendingEntry(d, cause))
}
}
break
}
for (const e of triageChunk(chunks[i], i, budgetDeadline)) {
if (seen.has(e.url)) continue
seen.add(e.url)
merged.push(e)
}
}
// Coverage: every digest PR gets a triage entry so the PR body's skipped
// table is complete. Unclassified defaults to not-docs-worthy (conservative).
// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and
// any other missing URL are pending:true — not a genuine "not worthy" verdict.
for (const d of digest) {
if (seen.has(d.url)) continue
merged.push({
pr: d.number,
url: d.url,
docs_worthy: false,
reason: "not classified by triage",
target_sections: [],
priority: "medium",
})
merged.push(pendingEntry(d, "not classified by triage"))
}
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
const worthy = merged.filter((e) => e.docs_worthy).length
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`)
const pending = merged.filter((e) => e.pending === true)
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`)
// Upsert is gated off when worthy == 0, so triage emits its own Step Summary
// listing every PR it marked pending:true and why.
if (pending.length > 0) {
const lines = pending.map((e) => `- [${e.url}] ${e.reason}`)
appendSummary(
`### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`,
)
}
// Replay warning (S2j): warn IFF since-override AND something pending AND
// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs).
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
if (sinceOverride && pending.length > 0 && worthy === 0) {
console.warn(
"::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body",
)
}
+245 -15
View File
@@ -8,6 +8,14 @@
* diff exceeds the file cap or verification failed. The PR body carries
* marker-delimited sections so later runs can append rows, plus a
* machine-readable processed-through watermark.
*
* Watermark invariant: processed-through never moves past a PR that has no
* terminal outcome. Terminal := action !== "pending" (a deliberate agent
* "skipped" IS terminal). Uncovered PRs hold the marker at earliest
* merged_at − 1 ms so collect's merged:>=since re-collects them next run.
* Three review rounds found four independent defects in a queue-based
* alternative (unreachable gate, empty-PR creation, cap-overflow loss,
* draft-state corruption); a held-back watermark has none of those modes.
*/
import { execFileSync } from "node:child_process"
@@ -17,6 +25,7 @@ import { pathToFileURL } from "node:url"
const BRANCH = process.env.BRANCH || "docs/auto-sync"
const FILE_CAP = 15
const ROW_CAP = 150
const PENDING_DISPLAY_CAP = 60
const SUMMARY_FILE = ".docs-sync-summary.json"
const DOCS_PATH = "packages/kilo-docs"
@@ -42,6 +51,11 @@ function skippedRow(e) {
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
}
function pendingRow(e) {
const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
}
export function extractSectionRows(body, name) {
const m = String(body ?? "").match(
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\s*-->`),
@@ -50,7 +64,14 @@ export function extractSectionRows(body, name) {
return m[1]
.split("\n")
.map((l) => l.trim())
.filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l))
.filter(
(l) =>
l.startsWith("|") &&
!l.startsWith("| ---") &&
!/^\|\s*Docs change/.test(l) &&
!/^\|\s*PR\s*\|/.test(l) &&
!/^\|\s*Why\s*\|/.test(l),
)
}
function section(name, header, rows) {
@@ -58,7 +79,12 @@ function section(name, header, rows) {
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
}
export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) {
export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) {
const pendingDisplay =
pendingRows.length > PENDING_DISPLAY_CAP
? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`]
: pendingRows
return `## Automated docs sync — ${date}
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
@@ -70,6 +96,10 @@ ${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draf
${section("changes", "| Docs change | Source |", changesRows)}
### Pending — will retry
${section("pending", "| PR | Why |", pendingDisplay)}
### Considered, no docs change needed
${section("skipped", "| PR | Reason |", skippedRows)}
@@ -100,32 +130,222 @@ function readJson(path, fallback) {
}
}
/**
* Uncovered = (worthy URLs with no summary row) ∪ (summary action "pending")
* ∪ (triage entries with pending: true). A worthy PR is covered iff it has a
* summary row whose action !== "pending" and carries no triage pending flag.
*/
export function computeUncovered({ worthy, summary, triage }) {
const worthyList = Array.isArray(worthy) ? worthy : []
const summaryList = Array.isArray(summary) ? summary : []
const triageList = Array.isArray(triage) ? triage : []
const summaryByUrl = new Map()
for (const e of summaryList) {
if (e?.url) summaryByUrl.set(e.url, e)
}
const triagePendingByUrl = new Map()
for (const e of triageList) {
if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e)
}
/** @type {Map<string, { url: string, pr?: number, reason: string }>} */
const out = new Map()
for (const w of worthyList) {
const url = w?.url
if (!url) continue
const row = summaryByUrl.get(url)
if (!row) {
out.set(url, {
url,
pr: w.number ?? w.pr,
reason: "no edit summary row (edit pass did not cover this PR)",
})
continue
}
if (row.action === "pending") {
out.set(url, {
url,
pr: row.pr ?? w.number ?? w.pr,
reason: row.reason || "edit pass pending",
})
}
}
// Summary pending rows for URLs not in worthy (defensive).
for (const row of summaryList) {
if (row?.action === "pending" && row.url && !out.has(row.url)) {
out.set(row.url, {
url: row.url,
pr: row.pr,
reason: row.reason || "edit pass pending",
})
}
}
for (const [url, e] of triagePendingByUrl) {
if (out.has(url)) continue
out.set(url, {
url,
pr: e.pr,
reason: e.reason || "triage pending",
})
}
return [...out.values()]
}
/**
* processed-through = now when uncovered is empty; otherwise earliest
* merged_at among uncovered PRs minus 1 ms (from digest-full.json).
* When uncovered is non-empty but no merged_at resolves, hold at
* `fallback` (the run's window start / SINCE): every uncovered PR was
* collected via merged:>=since, so holding there re-collects all of them.
* Never advance past unresolved uncovered PRs (Defect-B permanent-loss).
*/
export function computeProcessedThrough({ uncovered, digest, now, fallback }) {
const nowIso = typeof now === "string" ? now : new Date(now).toISOString()
if (!uncovered || uncovered.length === 0) return nowIso
const digestList = Array.isArray(digest) ? digest : []
const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d]))
let earliest = null
for (const u of uncovered) {
const d = byUrl.get(u.url)
const mergedAt = d?.merged_at
if (!mergedAt) continue
const t = Date.parse(mergedAt)
if (!Number.isFinite(t)) continue
if (earliest === null || t < earliest) earliest = t
}
if (earliest === null) {
// digest-full missing/corrupt while uncovered is non-empty: hold at
// window start so collect's merged:>=since re-collects every PR.
// Never use now−1ms — that strands uncovered PRs permanently.
const fallbackMs = fallback == null ? NaN : Date.parse(fallback)
if (!Number.isFinite(fallbackMs)) {
throw new Error(
`docs-sync: cannot resolve merged_at for ${uncovered.length} uncovered PR(s) and fallback/SINCE is missing or unparseable; refusing to advance processed-through`,
)
}
const fallbackIso = new Date(fallbackMs).toISOString()
console.warn(
`::warning::docs-sync: merge times for ${uncovered.length} uncovered PR(s) could not be resolved; holding watermark at window start ${fallbackIso}`,
)
return fallbackIso
}
return new Date(earliest - 1).toISOString()
}
/**
* Route summary + triage into the three body sections.
* changesRows = action neither skipped nor pending
* pendingRows = uncovered from computeUncovered
* skippedRows = action === "skipped" ∪ triage docs_worthy false && !pending
*/
export function routeRows({ summary, triage, uncovered }) {
const summaryList = Array.isArray(summary) ? summary : []
const triageList = Array.isArray(triage) ? triage : []
const uncoveredList = Array.isArray(uncovered) ? uncovered : []
const changesEntries = summaryList.filter((e) => e.action !== "skipped" && e.action !== "pending")
const skippedEntries = [
...triageList.filter((e) => e.docs_worthy === false && e.pending !== true),
...summaryList.filter((e) => e.action === "skipped"),
]
return {
changesRows: changesEntries.map(changeRow),
pendingRows: uncoveredList.map(pendingRow),
skippedRows: skippedEntries.map(skippedRow),
}
}
/**
* Drop pre-existing Considered rows whose reason contains any of the three
* legacy failure literals (substring match — live rows carry longer strings).
* Genuine no-doc-needed rows are untouched.
*/
export function dropLegacySkipped(rows) {
const list = Array.isArray(rows) ? rows : []
const needles = ["edit pass failed or timed out", "triage failed to classify", "not classified by triage"]
return list.filter((row) => {
const s = String(row ?? "")
return !needles.some((n) => s.includes(n))
})
}
/**
* No-diff early-return report. Returns summary markdown and an optional
* replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit
* happened — that is the caller's situation).
*/
export function noDiffReport({ uncovered, sinceOverride }) {
const list = Array.isArray(uncovered) ? uncovered : []
const lines =
list.length === 0
? ["The agent found nothing worth documenting in this window."]
: [
`No packages/kilo-docs diff was produced, but ${list.length} PR(s) remain uncovered and will be re-collected on the next scheduled run:`,
"",
...list.map((u) => `- [${u.url}] ${u.reason || "uncovered"}`),
]
const summary = `### docs-sync: no docs changes\n\n${lines.join("\n")}`
let warning = null
if (sinceOverride && list.length > 0) {
warning =
"docs-sync since-override replay left uncovered PRs and wrote no PR body (no docs commit); re-run the override — the watermark was not held back in the body"
}
return { summary, warning }
}
async function main() {
const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs")
const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
const since = process.env.SINCE ?? "unknown"
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh"
const existingPr = process.env.PR_NUMBER || ""
const verified = process.env.VERIFIED === "true"
const date = through.slice(0, 10)
const date = now.slice(0, 10)
// The agent's run summary is consumed here and never committed.
const agentSummary = readJson(SUMMARY_FILE, [])
fs.rmSync(SUMMARY_FILE, { force: true })
const triage = readJson("docs-sync-out/triage.json", [])
const worthy = readJson("docs-sync-out/worthy.json", [])
const digest = readJson("docs-sync-out/digest-full.json", [])
// Order matters: compute uncovered BEFORE the no-diff early return so
// noDiffReport can name every held-back PR.
const uncovered = computeUncovered({ worthy, summary: agentSummary, triage })
if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") {
console.log("no packages/kilo-docs changes produced; nothing to commit")
appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.")
const { summary, warning } = noDiffReport({ uncovered, sinceOverride })
appendSummary(summary)
if (warning) console.warn(`::warning::${warning}`)
return
}
git(["config", "user.name", "github-actions[bot]"])
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"])
// Git identity is configured once in docs-sync.yml (Configure git identity)
// before any commit-creating step, including prepare-branch's merge.
git(["add", DOCS_PATH])
git(["commit", "-m", `docs: sync with merged PRs (${date})`])
// Watermark: now when fully covered; else earliest uncovered merged_at − 1ms.
// Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs.
const through = computeProcessedThrough({ uncovered, digest, now, fallback: since })
// The draft cap bounds the cumulative PR diff, not just this run's commit.
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH])
.split("\n")
@@ -151,25 +371,33 @@ async function main() {
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow)
const skippedNew = [
...triage.filter((e) => e.docs_worthy === false),
...agentSummary.filter((e) => e.action === "skipped"),
].map(skippedRow)
const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({
summary: agentSummary,
triage,
uncovered,
})
let oldChanges = []
let oldSkipped = []
let oldPending = []
if (mode === "update" && existingPr) {
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
oldChanges = extractSectionRows(pr.body, "changes")
oldSkipped = extractSectionRows(pr.body, "skipped")
oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped"))
oldPending = extractSectionRows(pr.body, "pending")
}
// Pending is replaced each run (informational only); do not merge legacy
// pending rows — uncovered is recomputed fresh. oldPending is read only so
// extractSectionRows stays exercised; discarded deliberately.
void oldPending
const body = renderBody({
date,
since,
through,
changesRows: mergeRows(oldChanges, changesNew),
pendingRows: pendingNew,
skippedRows: mergeRows(oldSkipped, skippedNew),
verified,
draftReasons,
@@ -228,8 +456,10 @@ async function main() {
}
appendOutput("pr_url", prUrl)
appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`)
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`)
appendSummary(
`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`,
)
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`)
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
+58 -26
View File
@@ -7,9 +7,10 @@
*
* Priority: workflow_dispatch input `since` > latest open bot PR marker >
* last merged bot PR marker > 72h ago. Hard cap: never look back more than
* 14 days.
* 14 days — unless the human explicitly requested a window via INPUT_SINCE.
*/
import { pathToFileURL } from "node:url"
import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs"
const FALLBACK_HOURS = 72
@@ -42,34 +43,65 @@ async function findWatermark() {
return null
}
const now = new Date()
let since
const input = (process.env.INPUT_SINCE ?? "").trim()
if (input) {
since = new Date(input)
if (Number.isNaN(since.getTime())) {
throw new Error(`Invalid INPUT_SINCE: ${input}`)
/**
* Apply the 14-day lookback cap. When `explicit` is true (dispatch override),
* the cap is skipped so a human-requested recovery window is not silently
* shortened. Returns `{ since, clamped }`.
*/
export function applyCap(since, now, { explicit = false } = {}) {
if (explicit) {
console.log(`14-day cap skipped: INPUT_SINCE was set explicitly (${since.toISOString()})`)
return { since, clamped: false }
}
console.log(`watermark from dispatch input: ${since.toISOString()}`)
} else {
since =
(await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
if (since < cap) {
const from = since.toISOString()
const to = cap.toISOString()
console.warn(
`::warning::docs-sync watermark clamped from ${from} to ${to} (${CAP_DAYS}-day cap). ` +
`Anything still uncovered before ${to} is abandoned and needs a human.`,
)
return { since: cap, clamped: true }
}
return { since, clamped: false }
}
// A forged, edited, or malformed marker in the future would silently match
// nothing in the merged:>= search; clamp it loudly.
if (since > now) {
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
since = now
async function main() {
const now = new Date()
let since
let explicit = false
const input = (process.env.INPUT_SINCE ?? "").trim()
if (input) {
since = new Date(input)
if (Number.isNaN(since.getTime())) {
throw new Error(`Invalid INPUT_SINCE: ${input}`)
}
explicit = true
console.log(`watermark from dispatch input: ${since.toISOString()}`)
} else {
since = (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
}
// A forged, edited, or malformed marker in the future would silently match
// nothing in the merged:>= search; clamp it loudly.
if (since > now) {
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
since = now
}
;({ since } = applyCap(since, now, { explicit }))
appendOutput("since", since.toISOString())
appendOutput("now", now.toISOString())
appendOutput("since_override", explicit ? "true" : "false")
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
}
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
if (since < cap) {
console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`)
since = cap
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (isMain) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
appendOutput("since", since.toISOString())
appendOutput("now", now.toISOString())
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
+61 -12
View File
@@ -5,9 +5,13 @@ name: docs-sync
# triages them for docs relevance, runs Kilo CLI headless to update
# packages/kilo-docs, and maintains one rolling PR for human review.
#
# Security posture: scheduled/manual only, checks out main, never executes
# code from PR branches. State is derived from the bot's own PRs (watermark
# marker in the PR body), so missed or failed runs self-heal on the next run.
# Security posture: scheduled/manual runs check out the dispatched ref and may
# push/comment with write permissions and org secrets. PR runs (paths-limited to
# this workflow and .github/docs-sync/**) execute branch code only in a
# read-only, secretless `selftest` job that never pushes, comments, or calls an
# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only.
# State is derived from the bot's own PRs (watermark marker in the PR body), so
# missed or failed runs self-heal on the next run.
on:
schedule:
@@ -22,6 +26,10 @@ on:
description: "Collect + triage only, no edits, no PR"
type: boolean
default: false
pull_request:
paths:
- ".github/docs-sync/**"
- ".github/workflows/docs-sync.yml"
permissions:
contents: write # push the rolling branch, create the auto-docs label
@@ -29,7 +37,7 @@ permissions:
issues: write # comment on the rolling PR
concurrency:
group: docs-sync
group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }}
cancel-in-progress: false
env:
@@ -37,15 +45,14 @@ env:
EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }}
jobs:
sync:
selftest:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 120
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # prepare-branch merges main into the rolling branch
- name: Setup Node
uses: actions/setup-node@v6
@@ -53,6 +60,39 @@ jobs:
node-version: "24"
package-manager-cache: false
- name: Run docs-sync selftest
run: node .github/docs-sync/selftest.mjs
sync:
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
runs-on: blacksmith-4vcpu-ubuntu-2404
# Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve.
timeout-minutes: 120
env:
# Both are required: without KILO_ORG_ID the gateway bills the key
# owner's personal balance (402 "Add credits") instead of the org.
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # prepare-branch merges main into the rolling branch
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
package-manager-cache: false
- name: Run docs-sync selftest
run: node .github/docs-sync/selftest.mjs
- name: Install Kilo CLI
run: |
npm install -g @kilocode/cli
@@ -75,7 +115,7 @@ jobs:
id: triage
if: steps.collect.outputs.count != '0'
env:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
run: node .github/docs-sync/triage.mjs
- name: Filter docs-worthy PRs
@@ -101,10 +141,18 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: node .github/docs-sync/prepare-branch.mjs
# After prepare-branch checks out the rolling branch and merges main, the
# worktree holds main's scripts. Restore the dispatched ref's copies so a
# branch-dispatch AC9 run actually exercises the fixed code. git restore
# (not checkout) leaves them unstaged so upsert-pr's bare commit won't
# include them in the docs PR.
- name: Restore docs-sync scripts from the dispatched ref
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
run: git restore --source=${{ github.sha }} -- .github/docs-sync
- name: Update docs (Kilo CLI, batched)
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
env:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
continue-on-error: true
run: node .github/docs-sync/edit.mjs
- name: Verify docs build and tests
@@ -121,8 +169,8 @@ jobs:
id: fix
if: steps.verify.outcome == 'failure'
continue-on-error: true
timeout-minutes: 10
env:
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
set -o pipefail
@@ -150,6 +198,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
SINCE: ${{ steps.wm.outputs.since }}
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
BRANCH: ${{ steps.prep.outputs.branch }}
PREP_MODE: ${{ steps.prep.outputs.mode }}
PR_NUMBER: ${{ steps.prep.outputs.pr_number }}
@@ -0,0 +1,341 @@
# kilocode_change - new file
name: publish-jetbrains-bundled
on:
workflow_dispatch:
inputs:
pr:
description: Merged JetBrains release PR number to bundle
required: true
type: string
merge_commit:
description: Merge commit SHA from the reviewed release PR
required: true
type: string
concurrency:
group: publish-jetbrains-bundled-pr-${{ inputs.pr }}
cancel-in-progress: false
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
validate:
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
kind: ${{ steps.release.outputs.kind }}
tag: ${{ steps.release.outputs.tag }}
channel: ${{ steps.release.outputs.marketplace_channel }}
steps:
- name: Checkout trusted validation scripts
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: main
- name: Setup Bun for validation
uses: ./.github/actions/setup-bun
- name: Checkout merged release PR for validation
uses: actions/checkout@v6
with:
fetch-depth: 0
path: release
persist-credentials: false
ref: ${{ inputs.merge_commit }}
- name: Validate release PR and tag
id: release
working-directory: release
run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr }}
bundle:
needs: validate
if: github.repository == 'Kilo-Org/kilocode'
runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
actions: read
contents: write
outputs:
version: ${{ needs.validate.outputs.version }}
kind: ${{ needs.validate.outputs.kind }}
steps:
- name: Checkout merged release PR metadata
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ inputs.merge_commit }}
- name: Save reviewed release metadata
run: |
cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
- name: Checkout release tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.validate.outputs.tag }}
- name: Restore reviewed release metadata
run: |
cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Install build tools
run: |
sudo apt-get update
sudo apt-get install -y patchelf zip unzip
curl --fail --location \
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
--output "$RUNNER_TEMP/zig.tar.xz"
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
- name: Validate signing secrets
run: |
missing=0
for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
if [[ -z "${!name}" ]]; then
echo "Missing required secret: $name" >&2
missing=1
fi
done
exit "$missing"
env:
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Write signing secrets to temp files
run: |
dir="$RUNNER_TEMP/jetbrains-signing"
mkdir -m 700 -p "$dir"
chain="$dir/certificate-chain.pem"
key="$dir/private-key.pem"
umask 077
printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain"
printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key"
chmod 600 "$chain" "$key"
echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV"
echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV"
env:
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
- name: Build signed bundled plugin
working-directory: packages/kilo-jetbrains
run: |
args=(
-Pproduction=true
-Pkilo.version="$VERSION"
-Pkilo.channel="$CHANNEL"
-Pkilo.cli.bundled=true
)
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}"
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
VERSION: ${{ needs.validate.outputs.version }}
CHANNEL: ${{ needs.validate.outputs.channel }}
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
- name: Remove signing secret temp files
if: always()
run: rm -rf "$RUNNER_TEMP/jetbrains-signing"
- name: Resolve bundled archive
id: archive
run: |
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
if [[ "${#signed[@]}" -ne 1 ]]; then
echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
printf '%s\n' "${signed[@]}" >&2
exit 1
fi
asset="kilo-code-${VERSION}-bundled.zip"
dest="packages/kilo-jetbrains/build/release/$asset"
mkdir -p "$(dirname "$dest")"
cp "${signed[0]}" "$dest"
echo "asset=$asset" >> "$GITHUB_OUTPUT"
echo "path=$dest" >> "$GITHUB_OUTPUT"
env:
VERSION: ${{ needs.validate.outputs.version }}
- name: Upload bundled ZIP to GitHub Release
run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.validate.outputs.tag }}
ARCHIVE: ${{ steps.archive.outputs.path }}
- name: Resolve bundled asset URL
id: asset
run: |
url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")"
if [[ -z "$url" ]]; then
echo "Could not resolve GitHub Release URL for $ASSET" >&2
exit 1
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.validate.outputs.tag }}
ASSET: ${{ steps.archive.outputs.asset }}
- name: Generate stable plugin repository XML
if: needs.validate.outputs.kind == 'stable'
run: |
mkdir -p pages/jetbrains
python3 <<'PY'
import html
import io
import os
import zipfile
import xml.etree.ElementTree as ET
archive = os.environ["ARCHIVE"]
asset = os.environ["ASSET_URL"]
version = os.environ["VERSION"]
def plugin_xml(path):
with zipfile.ZipFile(path) as zip:
for name in zip.namelist():
if name.endswith("META-INF/plugin.xml"):
return zip.read(name)
for name in zip.namelist():
if not name.endswith(".jar"):
continue
with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
for item in jar.namelist():
if item.endswith("META-INF/plugin.xml"):
return jar.read(item)
raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
root = ET.fromstring(plugin_xml(archive))
def text(name, default=""):
item = root.find(name)
return item.text.strip() if item is not None and item.text else default
def cdata(value):
return "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
plugin = text("id", "ai.kilocode.jetbrains")
name = text("name", "Kilo Code")
vendor = text("vendor", "Kilo Code")
desc = text("description")
notes = text("change-notes")
idea = root.find("idea-version")
attrs = ""
if idea is not None:
since = idea.attrib.get("since-build")
until = idea.attrib.get("until-build")
if since:
attrs += f' since-build="{html.escape(since)}"'
if until:
attrs += f' until-build="{html.escape(until)}"'
xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plugins>',
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
f' <name>{html.escape(name)}</name>',
f' <vendor>{html.escape(vendor)}</vendor>',
f' <idea-version{attrs}/>',
]
if desc:
xml.append(f' <description>{cdata(desc)}</description>')
if notes:
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
xml.extend([' </plugin>', '</plugins>', ''])
with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
file.write("\n".join(xml))
PY
env:
ARCHIVE: ${{ steps.archive.outputs.path }}
ASSET_URL: ${{ steps.asset.outputs.url }}
VERSION: ${{ needs.validate.outputs.version }}
- name: Upload stable Pages source
if: needs.validate.outputs.kind == 'stable'
uses: actions/upload-artifact@v4
with:
name: jetbrains-pages-${{ needs.validate.outputs.version }}
path: pages
if-no-files-found: error
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }}
path: |
packages/kilo-jetbrains/build/release/*.zip
pages/jetbrains/updatePlugins.xml
if-no-files-found: ignore
pages:
needs: bundle
if: needs.bundle.outputs.kind == 'stable'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
actions: read
id-token: write
pages: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download stable Pages source
uses: actions/download-artifact@v4
with:
name: jetbrains-pages-${{ needs.bundle.outputs.version }}
path: pages
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: pages
- name: Deploy Pages
id: deployment
uses: actions/deploy-pages@v4
+14
View File
@@ -23,6 +23,7 @@ concurrency:
cancel-in-progress: false
permissions:
actions: write
contents: write
pull-requests: read
@@ -199,6 +200,19 @@ jobs:
ARCHIVE: ${{ steps.archive.outputs.path }}
NOTES: packages/kilo-jetbrains/build/release-notes.md
- name: Dispatch bundled GitHub release build
continue-on-error: true
run: |
gh workflow run publish-jetbrains-bundled.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f pr="$PR_NUMBER" \
-f merge_commit="$MERGE_COMMIT"
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
- name: Upload workflow artifact
if: always()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -1 +1 @@
v1.17.5
v1.17.9
+5
View File
@@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `KILO_TERMINAL`.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `KILO_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
+37 -24
View File
@@ -798,6 +798,7 @@
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@pierre/diffs": "catalog:",
"@shikijs/stream": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/event-listener": "2.4.5",
@@ -844,22 +845,22 @@
},
},
"trustedDependencies": [
"esbuild",
"protobufjs",
"web-tree-sitter",
"esbuild",
"tree-sitter-bash",
"protobufjs",
],
"patchedDependencies": {
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -898,13 +899,15 @@
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@pierre/diffs": "1.1.22",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@shikijs/stream": "4.2.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.28",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.14",
@@ -931,7 +934,7 @@
"remeda": "2.26.0",
"remend": "1.3.0",
"semver": "7.7.4",
"shiki": "3.20.0",
"shiki": "4.2.0",
"solid-js": "1.9.12",
"solid-list": "0.3.0",
"sst": "4.13.1",
@@ -1956,9 +1959,11 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@pierre/diffs": ["@pierre/diffs@1.1.22", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-1Iv7kdl6OABFCd1n2HQbGUiRHouXPaHoIjcb7Lwg8zeJKY5ph+cESFcGEyIiwW0NCbKGtYS2bTnXmI+Eze5dwg=="],
"@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
"@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="],
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -2076,15 +2081,19 @@
"@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="],
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
"@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
"@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
"@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="],
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
@@ -4306,7 +4315,7 @@
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
@@ -5160,13 +5169,19 @@
"@qdrant/js-client-rest/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@smithy/config-resolver/@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
@@ -5594,9 +5609,7 @@
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases.
minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours)
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
+4 -55
View File
@@ -21,59 +21,7 @@
devShells = forEachSystem (pkgs: {
default =
let
# Pin bun to the version declared in package.json (packageManager: "bun@1.3.14").
# The locked nixpkgs revision ships 1.3.11, so we fetch the official release directly.
bun =
let
sources = {
"aarch64-linux" = {
name = "bun-linux-aarch64";
hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs=";
};
"x86_64-linux" = {
name = "bun-linux-x64";
hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8=";
};
"aarch64-darwin" = {
name = "bun-darwin-aarch64";
hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA=";
};
"x86_64-darwin" = {
name = "bun-darwin-x64";
hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM=";
};
};
source =
sources.${pkgs.stdenv.hostPlatform.system}
or (throw "Unsupported system for bun: ${pkgs.stdenv.hostPlatform.system}");
in
pkgs.stdenv.mkDerivation rec {
pname = "bun";
version = "1.3.14";
src = pkgs.fetchurl {
url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip";
inherit (source) hash;
};
nativeBuildInputs = [
pkgs.unzip
] ++ pkgs.lib.optional pkgs.stdenv.isLinux pkgs.autoPatchelfHook;
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ];
dontConfigure = true;
dontBuild = true;
installPhase = ''
runHook preInstall
install -Dm755 bun $out/bin/bun
ln -s $out/bin/bun $out/bin/bunx
runHook postInstall
'';
meta = {
description = "Fast all-in-one JavaScript runtime";
homepage = "https://bun.sh";
license = pkgs.lib.licenses.mit;
mainProgram = "bun";
platforms = builtins.attrNames sources;
};
};
bun = pkgs.callPackage ./nix/bun.nix { };
kilo-dev = pkgs.writeShellScriptBin "kilo-dev" ''
set -euo pipefail
@@ -279,11 +227,12 @@
packages = forEachSystem (
pkgs:
let
bun = pkgs.callPackage ./nix/bun.nix { };
node_modules = pkgs.callPackage ./nix/node_modules.nix {
inherit rev;
inherit bun rev;
};
kilo = pkgs.callPackage ./nix/kilo.nix {
inherit node_modules;
inherit bun node_modules;
};
in
{
+60
View File
@@ -0,0 +1,60 @@
{
lib,
stdenv,
fetchurl,
unzip,
autoPatchelfHook,
}:
let
package = lib.pipe ../package.json [
builtins.readFile
builtins.fromJSON
];
version = lib.removePrefix "bun@" package.packageManager;
sources = {
"aarch64-linux" = {
name = "bun-linux-aarch64";
hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs=";
};
"x86_64-linux" = {
name = "bun-linux-x64";
hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8=";
};
"aarch64-darwin" = {
name = "bun-darwin-aarch64";
hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA=";
};
"x86_64-darwin" = {
name = "bun-darwin-x64";
hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM=";
};
};
source =
sources.${stdenv.hostPlatform.system}
or (throw "Unsupported system for bun: ${stdenv.hostPlatform.system}");
in
stdenv.mkDerivation {
pname = "bun";
inherit version;
src = fetchurl {
url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip";
inherit (source) hash;
};
nativeBuildInputs = [ unzip ] ++ lib.optional stdenv.isLinux autoPatchelfHook;
buildInputs = lib.optionals stdenv.isLinux [ stdenv.cc.cc.lib ];
dontConfigure = true;
dontBuild = true;
installPhase = ''
runHook preInstall
install -Dm755 bun $out/bin/bun
ln -s $out/bin/bun $out/bin/bunx
runHook postInstall
'';
meta = {
description = "Fast all-in-one JavaScript runtime";
homepage = "https://bun.sh";
license = lib.licenses.mit;
mainProgram = "bun";
platforms = builtins.attrNames sources;
};
}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-UHxMHmx17Jex0yXgbpXCvIORubs9cFMsIXGacvs9+gA=",
"aarch64-linux": "sha256-tTnW84VaNEbfo46H24ETKZgFumZMwu6pgNDlY8KYkqo=",
"aarch64-darwin": "sha256-tBi4D1tfACA4ogYMdyjUK8sDb370rAGE2q8baeJjpdA=",
"x86_64-darwin": "sha256-MPIai9M+EQps81qp7RBmTsW/qPjcrWd7GGJQyeNCz/U="
"x86_64-linux": "sha256-Y64ujq2R4SiyOavsXmaYsQkVNfdjhkzmPolOEV1RVyc=",
"aarch64-linux": "sha256-7S209ir2j9o9NXYmMUnEfo6m64cUIkexd8L95UeCMFE=",
"aarch64-darwin": "sha256-bE1zw7EK+4rgUgw80t8hdc2yojAF2f8ihjorh6B7jJE=",
"x86_64-darwin": "sha256-xukM9cEvZAKCXewun9xReGBKz09i63LACLN/eH54020="
}
}
+11 -5
View File
@@ -45,7 +45,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.22",
"@pierre/diffs": "1.2.10",
"opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
@@ -70,7 +70,7 @@
"@typescript/native-preview": "7.0.0-dev.20260316.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"shiki": "3.20.0",
"shiki": "4.2.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.49.1",
@@ -85,7 +85,9 @@
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"sst": "4.13.1"
"sst": "4.13.1",
"@tanstack/solid-virtual": "3.13.28",
"@shikijs/stream": "4.2.0"
}
},
"devDependencies": {
@@ -157,11 +159,15 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
+2 -2
View File
@@ -10,8 +10,8 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"test": "bun test --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"bin": {
"opencode": "./bin/opencode"
+38 -55
View File
@@ -10,8 +10,7 @@ import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: ProviderV2.Info
@@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = {
ModelUpdated: EventV2.define({
type: "catalog.model.updated",
schema: {
model: ModelV2.Info,
},
}),
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
}
type Data = {
@@ -96,31 +90,17 @@ export const layer = Layer.effect(
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const scope = yield* Scope.Scope
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
const credential = active.get(IntegrationSchema.ID.make(provider.id))
if (!credential) return provider
const body = { ...provider.request.body }
if (credential.value.type === "key") {
body.apiKey = credential.value.key
Object.assign(body, credential.value.metadata ?? {})
}
// kilocode_change start - preserve Kilo organization routing from migrated OAuth credentials
if (credential.value.type === "oauth") {
body.apiKey = credential.value.access
if (credential.value.metadata?.accountID) body.kilocodeOrganizationId = credential.value.metadata.accountID
}
// kilocode_change end
return new ProviderV2.Info({
...provider,
enabled: { via: "credential", credentialID: credential.id },
request: { ...provider.request, body },
})
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true
return !integration
}
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const api =
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id }
@@ -208,18 +188,16 @@ export const layer = Layer.effect(
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
if (!policy.hasStatements()) return
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}
yield* events.publish(Event.Updated, {})
}),
})
const active = Effect.fn("CatalogV2.active")(function* () {
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
})
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
Stream.filter(
@@ -238,18 +216,23 @@ export const layer = Layer.effect(
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return project(record.provider, yield* active())
return record.provider
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
const credentials = yield* active()
return Array.fromIterable(state.get().providers.values()).map((record) =>
project(record.provider, credentials),
)
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
}),
available: Effect.fn("CatalogV2.provider.available")(function* () {
return (yield* result.provider.all()).filter((provider) => provider.enabled)
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) =>
available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
)
}),
},
@@ -258,33 +241,32 @@ export const layer = Layer.effect(
const record = yield* getRecord(providerID)
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model, project(record.provider, yield* active()))
return projectModel(model, record.provider)
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
const credentials = yield* active()
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => {
const provider = project(record.provider, credentials)
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
available: Effect.fn("CatalogV2.model.available")(function* () {
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
return (yield* result.model.all()).filter(
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
)
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (Option.isSome(provider) && provider.value.enabled !== false) {
if (
Option.isSome(provider) &&
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
}
@@ -300,11 +282,11 @@ export const layer = Layer.effect(
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
const provider = project(record.provider, yield* active())
const provider = record.provider
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
}
const candidates = pipe(
@@ -332,7 +314,7 @@ export const layer = Layer.effect(
return pipe(
items,
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
Array.map((item) => resolve(item.model, provider)),
Array.map((item) => projectModel(item.model, provider)),
Array.head,
)
}
@@ -353,6 +335,7 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Policy.locationLayer),
)
+25 -2
View File
@@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
@@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const integrations = yield* Integration.Service
const transform = yield* catalog.transform()
const integrationTransform = yield* integrations.transform()
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
),
)
yield* integrationTransform((integrations) => {
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = Integration.ID.make(id)
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}
}
})
yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model")
@@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
const providerID = ProviderV2.ID.make(id)
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.env !== undefined) provider.env = [...item.env]
provider.enabled = { via: "custom", data: {} }
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
+7
View File
@@ -102,6 +102,8 @@ export interface Interface {
readonly all: () => Effect.Effect<Stored[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: IntegrationSchema.ID
@@ -350,6 +352,11 @@ export const layer = Layer.effect(
return credential ? [credential] : []
})
}),
get: Effect.fn("Credential.get")(function* (id) {
if (isolated) return find(id) // kilocode_change - injected workspace credentials are process-local
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? stored(row) : undefined
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({
id: ID.create(),
+73 -25
View File
@@ -29,15 +29,16 @@ export const When = Schema.Struct({
}).annotate({ identifier: "Integration.When" })
export type When = typeof When.Type
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
export const TextPrompt = Schema.Struct({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}) {}
}).annotate({ identifier: "Integration.TextPrompt" })
export type TextPrompt = typeof TextPrompt.Type
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
@@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
}),
),
when: Schema.optional(When),
}) {}
}).annotate({ identifier: "Integration.SelectPrompt" })
export type SelectPrompt = typeof SelectPrompt.Type
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
export const OAuthMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {}
}).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: Schema.optional(Schema.String),
}) {}
}).annotate({ identifier: "Integration.KeyMethod" })
export type KeyMethod = typeof KeyMethod.Type
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
names: Schema.Array(Schema.String),
}) {}
}).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type
@@ -197,7 +202,11 @@ export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]>
readonly connect: {
readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Integration receiving the credential. */
@@ -218,6 +227,13 @@ export interface Interface {
/** User-facing label for the credential created on completion. */
readonly label?: string
}) => Effect.Effect<Attempt, AuthorizationError>
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>,
) => Effect.Effect<void>
/** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
}
readonly attempt: {
/** Returns the current state of an OAuth attempt. */
@@ -328,23 +344,32 @@ export const locationLayer = Layer.effect(
})
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const connected = saved.map(
(credential) =>
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
)
const connected = saved.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
const detected = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map(
(name, index) =>
new IntegrationConnection.EnvInfo({
type: "env",
name,
}),
)
.map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected]
}
const activeConnection = (
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
new Info({
id: entry.ref.id,
@@ -412,6 +437,7 @@ export const locationLayer = Layer.effect(
return [attempt, new Map(current).set(attemptID, terminal)]
})
if (!result) return settled
if (Exit.isSuccess(settled)) yield* events.publish(Event.Updated, {})
yield* close(result.scope)
return settled
}),
@@ -454,8 +480,21 @@ export const locationLayer = Layer.effect(
}),
)).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connect: {
key: Effect.fn("Integration.connect.key")(function* (input) {
connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id))
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
.get()
.integrations.get(input.integrationID)
@@ -466,8 +505,9 @@ export const locationLayer = Layer.effect(
label: input.label,
value: new Credential.Key({ type: "key", key: input.key }),
})
yield* events.publish(Event.Updated, {})
}),
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
if (!method) {
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
@@ -510,6 +550,14 @@ export const locationLayer = Layer.effect(
time,
})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
yield* credentials.update(credentialID, updates)
yield* events.publish(Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
yield* credentials.remove(credentialID)
yield* events.publish(Event.Updated, {})
}),
},
attempt: {
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
+6 -4
View File
@@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
import { Schema } from "effect"
import { Credential } from "../credential"
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
export const CredentialInfo = Schema.Struct({
type: Schema.Literal("credential"),
id: Credential.ID,
label: Schema.String,
}) {}
}).annotate({ identifier: "Connection.CredentialInfo" })
export type CredentialInfo = typeof CredentialInfo.Type
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
export const EnvInfo = Schema.Struct({
type: Schema.Literal("env"),
name: Schema.String,
}) {}
}).annotate({ identifier: "Connection.EnvInfo" })
export type EnvInfo = typeof EnvInfo.Type
export const Info = Schema.Union([CredentialInfo, EnvInfo])
.pipe(Schema.toTaggedUnion("type"))
+126
View File
@@ -0,0 +1,126 @@
export function args(command: string) {
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)]
}
const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
$OutputEncoding = [Console]::OutputEncoding;
`
function script(command: string) {
const pos = prologue(command)
const head = command.slice(0, pos)
const body = command.slice(pos)
const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : ""
return `${head}${gap}${setup}${body}`
}
function prologue(command: string) {
const pos = scan(command, 0)
const attr = attrs(command, pos)
const body = command.slice(attr)
const match = /^param\s*\(/i.exec(body)
if (!match) return pos
const start = attr + match[0].lastIndexOf("(")
const end = block(command, start, "(", ")")
if (end === undefined) return pos
return end
}
function attrs(command: string, start: number) {
let pos = start
while (pos < command.length) {
const next = scan(command, pos)
if (command[next] !== "[") return next
const end = block(command, next, "[", "]")
if (end === undefined) return start
pos = end
}
return pos
}
function scan(command: string, start: number) {
let pos = start
while (pos < command.length) {
const next = trivia(command, pos)
if (next !== pos) {
pos = next
continue
}
const end = line(command, pos)
const value = command.slice(pos, end)
if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) {
pos = end
continue
}
return pos
}
return pos
}
function trivia(command: string, start: number) {
let pos = start
while (pos < command.length) {
while (/\s/.test(command[pos] ?? "")) pos++
if (command[pos] === "#") {
pos = line(command, pos)
continue
}
if (command.startsWith("<#", pos)) {
const end = command.indexOf("#>", pos + 2)
if (end === -1) return command.length
pos = end + 2
continue
}
return pos
}
return pos
}
function line(command: string, start: number) {
const index = command.indexOf("\n", start)
if (index === -1) return command.length
return index + 1
}
function block(command: string, start: number, open: string, close: string) {
let depth = 0
let quote: string | undefined
for (let pos = start; pos < command.length; pos++) {
const char = command[pos]
if (quote) {
if (quote === "'" && char === "'" && command[pos + 1] === "'") {
pos++
continue
}
if (quote === '"' && char === "`") {
pos++
continue
}
if (char === quote) quote = undefined
continue
}
if (char === "'" || char === '"') {
quote = char
continue
}
if (command.startsWith("<#", pos)) {
const end = command.indexOf("#>", pos + 2)
if (end === -1) return
pos = end + 1
continue
}
if (char === "#") {
pos = line(command, pos) - 1
continue
}
if (char === open) depth++
if (char === close) {
depth--
if (depth === 0) return pos + 1
}
}
}
export const PowerShell = { args }
@@ -0,0 +1,61 @@
import path from "path"
type Input = {
command?: string
args?: string[]
cwd?: string
}
type Command = {
command: string
args: string[]
cwd?: string
}
const names = new Set(["kilo", "kilocode"])
const self = command()
function clean(input: string[]) {
return input.filter((arg, index) => {
if (arg === "--cwd") return false
if (input[index - 1] === "--cwd") return false
if (arg.startsWith("--cwd=")) return false
return true
})
}
function full(input: string, cwd: string) {
if (path.isAbsolute(input)) return input
return path.resolve(cwd, input)
}
export function command(
proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() },
): Command {
const script = proc.argv[1]
const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false)
if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) {
const file = full(script, proc.cwd)
const dir = path.dirname(file)
const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd
return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root }
}
return { command: full(proc.execPath, proc.cwd), args: [] }
}
export function resolve(input: Input, cmd = self): Input {
if (!input.command || !names.has(input.command)) return input
const args = input.args ?? []
const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : []
return {
...input,
command: cmd.command,
args: [...cmd.args, ...project, ...args],
cwd: cmd.cwd ?? input.cwd,
}
}
export const KiloPtySelfCommand = {
command,
resolve,
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "./project"
import { AbsolutePath } from "./schema"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
@@ -12,7 +12,7 @@ export class Ref extends Schema.Class<Ref>("Location.Ref")({
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,
-2
View File
@@ -21,7 +21,6 @@ import { PluginV2 } from "../plugin"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
@@ -98,7 +97,6 @@ export const layer = Layer.effect(
})
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
// kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill.
-22
View File
@@ -1,22 +0,0 @@
import { Effect } from "effect"
import { PluginV2 } from "../plugin"
export const EnvPlugin = PluginV2.define({
id: PluginV2.ID.make("env"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
const key = item.provider.env.find((env) => process.env[env])
if (!key) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "env",
name: key,
}
})
}
}),
}
}),
})
+2 -8
View File
@@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
method: new Integration.KeyMethod({
type: "key",
}),
method: { type: "key" },
})
integrations.method.update({
integrationID,
method: new Integration.EnvMethod({
type: "env",
names: [...item.env],
}),
method: { type: "env", names: [...item.env] },
})
}
})
@@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.env = [...item.env]
provider.api = item.npm
? {
type: "aisdk",
@@ -24,7 +24,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({
apiKey: config.apiKey,
options: gatewayOptions(evt.options, metadata),
} as any)
const unified = createUnified()
const unified = createUnified({ apiKey: config.apiKey })
evt.sdk = {
languageModel(modelID: string) {
return gateway(unified(modelID))
+2 -2
View File
@@ -27,9 +27,9 @@ export const KiloPlugin = PluginV2.define({
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/"
// kilocode_change start
provider.request.headers["X-Title"] = "Kilo Code"
options.kilocodeToken = token ?? "anonymous"
options.apiKey = token ?? "anonymous"
options.kilocodeToken = options.apiKey
if (org) options.kilocodeOrganizationId = org
if (!provider.enabled) provider.enabled = { via: "custom", data: { anonymous: true } }
// kilocode_change end
})
}
@@ -1,20 +1,23 @@
import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider" // kilocode_change
export const LLMGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("llmgateway"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.enabled === false) continue
if (item.provider.disabled) continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (item.provider.id !== ProviderV2.ID.make("llmgateway")) continue // kilocode_change
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/"
// kilocode_change start
provider.request.headers["X-Title"] = "Kilo Code"
provider.request.headers["X-Source"] = "kilo"
@@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
export const browser = {
integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({
method: {
id: browserMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
@@ -95,11 +95,11 @@ export const browser = {
export const headless = {
integrationID: Integration.ID.make("openai"),
method: new Integration.OAuthMethod({
method: {
id: headlessMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
},
authorize: () =>
Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
@@ -258,6 +258,6 @@ function claim(token: string) {
}
const successPage =
"<!doctype html><title>Kilo</title><h1>Authorization successful</h1><p>You can close this window.</p>" // kilocode_change
"<!doctype html><title>Kilo</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>Kilo</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>` // kilocode_change
`<!doctype html><title>Kilo</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
@@ -1,20 +1,20 @@
import { Effect } from "effect"
import { Integration } from "../../integration"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const OpencodePlugin = PluginV2.define({
id: PluginV2.ID.make("opencode"),
effect: Effect.gen(function* () {
const integrations = yield* Integration.Service
let hasKey = false
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean(
process.env.OPENCODE_API_KEY ||
item.provider.env.some((env) => process.env[env]) ||
item.provider.request.body.apiKey ||
(item.provider.enabled && item.provider.enabled.via === "credential"),
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"
+1 -1
View File
@@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({
skill: new SkillV2.Info({
name: "customize-opencode",
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.",
"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, commands, 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.",
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
}),
@@ -43,6 +43,8 @@ already-loaded config until then.
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
| Global commands | `~/.config/opencode/command(s)/<name>.md` |
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
@@ -96,7 +98,7 @@ Every field is optional.
},
"command": {
"deploy": { "description": "...", "prompt": "..." }
"deploy": { "description": "...", "template": "..." }
},
"provider": {
@@ -151,6 +153,7 @@ Shape notes worth being explicit about:
- `skills` is an object with `paths` and/or `urls`, not an array.
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
- `agent` is an object keyed by agent name, not an array.
- `command` is an object keyed by command 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.
@@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent
`compaction`, `title`, `summary`. To override a built-in's fields, define the
same key in `agent: { <name>: { ... } }`.
## Commands
opencode's command loader scans for `**/*.md` inside command directories. The
file is named after the command, and lives directly inside the `command` folder:
```
.opencode/command/deploy.md
```
Frontmatter:
```markdown
---
description: One sentence describing what the command does.
agent: build
model: anthropic/claude-sonnet-4-6
---
(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input)
```
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
## Plugins
`plugin:` is an array. Each entry is one of:
@@ -300,7 +328,7 @@ function, not a plain object literal, and the function returns an object
(return `{}` if there is nothing to register).
```ts
import type { Plugin } from "@opencode-ai/plugin"
import type { Plugin } from "@kilocode/plugin"
export default (async ({ client, project, directory, $ }) => {
return {
@@ -397,16 +425,16 @@ the `plan` agent's permission ruleset (`edit: deny *`).
When a user's config is broken and opencode won't start, these env vars help:
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
- `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.
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
- `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.
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
- `OPENCODE_PURE=1`: skip external plugins entirely.
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
- `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
@@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help:
exact shape, or the field is not covered in this skill, fetch
`https://opencode.ai/config.json` and read the schema 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`.
- For agent, command, 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
hatches above so they can edit from inside opencode without breaking their
session.
+1
View File
@@ -245,6 +245,7 @@ export const layer = Layer.effect(
(sourceDirectory) =>
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
+1 -19
View File
@@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema } from "effect"
import { Credential } from "./credential"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
@@ -49,22 +48,7 @@ export type Request = typeof Request.Type
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
id: ID,
name: Schema.String,
enabled: Schema.Union([
Schema.Literal(false),
Schema.Struct({
via: Schema.Literal("env"),
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("credential"),
credentialID: Credential.ID,
}),
Schema.Struct({
via: Schema.Literal("custom"),
data: Schema.Record(Schema.String, Schema.Any),
}),
]),
env: Schema.String.pipe(Schema.Array),
disabled: Schema.Boolean.pipe(Schema.optional),
api: Api,
request: Request,
}) {
@@ -72,8 +56,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
return new Info({
id: providerID,
name: providerID,
enabled: false,
env: [],
api: {
type: "native",
settings: {},
+156 -107
View File
@@ -2,23 +2,29 @@ export * as Pty from "./pty"
import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { SessionSchema } from "./session/schema" // kilocode_change
import { Shell } from "./shell"
import { lazy } from "./util/lazy"
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
// Cap retention so abandoned terminals do not accumulate unbounded buffers.
const EXITED_LIMIT = 25
const pty = lazy(() => import("#pty"))
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
type Subscriber = {
readonly onData: (chunk: string) => void
readonly onEnd: (event: { exitCode?: number }) => void
active: boolean
detached: boolean
pending: string[]
end?: { exitCode?: number }
}
type Active = {
@@ -27,22 +33,10 @@ type Active = {
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
subscribers: Map<object, Subscriber>
listeners: Disp[]
}
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
export const Info = Schema.Struct({
id: PtyID,
title: Schema.String,
@@ -52,6 +46,8 @@ export const Info = Schema.Struct({
status: Schema.Literals(["running", "exited"]),
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
pid: NonNegativeInt,
// Present once status is "exited".
exitCode: Schema.optional(NonNegativeInt),
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
}).annotate({ identifier: "Pty" })
@@ -67,14 +63,6 @@ export const CreateInput = Schema.Struct({
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
export type PreparedCreate = {
readonly command: string
readonly args: string[]
readonly cwd: string
readonly title?: string
readonly env: Record<string, string>
}
export const UpdateInput = Schema.Struct({
title: Schema.optional(Schema.String),
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
@@ -88,10 +76,34 @@ export const UpdateInput = Schema.Struct({
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
export type AttachInput = {
// Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer.
readonly cursor?: number
// Callbacks fire synchronously from the native PTY data path; keep them non-blocking.
readonly onData: (chunk: string) => void
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
readonly onEnd: (event: { exitCode?: number }) => void
}
export type Attachment = {
// Retained output from the requested cursor to the current end.
readonly replay: string
// Absolute output cursor after replay.
readonly cursor: number
readonly write: (data: string) => void
// Starts live delivery after the caller has applied replay and cursor metadata.
readonly activate: () => void
readonly detach: () => void
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
}) {}
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
ptyID: PtyID,
}) {}
export const Event = {
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
@@ -102,19 +114,11 @@ export const Event = {
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: PreparedCreate) => Effect.Effect<Info>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
NotFoundError
>
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
@@ -124,28 +128,41 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const config = yield* Config.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
if (!subscriber.active) {
subscriber.end = event
continue
}
try {
subscriber.onEnd(event)
} catch {}
}
session.subscribers.clear()
}
function teardown(session: Active) {
for (const listener of session.listeners) listener.dispose()
session.listeners.length = 0
try {
session.process.kill()
} catch {}
for (const [sub, ws] of session.subscribers.entries()) {
if (session.info.status === "running") {
try {
if (sock(ws) === sub) ws.close()
session.process.kill()
} catch {}
}
session.subscribers.clear()
notifyEnd(session, {})
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of sessions.values()) teardown(session)
sessions.clear()
exitOrder.length = 0
}),
)
@@ -157,12 +174,13 @@ export const layer = Layer.effect(
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return false
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
return true
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
@@ -178,29 +196,48 @@ export const layer = Layer.effect(
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
// kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd
const resolved = KiloPtySelfCommand.resolve({
command: input.command,
args: input.args ? [...input.args] : undefined,
cwd: input.cwd,
})
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const base = resolved.args ?? []
const args = Shell.login(command) ? [...base, "-l"] : [...base]
const cwd = resolved.cwd || location.directory
// kilocode_change end
const env = {
...process.env,
...input.env,
TERM: "xterm-256color",
KILO_TERMINAL: "1",
KILO_PTY_ID: id, // kilocode_change - let nested Kilo processes identify their parent terminal
} as Record<string, string>
// kilocode_change start - do not expose local server credentials to user terminals.
// node-pty inherits parent values for omitted keys, so empty tombstones are required.
env.KILO_SERVER_PASSWORD = ""
env.KILO_SERVER_USERNAME = ""
// kilocode_change end
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
// kilocode_change - expose the pty id to the spawned shell so a nested `kilo tui`/`kilo run` can
// detect it is running inside a kilo-spawned terminal (read via process.env.KILO_PTY_ID)
const env = { ...input.env, KILO_PTY_ID: id }
const proc = yield* Effect.sync(() =>
spawn(input.command, input.args, {
name: "xterm-256color",
cwd: input.cwd,
env,
}),
)
const info = {
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
const info: Info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command: input.command,
args: input.args,
cwd: input.cwd,
command,
args,
cwd,
status: "running",
pid: proc.pid,
} as const
}
const session: Active = {
info,
process: proc,
@@ -214,15 +251,15 @@ export const layer = Layer.effect(
session.listeners.push(
proc.onData((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1 || sock(ws) !== key) {
session.subscribers.delete(key)
for (const [token, subscriber] of session.subscribers.entries()) {
if (!subscriber.active) {
subscriber.pending.push(chunk)
continue
}
try {
ws.send(chunk)
subscriber.onData(chunk)
} catch {
session.subscribers.delete(key)
session.subscribers.delete(token)
}
}
session.buffer += chunk
@@ -233,12 +270,19 @@ export const layer = Layer.effect(
}),
proc.onExit(({ exitCode }) => {
if (session.info.status === "exited") return
session.info.status = "exited"
session.info.exitCode = exitCode
notifyEnd(session, { exitCode })
exitOrder.push(id)
runFork(
Effect.gen(function* () {
yield* Effect.logInfo("session exited", { id, exitCode })
session.info.status = "exited"
yield* events.publish(Event.Exited, { id, exitCode })
yield* removeSession(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(oldest)
}
}),
)
}),
@@ -253,66 +297,71 @@ export const layer = Layer.effect(
// kilocode_change start - associate nested Kilo TUI terminals with the viewed session
if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined
// kilocode_change end
if (input.size) session.process.resize(input.size.cols, input.size.rows)
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
yield* events.publish(Event.Updated, { info: session.info })
return session.info
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.resize(cols, rows)
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.write(data)
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => session.subscribers.delete(sub)
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
const token = {}
const subscriber: Subscriber = {
onData: input.onData,
onEnd: input.onEnd,
active: false,
detached: false,
pending: [],
}
session.subscribers.set(token, subscriber)
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
input.cursor === -1
? end
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
? Math.max(0, input.cursor)
: 0
const replay = (() => {
if (!session.buffer || from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
} catch {
cleanup()
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
replay,
cursor: end,
write: (data: string) => {
if (session.info.status === "running") session.process.write(data)
},
onClose: () => {
cleanup()
activate: () => {
if (subscriber.active || subscriber.detached) return
subscriber.active = true
try {
for (const chunk of subscriber.pending) subscriber.onData(chunk)
subscriber.pending.length = 0
if (subscriber.end) subscriber.onEnd(subscriber.end)
} catch {
session.subscribers.delete(token)
}
},
detach: () => {
subscriber.detached = true
subscriber.pending.length = 0
subscriber.end = undefined
session.subscribers.delete(token)
},
}
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
return Service.of({ list, get, create, update, remove, write, attach })
}),
)
export const locationLayer = layer
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
-24
View File
@@ -1,24 +0,0 @@
import { Effect } from "effect"
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
export function handlePtyInput(
handler: { onMessage: (message: string | ArrayBuffer) => void },
message: string | Uint8Array,
) {
if (typeof message === "string") {
handler.onMessage(message)
return Effect.void
}
return Effect.try({
try: () => inputDecoder.decode(message),
catch: () => new Error("invalid PTY websocket input"),
}).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((decoded) => {
if (decoded === undefined) return Effect.void
handler.onMessage(decoded)
return Effect.void
}),
)
}
+37
View File
@@ -0,0 +1,37 @@
export * as PtyProtocol from "./protocol"
// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server
// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol.
//
// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by
// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later.
const encoder = new TextEncoder()
const decoder = new TextDecoder("utf-8", { fatal: true })
// Replay can be megabytes; send it in bounded frames.
export const REPLAY_CHUNK = 64 * 1024
export function metaFrame(cursor: number) {
const bytes = encoder.encode(JSON.stringify({ cursor }))
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
export function chunks(data: string) {
const out: string[] = []
for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK))
return out
}
// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped.
export function decodeInput(message: string | Uint8Array | ArrayBuffer) {
if (typeof message === "string") return message
try {
return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message)
} catch {
return undefined
}
}
+1 -10
View File
@@ -1,7 +1,7 @@
export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect, Schema } from "effect"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
@@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index"
import type { SessionContextEpoch } from "../context-epoch"
import type { ToolOutputStore } from "../../tool-output-store"
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
"SessionRunner.StepLimitExceededError",
{
sessionID: SessionSchema.ID,
limit: Schema.Int,
},
) {}
export type RunError =
| LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| ContextSnapshotDecodeError
| StepLimitExceededError
| SystemContext.InitializationBlocked
| SessionContextEpoch.AgentReplacementBlocked
| ToolOutputStore.Error
+26 -22
View File
@@ -3,6 +3,7 @@ import {
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
@@ -29,10 +30,11 @@ import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { type RunError, Service, StepLimitExceededError } from "./index"
import { type RunError, Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
/**
* Runs one durable coding-agent Session until it settles.
@@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message"
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Bound model steps.
* - [x] Honor optional agent step limits.
* - [ ] Bound provider retries and repeated identical tool calls.
*
* - Runtime context assembly
@@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message"
* Durable activity recovery remains a separate future slice with an explicit retry policy.
*
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and a
* bounded explicit loop starts the next provider turn after local settlement.
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
* explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
*/
// QUESTION: Did this exist previously, or did we add this limit? Does it make sense?
const MAX_STEPS = 25
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -175,6 +174,7 @@ export const layer = Layer.effect(
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
) {
const session = yield* getSession(sessionID)
@@ -214,7 +214,8 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@@ -222,8 +223,9 @@ export const layer = Layer.effect(
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(context, model),
tools: toolMaterialization.definitions,
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
@@ -254,6 +256,10 @@ export const layer = Layer.effect(
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
@@ -340,31 +346,32 @@ export const layer = Layer.effect(
type RunTurn = (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
step: number,
) => Effect.Effect<boolean, RunError>
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion).pipe(
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion)
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
}),
),
)
})
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe(
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined)
return yield* runTurn(sessionID, defect.transition.promotion)
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
return yield* runTurn(sessionID, defect.transition.promotion, step)
}),
),
)
@@ -382,14 +389,11 @@ export const layer = Layer.effect(
let openActivity = input.force === true || hasSteer || hasQueue
while (openActivity) {
let needsContinuation = true
for (let step = 0; step < MAX_STEPS; step++) {
needsContinuation = yield* runTurn(input.sessionID, promotion)
for (let step = 1; needsContinuation; step++) {
needsContinuation = yield* runTurn(input.sessionID, promotion, step)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
if (!needsContinuation) break
}
if (needsContinuation)
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
promotion = openActivity ? "queue" : undefined
}
@@ -1,4 +1,4 @@
CRITICAL - MAXIMUM STEPS REACHED
export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED
The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
@@ -13,4 +13,4 @@ Response must include:
- List of any remaining tasks that were not completed
- Recommendations for what should be done next
Any attempt to use tools is a critical violation. Respond with text ONLY.
Any attempt to use tools is a critical violation. Respond with text ONLY.`
+44 -19
View File
@@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { IntegrationConnection } from "../../integration/connection"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginBoot } from "../../plugin/boot"
@@ -45,10 +48,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
return connection?.type === "env" ? Auth.config(connection.name) : undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
@@ -83,41 +88,54 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = (
model: ModelV2.Info,
provider?: ProviderV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Stored,
): Effect.Effect<Model, UnsupportedApiError> => {
const key = apiKey(model, provider)
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
const resolved =
credential?.value.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.value.metadata)
// kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route
if (credential.value.type === "oauth" && credential.value.metadata?.accountID) {
draft.request.body.kilocodeOrganizationId = credential.value.metadata.accountID
delete draft.request.body.accountID
}
// kilocode_change end
})
const key = apiKey(resolved, connection, credential)
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
return Effect.succeed(
withDefaults(model, OpenAIResponses.route)
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(model, AnthropicMessages.route)
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
return Effect.succeed(
withDefaults(model, OpenAICompatibleChat.route)
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: model.api.id }),
.model({ id: resolved.api.id }),
)
}
return Effect.fail(
new UnsupportedApiError({
providerID: model.providerID,
modelID: model.id,
api: apiName(model),
providerID: resolved.providerID,
modelID: resolved.id,
api: apiName(resolved),
}),
)
}
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant), provider)
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
fromCatalogModel(withVariant(model, session.model?.variant))
export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" &&
@@ -130,6 +148,8 @@ export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const boot = yield* PluginBoot.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
@@ -140,7 +160,12 @@ export const locationLayer = Layer.effect(
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
(yield* catalog.model.available()).find(supported))
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* fromCatalogModel(
withVariant(selected, session.model?.variant),
connection,
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
)
}),
})
}),
@@ -1,11 +1,14 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - PowerShell args
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@opencode-ai/core/util/which"
export * as Shell from "./shell"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout as sleep } from "node:timers/promises"
import { Flag } from "./flag/flag"
import { FSUtil } from "./fs-util"
import { which } from "./util/which"
import { PowerShell } from "./kilocode/powershell" // kilocode_change
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
@@ -48,7 +51,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
} catch {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
@@ -57,9 +60,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
}
}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string) {
if (process.platform !== "win32") return file
const shell = Filesystem.windowsPath(file)
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
@@ -77,13 +84,13 @@ function ok(file: string) {
}
function rooted(file: string) {
return path.isAbsolute(Filesystem.windowsPath(file))
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (Filesystem.stat(shell)?.isFile()) return shell
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
@@ -100,7 +107,7 @@ function win() {
}
async function unix() {
const text = await Filesystem.readText("/etc/shells").catch(() => "")
const text = await readFile("/etc/shells", "utf8").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
@@ -110,7 +117,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]!
if (process.platform === "win32") return win()[0]
return fallback()
}
@@ -120,7 +127,7 @@ export function gitbash() {
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (Filesystem.stat(file)?.size) return file
if (stat(file)?.size) return file
}
function fallback() {
@@ -131,7 +138,7 @@ function fallback() {
}
export function name(file: string) {
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
return path.basename(file).toLowerCase()
}
@@ -189,28 +196,32 @@ export function args(file: string, command: string, cwd: string) {
]
}
if (n === "cmd") return ["/c", command]
if (ps(file)) return PowerShell.args(command) // kilocode_change - PowerShell args
if (ps(file)) return PowerShell.args(command) // kilocode_change - preserve UTF-8 and script prologues
return ["-c", command]
}
const defaultPreferred = lazy(() => select(process.env.SHELL))
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
return defaultPreferred()
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
preferred.reset = () => {
defaultPreferred = undefined
}
preferred.reset = () => defaultPreferred.reset()
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
return defaultAcceptable()
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
acceptable.reset = () => defaultAcceptable.reset()
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}
export * as Shell from "./shell"
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+55 -52
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -25,47 +25,29 @@ const it = testEffect(
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
),
)
describe("CatalogV2", () => {
it.effect("projects Kilo organization routing from OAuth credentials", () => {
const integrationID = Integration.ID.make("kilocode")
const credential = new Credential.Stored({
id: Credential.ID.create(),
integrationID,
label: "Organization",
value: new Credential.OAuth({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "access",
refresh: "refresh",
expires: 1,
metadata: { accountID: "organization" },
}),
})
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(
Layer.mock(Credential.Service)({ all: () => Effect.succeed([credential]) }),
),
)
return Effect.gen(function* () {
it.effect("publishes an updated event after catalog changes", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("kilocode"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("kilocode"))).toMatchObject({
request: { body: { apiKey: "access", kilocodeOrganizationId: "organization" } },
})
}).pipe(Effect.provide(layer))
})
const events = yield* EventV2.Service
const updated = yield* events
.subscribe(Catalog.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
it.effect("projects active credentials without rebuilding catalog state", () => {
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
)
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const first = {
id: Credential.ID.create(),
@@ -87,6 +69,7 @@ describe("CatalogV2", () => {
Layer.provideMerge(
Layer.mock(Credential.Service)({
all: () => Effect.sync(() => [active]),
list: () => Effect.sync(() => [active]),
}),
),
)
@@ -96,18 +79,44 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: first.id },
request: { body: { apiKey: "first", tenant: "one" } },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
active = second
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: second.id },
request: { body: { apiKey: "second", tenant: "two" } },
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer))
})
it.effect("projects environment connections without a catalog plugin", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.CATALOG_TEST_API_KEY
process.env.CATALOG_TEST_API_KEY = "secret"
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = ProviderV2.ID.make("test")
yield* integrations.update((editor) =>
editor.method.update({
integrationID: Integration.ID.make(providerID),
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
}),
)
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
else process.env.CATALOG_TEST_API_KEY = previous
}),
),
)
it.effect("normalizes provider baseURL into api url", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -326,9 +335,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
@@ -350,9 +357,7 @@ describe("CatalogV2", () => {
const transform = yield* catalog.transform()
const models = (catalog: Catalog.Editor) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, old, (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
@@ -383,12 +388,10 @@ describe("CatalogV2", () => {
yield* transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => {
provider.enabled = false
provider.disabled = true
})
catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.provider.update(enabledProvider, () => {})
catalog.model.update(enabledProvider, fallbackModel, () => {})
catalog.model.default.set(disabledProvider, disabledModel)
})
+116 -103
View File
@@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it } from "../plugin/provider-helper"
import { it, withEnv } from "../plugin/provider-helper"
function request(headers: Record<string, string>, variant?: string) {
return {
@@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
@@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
@@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
@@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
@@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => {
)
it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
api: { type: "native", settings: {} },
request: request({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
request: request({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
withEnv({ CUSTOM_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
env: ["CUSTOM_API_KEY"],
api: { type: "native", settings: {} },
request: request({ first: "first", shared: "first" }),
models: {
chat: {
name: "First",
capabilities: { tools: true, input: ["text"], output: ["text"] },
disabled: true,
limit: { context: 100, output: 50 },
cost: { input: 1, output: 2 },
request: request({ first: "first", shared: "first" }, "retained"),
variants: [
{
id: "fast",
headers: { first: "first", shared: "first" },
},
],
},
},
},
},
},
}),
}),
}),
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
request: request({ last: "last", shared: "last" }),
models: {
default: {
name: "Default",
},
chat: {
api: { id: "api-chat" },
name: "Last",
limit: { output: 75 },
request: request({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
request: request({ last: "last", shared: "last" }),
models: {
default: {
name: "Default",
},
chat: {
api: { id: "api-chat" },
name: "Last",
limit: { output: 75 },
request: request({ last: "last", shared: "last" }),
variants: [
{
id: "fast",
headers: { last: "last", shared: "last" },
},
{
id: "slow",
headers: { slow: "slow" },
},
],
},
},
},
},
},
}),
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
new Config.Document({
type: "document",
info: decode({
providers: {
custom: { name: "Renamed" },
},
}),
}),
}),
]),
})
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(Integration.Service, integrations),
),
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
expect(provider.enabled).toEqual({ via: "custom", data: {} })
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.request.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
type: "env",
names: ["CUSTOM_API_KEY"],
})
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.disabled).toBeUndefined()
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.request.variant).toBe("retained")
expect(model.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("fast"),
ModelV2.VariantID.make("slow"),
])
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
}),
),
)
})
+31 -21
View File
@@ -1,8 +1,7 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Layer, Scope } from "effect"
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Integration } from "@opencode-ai/core/integration"
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
@@ -25,7 +24,7 @@ function connectionLayer(
}>,
) {
return Integration.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: (input) =>
@@ -103,7 +102,7 @@ describe("Integration", () => {
.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize,
}),
)
@@ -117,7 +116,7 @@ describe("Integration", () => {
])
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
authorize,
})
})
@@ -140,15 +139,20 @@ describe("Integration", () => {
}> = []
return Effect.gen(function* () {
const integrations = yield* Integration.Service
const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.KeyMethod({ type: "key", label: "API key" }),
method: { type: "key", label: "API key" },
}),
)
const updated = yield* events
.subscribe(Integration.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* integrations.connect.key({
yield* integrations.connection.key({
integrationID,
key: "secret",
label: "Work",
@@ -161,6 +165,7 @@ describe("Integration", () => {
value: new Credential.Key({ type: "key", key: "secret" }),
},
])
expect((yield* Fiber.join(updated)).length).toBe(1)
}).pipe(Effect.provide(connectionLayer(created)))
})
@@ -177,7 +182,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
@@ -198,7 +203,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({
const attempt = yield* integrations.connection.oauth({
integrationID,
methodID,
inputs: {},
@@ -236,7 +241,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
@@ -249,7 +254,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
Integration.CodeRequiredError,
)
@@ -273,7 +278,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.succeed({
mode: "auto" as const,
@@ -286,7 +291,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "complete",
@@ -310,7 +315,7 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
@@ -323,7 +328,7 @@ describe("Integration", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow
@@ -373,23 +378,28 @@ describe("Integration", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.EnvMethod({
method: {
type: "env",
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
}),
},
}),
)
// Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }),
new IntegrationConnection.CredentialInfo({
{ type: "credential", id: rows[0]!.id, label: "Work" },
{
type: "credential",
id: rows[1]!.id,
label: "Personal",
}),
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }),
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential",
id: rows[1]!.id,
label: "Personal",
})
}).pipe(Effect.provide(projectionLayer)),
(previous) =>
Effect.sync(() => {
@@ -65,7 +65,7 @@ describe("Integration settlement guards", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.succeed({
mode: "auto" as const,
@@ -78,7 +78,7 @@ describe("Integration settlement guards", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({
status: "failed",
@@ -104,7 +104,7 @@ describe("Integration settlement guards", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
@@ -118,10 +118,10 @@ describe("Integration settlement guards", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const exit = yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }).pipe(
Effect.exit,
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const exit = yield* integrations.attempt
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("database unavailable")
expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({
@@ -165,7 +165,7 @@ describe("Integration settlement guards", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
@@ -179,7 +179,7 @@ describe("Integration settlement guards", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const fiber = yield* integrations.attempt
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.forkScoped)
@@ -212,7 +212,7 @@ describe("Integration settlement guards", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
@@ -229,7 +229,7 @@ describe("Integration settlement guards", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const fiber = yield* integrations.attempt
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.forkScoped)
@@ -253,7 +253,7 @@ describe("Integration settlement guards", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (state.closed = true))).pipe(
Effect.as({
@@ -266,7 +266,7 @@ describe("Integration settlement guards", () => {
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const fiber = yield* integrations.attempt
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.exit, Effect.forkScoped)
@@ -301,7 +301,7 @@ describe("Integration settlement guards", () => {
yield* integrations.update((editor) =>
editor.method.update({
integrationID,
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
@@ -310,14 +310,20 @@ describe("Integration settlement guards", () => {
instructions: "Paste the code",
callback: () =>
Effect.succeed(
new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
new Credential.OAuth({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
}),
),
}),
),
}),
)
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
const fiber = yield* integrations.attempt
.complete({ attemptID: attempt.attemptID, code: "1234" })
.pipe(Effect.exit, Effect.forkScoped)
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
@@ -16,7 +17,12 @@ describe("provider attribution isolation", () => {
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const catalog = yield* Catalog.Service
for (const plugin of [LLMGatewayPlugin, NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) {
const integrations = yield* Integration.Service
yield* plugins.add({
...LLMGatewayPlugin,
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
for (const plugin of [NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) {
yield* plugins.add(plugin)
}
@@ -24,7 +30,6 @@ describe("provider attribution isolation", () => {
yield* transform((catalog) => {
const items = [
provider("custom-llmgateway", {
enabled: { via: "env", name: "CUSTOM_LLMGATEWAY_API_KEY" },
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
}),
provider("custom-nvidia", {
@@ -47,7 +52,6 @@ describe("provider attribution isolation", () => {
for (const item of items) {
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
draft.api = item.api
draft.request.headers.Existing = "value"
})
@@ -0,0 +1,56 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { it } from "../lib/effect"
describe("SessionRunnerModel Kilo credentials", () => {
it.effect("maps OAuth account IDs to Kilo organization routing", () =>
Effect.gen(function* () {
const model = new ModelV2.Info({
id: ModelV2.ID.make("test-model"),
providerID: ProviderV2.ID.make("kilo"),
name: "Test model",
api: {
id: ModelV2.ID.make("api-test-model"),
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://api.kilo.ai/openrouter",
},
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {}, generation: {}, options: {} },
variants: [],
time: { released: DateTime.makeUnsafe(0) },
cost: [],
status: "active",
enabled: true,
limit: { context: 100, output: 20 },
})
const credential = new Credential.Stored({
id: Credential.ID.create(),
integrationID: Integration.ID.make("kilo"),
label: "Work",
value: new Credential.OAuth({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
refresh: "refresh",
access: "access",
expires: 1,
metadata: { accountID: "org-enterprise" },
}),
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(
model,
{ type: "credential", id: credential.id, label: credential.label },
credential,
)
expect(resolved.route.defaults.http?.body).toMatchObject({ kilocodeOrganizationId: "org-enterprise" })
expect(resolved.route.defaults.http?.body).not.toHaveProperty("accountID")
}),
)
})
+6 -4
View File
@@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
Layer.provide(events),
)
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
const catalog = Catalog.layer.pipe(
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
)
const layer = Layer.mergeAll(
catalog.pipe(Layer.provide(connections)),
integrations,
@@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
new Integration.KeyMethod({ type: "key" }),
new Integration.EnvMethod({
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
}),
},
],
connections: [],
}),
@@ -1,35 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () =>
@@ -73,35 +48,7 @@ describe("AzurePlugin", () => {
),
)
itWithAccount.effect("prefers account resourceName over env", () =>
withEnv(
{
AZURE_RESOURCE_NAME: "from-env",
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("azure"),
value: new Credential.Key({
type: "key",
key: "key",
metadata: { resourceName: "from-account" },
}),
})
yield* plugin.add(AzurePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account")
}),
),
)
// kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up
it.effect("falls back to env when configured resourceName is blank", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
@@ -1,36 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
),
Layer.provideMerge(npmLayer),
),
)
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -126,41 +101,7 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)
itWithAccount.effect("falls back to account metadata when account env is absent", () =>
withEnv(
{
CLOUDFLARE_ACCOUNT_ID: undefined,
CLOUDFLARE_API_KEY: undefined,
},
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("cloudflare-workers-ai"),
value: new Credential.Key({
type: "key",
key: "account-key",
metadata: { accountId: "account-acct" },
}),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.api = { type: "aisdk", package: "test-provider" }
}),
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject(
{
apiKey: "account-key",
accountId: "account-acct",
},
)
}),
),
)
// kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up
it.effect("uses env account ID over configured account ID", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
@@ -1,26 +1,10 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Database } from "@opencode-ai/core/database/database"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, withEnv } from "./provider-helper"
import { it, model, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
const preferences = Credential.layer.pipe(Layer.provide(database))
const accounts = Layer.merge(
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
preferences,
)
void mock.module("gitlab-ai-provider", () => ({
VERSION: "test-version",
@@ -35,17 +19,6 @@ void mock.module("gitlab-ai-provider", () => ({
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
}))
const itWithAccount = testEffect(
Catalog.locationLayer.pipe(
Layer.provideMerge(accounts),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))),
),
Layer.provideMerge(npmLayer),
),
)
describe("GitLabPlugin", () => {
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
withEnv(
@@ -163,77 +136,7 @@ describe("GitLabPlugin", () => {
}),
)
itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
withEnv(
{
GITLAB_TOKEN: "env-token",
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("gitlab"),
value: new Credential.Key({ type: "key", key: "account-token" }),
})
yield* plugin.add(GitLabPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: provider.request.body,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
}),
),
)
itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
withEnv(
{
GITLAB_TOKEN: undefined,
},
() =>
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* credentials.create({
integrationID: Integration.ID.make("gitlab"),
value: new Credential.OAuth({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
refresh: "refresh-token",
access: "account-oauth-token",
expires: 9999999999999,
}),
})
yield* plugin.add(GitLabPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: provider.request.body,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
}),
),
)
// kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
Effect.gen(function* () {
@@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
Layer.provide(
Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"),
all: () => Effect.succeed([]),
list: () => Effect.succeed([]),
}),
),
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
@@ -151,20 +150,18 @@ describe("KiloPlugin", () => {
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("kilo", {
enabled: { via: "credential", credentialID: Credential.ID.make("cred_kilo") },
request: {
headers: {},
body: { apiKey: "authenticated-token", kilocodeOrganizationId: "authenticated-org" },
},
})
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
draft.request = item.request
})
})
const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
expect(result.enabled).toEqual({ via: "credential", credentialID: Credential.ID.make("cred_kilo") })
expect(result.request.body.apiKey).toBe("authenticated-token")
expect(result.request.body.kilocodeToken).toBe("authenticated-token")
expect(result.request.body.kilocodeOrganizationId).toBe("environment-org")
}),
@@ -181,7 +178,10 @@ describe("KiloPlugin", () => {
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("kilo"), () => {}))
const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
expect(result.enabled).toEqual({ via: "custom", data: { anonymous: true } })
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(
ProviderV2.ID.make("kilo"),
)
expect(result.request.body.apiKey).toBe("anonymous")
expect(result.request.body.kilocodeToken).toBe("anonymous")
}),
),
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
@@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => {
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
const integrations = yield* Integration.Service
yield* plugin.add({
...LLMGatewayPlugin,
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
})
it.effect("is registered so legacy referer headers can be applied", () =>
Effect.sync(() =>
expectPluginRegistered(
@@ -21,31 +30,29 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
yield* add(plugin)
const integrations = yield* Integration.Service
yield* integrations.update((editor) => {
editor.update(Integration.ID.make("llmgateway"), () => {})
editor.update(Integration.ID.make("openrouter"), () => {})
})
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const llmgateway = provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
request: { headers: { Existing: "value" }, body: {} },
})
catalog.provider.update(llmgateway.id, (draft) => {
draft.enabled = llmgateway.enabled
draft.api = llmgateway.api
draft.request = llmgateway.request
})
const openrouter = provider("openrouter", {
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
})
catalog.provider.update(openrouter.id, (draft) => {
draft.enabled = openrouter.enabled
})
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code",
"X-Source": "kilo",
"X-Title": "Kilo Code", // kilocode_change
"X-Source": "kilo", // kilocode_change
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
}),
@@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
yield* add(plugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("llmgateway", {
@@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
}),
)
@@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
const plugin = yield* PluginV2.Service
yield* add(plugin, yield* Integration.Service)
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
new Integration.OAuthMethod({
{
id: Integration.MethodID.make("chatgpt-browser"),
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
new Integration.OAuthMethod({
},
{
id: Integration.MethodID.make("chatgpt-headless"),
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
},
])
}),
)
@@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
...OpencodePlugin,
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
})
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode")
@@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const integrations = yield* Integration.Service
yield* plugin.add(pluginWithIntegrations(integrations))
yield* integrations.update((editor) => {
editor.method.update({
integrationID: Integration.ID.make("opencode"),
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
})
})
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
catalog.provider.update(item.id, (draft) => {
draft.env = [...item.env]
})
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
@@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
@@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
),
)
it.effect("uses auth-enabled providers as credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("opencode", {
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
})
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
),
)
it.effect("ignores non-opencode providers and models", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
const transform = yield* catalog.transform()
yield* transform((catalog) => {
const item = provider("openai")
+12
View File
@@ -365,6 +365,18 @@ describe("ProjectCopy", () => {
}),
)
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const copy = yield* ProjectCopy.Service
yield* copy.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
)
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const copy = yield* ProjectCopy.Service
@@ -24,4 +24,9 @@ describe("Pty.Info", () => {
test("rejects a negative pid", () => {
expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow()
})
test("accepts an exit code for retained exited sessions", () => {
const info = Schema.decodeUnknownSync(Pty.Info)({ ...sample(48012), status: "exited", exitCode: 4 })
expect(info.exitCode).toBe(4)
})
})
-19
View File
@@ -1,19 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { handlePtyInput } from "@opencode-ai/core/pty/input"
import { it } from "../lib/effect"
describe("pty websocket input", () => {
it.effect("does not forward invalid binary frames to the PTY handler", () =>
Effect.gen(function* () {
const messages: Array<string | ArrayBuffer> = []
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
yield* handlePtyInput(handler, "ready")
yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))
yield* handlePtyInput(handler, new TextEncoder().encode("hello"))
expect(messages).toEqual(["ready", "hello"])
}),
)
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
describe("pty protocol", () => {
test("drops invalid binary input frames and decodes valid ones", () => {
expect(PtyProtocol.decodeInput("ready")).toBe("ready")
expect(PtyProtocol.decodeInput(new Uint8Array([0xff, 0xfe, 0xfd]))).toBeUndefined()
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello"))).toBe("hello")
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello").buffer)).toBe("hello")
})
test("encodes the cursor as a 0x00-prefixed JSON control frame", () => {
const frame = PtyProtocol.metaFrame(42)
expect(frame[0]).toBe(0)
expect(JSON.parse(new TextDecoder().decode(frame.subarray(1)))).toEqual({ cursor: 42 })
})
test("splits replay into bounded frames", () => {
expect(PtyProtocol.chunks("")).toEqual([])
expect(PtyProtocol.chunks("abc")).toEqual(["abc"])
const big = "x".repeat(PtyProtocol.REPLAY_CHUNK + 1)
const frames = PtyProtocol.chunks(big)
expect(frames.length).toBe(2)
expect(frames[0].length).toBe(PtyProtocol.REPLAY_CHUNK)
expect(frames.join("")).toBe(big)
})
})
@@ -1,110 +0,0 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, Queue } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
type Socket = Parameters<Pty.Interface["connect"]>[1]
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) {
const pty = yield* Pty.Service
return yield* Effect.acquireRelease(
pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", KILO_TERMINAL: "1" } }),
(info) => pty.remove(info.id).pipe(Effect.ignore),
)
})
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
typeof data === "string"
? data
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
const output = yield* Queue.unbounded<string>()
const socket: Socket = {
readyState: 1,
data,
send: (data) => Queue.offerUnsafe(output, decodeOutput(data)),
close: () => {},
}
return { socket, output }
})
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) received += yield* Queue.take(output)
return received
}).pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
describe("pty output isolation", () => {
ptyTest("does not leak output when websocket objects are reused", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty("cat")
const b = yield* createPty("cat")
const shared = yield* makeSocket({ events: { connection: "a" } })
const outB = yield* Queue.unbounded<string>()
yield* pty.connect(a.id, shared.socket)
shared.socket.data = { events: { connection: "b" } }
shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data))
yield* pty.connect(b.id, shared.socket)
yield* pty.write(a.id, "AAA\n")
const verify = yield* makeSocket({ events: { connection: "verify-a" } })
yield* pty.connect(a.id, verify.socket)
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
}),
)
ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const first = yield* makeSocket({ events: { connection: "a" } })
const recycled = yield* Queue.unbounded<string>()
yield* pty.connect(info.id, first.socket)
first.socket.data = { events: { connection: "b" } }
first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data))
yield* pty.write(info.id, "AAA\n")
const verify = yield* makeSocket({ events: { connection: "verify" } })
yield* pty.connect(info.id, verify.socket)
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
}),
)
ptyTest("treats in-place socket data mutation as the same connection", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const data = { connId: 1 }
const socket = yield* makeSocket(data)
yield* pty.connect(info.id, socket.socket)
data.connId = 2
yield* pty.write(info.id, "AAA\n")
expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA")
}),
)
})
+158 -10
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Queue } from "effect"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty"
@@ -14,7 +15,14 @@ const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
)
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect(
Pty.layer.pipe(
Layer.provide(configLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
@@ -56,36 +64,176 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
}),
)
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) {
const pty = yield* Pty.Service
const output = yield* Queue.unbounded<string>()
const ended = yield* Deferred.make<{ exitCode?: number }>()
const attachment = yield* pty.attach(id, {
cursor,
onData: (chunk) => Queue.offerUnsafe(output, chunk),
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
})
attachment.activate()
return { attachment, output, ended }
})
const waitForOutput = (output: Queue.Queue<string>, text: string) =>
Effect.gen(function* () {
let received = ""
while (!received.includes(text)) received += yield* Queue.take(output)
return received
}).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
}),
)
describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = "pty_missing" as PtyID
let closed = false
const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) }
for (const result of [
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
yield* pty.remove(id).pipe(Effect.exit),
yield* pty.resize(id, 80, 24).pipe(Effect.exit),
yield* pty.write(id, "input").pipe(Effect.exit),
yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit),
yield* pty.attach(id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.asVoid, Effect.exit),
]) {
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result))
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
}
expect(closed).toBe(true)
}),
)
ptyTest("publishes created, exited, deleted in order for a short-lived process", () =>
ptyTest("retains exited sessions until removed", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const events = yield* subscribePtyEvents()
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"])
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "exit 3"])
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
const exited = yield* pty.get(info.id)
expect(exited.status).toBe("exited")
expect(exited.exitCode).toBe(3)
yield* pty.remove(info.id)
expect(yield* waitForEvents(events, info.id, 1)).toEqual(["deleted"])
const missing = yield* pty.get(info.id).pipe(Effect.exit)
expect(Exit.isFailure(missing)).toBe(true)
}),
)
ptyTest("replays buffered output and streams live output to attachments", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
yield* pty.write(info.id, "AAA\n")
const first = yield* attachCollecting(info.id)
expect(yield* waitForOutput(first.output, "AAA")).toContain("AAA")
first.attachment.write("BBB\n")
yield* waitForOutput(first.output, "BBB")
// A later attachment replays everything already buffered.
const replayed = yield* attachCollecting(info.id)
expect(replayed.attachment.replay).toContain("AAA")
expect(replayed.attachment.replay).toContain("BBB")
expect(replayed.attachment.cursor).toBeGreaterThan(0)
// Tail attachments skip the buffer and only see subsequent output.
const tail = yield* attachCollecting(info.id, -1)
expect(tail.attachment.replay).toBe("")
expect(tail.attachment.cursor).toBe(replayed.attachment.cursor)
}),
)
ptyTest("stops delivering output after detach", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const info = yield* createPty("cat")
const attached = yield* attachCollecting(info.id, -1)
attached.attachment.detach()
yield* pty.write(info.id, "AAA\n")
const verify = yield* attachCollecting(info.id)
yield* waitForOutput(verify.output, "AAA")
const leaked = yield* Queue.poll(attached.output)
expect(leaked._tag).toBe("None")
}),
)
ptyTest("isolates output between sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const a = yield* createPty("cat")
const b = yield* createPty("cat")
const attachedA = yield* attachCollecting(a.id)
const attachedB = yield* attachCollecting(b.id)
yield* pty.write(a.id, "AAA\n")
yield* waitForOutput(attachedA.output, "AAA")
const leaked = yield* Queue.poll(attachedB.output)
expect(leaked._tag).toBe("None")
}),
)
ptyTest("notifies attachments with the exit code and rejects attach after exit", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const events = yield* subscribePtyEvents()
const info = yield* createPty("cat")
const attached = yield* attachCollecting(info.id)
yield* pty.write(info.id, "\u0004")
expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 })
yield* waitForEvents(events, info.id, 2)
const result = yield* pty.attach(info.id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
if (Exit.isFailure(result))
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
}),
)
})
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect(
Pty.layer.pipe(
Layer.provide(
Layer.mock(Config.Service)({
entries: () =>
Effect.succeed(
configuredShell
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
: [],
),
}),
),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
describe("pty create defaults", () => {
configuredTest("defaults command, login args, and cwd from config and location", () =>
Effect.gen(function* () {
if (!configuredShell) return
const pty = yield* Pty.Service
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
pty.remove(created.id).pipe(Effect.ignore),
)
expect(info.command).toBe(configuredShell)
expect(info.args).toEqual(["-l"])
expect(info.cwd).toBe("/tmp")
expect(info.title).toBe("configured")
}),
)
})
@@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm"
import { LLMClient } from "@opencode-ai/llm/route"
import { ConfigProvider, DateTime, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
@@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) =>
new ProviderV2.Info({
id: ProviderV2.ID.make("test-provider"),
name: "Test provider",
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
env: ["TEST_PROVIDER_API_KEY"],
api,
request: { headers: {}, body: {} },
})
@@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => {
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {}, generation: {}, options: {} },
}),
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
{ type: "env", name: "TEST_PROVIDER_API_KEY" },
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth
@@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = new Credential.Stored({
id: Credential.ID.create(),
integrationID: Integration.ID.make("test-provider"),
label: "Work",
value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }),
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(
new ModelV2.Info({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} },
}),
{ type: "credential", id: credential.id, label: credential.label },
credential,
)
const headers = yield* resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://openai.example/v1/responses",
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer stored-secret")
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
+56 -58
View File
@@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => {
yield* events.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change
})
expect(
yield* db
@@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => {
.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change
})
.pipe(Effect.asVoid)
})
@@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => {
})
requests.length = 0
executions.length = 0
responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
@@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("fails after the bounded number of local tool continuation steps", () =>
it.effect("continues past 25 local tool steps when the agent has no step limit", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => {
executions.length = 0
streamGate = undefined
streamStarted = undefined
responses = Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
])
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 })
expect(requests).toHaveLength(25)
expect(executions).toHaveLength(25)
}),
)
it.effect("does not restart a capped tool loop for a coalesced stale wake", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const coordinator = yield* SessionRunCoordinator.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false })
requests.length = 0
responses = Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
])
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* coordinator.wake(sessionID)
yield* Deferred.succeed(streamGate, undefined)
expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" })
streamGate = undefined
streamStarted = undefined
yield* Effect.yieldNow
expect(requests).toHaveLength(25)
}),
)
it.effect("accepts a terminal response on the final bounded provider turn", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
requests.length = 0
responses = [
...Array.from({ length: 24 }, (_, index) => [
...Array.from({ length: 25 }, (_, index) => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
]),
@@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests).toHaveLength(25)
expect(requests).toHaveLength(26)
expect(executions).toHaveLength(25)
}),
)
it.effect("forces a text response on an agent's configured final step", () =>
Effect.gen(function* () {
yield* setup
const agents = yield* AgentV2.Service
yield* agents.update((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.steps = 2
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
requests.length = 0
executions.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(requests[0]?.toolChoice).toBeUndefined()
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(requests[1]?.tools).toEqual([])
expect(requests[1]?.messages.at(-1)).toMatchObject({
role: "assistant",
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
})
expect(executions).toEqual(["done"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Finish at the limit" },
{ type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] },
{ type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] },
])
}),
)
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Shell } from "../../src/shell/shell"
import { Filesystem } from "@/util/filesystem"
import { Shell } from "@opencode-ai/core/shell"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { which } from "@opencode-ai/core/util/which"
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
@@ -54,6 +54,15 @@ describe("shell", () => {
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
})
test("builds command args per shell family", () => {
expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp")
expect(zsh[0]).toBe("-l")
expect(zsh[1]).toBe("-c")
expect(zsh.at(-1)).toBe("/tmp")
})
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
@@ -64,7 +73,7 @@ describe("shell", () => {
test("normalizes Git Bash shell paths from env", async () => {
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
await withShell(shell, async () => {
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell))
})
})
+2 -2
View File
@@ -7,8 +7,8 @@
"private": true,
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"exports": {
".": "./src/index.ts",
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+2 -2
View File
@@ -28,10 +28,10 @@
},
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit",
"build": "bun ./script/build.ts",
"verify:package": "bun ./script/verify-package.ts"
"verify:package": "bun ./script/verify-package.ts",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"exports": {
".": "./src/index.ts",
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+3
View File
@@ -60,6 +60,9 @@ exclude = [
# Consistently times out in CI
'^https?://opncd\.ai',
'^https?://zod\.dev/v4/changelog',
# Moonshot sites consistently time out from GitHub Actions runners.
'^https?://(www\.)?moonshot\.cn/?$',
'^https?://platform\.moonshot\.cn/?$',
# Example punycode domain used in homograph attack documentation — does not exist
'^https?://xn--pitest',
# OpenAI docs return 404 to plain GET link checks but resolve in browsers

Some files were not shown because too many files have changed in this diff Show More