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
78 changes: 78 additions & 0 deletions .agents/plans/07-reviewer-picker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Choose the reviewer inside OpenCode

The 8.4.0 candidate adds an optional TUI module and native /flow-reviewer command.
This is a feature PR, not a published release. Installed 8.3.1 remains unchanged.
No paid evals or canaries are authorized.

## User flow

Open /flow-reviewer or choose “Flow: Choose reviewer model” from the command
palette. Search connected providers' text/tool-capable models. Deprecated models
are omitted. Availability here means configured catalog availability; no paid
probe establishes actual provider execution.

Choose a model, then confirm the global save and server reload. Current-project
busy sessions block saving. The confirmation tells the user to finish other
projects first because OpenCode's global config API disposes server instances.
The picker does not claim it can atomically rule out work starting elsewhere.
Cancellation makes no write. Choosing the already-selected value closes the picker.
A changed project, disconnected model, changed preference, or disposed plugin
prevents the pending save. API errors surface without claiming success.

## Stored preference and precedence

The picker patches only agent.flow-reviewer.options.flowReviewerModel through
OpenCode's global configuration API. It never writes the returned plugin list,
providers, credentials, or the manager's model. A project value for this same
preference overrides the global value through OpenCode's normal config merge.
There is no project-write UI in this first version.

A nonempty picker preference takes priority over reviewer.model tuple options
and OPENCODE_FLOW_REVIEWER_MODEL. It uses default model reasoning and retains
configured reviewer steps. An empty preference (“Use default”) restores the
existing tuple/environment model and variant. It does not delete or materialize
those settings. Project tuple settings alone do not override a nonempty picker
preference; use an empty project preference to restore tuple/environment behavior.

Flow consumes the preference during config application and reports its effective
requested source as picker in /flow-status. It is removed from the generated
agent options, so it is not forwarded as a provider option. OpenCode still owns
persistence, configuration layering, and reload behavior. No Session v5 fields or
new lifecycle tools are added.

## Packaging and host boundary

The package retains its server entry point and adds ./tui with its own compiled
module and declaration. OpenCode's installer detects both targets; reinstalling
with --force registers the TUI entry. Manual users must list the package in their
tui.json as well as their server config on OpenCode 1.18.6.

Only the existing SDK seam imports OpenCode types. The optional TUI implementation
has an 8 KiB source allowance; server-side preference resolution adds 1 KiB to the
existing config/status allowance. Runtime workflow code is otherwise unchanged.

Sources inspected for the pinned host:
- https://github.com/anomalyco/opencode/blob/v1.18.6/packages/opencode/specs/tui-plugins.md
- https://github.com/anomalyco/opencode/blob/v1.18.6/packages/plugin/src/tui.ts
- https://github.com/anomalyco/opencode/blob/v1.18.6/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts

## Verification

Unit tests cover connected-model filtering, minimal preference patches, precedence
and reset, cancellation, confirmed saves, busy sessions, stale preferences, removed
models, and server config propagation. Existing surface and package checks pass.

Pinned OpenCode 1.18.6 rendered the real native picker and confirmation in an
isolated PTY using a synthetic catalog and a disabled write transport. This proves
UI registration and rendering; it is not a real provider or billing test. Early
probe attempts hit an old PATH executable and then a syntax error in the temporary
wrapper; the corrected pinned-host probe rendered both dialogs. Terminal capture
is retained outside Git in the temporary flow-picker-ui workspace.

The provider-free host smoke uses the real global PATCH API against an isolated
candidate installation. It verifies selected model/default reasoning, retained
steps, restoration of the original model/variant, unchanged unrelated environment
references in the config file, and no .flow state creation. No prompt is submitted.

The 8.3.1 baseline patch record cannot qualify this new public UI and runtime
configuration behavior. No 8.4.0 tag or publication is part of this implementation.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

One short entry per release, written for users deciding whether to upgrade.

## [8.4.0] - 2026-09-14

