diff --git a/.opencode/commands/workflow.md b/.opencode/commands/workflow.md index 20ee2228..ad050cf0 100644 --- a/.opencode/commands/workflow.md +++ b/.opencode/commands/workflow.md @@ -9,10 +9,14 @@ Toggle workflows for the current session: - `/wf on` - Enable workflows (shorthand) - `/wf off` - Disable workflows (shorthand) -When workflows are disabled, the plugin will not inject development instructions or enforce file edit restrictions. +The override is per-session only and resets when the session ends. -You can also set the initial state via environment variable: +--- + +You can also restrict which agents have workflows active by default via `WORKFLOW_ACTIVE_AGENTS`: ```bash -WORKFLOW=off opencode +WORKFLOW_ACTIVE_AGENTS=agent1,agent2,agentN opencode ``` + +When not set, workflows are active for all agents. `/workflow on` overrides this for the current session. diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 32b23920..5596d6bd 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -79,6 +79,30 @@ Or for local development: Integrated with `@codemcp/workflows-core` for real state management and phase-based file restrictions. +## Configuration + +### Agent filtering + +By default the plugin is active for all agents. Set `WORKFLOW_ACTIVE_AGENTS` to a comma-separated list of agent names to restrict it to specific agents only: + +```bash +# Only activate for the "coder" and "architect" agents +WORKFLOW_ACTIVE_AGENTS=coder,architect +``` + +When the env var is set, workflow hooks and edit restrictions are skipped for any agent not in the list. Workflow tools are still registered, and calling them from an inactive agent will result in an error. This prevents subagents (Tasks) from being interrupted by workflow instructions when they are not expected to follow the workflow. + +### Session override + +Use the `/workflow` command to toggle the plugin on or off for the current session, regardless of the agent filter: + +``` +/workflow off # disable for this session +/workflow on # enable for this session (overrides WORKFLOW_ACTIVE_AGENTS filter) +``` + +`/workflow on` acts as a full escape hatch — it forces workflows active even if the current agent is not listed in `WORKFLOW_ACTIVE_AGENTS`. + ## Related - [`@codemcp/workflows-core`](../core) — The workflow engine (shared with MCP server) diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 5e367ca9..de53ad5d 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -8,6 +8,8 @@ * 1. chat.message - Add synthetic part with phase instructions after each user message * 2. tool.execute.before - Block editing of certain files based on phase * 3. experimental.session.compacting - Inject workflow state into compaction context + * 4. experimental.chat.system.transform - Suppress tools for non-whitelisted agents + * 5. command.execute.before - Handle /workflow on|off commands * * Logs are sent via OpenCode SDK's client.app.log() API */ @@ -114,10 +116,110 @@ export const WorkflowsPlugin: Plugin = async ( worktree: input.worktree, }); - // Initialize workflows enabled state from environment variable - const envWorkflows = process.env.WORKFLOW?.toLowerCase(); - let workflowsEnabled = envWorkflows === 'off' ? false : true; // default: enabled - logger.info('Workflows state initialized', { workflowsEnabled }); + // Parse WORKFLOW_ACTIVE_AGENTS env var: comma-separated list of agent names. + // When set, workflows only activate for agents in that list. + // When not set (or empty), workflows activate for all agents (default). + const envActiveAgents = process.env.WORKFLOW_ACTIVE_AGENTS; + const activeAgentFilter: Set | null = + envActiveAgents && envActiveAgents.trim() + ? new Set( + envActiveAgents + .split(',') + .map(a => a.trim().toLowerCase()) + .filter(Boolean) + ) + : null; // null = no filter, all agents active + + // Per-session override: /workflow on|off toggles this for the given session only. + // Sessions not explicitly toggled follow the default agent activation behavior: + // enabled for all agents when WORKFLOW_ACTIVE_AGENTS is unset, otherwise only + // for agents included in that filter. + // Bounded to the last 50 sessions to prevent unbounded growth. + const MAX_TRACKED_SESSIONS = 50; + + // Bounded set with LRU eviction — used for pendingActivation so it can't + // grow unbounded if sessions end without a follow-up chat.message. + class BoundedSessionSet extends Set { + constructor(private readonly maxSize: number) { + super(); + } + + override add(value: string): this { + if (this.has(value)) super.delete(value); + super.add(value); + while (this.size > this.maxSize) { + const oldest = this.values().next().value; + if (oldest === undefined) break; + super.delete(oldest); + } + return this; + } + } + + // Bounded map with LRU eviction. When a session is evicted from + // sessionEnabled, it is also removed from pendingActivation. + class BoundedSessionMap extends Map { + constructor( + private readonly maxSize: number, + private readonly onEvict: (sessionID: string) => void + ) { + super(); + } + + override set(key: string, value: boolean): this { + if (this.has(key)) super.delete(key); + super.set(key, value); + while (this.size > this.maxSize) { + const oldest = this.keys().next().value; + if (oldest === undefined) break; + super.delete(oldest); + this.onEvict(oldest); + } + return this; + } + } + + // Sessions where /workflow on was just typed by the user. The next + // chat.message hook will inject a counter-instruction to override any + // prior "ignore these tools" suppression the LLM may have seen. + const pendingActivation = new BoundedSessionSet(MAX_TRACKED_SESSIONS); + + const sessionEnabled = new BoundedSessionMap( + MAX_TRACKED_SESSIONS, + sessionID => { + pendingActivation.delete(sessionID); + } + ); + + /** + * Returns true if workflows should run for the given agent in the given session. + * + * Logic: + * - If the session was explicitly disabled (/workflow off) → false + * - If the session was explicitly enabled (/workflow on) → true (overrides agent filter) + * - Otherwise (default): apply the agent filter + */ + function isActiveForAgent( + agent: string | undefined, + sessionID: string | undefined + ): boolean { + const override = sessionID ? sessionEnabled.get(sessionID) : undefined; + if (override === false) return false; // explicitly disabled + if (override === true) return true; // explicitly enabled → bypass agent filter + // No override: apply agent filter (undefined = follow filter) + if (activeAgentFilter === null) return true; // no filter → all agents + return activeAgentFilter.has((agent ?? '').toLowerCase()); + } + + function setSessionEnabled(sessionID: string, value: boolean): void { + sessionEnabled.set(sessionID, value); + } + + logger.info('Workflows state initialized', { + activeAgentFilter: activeAgentFilter + ? [...activeAgentFilter] + : 'all (no filter)', + }); // Initialize instruction generator const planManager = new PlanManager(); @@ -138,11 +240,29 @@ export const WorkflowsPlugin: Plugin = async ( // Consumed and cleared by the next chat.message hook call. let bufferedInstructions: BufferedInstructions | null = null; + // Track the most recent agent name seen in chat.message per session. + // Used to gate tool.execute.before, which doesn't carry an agent field. + // Bounded to MAX_TRACKED_SESSIONS to prevent unbounded growth. + const sessionAgents = new Map(); + + /** + * Record the agent for a session with LRU eviction. + * Deletes and re-inserts the key to refresh insertion order. + */ + function setSessionAgent(sessionID: string, agent: string): void { + sessionAgents.delete(sessionID); + sessionAgents.set(sessionID, agent); + if (sessionAgents.size > MAX_TRACKED_SESSIONS) { + const oldest = sessionAgents.keys().next().value; + if (oldest !== undefined) sessionAgents.delete(oldest); + } + } + /** * Set buffered instructions from a tool result. * The next chat.message hook will use these instead of calling WhatsNextHandler. */ - function setBufferedInstructions(result: WhatsNextResult) { + function setBufferedInstructions(result: WhatsNextResult): void { bufferedInstructions = { phase: result.phase, instructions: result.instructions, @@ -260,11 +380,36 @@ export const WorkflowsPlugin: Plugin = async ( newSessionId: currentSessionId, }); } + + // Track the agent for this session so tool.execute.before can use it + if (hookInput.agent) { + setSessionAgent(hookInput.sessionID, hookInput.agent); + } } - // Skip if workflows are disabled - if (!workflowsEnabled) { - logger.debug('chat.message: Workflows disabled, skipping hook'); + // If /workflow on was just issued, inject counter-instruction first, + // regardless of agent filter — this overrides any prior suppression. + if (pendingActivation.has(hookInput.sessionID)) { + pendingActivation.delete(hookInput.sessionID); + output.parts.push({ + id: `prt_workflows_activate_${Date.now()}`, + sessionID: hookInput.sessionID, + messageID: hookInput.messageID || output.message.id, + type: 'text' as const, + synthetic: true, + text: 'Workflows have been enabled for this session. Any prior instruction to ignore workflow tools (start_development, proceed_to_phase, conduct_review, reset_development, setup_project_docs) is rescinded — these tools are now available and should be used normally.', + } as (typeof output.parts)[0]); + logger.info( + 'chat.message: injected workflow activation counter-instruction' + ); + } + + // Skip if workflows are disabled or agent is not in the active list. + if (!isActiveForAgent(hookInput.agent, hookInput.sessionID)) { + logger.debug( + 'chat.message: Workflows inactive for agent, skipping hook', + { agent: hookInput.agent } + ); return; } @@ -303,7 +448,7 @@ export const WorkflowsPlugin: Plugin = async ( messageID: hookInput.messageID || output.message.id, type: 'text' as const, synthetic: true, - text: `No Active Workflow Use the \`start_development\` tool to begin.`, + text: 'No active workflow. Use the `start_development` tool to begin.\n\nNote: The user can type `/workflow off` to disable workflow instructions for this session, or `/workflow on` to re-enable them.', } as (typeof output.parts)[0]); return; } @@ -322,7 +467,7 @@ export const WorkflowsPlugin: Plugin = async ( messageID: hookInput.messageID || output.message.id, type: 'text' as const, synthetic: true, - text: `No Active Workflow Use the \`start_development\` tool to begin.`, + text: 'No active workflow. Use the `start_development` tool to begin.\n\nNote: The user can type `/workflow off` to disable workflow instructions for this session, or `/workflow on` to re-enable them.', } as (typeof output.parts)[0]); return; } @@ -365,11 +510,18 @@ export const WorkflowsPlugin: Plugin = async ( /** * Hook 2: tool.execute.before * Fires before each tool execution. We block disallowed file edits based on phase. + * + * Note: tool.execute.before does not carry an agent field. We use the agent + * last seen in chat.message for the same session (stored in sessionAgents). */ 'tool.execute.before': async (hookInput, output) => { - // Skip if workflows are disabled - if (!workflowsEnabled) { - logger.debug('tool.execute.before: Workflows disabled, skipping hook'); + // Skip if workflows are disabled or agent is not in the active list + const sessionAgent = sessionAgents.get(hookInput.sessionID); + if (!isActiveForAgent(sessionAgent, hookInput.sessionID)) { + logger.debug( + 'tool.execute.before: Workflows inactive for agent, skipping hook', + { agent: sessionAgent } + ); return; } @@ -428,10 +580,12 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin * to preserve and instruct the summary to end with phase continuation. */ 'experimental.session.compacting': async (hookInput, output) => { - // Skip if workflows are disabled - if (!workflowsEnabled) { + // Use the agent last seen for this session + const sessionAgent = sessionAgents.get(hookInput.sessionID); + if (!isActiveForAgent(sessionAgent, hookInput.sessionID)) { logger.debug( - 'experimental.session.compacting: Workflows disabled, skipping hook' + 'experimental.session.compacting: Workflows inactive for agent, skipping hook', + { agent: sessionAgent } ); return; } @@ -456,9 +610,42 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin logger.info('Injected compaction guidance', { phase: state.phase }); }, + /** + * Hook 3b: experimental.chat.system.transform + * When workflows are inactive for the current agent, inject a system prompt + * instruction telling the agent to completely ignore the workflow tools. + * This prevents the agent from discovering and calling them unprompted. + */ + 'experimental.chat.system.transform': async (hookInput, output) => { + const sessionAgent = hookInput.sessionID + ? sessionAgents.get(hookInput.sessionID) + : undefined; + const override = hookInput.sessionID + ? sessionEnabled.get(hookInput.sessionID) + : undefined; + // Explicitly enabled → never suppress + if (override === true) return; + // Explicitly disabled → suppress + if (override === false) { + output.system.push( + 'IMPORTANT: The following tools are NOT available for use in this session and must be completely ignored. Never call them under any circumstances: start_development, proceed_to_phase, conduct_review, reset_development, setup_project_docs.' + ); + return; + } + // No override: only suppress if we know the agent AND it's not in the filter. + // If agent is unknown yet, don't suppress — we can't know yet. + if (sessionAgent === undefined) return; + if (isActiveForAgent(sessionAgent, hookInput.sessionID)) return; + output.system.push( + 'IMPORTANT: The following tools are NOT available for use in this session and must be completely ignored. Never call them under any circumstances: start_development, proceed_to_phase, conduct_review, reset_development, setup_project_docs.' + ); + }, + /** * Hook 4: command.execute.before * Intercept /workflow and /wf commands to toggle workflows enabled state + * for the current session only. A synthetic instruction is injected telling + * the agent to do nothing — so the toggle is silent from the agent's perspective. */ 'command.execute.before': async (hookInput, output) => { const cmd = hookInput.command.toLowerCase(); @@ -466,44 +653,48 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin if (cmd === 'workflow' || cmd === 'wf') { if (args === 'on') { - workflowsEnabled = true; - output.parts.push({ - id: `prt_workflows_toggle_${Date.now()}`, - type: 'text' as const, - text: 'Workflows enabled for this session.', + setSessionEnabled(hookInput.sessionID, true); + pendingActivation.add(hookInput.sessionID); + logger.info('Workflows toggled via command', { + enabled: true, + sessionID: hookInput.sessionID, }); - logger.info('Workflows toggled via command', { workflowsEnabled }); } else if (args === 'off') { - workflowsEnabled = false; - output.parts.push({ - id: `prt_workflows_toggle_${Date.now()}`, - type: 'text' as const, - text: 'Workflows disabled for this session. Plugin will not inject instructions or enforce file restrictions.', - }); - logger.info('Workflows toggled via command', { workflowsEnabled }); - } else { - output.parts.push({ - id: `prt_workflows_toggle_${Date.now()}`, - type: 'text' as const, - text: `Usage: /workflow on|off or /wf on|off\nCurrent state: ${workflowsEnabled ? 'enabled' : 'disabled'}`, + setSessionEnabled(hookInput.sessionID, false); + pendingActivation.delete(hookInput.sessionID); + logger.info('Workflows toggled via command', { + enabled: false, + sessionID: hookInput.sessionID, }); } + // Replace the user-visible command text with a silent instruction so the + // agent does not react to the toggle command at all. + output.parts.push({ + id: `prt_workflows_toggle_${Date.now()}`, + sessionID: hookInput.sessionID, + type: 'text' as const, + synthetic: true, + text: 'The /workflow command was handled by the plugin. Do not respond to this message. Do not take any action. Simply wait for the next user message.', + } as (typeof output.parts)[0]); } }, /** * Custom tools - always registered so /workflow on can re-enable them mid-session. - * Each tool's execute method checks workflowsEnabled at call time and throws a - * clear message when disabled, rather than silently failing. + * Each tool's execute method checks the agent filter and per-session override + * at call time, throwing a clear error when inactive. */ tool: await (async (): Promise<{ [key: string]: ToolDefinition }> => { const DISABLED_MSG = - 'Workflows are disabled (WORKFLOW=off). Enable with /workflow on or /wf on'; + 'Workflows are disabled (/workflow off). Enable with /workflow on or /wf on'; + const AGENT_MSG = 'Workflow tools are not active for the current agent.'; const wrap = (def: ToolDefinition): ToolDefinition => ({ ...def, execute: async (args, ctx) => { - if (!workflowsEnabled) { - throw new Error(DISABLED_MSG); + const active = isActiveForAgent(ctx.agent, ctx.sessionID); + if (!active) { + const override = sessionEnabled.get(ctx.sessionID); + throw new Error(override === false ? DISABLED_MSG : AGENT_MSG); } return def.execute(args, ctx); }, diff --git a/packages/opencode-plugin/test/e2e/plugin.test.ts b/packages/opencode-plugin/test/e2e/plugin.test.ts index f723e2b6..f051706f 100644 --- a/packages/opencode-plugin/test/e2e/plugin.test.ts +++ b/packages/opencode-plugin/test/e2e/plugin.test.ts @@ -744,150 +744,290 @@ describe('File Pattern Restrictions', () => { } }); -describe('WORKFLOW=off environment variable', () => { - it('registers tools when WORKFLOW=off, but execute throws a clear disabled error', async () => { - const dir = createTempDir(); - const originalEnv = process.env.WORKFLOW; - try { - process.env.WORKFLOW = 'off'; - - const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); +describe('WORKFLOW_ACTIVE_AGENTS environment variable', () => { + let dir: string; + let originalEnv: string | undefined; - // Tools are still registered (so /workflow on can re-enable them) - expect(hooks.tool).toBeDefined(); - expect(hooks.tool).toHaveProperty('start_development'); - expect(hooks.tool).toHaveProperty('proceed_to_phase'); - expect(hooks.tool).toHaveProperty('conduct_review'); - expect(hooks.tool).toHaveProperty('reset_development'); - expect(hooks.tool).toHaveProperty('setup_project_docs'); + beforeEach(() => { + dir = createTempDir(); + originalEnv = process.env.WORKFLOW_ACTIVE_AGENTS; + }); - // But executing a tool throws with a clear message - await expect( - hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - } as unknown) - ).rejects.toThrow(/disabled/i); - - // Command hook is available for toggling - expect(hooks['command.execute.before']).toBeDefined(); - } finally { - if (originalEnv === undefined) { - delete process.env.WORKFLOW; - } else { - process.env.WORKFLOW = originalEnv; - } - cleanupDir(dir); + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.WORKFLOW_ACTIVE_AGENTS; + } else { + process.env.WORKFLOW_ACTIVE_AGENTS = originalEnv; } + cleanupDir(dir); }); - it('allows tool execution after /wf on when started with WORKFLOW=off', async () => { - const dir = createTempDir(); - const originalEnv = process.env.WORKFLOW; - try { - process.env.WORKFLOW = 'off'; + it('activates for all agents when WORKFLOW_ACTIVE_AGENTS is not set', async () => { + delete process.env.WORKFLOW_ACTIVE_AGENTS; - const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + await setupWorkflowState(dir, { + workflowName: 'epcc', + currentPhase: 'explore', + }); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-1', + role: 'user', + }; + const output = { message: mockMessage, parts: [] as Part[] }; + + // Any agent (including unlisted ones) should get instructions injected + await hooks['chat.message']!( + { sessionID: 'sess-1', agent: 'build' }, + output + ); + expect(output.parts.length).toBeGreaterThan(0); + }); - // Confirm disabled initially - await expect( - hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - } as unknown) - ).rejects.toThrow(/disabled/i); - - // Toggle on via command - const output: { parts: Part[] } = { parts: [] }; - await hooks['command.execute.before']!( - { command: 'workflow', arguments: 'on', sessionID: 'test-session' }, + it('skips chat.message hook for agents not in the filter list', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; + + await setupWorkflowState(dir, { + workflowName: 'epcc', + currentPhase: 'explore', + }); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-1', + role: 'user', + }; + const output = { message: mockMessage, parts: [] as Part[] }; + + // 'build' is not in the active agents list → hook should skip + await hooks['chat.message']!( + { sessionID: 'sess-1', agent: 'build' }, + output + ); + expect(output.parts.length).toBe(0); + }); + + it('injects instructions for agents that are in the filter list', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; + + await setupWorkflowState(dir, { + workflowName: 'epcc', + currentPhase: 'explore', + }); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-1', + role: 'user', + }; + const output = { message: mockMessage, parts: [] as Part[] }; + + // 'vibe' is in the active agents list → instructions should be injected + await hooks['chat.message']!( + { sessionID: 'sess-1', agent: 'vibe' }, + output + ); + expect(output.parts.length).toBeGreaterThan(0); + }); + + it('is case-insensitive when matching agent names', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'Vibe'; + + await setupWorkflowState(dir, { + workflowName: 'epcc', + currentPhase: 'explore', + }); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-1', + role: 'user', + }; + const output = { message: mockMessage, parts: [] as Part[] }; + + // 'VIBE' (uppercase from runtime) should match 'Vibe' in env var + await hooks['chat.message']!( + { sessionID: 'sess-1', agent: 'VIBE' }, + output + ); + expect(output.parts.length).toBeGreaterThan(0); + }); + + it('supports multiple agents in comma-separated list', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe, dev, build'; + + await setupWorkflowState(dir, { + workflowName: 'epcc', + currentPhase: 'explore', + }); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + for (const agent of ['vibe', 'dev', 'build']) { + const mockMessage: UserMessage = { + id: `msg-${agent}`, + sessionID: `sess-${agent}`, + role: 'user', + }; + const output = { message: mockMessage, parts: [] as Part[] }; + await hooks['chat.message']!( + { sessionID: `sess-${agent}`, agent }, output ); - expect( - output.parts[0]?.type === 'text' && output.parts[0].text - ).toContain('enabled'); - - // Now the tool should no longer throw the disabled error - // (it may fail for other reasons like no plan file, but not the disabled guard) - let thrownMessage: string | undefined; - try { - await hooks.tool!['start_development'].execute({ workflow: 'minor' }, { - sessionID: 'test-session', - } as unknown); - } catch (err) { - thrownMessage = (err as Error).message; - } - // If it did throw, it must NOT be the disabled message - if (thrownMessage !== undefined) { - expect(thrownMessage).not.toMatch(/disabled/i); - } - } finally { - if (originalEnv === undefined) { - delete process.env.WORKFLOW; - } else { - process.env.WORKFLOW = originalEnv; - } - cleanupDir(dir); + expect(output.parts.length).toBeGreaterThan(0); } }); - it('loads all tools and hooks when WORKFLOW is not set (default)', async () => { - const dir = createTempDir(); - const originalEnv = process.env.WORKFLOW; - try { - delete process.env.WORKFLOW; + it('throws agent-inactive error when tool is called by an inactive agent', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; - const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); - // When WORKFLOW is not set, all hooks and tools should be registered - expect(hooks['chat.message']).toBeDefined(); - expect(hooks['tool.execute.before']).toBeDefined(); - expect(hooks['experimental.session.compacting']).toBeDefined(); - expect(hooks['command.execute.before']).toBeDefined(); - expect(hooks.tool).toBeDefined(); + // 'build' is not in the active agents list + await expect( + hooks.tool!['start_development'].execute({ workflow: 'epcc' }, { + agent: 'build', + sessionID: 'sess-1', + } as never) + ).rejects.toThrow(/not active/i); + }); - // Tools should be populated - expect(hooks.tool).toHaveProperty('start_development'); - expect(hooks.tool).toHaveProperty('proceed_to_phase'); - expect(hooks.tool).toHaveProperty('conduct_review'); - expect(hooks.tool).toHaveProperty('reset_development'); - expect(hooks.tool).toHaveProperty('setup_project_docs'); - } finally { - if (originalEnv === undefined) { - delete process.env.WORKFLOW; - } else { - process.env.WORKFLOW = originalEnv; - } - cleanupDir(dir); + it('allows tool execution for agents in the filter list', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; + + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + // 'vibe' is in the active agents list — should not throw the agent-inactive error + let thrownMessage: string | undefined; + try { + await hooks.tool!['start_development'].execute({ workflow: 'epcc' }, { + agent: 'vibe', + sessionID: 'sess-1', + } as never); + } catch (err) { + thrownMessage = (err as Error).message; + } + // If it threw, it must not be the agent-inactive error + if (thrownMessage !== undefined) { + expect(thrownMessage).not.toMatch(/not active/i); } }); - it('loads all tools and hooks when WORKFLOW=on', async () => { - const dir = createTempDir(); - const originalEnv = process.env.WORKFLOW; - try { - process.env.WORKFLOW = 'on'; + it('skips tool.execute.before for inactive agents (no file blocking)', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; - const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + await setupWorkflowState(dir, { + workflowName: 'epcc', + currentPhase: 'explore', + }); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + // First, register the agent via chat.message so sessionAgents is populated + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-build', + role: 'user', + }; + await hooks['chat.message']!( + { sessionID: 'sess-build', agent: 'build' }, + { message: mockMessage, parts: [] as Part[] } + ); + + // Now try an edit that would be blocked in explore phase — but agent is 'build' (inactive) + // so the hook should skip and NOT throw + await expect( + hooks['tool.execute.before']!( + { tool: 'edit', sessionID: 'sess-build', callID: 'call-1' }, + { args: { filePath: '/some/file.ts', oldString: 'a', newString: 'b' } } + ) + ).resolves.not.toThrow(); + }); - // When WORKFLOW=on, all hooks and tools should be registered - expect(hooks['chat.message']).toBeDefined(); - expect(hooks['tool.execute.before']).toBeDefined(); - expect(hooks['experimental.session.compacting']).toBeDefined(); - expect(hooks['command.execute.before']).toBeDefined(); - expect(hooks.tool).toBeDefined(); + it('injects system prompt suppression for inactive agents', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; + + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + // Seed sessionAgents by firing chat.message for an inactive agent + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-build', + role: 'user', + }; + await hooks['chat.message']!( + { sessionID: 'sess-build', agent: 'build' }, + { message: mockMessage, parts: [] as Part[] } + ); + + const systemOutput = { system: [] as string[] }; + await hooks['experimental.chat.system.transform']!( + { sessionID: 'sess-build', model: { providerID: 'x', modelID: 'y' } }, + systemOutput + ); + + // Should inject a suppression instruction for the inactive agent + expect(systemOutput.system.length).toBeGreaterThan(0); + expect(systemOutput.system.some(s => s.includes('start_development'))).toBe( + true + ); + }); - // Tools should be populated - expect(hooks.tool).toHaveProperty('start_development'); - expect(hooks.tool).toHaveProperty('proceed_to_phase'); - expect(hooks.tool).toHaveProperty('conduct_review'); - expect(hooks.tool).toHaveProperty('reset_development'); - expect(hooks.tool).toHaveProperty('setup_project_docs'); - } finally { - if (originalEnv === undefined) { - delete process.env.WORKFLOW; - } else { - process.env.WORKFLOW = originalEnv; - } - cleanupDir(dir); - } + it('does NOT inject system prompt suppression for active agents', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; + + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + // Seed sessionAgents by firing chat.message for the active agent + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-vibe', + role: 'user', + }; + await hooks['chat.message']!( + { sessionID: 'sess-vibe', agent: 'vibe' }, + { message: mockMessage, parts: [] as Part[] } + ); + + const systemOutput = { system: [] as string[] }; + await hooks['experimental.chat.system.transform']!( + { sessionID: 'sess-vibe', model: { providerID: 'x', modelID: 'y' } }, + systemOutput + ); + + // Active agent should NOT get the suppression instruction + expect(systemOutput.system.length).toBe(0); + }); + + it('/workflow off disables hooks even for agents in the active list', async () => { + process.env.WORKFLOW_ACTIVE_AGENTS = 'vibe'; + + await setupWorkflowState(dir, { + workflowName: 'epcc', + currentPhase: 'explore', + }); + const hooks = await WorkflowsPlugin(createMockPluginInput(dir)); + + // Disable via command + await hooks['command.execute.before']!( + { command: 'workflow', arguments: 'off', sessionID: 'sess-vibe' }, + { parts: [] as Part[] } + ); + + // Even 'vibe' (active agent) should now be skipped + const mockMessage: UserMessage = { + id: 'msg-1', + sessionID: 'sess-vibe', + role: 'user', + }; + const output = { message: mockMessage, parts: [] as Part[] }; + await hooks['chat.message']!( + { sessionID: 'sess-vibe', agent: 'vibe' }, + output + ); + expect(output.parts.length).toBe(0); }); }); diff --git a/packages/opencode-tui-plugin/README.md b/packages/opencode-tui-plugin/README.md index 8065f059..40369db6 100644 --- a/packages/opencode-tui-plugin/README.md +++ b/packages/opencode-tui-plugin/README.md @@ -35,6 +35,19 @@ The plugin works with both integration modes: | **opencode-plugin** (direct) | `start_development`, `proceed_to_phase`, `conduct_review`, `reset_development`, `setup_project_docs` | | **MCP server** | `workflows_start_development`, `workflows_proceed_to_phase`, `workflows_conduct_review`, `workflows_reset_development`, `workflows_setup_project_docs` | +## Configuration + +### Agent filtering + +By default the sidebar widget is visible for all agents. Set `WORKFLOW_ACTIVE_AGENTS` to a comma-separated list of agent names to show it only for those agents: + +```bash +# Only show the workflow widget when the "coder" or "architect" agent is active +WORKFLOW_ACTIVE_AGENTS=coder,architect +``` + +This uses the same env var as the `@codemcp/workflows-opencode` plugin, so both plugins respond consistently to the same configuration. + ## Local development To test the plugin locally before publishing, point `tui.json` at the absolute path to this package: diff --git a/packages/opencode-tui-plugin/workflows-phase.tsx b/packages/opencode-tui-plugin/workflows-phase.tsx index a4c85231..13255112 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -51,6 +51,23 @@ interface MessagePartUpdatedEvent { }; } +interface MessageUpdatedEvent { + properties?: { + sessionID?: string; + info?: { + agent?: string; + }; + }; +} + +interface CommandExecutedEvent { + properties?: { + name?: string; + sessionID?: string; + arguments?: string; + }; +} + /** * Extract ordered phase names from a workflow YAML file without a YAML parser. * @@ -229,10 +246,52 @@ function readStateBySessionId( } } +/** + * Parse the WORKFLOW_ACTIVE_AGENTS env var into a Set of lowercase agent names, + * or null if unset/empty (meaning all agents are active). + */ +function parseActiveAgentFilter(): Set | null { + const raw = process.env.WORKFLOW_ACTIVE_AGENTS; + if (!raw || !raw.trim()) return null; + return new Set( + raw + .split(',') + .map(a => a.trim().toLowerCase()) + .filter(Boolean) + ); +} + +class BoundedSessionOverrideMap extends Map { + constructor(private readonly maxEntries: number) { + super(); + } + + override set(key: string, value: boolean): this { + if (super.has(key)) { + super.delete(key); + } + + super.set(key, value); + + if (this.size > this.maxEntries) { + const oldestKey = this.keys().next().value as string | undefined; + if (oldestKey !== undefined) { + super.delete(oldestKey); + } + } + + return this; + } +} + +// Module-level map so session overrides survive sidebar remounts, but bounded to +// avoid unbounded growth across many sessions. +const sessionOverrideMap = new BoundedSessionOverrideMap(500); // eslint-disable-next-line @typescript-eslint/require-await -- TuiPlugin signature requires Promise; plugin body is synchronous const tui: TuiPlugin = async api => { - // Respect the WORKFLOW env var used by the opencode-plugin. - // Set WORKFLOW=off to disable the TUI sidebar widget. + const activeAgentFilter = parseActiveAgentFilter(); + + // Respect legacy WORKFLOW=off env var as well. if (process.env.WORKFLOW?.toLowerCase() === 'off') return; api.slots.register({ @@ -247,6 +306,39 @@ const tui: TuiPlugin = async api => { } | null>(null); const [collapsed, setCollapsed] = createSignal(false); + // Per-session override from /workflow on|off commands. + // Backed by module-level map so it survives sidebar remounts. + // null = no override (fall back to agent filter). + const [sessionOverride, setSessionOverride] = createSignal< + boolean | null + >(sessionOverrideMap.get(props.session_id) ?? null); + + // Derive the current agent for this session from the last message. + // api.state.session.messages() is a reactive SolidJS accessor. + const currentAgent = createMemo(() => { + const messages = api.state.session.messages(props.session_id); + if (!messages || messages.length === 0) return undefined; + // Walk backwards to find the most recent message with an agent field + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] as { agent?: string }; + if (msg.agent) return msg.agent.toLowerCase(); + } + return undefined; + }); + + // Derive whether the widget should be visible based on agent filter + + // per-session override from /workflow on|off. + const isActive = createMemo(() => { + const override = sessionOverride(); + if (override === false) return false; // explicitly disabled + if (override === true) return true; // explicitly enabled + // No override: apply agent filter + if (activeAgentFilter === null) return true; // no filter → always active + const agent = currentAgent(); + if (agent === undefined) return false; // filter set but agent unknown → hide + return activeAgentFilter.has(agent); + }); + // Spinner frames for the current-phase icon const SPINNER = ['◐', '◓', '◑', '◒']; const [spinnerFrame, setSpinnerFrame] = createSignal(0); @@ -293,19 +385,74 @@ const tui: TuiPlugin = async api => { }); onCleanup(offPart); + // Listen for /workflow on|off commands to toggle visibility. + const offCmd = api.event.on('command.executed', e => { + const ev = e as CommandExecutedEvent; + if (ev.properties?.sessionID !== props.session_id) return; + const name = ev.properties?.name?.toLowerCase(); + if (name !== 'workflow' && name !== 'wf') return; + const args = (ev.properties?.arguments ?? '').toLowerCase().trim(); + if (args === 'on') { + sessionOverrideMap.set(props.session_id, true); + setSessionOverride(true); + } else if (args === 'off') { + sessionOverrideMap.set(props.session_id, false); + setSessionOverride(false); + } + }); + onCleanup(offCmd); + + // Also refresh state when the agent changes (e.g. subagent session becomes active) + let lastAgent: string | undefined; + const offMsg = api.event.on('message.updated', e => { + const ev = e as MessageUpdatedEvent; + if (ev.properties?.sessionID !== props.session_id) return; + const agent = ev.properties?.info?.agent as string | undefined; + // Only refresh when agent information is present and has changed. + // Normalize to lowercase to match the filter and currentAgent() comparison. + if (!agent) return; + const normalizedAgent = agent.toLowerCase(); + if (normalizedAgent === lastAgent) return; + lastAgent = normalizedAgent; + if (!dir) return; + const stateBySession = readStateBySessionId(dir, props.session_id); + setState(stateBySession); + }); + onCleanup(offMsg); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSX element typed as `error` by @opentui/solid's JSX types; safe at runtime return ( - - {/* Header row — clickable to collapse/expand when an active workflow is present */} - state() && setCollapsed(c => !c)} - > - {state() ? ( - (state()?.phases ?? []).length === 0 ? ( - // Phases unknown - collapsed() ? ( - // Collapsed: ▶ workflowName phaseName + // Return null (no DOM node at all) when the agent filter excludes this session's agent. + // Returning an empty would still occupy a line in the sidebar. + !isActive() ? null : ( + + {/* Header row — clickable to collapse/expand when an active workflow is present */} + state() && setCollapsed(c => !c)} + > + {state() ? ( + (state()?.phases ?? []).length === 0 ? ( + // Phases unknown + collapsed() ? ( + // Collapsed: ▶ workflowName phaseName + + {'▶ '} + {state()?.workflow} + + {' '} + {state()?.phase} + + + ) : ( + // Expanded: ▼ Workflow (body shows workflowName phaseName) + + {'▼ '} + Workflow + + ) + ) : collapsed() ? ( + // Collapsed + active: ▶ workflowName phaseName {'▶ '} {state()?.workflow} @@ -315,78 +462,62 @@ const tui: TuiPlugin = async api => { ) : ( - // Expanded: ▼ Workflow (body shows workflowName phaseName) + // Expanded + active: ▼ Workflow workflowName {'▼ '} - Workflow + Workflow {state()?.workflow} ) - ) : collapsed() ? ( - // Collapsed + active: ▶ workflowName phaseName - - {'▶ '} + ) : ( + // No active workflow + // eslint-disable-next-line solid/style-prop -- `fg` is an OpenTUI-specific style prop, not a standard CSS property + Workflow + )} + + {/* Expanded phase list */} + {!collapsed() && state() ? ( + (state()?.phases ?? []).length > 0 ? ( + + + {(phase, index) => ( + = 0 && + index < currentPhaseIndex() + ? theme().success + : theme().textMuted + } + > + {phase() === state()?.phase + ? `${SPINNER[spinnerFrame()]} ` + : currentPhaseIndex() >= 0 && + index < currentPhaseIndex() + ? '● ' + : '○ '} + {phase()} + + )} + + + ) : ( + // Phases unknown — show workflowName phaseName + {state()?.workflow} {' '} {state()?.phase} - - ) : ( - // Expanded + active: ▼ Workflow workflowName - - {'▼ '} - Workflow {state()?.workflow} - + ) - ) : ( - // No active workflow - // eslint-disable-next-line solid/style-prop -- `fg` is an OpenTUI-specific style prop, not a standard CSS property - Workflow - )} - - {/* Expanded phase list */} - {!collapsed() && state() ? ( - (state()?.phases ?? []).length > 0 ? ( - - - {(phase, index) => ( - = 0 && - index < currentPhaseIndex() - ? theme().success - : theme().textMuted - } - > - {phase() === state()?.phase - ? `${SPINNER[spinnerFrame()]} ` - : currentPhaseIndex() >= 0 && - index < currentPhaseIndex() - ? '● ' - : '○ '} - {phase()} - - )} - - - ) : ( - // Phases unknown — show workflowName phaseName - - {state()?.workflow} - - {' '} - {state()?.phase} - - - ) - ) : null} - {/* No active workflow message */} - {!state() ? ( - No Active Workflow - ) : null} - + ) : null} + {/* No active workflow message */} + {!state() ? ( + No Active Workflow + ) : null} + + ) ); }, },