alchemylab 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ /**
3
+ * Hook Condition Evaluator
4
+ *
5
+ * Evaluates hook conditions against event variables to determine
6
+ * if a hook should be triggered.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.evaluateSingleCondition = evaluateSingleCondition;
10
+ exports.evaluatePathsCondition = evaluatePathsCondition;
11
+ exports.evaluateCondition = evaluateCondition;
12
+ /**
13
+ * Simple glob-to-regex converter for pattern matching
14
+ * Handles common glob patterns without external dependencies
15
+ */
16
+ // Cache compiled glob→regex patterns to avoid recompilation on every condition check
17
+ const _globCache = new Map();
18
+ function globToRegex(pattern) {
19
+ const cached = _globCache.get(pattern);
20
+ if (cached)
21
+ return cached;
22
+ let regex = pattern
23
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
24
+ .replace(/\*\*/g, '{{GLOBSTAR}}')
25
+ .replace(/\*/g, '[^/]*')
26
+ .replace(/\?/g, '.')
27
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*');
28
+ const compiled = new RegExp(`^${regex}$`);
29
+ // Cap cache to prevent unbounded growth
30
+ if (_globCache.size > 500)
31
+ _globCache.clear();
32
+ _globCache.set(pattern, compiled);
33
+ return compiled;
34
+ }
35
+ /**
36
+ * Check if a string matches a glob pattern
37
+ */
38
+ function isMatch(str, pattern) {
39
+ try {
40
+ return globToRegex(pattern).test(str);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * Evaluate a single condition against the field value
48
+ */
49
+ function evaluateSingleCondition(fieldValue, operator, value) {
50
+ switch (operator) {
51
+ case 'matches':
52
+ return isMatch(fieldValue, value);
53
+ case 'not_matches':
54
+ return !isMatch(fieldValue, value);
55
+ case 'contains':
56
+ return fieldValue.includes(value);
57
+ case 'not_contains':
58
+ return !fieldValue.includes(value);
59
+ case 'equals':
60
+ return fieldValue === value;
61
+ case 'not_equals':
62
+ return fieldValue !== value;
63
+ default:
64
+ return true;
65
+ }
66
+ }
67
+ /**
68
+ * Evaluate a condition against a paths array (comma-separated)
69
+ * For 'matches'/'contains' - returns true if ANY path matches
70
+ * For 'not_matches'/'not_contains' - returns true if ALL paths don't match
71
+ */
72
+ function evaluatePathsCondition(paths, operator, value) {
73
+ switch (operator) {
74
+ case 'matches':
75
+ return paths.some(p => isMatch(p, value));
76
+ case 'not_matches':
77
+ return paths.every(p => !isMatch(p, value));
78
+ case 'contains':
79
+ return paths.some(p => p.includes(value));
80
+ case 'not_contains':
81
+ return paths.every(p => !p.includes(value));
82
+ case 'equals':
83
+ return paths.some(p => p === value);
84
+ case 'not_equals':
85
+ return paths.every(p => p !== value);
86
+ default:
87
+ return true;
88
+ }
89
+ }
90
+ /**
91
+ * Evaluate hook conditions against current variables
92
+ * Returns true if hook should be triggered, false to skip
93
+ */
94
+ function evaluateCondition(condition, variables) {
95
+ // No conditions or empty conditions = always match
96
+ if (!condition || !condition.conditions || condition.conditions.length === 0) {
97
+ return true;
98
+ }
99
+ const results = condition.conditions.map((c) => {
100
+ const fieldValue = variables[c.field] || '';
101
+ // For 'paths' field, check if any path matches
102
+ if (c.field === 'paths' && fieldValue.includes(', ')) {
103
+ const paths = fieldValue.split(', ');
104
+ return evaluatePathsCondition(paths, c.operator, c.value);
105
+ }
106
+ // Single value evaluation
107
+ return evaluateSingleCondition(fieldValue, c.operator, c.value);
108
+ });
109
+ // Combine results based on logic (default: AND)
110
+ return condition.logic === 'OR'
111
+ ? results.some((r) => r)
112
+ : results.every((r) => r);
113
+ }
114
+ //# sourceMappingURL=condition-evaluator.js.map
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ /**
3
+ * Deduplication Utilities
4
+ *
5
+ * Functions to prevent duplicate hook triggers when OpenCode fires
6
+ * the same event multiple times in quick succession.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.createToolCallHash = createToolCallHash;
10
+ exports.isDuplicateToolCall = isDuplicateToolCall;
11
+ exports.clearRecentCalls = clearRecentCalls;
12
+ exports.getRecentCallsCount = getRecentCallsCount;
13
+ exports.hasProcessedMessage = hasProcessedMessage;
14
+ exports.markMessageProcessed = markMessageProcessed;
15
+ exports.tryMarkMessageProcessed = tryMarkMessageProcessed;
16
+ exports.clearProcessedMessages = clearProcessedMessages;
17
+ // Track recent tool calls with timestamps
18
+ const recentCalls = new Map();
19
+ // Default window for considering calls as duplicates (2 seconds)
20
+ const DEFAULT_DEDUP_WINDOW = 2000;
21
+ /**
22
+ * Create a hash for a tool call based on tool name and output/input
23
+ * Used to identify potentially duplicate calls
24
+ *
25
+ * @param toolName - The name of the tool being called
26
+ * @param text - The output or input text to include in hash (truncated to 100 chars)
27
+ * @returns A string hash identifying this specific call
28
+ */
29
+ function createToolCallHash(toolName, text) {
30
+ // Simple hash using tool name + first 100 chars of text
31
+ return `${toolName}:${text?.substring(0, 100) || ''}`;
32
+ }
33
+ /**
34
+ * Check if a tool call is a duplicate of a recent call
35
+ * Also cleans up old entries from the tracking map
36
+ *
37
+ * @param hash - The hash created by createToolCallHash
38
+ * @param now - Current timestamp (allows injection for testing)
39
+ * @param dedupWindow - Time window in ms to consider calls as duplicates (default: 2000ms)
40
+ * @returns true if this appears to be a duplicate call, false otherwise
41
+ */
42
+ function isDuplicateToolCall(hash, now = Date.now(), dedupWindow = DEFAULT_DEDUP_WINDOW) {
43
+ const lastTime = recentCalls.get(hash);
44
+ if (lastTime && (now - lastTime) < dedupWindow) {
45
+ return true;
46
+ }
47
+ // Clean up old entries (older than 2x the dedup window)
48
+ const cleanupThreshold = dedupWindow * 2;
49
+ for (const [key, time] of recentCalls.entries()) {
50
+ if (now - time > cleanupThreshold) {
51
+ recentCalls.delete(key);
52
+ }
53
+ }
54
+ // Record this call
55
+ recentCalls.set(hash, now);
56
+ return false;
57
+ }
58
+ /**
59
+ * Clear all tracked calls (useful for testing)
60
+ */
61
+ function clearRecentCalls() {
62
+ recentCalls.clear();
63
+ }
64
+ /**
65
+ * Get the number of tracked calls (useful for testing)
66
+ */
67
+ function getRecentCallsCount() {
68
+ return recentCalls.size;
69
+ }
70
+ // Track last processed message ID per session to prevent duplicate triggers
71
+ const lastProcessedMessageId = new Map();
72
+ /**
73
+ * Check if we've already processed this message for a session
74
+ * Returns true if this message was already processed (should skip)
75
+ * @deprecated Use tryMarkMessageProcessed for atomic check-and-mark
76
+ */
77
+ function hasProcessedMessage(sessionId, messageId) {
78
+ if (!messageId)
79
+ return false;
80
+ const lastId = lastProcessedMessageId.get(sessionId);
81
+ return lastId === messageId;
82
+ }
83
+ /**
84
+ * Mark a message as processed for a session
85
+ * @deprecated Use tryMarkMessageProcessed for atomic check-and-mark
86
+ */
87
+ function markMessageProcessed(sessionId, messageId) {
88
+ if (!messageId)
89
+ return;
90
+ lastProcessedMessageId.set(sessionId, messageId);
91
+ // Clean up old entries (keep max 100 sessions)
92
+ if (lastProcessedMessageId.size > 100) {
93
+ const firstKey = lastProcessedMessageId.keys().next().value;
94
+ if (firstKey)
95
+ lastProcessedMessageId.delete(firstKey);
96
+ }
97
+ }
98
+ /**
99
+ * Atomically check if message was processed and mark it if not.
100
+ * Returns true if this call should proceed (message was NOT already processed).
101
+ * Returns false if should skip (message was already processed by another call).
102
+ *
103
+ * This prevents race conditions where two concurrent calls both check
104
+ * hasProcessedMessage() before either calls markMessageProcessed().
105
+ */
106
+ function tryMarkMessageProcessed(sessionId, messageId) {
107
+ if (!messageId)
108
+ return true; // No message ID = proceed (can't dedupe)
109
+ const lastId = lastProcessedMessageId.get(sessionId);
110
+ if (lastId === messageId) {
111
+ // Already processed - skip
112
+ return false;
113
+ }
114
+ // Mark as processed immediately (atomic with check above in single-threaded JS)
115
+ lastProcessedMessageId.set(sessionId, messageId);
116
+ // Clean up old entries (keep max 100 sessions)
117
+ if (lastProcessedMessageId.size > 100) {
118
+ const firstKey = lastProcessedMessageId.keys().next().value;
119
+ if (firstKey)
120
+ lastProcessedMessageId.delete(firstKey);
121
+ }
122
+ return true; // Proceed - we claimed this message
123
+ }
124
+ /**
125
+ * Clear processed message tracking (for testing)
126
+ */
127
+ function clearProcessedMessages() {
128
+ lastProcessedMessageId.clear();
129
+ }
130
+ //# sourceMappingURL=dedup-utils.js.map
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ /**
3
+ * Hook Utility Functions
4
+ *
5
+ * Shared utilities for hook processing including template interpolation
6
+ * and ID extraction from tool outputs.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.interpolatePrompt = interpolatePrompt;
10
+ exports.extractRequestId = extractRequestId;
11
+ exports.extractTaskId = extractTaskId;
12
+ exports.extractFilePath = extractFilePath;
13
+ exports.sanitizeTemplateValue = sanitizeTemplateValue;
14
+ /**
15
+ * Interpolate template variables in prompt
16
+ * Replaces {{variableName}} patterns with corresponding values from variables object
17
+ *
18
+ * @param template - The template string with {{variable}} placeholders
19
+ * @param variables - Key-value pairs to substitute into the template
20
+ * @returns The interpolated string with all variables replaced
21
+ */
22
+ function interpolatePrompt(template, variables) {
23
+ let result = template;
24
+ for (const [key, value] of Object.entries(variables)) {
25
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
26
+ }
27
+ return result;
28
+ }
29
+ /**
30
+ * Extract request ID from tool output
31
+ * Matches patterns like "Request completed: req001234" or "Request req001234 completed successfully"
32
+ *
33
+ * @param text - The tool output text to search
34
+ * @returns The request ID (e.g., "req001234") or null if not found
35
+ */
36
+ function extractRequestId(text) {
37
+ const patterns = [
38
+ /Request completed:\s*(req\d+)/i,
39
+ /Request (req\d+) completed/i,
40
+ /completed.*?(req\d{6})/i,
41
+ /Request finalized:\s*(req\d+)/i,
42
+ /Request (req\d+) finalized/i,
43
+ /finalized.*?(req\d{6})/i,
44
+ ];
45
+ for (const pattern of patterns) {
46
+ const match = text.match(pattern);
47
+ if (match)
48
+ return match[1];
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Extract task ID from tool output
54
+ * Matches patterns like "Task tas002152 has been marked as completed"
55
+ *
56
+ * @param text - The tool output text to search
57
+ * @returns The task ID (e.g., "tas002152") or null if not found
58
+ */
59
+ function extractTaskId(text) {
60
+ const match = text.match(/Task\s+(tas\d+).*(?:completed|marked as completed)/i);
61
+ return match ? match[1] : null;
62
+ }
63
+ /**
64
+ * Extract file path from write_file tool output
65
+ * Matches patterns like "Created file path/to/file.ts"
66
+ *
67
+ * @param text - The tool output text to search
68
+ * @returns The file path or null if not found
69
+ */
70
+ function extractFilePath(text) {
71
+ // Match "Created file X" or "**Created file X**"
72
+ const createdMatch = text.match(/\*?\*?Created file\s+([^\s*]+)/i);
73
+ if (createdMatch)
74
+ return createdMatch[1];
75
+ // Match "File Modified: Yes" with path context
76
+ const modifiedMatch = text.match(/File \d+:\s*([^\n]+)\n.*File Modified:\s*Yes/i);
77
+ if (modifiedMatch)
78
+ return modifiedMatch[1];
79
+ return null;
80
+ }
81
+ /**
82
+ * Sanitize a value for use in template interpolation
83
+ * Truncates long values and escapes special characters
84
+ *
85
+ * @param value - The value to sanitize
86
+ * @param maxLength - Maximum length before truncation (default: 1000)
87
+ * @returns The sanitized string
88
+ */
89
+ function sanitizeTemplateValue(value, maxLength = 1000) {
90
+ if (!value)
91
+ return '';
92
+ if (value.length > maxLength) {
93
+ return value.substring(0, maxLength) + '... (truncated)';
94
+ }
95
+ return value;
96
+ }
97
+ //# sourceMappingURL=hook-utils.js.map
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode Event Hooks Plugin — Thin Event Emitter
4
+ *
5
+ * Detects events from OpenCode (tool execution, session lifecycle) and POSTs
6
+ * them to the action server's /api/hook-event endpoint. All hook logic
7
+ * (fetching, condition evaluation, template interpolation, execution) lives
8
+ * in the action server.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const path_1 = require("path");
12
+ const fs_1 = require("fs");
13
+ const ACTION_SERVER_PORT = 3456;
14
+ const ACTION_SERVER_URL = `http://localhost:${ACTION_SERVER_PORT}/api/hook-event`;
15
+ // Singleton guard — OpenCode calls plugin init twice
16
+ let initializedDirectory = null;
17
+ let cachedHandlers = null;
18
+ /**
19
+ * Extract request ID from tool output
20
+ */
21
+ function extractRequestId(text) {
22
+ const patterns = [
23
+ /Request completed:\s*(req\d+)/i,
24
+ /Request (req\d+) completed/i,
25
+ /completed.*?(req\d{6})/i,
26
+ /Request finalized:\s*(req\d+)/i,
27
+ /Request (req\d+) finalized/i,
28
+ /finalized.*?(req\d{6})/i,
29
+ /Request (req\d+) merged/i,
30
+ /merged.*?(req\d{6})/i,
31
+ ];
32
+ for (const pattern of patterns) {
33
+ const match = text.match(pattern);
34
+ if (match)
35
+ return match[1];
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * Extract task ID from tool output
41
+ */
42
+ function extractTaskId(text) {
43
+ const match = text.match(/Task\s+(tas\d+).*(?:completed|marked as completed)/i);
44
+ return match ? match[1] : null;
45
+ }
46
+ /**
47
+ * Fire-and-forget POST to action server. Does not throw.
48
+ */
49
+ function postHookEvent(eventType, sessionId, variables, timing) {
50
+ fetch(ACTION_SERVER_URL, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ eventType, sessionId, variables, timing }),
54
+ }).catch(() => { }); // Fire-and-forget
55
+ }
56
+ /**
57
+ * Await POST to action server. Returns silently on failure.
58
+ */
59
+ async function postHookEventAsync(eventType, sessionId, variables, timing) {
60
+ try {
61
+ await fetch(ACTION_SERVER_URL, {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ eventType, sessionId, variables, timing }),
65
+ });
66
+ }
67
+ catch {
68
+ // Non-fatal
69
+ }
70
+ }
71
+ /**
72
+ * Detect tool type and determine event type + variables from tool execution context.
73
+ */
74
+ function extractToolEventInfo(toolName, input, resultText, sessionId) {
75
+ const events = [];
76
+ const baseVars = {
77
+ output: resultText || '',
78
+ toolName,
79
+ sessionId: sessionId || '',
80
+ };
81
+ const isRequestOps = toolName.includes('request_operations') || toolName.includes('complete_request');
82
+ const isTaskOps = toolName.includes('task_operations') || toolName.includes('complete_task');
83
+ const isWriteFile = toolName.includes('write_file');
84
+ const isReplaceInFiles = toolName.includes('replace_in_files');
85
+ const isQuickEdit = toolName.includes('quick_write') || toolName.includes('quick_replace') || toolName.includes('quick_manage');
86
+ const isReadFiles = toolName.includes('read_files');
87
+ const isSearch = toolName.includes('search');
88
+ const isManageFiles = toolName.includes('manage_files');
89
+ const isDocumentOps = toolName.includes('document_operations');
90
+ const isQueryHistory = toolName.includes('query_history');
91
+ if (isRequestOps && resultText) {
92
+ const requestId = extractRequestId(resultText);
93
+ if (requestId) {
94
+ events.push({
95
+ eventType: 'request_completed',
96
+ variables: { ...baseVars, requestId },
97
+ });
98
+ }
99
+ }
100
+ else if (isTaskOps && resultText) {
101
+ const taskId = extractTaskId(resultText);
102
+ if (taskId) {
103
+ events.push({
104
+ eventType: 'task_completed',
105
+ variables: { ...baseVars, taskId },
106
+ });
107
+ }
108
+ }
109
+ else if (isWriteFile) {
110
+ const pathMatch = resultText?.match(/(?:Created|Updated|Wrote)\s+(?:file\s+)?([^\s\n]+)/i);
111
+ events.push({
112
+ eventType: 'write_file',
113
+ variables: { ...baseVars, path: pathMatch?.[1] || '' },
114
+ });
115
+ }
116
+ else if (isReplaceInFiles) {
117
+ const operations = input?.input?.operations || [];
118
+ const paths = operations.map((op) => op.path || op.documentId).filter(Boolean);
119
+ events.push({
120
+ eventType: 'replace_in_files',
121
+ variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
122
+ });
123
+ }
124
+ else if (isQuickEdit) {
125
+ const vars = {
126
+ ...baseVars,
127
+ path: input?.input?.operationArgs?.path || '',
128
+ title: input?.input?.title || '',
129
+ };
130
+ const qeRequestMatch = resultText?.match(/\*\*Request:\*\*\s*(req\d+)/i);
131
+ if (qeRequestMatch)
132
+ vars.requestId = qeRequestMatch[1];
133
+ events.push({ eventType: 'quick_edit', variables: vars });
134
+ // Quick-edit auto-completes requests — also emit request_completed
135
+ if (vars.requestId) {
136
+ events.push({
137
+ eventType: 'request_completed',
138
+ variables: { ...baseVars, requestId: vars.requestId },
139
+ });
140
+ }
141
+ }
142
+ else if (isReadFiles) {
143
+ const pathsInput = input?.input?.paths || [];
144
+ const paths = pathsInput.map((p) => p.path || p.documentId).filter(Boolean);
145
+ events.push({
146
+ eventType: 'read_files',
147
+ variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
148
+ });
149
+ }
150
+ else if (isSearch) {
151
+ events.push({
152
+ eventType: 'search',
153
+ variables: {
154
+ ...baseVars,
155
+ query: input?.input?.needle || '',
156
+ directory: input?.input?.directory || '',
157
+ fileExtension: input?.input?.options?.fileExtension || '*',
158
+ },
159
+ });
160
+ }
161
+ else if (isManageFiles) {
162
+ const operations = input?.input?.operations || [];
163
+ const opTypes = operations.map((op) => op.operationType).filter(Boolean);
164
+ events.push({
165
+ eventType: 'manage_files',
166
+ variables: { ...baseVars, operationTypes: [...new Set(opTypes)].join(', ') },
167
+ });
168
+ }
169
+ else if (isDocumentOps) {
170
+ events.push({ eventType: 'document_operations', variables: baseVars });
171
+ }
172
+ else if (isQueryHistory) {
173
+ events.push({ eventType: 'query_history', variables: baseVars });
174
+ }
175
+ // Always emit tool_executed for any tool
176
+ events.push({
177
+ eventType: 'tool_executed',
178
+ variables: {
179
+ tool: toolName,
180
+ toolName,
181
+ output: resultText || '',
182
+ sessionId: sessionId || '',
183
+ },
184
+ });
185
+ return events;
186
+ }
187
+ /**
188
+ * Fetch recent output from a session (for lastOutput variable in session_idle).
189
+ */
190
+ async function fetchRecentSessionOutput(sessionId, directory) {
191
+ try {
192
+ const configPath = (0, path_1.join)(directory, '.alchemy_lab', 'opencode.json');
193
+ if (!(0, fs_1.existsSync)(configPath))
194
+ return { output: '' };
195
+ const config = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
196
+ const serverUrl = config.port ? `http://127.0.0.1:${config.port}` : null;
197
+ if (!serverUrl)
198
+ return { output: '' };
199
+ const url = `${serverUrl}/session/${sessionId}/message?limit=5`;
200
+ const response = await fetch(url);
201
+ if (!response.ok)
202
+ return { output: '' };
203
+ const messages = await response.json();
204
+ const assistantMessages = messages.filter(m => m.info.role === 'assistant');
205
+ const lastAssistantMessage = assistantMessages[0];
206
+ const assistantOutput = assistantMessages
207
+ .flatMap(m => m.parts)
208
+ .filter(p => p.type === 'text' && p.text)
209
+ .map(p => p.text)
210
+ .join('\n');
211
+ return { output: assistantOutput, lastMessageId: lastAssistantMessage?.info?.id };
212
+ }
213
+ catch {
214
+ return { output: '' };
215
+ }
216
+ }
217
+ exports.default = async ({ directory }) => {
218
+ const dir = directory || process.cwd();
219
+ // Singleton guard
220
+ if (initializedDirectory === dir && cachedHandlers) {
221
+ return cachedHandlers;
222
+ }
223
+ initializedDirectory = dir;
224
+ const handlers = {
225
+ "tool.execute.before": async (input) => {
226
+ const toolName = input?.tool || input?.name;
227
+ if (!toolName)
228
+ return;
229
+ const sessionId = input?.sessionID || '';
230
+ const inputText = JSON.stringify(input?.input || {}).substring(0, 1000);
231
+ postHookEvent('tool_executed', sessionId, {
232
+ tool: toolName,
233
+ toolName,
234
+ input: inputText,
235
+ }, 'before');
236
+ },
237
+ "tool.execute.after": async (input, output) => {
238
+ const toolName = input?.tool || input?.name;
239
+ if (!toolName)
240
+ return;
241
+ const sessionId = input?.sessionID || '';
242
+ const resultText = output?.content?.[0]?.text;
243
+ if (!resultText)
244
+ return;
245
+ const events = extractToolEventInfo(toolName, input, resultText, sessionId);
246
+ for (const { eventType, variables } of events) {
247
+ await postHookEventAsync(eventType, sessionId, variables, 'after');
248
+ }
249
+ },
250
+ event: async ({ event }) => {
251
+ switch (event.type) {
252
+ case 'session.idle': {
253
+ const sessionId = event.properties.sessionID || '';
254
+ const { output: lastOutput, lastMessageId } = await fetchRecentSessionOutput(sessionId, dir);
255
+ // Fire-and-forget — process is dying
256
+ postHookEvent('session_idle', sessionId, {
257
+ sessionId,
258
+ lastOutput,
259
+ lastMessageId: lastMessageId || '',
260
+ });
261
+ return;
262
+ }
263
+ case 'session.created': {
264
+ const sessionInfo = event.properties.info;
265
+ const sessionId = sessionInfo?.id || '';
266
+ if (!sessionId)
267
+ return;
268
+ // Delay to allow workflow assignment
269
+ setTimeout(() => {
270
+ postHookEvent('session_created', sessionId, { sessionId });
271
+ }, 500);
272
+ return;
273
+ }
274
+ }
275
+ },
276
+ };
277
+ cachedHandlers = handlers;
278
+ return handlers;
279
+ };
280
+ //# sourceMappingURL=plugin.js.map
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode Inject Session Plugin
4
+ *
5
+ * Injects the current session ID into spawn-background-work tool calls
6
+ * so that spawned background sessions can be linked as children of the parent.
7
+ *
8
+ * Hooks into tool.execute.before to modify args before the MCP tool executes.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const fs_1 = require("fs");
12
+ const path_1 = require("path");
13
+ // Log immediately when module is loaded
14
+ try {
15
+ const logFile = (0, path_1.join)(process.env.HOME || '/tmp', 'inject-session-plugin.log');
16
+ (0, fs_1.appendFileSync)(logFile, `[${new Date().toISOString()}] MODULE LOADED - file was imported\n`);
17
+ }
18
+ catch (e) {
19
+ // ignore
20
+ }
21
+ const debugLog = (msg, data) => {
22
+ const logFile = (0, path_1.join)(process.env.HOME || '/tmp', 'inject-session-plugin.log');
23
+ const line = `[${new Date().toISOString()}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}\n`;
24
+ try {
25
+ (0, fs_1.appendFileSync)(logFile, line);
26
+ }
27
+ catch (e) {
28
+ // ignore
29
+ }
30
+ console.log('[inject-session-plugin]', msg, data || '');
31
+ };
32
+ exports.default = async ({ project, directory }) => {
33
+ debugLog('Plugin loaded', { project: project?.id, directory });
34
+ return {
35
+ "tool.execute.before": async (context, data) => {
36
+ debugLog('tool.execute.before called', {
37
+ tool: context.tool,
38
+ sessionID: context.sessionID,
39
+ hasArgs: !!data?.args
40
+ });
41
+ try {
42
+ // Check if this is a spawn-background-work tool
43
+ // MCP tool names can use dashes or underscores depending on how they're registered
44
+ const toolName = context.tool;
45
+ if (!toolName || (!toolName.includes('spawn_background_work') && !toolName.includes('spawn-background-work'))) {
46
+ return; // Not logging every non-matching tool to avoid spam
47
+ }
48
+ debugLog('Matched spawn-background-work tool!');
49
+ // Inject parentSessionId into args if not already set
50
+ if (!data.args.parentSessionId && context.sessionID) {
51
+ data.args.parentSessionId = context.sessionID;
52
+ debugLog('Injected parentSessionId', context.sessionID);
53
+ }
54
+ else {
55
+ debugLog('parentSessionId already set or no sessionID', {
56
+ existing: data.args.parentSessionId,
57
+ sessionID: context.sessionID
58
+ });
59
+ }
60
+ }
61
+ catch (error) {
62
+ debugLog('Error', { error: String(error) });
63
+ }
64
+ }
65
+ };
66
+ };
67
+ //# sourceMappingURL=inject-session.js.map