Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ After the architect presents a summary, the user chooses an execution mode from
| `Execute here` | When preserving current context matters |
| `Loop` | Safer autonomous iteration |

The dialog also lets you pick the execution model and auditor model at launch time. Those selections are remembered per project and pre-filled on later launches.
The dialog also lets you pick the execution model and auditor model at launch time. Those selections are remembered per project and pre-filled on later launches. Optional **variant selectors** accompany each model selector, letting you choose provider-specific reasoning or thinking-effort levels (e.g., `low`, `high`, `max`) when the model exposes them. Variant selections are also persisted per project.

Execution is immediate — there are no additional LLM calls between approval and execution. The system intercepts the user's approval answer, reads the cached plan, and dispatches it programmatically to the code agent. The architect never processes the approval response.

Expand Down
14 changes: 14 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,16 @@ Two model selectors are available:
- Same model selection interface
- Defaults to last-used selection, falling back to `config.auditorModel` → `config.executionModel`

#### Variant Selection

Each model selector has a companion **variant selector** that lets you choose a provider-specific reasoning or thinking-effort level. Variants are optional and provider/model-dependent — they appear only when the selected model exposes them via OpenCode model metadata.

- Variants are provided by OpenCode model metadata. Examples include reasoning/thinking effort levels such as `low`, `high`, or `max` when exposed by the provider.
- **Use default** leaves OpenCode, the agent, and the model defaults in control.
- **Execution variants** affect code prompts for all launch modes (`New session`, `Execute here`, and `Loop`).
- **Auditor variants** affect loop audit and final-audit prompts.
- Variants are persisted per-project alongside model selections (same 30-day TTL).

#### Persistence

Your selections are automatically saved in `tui_preferences` after launch:
Expand Down Expand Up @@ -478,6 +488,10 @@ Model selection follows this priority order:
3. `config.executionModel`
4. Platform default

**For variants:**
1. Dialog selection persisted per project
2. OpenCode/agent/model default

### Troubleshooting

- **No plan found** — Ensure the architect output included the forge plan markers, or open the Plan Viewer for the current session.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The TUI plugin provides a sidebar widget that displays:

- Active and recent loops
- Plan viewer with inline editing (view/edit/execute/export tabs)
- Execution dialog with mode and model selection
- Execution dialog with mode, model, and variant selection
- Loop details dialog with session statistics
- Command palette integration (`Forge: Show loops`, `Forge: View plan`, `Forge: Execute plan`)
- Model selection dialog with recent model tracking
Expand Down
2 changes: 1 addition & 1 deletion docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The TUI plugin providing sidebar widget and dialog system. Communicates with the

- Exports `{ id: 'oc-forge', tui }`
- Registers commands: `forge.plan.view`, `forge.plan.load`
- Provides plan viewer, execution dialog, loop details, model selection
- Provides plan viewer, execution dialog, loop details, model and variant selection

