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
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.0",
"version": "0.4.1",
"type": "module",
"oc-plugin": [
"server",
Expand Down
6 changes: 6 additions & 0 deletions src/constants/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ export function buildLoopPermissionRuleset(): PermissionRule[] {
rules.push({ permission: '*', pattern: '*', action: 'allow' })

// External directory access: always denied to prevent unauthorized file system traversal.
// /tmp is allowed as a scratch area.
rules.push({
permission: 'external_directory',
pattern: '*',
action: 'deny',
})
rules.push({
permission: 'external_directory',
pattern: '/tmp',
action: 'allow',
})

// Code agent forbidden tools. Placed after *:allow so findLast picks them up.
rules.push(
Expand Down
46 changes: 29 additions & 17 deletions src/hooks/forge-session-attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,28 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
const sessionInfo = eventInput.event.properties?.info as Record<string, unknown> | undefined
const sessionId = sessionInfo?.id as string | undefined
const workspaceId = sessionInfo?.workspaceID as string | undefined
const sessionDirectory = sessionInfo?.directory as string | undefined
const sessionProjectId = (sessionInfo?.projectID as string | undefined) ?? deps.projectId
if (!sessionId || !workspaceId) return

let ws = await findWorkspaceById(deps, workspaceId)
let ws = await findWorkspaceById(deps, workspaceId, sessionDirectory)
if (!ws) {
await new Promise<void>((r) => setTimeout(r, 100))
ws = await findWorkspaceById(deps, workspaceId)
ws = await findWorkspaceById(deps, workspaceId, sessionDirectory)
if (!ws) {
deps.logger.log(`[forge-session-attach] skip session=${sessionId}: workspace ${workspaceId} not found via experimental.workspace.list (may be cross-project; check plugin directory)`)
deps.logger.log(
`[forge-session-attach] skip session=${sessionId}: workspace ${workspaceId} not found ` +
`via experimental.workspace.list directory=${sessionDirectory ?? '(none)'} ` +
`(cross-project or sync lag)`,
)
if (sessionDirectory) {
publishAttachFailureToast(
deps,
sessionDirectory,
`Forge loop (workspace ${workspaceId})`,
'Workspace not visible from this plugin instance - open the TUI in the loop\'s project, or run the reconciler.',
)
}
return
}
}
Expand Down Expand Up @@ -61,16 +75,16 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
return
}

const existing = deps.execDeps.loopsRepo.get(deps.projectId, cfg.loopName)
const existing = deps.execDeps.loopsRepo.get(sessionProjectId, cfg.loopName)
if (existing && existing.status === 'running') {
// Live loop with this name; skip to avoid double-attach.
deps.logger.log(`[forge-session-attach] skip session=${sessionId} loop=${cfg.loopName} reason=already-running`)
return
}
if (existing) {
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} existing-row-status=${existing.status} (will re-attach)`)
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} projectId=${sessionProjectId} existing-row-status=${existing.status} (will re-attach)`)
} else {
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} no existing row, proceeding`)
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} projectId=${sessionProjectId} no existing row, proceeding`)
}

