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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-forge",
"version": "0.4.21",
"version": "0.5.0",
"type": "module",
"oc-plugin": [
"server",
Expand Down
21 changes: 21 additions & 0 deletions src/client/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ export type WorkspaceCreateResult = NonNullable<Awaited<ReturnType<V2['experimen
export type WorkspaceList = NonNullable<Awaited<ReturnType<V2['experimental']['workspace']['list']>>['data']>
export type WorkspaceStatus = NonNullable<Awaited<ReturnType<V2['experimental']['workspace']['status']>>['data']>

// ── Session list types (experimental) ───────────────────────────────────────
// `list` lives under `experimental.session` in the SDK; the port hides that
// placement so callers use a single `session` namespace.
export type SessionListParams = NonNullable<Parameters<V2['experimental']['session']['list']>[0]>
export type SessionList = NonNullable<Awaited<ReturnType<V2['experimental']['session']['list']>>['data']>

// ── Project param/result types ───────────────────────────────────────────────
export type ProjectListParams = NonNullable<Parameters<V2['project']['list']>[0]>
export type ProjectList = NonNullable<Awaited<ReturnType<V2['project']['list']>>['data']>

// ── Provider param/result types ──────────────────────────────────────────────
export type ProviderListParams = NonNullable<Parameters<V2['provider']['list']>[0]>
export type ProviderList = NonNullable<Awaited<ReturnType<V2['provider']['list']>>['data']>

// ── TUI param types ──────────────────────────────────────────────────────────
export type TuiPublishParams = NonNullable<Parameters<V2['tui']['publish']>[0]>
export type TuiSelectSessionParams = NonNullable<Parameters<V2['tui']['selectSession']>[0]>
Expand Down Expand Up @@ -71,6 +85,7 @@ export interface ForgeClient {
update(params: SessionUpdateParams): Promise<void>
messages(params: SessionMessagesParams): Promise<SessionMessages>
status(params?: SessionStatusParams): Promise<SessionStatus>
list(params?: SessionListParams): Promise<SessionList>
promptAsync(params: SessionPromptAsyncParams): Promise<void>
abort(params: SessionAbortParams): Promise<void>
delete(params: SessionDeleteParams): Promise<void>
Expand All @@ -83,6 +98,12 @@ export interface ForgeClient {
remove(params: WorkspaceRemoveParams): Promise<void>
warp(params: WorkspaceWarpParams): Promise<void>
}
project: {
list(params?: ProjectListParams): Promise<ProjectList>
}
provider: {
list(params?: ProviderListParams): Promise<ProviderList>
}
tui: {
publish(params: TuiPublishParams): Promise<void>
selectSession(params: TuiSelectSessionParams): Promise<void>
Expand Down
25 changes: 24 additions & 1 deletion src/client/sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ export function createForgeClient(v2: OpencodeClient): ForgeClient {
update: (params) => withVoid('session.update', v2.session.update(params)),
messages: (params) => withData('session.messages', v2.session.messages(params)),
status: (params) => withData('session.status', v2.session.status(params)),
// `list` is exposed by the SDK under `experimental.session`; guard for
// hosts that do not provide it so callers get a classified error.
list: (params) => {
const expSession = v2.experimental?.session
if (!expSession || typeof expSession.list !== 'function') {
return Promise.reject(new ForgeClientError({
kind: 'unavailable',
method: 'session.list',
message: 'experimental.session.list not available on this host',
}))
}
return withData('session.list', expSession.list(params))
},
promptAsync: (params) => withVoid('session.promptAsync', v2.session.promptAsync(params)),
abort: (params) => withVoid('session.abort', v2.session.abort(params)),
delete: (params) => withVoid('session.delete', v2.session.delete(params)),
Expand Down Expand Up @@ -126,6 +139,16 @@ export function createForgeClient(v2: OpencodeClient): ForgeClient {
warp: (params) => guardWs('warp', () => withVoid('workspace.warp', wsApi!.warp(params))),
}

// ── project namespace ─────────────────────────────────────────────────────
const project: ForgeClient['project'] = {
list: (params) => withData('project.list', v2.project.list(params)),
}

// ── provider namespace ────────────────────────────────────────────────────
const provider: ForgeClient['provider'] = {
list: (params) => withData('provider.list', v2.provider.list(params)),
}

// ── tui namespace ────────────────────────────────────────────────────────
const tui: ForgeClient['tui'] = {
publish: async (params) => {
Expand All @@ -152,7 +175,7 @@ export function createForgeClient(v2: OpencodeClient): ForgeClient {
},
}

return { session, workspace, tui, sync }
return { session, workspace, project, provider, tui, sync }
}

// ── Combined factory ─────────────────────────────────────────────────────────
Expand Down
98 changes: 37 additions & 61 deletions src/loop/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { ReviewFindingsRepo, ReviewFindingRow } from '../storage/repos/revi
import type { SectionPlansRepo, SectionPlanRow } from '../storage/repos/section-plans-repo'
import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo'
import { createLoopWatchdog, type LoopWatchdogStallInfo, type LoopWatchdogRecoveryContext } from '../hooks/watchdog'
import { retryWithModelFallback } from '../utils/model-fallback'
import { sendLoopPrompt } from './send-loop-prompt'
import { resolveLoopModel, resolveLoopAuditorModel } from '../utils/loop-helpers'
import type { createSandboxManager } from '../sandbox/manager'
// worktree-completion imports moved to hooks/loop.ts (termination side-effects)
Expand Down Expand Up @@ -195,73 +195,49 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {

if (agent === 'auditor-loop') {
const auditorModel = input.model != null ? input.model : undefined
const { result, usedModel } = await sendLoopPrompt({
loopName, sessionId, agent: 'auditor-loop', logger,
primaryModel: auditorModel,
performPrompt: async (model) => {
const freshState = loopService.getActiveState(loopName)
if (!freshState?.active) throw new Error('loop_cancelled')
markPromptSent(loopName, sessionId, logger)
const r = await promptAuditSession(client, {
sessionId,
worktreeDir: freshState.worktreeDir,
workspaceId: freshState.workspaceId,
prompt: promptText,
...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}),
})
return r.ok ? {} : { error: r.error }
},
})
return { error: result.error, usedModel }
}

const sendFn = async (model?: { providerID: string; modelID: string }) => {
const effectiveModel = input.model != null ? input.model : resolveLoopModel(getConfig(), loopService, loopName)
const { result, usedModel } = await sendLoopPrompt({
loopName, sessionId, agent: 'code', logger,
primaryModel: effectiveModel,
performPrompt: async (model) => {
const freshState = loopService.getActiveState(loopName)
if (!freshState?.active) throw new Error('loop_cancelled')
markPromptSent(loopName, sessionId, logger)
try {
return await withInFlightGuard(loopName, sessionId, 'auditor-loop', logger, async () => {
markPromptSent(loopName, sessionId, logger)
const result = await promptAuditSession(client, {
sessionId,
worktreeDir: freshState.worktreeDir,
workspaceId: freshState.workspaceId,
prompt: promptText,
...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}),
})
return result.ok ? { data: true } : { error: result.error }
await client.session.promptAsync({
sessionID: sessionId,
directory: freshState.worktreeDir,
...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}),
agent: 'code',
parts: [{ type: 'text' as const, text: promptText }],
...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}),
})
return {}
} catch (err) {
if (err instanceof ConcurrentPromptError) return { error: err }
throw err
}
}

const { result, usedModel } = await retryWithModelFallback(() => sendFn(auditorModel), () => sendFn(undefined), auditorModel, logger)
if (result.error) {
if (result.error instanceof ConcurrentPromptError) {
return { error: result.error, usedModel }
return { error: err }
}
clearPromptPending(loopName, logger)
}
return { error: result.error, usedModel }
}

const effectiveModel = input.model != null ? input.model : resolveLoopModel(getConfig(), loopService, loopName)

const sendFn = async (model?: { providerID: string; modelID: string }) => {
const freshState = loopService.getActiveState(loopName)
if (!freshState?.active) throw new Error('loop_cancelled')
try {
return await withInFlightGuard(loopName, sessionId, 'code', logger, async () => {
markPromptSent(loopName, sessionId, logger)
try {
await client.session.promptAsync({
sessionID: sessionId,
directory: freshState.worktreeDir,
...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}),
agent: 'code',
parts: [{ type: 'text' as const, text: promptText }],
...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}),
})
return { data: true }
} catch (err) {
return { error: err }
}
})
} catch (err) {
if (err instanceof ConcurrentPromptError) return { error: err }
throw err
}
}

