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
8 changes: 2 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@typescript-eslint/parser": "^8.58.0",
"better-sqlite3": "^12.9.0",
"bun-types": "latest",
"bun-types": "^1.3.14",
"eslint": "^10.2.0",
"eslint-plugin-solid": "^0.14.5",
"solid-js": "^1.9.12",
Expand Down
8 changes: 3 additions & 5 deletions src/dashboard/launch.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Database } from 'bun:sqlite'
import { existsSync } from 'fs'
import { join } from 'path'
import { platform } from 'os'
import { resolveDataDir } from '../storage/database'
import { resolveForgeDbPath, openForgeDatabaseReadonly } from '../storage/database'
import { createRequestHandler } from './server'

export interface DashboardServerHandle {
Expand All @@ -23,7 +21,7 @@ const DEFAULT_MAX_ATTEMPTS = 10
export function resolveDashboardDbPath(explicit?: string): string {
if (explicit) return explicit
if (process.env.FORGE_DB) return process.env.FORGE_DB
return join(resolveDataDir(), 'forge.db')
return resolveForgeDbPath()
}

function isAddrInUse(err: unknown): boolean {
Expand All @@ -48,7 +46,7 @@ export function startDashboardServer(options: StartDashboardOptions = {}): Dashb

const basePort = options.port ?? DEFAULT_PORT
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const db = new Database(dbPath, { readonly: true })
const db = openForgeDatabaseReadonly(dbPath)
const handler = createRequestHandler(db)

for (let attempt = 0; attempt < maxAttempts; attempt++) {
Expand Down
11 changes: 2 additions & 9 deletions src/hooks/plan-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ToolContext } from '../tools/types'
import type { Hooks } from '@opencode-ai/plugin'
import { parseModelString, retryWithModelFallback } from '../utils/model-fallback'
import { extractPlanExecutionMetadata, PLAN_EXECUTION_LABELS, type PlanExecutionLabel } from '../utils/plan-execution'
import { hashPlanText } from '../utils/plan-hash'
import { buildStartLoopCommand, createForgeExecutionService, type ForgeExecutionRequestContext } from '../services/execution'

function publishPlanApprovalToast(
Expand Down Expand Up @@ -115,14 +116,6 @@ async function resolveBlockedLoopToolState(
return null
}

function hashApprovalPlan(planText: string): string {
let hash = 5381
for (let i = 0; i < planText.length; i += 1) {
hash = ((hash << 5) + hash) ^ planText.charCodeAt(i)
}
return (hash >>> 0).toString(36)
}

function claimApprovalCall(ctx: ToolContext, input: { sessionID: string }, label: string, planKey: string): boolean {
let processed = processedApprovalCalls.get(ctx)
if (!processed) {
Expand All @@ -145,7 +138,7 @@ function resolveCurrentSessionPlan(ctx: ToolContext, sessionID: string): { conte
if (!plan) return null
return {
content: plan.content,
key: hashApprovalPlan(plan.content),
key: hashPlanText(plan.content),
}
}

Expand Down
73 changes: 73 additions & 0 deletions src/hooks/plan-capture.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { ToolContext } from '../tools/types'
import { captureLatestPlanForSession, captureMarkedPlanTextForSession } from '../services/plan-capture'
import { PLAN_END_MARKER, PLAN_START_MARKER } from '../utils/marked-plan-parser'
import { PLAN_EXECUTION_LABELS } from '../utils/plan-execution'
import { hashPlanText } from '../utils/plan-hash'
import { promptAgentViaClientThenV2 } from '../utils/prompt-agent'

const MESSAGE_PART_UPDATED_EVENT = 'message.part.updated'
const MESSAGE_UPDATED_EVENT = 'message.updated'
Expand All @@ -25,6 +28,22 @@ function isMessageUpdatedEvent(event: PlanCaptureEvent): event is MessageUpdated
return event.type === MESSAGE_UPDATED_EVENT
}

const PLAN_KEY_CAP = 1000

// Caps an in-memory map so a long-lived process cannot grow it without bound;
// clearing on overflow only risks an occasional duplicate prompt.
function trackEntry<K, V>(map: Map<K, V>, key: K, value: V): void {
if (map.size > PLAN_KEY_CAP) map.clear()
map.set(key, value)
}

// Dedupes architect prompts by `${sessionID}:${planHash}`.
const promptedPlanKeys = new Set<string>()
// Plans captured by the streaming branch, keyed by `${sessionID}:${messageID}`,
// awaiting the message's completion so the (role-aware) completion handler can
// prompt the architect without re-reading the conversation.
const pendingUserPastePlans = new Map<string, string>()

export function createPlanCaptureEventHook(ctx: ToolContext) {
const { v2, input: { client }, logger, plansRepo, projectId, directory } = ctx

Expand All @@ -39,6 +58,15 @@ export function createPlanCaptureEventHook(ctx: ToolContext) {
if (!part.text.includes(PLAN_START_MARKER)) return
if (!part.text.includes(PLAN_END_MARKER)) return

// Skip capture if session has an active loop (prevents user-pasted plans
// from being captured during loops).
const loop = ctx.loop
if (loop) {
const loopName = loop.resolveLoopName(sessionID)
const state = loopName ? loop.getActiveState(loopName) : null
if (state?.active) return
}

try {
const result = captureMarkedPlanTextForSession(
{ plansRepo, projectId, directory, logger },
Expand All @@ -47,8 +75,13 @@ export function createPlanCaptureEventHook(ctx: ToolContext) {
part.messageID
)

// Stash freshly captured plans so the completion handler can prompt the
// architect once it knows the message role (this event carries no role).
if (result.status === 'captured') {
logger.log(`plan-capture: captured marked plan from message part for session ${sessionID}`)
if (part.messageID) {
trackEntry(pendingUserPastePlans, `${sessionID}:${part.messageID}`, result.planText)
}
} else if (result.status === 'invalid') {
logger.log(`plan-capture: streaming branch saw invalid plan for session ${sessionID}: ${result.reason}`)
}
Expand Down Expand Up @@ -86,6 +119,45 @@ export function createPlanCaptureEventHook(ctx: ToolContext) {
}
}

async function handleUserMessageCompleted(event: MessageUpdatedEvent) {
const sessionID = event.properties?.sessionID
const info = event.properties?.info

if (!sessionID || info?.role !== 'user' || typeof info?.time?.completed !== 'number' || !info.id) return

// The streaming branch already captured any pasted plan and stashed it under
// this message id. Now that we know the role is `user`, prompt the architect.
const stashKey = `${sessionID}:${info.id}`
const planText = pendingUserPastePlans.get(stashKey)
if (!planText) return
pendingUserPastePlans.delete(stashKey)

try {
logger.log(`plan-capture: user-pasted plan completed for session ${sessionID}, prompting architect`)
await triggerPasteApprovalQuestion(sessionID, planText)
} catch (error) {
logCaptureError(sessionID, error)
}
}

async function triggerPasteApprovalQuestion(sessionID: string, planText: string) {
const planKey = `${sessionID}:${hashPlanText(planText)}`
if (promptedPlanKeys.has(planKey)) {
logger.log(`plan-capture: already prompted for plan ${planKey}, skipping`)
return
}
if (promptedPlanKeys.size > PLAN_KEY_CAP) promptedPlanKeys.clear()
promptedPlanKeys.add(planKey)

const optionsList = PLAN_EXECUTION_LABELS.join(', ')
const prompt = `A user pasted an implementation plan into this session. The plan has already been captured. Do NOT re-plan or modify it. Immediately call the \`question\` tool exactly once to ask how to execute it, with these three options as labels: ${optionsList}. Ask only this question and take no other action.`

await promptAgentViaClientThenV2(
{ legacyClient: ctx.input?.client, v2, logger, directory: ctx.directory },
{ sessionID, agent: 'architect', prompt }
)
}

return async (eventInput: { event: PlanCaptureEvent }) => {
const event = eventInput.event
if (!event) return
Expand All @@ -97,6 +169,7 @@ export function createPlanCaptureEventHook(ctx: ToolContext) {

if (isMessageUpdatedEvent(event)) {
await handleAssistantMessageCompleted(event)
await handleUserMessageCompleted(event)
return
}
}
Expand Down
8 changes: 2 additions & 6 deletions src/services/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from '../loop/in-flight-guard'
import { getRestartability, type RestartBlockedReason } from '../loop/restartability'
import { resolveHostSessionDirectory } from '../utils/resolve-project-root'
import { hashPlanText } from '../utils/plan-hash'

// ============================================================================
// Surface Types - Identifies the caller boundary
Expand Down Expand Up @@ -1062,11 +1063,6 @@ export async function attachLoopToSession(
export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): ForgeExecutionService {

const inFlightLoopStarts = new Map<string, Promise<ForgeExecutionResponse<LoopStartedResult>>>()
function hashPlanForDedupe(text: string): string {
let h = 5381
for (let i = 0; i < text.length; i += 1) h = ((h << 5) + h) ^ text.charCodeAt(i)
return (h >>> 0).toString(36)
}

async function handlePlanNewSession(
ctx: ForgeExecutionRequestContext,
Expand Down Expand Up @@ -1236,7 +1232,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo
const uniqueLoopName = deps.loop.generateUniqueLoopName(command.loopName ?? executionName)

// In-flight dedupe: suppress concurrent starts for the same source
const dedupeKey = `${ctx.projectId}::${command.hostSessionId ?? ctx.sourceSessionId ?? ''}::${hashPlanForDedupe(planText)}`
const dedupeKey = `${ctx.projectId}::${command.hostSessionId ?? ctx.sourceSessionId ?? ''}::${hashPlanText(planText)}`
const existing = inFlightLoopStarts.get(dedupeKey)
if (existing) {
deps.logger.log(`handleStartLoop: dedupe — concurrent start suppressed for key=${dedupeKey}`)
Expand Down
15 changes: 15 additions & 0 deletions src/storage/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ export function resolveLogPath(): string {
return join(resolveDataDir(), 'logs', 'forge.log')
}

export function resolveForgeDbPath(): string {
return join(resolveDataDir(), 'forge.db')
}

/**
* Opens the Forge database read-only with a busy timeout so concurrent reads
* (e.g. from the TUI or dashboard) coexist safely with the server's writer.
* The caller owns the connection and must close it.
*/
export function openForgeDatabaseReadonly(dbPath: string = resolveForgeDbPath()): Database {
const db = new Database(dbPath, { readonly: true })
db.run('PRAGMA busy_timeout=5000')
return db
}

function runMigrations(db: Database): void {
db.run(`
CREATE TABLE IF NOT EXISTS migrations (
Expand Down
2 changes: 1 addition & 1 deletion src/storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { initializeDatabase, closeDatabase, resolveDataDir, resolveLogPath } from './database'
export { initializeDatabase, closeDatabase, resolveDataDir, resolveLogPath, resolveForgeDbPath, openForgeDatabaseReadonly } from './database'

export { createLoopsRepo } from './repos/loops-repo'
export type { LoopRow } from './repos/loops-repo'
Expand Down
16 changes: 15 additions & 1 deletion src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ExecutePlanPanel } from './tui/execute-plan-panel'
import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow'
import { openInBrowser, startDashboardServer, type DashboardServerHandle } from './dashboard/launch'
import { fetchLatestPlanForSession } from './utils/plan-from-messages'
import { readPlan } from './utils/tui-plan-store'
import { normalizePastedPlanText } from './utils/marked-plan-parser'

type TuiKeybinds = {
Expand Down Expand Up @@ -369,7 +370,20 @@ const tui: TuiPlugin = async (api) => {
const currentClient = await ensureClient()
if (!currentClient) return

const planText = await fetchLatestPlanForSession(api.client, sessionID, directory)
// Prefer the persisted captured plan (written by Phase 3 server-side
// capture) so that a plan survives message compaction, SDK limit
// changes, or transient read failures.
let planText: string | null = null
if (currentClient.projectId) {
planText = readPlan(currentClient.projectId, sessionID)
}

// Fall back to parsing chat messages (covers legacy sessions and
// plans that were never captured server-side).
if (!planText) {
planText = await fetchLatestPlanForSession(api.client, sessionID, directory)
}

if (!planText) {
api.ui.toast({
message: 'No plan in current session — paste one to execute',
Expand Down
28 changes: 23 additions & 5 deletions src/utils/marked-plan-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,15 @@ export function messageText(message: PlanCaptureMessage): string {
return textParts.join('\n')
}

export function inspectLatestMarkedPlan(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection {
const repaired = inspectLatestPlanCompletedByLaterEndMarker(messages)
if (repaired) return repaired

function inspectLatestMarkedPlanForRole(
messages: PlanCaptureMessage[],
role: 'assistant' | 'user',
firstOnly?: boolean,
): LatestMarkedPlanInspection {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]

if (message.info.role !== 'assistant') {
if (message.info.role !== role) {
continue
}

Expand All @@ -159,11 +160,28 @@ export function inspectLatestMarkedPlan(messages: PlanCaptureMessage[]): LatestM
messageId: message.info.id,
}
}

// When firstOnly is set, stop at the first matching role message even if no
// markers are found — prevents scanning older messages of the same role.
if (firstOnly) {
return { status: 'missing' }
}
}

return { status: 'missing' }
}

export function inspectLatestMarkedPlan(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection {
const repaired = inspectLatestPlanCompletedByLaterEndMarker(messages)
if (repaired) return repaired

return inspectLatestMarkedPlanForRole(messages, 'assistant')
}

export function inspectLatestPastedPlan(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection {
return inspectLatestMarkedPlanForRole(messages, 'user', true)
}

function inspectLatestPlanCompletedByLaterEndMarker(messages: PlanCaptureMessage[]): LatestMarkedPlanInspection | null {
let latestEndOnly: { text: string; messageId?: string } | undefined

Expand Down
Loading