Skip to content

Commit 6fbb56e

Browse files
authored
feat: add session metadata linking to establish foreign key relationship (#249)
Establish a proper foreign key relationship between OpenCode sessions and workflow state instead of relying on file modification times. Changes: - Add SessionMetadata interface to core types with referenceId and createdAt - Extend ConversationState with optional sessionMetadata field - Extend ServerContext to include sessionMetadata property - Add setSessionMetadata/getSessionMetadata methods to ConversationManager - Update ConversationManager to store sessionMetadata when creating new conversation states - Pass sessionMetadata from OpenCode plugin to ServerContext at initialization - Capture OpenCode sessionID in chat.message hook for use in subsequent operations - Implement sessionId-based lookup in TUI plugin (readStateBySessionId) - Keep mtime-based fallback in TUI plugin for backward compatibility - Export SessionMetadata type from mcp-server for external use This enables the TUI plugin to correctly display the active phase of the current OpenCode session instead of always showing the most recently modified workflow's phase.
1 parent 695d4f9 commit 6fbb56e

8 files changed

Lines changed: 141 additions & 6 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,8 @@ dist
139139
# Mac
140140
.DS_Store
141141
**/.DS_Store
142+
143+
# Generated resources by build scripts
142144
packages/mcp-server/resources
143145
packages/cli/resources
146+
packages/opencode-plugin/resources

packages/core/src/conversation-manager.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { existsSync } from 'node:fs';
1212
import { createLogger } from './logger.js';
1313
import { getPathBasename } from './path-validation-utils.js';
1414
import type { IPersistence } from './persistence-interface.js';
15-
import type { ConversationState, ConversationContext } from './types.js';
15+
import type {
16+
ConversationState,
17+
ConversationContext,
18+
SessionMetadata,
19+
} from './types.js';
1620
import { WorkflowManager } from './workflow-manager.js';
1721
import { PlanManager } from './plan-manager.js';
1822

@@ -22,6 +26,7 @@ export class ConversationManager {
2226
private database: IPersistence;
2327
private projectPath: string;
2428
private workflowManager: WorkflowManager;
29+
private currentSessionMetadata: SessionMetadata | null = null;
2530

2631
constructor(
2732
database: IPersistence,
@@ -33,6 +38,26 @@ export class ConversationManager {
3338
this.projectPath = projectPath;
3439
}
3540

41+
/**
42+
* Set session metadata for subsequent conversation state operations.
43+
* This links the workflow state to an external session context.
44+
*
45+
* @param sessionMetadata - Session metadata to store with conversation state
46+
*/
47+
setSessionMetadata(sessionMetadata: SessionMetadata): void {
48+
this.currentSessionMetadata = sessionMetadata;
49+
logger.debug('Session metadata set', {
50+
referenceId: sessionMetadata.referenceId,
51+
});
52+
}
53+
54+
/**
55+
* Get current session metadata
56+
*/
57+
getSessionMetadata(): SessionMetadata | null {
58+
return this.currentSessionMetadata;
59+
}
60+
3661
/**
3762
* Get conversation state by ID
3863
*/
@@ -256,6 +281,9 @@ export class ConversationManager {
256281
requireReviewsBeforePhaseTransition: false, // Default to false for new conversations
257282
createdAt: timestamp,
258283
updatedAt: timestamp,
284+
...(this.currentSessionMetadata && {
285+
sessionMetadata: this.currentSessionMetadata,
286+
}),
259287
};
260288

261289
// Save to database

packages/core/src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
* Common types used across the application
33
*/
44

5+
/**
6+
* Session metadata linking workflow state to an external session/context
7+
*/
8+
export interface SessionMetadata {
9+
referenceId: string;
10+
createdAt: string;
11+
}
12+
513
/**
614
* Interface for interaction log entries
715
*/
@@ -42,6 +50,7 @@ export interface ConversationState {
4250
requireReviewsBeforePhaseTransition: boolean;
4351
createdAt: string;
4452
updatedAt: string;
53+
sessionMetadata?: SessionMetadata;
4554
}
4655

4756
/**

packages/mcp-server/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export {
2020
export * from './tool-handlers/index.js';
2121

2222
// Re-export types needed by external consumers
23-
export type { ServerContext, HandlerResult } from './types.js';
23+
export type { ServerContext, HandlerResult, SessionMetadata } from './types.js';
2424

2525
// Re-export plugin system for external use (e.g., OpenCode plugin)
2626
export { PluginRegistry } from './plugin-system/index.js';

packages/mcp-server/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ import { InteractionLogger } from '@codemcp/workflows-core';
1111
import type { TaskBackendConfig, LoggerFactory } from '@codemcp/workflows-core';
1212
import type { IPluginRegistry } from './plugin-system/plugin-interfaces.js';
1313

14+
/**
15+
* Session metadata linking workflow state to an external session/context
16+
*/
17+
export interface SessionMetadata {
18+
referenceId: string;
19+
createdAt: string;
20+
}
21+
1422
/**
1523
* Server context shared across all handlers
1624
* Contains all the core dependencies needed by tool and resource handlers
@@ -26,6 +34,8 @@ export interface ServerContext {
2634
pluginRegistry?: IPluginRegistry;
2735
/** Logger factory for creating component loggers - if not provided, handlers use global createLogger */
2836
loggerFactory?: LoggerFactory;
37+
/** Session metadata linking workflow state to external session context */
38+
sessionMetadata?: SessionMetadata;
2939
}
3040

3141
/**

packages/opencode-plugin/src/plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const WorkflowsPlugin: Plugin = async (
129129
ReturnType<typeof createServerContext>
130130
> | null = null;
131131
let serverContextInitialized = false;
132+
let currentSessionId: string | null = null;
132133

133134
// Buffered instructions from tools (proceed_to_phase, start_development).
134135
// Consumed and cleared by the next chat.message hook call.
@@ -151,12 +152,27 @@ export const WorkflowsPlugin: Plugin = async (
151152
// Creates once, reuses for all subsequent calls
152153
async function getServerContext() {
153154
if (!cachedServerContext) {
155+
const sessionMetadata = currentSessionId
156+
? {
157+
referenceId: currentSessionId,
158+
createdAt: new Date().toISOString(),
159+
}
160+
: undefined;
161+
154162
cachedServerContext = createServerContext({
155163
projectDir: input.directory,
156164
planManager,
157165
instructionGenerator,
158166
loggerFactory,
167+
sessionMetadata,
159168
});
169+
170+
// Set session metadata in the conversation manager for new conversations
171+
if (sessionMetadata) {
172+
cachedServerContext.conversationManager.setSessionMetadata(
173+
sessionMetadata
174+
);
175+
}
160176
}
161177

162178
if (!serverContextInitialized) {
@@ -218,6 +234,12 @@ export const WorkflowsPlugin: Plugin = async (
218234
* We add a synthetic part with phase instructions.
219235
*/
220236
'chat.message': async (hookInput, output) => {
237+
// Capture session ID from the first hook that has it
238+
if (hookInput.sessionID && !currentSessionId) {
239+
currentSessionId = hookInput.sessionID;
240+
logger.debug('Captured session ID', { sessionId: currentSessionId });
241+
}
242+
221243
// Skip if workflows are disabled
222244
if (!workflowsEnabled) {
223245
logger.debug('chat.message: Workflows disabled, skipping hook');

packages/opencode-plugin/src/server-context.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type IInstructionGenerator,
2121
type LoggerFactory,
2222
} from '@codemcp/workflows-core';
23+
import type { SessionMetadata } from '@codemcp/workflows-server';
2324
import * as path from 'node:path';
2425

2526
export interface ServerContextOptions {
@@ -28,6 +29,8 @@ export interface ServerContextOptions {
2829
instructionGenerator: IInstructionGenerator;
2930
/** Optional logger factory - if provided, handlers will use this instead of global createLogger */
3031
loggerFactory?: LoggerFactory;
32+
/** Optional session metadata to link workflow state to external context */
33+
sessionMetadata?: SessionMetadata;
3134
}
3235

3336
/**
@@ -39,8 +42,13 @@ export interface ServerContextOptions {
3942
export function createServerContext(
4043
options: ServerContextOptions
4144
): ServerContext {
42-
const { projectDir, planManager, instructionGenerator, loggerFactory } =
43-
options;
45+
const {
46+
projectDir,
47+
planManager,
48+
instructionGenerator,
49+
loggerFactory,
50+
sessionMetadata,
51+
} = options;
4452

4553
// Create workflow manager and load project workflows
4654
const workflowManager = new WorkflowManager();
@@ -84,6 +92,7 @@ export function createServerContext(
8492
projectPath: projectDir,
8593
pluginRegistry,
8694
loggerFactory,
95+
sessionMetadata,
8796
};
8897
}
8998

packages/opencode-tui-plugin/workflows-phase.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const WORKFLOW_TOOLS = new Set([
2929
interface StateJson {
3030
currentPhase?: string;
3131
workflowName?: string;
32+
sessionMetadata?: {
33+
referenceId: string;
34+
createdAt: string;
35+
};
3236
}
3337

3438
interface MessagePartUpdatedEvent {
@@ -41,6 +45,52 @@ interface MessagePartUpdatedEvent {
4145
};
4246
}
4347

48+
function readStateBySessionId(
49+
sessionDir: string,
50+
sessionId: string
51+
): { phase: string; workflow: string } | null {
52+
try {
53+
// require() is intentional: top-level ESM imports of Node built-ins are not
54+
// supported in the Bun plugin runtime.
55+
// eslint-disable-next-line @typescript-eslint/no-require-imports
56+
const fsSync = require('node:fs') as typeof fs;
57+
// eslint-disable-next-line @typescript-eslint/no-require-imports
58+
const pathSync = require('node:path') as typeof path;
59+
const vibeDir = pathSync.join(sessionDir, '.vibe', 'conversations');
60+
const dirs = fsSync.readdirSync(vibeDir);
61+
62+
// Search for the state file that matches this session ID
63+
for (const dir of dirs) {
64+
const file = pathSync.join(vibeDir, dir, 'state.json');
65+
try {
66+
const state = JSON.parse(
67+
fsSync.readFileSync(file, 'utf8')
68+
) as StateJson;
69+
70+
// Check if this state's sessionMetadata matches the current session ID
71+
if (state.sessionMetadata?.referenceId === sessionId) {
72+
if (!state.currentPhase && !state.workflowName) return null;
73+
return {
74+
phase: state.currentPhase ?? '—',
75+
workflow: state.workflowName ?? '—',
76+
};
77+
}
78+
} catch {
79+
// unreadable entry — skip silently
80+
}
81+
}
82+
83+
return null;
84+
} catch {
85+
return null;
86+
}
87+
}
88+
89+
/**
90+
* Fallback: Read the most recently modified state.
91+
* Used when no matching session ID is found or for backward compatibility.
92+
* This is deprecated in favor of sessionId-based lookup.
93+
*/
4494
function readLatestState(
4595
sessionDir: string
4696
): { phase: string; workflow: string } | null {
@@ -95,7 +145,9 @@ const tui: TuiPlugin = async api => {
95145
// not only after the first tool call.
96146
const dir = api.state.path.directory;
97147
if (dir) {
98-
setState(readLatestState(dir));
148+
// Try session ID-based lookup first, fall back to most recent state
149+
const stateBySession = readStateBySessionId(dir, props.session_id);
150+
setState(stateBySession || readLatestState(dir));
99151
}
100152

101153
const offPart = api.event.on('message.part.updated', e => {
@@ -106,7 +158,9 @@ const tui: TuiPlugin = async api => {
106158
if (part.type !== 'tool') return;
107159
if (!part.tool || !WORKFLOW_TOOLS.has(part.tool)) return;
108160
if (!dir) return;
109-
setState(readLatestState(dir));
161+
// Use session ID-based lookup to get the correct state for this session
162+
const stateBySession = readStateBySessionId(dir, props.session_id);
163+
setState(stateBySession || readLatestState(dir));
110164
});
111165
onCleanup(offPart);
112166

0 commit comments

Comments
 (0)