const resolvedHostSessionId = cfg.hostSessionId && cfg.hostSessionId.length > 0
Expand All @@ -86,7 +100,7 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
if (planSource.kind === 'inline') {
planText = planSource.planText
} else {
const row = deps.execDeps.plansRepo.getForSession(deps.projectId, planSource.sessionId)
const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId)
if (!row) {
deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${cfg.loopName} workspace=${workspaceId}`)
await failAndCleanup(
Expand All @@ -104,7 +118,7 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
try {
const result = await attachLoopToSession(
deps.execDeps,
{ surface: 'tui', projectId: deps.projectId, directory: ws.directory ?? deps.directory },
{ surface: 'tui', projectId: sessionProjectId, directory: ws.directory ?? deps.directory },
{
sessionId,
workspaceId,
Expand Down Expand Up @@ -153,14 +167,14 @@ async function failAndCleanup(
loopName: string,
message: string,
): Promise<void> {
publishAttachFailureToast(deps, directory, loopName, message)
publishAttachFailureToast(deps, directory, `Forge loop "${loopName}"`, message)
await removeOrphanWorkspace(deps, workspaceId, loopName)
}

function publishAttachFailureToast(
deps: ForgeSessionAttachHookDeps,
directory: string,
loopName: string,
title: string,
message: string,
): void {
const tui = deps.v2.tui
Expand All @@ -169,12 +183,7 @@ function publishAttachFailureToast(
directory,
body: {
type: 'tui.toast.show',
properties: {
title: `Forge loop "${loopName}"`,
message,
variant: 'error',
duration: 6000,
},
properties: { title, message, variant: 'error', duration: 6000 },
},
}).catch((err) => {
deps.logger.error('[forge-session-attach] failed to publish toast', err)
Expand Down Expand Up @@ -206,9 +215,12 @@ async function removeOrphanWorkspace(
async function findWorkspaceById(
deps: ForgeSessionAttachHookDeps,
workspaceId: string,
directory?: string,
): Promise<WorkspaceEntry | null> {
try {
const result = await deps.v2.experimental.workspace.list()
const result = await deps.v2.experimental.workspace.list(
directory ? { directory } : undefined,
)
const entries = (result.data ?? []) as WorkspaceEntry[]
return entries.find((e) => e.id === workspaceId) ?? null
} catch {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
loop: loopHandler.loop,
sandboxManager,
sectionPlansRepo,
reviewFindingsRepo,
workspaceStatusRegistry,
})

Expand Down Expand Up @@ -435,6 +436,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
loop: loopHandler.loop,
sandboxManager,
sectionPlansRepo,
reviewFindingsRepo,
workspaceStatusRegistry,
}
const forgeSessionAttachHook = createForgeSessionAttachHook({
Expand Down
62 changes: 62 additions & 0 deletions src/loop/in-flight-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Logger } from '../types'

export type PromptAgent = 'code' | 'auditor-loop' | 'decomposer'

export class ConcurrentPromptError extends Error {
readonly code = 'concurrent_prompt'
constructor(
public readonly loopName: string,
public readonly priorSessionId: string,
public readonly priorAgent: PromptAgent,
public readonly attemptedSessionId: string,
public readonly attemptedAgent: PromptAgent,
) {
super(
`Concurrent agent prompt rejected for loop=${loopName}: ` +
`prior ${priorAgent} on session=${priorSessionId} still in-flight, ` +
`attempted ${attemptedAgent} on session=${attemptedSessionId}`,
)
this.name = 'ConcurrentPromptError'
}
}

interface InFlightEntry {
sessionId: string
agent: PromptAgent
startedAt: number
}

const inFlight = new Map<string, InFlightEntry>()

export function markPromptInFlight(loopName: string, sessionId: string, agent: PromptAgent): void {
inFlight.set(loopName, { sessionId, agent, startedAt: Date.now() })
}

export function clearPromptInFlight(loopName: string): void {
inFlight.delete(loopName)
}

export function getPromptInFlight(loopName: string): InFlightEntry | undefined {
return inFlight.get(loopName)
}

export function assertNoPromptInFlight(
loopName: string,
attemptedSessionId: string,
attemptedAgent: PromptAgent,
logger: Logger,
): void {
const prior = inFlight.get(loopName)
if (!prior) return
if (prior.sessionId === attemptedSessionId && prior.agent === attemptedAgent) return
logger.error(
`[in-flight-guard] concurrent prompt rejected loop=${loopName} ` +
`prior=${prior.agent}: ${prior.sessionId} attempted=${attemptedAgent}: ${attemptedSessionId}`,
)
throw new ConcurrentPromptError(loopName, prior.sessionId, prior.agent, attemptedSessionId, attemptedAgent)
}

// Test-only: clear all state.
export function __resetInFlightGuard(): void {
inFlight.clear()
}
Loading