Source: [src/tui.tsx](../src/tui.tsx)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-forge",
"version": "0.4.5",
"version": "0.4.6",
"type": "module",
"oc-plugin": [
"server",
Expand Down
4 changes: 2 additions & 2 deletions src/agents/architect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The plugin auto-captures marked plans from your assistant responses into SQL sto
- Start with a short unmarked summary containing **Intention**, **Goal**, and **Approach**. Keep it brief: 1-3 sentences for intention/goal and 2-4 bullets for approach.
- After the summary, wrap exactly one final plan with \`<!-- forge-plan:start -->\` and \`<!-- forge-plan:end -->\` markers (each on its own line)
- Do NOT wrap only summaries, design options, or partial drafts
- The marked plan body must follow the existing detailed plan format: Objective, Loop Name, Phases with file targets/edits/acceptance criteria/verification, Decisions, Conventions, Key Context
- The marked plan body must follow the existing detailed plan format: Objective, a machine-readable \`Loop Name: short-slug\` line, Phases with file targets/edits/acceptance criteria/verification, Decisions, Conventions, Key Context
- The marked plan must be extremely detailed and execution-ready: name exact files, exact symbols/functions/types to change, concrete data shapes, command wiring, expected control flow, error handling, and validation steps
- Every phase must include explicit implementation instructions, precise edits per file, acceptance criteria, and targeted verification commands or assertions the code agent can run
4. **Approve** — After the marked plan is output and auto-captured, call the question tool to get explicit approval with these options:
Expand All @@ -76,7 +76,7 @@ The plugin auto-captures marked plans from your assistant responses into SQL sto

Present plans with:
- **Objective**: What we're building and why
- **Loop Name**: A short, machine-friendly name (1-3 words) that captures the plan's main intent. This will be used for worktree/session naming. Example: "Loop Name: auth-refactor" or "Loop Name: api-validation"
- **Loop Name**: A short, machine-friendly name (1-3 words) that captures the plan's main intent. This will be used for worktree/session naming. Emit it as a plain machine-readable line, not a markdown heading or bullet: \`Loop Name: auth-refactor\` or \`Loop Name: api-validation\`. Place it near the top of the marked plan immediately after the objective.
- **Phases**: Ordered implementation steps. Use exactly one \`<!-- forge-section -->\` marker per executable phase. Place it immediately before that phase's \`## Phase ...\` heading. Never place it before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\` — those are subsections inside the current phase. For every phase, specify the exact files affected, the precise code-level edits to make, sample change examples (such as function signature updates, new branches, or new exports), the existing symbols/modules being integrated with, concrete acceptance criteria, and phase-specific verification. Use \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, and \`### Verification\` as subsections inside each phase. Shared blocks (\`## Decisions\`, \`## Conventions\`, \`## Key Context\`) go after all sections without a preceding marker.

**Valid shape:**
Expand Down
6 changes: 5 additions & 1 deletion src/hooks/forge-session-attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ async function attachForgeSession(
title?: string
executionModel?: string
auditorModel?: string
executionVariant?: string
auditorVariant?: string
planSource?: 'stored' | 'inline'
planText?: string
initialPromptOwner?: 'server' | 'tui'
Expand Down Expand Up @@ -270,6 +272,8 @@ async function attachForgeSession(
hostSessionId: resolvedHostSessionId,
executionModel: cfg.executionModel,
auditorModel: cfg.auditorModel,
executionVariant: cfg.executionVariant,
auditorVariant: cfg.auditorVariant,
maxIterations: cfg.maxIterations ?? 50,
sandboxEnabled: cfg.sandboxEnabled ?? false,
planText,
Expand All @@ -278,7 +282,7 @@ async function attachForgeSession(
startWatchdog: true,
sendInitialPrompt,
},
)
)
if (!result.ok && result.code === 'conflict') {
const row = deps.execDeps.loopsRepo.get(sessionProjectId, loopName)
const removalAction = row?.status === 'cancelled' || row?.status === 'errored' || row?.status === 'stalled'
Expand Down
7 changes: 3 additions & 4 deletions src/hooks/plan-approval.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ToolContext } from '../tools/types'
import type { Hooks } from '@opencode-ai/plugin'
import { parseModelString, retryWithModelFallback } from '../utils/model-fallback'
import { extractPlanTitle, extractLoopNames, PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution'
import { extractPlanExecutionMetadata, PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution'
import { buildStartLoopCommand, createForgeExecutionService, type ForgeExecutionRequestContext } from '../services/execution'
import { captureLatestPlanForSession } from '../services/plan-capture'

Expand Down Expand Up @@ -96,7 +96,7 @@ const processedApprovalCalls = new WeakMap<ToolContext, Set<string>>()
const claimedApprovalPlans = new Set<string>()

export { LOOP_BLOCKED_TOOLS }
export { extractPlanTitle }
export { extractPlanTitle } from '../utils/plan-execution'

function isActiveLoopToolSession(state: { active?: boolean; sessionId?: string }, sessionID: string): boolean {
return state.active === true && state.sessionId === sessionID
Expand Down Expand Up @@ -262,7 +262,7 @@ export function createToolExecuteAfterHook(ctx: ToolContext): Hooks['tool.execut
return
}
const planText = plan.content
const title = extractPlanTitle(planText)
const { title, executionName } = extractPlanExecutionMetadata(planText)

if (matchedLabel && !claimApprovalCall(ctx, input, matchedLabel, plan.key)) {
markApprovalHandled(output, true)
Expand Down Expand Up @@ -330,7 +330,6 @@ export function createToolExecuteAfterHook(ctx: ToolContext): Hooks['tool.execut
}

if (matchedLabel === 'Loop') {
const { executionName } = extractLoopNames(planText)
const uniqueLoopName = loop.generateUniqueLoopName(executionName)

logger.log(`Plan approval: "${matchedLabel}" — scheduling dispatch IIFE for loop "${uniqueLoopName}"`)
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ READ-ONLY mode: no file edits, no destructive commands. Search and analyze only.

When emitting the final plan:
- Wrap the plan in \`<!-- forge-plan:start -->\` and \`<!-- forge-plan:end -->\` (each on its own line)
- Include one plain machine-readable \`Loop Name: short-slug\` line near the top of the marked plan, immediately after the objective. Do not emit loop name as a markdown heading or bullet.
- Use exactly one \`<!-- forge-section -->\` marker per executable phase; place it immediately before that phase's \`## Phase\` heading
- Do not insert \`<!-- forge-section -->\` before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\`
- Shared \`## Decisions\` / \`## Conventions\` / \`## Key Context\` blocks go after all sections (no preceding marker)
Expand Down
32 changes: 24 additions & 8 deletions src/loop/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
promptText: string
agent: 'code' | 'auditor-loop'
model?: { providerID: string; modelID: string } | null
variant?: string
}): Promise<{ error?: unknown; usedModel?: { providerID: string; modelID: string } | undefined }> {
const { loopName, sessionId, promptText, agent } = input

Expand All @@ -200,7 +201,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
worktreeDir: freshState.worktreeDir,
workspaceId: freshState.workspaceId,
prompt: promptText,
...(model ? { auditorModel: model } : {}),
...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}),
})
return result.ok ? { data: true } : { error: result.error }
})
Expand Down Expand Up @@ -234,7 +235,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}),
agent: 'code',
parts: [{ type: 'text' as const, text: promptText }],
...(model ? { model } : {}),
...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}),
})
})
} catch (err) {
Expand Down Expand Up @@ -282,14 +283,17 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
}>

const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null
const lastAssistant = [...messages].reverse().find((m) => m.info.role === 'assistant')

if (!lastAssistant) {
const role = lastMessage?.info.role ?? 'none'
logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${role}`)
return { text: null, error: null, lastMessageRole: role }
if (!lastMessage) {
return { text: null, error: null, lastMessageRole: 'none' }
}

