-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathplugin.ts
More file actions
847 lines (768 loc) · 32 KB
/
Copy pathplugin.ts
File metadata and controls
847 lines (768 loc) · 32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
/**
* OpenCode Workflows Plugin
*
* Integrates workflows-core state management with OpenCode hooks to provide
* phase-aware development guidance and file edit restrictions.
*
* Hooks implemented:
* 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
*
* Logs are sent via OpenCode SDK's client.app.log() API
*/
import type { Plugin, PluginInput, Hooks, ToolDefinition } from './types.js';
import { Effect } from 'effect';
import { createProceedToPhaseTool } from './tool-handlers/proceed-to-phase.js';
import { createConductReviewTool } from './tool-handlers/conduct-review.js';
import { createResetDevelopmentTool } from './tool-handlers/reset-development.js';
import { createStartDevelopmentTool } from './tool-handlers/start-development.js';
import { createSetupProjectDocsTool } from './tool-handlers/setup-project-docs.js';
import {
createOpenCodeLogger,
createOpenCodeLoggerFactory,
type OpenCodeClient,
} from './opencode-logger.js';
import { PlanManager, InstructionGenerator } from '@codemcp/workflows-core';
import {
WhatsNextHandler,
type WhatsNextResult,
} from '@codemcp/workflows-server';
import {
createServerContext,
initializeServerContext,
} from './server-context.js';
import { stripWhatsNextReferences } from './utils.js';
// ---------------------------------------------------------------------------
// Monkey-patch resilience: ToolContext.ask return-type detection
//
// opencode has changed ToolContext.ask's return type between SDK releases:
// • SDK ≤ some pre-April-2026 version → Promise<void>
// • SDK after PR #21986 (Apr 10 2026) → Effect.Effect<void>
// • SDK 1.15.x (current, Jun 2026) → Promise<void> ← reverted again
//
// Rather than chasing each flip, we inspect the actual return value at
// runtime and dispatch accordingly. An Effect object carries the property
// key "~effect/Effect" (its TypeId), which is stable across Effect 3.x and
// 4.x. A plain Promise does not have that key and is always thenable.
//
// This is intentionally a monkey-patch: it compensates for an upstream API
// that has been unstable across SDK versions. If the SDK stabilises on one
// form, this helper can be simplified, but it is cheap enough to keep.
// ---------------------------------------------------------------------------
const EFFECT_TYPE_ID = '~effect/Effect';
/**
* Execute the result of `ToolContext.ask()`, regardless of whether the SDK
* version returns a `Promise<void>` or an `Effect.Effect<void>`.
*/
async function runAsk(
askResult: Promise<void> | Effect.Effect<void>
): Promise<void> {
if (
askResult !== null &&
typeof askResult === 'object' &&
EFFECT_TYPE_ID in askResult
) {
// SDK returned an Effect — bridge it into the async/await world.
await Effect.runPromise(askResult as Effect.Effect<void>);
} else {
// SDK returned a Promise (current behaviour as of SDK 1.15.x).
await (askResult as Promise<void>);
}
}
/**
* Buffered instructions from proceed_to_phase or start_development tools.
* Consumed (and cleared) by the next chat.message hook invocation.
* Falls back to WhatsNextHandler when null.
*/
interface BufferedInstructions {
phase: string;
instructions: string;
planFilePath: string;
allowedFilePatterns: string[];
}
/**
* Match a file path against a glob pattern.
* Supports patterns like:
* - `**\/*` → matches everything
* - `**\/*.md` → matches any .md file in any directory
* - `**\/*.test.ts` → matches test files
*/
function matchGlobPattern(filePath: string, pattern: string): boolean {
// Normalise to forward slashes
const normalised = filePath.replace(/\\/g, '/');
const baseName = normalised.split('/').pop() ?? '';
// `**/*` means "allow everything"
if (pattern === '**/*' || pattern === '*') {
return true;
}
// Convert glob pattern to a regex:
// - Escape regex metacharacters except * and .
// - `**/` at the start → match any path prefix (or empty)
// - `**` elsewhere → match any sequence of characters incl. /
// - `*` → match any sequence of characters excl. /
// - `.` → literal dot
const regexSource = pattern
.replace(/\\/g, '/')
// Escape regex special chars (except * which we handle separately)
.replace(/[+?^${}()|[\]]/g, '\\$&')
// Literal dot
.replace(/\./g, '\\.')
// `**/` at the start → optional path prefix
.replace(/^\*\*\//, '(?:.+\\/)?')
// remaining `**` → any chars including /
.replace(/\*\*/g, '.*')
// remaining `*` → any chars except /
.replace(/\*/g, '[^/]*');
const regex = new RegExp(`^${regexSource}$`);
// Try matching against full normalised path and against basename
return regex.test(normalised) || regex.test(baseName);
}
/**
* Check if a file edit is allowed based on glob patterns
*/
function isFileAllowed(filePath: string, patterns: string[]): boolean {
// If allowed patterns includes '**/*' or '*', all files are allowed
if (patterns.includes('**/*') || patterns.includes('*')) {
return true;
}
// Check if the file path matches any allowed glob pattern
return patterns.some(pattern => matchGlobPattern(filePath, pattern));
}
/**
* Main plugin export
*/
export const WorkflowsPlugin: Plugin = async (
input: PluginInput
): Promise<Hooks> => {
// Initialize logger using OpenCode SDK
const logger = createOpenCodeLogger(input.client);
const loggerFactory = createOpenCodeLoggerFactory(input.client);
logger.info('Plugin initializing', {
directory: input.directory,
worktree: input.worktree,
});
// Parse WORKFLOW_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 envAgentFilter = process.env.WORKFLOW_AGENTS;
const agentFilter: Set<string> | null =
envAgentFilter && envAgentFilter.trim()
? new Set(
envAgentFilter
.split(',')
.map(a => a.trim().toLowerCase())
.filter(Boolean)
)
: null; // null = no filter, all agents active
/**
* Check if workflows should run for the given agent.
* If WORKFLOW_AGENTS is set, only agents in that list are active.
* If WORKFLOW_AGENTS is not set, all agents are active.
*/
function isAgentEnabled(agent: string | undefined): boolean {
if (agentFilter === null) return true; // no filter → all agents active
return agentFilter.has((agent ?? '').toLowerCase());
}
logger.info('Workflows state initialized', {
agentFilter: agentFilter ? [...agentFilter] : 'all (no filter)',
});
// Initialize instruction generator
const planManager = new PlanManager();
const instructionGenerator = new InstructionGenerator();
// Cached ServerContext - created once, reused for all requests within a session
// This avoids creating new WorkflowManager/PluginRegistry instances per request
// When the OpenCode session changes, the context is invalidated to prevent
// showing workflow state from a previous session
let cachedServerContext: Awaited<
ReturnType<typeof createServerContext>
> | null = null;
let serverContextInitialized = false;
let currentSessionId: string | null = null;
let lastKnownSessionId: string | null = null;
// Buffered instructions from tools (proceed_to_phase, start_development).
// Consumed and cleared by the next chat.message hook call.
let bufferedInstructions: BufferedInstructions | null = null;
// Tracks sessions that just completed compaction and need a phase-aware
// continue message once the session becomes idle.
let postCompactionSession: string | null = null;
// When Hook 4 sends phase instructions via promptAsync after compaction,
// this flag is set to true so Hook 1 (chat.message) can skip injecting a
// duplicate synthetic part for that specific message.
let postCompactionMessagePending = false;
// Set to true right after session.compacted fires so that the very next
// chat.message (OpenCode's own auto-continue) bypasses the agent filter
// and injects proper phase instructions instead of a suppression notice.
let postCompactionAutoResume = false;
// Last-known model from chat.message hook. Cached so proceed_to_phase can
// pass providerID + modelID to the summarize API (which requires them).
let lastKnownModel: { providerID: string; modelID: string } | null = null;
// Last-known agent from chat.message hook. Used when sending the
// post-compaction phase-aware continue message so it runs under the
// correct agent (e.g. 'workflow') rather than OpenCode's default.
let lastKnownAgent: string | null = null;
/**
* Set buffered instructions from a tool result.
* The next chat.message hook will use these instead of calling WhatsNextHandler.
*/
function setBufferedInstructions(result: WhatsNextResult) {
bufferedInstructions = {
phase: result.phase,
instructions: result.instructions,
planFilePath: result.plan_file_path,
allowedFilePatterns: result.allowed_file_patterns,
};
}
// Helper to get an initialized ServerContext for handler delegation
// Creates once per session, reuses for all subsequent calls within the same session.
// If the session ID changes, the cached context is invalidated to prevent
// showing workflow state from a previous OpenCode session.
async function getServerContext() {
// Invalidate cache if session ID has changed
if (currentSessionId && currentSessionId !== lastKnownSessionId) {
logger.debug('Session ID changed, invalidating cached ServerContext', {
oldSessionId: lastKnownSessionId,
newSessionId: currentSessionId,
});
cachedServerContext = null;
serverContextInitialized = false;
lastKnownSessionId = currentSessionId;
}
if (!cachedServerContext) {
const sessionMetadata = currentSessionId
? {
referenceId: currentSessionId,
createdAt: new Date().toISOString(),
}
: undefined;
cachedServerContext = createServerContext({
projectDir: input.directory,
planManager,
instructionGenerator,
loggerFactory,
sessionMetadata,
});
// Set session metadata in the conversation manager for new conversations
if (sessionMetadata) {
cachedServerContext.conversationManager.setSessionMetadata(
sessionMetadata
);
}
}
if (!serverContextInitialized) {
await initializeServerContext(cachedServerContext);
serverContextInitialized = true;
}
return cachedServerContext;
}
// Note: We don't call getServerContext() at startup because currentSessionId is not yet available.
// The first call to getServerContext() will happen when the first hook is invoked, which ensures
// that the ServerContext is created with the correct session metadata. This is critical for
// properly linking workflow state to OpenCode sessions.
//
// The plugin registry logging that was previously done here was non-critical and can be removed
// to ensure session metadata is properly set when workflows are started.
/**
* Read current workflow state from ConversationManager via shared ServerContext.
* Returns null if no active conversation exists.
*/
async function getWorkflowState(): Promise<{
phase: string;
phaseDescription: string | null;
allowedFilePatterns: string[];
workflowName: string;
} | null> {
try {
const serverContext = await getServerContext();
const context =
await serverContext.conversationManager.getConversationContext();
const stateMachine = serverContext.workflowManager.loadWorkflowForProject(
context.projectPath,
context.workflowName
);
const phaseState = stateMachine.states[context.currentPhase];
return {
phase: context.currentPhase,
phaseDescription: phaseState?.description ?? null,
allowedFilePatterns: phaseState?.allowed_file_patterns ?? ['**/*'],
workflowName: context.workflowName,
};
} catch (_error) {
return null;
}
}
return {
/**
* Hook 1: chat.message
* Fires after user message is created but before LLM processes it.
* We add a synthetic part with phase instructions.
*/
'chat.message': async (hookInput, output) => {
// Capture session ID and detect session changes
if (hookInput.sessionID) {
if (!currentSessionId) {
currentSessionId = hookInput.sessionID;
lastKnownSessionId = hookInput.sessionID;
logger.debug('Captured initial session ID', {
sessionId: currentSessionId,
});
} else if (currentSessionId !== hookInput.sessionID) {
// Session has changed - the getServerContext() function will handle invalidation
currentSessionId = hookInput.sessionID;
logger.info('Session ID changed', {
oldSessionId: lastKnownSessionId,
newSessionId: currentSessionId,
});
}
}
// Cache the model for use by tools (e.g. proceed_to_phase needs it for summarize API)
if (hookInput.model) {
lastKnownModel = hookInput.model;
}
// Cache the agent for use by the post-compaction continue message.
// Only cache when the agent is enabled (i.e. a primary workflow agent),
// so we don't accidentally cache a subagent name.
if (hookInput.agent && isAgentEnabled(hookInput.agent)) {
lastKnownAgent = hookInput.agent;
}
// If this message was the post-compaction instructions prompt sent by Hook 4,
// skip synthetic part injection — the message body IS the instructions.
if (postCompactionMessagePending) {
postCompactionMessagePending = false;
logger.debug(
'chat.message: skipping synthetic part for post-compaction instructions message'
);
return;
}
// After compaction, OpenCode sends an auto-continue message which may arrive
// as a non-workflow agent (e.g. 'build'). In that case we still want to inject
// the phase instructions rather than the suppression/no-workflow notice, so we
// consume the flag and fall through to normal phase-instruction injection below.
const bypassAgentFilter = postCompactionAutoResume;
if (bypassAgentFilter) {
postCompactionAutoResume = false;
logger.debug(
'chat.message: bypassing agent filter for post-compaction auto-resume',
{ agent: hookInput.agent }
);
}
// If WORKFLOW_AGENTS is set and this agent is not in the allowlist, inject a
// suppression instruction as a synthetic part so the LLM knows not to call the
// workflow tools (which would only throw errors for non-enabled agents).
// We use chat.message (not experimental.chat.system.transform) because:
// 1. chat.message already has hookInput.agent directly — no stale-state risk.
// 2. chat.message fires reliably for every user turn; transform may fire for
// intermediate tool-loop LLM calls without a preceding chat.message.
if (!bypassAgentFilter && !isAgentEnabled(hookInput.agent)) {
logger.debug(
'chat.message: Agent not enabled — injecting tool suppression',
{
agent: hookInput.agent,
}
);
output.parts.push({
id: `prt_workflows_suppress_${Date.now()}`,
sessionID: hookInput.sessionID,
messageID: hookInput.messageID || output.message.id,
type: 'text' as const,
synthetic: true,
text:
'IMPORTANT: The following workflow tools are NOT available in this session and must NOT be called under any circumstances: ' +
'start_development, proceed_to_phase, conduct_review, reset_development, setup_project_docs. ' +
'Calling them will result in an error. Ignore them entirely.',
} as (typeof output.parts)[0]);
return;
}
let result: WhatsNextResult | null = null;
// If a tool (proceed_to_phase / start_development) buffered instructions,
// use those — they are authoritative and avoid potential staleness from
// re-querying WhatsNextHandler.
if (bufferedInstructions) {
logger.debug(
'chat.message: Using buffered instructions from tool call',
{ phase: bufferedInstructions.phase }
);
result = {
phase: bufferedInstructions.phase,
instructions: bufferedInstructions.instructions,
plan_file_path: bufferedInstructions.planFilePath,
allowed_file_patterns: bufferedInstructions.allowedFilePatterns,
};
// Consume the buffer — next call will fall through to WhatsNextHandler
bufferedInstructions = null;
} else {
// No buffered instructions — query WhatsNextHandler (reads from disk)
try {
const serverContext = await getServerContext();
const handler = new WhatsNextHandler();
const handlerResult = await handler.handle({}, serverContext);
if (!handlerResult.success || !handlerResult.data) {
logger.info(
'chat.message: No active workflow, injecting start prompt'
);
output.parts.push({
id: `prt_workflows_${Date.now()}`,
sessionID: hookInput.sessionID,
messageID: hookInput.messageID || output.message.id,
type: 'text' as const,
synthetic: true,
text: `No Active Workflow Detected. You MUST initiate a new development workflow before proceeding. First, create a new branch with a meaningful name using a conventional commit prefix (e.g., \`feat/add-new-feature\`, \`fix/bug-description\`, \`refactor/improve-logic\`). Then call the \`start_development\` tool to begin. Do NOT attempt any file edits or tool executions until a workflow is active.`,
} as (typeof output.parts)[0]);
return;
}
result = handlerResult.data;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes('CONVERSATION_NOT_FOUND')) {
logger.info(
'chat.message: No active workflow, injecting start prompt'
);
output.parts.push({
id: `prt_workflows_${Date.now()}`,
sessionID: hookInput.sessionID,
messageID: hookInput.messageID || output.message.id,
type: 'text' as const,
synthetic: true,
text: `No Active Workflow Detected. You MUST initiate a new development workflow before proceeding. First, create a new branch with a meaningful name using a conventional commit prefix (e.g., \`feat/add-new-feature\`, \`fix/bug-description\`, \`refactor/improve-logic\`). Then call the \`start_development\` tool to begin. Do NOT attempt any file edits or tool executions until a workflow is active.`,
} as (typeof output.parts)[0]);
return;
}
logger.error('chat.message: Error delegating to WhatsNextHandler', {
error: errorMessage,
});
return;
}
}
logger.info('chat.message hook fired', {
sessionID: hookInput.sessionID,
phase: result.phase,
});
// Strip whats_next() references — plugin auto-injects instructions
const instructionText = stripWhatsNextReferences(result.instructions);
if (!instructionText.trim()) {
logger.info('chat.message: No instructions to inject');
return;
}
output.parts.push({
id: `prt_workflows_${Date.now()}`,
sessionID: hookInput.sessionID,
messageID: hookInput.messageID || output.message.id,
type: 'text' as const,
synthetic: true,
text: instructionText,
} as (typeof output.parts)[0]);
logger.info('chat.message: injected phase instructions', {
phase: result.phase,
length: instructionText.length,
preview: instructionText.slice(0, 300),
});
},
/**
* Hook 2: tool.execute.before
* Fires before each tool execution. We block disallowed file edits based on phase.
*/
'tool.execute.before': async (hookInput, output) => {
const editTools = ['edit', 'write', 'patch', 'apply_patch', 'multiedit'];
if (!editTools.includes(hookInput.tool)) {
return;
}
// Read current workflow state from ConversationManager
const state = await getWorkflowState();
if (!state) {
// No active workflow — allow all edits
return;
}
logger.debug('tool.execute.before', {
tool: hookInput.tool,
phase: state.phase,
});
// Extract file path from tool args
const args = output.args as Record<string, unknown>;
const filePath = String(args?.filePath || args?.path || '');
if (!filePath) {
logger.warn('Edit tool called without filePath', {
tool: hookInput.tool,
});
return;
}
if (!isFileAllowed(filePath, state.allowedFilePatterns)) {
const allowedList = state.allowedFilePatterns
.map(p => ` • ${p}`)
.join('\n');
const error = `BLOCKED: Cannot edit "${filePath}" in ${state.phase} phase.
Current phase "${state.phase}" only allows editing:
${allowedList}
ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editing this file type, OR focus on files matching the allowed patterns above.`;
logger.error('BLOCKING edit', {
filePath,
phase: state.phase,
allowedPatterns: state.allowedFilePatterns,
});
throw new Error(error);
}
},
/**
* Hook 3: experimental.session.compacting
* Fires when session is being compacted. We provide full phase instructions
* so the compaction summary is self-sufficient — the AI knows exactly what
* to continue even if the chat.message hook doesn't fire for the synthetic
* auto-compaction "continue" message.
*/
'experimental.session.compacting': async (hookInput, output) => {
logger.debug('experimental.session.compacting hook fired', {
sessionID: hookInput.sessionID,
});
const state = await getWorkflowState();
if (!state) {
logger.debug('No active workflow - skipping compaction guidance');
return;
}
// Get full phase instructions to embed in compaction context
let phaseInstructions: string | null = null;
try {
const serverContext = await getServerContext();
const handler = new WhatsNextHandler();
const handlerResult = await handler.handle({}, serverContext);
if (handlerResult.success && handlerResult.data) {
phaseInstructions = stripWhatsNextReferences(
handlerResult.data.instructions
);
}
} catch (_err) {
// Fall back to minimal guidance if instructions can't be fetched
}
output.context.push(
'Preserve: user intents, key decisions, significant changes and the reasoning why they were made. Remove tool calls, intermediate thoughts, and minor details.'
);
if (phaseInstructions) {
output.context.push(
`Current workflow phase: ${state.phase}. After compaction, resume with full phase context:\n\n${phaseInstructions}`
);
} else {
output.context.push(
`End summary with: "Continue ${state.phase} phase. ${state.phaseDescription || ''}"`
);
}
logger.info('Injected compaction guidance', {
phase: state.phase,
fullInstructions: phaseInstructions !== null,
});
},
/**
* Hook 4: event
* Listens for bus events. When a session compaction completes we record it,
* then when the session becomes idle we send a real user message so the
* normal chat.message hook fires and injects phase instructions — giving the
* AI full workflow context to continue after the compaction.
*
* We intentionally do NOT suppress the default synthetic "continue" message
* (experimental.compaction.autocontinue). It may produce a first generic AI
* response, but the idle trigger below ensures a proper phase-aware follow-up.
*/
event: async ({ event }) => {
logger.debug('event hook fired', { type: event.type });
if (event.type === 'session.compacted') {
postCompactionSession = event.properties.sessionID as string;
// Set flag so the next chat.message (OpenCode's own auto-continue,
// which may fire as a non-workflow agent like 'build') bypasses the
// agent filter and injects proper phase instructions instead of a
// suppression/no-workflow notice.
postCompactionAutoResume = true;
logger.info('session.compacted: pending phase-aware continue', {
sessionID: postCompactionSession,
});
return;
}
if (
event.type === 'session.idle' &&
postCompactionSession === (event.properties.sessionID as string)
) {
const sessionID = postCompactionSession;
postCompactionSession = null;
logger.info(
'session.idle after compaction: sending phase-aware continue',
{ sessionID }
);
// Wait a short time to allow the OpenCode runner state machine to
// fully settle after compaction before we fire the follow-up prompt.
await new Promise(resolve => setTimeout(resolve, 500));
// Fetch the current phase instructions to use as the prompt text.
// This way the AI receives its phase context directly as the user message,
// and the chat.message hook skips injecting a duplicate synthetic part.
let promptText = 'Continue with the current phase.';
let usedPhaseInstructions = false;
try {
const serverContext = await getServerContext();
const handler = new WhatsNextHandler();
const handlerResult = await handler.handle({}, serverContext);
if (handlerResult.success && handlerResult.data) {
const instructions = stripWhatsNextReferences(
handlerResult.data.instructions
);
if (instructions.trim()) {
promptText = instructions;
usedPhaseInstructions = true;
}
}
} catch (_err) {
// Fall back to hardcoded string
}
// Set flag so chat.message skips synthetic part for this message
// only when we're sending actual instructions (not the fallback).
if (usedPhaseInstructions) {
postCompactionMessagePending = true;
}
try {
const client = input.client as {
session: {
promptAsync(params: {
path: { id: string };
body: {
parts: Array<{ type: string; text: string }>;
agent?: string;
};
}): Promise<unknown>;
};
};
await client.session.promptAsync({
path: { id: sessionID },
body: {
parts: [{ type: 'text', text: promptText }],
...(lastKnownAgent ? { agent: lastKnownAgent } : {}),
},
});
logger.info('session.idle: phase-aware continue sent (async)', {
sessionID,
});
} catch (err) {
logger.error('session.idle: failed to send phase-aware continue', {
sessionID,
error: err instanceof Error ? err.message : String(err),
});
}
}
},
/**
* Custom tools - always registered to allow clear error messages.
* Each tool's execute method checks the agent filter at call time and throws
* an error if the agent is not allowed to use workflows.
*/
tool: await (async (): Promise<{ [key: string]: ToolDefinition }> => {
/**
* Build human-readable permission patterns for the web UI.
* The opencode web permission dialog only shows `patterns`, so we put
* meaningful "key: value" strings here instead of the generic '*'.
*/
const buildPermissionPatterns = (
toolName: string,
args: Record<string, unknown>
): string[] => {
const entry = (key: string, value: unknown): string | null => {
if (value === undefined || value === null || value === '')
return null;
return `${key}: ${value}`;
};
switch (toolName) {
case 'start_development': {
const patterns = [entry('workflow', args['workflow'])].filter(
(p): p is string => p !== null
);
return patterns.length > 0 ? patterns : ['*'];
}
case 'proceed_to_phase': {
const patterns = [
entry('target_phase', args['target_phase']),
entry('reason', args['reason']),
].filter((p): p is string => p !== null);
return patterns.length > 0 ? patterns : ['*'];
}
case 'conduct_review': {
const patterns = [
entry('target_phase', args['target_phase']),
].filter((p): p is string => p !== null);
return patterns.length > 0 ? patterns : ['*'];
}
case 'reset_development': {
const patterns = [
args['delete_plan'] === true
? entry('delete_plan', args['delete_plan'])
: null,
entry('reason', args['reason']),
].filter((p): p is string => p !== null);
return patterns.length > 0 ? patterns : ['*'];
}
case 'setup_project_docs': {
const patterns = [
entry('architecture', args['architecture']),
entry('requirements', args['requirements']),
entry('design', args['design']),
].filter((p): p is string => p !== null);
return patterns.length > 0 ? patterns : ['*'];
}
default:
return ['*'];
}
};
const wrap = (toolName: string, def: ToolDefinition): ToolDefinition => ({
...def,
execute: async (args, ctx) => {
const agent = ctx.agent;
if (!isAgentEnabled(agent)) {
throw new Error(
`Workflows are not enabled for this agent (${agent}). Set WORKFLOW_AGENTS environment variable to include this agent, or use a different agent.`
);
}
await runAsk(
ctx.ask({
permission: toolName,
patterns: buildPermissionPatterns(
toolName,
args as Record<string, unknown>
),
always: ['*'],
metadata: args as Record<string, unknown>,
})
);
return def.execute(args, ctx);
},
});
return {
start_development: wrap(
'start_development',
createStartDevelopmentTool(
input.directory,
getServerContext,
setBufferedInstructions
)
),
proceed_to_phase: wrap(
'proceed_to_phase',
createProceedToPhaseTool(
getServerContext,
setBufferedInstructions,
input.client as unknown as OpenCodeClient,
() => lastKnownModel
)
),
conduct_review: wrap(
'conduct_review',
createConductReviewTool(getServerContext)
),
reset_development: wrap(
'reset_development',
createResetDevelopmentTool(input.directory, getServerContext)
),
setup_project_docs: wrap(
'setup_project_docs',
await createSetupProjectDocsTool(input.directory, getServerContext)
),
};
})(),
};
};
// Default export for opencode plugin loader
export default {
id: 'workflows',
server: WorkflowsPlugin,
} satisfies { id: string; server: Plugin };