From 829141c73af1965e47ed2b13589851b83e62f235 Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 18:56:38 +0200 Subject: [PATCH 01/11] feat: restrict workflow plugin to configured agents via WORKFLOW_ACTIVE_AGENTS --- packages/opencode-plugin/README.md | 23 ++ packages/opencode-plugin/src/plugin.ts | 116 ++++++++-- packages/opencode-tui-plugin/README.md | 13 ++ .../opencode-tui-plugin/workflows-phase.tsx | 210 +++++++++++------- 4 files changed, 268 insertions(+), 94 deletions(-) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 32b23920..f2d6d124 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -79,6 +79,29 @@ 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, hooks and tool-call enforcement are silently skipped for any agent not in the list. This prevents subagents (Tasks) from being interrupted by workflow instructions when they are not expected to follow the workflow. + +### Session-level 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 # re-enable for this session +/workflow # show current state +``` + ## 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..2f84e48e 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -114,10 +114,40 @@ 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 + + // Session-level override: /workflow on|off works on top of the agent filter. + // Starts as true so the agent filter alone controls whether hooks fire. + let workflowsEnabled = true; + + /** + * Returns true if workflows should run for the given agent name, + * taking both the session-level override and the agent filter into account. + */ + function isActiveForAgent(agent: string | undefined): boolean { + if (!workflowsEnabled) return false; + if (activeAgentFilter === null) return true; // no filter → all agents + return activeAgentFilter.has((agent ?? '').toLowerCase()); + } + + logger.info('Workflows state initialized', { + activeAgentFilter: activeAgentFilter + ? [...activeAgentFilter] + : 'all (no filter)', + workflowsEnabled, + }); // Initialize instruction generator const planManager = new PlanManager(); @@ -138,11 +168,15 @@ 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. + const sessionAgents = new Map(); + /** * 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 +294,19 @@ export const WorkflowsPlugin: Plugin = async ( newSessionId: currentSessionId, }); } + + // Track the agent for this session so tool.execute.before can use it + if (hookInput.agent) { + sessionAgents.set(hookInput.sessionID, hookInput.agent); + } } - // Skip if workflows are disabled - if (!workflowsEnabled) { - logger.debug('chat.message: Workflows disabled, skipping hook'); + // Skip if workflows are disabled or agent is not in the active list + if (!isActiveForAgent(hookInput.agent)) { + logger.info( + 'chat.message: Workflows inactive for agent, skipping hook', + { agent: hookInput.agent } + ); return; } @@ -365,11 +407,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)) { + logger.debug( + 'tool.execute.before: Workflows inactive for agent, skipping hook', + { agent: sessionAgent } + ); return; } @@ -428,10 +477,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)) { logger.debug( - 'experimental.session.compacting: Workflows disabled, skipping hook' + 'experimental.session.compacting: Workflows inactive for agent, skipping hook', + { agent: sessionAgent } ); return; } @@ -456,9 +507,28 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin logger.info('Injected compaction guidance', { phase: state.phase }); }, + /** + * Hook: 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; + // If no agent is known yet for this session and a filter is set, + // assume inactive (safe default — don't expose tools until we know the agent). + if (isActiveForAgent(sessionAgent)) 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 + * Intercept /workflow and /wf commands to toggle workflows enabled state. + * This is a session-level override on top of the WORKFLOW_ACTIVE_AGENTS filter. */ 'command.execute.before': async (hookInput, output) => { const cmd = hookInput.command.toLowerCase(); @@ -482,10 +552,14 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin }); logger.info('Workflows toggled via command', { workflowsEnabled }); } else { + const filterDesc = + activeAgentFilter === null + ? 'all agents' + : [...activeAgentFilter].join(', '); 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'}`, + text: `Usage: /workflow on|off or /wf on|off\nSession override: ${workflowsEnabled ? 'enabled' : 'disabled'}\nActive agents filter: ${filterDesc}`, }); } } @@ -493,18 +567,22 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin /** * 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 workflowsEnabled and the agent filter at call + * time and throws a clear message when inactive, rather than silently failing. */ 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); } + if (!isActiveForAgent(ctx.agent)) { + throw new Error(AGENT_MSG); + } return def.execute(args, ctx); }, }); 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..1adbdee8 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -51,6 +51,15 @@ interface MessagePartUpdatedEvent { }; } +interface MessageUpdatedEvent { + properties?: { + sessionID?: string; + info?: { + agent?: string; + }; + }; +} + /** * Extract ordered phase names from a workflow YAML file without a YAML parser. * @@ -229,10 +238,26 @@ 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) + ); +} + // 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 +272,27 @@ const tui: TuiPlugin = async api => { } | null>(null); const [collapsed, setCollapsed] = createSignal(false); + // 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 the agent filter. + const isActive = createMemo(() => { + if (activeAgentFilter === null) return true; // no filter → always active + const agent = currentAgent(); + if (agent === undefined) return true; // no messages yet → show optimistically + return activeAgentFilter.has(agent); + }); + // Spinner frames for the current-phase icon const SPINNER = ['◐', '◓', '◑', '◒']; const [spinnerFrame, setSpinnerFrame] = createSignal(0); @@ -293,19 +339,49 @@ const tui: TuiPlugin = async api => { }); onCleanup(offPart); + // Also refresh state when the agent changes (e.g. subagent session becomes active) + const offMsg = api.event.on('message.updated', e => { + const ev = e as MessageUpdatedEvent; + if (ev.properties?.sessionID !== props.session_id) return; + 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 +391,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} + + ) ); }, }, From 909971331a2fd0bef49800c7d0670890468e3ec6 Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 19:12:24 +0200 Subject: [PATCH 02/11] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20debug=20log,=20message.updated=20optimization,?= =?UTF-8?q?=20agent=20filter=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode-plugin/src/plugin.ts | 3 +- .../opencode-plugin/test/e2e/plugin.test.ts | 388 ++++++++++++------ .../opencode-tui-plugin/workflows-phase.tsx | 5 + 3 files changed, 270 insertions(+), 126 deletions(-) diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 2f84e48e..c04050f5 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -127,7 +127,6 @@ export const WorkflowsPlugin: Plugin = async ( .filter(Boolean) ) : null; // null = no filter, all agents active - // Session-level override: /workflow on|off works on top of the agent filter. // Starts as true so the agent filter alone controls whether hooks fire. let workflowsEnabled = true; @@ -303,7 +302,7 @@ export const WorkflowsPlugin: Plugin = async ( // Skip if workflows are disabled or agent is not in the active list if (!isActiveForAgent(hookInput.agent)) { - logger.info( + logger.debug( 'chat.message: Workflows inactive for agent, skipping hook', { agent: hookInput.agent } ); 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/workflows-phase.tsx b/packages/opencode-tui-plugin/workflows-phase.tsx index 1adbdee8..0e0eb727 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -340,9 +340,14 @@ const tui: TuiPlugin = async api => { onCleanup(offPart); // 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 + if (!agent || agent === lastAgent) return; + lastAgent = agent; if (!dir) return; const stateBySession = readStateBySessionId(dir, props.session_id); setState(stateBySession); From 565bf84705c4ee2da345a2eccb66bd25d4056d1d Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 19:22:20 +0200 Subject: [PATCH 03/11] docs: clarify /workflow toggle is global, not session-scoped --- packages/opencode-plugin/README.md | 8 ++++---- packages/opencode-plugin/src/plugin.ts | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index f2d6d124..98011920 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -92,13 +92,13 @@ WORKFLOW_ACTIVE_AGENTS=coder,architect When the env var is set, hooks and tool-call enforcement are silently skipped for any agent not in the list. This prevents subagents (Tasks) from being interrupted by workflow instructions when they are not expected to follow the workflow. -### Session-level override +### Global override -Use the `/workflow` command to toggle the plugin on or off for the current session, regardless of the agent filter: +Use the `/workflow` command to toggle the plugin on or off globally (affects all sessions for the lifetime of the plugin instance), regardless of the agent filter: ``` -/workflow off # disable for this session -/workflow on # re-enable for this session +/workflow off # disable globally +/workflow on # re-enable globally /workflow # show current state ``` diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index c04050f5..f54d6b13 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -127,13 +127,14 @@ export const WorkflowsPlugin: Plugin = async ( .filter(Boolean) ) : null; // null = no filter, all agents active - // Session-level override: /workflow on|off works on top of the agent filter. + // Global override: /workflow on|off toggles this flag for the lifetime of + // the plugin instance (i.e. all sessions sharing this instance). // Starts as true so the agent filter alone controls whether hooks fire. let workflowsEnabled = true; /** * Returns true if workflows should run for the given agent name, - * taking both the session-level override and the agent filter into account. + * taking both the global on/off override and the agent filter into account. */ function isActiveForAgent(agent: string | undefined): boolean { if (!workflowsEnabled) return false; @@ -527,7 +528,8 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin /** * Hook 4: command.execute.before * Intercept /workflow and /wf commands to toggle workflows enabled state. - * This is a session-level override on top of the WORKFLOW_ACTIVE_AGENTS filter. + * Note: this toggle is global across all sessions for the lifetime of the + * plugin instance, not scoped to the current session. */ 'command.execute.before': async (hookInput, output) => { const cmd = hookInput.command.toLowerCase(); From 15bd31924a652dbef77e2ceef8338d6bf20ccb01 Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 19:28:54 +0200 Subject: [PATCH 04/11] fix: per-session workflow toggle, bounded session maps, hide TUI widget until agent known --- packages/opencode-plugin/README.md | 8 +- packages/opencode-plugin/src/plugin.ts | 91 +++++++++++++------ .../opencode-tui-plugin/workflows-phase.tsx | 2 +- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 98011920..0f815e15 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -92,13 +92,13 @@ WORKFLOW_ACTIVE_AGENTS=coder,architect When the env var is set, hooks and tool-call enforcement are silently skipped for any agent not in the list. This prevents subagents (Tasks) from being interrupted by workflow instructions when they are not expected to follow the workflow. -### Global override +### Session override -Use the `/workflow` command to toggle the plugin on or off globally (affects all sessions for the lifetime of the plugin instance), regardless of the agent filter: +Use the `/workflow` command to toggle the plugin on or off for the current session only, regardless of the agent filter: ``` -/workflow off # disable globally -/workflow on # re-enable globally +/workflow off # disable for this session +/workflow on # re-enable for this session /workflow # show current state ``` diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index f54d6b13..b635b7f9 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -127,26 +127,42 @@ export const WorkflowsPlugin: Plugin = async ( .filter(Boolean) ) : null; // null = no filter, all agents active - // Global override: /workflow on|off toggles this flag for the lifetime of - // the plugin instance (i.e. all sessions sharing this instance). - // Starts as true so the agent filter alone controls whether hooks fire. - let workflowsEnabled = true; + + // Per-session override: /workflow on|off toggles this for the given session only. + // Defaults to true (enabled) for any session not explicitly toggled. + // Bounded to the last 50 sessions to prevent unbounded growth. + const MAX_TRACKED_SESSIONS = 50; + const sessionEnabled = new Map(); /** - * Returns true if workflows should run for the given agent name, - * taking both the global on/off override and the agent filter into account. + * Returns true if workflows should run for the given agent in the given session, + * taking both the per-session on/off override and the agent filter into account. */ - function isActiveForAgent(agent: string | undefined): boolean { - if (!workflowsEnabled) return false; + function isActiveForAgent( + agent: string | undefined, + sessionID: string | undefined + ): boolean { + const enabled = sessionID ? (sessionEnabled.get(sessionID) ?? true) : true; + if (!enabled) return false; if (activeAgentFilter === null) return true; // no filter → all agents return activeAgentFilter.has((agent ?? '').toLowerCase()); } + /** + * Set per-session enabled state, pruning oldest entries if the map grows too large. + */ + function setSessionEnabled(sessionID: string, value: boolean): void { + sessionEnabled.set(sessionID, value); + if (sessionEnabled.size > MAX_TRACKED_SESSIONS) { + const oldest = sessionEnabled.keys().next().value; + if (oldest !== undefined) sessionEnabled.delete(oldest); + } + } + logger.info('Workflows state initialized', { activeAgentFilter: activeAgentFilter ? [...activeAgentFilter] : 'all (no filter)', - workflowsEnabled, }); // Initialize instruction generator @@ -170,8 +186,20 @@ export const WorkflowsPlugin: Plugin = async ( // 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, pruning oldest entry if map grows too large. + */ + function setSessionAgent(sessionID: string, agent: string): void { + 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. @@ -297,12 +325,12 @@ export const WorkflowsPlugin: Plugin = async ( // Track the agent for this session so tool.execute.before can use it if (hookInput.agent) { - sessionAgents.set(hookInput.sessionID, hookInput.agent); + setSessionAgent(hookInput.sessionID, hookInput.agent); } } // Skip if workflows are disabled or agent is not in the active list - if (!isActiveForAgent(hookInput.agent)) { + if (!isActiveForAgent(hookInput.agent, hookInput.sessionID)) { logger.debug( 'chat.message: Workflows inactive for agent, skipping hook', { agent: hookInput.agent } @@ -414,7 +442,7 @@ export const WorkflowsPlugin: Plugin = async ( 'tool.execute.before': async (hookInput, output) => { // Skip if workflows are disabled or agent is not in the active list const sessionAgent = sessionAgents.get(hookInput.sessionID); - if (!isActiveForAgent(sessionAgent)) { + if (!isActiveForAgent(sessionAgent, hookInput.sessionID)) { logger.debug( 'tool.execute.before: Workflows inactive for agent, skipping hook', { agent: sessionAgent } @@ -479,7 +507,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin 'experimental.session.compacting': async (hookInput, output) => { // Use the agent last seen for this session const sessionAgent = sessionAgents.get(hookInput.sessionID); - if (!isActiveForAgent(sessionAgent)) { + if (!isActiveForAgent(sessionAgent, hookInput.sessionID)) { logger.debug( 'experimental.session.compacting: Workflows inactive for agent, skipping hook', { agent: sessionAgent } @@ -519,7 +547,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin : undefined; // If no agent is known yet for this session and a filter is set, // assume inactive (safe default — don't expose tools until we know the agent). - if (isActiveForAgent(sessionAgent)) 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.' ); @@ -527,9 +555,8 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin /** * Hook 4: command.execute.before - * Intercept /workflow and /wf commands to toggle workflows enabled state. - * Note: this toggle is global across all sessions for the lifetime of the - * plugin instance, not scoped to the current session. + * Intercept /workflow and /wf commands to toggle workflows enabled state + * for the current session only. */ 'command.execute.before': async (hookInput, output) => { const cmd = hookInput.command.toLowerCase(); @@ -537,30 +564,37 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin if (cmd === 'workflow' || cmd === 'wf') { if (args === 'on') { - workflowsEnabled = true; + setSessionEnabled(hookInput.sessionID, true); output.parts.push({ id: `prt_workflows_toggle_${Date.now()}`, type: 'text' as const, text: 'Workflows enabled for this session.', }); - logger.info('Workflows toggled via command', { workflowsEnabled }); + logger.info('Workflows toggled via command', { + enabled: true, + sessionID: hookInput.sessionID, + }); } else if (args === 'off') { - workflowsEnabled = false; + setSessionEnabled(hookInput.sessionID, 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 }); + logger.info('Workflows toggled via command', { + enabled: false, + sessionID: hookInput.sessionID, + }); } else { const filterDesc = activeAgentFilter === null ? 'all agents' : [...activeAgentFilter].join(', '); + const enabled = sessionEnabled.get(hookInput.sessionID) ?? true; output.parts.push({ id: `prt_workflows_toggle_${Date.now()}`, type: 'text' as const, - text: `Usage: /workflow on|off or /wf on|off\nSession override: ${workflowsEnabled ? 'enabled' : 'disabled'}\nActive agents filter: ${filterDesc}`, + text: `Usage: /workflow on|off or /wf on|off\nSession override: ${enabled ? 'enabled' : 'disabled'}\nActive agents filter: ${filterDesc}`, }); } } @@ -568,8 +602,8 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin /** * Custom tools - always registered so /workflow on can re-enable them mid-session. - * Each tool's execute method checks workflowsEnabled and the agent filter at call - * time and throws a clear message when inactive, rather than silently failing. + * Each tool's execute method checks the per-session enabled state and the agent + * filter at call time and throws a clear message when inactive. */ tool: await (async (): Promise<{ [key: string]: ToolDefinition }> => { const DISABLED_MSG = @@ -578,11 +612,10 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin const wrap = (def: ToolDefinition): ToolDefinition => ({ ...def, execute: async (args, ctx) => { - if (!workflowsEnabled) { - throw new Error(DISABLED_MSG); - } - if (!isActiveForAgent(ctx.agent)) { - throw new Error(AGENT_MSG); + if (!isActiveForAgent(ctx.agent, ctx.sessionID)) { + // Distinguish disabled-by-command from disabled-by-agent-filter + const enabled = sessionEnabled.get(ctx.sessionID) ?? true; + throw new Error(enabled ? AGENT_MSG : DISABLED_MSG); } return def.execute(args, ctx); }, diff --git a/packages/opencode-tui-plugin/workflows-phase.tsx b/packages/opencode-tui-plugin/workflows-phase.tsx index 0e0eb727..d6e539ea 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -289,7 +289,7 @@ const tui: TuiPlugin = async api => { const isActive = createMemo(() => { if (activeAgentFilter === null) return true; // no filter → always active const agent = currentAgent(); - if (agent === undefined) return true; // no messages yet → show optimistically + if (agent === undefined) return false; // filter set but agent unknown → hide return activeAgentFilter.has(agent); }); From cac28afe399ef03df5d9ac9c74ed4cfeb24d7c0d Mon Sep 17 00:00:00 2001 From: Daniel Schroeder Date: Thu, 2 Apr 2026 19:37:04 +0200 Subject: [PATCH 05/11] Update packages/opencode-plugin/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/opencode-plugin/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 0f815e15..59665376 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -90,7 +90,7 @@ By default the plugin is active for all agents. Set `WORKFLOW_ACTIVE_AGENTS` to WORKFLOW_ACTIVE_AGENTS=coder,architect ``` -When the env var is set, hooks and tool-call enforcement are silently skipped for any agent not in the list. This prevents subagents (Tasks) from being interrupted by workflow instructions when they are not expected to follow the workflow. +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 From 7650ea26c3f349d81ad1f1bed762c98f5eaf96e6 Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 19:41:04 +0200 Subject: [PATCH 06/11] fix: /workflow on overrides agent filter as escape hatch for inactive agents --- packages/opencode-plugin/README.md | 6 ++++-- packages/opencode-plugin/src/plugin.ts | 30 ++++++++++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 59665376..a3fd73e8 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -94,14 +94,16 @@ When the env var is set, workflow hooks and edit restrictions are skipped for an ### Session override -Use the `/workflow` command to toggle the plugin on or off for the current session only, regardless of the agent filter: +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 # re-enable for this session +/workflow on # enable for this session (overrides WORKFLOW_ACTIVE_AGENTS filter) /workflow # show current state ``` +`/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 b635b7f9..e12549ff 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -135,15 +135,21 @@ export const WorkflowsPlugin: Plugin = async ( const sessionEnabled = new Map(); /** - * Returns true if workflows should run for the given agent in the given session, - * taking both the per-session on/off override and the agent filter into account. + * 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 enabled = sessionID ? (sessionEnabled.get(sessionID) ?? true) : true; - if (!enabled) return false; + 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()); } @@ -568,7 +574,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin output.parts.push({ id: `prt_workflows_toggle_${Date.now()}`, type: 'text' as const, - text: 'Workflows enabled for this session.', + text: 'Workflows enabled for this session (overrides agent filter).', }); logger.info('Workflows toggled via command', { enabled: true, @@ -590,11 +596,17 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin activeAgentFilter === null ? 'all agents' : [...activeAgentFilter].join(', '); - const enabled = sessionEnabled.get(hookInput.sessionID) ?? true; + const override = sessionEnabled.get(hookInput.sessionID); + const overrideDesc = + override === true + ? 'forced on (overrides agent filter)' + : override === false + ? 'forced off' + : 'default (follows agent filter)'; output.parts.push({ id: `prt_workflows_toggle_${Date.now()}`, type: 'text' as const, - text: `Usage: /workflow on|off or /wf on|off\nSession override: ${enabled ? 'enabled' : 'disabled'}\nActive agents filter: ${filterDesc}`, + text: `Usage: /workflow on|off or /wf on|off\nSession override: ${overrideDesc}\nActive agents filter: ${filterDesc}`, }); } } @@ -614,8 +626,8 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin execute: async (args, ctx) => { if (!isActiveForAgent(ctx.agent, ctx.sessionID)) { // Distinguish disabled-by-command from disabled-by-agent-filter - const enabled = sessionEnabled.get(ctx.sessionID) ?? true; - throw new Error(enabled ? AGENT_MSG : DISABLED_MSG); + const override = sessionEnabled.get(ctx.sessionID); + throw new Error(override === false ? DISABLED_MSG : AGENT_MSG); } return def.execute(args, ctx); }, From c5f4dd9dfcf381eafad13a7eb85a266a9c276d18 Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 19:43:54 +0200 Subject: [PATCH 07/11] fix: correct LRU eviction order and normalize agent case in TUI plugin - Use delete+set pattern in setSessionEnabled and setSessionAgent to refresh insertion order so active sessions are not prematurely evicted - Normalize agent name to lowercase in TUI message.updated handler to match filter comparison in isActiveForAgent --- packages/opencode-plugin/src/plugin.ts | 9 +++++++-- packages/opencode-tui-plugin/workflows-phase.tsx | 9 ++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index e12549ff..7b1852c1 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -155,9 +155,12 @@ export const WorkflowsPlugin: Plugin = async ( } /** - * Set per-session enabled state, pruning oldest entries if the map grows too large. + * Set per-session enabled state with LRU eviction. + * Deletes and re-inserts the key to refresh insertion order, so recently + * used sessions are never evicted before truly idle ones. */ function setSessionEnabled(sessionID: string, value: boolean): void { + sessionEnabled.delete(sessionID); sessionEnabled.set(sessionID, value); if (sessionEnabled.size > MAX_TRACKED_SESSIONS) { const oldest = sessionEnabled.keys().next().value; @@ -196,9 +199,11 @@ export const WorkflowsPlugin: Plugin = async ( const sessionAgents = new Map(); /** - * Record the agent for a session, pruning oldest entry if map grows too large. + * 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; diff --git a/packages/opencode-tui-plugin/workflows-phase.tsx b/packages/opencode-tui-plugin/workflows-phase.tsx index d6e539ea..a1265849 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -345,9 +345,12 @@ const tui: TuiPlugin = async api => { 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 - if (!agent || agent === lastAgent) return; - lastAgent = agent; + // 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); From 36800ccc31d50c041da6e9a7f3f79fa9fe5208cd Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 21:07:58 +0200 Subject: [PATCH 08/11] fix: persist TUI session override across remounts and clean up workflow command docs --- .opencode/commands/workflow.md | 10 +- packages/opencode-plugin/src/plugin.ts | 97 +++++++++++-------- .../opencode-tui-plugin/workflows-phase.tsx | 42 +++++++- 3 files changed, 107 insertions(+), 42 deletions(-) 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/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 7b1852c1..c8d09c6b 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 */ @@ -134,6 +136,11 @@ export const WorkflowsPlugin: Plugin = async ( const MAX_TRACKED_SESSIONS = 50; const sessionEnabled = new Map(); + // 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 Set(); + /** * Returns true if workflows should run for the given agent in the given session. * @@ -340,7 +347,24 @@ export const WorkflowsPlugin: Plugin = async ( } } - // Skip if workflows are disabled or agent is not in the active list + // 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', @@ -384,7 +408,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; } @@ -403,7 +427,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; } @@ -547,7 +571,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin }, /** - * Hook: experimental.chat.system.transform + * 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. @@ -556,8 +580,21 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin const sessionAgent = hookInput.sessionID ? sessionAgents.get(hookInput.sessionID) : undefined; - // If no agent is known yet for this session and a filter is set, - // assume inactive (safe default — don't expose tools until we know the agent). + 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.' @@ -567,7 +604,8 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin /** * Hook 4: command.execute.before * Intercept /workflow and /wf commands to toggle workflows enabled state - * for the current session only. + * 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(); @@ -576,51 +614,34 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin if (cmd === 'workflow' || cmd === 'wf') { if (args === 'on') { setSessionEnabled(hookInput.sessionID, true); - output.parts.push({ - id: `prt_workflows_toggle_${Date.now()}`, - type: 'text' as const, - text: 'Workflows enabled for this session (overrides agent filter).', - }); + pendingActivation.add(hookInput.sessionID); logger.info('Workflows toggled via command', { enabled: true, sessionID: hookInput.sessionID, }); } else if (args === 'off') { setSessionEnabled(hookInput.sessionID, 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', { enabled: false, sessionID: hookInput.sessionID, }); - } else { - const filterDesc = - activeAgentFilter === null - ? 'all agents' - : [...activeAgentFilter].join(', '); - const override = sessionEnabled.get(hookInput.sessionID); - const overrideDesc = - override === true - ? 'forced on (overrides agent filter)' - : override === false - ? 'forced off' - : 'default (follows agent filter)'; - output.parts.push({ - id: `prt_workflows_toggle_${Date.now()}`, - type: 'text' as const, - text: `Usage: /workflow on|off or /wf on|off\nSession override: ${overrideDesc}\nActive agents filter: ${filterDesc}`, - }); } + // 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 the per-session enabled state and the agent - * filter at call time and throws a clear message when inactive. + * 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 = @@ -629,8 +650,8 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin const wrap = (def: ToolDefinition): ToolDefinition => ({ ...def, execute: async (args, ctx) => { - if (!isActiveForAgent(ctx.agent, ctx.sessionID)) { - // Distinguish disabled-by-command from disabled-by-agent-filter + const active = isActiveForAgent(ctx.agent, ctx.sessionID); + if (!active) { const override = sessionEnabled.get(ctx.sessionID); throw new Error(override === false ? DISABLED_MSG : AGENT_MSG); } diff --git a/packages/opencode-tui-plugin/workflows-phase.tsx b/packages/opencode-tui-plugin/workflows-phase.tsx index a1265849..42c93366 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -60,6 +60,14 @@ interface MessageUpdatedEvent { }; } +interface CommandExecutedEvent { + properties?: { + name?: string; + sessionID?: string; + arguments?: string; + }; +} + /** * Extract ordered phase names from a workflow YAML file without a YAML parser. * @@ -253,6 +261,9 @@ function parseActiveAgentFilter(): Set | null { ); } +// Module-level map so session overrides survive sidebar remounts. +const sessionOverrideMap = new Map(); + // eslint-disable-next-line @typescript-eslint/require-await -- TuiPlugin signature requires Promise; plugin body is synchronous const tui: TuiPlugin = async api => { const activeAgentFilter = parseActiveAgentFilter(); @@ -272,6 +283,13 @@ 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(() => { @@ -285,8 +303,13 @@ const tui: TuiPlugin = async api => { return undefined; }); - // Derive whether the widget should be visible based on the agent filter. + // 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 @@ -339,6 +362,23 @@ 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 => { From c6653605fe378e77e6de280cd25ebf259adc7add Mon Sep 17 00:00:00 2001 From: Daniel Schroeder Date: Thu, 2 Apr 2026 21:14:25 +0200 Subject: [PATCH 09/11] Update packages/opencode-plugin/src/plugin.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/opencode-plugin/src/plugin.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index c8d09c6b..7d2dd1f8 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -131,7 +131,9 @@ export const WorkflowsPlugin: Plugin = async ( : null; // null = no filter, all agents active // Per-session override: /workflow on|off toggles this for the given session only. - // Defaults to true (enabled) for any session not explicitly toggled. + // 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; const sessionEnabled = new Map(); From 7643cd3044d1946552c487690009ceb0a1d3d204 Mon Sep 17 00:00:00 2001 From: Daniel Schroeder Date: Thu, 2 Apr 2026 21:14:48 +0200 Subject: [PATCH 10/11] Update packages/opencode-tui-plugin/workflows-phase.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../opencode-tui-plugin/workflows-phase.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/opencode-tui-plugin/workflows-phase.tsx b/packages/opencode-tui-plugin/workflows-phase.tsx index 42c93366..e8a16523 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -261,9 +261,32 @@ function parseActiveAgentFilter(): Set | null { ); } -// Module-level map so session overrides survive sidebar remounts. -const sessionOverrideMap = new Map(); +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 => { const activeAgentFilter = parseActiveAgentFilter(); From 01130399951e0b370234792d7c4c1e66d8288ad4 Mon Sep 17 00:00:00 2001 From: udondan Date: Thu, 2 Apr 2026 22:18:54 +0200 Subject: [PATCH 11/11] =?UTF-8?q?fix(opencode-plugin):=20address=20PR=20re?= =?UTF-8?q?view=20comments=20=E2=80=94=20bound=20pendingActivation,=20clea?= =?UTF-8?q?r=20on=20off,=20safe=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode-plugin/README.md | 1 - packages/opencode-plugin/src/plugin.ts | 63 +++++++++++++++---- .../opencode-tui-plugin/workflows-phase.tsx | 2 +- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index a3fd73e8..5596d6bd 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -99,7 +99,6 @@ Use the `/workflow` command to toggle the plugin on or off for the current sessi ``` /workflow off # disable for this session /workflow on # enable for this session (overrides WORKFLOW_ACTIVE_AGENTS filter) -/workflow # show current state ``` `/workflow on` acts as a full escape hatch — it forces workflows active even if the current agent is not listed in `WORKFLOW_ACTIVE_AGENTS`. diff --git a/packages/opencode-plugin/src/plugin.ts b/packages/opencode-plugin/src/plugin.ts index 7d2dd1f8..de53ad5d 100644 --- a/packages/opencode-plugin/src/plugin.ts +++ b/packages/opencode-plugin/src/plugin.ts @@ -136,12 +136,60 @@ export const WorkflowsPlugin: Plugin = async ( // for agents included in that filter. // Bounded to the last 50 sessions to prevent unbounded growth. const MAX_TRACKED_SESSIONS = 50; - const sessionEnabled = new Map(); + + // 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 Set(); + 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. @@ -163,18 +211,8 @@ export const WorkflowsPlugin: Plugin = async ( return activeAgentFilter.has((agent ?? '').toLowerCase()); } - /** - * Set per-session enabled state with LRU eviction. - * Deletes and re-inserts the key to refresh insertion order, so recently - * used sessions are never evicted before truly idle ones. - */ function setSessionEnabled(sessionID: string, value: boolean): void { - sessionEnabled.delete(sessionID); sessionEnabled.set(sessionID, value); - if (sessionEnabled.size > MAX_TRACKED_SESSIONS) { - const oldest = sessionEnabled.keys().next().value; - if (oldest !== undefined) sessionEnabled.delete(oldest); - } } logger.info('Workflows state initialized', { @@ -623,6 +661,7 @@ ACTION REQUIRED: Use transition_phase tool to move to a phase that allows editin }); } else if (args === 'off') { setSessionEnabled(hookInput.sessionID, false); + pendingActivation.delete(hookInput.sessionID); logger.info('Workflows toggled via command', { enabled: false, sessionID: hookInput.sessionID, diff --git a/packages/opencode-tui-plugin/workflows-phase.tsx b/packages/opencode-tui-plugin/workflows-phase.tsx index e8a16523..13255112 100644 --- a/packages/opencode-tui-plugin/workflows-phase.tsx +++ b/packages/opencode-tui-plugin/workflows-phase.tsx @@ -391,7 +391,7 @@ const tui: TuiPlugin = async api => { 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(); + const args = (ev.properties?.arguments ?? '').toLowerCase().trim(); if (args === 'on') { sessionOverrideMap.set(props.session_id, true); setSessionOverride(true);