const { result, usedModel } = await retryWithModelFallback(() => sendFn(effectiveModel), () => sendFn(undefined), effectiveModel, logger)
if (result.error) {
if (result.error instanceof ConcurrentPromptError) {
return { error: result.error, usedModel }
}
clearPromptPending(loopName, logger)
}
},
})
return { error: result.error, usedModel }
}

Expand Down
57 changes: 57 additions & 0 deletions src/loop/send-loop-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Logger } from '../types'
import { retryWithModelFallback } from '../utils/model-fallback'
import { clearPromptPending } from './idle-gate'
import { withInFlightGuard, ConcurrentPromptError, type PromptAgent } from './in-flight-guard'

export interface SendLoopPromptOptions {
loopName: string
sessionId: string
agent: PromptAgent
logger: Logger
primaryModel?: { providerID: string; modelID: string } | null
/** Performs ONE provider call for the given model (caller owns markPromptSent +
* the promptAsync/promptAuditSession call). Returns {} on success, {error} on failure. */
performPrompt: (model: { providerID: string; modelID: string } | undefined) => Promise<{ error?: unknown }>
/** Wrap each attempt in withInFlightGuard. Default true. (attach path passes false.) */
useInFlightGuard?: boolean
/** Call clearPromptPending on non-concurrent error. Default true. (restart passes false; it clears once after its transient-retry loop.) */
clearPendingOnError?: boolean
}

export interface SendLoopPromptResult {
result: { error?: unknown }
usedModel?: { providerID: string; modelID: string } | undefined
}

/** Single source of truth for "send a loop prompt with model fallback + in-flight guard". */
export async function sendLoopPrompt(opts: SendLoopPromptOptions): Promise<SendLoopPromptResult> {
const { loopName, sessionId, agent, logger, performPrompt } = opts
const useGuard = opts.useInFlightGuard !== false
const clearOnError = opts.clearPendingOnError !== false
const primary = opts.primaryModel ?? undefined

const attempt = async (
model: { providerID: string; modelID: string } | undefined,
): Promise<{ error?: unknown }> => {
if (!useGuard) return performPrompt(model)
try {
return await withInFlightGuard(loopName, sessionId, agent, logger, () => performPrompt(model))
} catch (err) {
if (err instanceof ConcurrentPromptError) return { error: err }
throw err
}
}

const { result, usedModel } = await retryWithModelFallback(
() => attempt(primary),
() => attempt(undefined),
primary,
logger,
)

if (result.error && !(result.error instanceof ConcurrentPromptError) && clearOnError) {
clearPromptPending(loopName, logger)
}

return { result, usedModel }
}
Loading