Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions .github/releases/v1.0.64.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
## opencode 1.0.64

Stable release from `main` branch. Repair independently confirmed defects in the Claude-compatible hooks API, project memory, the autonomous goal loop and the DAG orchestrator.
Stable release from `main` branch. Repair independently confirmed defects in the Claude-compatible hooks API, project memory, the autonomous goal loop, the DAG orchestrator and opt-in reasoning distillation.

---

### 🎯 Features

- **Reasoning distillation**: `reasoningDistillation.language` selects the organizer language, `zh` (default; shorter for the same content) or `en`. Paths, commands, code, configuration keys, versions and numbers stay verbatim in either language.

---

Expand All @@ -13,6 +19,11 @@ Stable release from `main` branch. Repair independently confirmed defects in the
- **Goal**: An unanswered user message pauses the goal visibly instead of stalling while spending judge calls. The judge prompt receives the agent response verbatim. An undecodable goal record can be cleared and no longer blocks the session's automation lease. A stray brace in judge prose no longer hides a valid verdict.
- **DAG**: Parents are woken for terminal workflows after a paused reminder, for nodes settling while paused, and after a timed-out or failed wake delivery. Recovery reruns work skipped or aborted by workflow termination instead of completing without it. Replan/extend keeps a failed required upstream blocking its dependents. Moving a session hands its running workflow to the new owner. The supervision sweep honours attempt identity, and summaries refresh on queue, step and deadline-extension events.
- **DAG**: Conditions using `===`, `!==`, `&&` or `||` are rejected at authoring time instead of silently mis-routing; existing workflows keep their stored conditions. HTTP replan/extend apply the same authoring checks as the workflow tool. A missing prompt template fails its node instead of hanging the workflow. A cycle among required nodes is reported as a diagnostic. Review gates honour `node_defaults.required` and reject an inverted PASS gate. An empty model tier no longer blocks single-tier fallback. The workflow tool asks `read` permission before reading spec files.
- **DAG**: A node that needs clarification asks its owning parent through the `agent` tool instead of the user. The parent answers with a conclusion grounded in the user's instructions or checked evidence; when it cannot reach one it states what remains uncertain and what the node may safely do, or asks the user first.
- **Hooks**: A hook-forced `ask` shows the input the tool will actually run, including `updatedInput`, together with the hook's reason, in the TUI and web app; PermissionRequest hooks receive the same input. "Allow always" is hidden when nothing would be remembered. Once confirmed, the same call is not asked again; deny rules still apply. Tool parts keep the model's own input. An ignored PermissionRequest `allow` is logged, and the semantics are documented.
- **Reasoning distillation**: Long sessions keep distilling. Each organizer call's worst-case reservation is reconciled with reported usage, so the session budget no longer ran out after about ten turns. A transport failure or timeout is refunded; only three consecutive failures pause a session.
- **Reasoning distillation**: Reasoning too short to repay the organizer call is left as is, and a replacement that is not smaller than its source is discarded. In a multi-part batch, only the explicit no-useful-reasoning marker clears a part; an empty body keeps the original.
- **Reasoning distillation**: The turn-end snapshot covers only the completed turn instead of hashing the whole session on the foreground path. The core runner honours `OPENCODE_REASONING_DISTILLATION_AUX_TIMEOUT_MS` and a small model's declared `none` variant. The `compatibility` setting is documented as applying only to the legacy wire projector.

---

Expand All @@ -26,10 +37,11 @@ Stable release from `main` branch. Repair independently confirmed defects in the
### 🧪 Test Summary

