fix: move all imports to dynamic to defer matrix-bot-sdk loading

ES modules evaluate ALL static imports before any code executes.
ensureNativeBinary() was called but matrix-bot-sdk was already
loaded by the time it ran.

Solution: all imports are now dynamic (await import()). The setup
function calls ensureNativeBinary() first, then dynamically loads
everything else including matrix-bot-sdk transitively.

Changes:
- Remove all static imports from index.ts
- Move ensureNativeBinary() into async setup()
- Pass log/logError as parameters to runPlugin
- Make parseCodeModeTools async for dynamic acorn imports
- Add import type for MatrixEventContext (erased at compile time)
This commit is contained in:
Бородин Роман 2026-09-30 22:41:29 +03:00
parent 8dae9656b8
commit 4294e30f76
2 changed files with 108 additions and 250 deletions

159
dist/index.js vendored
View File

@ -1,46 +1,37 @@
import { ensureNativeBinary } from "./bootstrap-native.js"; // All imports are dynamic — loaded AFTER ensureNativeBinary() completes
// Ensure native binary is available before loading matrix-bot-sdk // ES modules evaluate ALL static imports before executing any code,
// (opencode's npm doesn't run postinstall scripts) // so we cannot have any static imports that transitively load matrix-bot-sdk
ensureNativeBinary();
import fs from "fs";
import os from "os";
import path from "path";
import * as acorn from "acorn";
import * as acornWalk from "acorn-walk";
import { loadConfig } from "./config-loader.js";
import { setAppLog, log, logError } from "./logger.js";
// ============================================================================= // =============================================================================
// Plugin data directory — uses the same default as matrix-client // Plugin data directory
// ============================================================================= // =============================================================================
function ensureDataDir(dir) { function ensureDataDir(dir) {
const fs = require("fs");
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
} }
// ============================================================================= // =============================================================================
// Global mutex — prevents double startup (server + client both load plugins) // Global mutex
// ============================================================================= // =============================================================================
let LOCK_FILE = ""; let LOCK_FILE = "";
function tryAcquireLock() { function tryAcquireLock() {
const fs = require("fs");
try { try {
// Check if an existing lock is stale (process no longer running)
if (fs.existsSync(LOCK_FILE)) { if (fs.existsSync(LOCK_FILE)) {
const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10); const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10);
if (existingPid && existingPid !== process.pid) { if (existingPid && existingPid !== process.pid) {
try { try {
process.kill(existingPid, 0); // throws if process doesn't exist process.kill(existingPid, 0);
} }
catch { catch {
// Process dead, remove stale lock
fs.unlinkSync(LOCK_FILE); fs.unlinkSync(LOCK_FILE);
} }
} }
else if (existingPid === process.pid) { else if (existingPid === process.pid) {
// Same process re-entering (shouldn't happen, but be safe)
return false; return false;
} }
else { else {
return false; // Valid lock held by another process return false;
} }
} }
fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 }); fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 });
@ -51,6 +42,7 @@ function tryAcquireLock() {
} }
} }
function releaseLock() { function releaseLock() {
const fs = require("fs");
try { try {
fs.unlinkSync(LOCK_FILE); fs.unlinkSync(LOCK_FILE);
} }
@ -61,16 +53,16 @@ function releaseLock() {
// ============================================================================= // =============================================================================
// Plugin Setup // Plugin Setup
// ============================================================================= // =============================================================================
async function runPlugin(ctx, options, dataDir) { async function runPlugin(ctx, options, dataDir, log, logError) {
// Dynamic imports — loaded AFTER ensureNativeBinary() completes // Dynamic imports — loaded AFTER ensureNativeBinary() completes
// (static imports would be hoisted and evaluated before ensureNativeBinary())
const { MatrixBotClient } = await import("./matrix-client.js"); const { MatrixBotClient } = await import("./matrix-client.js");
const { SessionManager } = await import("./session-manager.js"); const { SessionManager } = await import("./session-manager.js");
const { loadConfig } = await import("./config-loader.js");
ensureDataDir(dataDir); ensureDataDir(dataDir);
LOCK_FILE = `${dataDir}/lock`; LOCK_FILE = `${dataDir}/lock`;
const directory = ctx?.location?.directory || process.cwd(); const directory = ctx?.location?.directory || process.cwd();
log(`Plugin setup started, directory=${directory}`); log(`Plugin setup started, directory=${directory}`);
// Acquire global lock — only one instance (server or client) starts the bot // Acquire global lock
const locked = tryAcquireLock(); const locked = tryAcquireLock();
if (!locked) { if (!locked) {
log("Another instance already running (lock held), skipping bot startup"); log("Another instance already running (lock held), skipping bot startup");
@ -90,19 +82,15 @@ async function runPlugin(ctx, options, dataDir) {
// Track Matrix context per OpenCode session // Track Matrix context per OpenCode session
const sessionContextMap = new Map(); const sessionContextMap = new Map();
// ============================================================================= // =============================================================================
// Pending prompt responses — maps sessionID -> { resolve, reject } // Pending prompt responses
// ============================================================================= // =============================================================================
const pendingResponses = new Map(); const pendingResponses = new Map();
const sessionsBySessionID = new Map(); const sessionsBySessionID = new Map();
/** async function parseCodeModeTools(code) {
* Parse the CodeMode JS script and extract all tool calls.
* Uses acorn AST parser + walk — same approach as OpenCode's CodeMode interpreter.
* Does NOT execute the code, only parses and analyzes the AST.
*/
function parseCodeModeTools(code) {
const results = []; const results = [];
const seen = new Set(); const seen = new Set();
// Parse the code into an AST using acorn (same parser as CodeMode) const acorn = await import("acorn");
const acornWalk = await import("acorn-walk");
const ast = acorn.parse(code, { const ast = acorn.parse(code, {
ecmaVersion: "latest", ecmaVersion: "latest",
sourceType: "script", sourceType: "script",
@ -110,10 +98,8 @@ async function runPlugin(ctx, options, dataDir) {
allowAwaitOutsideFunction: true, allowAwaitOutsideFunction: true,
locations: true, locations: true,
}); });
// Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...)
acornWalk.simple(ast, { acornWalk.simple(ast, {
CallExpression(node) { CallExpression(node) {
// Check if callee is tools.<server>.<tool>(...)
const toolCall = extractToolCall(node); const toolCall = extractToolCall(node);
if (!toolCall) if (!toolCall)
return; return;
@ -122,20 +108,14 @@ async function runPlugin(ctx, options, dataDir) {
if (seen.has(name)) if (seen.has(name))
return; return;
seen.add(name); seen.add(name);
// Evaluate the arguments to get their string representation
const argsStr = evaluateArgs(args); const argsStr = evaluateArgs(args);
results.push({ name, args: argsStr }); results.push({ name, args: argsStr });
}, },
}); });
return results; return results;
} }
/**
* Check if a CallExpression is a tool call: tools.<server>.<tool>(...)
* Returns { path, args } or null.
*/
function extractToolCall(node) { function extractToolCall(node) {
const callee = node.callee; const callee = node.callee;
// Must be a MemberExpression: tools.xxx.yyy
if (callee.type !== "MemberExpression") if (callee.type !== "MemberExpression")
return null; return null;
if (callee.computed) if (callee.computed)
@ -144,10 +124,8 @@ async function runPlugin(ctx, options, dataDir) {
if (prop.type !== "Identifier") if (prop.type !== "Identifier")
return null; return null;
const toolName = prop.name; const toolName = prop.name;
// Object must be tools.xxx (nested MemberExpression) or just "tools"
let obj = callee.object; let obj = callee.object;
const path = []; const path = [];
// Walk up the nested MemberExpression chain
while (obj.type === "MemberExpression" && !obj.computed) { while (obj.type === "MemberExpression" && !obj.computed) {
const innerProp = obj.property; const innerProp = obj.property;
if (innerProp.type !== "Identifier") if (innerProp.type !== "Identifier")
@ -155,17 +133,11 @@ async function runPlugin(ctx, options, dataDir) {
path.unshift(innerProp.name); path.unshift(innerProp.name);
obj = obj.object; obj = obj.object;
} }
// The base must be the identifier "tools"
if (obj.type !== "Identifier" || obj.name !== "tools") if (obj.type !== "Identifier" || obj.name !== "tools")
return null; return null;
path.unshift(toolName); path.unshift(toolName);
return { path, args: (node.arguments ?? []) }; return { path, args: (node.arguments ?? []) };
} }
/**
* Evaluate arguments to a string representation.
* Handles literals, identifiers, objects, arrays, and template literals.
* Matches how CodeMode's interpreter evaluates expression values.
*/
function evaluateArgs(args) { function evaluateArgs(args) {
if (!args || args.length === 0) if (!args || args.length === 0)
return ""; return "";
@ -175,19 +147,12 @@ async function runPlugin(ctx, options, dataDir) {
} }
return values.join(", "); return values.join(", ");
} }
/**
* Evaluate an AST expression node to its string value.
* Handles the expression types used in CodeMode tool calls.
*/
function evaluateExpression(node) { function evaluateExpression(node) {
switch (node.type) { switch (node.type) {
// Literals
case "Literal": case "Literal":
return String(node.value); return String(node.value);
// String/number/boolean/null/regexp literals
case "Identifier": { case "Identifier": {
const name = node.name; const name = node.name;
// Known globals with known values
if (name === "undefined") if (name === "undefined")
return "undefined"; return "undefined";
if (name === "true") if (name === "true")
@ -196,10 +161,8 @@ async function runPlugin(ctx, options, dataDir) {
return "false"; return "false";
if (name === "null") if (name === "null")
return "null"; return "null";
// For variables, show the variable name
return name; return name;
} }
// Object literals: { key: value, ... }
case "ObjectExpression": { case "ObjectExpression": {
const parts = []; const parts = [];
for (const prop of node.properties ?? []) { for (const prop of node.properties ?? []) {
@ -211,12 +174,10 @@ async function runPlugin(ctx, options, dataDir) {
} }
return `{ ${parts.join(", ")} }`; return `{ ${parts.join(", ")} }`;
} }
// Array literals: [value1, value2, ...]
case "ArrayExpression": { case "ArrayExpression": {
const elements = (node.elements ?? []).map((el) => el ? evaluateExpression(el) : ""); const elements = (node.elements ?? []).map((el) => el ? evaluateExpression(el) : "");
return `[${elements.join(", ")}]`; return `[${elements.join(", ")}]`;
} }
// Template literals: `text ${expr} text`
case "TemplateLiteral": { case "TemplateLiteral": {
let result = "`"; let result = "`";
const quasis = node.quasis ?? []; const quasis = node.quasis ?? [];
@ -230,21 +191,17 @@ async function runPlugin(ctx, options, dataDir) {
result += "`"; result += "`";
return result; return result;
} }
// Binary expressions: a + b, a === b, etc.
case "BinaryExpression": { case "BinaryExpression": {
const left = evaluateExpression(node.left); const left = evaluateExpression(node.left);
const right = evaluateExpression(node.right); const right = evaluateExpression(node.right);
return `${left} ${node.operator} ${right}`; return `${left} ${node.operator} ${right}`;
} }
// Conditional: a ? b : c
case "ConditionalExpression": { case "ConditionalExpression": {
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`; return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`;
} }
// Function expressions (arrow functions, etc.)
case "ArrowFunctionExpression": case "ArrowFunctionExpression":
case "FunctionExpression": case "FunctionExpression":
return "function"; return "function";
// Call expressions: other().method()
case "CallExpression": { case "CallExpression": {
if (node.callee?.type === "MemberExpression") { if (node.callee?.type === "MemberExpression") {
const member = node.callee; const member = node.callee;
@ -254,30 +211,25 @@ async function runPlugin(ctx, options, dataDir) {
} }
return "call"; return "call";
} }
// Unary expressions: !a, -b, typeof c
case "UnaryExpression": { case "UnaryExpression": {
const arg = evaluateExpression(node.argument); const arg = evaluateExpression(node.argument);
return `${node.operator}${arg}`; return `${node.operator}${arg}`;
} }
// Logical expressions: a && b, a || b
case "LogicalExpression": { case "LogicalExpression": {
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`; return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`;
} }
// Await expressions
case "AwaitExpression": { case "AwaitExpression": {
return `await ${evaluateExpression(node.argument)}`; return `await ${evaluateExpression(node.argument)}`;
} }
// Spread: ...arr
case "SpreadElement": { case "SpreadElement": {
return `...${evaluateExpression(node.argument)}`; return `...${evaluateExpression(node.argument)}`;
} }
// Default: show "(expr)"
default: default:
return `(expr:${node.type})`; return `(expr:${node.type})`;
} }
} }
// ============================================================================= // =============================================================================
// Event subscription — collects step data and sends messages per step // Event subscription
// ============================================================================= // =============================================================================
void (async () => { void (async () => {
try { try {
@ -294,13 +246,10 @@ async function runPlugin(ctx, options, dataDir) {
const eventType = eventData?.type || ""; const eventType = eventData?.type || "";
const sessionId = eventData?.data?.sessionID || ""; const sessionId = eventData?.data?.sessionID || "";
const assistantMsgID = eventData?.data?.assistantMessageID || ""; const assistantMsgID = eventData?.data?.assistantMessageID || "";
// Skip all events after session is done
const state = sessionsBySessionID.get(sessionId); const state = sessionsBySessionID.get(sessionId);
if (state?.done) if (state?.done)
continue; continue;
// ── Step lifecycle ──────────────────────────────────────────────
if (eventType === "session.step.started") { if (eventType === "session.step.started") {
// SessionState created in message handler after prompt
if (!sessionsBySessionID.has(sessionId)) { if (!sessionsBySessionID.has(sessionId)) {
log(`step.started for unknown session ${sessionId}`); log(`step.started for unknown session ${sessionId}`);
continue; continue;
@ -315,7 +264,6 @@ async function runPlugin(ctx, options, dataDir) {
}); });
continue; continue;
} }
// ── Reasoning ───────────────────────────────────────────────────
if (eventType === "session.reasoning.delta" && assistantMsgID) { if (eventType === "session.reasoning.delta" && assistantMsgID) {
const state = sessionsBySessionID.get(sessionId); const state = sessionsBySessionID.get(sessionId);
const step = state?.steps.get(assistantMsgID); const step = state?.steps.get(assistantMsgID);
@ -330,7 +278,6 @@ async function runPlugin(ctx, options, dataDir) {
step.reasoning = eventData?.data?.text || ""; step.reasoning = eventData?.data?.text || "";
continue; continue;
} }
// ── Tool calls ──────────────────────────────────────────────────
if (eventType === "session.tool.input.started" && assistantMsgID) { if (eventType === "session.tool.input.started" && assistantMsgID) {
const toolID = eventData?.data?.id || ""; const toolID = eventData?.data?.id || "";
const name = eventData?.data?.name || ""; const name = eventData?.data?.name || "";
@ -351,7 +298,6 @@ async function runPlugin(ctx, options, dataDir) {
if (tool) if (tool)
tool.args = args; tool.args = args;
} }
// Parse CodeMode execute tool — extract child tool calls from JS code
if (toolID && step && args) { if (toolID && step && args) {
let parsedArgs; let parsedArgs;
try { try {
@ -362,7 +308,7 @@ async function runPlugin(ctx, options, dataDir) {
} }
const code = parsedArgs?.code || ""; const code = parsedArgs?.code || "";
if (code) { if (code) {
const childTools = parseCodeModeTools(code); const childTools = await parseCodeModeTools(code);
for (const child of childTools) { for (const child of childTools) {
if (!step.tools.find(t => t.name === child.name)) { if (!step.tools.find(t => t.name === child.name)) {
step.tools.push({ step.tools.push({
@ -413,10 +359,8 @@ async function runPlugin(ctx, options, dataDir) {
} }
continue; continue;
} }
// ── Text output ────────────────────────────────────────────────
if (eventType === "session.text.ended" && assistantMsgID) { if (eventType === "session.text.ended" && assistantMsgID) {
const state = sessionsBySessionID.get(sessionId); const state = sessionsBySessionID.get(sessionId);
// Skip text events after final step
if (state?.done) if (state?.done)
continue; continue;
const step = state?.steps.get(assistantMsgID); const step = state?.steps.get(assistantMsgID);
@ -427,10 +371,8 @@ async function runPlugin(ctx, options, dataDir) {
} }
continue; continue;
} }
// ── Step ended — send message for this step ────────────────────
if (eventType === "session.step.ended" && sessionId && assistantMsgID) { if (eventType === "session.step.ended" && sessionId && assistantMsgID) {
const state = sessionsBySessionID.get(sessionId); const state = sessionsBySessionID.get(sessionId);
// Skip steps after final (finish:stop) — they are re-projections/duplicates
if (state?.done) if (state?.done)
continue; continue;
const step = state?.steps.get(assistantMsgID); const step = state?.steps.get(assistantMsgID);
@ -440,7 +382,6 @@ async function runPlugin(ctx, options, dataDir) {
await sendMessageToMatrix(state.context, msg); await sendMessageToMatrix(state.context, msg);
log(`Step ${step.tools.length} tool(s), ${step.text.length} chars text → sent to ${state.context.roomId}`); log(`Step ${step.tools.length} tool(s), ${step.text.length} chars text → sent to ${state.context.roomId}`);
} }
// If this is the final step, mark done and resolve the pending promise
if (finish === "stop" && state) { if (finish === "stop" && state) {
state.done = true; state.done = true;
const pending = pendingResponses.get(sessionId); const pending = pendingResponses.get(sessionId);
@ -452,7 +393,6 @@ async function runPlugin(ctx, options, dataDir) {
} }
continue; continue;
} }
// ── Execution errors ───────────────────────────────────────────
if (eventType === "session.execution.failed" && sessionId) { if (eventType === "session.execution.failed" && sessionId) {
const pending = pendingResponses.get(sessionId); const pending = pendingResponses.get(sessionId);
if (pending) { if (pending) {
@ -487,19 +427,15 @@ async function runPlugin(ctx, options, dataDir) {
// ============================================================================= // =============================================================================
function formatStepMessage(step) { function formatStepMessage(step) {
const parts = []; const parts = [];
// Reasoning in spoiler
if (step.reasoning) { if (step.reasoning) {
parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`); parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`);
} }
// Tool calls
if (step.tools.length > 0) { if (step.tools.length > 0) {
// Check if any tool is an execute with child tool calls
const executeTool = step.tools.find((t) => t.name === "execute" && t.toolID.includes("/")); const executeTool = step.tools.find((t) => t.name === "execute" && t.toolID.includes("/"));
const childTools = executeTool const childTools = executeTool
? step.tools.filter((t) => t.toolID.startsWith(executeTool.toolID + "/")) ? step.tools.filter((t) => t.toolID.startsWith(executeTool.toolID + "/"))
: []; : [];
if (childTools.length > 0) { if (childTools.length > 0) {
// Execute has child tool calls — show children, then execute result
for (const child of childTools) { for (const child of childTools) {
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`); parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`);
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`); parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`);
@ -511,7 +447,6 @@ async function runPlugin(ctx, options, dataDir) {
} }
parts.push(`</details>`); parts.push(`</details>`);
} }
// Execute result as a separate spoiler (like a regular tool result)
if (executeTool?.result) { if (executeTool?.result) {
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`); parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`);
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`); parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`);
@ -519,7 +454,6 @@ async function runPlugin(ctx, options, dataDir) {
} }
} }
else { else {
// No child tool calls — show tools normally (including execute as-is)
for (const tool of step.tools) { for (const tool of step.tools) {
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`); parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`);
parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`); parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`);
@ -533,7 +467,6 @@ async function runPlugin(ctx, options, dataDir) {
} }
} }
} }
// Separator before text
if (step.text) { if (step.text) {
if (parts.length > 0) if (parts.length > 0)
parts.push("\n---\n"); parts.push("\n---\n");
@ -542,7 +475,7 @@ async function runPlugin(ctx, options, dataDir) {
return parts.join("\n"); return parts.join("\n");
} }
// ============================================================================= // =============================================================================
// Send message to Matrix (reuses existing sendReply/sendNotice) // Send message to Matrix
// ============================================================================= // =============================================================================
async function sendMessageToMatrix(context, text) { async function sendMessageToMatrix(context, text) {
try { try {
@ -556,12 +489,10 @@ async function runPlugin(ctx, options, dataDir) {
// Message handler // Message handler
matrix.on(async (data) => { matrix.on(async (data) => {
const { context, query, sender, roomId, eventId, timestamp } = data; const { context, query, sender, roomId, eventId, timestamp } = data;
// Check rate limit
if (sessionManager.isRateLimited(sender)) { if (sessionManager.isRateLimited(sender)) {
await matrix.sendNotice(context, "Rate limited. Please wait a moment."); await matrix.sendNotice(context, "Rate limited. Please wait a moment.");
return; return;
} }
// Check for active query
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) { if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
await matrix.sendNotice(context, "A request is already running in this thread. Please wait."); await matrix.sendNotice(context, "A request is already running in this thread. Please wait.");
return; return;
@ -576,7 +507,6 @@ async function runPlugin(ctx, options, dataDir) {
session.lastEventIds.set(context.replyThreadRootId || context.eventId, eventId); session.lastEventIds.set(context.replyThreadRootId || context.eventId, eventId);
session.lastActivity = Date.now(); session.lastActivity = Date.now();
let opencodeSessionId = session.opencodeSessionId; let opencodeSessionId = session.opencodeSessionId;
// If we have a session ID from the map, verify it exists in OpenCode
if (opencodeSessionId) { if (opencodeSessionId) {
try { try {
await ctx.session.get({ sessionID: opencodeSessionId }); await ctx.session.get({ sessionID: opencodeSessionId });
@ -587,7 +517,6 @@ async function runPlugin(ctx, options, dataDir) {
opencodeSessionId = ""; opencodeSessionId = "";
} }
} }
// If no session ID yet, create one
if (!opencodeSessionId) { if (!opencodeSessionId) {
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`); log(`Creating new OpenCode session for ${roomId}:${threadRootId}`);
try { try {
@ -608,13 +537,11 @@ async function runPlugin(ctx, options, dataDir) {
} }
} }
try { try {
// Trigger prompt — returns admission receipt, not response text
await ctx.session.prompt({ await ctx.session.prompt({
sessionID: opencodeSessionId, sessionID: opencodeSessionId,
text: query, text: query,
}); });
log(`Prompt sent to session ${opencodeSessionId}`); log(`Prompt sent to session ${opencodeSessionId}`);
// Create session state for step tracking
sessionsBySessionID.set(opencodeSessionId, { sessionsBySessionID.set(opencodeSessionId, {
steps: new Map(), steps: new Map(),
currentStepID: null, currentStepID: null,
@ -622,15 +549,11 @@ async function runPlugin(ctx, options, dataDir) {
finalResponse: undefined, finalResponse: undefined,
done: false, done: false,
}); });
// Wait for response text from event subscription
// Resolved by: session.step.ended (finish: "stop")
// Rejected by: session.execution.failed / session.execution.interrupted
const responsePromise = new Promise((resolve, reject) => { const responsePromise = new Promise((resolve, reject) => {
pendingResponses.set(opencodeSessionId, { resolve, reject }); pendingResponses.set(opencodeSessionId, { resolve, reject });
}); });
const responseText = await responsePromise; const responseText = await responsePromise;
session.outputChars += responseText.length; session.outputChars += responseText.length;
// Step messages already sent by event subscription — no double send
} }
catch (err) { catch (err) {
log(`Error processing message: ${String(err)}`); log(`Error processing message: ${String(err)}`);
@ -689,12 +612,11 @@ async function runPlugin(ctx, options, dataDir) {
await sendFn(`Unknown command: ${cmdName}. Try /help`); await sendFn(`Unknown command: ${cmdName}. Try /help`);
} }
} }
// Override the matrix.on handler to also process commands // Override the matrix.on handler
const originalListener = matrix["listeners"][0]; const originalListener = matrix["listeners"][0];
matrix["listeners"] = []; matrix["listeners"] = [];
matrix.on(async (data) => { matrix.on(async (data) => {
const { context, query, sender, eventId } = data; const { context, query, sender, eventId } = data;
// Handle bridge commands
if (query.startsWith("/")) { if (query.startsWith("/")) {
const cmdName = query.slice(1).split(" ")[0].toLowerCase(); const cmdName = query.slice(1).split(" ")[0].toLowerCase();
const bridgeCommands = ["status", "clear", "reset", "help", "h"]; const bridgeCommands = ["status", "clear", "reset", "help", "h"];
@ -704,7 +626,6 @@ async function runPlugin(ctx, options, dataDir) {
return; return;
} }
} }
// Forward to original handler
if (originalListener) { if (originalListener) {
await originalListener(data); await originalListener(data);
} }
@ -721,7 +642,6 @@ async function runPlugin(ctx, options, dataDir) {
log("Plugin cleaning up..."); log("Plugin cleaning up...");
eventController.abort(); eventController.abort();
matrix.stop(); matrix.stop();
// Don't release lock — keep it on disk so client always sees it
}; };
return cleanup; return cleanup;
} }
@ -735,23 +655,26 @@ async function runPlugin(ctx, options, dataDir) {
// V2 Setup Function // V2 Setup Function
// ============================================================================= // =============================================================================
async function v2Setup(ctx) { async function v2Setup(ctx) {
const rawStorage = ctx.options?.storagePath; // 1. Download native binary BEFORE any matrix imports
const dataDir = rawStorage const { ensureNativeBinary } = await import("./bootstrap-native.js");
? path.join(rawStorage, "plugin-data") ensureNativeBinary();
: path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data"); // 2. Load logger
ensureDataDir(dataDir); const { log, logError, setAppLog } = await import("./logger.js");
// Try to use OpenCode's logging API log("Using V2 path (full context)");
if (ctx?.app?.log) { if (ctx?.app?.log) {
setAppLog(ctx.app.log.bind(ctx.app)); setAppLog(ctx.app.log.bind(ctx.app));
} }
try { const rawStorage = ctx.options?.storagePath;
const os = require("os");
const path = require("path");
const dataDir = rawStorage
? path.join(rawStorage, "plugin-data")
: path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data");
// 3. Load config
const { loadConfig } = await import("./config-loader.js");
const { options } = await loadConfig(ctx.options); const { options } = await loadConfig(ctx.options);
return await runPlugin(ctx, options, dataDir); // 4. Run plugin (handles all matrix imports dynamically)
} return await runPlugin(ctx, options, dataDir, log, logError);
catch (err) {
logError(`V2 setup error: ${String(err)}`);
return () => { };
}
} }
// ============================================================================= // =============================================================================
// Export // Export
@ -759,7 +682,13 @@ async function v2Setup(ctx) {
export default { export default {
id: "matrix-plugin", id: "matrix-plugin",
async setup(ctx) { async setup(ctx) {
log("Using V2 path (full context)"); try {
return await v2Setup(ctx); return await v2Setup(ctx);
}
catch (err) {
const { logError } = await import("./logger.js");
logError(`V2 setup error: ${String(err)}`);
return () => { };
}
}, },
}; };

191
index.ts
View File

@ -1,53 +1,42 @@
import { ensureNativeBinary } from "./bootstrap-native.js" // All imports are dynamic — loaded AFTER ensureNativeBinary() completes
// ES modules evaluate ALL static imports before executing any code,
// so we cannot have any static imports that transitively load matrix-bot-sdk
// Type-only import — doesn't execute any code at runtime // Type-only import — erased at compile time, no runtime effect
import type { MatrixEventContext } from "./matrix-client.js" import type { MatrixEventContext } from "./matrix-client.js"
// Ensure native binary is available before loading matrix-bot-sdk
// (opencode's npm doesn't run postinstall scripts)
ensureNativeBinary()
import fs from "fs"
import os from "os"
import path from "path"
import * as acorn from "acorn"
import * as acornWalk from "acorn-walk"
import { loadConfig } from "./config-loader.js"
import { setAppLog, log, logError } from "./logger.js"
// ============================================================================= // =============================================================================
// Plugin data directory — uses the same default as matrix-client // Plugin data directory
// ============================================================================= // =============================================================================
function ensureDataDir(dir: string): void { function ensureDataDir(dir: string): void {
const fs = require("fs")
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }) fs.mkdirSync(dir, { recursive: true })
} }
} }
// ============================================================================= // =============================================================================
// Global mutex — prevents double startup (server + client both load plugins) // Global mutex
// ============================================================================= // =============================================================================
let LOCK_FILE = "" let LOCK_FILE = ""
function tryAcquireLock(): boolean { function tryAcquireLock(): boolean {
const fs = require("fs")
try { try {
// Check if an existing lock is stale (process no longer running)
if (fs.existsSync(LOCK_FILE)) { if (fs.existsSync(LOCK_FILE)) {
const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10) const existingPid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10)
if (existingPid && existingPid !== process.pid) { if (existingPid && existingPid !== process.pid) {
try { try {
process.kill(existingPid, 0) // throws if process doesn't exist process.kill(existingPid, 0)
} catch { } catch {
// Process dead, remove stale lock
fs.unlinkSync(LOCK_FILE) fs.unlinkSync(LOCK_FILE)
} }
} else if (existingPid === process.pid) { } else if (existingPid === process.pid) {
// Same process re-entering (shouldn't happen, but be safe)
return false return false
} else { } else {
return false // Valid lock held by another process return false
} }
} }
fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 }) fs.writeFileSync(LOCK_FILE, String(process.pid), { mode: 0o644 })
@ -58,6 +47,7 @@ function tryAcquireLock(): boolean {
} }
function releaseLock(): void { function releaseLock(): void {
const fs = require("fs")
try { try {
fs.unlinkSync(LOCK_FILE) fs.unlinkSync(LOCK_FILE)
} catch { } catch {
@ -69,11 +59,17 @@ function releaseLock(): void {
// Plugin Setup // Plugin Setup
// ============================================================================= // =============================================================================
async function runPlugin(ctx: any, options: any, dataDir: string) { async function runPlugin(
ctx: any,
options: any,
dataDir: string,
log: (msg: string) => void,
logError: (msg: string) => void,
) {
// Dynamic imports — loaded AFTER ensureNativeBinary() completes // Dynamic imports — loaded AFTER ensureNativeBinary() completes
// (static imports would be hoisted and evaluated before ensureNativeBinary())
const { MatrixBotClient } = await import("./matrix-client.js") const { MatrixBotClient } = await import("./matrix-client.js")
const { SessionManager } = await import("./session-manager.js") const { SessionManager } = await import("./session-manager.js")
const { loadConfig } = await import("./config-loader.js")
ensureDataDir(dataDir) ensureDataDir(dataDir)
LOCK_FILE = `${dataDir}/lock` LOCK_FILE = `${dataDir}/lock`
@ -81,7 +77,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
const directory = ctx?.location?.directory || process.cwd() const directory = ctx?.location?.directory || process.cwd()
log(`Plugin setup started, directory=${directory}`) log(`Plugin setup started, directory=${directory}`)
// Acquire global lock — only one instance (server or client) starts the bot // Acquire global lock
const locked = tryAcquireLock() const locked = tryAcquireLock()
if (!locked) { if (!locked) {
log("Another instance already running (lock held), skipping bot startup") log("Another instance already running (lock held), skipping bot startup")
@ -108,7 +104,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
const sessionContextMap = new Map<string, { roomId: string; threadRootId: string; context: any }>() const sessionContextMap = new Map<string, { roomId: string; threadRootId: string; context: any }>()
// ============================================================================= // =============================================================================
// Pending prompt responses — maps sessionID -> { resolve, reject } // Pending prompt responses
// ============================================================================= // =============================================================================
const pendingResponses = new Map<string, { const pendingResponses = new Map<string, {
@ -117,7 +113,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
}>() }>()
// ============================================================================= // =============================================================================
// Step tracking — each step is one LLM turn, sent as a separate Matrix message // Step tracking
// ============================================================================= // =============================================================================
interface ToolCall { interface ToolCall {
@ -136,19 +132,17 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
interface SessionState { interface SessionState {
steps: Map<string, StepData> // assistantMessageID → step steps: Map<string, StepData>
currentStepID: string | null currentStepID: string | null
context: MatrixEventContext context: MatrixEventContext
finalResponse?: string finalResponse?: string
done: boolean // true после finish:stop done: boolean
} }
const sessionsBySessionID = new Map<string, SessionState>() const sessionsBySessionID = new Map<string, SessionState>()
// ============================================================================= // =============================================================================
// Parse tool calls from CodeMode JS script using AST (same as OpenCode) // Parse tool calls from CodeMode JS script using AST
// CodeMode creates nested tools: tools.<server>.<tool>(...)
// e.g. tools.duckduckgo.search({ query: "..." })
// ============================================================================= // =============================================================================
interface ChildToolInfo { interface ChildToolInfo {
@ -156,19 +150,15 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
args: string args: string
} }
// Minimal AST node type (acorn doesn't export AST namespace in usable way)
type AstNode = { type: string; [key: string]: any } type AstNode = { type: string; [key: string]: any }
/** async function parseCodeModeTools(code: string): Promise<ChildToolInfo[]> {
* Parse the CodeMode JS script and extract all tool calls.
* Uses acorn AST parser + walk — same approach as OpenCode's CodeMode interpreter.
* Does NOT execute the code, only parses and analyzes the AST.
*/
function parseCodeModeTools(code: string): ChildToolInfo[] {
const results: ChildToolInfo[] = [] const results: ChildToolInfo[] = []
const seen = new Set<string>() const seen = new Set<string>()
// Parse the code into an AST using acorn (same parser as CodeMode) const acorn = await import("acorn")
const acornWalk = await import("acorn-walk")
const ast = acorn.parse(code, { const ast = acorn.parse(code, {
ecmaVersion: "latest", ecmaVersion: "latest",
sourceType: "script", sourceType: "script",
@ -177,10 +167,8 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
locations: true, locations: true,
}) as AstNode }) as AstNode
// Walk the AST and find all CallExpression nodes for tools.xxx.yyy(...)
acornWalk.simple(ast as any, { acornWalk.simple(ast as any, {
CallExpression(node) { CallExpression(node: AstNode) {
// Check if callee is tools.<server>.<tool>(...)
const toolCall = extractToolCall(node) const toolCall = extractToolCall(node)
if (!toolCall) return if (!toolCall) return
@ -189,7 +177,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
if (seen.has(name)) return if (seen.has(name)) return
seen.add(name) seen.add(name)
// Evaluate the arguments to get their string representation
const argsStr = evaluateArgs(args) const argsStr = evaluateArgs(args)
results.push({ name, args: argsStr }) results.push({ name, args: argsStr })
}, },
@ -198,14 +185,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
return results return results
} }
/**
* Check if a CallExpression is a tool call: tools.<server>.<tool>(...)
* Returns { path, args } or null.
*/
function extractToolCall(node: AstNode): { path: string[]; args: AstNode[] } | null { function extractToolCall(node: AstNode): { path: string[]; args: AstNode[] } | null {
const callee = node.callee as AstNode const callee = node.callee as AstNode
// Must be a MemberExpression: tools.xxx.yyy
if (callee.type !== "MemberExpression") return null if (callee.type !== "MemberExpression") return null
if (callee.computed) return null if (callee.computed) return null
@ -213,11 +195,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
if (prop.type !== "Identifier") return null if (prop.type !== "Identifier") return null
const toolName = prop.name const toolName = prop.name
// Object must be tools.xxx (nested MemberExpression) or just "tools"
let obj: AstNode = callee.object let obj: AstNode = callee.object
const path: string[] = [] const path: string[] = []
// Walk up the nested MemberExpression chain
while (obj.type === "MemberExpression" && !obj.computed) { while (obj.type === "MemberExpression" && !obj.computed) {
const innerProp = obj.property as AstNode const innerProp = obj.property as AstNode
if (innerProp.type !== "Identifier") return null if (innerProp.type !== "Identifier") return null
@ -225,18 +205,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
obj = obj.object obj = obj.object
} }
// The base must be the identifier "tools"
if (obj.type !== "Identifier" || obj.name !== "tools") return null if (obj.type !== "Identifier" || obj.name !== "tools") return null
path.unshift(toolName) path.unshift(toolName)
return { path, args: (node.arguments ?? []) as AstNode[] } return { path, args: (node.arguments ?? []) as AstNode[] }
} }
/**
* Evaluate arguments to a string representation.
* Handles literals, identifiers, objects, arrays, and template literals.
* Matches how CodeMode's interpreter evaluates expression values.
*/
function evaluateArgs(args: AstNode[]): string { function evaluateArgs(args: AstNode[]): string {
if (!args || args.length === 0) return "" if (!args || args.length === 0) return ""
@ -248,29 +222,20 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
return values.join(", ") return values.join(", ")
} }
/**
* Evaluate an AST expression node to its string value.
* Handles the expression types used in CodeMode tool calls.
*/
function evaluateExpression(node: AstNode): string { function evaluateExpression(node: AstNode): string {
switch (node.type) { switch (node.type) {
// Literals
case "Literal": case "Literal":
return String(node.value) return String(node.value)
// String/number/boolean/null/regexp literals
case "Identifier": { case "Identifier": {
const name = node.name const name = node.name
// Known globals with known values
if (name === "undefined") return "undefined" if (name === "undefined") return "undefined"
if (name === "true") return "true" if (name === "true") return "true"
if (name === "false") return "false" if (name === "false") return "false"
if (name === "null") return "null" if (name === "null") return "null"
// For variables, show the variable name
return name return name
} }
// Object literals: { key: value, ... }
case "ObjectExpression": { case "ObjectExpression": {
const parts: string[] = [] const parts: string[] = []
for (const prop of node.properties ?? []) { for (const prop of node.properties ?? []) {
@ -283,7 +248,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
return `{ ${parts.join(", ")} }` return `{ ${parts.join(", ")} }`
} }
// Array literals: [value1, value2, ...]
case "ArrayExpression": { case "ArrayExpression": {
const elements = (node.elements ?? []).map((el: AstNode | null) => const elements = (node.elements ?? []).map((el: AstNode | null) =>
el ? evaluateExpression(el) : "", el ? evaluateExpression(el) : "",
@ -291,7 +255,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
return `[${elements.join(", ")}]` return `[${elements.join(", ")}]`
} }
// Template literals: `text ${expr} text`
case "TemplateLiteral": { case "TemplateLiteral": {
let result = "`" let result = "`"
const quasis = node.quasis ?? [] const quasis = node.quasis ?? []
@ -306,24 +269,20 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
return result return result
} }
// Binary expressions: a + b, a === b, etc.
case "BinaryExpression": { case "BinaryExpression": {
const left = evaluateExpression(node.left) const left = evaluateExpression(node.left)
const right = evaluateExpression(node.right) const right = evaluateExpression(node.right)
return `${left} ${node.operator} ${right}` return `${left} ${node.operator} ${right}`
} }
// Conditional: a ? b : c
case "ConditionalExpression": { case "ConditionalExpression": {
return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}` return `${evaluateExpression(node.test)} ? ${evaluateExpression(node.consequent)} : ${evaluateExpression(node.alternate)}`
} }
// Function expressions (arrow functions, etc.)
case "ArrowFunctionExpression": case "ArrowFunctionExpression":
case "FunctionExpression": case "FunctionExpression":
return "function" return "function"
// Call expressions: other().method()
case "CallExpression": { case "CallExpression": {
if (node.callee?.type === "MemberExpression") { if (node.callee?.type === "MemberExpression") {
const member = node.callee as AstNode const member = node.callee as AstNode
@ -334,35 +293,30 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
return "call" return "call"
} }
// Unary expressions: !a, -b, typeof c
case "UnaryExpression": { case "UnaryExpression": {
const arg = evaluateExpression(node.argument) const arg = evaluateExpression(node.argument)
return `${node.operator}${arg}` return `${node.operator}${arg}`
} }
// Logical expressions: a && b, a || b
case "LogicalExpression": { case "LogicalExpression": {
return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}` return `${evaluateExpression(node.left)} ${node.operator} ${evaluateExpression(node.right)}`
} }
// Await expressions
case "AwaitExpression": { case "AwaitExpression": {
return `await ${evaluateExpression(node.argument)}` return `await ${evaluateExpression(node.argument)}`
} }
// Spread: ...arr
case "SpreadElement": { case "SpreadElement": {
return `...${evaluateExpression(node.argument)}` return `...${evaluateExpression(node.argument)}`
} }
// Default: show "(expr)"
default: default:
return `(expr:${node.type})` return `(expr:${node.type})`
} }
} }
// ============================================================================= // =============================================================================
// Event subscription — collects step data and sends messages per step // Event subscription
// ============================================================================= // =============================================================================
void (async () => { void (async () => {
@ -377,14 +331,10 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
const sessionId = eventData?.data?.sessionID || "" const sessionId = eventData?.data?.sessionID || ""
const assistantMsgID = eventData?.data?.assistantMessageID || "" const assistantMsgID = eventData?.data?.assistantMessageID || ""
// Skip all events after session is done
const state = sessionsBySessionID.get(sessionId) const state = sessionsBySessionID.get(sessionId)
if (state?.done) continue if (state?.done) continue
// ── Step lifecycle ──────────────────────────────────────────────
if (eventType === "session.step.started") { if (eventType === "session.step.started") {
// SessionState created in message handler after prompt
if (!sessionsBySessionID.has(sessionId)) { if (!sessionsBySessionID.has(sessionId)) {
log(`step.started for unknown session ${sessionId}`) log(`step.started for unknown session ${sessionId}`)
continue continue
@ -400,8 +350,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
continue continue
} }
// ── Reasoning ───────────────────────────────────────────────────
if (eventType === "session.reasoning.delta" && assistantMsgID) { if (eventType === "session.reasoning.delta" && assistantMsgID) {
const state = sessionsBySessionID.get(sessionId) const state = sessionsBySessionID.get(sessionId)
const step = state?.steps.get(assistantMsgID) const step = state?.steps.get(assistantMsgID)
@ -416,8 +364,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
continue continue
} }
// ── Tool calls ──────────────────────────────────────────────────
if (eventType === "session.tool.input.started" && assistantMsgID) { if (eventType === "session.tool.input.started" && assistantMsgID) {
const toolID = eventData?.data?.id || "" const toolID = eventData?.data?.id || ""
const name = eventData?.data?.name || "" const name = eventData?.data?.name || ""
@ -438,13 +384,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
const tool = step.tools.find(t => t.toolID === toolID) const tool = step.tools.find(t => t.toolID === toolID)
if (tool) tool.args = args if (tool) tool.args = args
} }
// Parse CodeMode execute tool — extract child tool calls from JS code
if (toolID && step && args) { if (toolID && step && args) {
let parsedArgs: any let parsedArgs: any
try { parsedArgs = JSON.parse(args) } catch { continue } try { parsedArgs = JSON.parse(args) } catch { continue }
const code = parsedArgs?.code || "" const code = parsedArgs?.code || ""
if (code) { if (code) {
const childTools = parseCodeModeTools(code) const childTools = await parseCodeModeTools(code)
for (const child of childTools) { for (const child of childTools) {
if (!step.tools.find(t => t.name === child.name)) { if (!step.tools.find(t => t.name === child.name)) {
step.tools.push({ step.tools.push({
@ -492,11 +437,8 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
continue continue
} }
// ── Text output ────────────────────────────────────────────────
if (eventType === "session.text.ended" && assistantMsgID) { if (eventType === "session.text.ended" && assistantMsgID) {
const state = sessionsBySessionID.get(sessionId) const state = sessionsBySessionID.get(sessionId)
// Skip text events after final step
if (state?.done) continue if (state?.done) continue
const step = state?.steps.get(assistantMsgID) const step = state?.steps.get(assistantMsgID)
if (step) { if (step) {
@ -506,12 +448,9 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
continue continue
} }
// ── Step ended — send message for this step ────────────────────
if (eventType === "session.step.ended" && sessionId && assistantMsgID) { if (eventType === "session.step.ended" && sessionId && assistantMsgID) {
const state = sessionsBySessionID.get(sessionId) const state = sessionsBySessionID.get(sessionId)
// Skip steps after final (finish:stop) — they are re-projections/duplicates
if (state?.done) continue if (state?.done) continue
const step = state?.steps.get(assistantMsgID) const step = state?.steps.get(assistantMsgID)
@ -523,7 +462,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
log(`Step ${step.tools.length} tool(s), ${step.text.length} chars text → sent to ${state.context.roomId}`) log(`Step ${step.tools.length} tool(s), ${step.text.length} chars text → sent to ${state.context.roomId}`)
} }
// If this is the final step, mark done and resolve the pending promise
if (finish === "stop" && state) { if (finish === "stop" && state) {
state.done = true state.done = true
const pending = pendingResponses.get(sessionId) const pending = pendingResponses.get(sessionId)
@ -537,8 +475,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
continue continue
} }
// ── Execution errors ───────────────────────────────────────────
if (eventType === "session.execution.failed" && sessionId) { if (eventType === "session.execution.failed" && sessionId) {
const pending = pendingResponses.get(sessionId) const pending = pendingResponses.get(sessionId)
if (pending) { if (pending) {
@ -575,14 +511,11 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
function formatStepMessage(step: StepData): string { function formatStepMessage(step: StepData): string {
const parts: string[] = [] const parts: string[] = []
// Reasoning in spoiler
if (step.reasoning) { if (step.reasoning) {
parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`) parts.push(`<details>\n<summary>Thinking</summary>\n\n${step.reasoning}\n\n</details>`)
} }
// Tool calls
if (step.tools.length > 0) { if (step.tools.length > 0) {
// Check if any tool is an execute with child tool calls
const executeTool = step.tools.find( const executeTool = step.tools.find(
(t) => t.name === "execute" && t.toolID.includes("/"), (t) => t.name === "execute" && t.toolID.includes("/"),
) )
@ -591,7 +524,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
: [] : []
if (childTools.length > 0) { if (childTools.length > 0) {
// Execute has child tool calls — show children, then execute result
for (const child of childTools) { for (const child of childTools) {
parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`) parts.push(`<details>\n<summary>🔧 ${child.name}</summary>\n\n`)
parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`) parts.push(`<b>Args:</b>\n\`\`\`\n${child.args}\n\`\`\`\n\n`)
@ -602,14 +534,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
parts.push(`</details>`) parts.push(`</details>`)
} }
// Execute result as a separate spoiler (like a regular tool result)
if (executeTool?.result) { if (executeTool?.result) {
parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`) parts.push(`<details>\n<summary>🔧 execute</summary>\n\n`)
parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`) parts.push(`<b>Result:</b>\n\`\`\`\n${executeTool.result}\n\`\`\`\n\n`)
parts.push(`</details>`) parts.push(`</details>`)
} }
} else { } else {
// No child tool calls — show tools normally (including execute as-is)
for (const tool of step.tools) { for (const tool of step.tools) {
parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`) parts.push(`<details>\n<summary>🔧 ${tool.name}</summary>\n\n`)
parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`) parts.push(`<b>Args:</b>\n\`\`\`\n${tool.args}\n\`\`\`\n\n`)
@ -623,7 +553,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
} }
// Separator before text
if (step.text) { if (step.text) {
if (parts.length > 0) parts.push("\n---\n") if (parts.length > 0) parts.push("\n---\n")
parts.push(step.text) parts.push(step.text)
@ -633,7 +562,7 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
// ============================================================================= // =============================================================================
// Send message to Matrix (reuses existing sendReply/sendNotice) // Send message to Matrix
// ============================================================================= // =============================================================================
async function sendMessageToMatrix( async function sendMessageToMatrix(
@ -652,13 +581,11 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
matrix.on(async (data: any) => { matrix.on(async (data: any) => {
const { context, query, sender, roomId, eventId, timestamp } = data const { context, query, sender, roomId, eventId, timestamp } = data
// Check rate limit
if (sessionManager.isRateLimited(sender)) { if (sessionManager.isRateLimited(sender)) {
await matrix.sendNotice(context, "Rate limited. Please wait a moment.") await matrix.sendNotice(context, "Rate limited. Please wait a moment.")
return return
} }
// Check for active query
if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) { if (sessionManager.hasActiveQuery(roomId, context.replyThreadRootId || context.eventId)) {
await matrix.sendNotice(context, "A request is already running in this thread. Please wait.") await matrix.sendNotice(context, "A request is already running in this thread. Please wait.")
return return
@ -677,7 +604,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
let opencodeSessionId = session.opencodeSessionId let opencodeSessionId = session.opencodeSessionId
// If we have a session ID from the map, verify it exists in OpenCode
if (opencodeSessionId) { if (opencodeSessionId) {
try { try {
await ctx.session.get({ sessionID: opencodeSessionId }) await ctx.session.get({ sessionID: opencodeSessionId })
@ -688,7 +614,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
} }
// If no session ID yet, create one
if (!opencodeSessionId) { if (!opencodeSessionId) {
log(`Creating new OpenCode session for ${roomId}:${threadRootId}`) log(`Creating new OpenCode session for ${roomId}:${threadRootId}`)
try { try {
@ -709,14 +634,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
try { try {
// Trigger prompt — returns admission receipt, not response text
await ctx.session.prompt({ await ctx.session.prompt({
sessionID: opencodeSessionId, sessionID: opencodeSessionId,
text: query, text: query,
}) })
log(`Prompt sent to session ${opencodeSessionId}`) log(`Prompt sent to session ${opencodeSessionId}`)
// Create session state for step tracking
sessionsBySessionID.set(opencodeSessionId, { sessionsBySessionID.set(opencodeSessionId, {
steps: new Map(), steps: new Map(),
currentStepID: null, currentStepID: null,
@ -725,16 +648,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
done: false, done: false,
}) })
// Wait for response text from event subscription
// Resolved by: session.step.ended (finish: "stop")
// Rejected by: session.execution.failed / session.execution.interrupted
const responsePromise = new Promise<string>((resolve, reject) => { const responsePromise = new Promise<string>((resolve, reject) => {
pendingResponses.set(opencodeSessionId, { resolve, reject }) pendingResponses.set(opencodeSessionId, { resolve, reject })
}) })
const responseText = await responsePromise const responseText = await responsePromise
session.outputChars += responseText.length session.outputChars += responseText.length
// Step messages already sent by event subscription — no double send
} catch (err: any) { } catch (err: any) {
log(`Error processing message: ${String(err)}`) log(`Error processing message: ${String(err)}`)
const errMsg = err?.response?.error?.message || String(err) const errMsg = err?.response?.error?.message || String(err)
@ -798,13 +717,12 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
} }
// Override the matrix.on handler to also process commands // Override the matrix.on handler
const originalListener = (matrix as any)["listeners"][0] const originalListener = (matrix as any)["listeners"][0]
;(matrix as any)["listeners"] = [] ;(matrix as any)["listeners"] = []
matrix.on(async (data: any) => { matrix.on(async (data: any) => {
const { context, query, sender, eventId } = data const { context, query, sender, eventId } = data
// Handle bridge commands
if (query.startsWith("/")) { if (query.startsWith("/")) {
const cmdName = query.slice(1).split(" ")[0].toLowerCase() const cmdName = query.slice(1).split(" ")[0].toLowerCase()
const bridgeCommands = ["status", "clear", "reset", "help", "h"] const bridgeCommands = ["status", "clear", "reset", "help", "h"]
@ -815,7 +733,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
} }
} }
// Forward to original handler
if (originalListener) { if (originalListener) {
await originalListener(data) await originalListener(data)
} }
@ -836,7 +753,6 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
log("Plugin cleaning up...") log("Plugin cleaning up...")
eventController.abort() eventController.abort()
matrix.stop() matrix.stop()
// Don't release lock — keep it on disk so client always sees it
} }
return cleanup return cleanup
@ -852,24 +768,32 @@ async function runPlugin(ctx: any, options: any, dataDir: string) {
// ============================================================================= // =============================================================================
async function v2Setup(ctx: any) { async function v2Setup(ctx: any) {
const rawStorage = (ctx.options as any)?.storagePath // 1. Download native binary BEFORE any matrix imports
const dataDir = rawStorage const { ensureNativeBinary } = await import("./bootstrap-native.js")
? path.join(rawStorage, "plugin-data") ensureNativeBinary()
: path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data")
ensureDataDir(dataDir) // 2. Load logger
const { log, logError, setAppLog } = await import("./logger.js")
log("Using V2 path (full context)")
// Try to use OpenCode's logging API
if (ctx?.app?.log) { if (ctx?.app?.log) {
setAppLog(ctx.app.log.bind(ctx.app)) setAppLog(ctx.app.log.bind(ctx.app))
} }
try { const rawStorage = (ctx.options as any)?.storagePath
const os = require("os")
const path = require("path")
const dataDir = rawStorage
? path.join(rawStorage, "plugin-data")
: path.join(os.homedir(), ".local", "share", "opencode-matrix-bot", "plugin-data")
// 3. Load config
const { loadConfig } = await import("./config-loader.js")
const { options } = await loadConfig(ctx.options) const { options } = await loadConfig(ctx.options)
return await runPlugin(ctx, options, dataDir)
} catch (err) { // 4. Run plugin (handles all matrix imports dynamically)
logError(`V2 setup error: ${String(err)}`) return await runPlugin(ctx, options, dataDir, log, logError)
return () => {}
}
} }
// ============================================================================= // =============================================================================
@ -879,7 +803,12 @@ async function v2Setup(ctx: any) {
export default { export default {
id: "matrix-plugin", id: "matrix-plugin",
async setup(ctx: any) { async setup(ctx: any) {
log("Using V2 path (full context)") try {
return await v2Setup(ctx) return await v2Setup(ctx)
} catch (err) {
const { logError } = await import("./logger.js")
logError(`V2 setup error: ${String(err)}`)
return () => {}
}
}, },
} }