Choose Flow's global reviewer model from OpenCode's terminal UI.

- `/flow-reviewer` opens a searchable picker of connected text/tool-capable models.
“Use default” restores plugin/environment settings. Selection makes no model call.
- Confirm before saving global settings and reloading OpenCode. Active work in the
current project blocks saving; finish work in other projects first. Project-level
preferences override the global choice. Picker selections use default reasoning;
resetting restores saved plugin/environment options without rewriting them.
- Add the optional `./tui` package entry point. Reinstall with
`opencode plugin opencode-plugin-flow@8.4.0 --global --force` to register it.
- Session v5 schema and Flow lifecycle tools are unchanged. This feature requires
its own release qualification; the previous patch declaration does not apply.

## [8.3.1] - 2026-09-14

Clarify review handoffs and make release operations recoverable.
Expand Down
43 changes: 20 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,46 +40,43 @@ expensive, and it is overhead when it is not.
Install the exact npm release through OpenCode:

```bash
opencode plugin opencode-plugin-flow@8.3.1 --global --force
opencode plugin opencode-plugin-flow@8.4.0 --global --force
```

Omit `--global` for project scope. Version pins are exact and never update on
their own; to update, rerun the command with the new version.

The equivalent manual project configuration is:
Manual setup needs this entry in both `opencode.json` and `tui.json`:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-plugin-flow@8.3.1"]
"plugin": ["opencode-plugin-flow@8.4.0"]
Comment thread
vriesd marked this conversation as resolved.
}
```

For an explicit reviewer model, use OpenCode's plugin tuple options:
Run `/flow-reviewer` for the global reviewer picker. Saving reloads the server;
finish other projects' work first. Project picker preferences take precedence.
“Use default” restores plugin/environment settings. No model call.

`opencode.json` reviewer options:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"opencode-plugin-flow@8.3.1",
{ "reviewer": { "model": "provider/model", "steps": 80 } }
]
]
"plugin": [["opencode-plugin-flow@8.4.0", {
"reviewer": { "model": "provider/model", "steps": 80 }
}]]
}
```

The tuple values take precedence over `OPENCODE_FLOW_REVIEWER_MODEL` and
`OPENCODE_FLOW_REVIEWER_STEPS`. Optional `reviewer.variant` selects a native
model variant and overrides `OPENCODE_FLOW_REVIEWER_VARIANT`. It requires an
explicit reviewer model. `/flow-status` reports the requested settings and
unsupported combinations. Only a successful reviewer run confirms availability.

