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
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { execSync } from 'node:child_process'

export function getRepositoryName(cwd: string): string | undefined {
try {
const url = execSync('git remote get-url origin', { encoding: 'utf-8', cwd }).trim()
const httpsMatch = url.match(/github\.com\/([^/]+\/[^/.]+?)(?:\.git)?$/)
if (httpsMatch?.[1] !== undefined) return httpsMatch[1]
const sshMatch = url.match(/github\.com:([^/]+\/[^/.]+?)(?:\.git)?$/)
if (sshMatch?.[1] !== undefined) return sshMatch[1]
return undefined
} catch {
return undefined
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RehydratableWorkflow } from '../../engine/index'
import type { WorkflowRunnerConfig, RunnerResult } from './workflow-runner'
import { createWorkflowRunner } from './workflow-runner'
import type { PlatformContext } from './platform-context'
import { getRepositoryName } from './repository-name'

export type ProcessDeps = {
readonly getEnv: (name: string) => string | undefined
Expand Down Expand Up @@ -69,6 +70,7 @@ export function createWorkflowCli<
store,
getPluginRoot: () => pluginRoot,
getEnvFilePath: () => join(readEnvVar('HOME'), '.claude', 'claude.env'),
getRepositoryName: () => getRepositoryName(process.cwd()),
readFile: processDeps.readFile,
appendToFile: processDeps.appendToFile,
now,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function createMockEngineDeps(hasSession = false): WorkflowEngineDeps {
store: createMockStore(hasSession),
getPluginRoot: () => '/tmp/plugin',
getEnvFilePath: () => '/tmp/.env',
getRepositoryName: () => 'owner/repo',
readFile: () => '# Procedure content',
appendToFile: () => undefined,
now: () => '2024-01-01T00:00:00Z',
Expand Down Expand Up @@ -268,6 +269,9 @@ describe('createWorkflowRunner', () => {
throw new Error('Expected session-started event')
}
expect(parsedSessionStarted.data.transcriptPath).toBe('/tmp/opencode.db')
expect(parsedSessionStarted.data.repository).toBe('owner/repo')
expect(parsedSessionStarted.data.currentState).toBe('planning')
expect(parsedSessionStarted.data.states).toEqual(['planning', 'coding'])
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { HookCommonInputSchema, PreToolUseInputSchema, SubagentStartInputSchema,
import { formatDenyDecision, formatContextInjection } from './hook-output'
import type { PreToolUseHandlerFn, CustomPreToolUseGate } from './pre-tool-use-handler'
import { createPreToolUseHandler } from './pre-tool-use-handler'
import { getRepositoryName } from './repository-name'

export type RunnerResult = { readonly output: string; readonly exitCode: number }

Expand All @@ -24,6 +25,7 @@ export type RunnerOptions = {
readonly readStdin?: () => string
readonly getSessionId?: () => string
readonly getSessionTranscriptPath?: () => string
readonly getSessionRepository?: () => string | undefined
}

export type WorkflowRunnerConfig<
Expand Down Expand Up @@ -157,6 +159,7 @@ export function createWorkflowRunner<
routeName,
options?.getSessionId,
options?.getSessionTranscriptPath,
options?.getSessionRepository,
)
}

Expand All @@ -181,6 +184,7 @@ function handleRoute<
routeName: string,
getSessionId?: () => string,
getSessionTranscriptPath?: () => string,
getSessionRepository?: () => string | undefined,
): RunnerResult {
const routeDef = config.routes[routeName]
if (routeDef === undefined) {
Expand Down Expand Up @@ -216,7 +220,8 @@ function handleRoute<
case 'session-start': {
const sessionId = resolveSessionId()
const transcriptPath = getSessionTranscriptPath?.() ?? ''
const result = engine.startSession(sessionId, transcriptPath)
const repository = getSessionRepository?.()
const result = engine.startSession(sessionId, transcriptPath, repository)
return engineResultToRunnerResult(result)
}
case 'transition': {
Expand Down Expand Up @@ -258,7 +263,7 @@ function handleHook<
const common = commonParse.data

if (common.hook_event_name === 'SessionStart') {
const result = engine.startSession(common.session_id, common.transcript_path)
const result = engine.startSession(common.session_id, common.transcript_path, getRepositoryName(common.cwd))
engine.persistSessionId(common.session_id)
return engineResultToRunnerResult(result)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ function makeEngineDeps(overrides?: EngineDepsOverrides): WorkflowEngineDeps {
store: makeStore(storeOverrides),
getPluginRoot: () => '/plugin',
getEnvFilePath: () => '/test/claude.env',
getRepositoryName: () => 'owner/repo',
readFile: () => '# Procedure\n\n- [ ] Do the thing',
appendToFile: () => undefined,
now: () => '2026-01-01T00:00:00.000Z',
Expand Down Expand Up @@ -213,7 +214,7 @@ describe('WorkflowEngine.startSession', () => {
expect(result.type).toStrictEqual('success')
expect(result.output).toContain('Feature team initialized')
expect(appended[0]?.events[0]?.type).toStrictEqual('session-started')
expect(appended[0]?.events[0]).toMatchObject({ transcriptPath: '/transcript.jsonl', currentState: 'SPAWN', states: TEST_STATE_NAMES })
expect(appended[0]?.events[0]).toMatchObject({ transcriptPath: '/transcript.jsonl', repository: 'owner/repo', currentState: 'SPAWN', states: TEST_STATE_NAMES })
})

it('persists session-started event with session id in SPAWN state', () => {
Expand Down Expand Up @@ -243,6 +244,24 @@ describe('WorkflowEngine.startSession', () => {
expect(appended[0]?.events[0]).toMatchObject({ repository: 'owner/repo' })
})

it('returns error when repository cannot be determined', () => {
const appended: Array<{ sessionId: string; events: readonly BaseEvent[] }> = []
const engine = makeEngine({
getRepositoryName: () => undefined,
store: {
sessionExists: () => false,
hasSessionStarted: () => false,
appendEvents: (sessionId, events) => appended.push({ sessionId, events }),
},
})

const result = engine.startSession('sess1', '/t.jsonl')

expect(result.type).toStrictEqual('error')
expect(result.output).toContain('Could not determine repository name')
expect(appended).toHaveLength(0)
})

it('returns empty output when session already exists', () => {
const engine = makeEngine({ store: { sessionExists: () => true, hasSessionStarted: () => true } })
const result = engine.startSession('sess1', '/t.jsonl')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type WorkflowEngineDeps = {
readonly store: WorkflowEventStore
readonly getPluginRoot: () => string
readonly getEnvFilePath: () => string
readonly getRepositoryName?: () => string | undefined
readonly readFile: (path: string) => string
readonly appendToFile: (filePath: string, content: string) => void
readonly now: () => string
Expand Down Expand Up @@ -92,8 +93,20 @@ export class WorkflowEngine<
}
const initialState = this.factory.initialState()
const workflow = this.factory.buildWorkflow(initialState, this.workflowDeps)
workflow.startSession(transcriptPath, repository)
this.engineDeps.store.appendEvents(sessionId, workflow.getPendingEvents())
const resolvedRepository = repository ?? this.engineDeps.getRepositoryName?.()
if (resolvedRepository === undefined || resolvedRepository === '') {
return { type: 'error', output: 'Could not determine repository name for session-started event.' }
}
workflow.startSession(transcriptPath, resolvedRepository)
const stateNames = Object.keys(this.factory.getRegistry())
const pendingEvents = this.enrichSessionStartedEvents(
workflow.getPendingEvents(),
transcriptPath,
resolvedRepository,
initialState.currentStateMachineState,
stateNames,
)
this.engineDeps.store.appendEvents(sessionId, pendingEvents)
const procedureContent = this.engineDeps.readFile(this.buildProcedurePath(initialState.currentStateMachineState))
const registry = this.factory.getRegistry()
const expectedPrefix = this.getExpectedPrefix(initialState.currentStateMachineState, registry)
Expand Down Expand Up @@ -353,4 +366,38 @@ export class WorkflowEngine<
private buildProcedurePath(stateName: TStateName): string {
return `${this.engineDeps.getPluginRoot()}/states/${String(stateName).toLowerCase()}.md`
}

private enrichSessionStartedEvents(
events: readonly BaseEvent[],
transcriptPath: string,
repository: string,
currentState: TStateName,
states: readonly string[],
): readonly BaseEvent[] {
let foundSessionStarted = false
const enriched = events.map((event) => {
if (event.type !== 'session-started') {
return event
}
foundSessionStarted = true
return {
...event,
transcriptPath,
repository,
currentState,
states: [...states],
}
})
if (foundSessionStarted) {
return enriched
}
return [{
type: 'session-started',
at: this.engineDeps.now(),
transcriptPath,
repository,
currentState,
states: [...states],
}, ...enriched]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'
import { z } from 'zod'
import type { Config as OpenCodeConfig, Hooks } from '@opencode-ai/plugin'
import type { ToolContext } from '@opencode-ai/plugin/tool'
import { createOpenCodeWorkflowPlugin } from './opencode-workflow-plugin.js'
import { createOpenCodeWorkflowPlugin, createSessionIdleEventHook } from './opencode-workflow-plugin.js'
import type { OpenCodeWorkflowPluginConfig } from './opencode-workflow-plugin.js'
import { createStore } from '../../event-store/index.js'
import type {
Expand Down Expand Up @@ -192,6 +192,46 @@ describe('createOpenCodeWorkflowPlugin — plugin factory', () => {
const plugin = createOpenCodeWorkflowPlugin(createConfig())
const hooks = await plugin()
expect(hooks['tool.execute.before']).toBeTypeOf('function')
expect(hooks.event).toBeTypeOf('function')
})
})

describe('createSessionIdleEventHook', () => {
it('sends the recovery prompt when a started session goes idle', async () => {
const sendIdleRecoveryPrompt = vi.fn(async () => undefined)
const eventHook = createSessionIdleEventHook({
hasSessionStarted: () => true,
sendIdleRecoveryPrompt,
})

await eventHook({ event: { type: 'session.idle', properties: { sessionID: 'idle-session' } } })

expect(sendIdleRecoveryPrompt).toHaveBeenCalledTimes(1)
expect(sendIdleRecoveryPrompt).toHaveBeenCalledWith('idle-session')
})

it('ignores idle events for sessions that have not started', async () => {
const sendIdleRecoveryPrompt = vi.fn(async () => undefined)
const eventHook = createSessionIdleEventHook({
hasSessionStarted: () => false,
sendIdleRecoveryPrompt,
})

await eventHook({ event: { type: 'session.idle', properties: { sessionID: 'never-started' } } })

expect(sendIdleRecoveryPrompt).not.toHaveBeenCalled()
})

it('ignores non-idle events', async () => {
const sendIdleRecoveryPrompt = vi.fn(async () => undefined)
const eventHook = createSessionIdleEventHook({
hasSessionStarted: () => true,
sendIdleRecoveryPrompt,
})

await eventHook({ event: { type: 'session.status', properties: { sessionID: 'session-1', status: { type: 'idle' } } } })

expect(sendIdleRecoveryPrompt).not.toHaveBeenCalled()
})
})

Expand Down Expand Up @@ -428,6 +468,9 @@ describe('createOpenCodeWorkflowPlugin — routes (workflow tool)', () => {
const identityVerified = [...events].reverse().find((event) => event.type === 'identity-verified')

expect(sessionStarted?.['transcriptPath']).toBe(databasePath)
expect(sessionStarted?.['repository']).toBe('NTCoding/autonomous-claude-agent-team')
expect(sessionStarted?.['currentState']).toBe('planning')
expect(sessionStarted?.['states']).toEqual(['planning'])
expect(identityVerified?.['transcriptPath']).toBe(databasePath)
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import type { PlatformContext, PreToolUseHandlerConfig, RouteMap } from '../../c
import { createPreToolUseHandler, createWorkflowRunner } from '../../cli/index'
import { createStore } from '../../event-store/index'
import { OpenCodeTranscriptReader } from './opencode-transcript-reader'
import { getRepositoryName } from '../../cli/domain/repository-name'

export const IDLE_RECOVERY_MESSAGE = 'You have stopped. You should never stop until the workflow is complete unless your current state permits stopping.'

const TRANSLATION_NOTE = [
'> **OpenCode**: When instructions say `/dev-workflow-v2:workflow <op> [args]`, call',
Expand All @@ -33,9 +36,44 @@ function injectTranslationNote(content: string): string {
type OpenCodeToolExecuteBefore = NonNullable<Hooks['tool.execute.before']>
type OpenCodeToolBeforeInput = Parameters<OpenCodeToolExecuteBefore>[0]
type OpenCodeToolBeforeOutput = Parameters<OpenCodeToolExecuteBefore>[1]
type OpenCodeEventHook = NonNullable<Hooks['event']>
type OpenCodeCommandMap = NonNullable<OpenCodeConfig['command']>
type OpenCodePluginInput = Parameters<Plugin>[0]
type OpenCodePluginOptions = Parameters<Plugin>[1]
type SessionPromptClient = {
readonly session: {
promptAsync: (input: {
readonly path: { readonly id: string }
readonly body: {
readonly parts: Array<{ readonly type: 'text'; readonly text: string }>
}
}) => unknown
}
}

export type IdleEventHookDeps = {
readonly hasSessionStarted: (sessionID: string) => boolean
readonly sendIdleRecoveryPrompt: (sessionID: string) => Promise<void>
}

async function promptIdleRecovery(client: SessionPromptClient, sessionID: string): Promise<void> {
await client.session.promptAsync({
path: { id: sessionID },
body: { parts: [{ type: 'text', text: IDLE_RECOVERY_MESSAGE }] },
})
}

export function createSessionIdleEventHook(deps: IdleEventHookDeps): OpenCodeEventHook {
return async ({ event }): Promise<void> => {
if (event.type !== 'session.idle') {
return
}
if (!deps.hasSessionStarted(event.properties.sessionID)) {
return
}
await deps.sendIdleRecoveryPrompt(event.properties.sessionID)
}
}

export type OpenCodePlugin = (
input?: OpenCodePluginInput,
Expand Down Expand Up @@ -86,6 +124,7 @@ export function createOpenCodeWorkflowPlugin<
getPluginRoot: () => config.pluginRoot,
/* v8 ignore next */
getEnvFilePath: () => join(homedir(), '.opencode', 'opencode.env'),
getRepositoryName: () => getRepositoryName(process.cwd()),
readFile,
/* v8 ignore next */
appendToFile: (path, content) => appendFileSync(path, content),
Expand All @@ -109,6 +148,19 @@ export function createOpenCodeWorkflowPlugin<
isWriteAllowed: config.isWriteAllowed,
...(config.customGates !== undefined ? { customGates: config.customGates } : {}),
})
const eventHook = createSessionIdleEventHook({
hasSessionStarted: (sessionID) => {
const { engineDeps, workflowDeps } = buildEngineContext(sessionID)
const engine = new WorkflowEngine(config.workflowDefinition, engineDeps, workflowDeps)
return engine.hasSessionStarted(sessionID)
},
sendIdleRecoveryPrompt: async (sessionID) => {
if (_input === undefined) {
return
}
await promptIdleRecovery(_input.client, sessionID)
},
})

const toolExecuteBefore = async (input: OpenCodeToolBeforeInput, output: OpenCodeToolBeforeOutput): Promise<void> => {
const { engineDeps, workflowDeps } = buildEngineContext(input.sessionID)
Expand All @@ -135,7 +187,7 @@ export function createOpenCodeWorkflowPlugin<
}

if (config.routes === undefined) {
return { 'tool.execute.before': toolExecuteBefore }
return { event: eventHook, 'tool.execute.before': toolExecuteBefore }
}

const workflowTool = tool({
Expand Down Expand Up @@ -164,6 +216,7 @@ export function createOpenCodeWorkflowPlugin<
{
getSessionId: () => ctx.sessionID,
getSessionTranscriptPath: () => dbPath,
getSessionRepository: () => getRepositoryName(ctx.worktree),
},
)
return result.output
Expand All @@ -173,6 +226,7 @@ export function createOpenCodeWorkflowPlugin<
const commands = loadCommands(config.commandDirectories ?? [], config.commandPrefix ?? '')

return {
event: eventHook,
'tool.execute.before': toolExecuteBefore,
tool: { workflow: workflowTool },
...(Object.keys(commands).length > 0
Expand Down
Loading