if (lastMessage.info.role !== 'assistant') {
logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${lastMessage.info.role ?? 'unknown'}`)
return { text: null, error: null, lastMessageRole: lastMessage.info.role ?? 'unknown' }
}

const lastAssistant = lastMessage
if (lastAssistant.info.finish && lastAssistant.info.finish !== 'stop') {
logger.log(`Loop: assistant message in session ${sessionId} is not final yet (finish=${lastAssistant.info.finish})`)
return { text: null, error: null, lastMessageRole: `assistant:${lastAssistant.info.finish}` }
Expand Down Expand Up @@ -700,6 +704,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
promptText: continuationPrompt,
agent: 'code',
model: loopModel,
variant: currentState.executionVariant,
})

if (promptResultError) {
Expand Down Expand Up @@ -762,6 +767,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
promptText: continuationPrompt,
agent: 'code',
model: loopModel,
variant: state.executionVariant,
})
if (error) {
logger.error(`rotateToCodingAfterAuditFailure: failed to send continuation prompt`, error)
Expand Down Expand Up @@ -804,6 +810,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
promptText: recoveryPrompt,
agent: 'code',
model: resolveLoopModel(currentConfig, loopService, loopName),
variant: freshState.executionVariant,
})
if (promptResultError) {
clearPromptPending(loopName, logger)
Expand Down Expand Up @@ -1057,6 +1064,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
workspaceId?: string
prompt: string
auditorModel?: { providerID: string; modelID: string }
auditorVariant?: string
}): Promise<{ ok: true } | { ok: false; error: unknown }> {
const result = await promptAuditSession(v2Client, input)
if (result.ok) return result
Expand All @@ -1069,7 +1077,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
body: {
agent: 'auditor-loop',
parts: [{ type: 'text' as const, text: input.prompt }],
...(input.auditorModel ? { model: input.auditorModel } : {}),
...(input.auditorModel ? { model: input.auditorModel, ...(input.auditorVariant ? { variant: input.auditorVariant } : {}) } : {}),
},
})
if (legacyResult.error) return { ok: false, error: legacyResult.error }
Expand Down Expand Up @@ -1149,6 +1157,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
promptText: finalAuditPrompt,
agent: 'auditor-loop',
model: auditorModel,
variant: currentState.auditorVariant,
})

if (finalAuditPromptErr) {
Expand Down Expand Up @@ -1281,6 +1290,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
sessionId: rotatedSessionId,
promptText: continuationPrompt,
agent: 'code',
variant: currentState.executionVariant,
})
if (promptErr) {
logger.error(`Loop: failed to send continuation prompt after audit creation failure`, promptErr)
Expand Down Expand Up @@ -1315,6 +1325,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
promptText: loopService.buildAuditPrompt(currentState),
agent: 'auditor-loop',
model: auditorModel,
variant: currentState.auditorVariant,
})

if (auditPromptErr) {
Expand All @@ -1327,6 +1338,8 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
worktreeDir: currentState.worktreeDir,
workspaceId: currentState.workspaceId,
prompt: loopService.buildAuditPrompt(currentState),
auditorModel,
auditorVariant: currentState.auditorVariant,
})
if (retryResult.ok) {
logger.log(`Loop: recovered audit prompt after workspace re-bind for ${loopName}`)
Expand All @@ -1345,6 +1358,8 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
worktreeDir: fresh.worktreeDir,
workspaceId: fresh.workspaceId,
prompt: loopService.buildAuditPrompt(fresh),
auditorModel,
auditorVariant: fresh.auditorVariant,
})
if (!retry.ok) throw retry.error
})
Expand Down Expand Up @@ -1699,6 +1714,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
sessionId: newCodeSessionId,
promptText: continuationPrompt,
agent: 'code',
variant: currentState.executionVariant,
})
if (promptErr) {
logger.error(`Loop: failed to send rewind continuation prompt for ${loopName}`, promptErr)
Expand Down
4 changes: 4 additions & 0 deletions src/loop/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export function rowToLoopState(row: LoopRow, large: LoopLargeFields | null): Loo
currentSectionIndex: row.currentSectionIndex,
totalSections: row.totalSections,
finalAuditDone: row.finalAuditDone === 1,
executionVariant: row.executionVariant ?? undefined,
auditorVariant: row.auditorVariant ?? undefined,
}
}

Expand Down Expand Up @@ -156,6 +158,8 @@ export function createLoopService(
currentSectionIndex: state.currentSectionIndex,
totalSections: state.totalSections,
finalAuditDone: state.finalAuditDone ? 1 : 0,
executionVariant: state.executionVariant ?? null,
auditorVariant: state.auditorVariant ?? null,
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/loop/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ interface LoopStateBase {
currentSectionIndex: number
totalSections: number
finalAuditDone: boolean
executionVariant?: string
auditorVariant?: string
}

export interface CodingState extends LoopStateBase {
Expand Down Expand Up @@ -76,6 +78,8 @@ export function loopRowToState(row: LoopRow, large?: LoopLargeFields | null): Lo
currentSectionIndex: row.currentSectionIndex,
totalSections: row.totalSections,
finalAuditDone: row.finalAuditDone === 1,
executionVariant: row.executionVariant ?? undefined,
auditorVariant: row.auditorVariant ?? undefined,
}

switch (row.phase) {
Expand Down Expand Up @@ -117,5 +121,7 @@ export function loopStateToRow(state: LoopState, projectId: string): Omit<LoopRo
currentSectionIndex: state.currentSectionIndex,
totalSections: state.totalSections,
finalAuditDone: state.finalAuditDone ? 1 : 0,
executionVariant: state.executionVariant ?? null,
auditorVariant: state.auditorVariant ?? null,
}
}
Loading