From f10e9d6a1b5ce87955f260a5639c0af3516c9cdd Mon Sep 17 00:00:00 2001 From: Nick Tune Date: Sun, 12 Apr 2026 16:40:09 +0100 Subject: [PATCH] Move session-start metadata and idle recovery into platform This makes repository, currentState, and states mandatory on session-started events for all consumers. It also nudges started OpenCode sessions to continue when they go idle unexpectedly. --- .../src/cli/domain/repository-name.ts | 14 +++++ .../src/cli/domain/workflow-cli.ts | 2 + .../src/cli/domain/workflow-runner.spec.ts | 4 ++ .../src/cli/domain/workflow-runner.ts | 9 ++- .../src/engine/domain/workflow-engine.spec.ts | 21 ++++++- .../src/engine/domain/workflow-engine.ts | 51 ++++++++++++++++- .../domain/opencode-workflow-plugin.spec.ts | 45 ++++++++++++++- .../domain/opencode-workflow-plugin.ts | 56 ++++++++++++++++++- 8 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 packages/agentic-workflow-builder/src/cli/domain/repository-name.ts diff --git a/packages/agentic-workflow-builder/src/cli/domain/repository-name.ts b/packages/agentic-workflow-builder/src/cli/domain/repository-name.ts new file mode 100644 index 0000000..d488907 --- /dev/null +++ b/packages/agentic-workflow-builder/src/cli/domain/repository-name.ts @@ -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 + } +} diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts index a92bed2..8627208 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-cli.ts @@ -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 @@ -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, diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts index 2b5bc38..12f0c54 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.spec.ts @@ -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', @@ -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']) }) }) diff --git a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts index 8f8d520..66d7bf9 100644 --- a/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts +++ b/packages/agentic-workflow-builder/src/cli/domain/workflow-runner.ts @@ -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 } @@ -24,6 +25,7 @@ export type RunnerOptions = { readonly readStdin?: () => string readonly getSessionId?: () => string readonly getSessionTranscriptPath?: () => string + readonly getSessionRepository?: () => string | undefined } export type WorkflowRunnerConfig< @@ -157,6 +159,7 @@ export function createWorkflowRunner< routeName, options?.getSessionId, options?.getSessionTranscriptPath, + options?.getSessionRepository, ) } @@ -181,6 +184,7 @@ function handleRoute< routeName: string, getSessionId?: () => string, getSessionTranscriptPath?: () => string, + getSessionRepository?: () => string | undefined, ): RunnerResult { const routeDef = config.routes[routeName] if (routeDef === undefined) { @@ -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': { @@ -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) } diff --git a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts index 0335884..2c34264 100644 --- a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts +++ b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.spec.ts @@ -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', @@ -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', () => { @@ -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') diff --git a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts index b897367..c42d245 100644 --- a/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts +++ b/packages/agentic-workflow-builder/src/engine/domain/workflow-engine.ts @@ -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 @@ -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) @@ -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] + } } diff --git a/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.spec.ts b/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.spec.ts index 774c643..bfdf088 100644 --- a/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.spec.ts +++ b/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.spec.ts @@ -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 { @@ -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() }) }) @@ -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) }) }) diff --git a/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.ts b/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.ts index f2a23e3..4a8cfaf 100644 --- a/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.ts +++ b/packages/agentic-workflow-builder/src/opencode/domain/opencode-workflow-plugin.ts @@ -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 [args]`, call', @@ -33,9 +36,44 @@ function injectTranslationNote(content: string): string { type OpenCodeToolExecuteBefore = NonNullable type OpenCodeToolBeforeInput = Parameters[0] type OpenCodeToolBeforeOutput = Parameters[1] +type OpenCodeEventHook = NonNullable type OpenCodeCommandMap = NonNullable type OpenCodePluginInput = Parameters[0] type OpenCodePluginOptions = Parameters[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 +} + +async function promptIdleRecovery(client: SessionPromptClient, sessionID: string): Promise { + 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 => { + if (event.type !== 'session.idle') { + return + } + if (!deps.hasSessionStarted(event.properties.sessionID)) { + return + } + await deps.sendIdleRecoveryPrompt(event.properties.sessionID) + } +} export type OpenCodePlugin = ( input?: OpenCodePluginInput, @@ -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), @@ -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 => { const { engineDeps, workflowDeps } = buildEngineContext(input.sessionID) @@ -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({ @@ -164,6 +216,7 @@ export function createOpenCodeWorkflowPlugin< { getSessionId: () => ctx.sessionID, getSessionTranscriptPath: () => dbPath, + getSessionRepository: () => getRepositoryName(ctx.worktree), }, ) return result.output @@ -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