Restart OpenCode after changing configuration. OpenCode owns installation and
configuration; see its
[plugin documentation](https://opencode.ai/docs/plugins/). Flow has no installer
or activation CLI, and removing the plugin entry disables it. If two Flow copies
load for one project, both fail closed until the duplicate is removed.
Picker preferences take priority. Otherwise tuple settings override
`OPENCODE_FLOW_REVIEWER_MODEL`,
`OPENCODE_FLOW_REVIEWER_STEPS`, and `OPENCODE_FLOW_REVIEWER_VARIANT`.
`reviewer.variant` requires an explicit model. `/flow-status` shows requested
settings; only successful reviews confirm availability.

[OpenCode](https://opencode.ai/docs/plugins/) owns installation and configuration.
Restart after manual changes. Remove the plugin entry to disable Flow; duplicate
copies fail closed until one remains.

**Changing versions.** Finish or explicitly close any active session first, in
either direction. Flow opens only Session v5 active state, and an older build
Expand Down
4 changes: 4 additions & 0 deletions docs/maintainer-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,7 @@ host-visible configuration without adding a scheduler or tests-of-tests.

See [Model-driven wave evidence](development.md#model-driven-wave-evidence) for
the manual canary policy.


The optional `./tui` export adds native `/flow-reviewer` selection of the global
reviewer. Saving requires confirmation. No server command or lifecycle tool is added.
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-plugin-flow",
"version": "8.3.1",
"version": "8.4.0",
"description": "Small durable planning, validation, and review workflow for OpenCode",
"type": "module",
"repository": {
Expand All @@ -10,13 +10,20 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
"./tui": {
"types": "./dist/tui.d.ts",
"import": "./dist/tui.js"
},
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist/tui.js",
"dist/tui.js.map",
"dist/tui.d.ts",
"dist/index.js",
"dist/index.js.map",
"dist/index.d.ts",
Expand All @@ -27,8 +34,9 @@
"CHANGELOG.md"
],
"scripts": {
"build": "bun run clean && bun run build:plugin && bun run build:types",
"build": "bun run clean && bun run build:plugin && bun run build:tui && bun run build:types",
"build:plugin": "bun build --target=node --outdir=./dist --entry-naming=index.js --external=@opencode-ai/plugin --external=zod --sourcemap=external ./src/index.ts",
"build:tui": "bun build --target=bun --outdir=./dist --entry-naming=tui.js --sourcemap=external ./src/tui.ts",
"build:types": "tsc -p tsconfig.types.json && bun run scripts/prune-dist-declarations.ts",
"clean": "bun run scripts/clean-dist.ts",
"eval": "bun run evals/run.ts",
Expand Down
4 changes: 4 additions & 0 deletions scripts/lib/package-surface.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
export const PUBLIC_DECLARATION_PATHS = [
"dist/tui.d.ts",
"dist/index.d.ts",
"dist/platform/opencode/plugin.d.ts",
"dist/platform/opencode/sdk.d.ts",
] as const;

export const PACKED_PACKAGE_PATHS = [
"dist/tui.js",
"dist/tui.js.map",
"dist/tui.d.ts",
"CHANGELOG.md",
"LICENSE",
"README.md",
Expand Down
2 changes: 1 addition & 1 deletion src/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type MutableFlowConfig = {
};

type FlowEnvironment = Readonly<Record<string, string | undefined>>;
type ReviewerSettingSource = "plugin-option" | "environment";
type ReviewerSettingSource = "plugin-option" | "environment" | "picker";

type FlowPluginOptions = Readonly<{
reviewer?: unknown;
Expand Down
18 changes: 15 additions & 3 deletions src/platform/opencode/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,38 @@ import {
type MutableFlowConfig,
} from "../../config-shared.js";
import { createFlowLog } from "./logging.js";
import {
applyReviewerPreference,
reviewerPreference,
} from "./reviewer-picker.js";

export function createConfigHook(
ctx: unknown,
options?: {
assertOperational?: (action: string) => void;
reviewerConfiguration?: FlowReviewerConfiguration | undefined;
onReviewerConfiguration?: (
configuration: FlowReviewerConfiguration,
) => void;
},
) {
const log = createFlowLog(ctx);
let preference: string | undefined;
return async (config: MutableFlowConfig) => {
try {
options?.assertOperational?.("apply its OpenCode configuration");
} catch (error) {
log("error", error instanceof Error ? error.message : String(error));
return;
}
const saved = reviewerPreference(config);
if (saved !== undefined) preference = saved;
const reviewer = options?.reviewerConfiguration
? applyReviewerPreference(options.reviewerConfiguration, preference)
: undefined;
if (reviewer) options?.onReviewerConfiguration?.(reviewer);
applyFlowConfig(config, {
...(options?.reviewerConfiguration
? { reviewerConfiguration: options.reviewerConfiguration }
: {}),
...(reviewer ? { reviewerConfiguration: reviewer } : {}),
onWarning: (warning) => log("warn", warning),
onNotice: (notice) => log("info", notice),
onCollision: (kind, name) =>
Expand Down
12 changes: 6 additions & 6 deletions src/platform/opencode/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,7 @@ type CommandHook = NonNullable<Hooks["command.execute.before"]>;
type CommandOutput = Parameters<CommandHook>[1];
type Part = CommandOutput["parts"][number];
type TextPart = Extract<Part, { type: "text" }>;
/**
* A text part as the plugin writes one: the host assigns id, sessionID, and
* messageID only after the command hook returns.
*/
// Host assigns id, sessionID and messageID after the command hook returns.
type DraftTextPart = Omit<TextPart, "id" | "sessionID" | "messageID">;
const MUTATION =
/^flow_(?:plan_save|plan_approve|run_start|review_start|feature_complete|feature_reset|session_close)$/;
Expand Down Expand Up @@ -260,7 +257,7 @@ function guardTools(

const FlowPlugin: Plugin = async (ctx, pluginOptions) => {
const log = createFlowLog(ctx);
const reviewerConfiguration = resolveFlowReviewerConfiguration({
let reviewerConfiguration = resolveFlowReviewerConfiguration({
pluginOptions,
onWarning: (warning) => log("warn", warning),
});
Expand Down Expand Up @@ -323,13 +320,16 @@ const FlowPlugin: Plugin = async (ctx, pluginOptions) => {
prepareValidation: prepareWorkspaceValidation,
autoTimingSnapshot: () => autoDrive.timingSnapshot(),
autoContinuationSupport: () => autoDrive.continuationSupport(),
reviewerConfiguration,
readReviewerConfiguration: () => reviewerConfiguration,
runtimeIdentity: { packageVersion: version, pluginEntrySha256 },
});
return {
config: createConfigHook(ctx, {
assertOperational: (action) => runtimeGuard.assertOperational(action),
reviewerConfiguration,
onReviewerConfiguration: (configuration) => {
reviewerConfiguration = configuration;
},
}),
tool: guardTools(tools, runtimeGuard, autoDrive),
"command.execute.before": createCommandHook(
Expand Down
69 changes: 69 additions & 0 deletions src/platform/opencode/reviewer-picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { FlowReviewerConfiguration } from "../../config-shared.js";
import type { Provider } from "./sdk.js";

export function reviewerChoices(
providers: readonly Provider[],
connected: readonly string[],
) {
const enabled = new Set(connected);
return providers
.filter((provider) => enabled.has(provider.id))
.flatMap((provider) =>
Object.values(provider.models)
.filter(
(model) =>
model.capabilities.toolcall &&
model.capabilities.input.text &&
model.capabilities.output.text &&
model.status !== "deprecated",
)
.map((model) => ({
title: model.name,
value: `${provider.id}/${model.id}`,
category: provider.name,
description: `${provider.id}/${model.id}`,
})),
)
.sort(
(a, b) =>
a.category.localeCompare(b.category) ||
a.title.localeCompare(b.title) ||
a.value.localeCompare(b.value),
);
}

export function reviewerPreference(config: {
agent?: Record<string, unknown>;
}): string | undefined {
const agent = config.agent?.["flow-reviewer"];
if (!agent || typeof agent !== "object" || !("options" in agent))
return undefined;
const options = agent.options;
if (
!options ||
typeof options !== "object" ||
!("flowReviewerModel" in options)
)
return undefined;
if (typeof options.flowReviewerModel !== "string")
throw new Error("Flow reviewer preference must be a string.");
return options.flowReviewerModel.trim();
}

export function reviewerPreferencePatch(model: string) {
return {
agent: { "flow-reviewer": { options: { flowReviewerModel: model } } },
};
}

export function applyReviewerPreference(
base: FlowReviewerConfiguration,
model: string | undefined,
): FlowReviewerConfiguration {
return model
? {
model: { kind: "explicit", source: "picker", value: model },
steps: base.steps,
}
: base;
}
3 changes: 3 additions & 0 deletions src/platform/opencode/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ export type {
ToolResult,
} from "@opencode-ai/plugin";
export { tool } from "@opencode-ai/plugin";

export type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
export type { Config as HostConfig, Provider } from "@opencode-ai/sdk/v2";
Loading