```text
workspace test tasks: 26/26 passed
workspace typecheck tasks: 31/31 passed
opencode tests: 5322 passed, 0 failed
core tests: 1559 passed, 0 failed
opencode tests: 5332 passed; 9 prompt.test.ts cases hit the 30s timeout under host load, 158/158 on an isolated rerun
core tests: 1568 passed, 0 failed
tui tests: 282 passed, 0 failed
app unit tests: 456 passed, 0 failed
HTTP exerciser scenarios: 239 passed (coverage, auth, effect)
DAG behavior/coverage: passed
curated DAG templates: 16/16 validated
Expand All @@ -41,7 +53,7 @@ dual client generation: unchanged

### 🔍 Verification

Each repaired finding was reproduced by a failing test, independently re-confirmed before repair, and kept as a regression test. Tests use isolated sessions, temporary data and synthetic model fixtures; no live model calls or interactive TUI sessions were used. Native CI verifies the final PR head before merge, and the release workflow validates reference templates with the releasing runtime and verifies platform archives and checksums.
Each repaired finding was reproduced by a failing test, independently re-confirmed before repair, and kept as a regression test. Tests use isolated sessions, temporary data and synthetic model fixtures. The distillation and forced-ask repairs were also exercised against the configured DeepSeek and GLM models with a temporary config and database: a 3,309-character reasoning part was organized by one small-model call into 909 Chinese characters and adopted, with the reservation reconciled to reported usage; a forced ask with a rewritten command was shown with its reason and effective input in the TUI and web app, ran the rewritten command once and was not asked again; a forced file write showed its path and content in the TUI and was rejected without writing. Native CI verifies the final PR head before merge, and the release workflow validates reference templates with the releasing runtime and verifies platform archives and checksums.

---

Expand Down
59 changes: 32 additions & 27 deletions packages/app/e2e/regression/session-request-docks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,36 +46,41 @@ test("shows a pending question dock", async ({ page }) => {
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
})

test("shows a pending permission dock", async ({ page }) => {
await mockServer(page, {
permissions: [
{
id: "permission-request",
sessionID,
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: [],
},
],
})
for (const scenario of [
{ name: "with a persistable pattern", always: ["git *"], buttons: ["Deny", "Allow always", "Allow once"] },
{ name: "without a persistable pattern", always: [], buttons: ["Deny", "Allow once"] },
]) {
test(`shows a pending permission dock ${scenario.name}`, async ({ page }) => {
await mockServer(page, {
permissions: [
{
id: "permission-request",
sessionID,
permission: "bash",
patterns: ["git status", "git diff"],
metadata: {},
always: scenario.always,
},
],
})

await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)

const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
await expect(permission).toBeVisible()
await expect(permission.getByText("git status")).toBeVisible()
await expect(permission.getByText("git diff")).toBeVisible()
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
await expect(permission).toBeVisible()
await expect(permission.getByText("git status")).toBeVisible()
await expect(permission.getByText("git diff")).toBeVisible()
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveText(scenario.buttons)
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)

const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
})
const reply = page.waitForRequest((request) => request.method() === "POST")
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
expect(request.postDataJSON()).toEqual({ response: "once" })
})
}

async function mockServer(
page: Page,
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/pages/session/composer/hook-ask-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { hookAskInput } from "./hook-ask-input"

describe("hook-forced ask input", () => {
test("shows a command as is and other tools' full arguments, bounded", () => {
expect(hookAskInput({ metadata: { hookAsk: true, input: { command: "echo rewritten" } } })).toBe("echo rewritten")
const edit = hookAskInput({
metadata: { hookAsk: true, input: { filePath: "src/a.ts", oldString: "a", newString: "b" } },
})
expect(edit).toContain('"filePath": "src/a.ts"')
expect(edit).toContain('"newString": "b"')
const large = hookAskInput({ metadata: { hookAsk: true, input: { filePath: "a", content: "x".repeat(5_000) } } })
expect(large.length).toBeLessThan(2_100)
expect(large).toEndWith("… (truncated)")
})

test("ordinary requests keep their patterns", () => {
expect(hookAskInput({ metadata: { input: { command: "ls" } } })).toBe("")
expect(hookAskInput({ metadata: { hookAsk: true } })).toBe("")
})
})
17 changes: 17 additions & 0 deletions packages/app/src/pages/session/composer/hook-ask-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { PermissionRequest } from "@opencode-ai/sdk/v2"

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)

const MAX_CHARS = 2_000

/**
* The input a hook-forced ask will run with: a shell command as is, otherwise all arguments as JSON (so a path is
* shown together with the content or edit it applies), bounded for the dock.
*/
export const hookAskInput = (request: Pick<PermissionRequest, "metadata">) => {
const input = request.metadata?.hookAsk === true ? request.metadata.input : undefined
if (!isRecord(input)) return ""
const text = typeof input.command === "string" && input.command ? input.command : JSON.stringify(input, null, 2)
return text.length > MAX_CHARS ? `${text.slice(0, MAX_CHARS)}\n… (truncated)` : text
}
43 changes: 32 additions & 11 deletions packages/app/src/pages/session/composer/session-permission-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { hookAskInput } from "./hook-ask-input"

export function SessionPermissionDock(props: {
request: PermissionRequest
Expand All @@ -12,13 +13,24 @@ export function SessionPermissionDock(props: {
}) {
const language = useLanguage()

const hookReason = () => {
const reason = props.request.metadata?.reason
return props.request.metadata?.hookAsk === true && typeof reason === "string" ? reason : ""
}

const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}

// A hook-forced ask confirms the effective input; its pattern is only the tool name.
const shown = () => {
const input = hookAskInput(props.request)
return input ? [input] : props.request.patterns
}

return (
<DockPrompt
kind="permission"
Expand All @@ -37,34 +49,43 @@ export function SessionPermissionDock(props: {
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Show when={props.request.always.length > 0}>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
</Show>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
}
>
<Show when={hookReason()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{hookReason()}</div>
</div>
</Show>

<Show when={toolDescription()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
</div>
</Show>

<Show when={props.request.patterns.length > 0}>
<Show when={shown().length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={props.request.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
<For each={shown()}>
{(pattern) => <code class="text-12-regular text-text-base break-all whitespace-pre-wrap">{pattern}</code>}
</For>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
}),
reasoningDistillation: ConfigReasoningDistillation.Info.pipe(Schema.optional).annotate({
description:
"Reasoning distillation of returned model thoughts (default-off; enable in opencode.json; any rewrite is gated by per-provider compatibility evidence)",
"Reasoning distillation of returned model thoughts (default-off; enable in opencode.json; signed, encrypted or unknown provider reasoning is never rewritten; `language` selects zh (default) or en)",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
description: "Additional paths or URLs to discover skills from",
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/config/reasoning-distillation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,24 @@ export class Compatibility extends Schema.Class<Compatibility>("ConfigV2.Reasoni
upstreamVerified: Schema.Boolean,
}) {}

export const Language = Schema.Literals(["zh", "en"])
export type Language = typeof Language.Type

/**
* Reasoning-distillation switch (default-off; explicit opt-in still requires compatibility authorization).
* The feature only ever rewrites a reasoning slot that carries a §2.1 dual-evidence compatibility record; absent that
* record every slot is P5-protected, so enabling the switch is safe and never rewrites an unproven provider.
* Reasoning-distillation switch (default-off). When enabled, settled canonical reasoning without signed, encrypted
* or unknown provider carriers is organized once per completed turn and the result replaces the stored text.
* `compatibility` records only gate the legacy native-wire projector; canonical adoption does not consult them.
*/
export class Info extends Schema.Class<Info>("ConfigV2.ReasoningDistillation")({
enabled: Schema.Boolean.pipe(Schema.optional),
/** Language of the organized prose; literals such as paths and commands stay verbatim. Defaults to `zh`. */
language: Language.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.Array, Schema.optional),
}) {}

/** Organizer language; Chinese by default because it is shorter for the same content. */
export const resolveLanguage = (info: Pick<Info, "language"> | undefined): Language => info?.language ?? "zh"

export type EnableSource = "environment" | "config" | "default"

export type EnableResolution = Readonly<{
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/plugin/command/orchestration-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ cancellable limit. A read or cancelled wait does not consume messages; delivery
means inclusion in a model input snapshot. An ordinary message does not require
interrupting the active turn or polling merely to wait.

A node that needs clarification or a decision asks its owning parent through
`agent`; nodes do not ask the user. When a node asks you, reply to its message
with a conclusion you can stand behind, grounded in the user's instructions or
evidence you checked. If you cannot reach one, state what remains uncertain and
what the node may safely do, or ask the user before replying. Never present a
guess as settled.

Agent messages are supplied context with their recorded source, not human user
authorization or a workflow-control operation. Preserve permissions, budgets,
checkpoint decisions, output schemas, and review fingerprints. If a message
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/plugin/skill/configure-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,18 @@ project-controlled PATH are unavailable. Use the three file tools on Windows.

`continue: false` stops prompt admission and wins over a Stop hook's request to
continue. Permission hooks can reject using `permissionDecision: "deny"`, a block
decision, or exit code 2. Post-tool block reasons and contexts are appended to
decision, or exit code 2.

A PreToolUse `permissionDecision: "ask"` is a forced ask: the user always
confirms it, even after an earlier "always" approval, and the dialog shows the
input the tool will run with, including any `updatedInput`, plus the hook's
`permissionDecisionReason`. A PermissionRequest hook can deny a forced ask, but
its `allow` is ignored (logged). Once confirmed, the tool's own ask-level
permission checks for that call do not prompt again; deny rules still apply.
`opencode run --dangerously-skip-permissions` is an automatic mode and approves
forced asks once; without it, `run` rejects them.

Post-tool block reasons and contexts are appended to
model-facing tool feedback after execution; they cannot undo a completed write.
`FileChanged` carries an absolute path and `add`, `change` or `delete`; patch
renames report both the removed path and the added destination.
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/session/reasoning-distillation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export {
} from "./plan"
export { ReasoningDistillationPolicy } from "./policy"
export { compactCandidateForReview } from "./review"
export { organizeReasoning, NO_USEFUL_REASONING, ORGANIZE_INSTRUCTION } from "./organize"
export type { OrganizeCall, OrganizeResult, OrganizeSlot } from "./organize"
export { organizeReasoning, NO_USEFUL_REASONING, ORGANIZE_INSTRUCTION, ORGANIZE_INSTRUCTIONS } from "./organize"
export type { OrganizeCall, OrganizeLanguage, OrganizeResult, OrganizeSlot } from "./organize"
export { projectDistillationRequest } from "./projection"
export { capabilityFingerprint, classifySlotEligibility } from "./slot"
export type { CompatibilityRecord, SlotAssessment, SlotCapability } from "./slot"
Expand Down
Loading
Loading