@vicoa/opencode 0.1.0

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.
package/dist/index.js ADDED
@@ -0,0 +1,538 @@
1
+ /**
2
+ * opencode-vicoa
3
+ *
4
+ * OpenCode plugin that integrates with Vicoa web & mobile
5
+ * - Real-time session monitoring
6
+ * - Bidirectional messaging (UI ↔ agent)
7
+ * - Permission request forwarding
8
+ * - Tool execution tracking
9
+ * - Session state management
10
+ *
11
+ * This plugin runs INSIDE OpenCode and directly calls Vicoa REST APIs,
12
+ * eliminating the need for a separate Python wrapper process.
13
+ */
14
+ import { VicoaClient } from './plugin/vicoa-client.js';
15
+ import { MessagePoller } from './plugin/message-poller.js';
16
+ import { getApiKey } from './plugin/credentials.js';
17
+ import { formatFilePart, formatReasoningPart, formatToolPart } from './plugin/format-utils.js';
18
+ import { buildPermissionOptions, formatPermissionRequest, parsePermissionReply, } from './plugin/permission.js';
19
+ import { randomUUID } from 'crypto';
20
+ import * as os from 'os';
21
+ import { OPENCODE_SLASH_AGENT_TYPE, handleSlashCommand, scanOpencodeCommands, } from './plugin/commands.js';
22
+ import { handleControlCommand } from './plugin/control.js';
23
+ import { log } from './plugin/utils.js';
24
+ import { formatProjectPath } from './plugin/path-utils.js';
25
+ /**
26
+ * Plugin version - increment on changes
27
+ */
28
+ const PLUGIN_VERSION = '0.1.0';
29
+ let lastSessionTitle = null;
30
+ let currentSessionId;
31
+ let preferredAgent;
32
+ // The agent that the TUI *thinks* is active. Updated every time we see a
33
+ // user-role message.updated event (the UserMessage.agent field is the agent
34
+ // the TUI sent that message with). Used to compute how many agent.cycle
35
+ // steps are needed to land on a target agent.
36
+ let tuiCurrentAgent;
37
+ // Track messages that came from the UI (to avoid sending them back)
38
+ // Keep a simple FIFO buffer of message content
39
+ const messagesFromUI = [];
40
+ const MAX_UI_MESSAGES = 50;
41
+ const pendingPermissions = new Map();
42
+ // Track assistant message IDs we've already forwarded to Vicoa (avoid duplicates)
43
+ const sentAssistantMessageIds = new Set();
44
+ const sentAssistantMessageQueue = [];
45
+ const MAX_SENT_MESSAGE_IDS = 200;
46
+ // Track message parts by ID (for incremental updates)
47
+ const messagePartsById = new Map();
48
+ function getMessageState(messageId) {
49
+ let state = messagePartsById.get(messageId);
50
+ if (!state) {
51
+ state = { order: [], parts: new Map(), textByPartId: new Map() };
52
+ messagePartsById.set(messageId, state);
53
+ }
54
+ return state;
55
+ }
56
+ function setPartContent(state, partId, content) {
57
+ if (!state.order.includes(partId)) {
58
+ state.order.push(partId);
59
+ }
60
+ if (!content) {
61
+ state.parts.delete(partId);
62
+ return;
63
+ }
64
+ state.parts.set(partId, content);
65
+ }
66
+ function formatToolPartSafe(part) {
67
+ try {
68
+ return formatToolPart(part);
69
+ }
70
+ catch {
71
+ return `Using tool: ${part.tool}`;
72
+ }
73
+ }
74
+ function formatFilePartSafe(part) {
75
+ try {
76
+ return formatFilePart(part);
77
+ }
78
+ catch {
79
+ return part.filename ? `File: \`${part.filename}\`` : 'File attached';
80
+ }
81
+ }
82
+ function formatReasoningPartSafe(part) {
83
+ try {
84
+ return formatReasoningPart(part);
85
+ }
86
+ catch {
87
+ return '';
88
+ }
89
+ }
90
+ function buildMessageContent(messageId) {
91
+ const state = messagePartsById.get(messageId);
92
+ if (!state) {
93
+ return '';
94
+ }
95
+ const parts = state.order
96
+ .map((partId) => state.parts.get(partId))
97
+ .filter((part) => Boolean(part && part.trim().length));
98
+ return parts.join('\n\n').trim();
99
+ }
100
+ // Accumulates non-tool parts into the per-message state for later assembly.
101
+ // Tool parts are intentionally excluded — they are flushed as individual
102
+ // messages by the event handler as soon as they reach a terminal state.
103
+ function handlePartUpdate(part, delta) {
104
+ if (!('messageID' in part) || !part.messageID) {
105
+ return;
106
+ }
107
+ const state = getMessageState(part.messageID);
108
+ switch (part.type) {
109
+ case 'text': {
110
+ const currentText = state.textByPartId.get(part.id) || '';
111
+ const nextText = typeof delta === 'string' ? currentText + delta : part.text || currentText;
112
+ state.textByPartId.set(part.id, nextText);
113
+ setPartContent(state, part.id, nextText);
114
+ return;
115
+ }
116
+ // file parts — keep images (useful in UI), drop everything else
117
+ // (source dumps are noise; the tool usage line already names the file)
118
+ case 'file':
119
+ if (part.mime?.startsWith('image/') && part.url) {
120
+ setPartContent(state, part.id, formatFilePartSafe(part));
121
+ }
122
+ return;
123
+ // patch parts are internal bookkeeping — skip them entirely
124
+ case 'patch':
125
+ return;
126
+ case 'reasoning': {
127
+ const content = formatReasoningPartSafe(part);
128
+ setPartContent(state, part.id, content);
129
+ return;
130
+ }
131
+ // tool parts handled separately in the event handler
132
+ default:
133
+ return;
134
+ }
135
+ }
136
+ // When the user types @filename or @folder, OpenCode resolves it by calling
137
+ // the read/list tool and appending the result into the user message. Forms:
138
+ // 1) "Called the Read tool with the following input: {…}\n<file>…</file>"
139
+ // 2) bare "<file>…</file>" blocks (sometimes without the header)
140
+ // 3) unclosed "<file>" tag followed by content (when both folder + file selected)
141
+ // 4) directory listings (folder tree with indented file names)
142
+ // Strip all of these so only the real prompt survives.
143
+ const TOOL_RESULT_HEADER = /Called the \w+ tool with the following input:[\s\S]*/;
144
+ const FILE_BLOCK = /<file>[\s\S]*?<\/file>/g;
145
+ const UNCLOSED_FILE_TAG = /<file>[\s\S]*/g; // Match <file> tag without closing
146
+ const DIRECTORY_LISTING = /^\/[^\n]+\/\n(?:[ \t]+[^\n]+\n)+/gm; // Match directory tree structure
147
+ function stripToolResults(text) {
148
+ let cleaned = text;
149
+ // Strip tool result headers
150
+ cleaned = cleaned.replace(TOOL_RESULT_HEADER, '');
151
+ // Strip closed file blocks first
152
+ cleaned = cleaned.replace(FILE_BLOCK, '');
153
+ // Strip unclosed file tags with content (must come after closed blocks)
154
+ cleaned = cleaned.replace(UNCLOSED_FILE_TAG, '');
155
+ // Strip directory listings (folder path + indented file list)
156
+ cleaned = cleaned.replace(DIRECTORY_LISTING, '');
157
+ return cleaned;
158
+ }
159
+ function normalizeMessage(content) {
160
+ return content.replace(/\r\n/g, '\n').trim();
161
+ }
162
+ // Helper to add UI message with size limit (FIFO eviction)
163
+ function addUIMessage(content) {
164
+ messagesFromUI.push(normalizeMessage(content));
165
+ // If buffer exceeds limit, remove first 40 messages
166
+ if (messagesFromUI.length > MAX_UI_MESSAGES) {
167
+ messagesFromUI.splice(0, 40);
168
+ }
169
+ }
170
+ // Helper to check and remove UI message
171
+ function isFromUI(content) {
172
+ content = normalizeMessage(content);
173
+ const index = messagesFromUI.indexOf(content);
174
+ if (index !== -1) {
175
+ messagesFromUI.splice(index, 1); // Remove it immediately
176
+ return true;
177
+ }
178
+ return false;
179
+ }
180
+ function trackSentMessage(set, queue, messageId) {
181
+ if (set.has(messageId)) {
182
+ return;
183
+ }
184
+ set.add(messageId);
185
+ queue.push(messageId);
186
+ if (queue.length > MAX_SENT_MESSAGE_IDS) {
187
+ const evicted = queue.shift();
188
+ if (evicted) {
189
+ set.delete(evicted);
190
+ }
191
+ }
192
+ }
193
+ async function sendAgentSwitchToUi(vicoaClient, logClient, agentName) {
194
+ const controlPayload = JSON.stringify({ type: 'control', setting: 'agent_type', value: agentName.toLowerCase() });
195
+ await vicoaClient.sendMessage(`Agent changed to ${agentName}. ${controlPayload}`);
196
+ logClient('info', `[Vicoa] Forwarded terminal agent change to UI: ${agentName}`);
197
+ }
198
+ export const VicoaPlugin = async (context) => {
199
+ const { client, directory } = context;
200
+ // Get API key from environment or credentials file
201
+ const apiKey = getApiKey();
202
+ const baseUrl = process.env.VICOA_API_URL || process.env.VICOA_BASE_URL || 'https://api.vicoa.ai:8443';
203
+ if (!apiKey) {
204
+ log(client, 'warn', `[Vicoa v${PLUGIN_VERSION}] Disabled: No API key found`);
205
+ log(client, 'info', '[Vicoa] Set VICOA_API_KEY environment variable or run "vicoa --auth" to authenticate');
206
+ return {};
207
+ }
208
+ log(client, 'info', `[Vicoa v${PLUGIN_VERSION}] Initializing...`);
209
+ // Generate or reuse agent instance ID
210
+ const agentInstanceId = process.env.VICOA_AGENT_INSTANCE_ID || randomUUID();
211
+ const agentName = process.env.VICOA_AGENT_NAME || 'OpenCode';
212
+ // Create Vicoa client
213
+ const vicoaClient = new VicoaClient({
214
+ apiKey,
215
+ baseUrl,
216
+ agentType: agentName,
217
+ agentInstanceId,
218
+ logFunc: (level, msg) => {
219
+ const logLevel = level || 'info';
220
+ log(client, logLevel, `[Vicoa] ${msg}`);
221
+ },
222
+ });
223
+ // Register agent instance
224
+ try {
225
+ const projectPath = directory || process.cwd();
226
+ const homeDir = os.homedir();
227
+ // Format project path to use ~ for home directory (consistent with Claude wrapper)
228
+ const formattedProjectPath = formatProjectPath(projectPath);
229
+ await vicoaClient.registerAgentInstance(formattedProjectPath, homeDir);
230
+ log(client, "info", `[Vicoa] Registered session: ${agentInstanceId}`);
231
+ // Send initial message
232
+ await vicoaClient.sendMessage('OpenCode session started, waiting for your input...');
233
+ try {
234
+ const opencodeCommands = await scanOpencodeCommands(projectPath, homeDir);
235
+ const count = Object.keys(opencodeCommands).length;
236
+ if (count > 0) {
237
+ await vicoaClient.syncCommands(OPENCODE_SLASH_AGENT_TYPE, opencodeCommands);
238
+ log(client, "info", `[Vicoa] Synced ${count} OpenCode slash commands`);
239
+ }
240
+ }
241
+ catch (error) {
242
+ log(client, "warn", `[Vicoa] Failed to sync OpenCode slash commands: ${error}`);
243
+ }
244
+ }
245
+ catch (error) {
246
+ log(client, "error", `[Vicoa] Failed to register: ${error}`);
247
+ return {};
248
+ }
249
+ // Start polling for user messages
250
+ const messagePoller = new MessagePoller(vicoaClient, async (userMessage) => {
251
+ log(client, "info", `[Vicoa] Received message from dashboard: ${userMessage.substring(0, 80)}${userMessage.length > 80 ? '...' : ''}`);
252
+ // Check if it's a control command (matches Claude wrapper pattern)
253
+ if (await handleControlCommand(userMessage, {
254
+ client,
255
+ vicoaClient,
256
+ currentSessionId,
257
+ getTuiCurrentAgent: () => tuiCurrentAgent,
258
+ setTuiCurrentAgent: (agent) => {
259
+ tuiCurrentAgent = agent;
260
+ },
261
+ setPreferredAgent: (agent) => {
262
+ preferredAgent = agent;
263
+ },
264
+ })) {
265
+ await vicoaClient.updateStatus('AWAITING_INPUT');
266
+ return; // Control command handled
267
+ }
268
+ // ── permission reply interception ───────────────────────────────
269
+ // If there is a pending permission and the user's message matches one
270
+ // of the options we sent, reply via OpenCode's permission API instead
271
+ // of forwarding as a chat prompt.
272
+ for (const [permId, pending] of pendingPermissions) {
273
+ const matched = parsePermissionReply(userMessage, pending.options);
274
+ if (!matched)
275
+ continue;
276
+ log(client, 'info', `[Vicoa] Replying to permission ${permId} with "${matched}"`);
277
+ try {
278
+ await client.postSessionIdPermissionsPermissionId({
279
+ path: {
280
+ id: pending.permission.sessionID,
281
+ permissionID: permId,
282
+ },
283
+ body: { response: matched },
284
+ });
285
+ log(client, 'info', `[Vicoa] Permission ${permId} replied successfully`);
286
+ }
287
+ catch (error) {
288
+ log(client, 'error', `[Vicoa] Failed to reply to permission ${permId}: ${error}`);
289
+ }
290
+ pendingPermissions.delete(permId);
291
+ return; // Do NOT forward this message as a prompt
292
+ }
293
+ if (await handleSlashCommand(userMessage, client, currentSessionId, vicoaClient)) {
294
+ return;
295
+ }
296
+ // Submit as a prompt via the TUI. Mark it first so the chat.message
297
+ // hook doesn't echo it back to Vicoa.
298
+ addUIMessage(userMessage);
299
+ await client.tui.appendPrompt({ body: { text: userMessage } });
300
+ // A trailing space is needed for @ mentions and slash commands so
301
+ // OpenCode resolves them before submitting.
302
+ if (userMessage.includes('@') || userMessage.startsWith('/')) {
303
+ await client.tui.appendPrompt({ body: { text: ' ' } });
304
+ }
305
+ await client.tui.submitPrompt();
306
+ log(client, "info", `[Vicoa] Executed prompt in OpenCode: ${userMessage.substring(0, 80)}...`);
307
+ }, (level, msg) => log(client, "info", msg));
308
+ messagePoller.start();
309
+ return {
310
+ event: async ({ event }) => {
311
+ try {
312
+ switch (event.type) {
313
+ // ── message streaming ─────────────────────────────────────
314
+ case 'message.part.updated': {
315
+ const { part, delta } = event.properties;
316
+ // Tool parts are sent as their own messages once they finish,
317
+ // rather than being folded into the surrounding assistant text.
318
+ if (part.type === 'tool') {
319
+ const status = part.state.status;
320
+ if (status === 'completed' || status === 'error') {
321
+ // read/grep/glob/… — only show the usage line, no result
322
+ const formatted = formatToolPartSafe(part);
323
+ if (formatted) {
324
+ await vicoaClient.sendMessage(formatted);
325
+ }
326
+ }
327
+ // pending / running — nothing to send yet
328
+ return;
329
+ }
330
+ handlePartUpdate(part, delta);
331
+ return;
332
+ }
333
+ // ── completed assistant message forwarding ────────────────
334
+ case 'message.updated': {
335
+ const message = event.properties.info;
336
+ // User messages carry the agent the TUI used — track it so that
337
+ // cycleTuiToAgent knows where the indicator currently is.
338
+ // This is the only reliable signal for terminal-side agent changes
339
+ // (keybind cycles, /agent commands, etc.) because command.executed
340
+ // does not fire for TUI-dispatched commands.
341
+ if (message.role === 'user' && message.agent) {
342
+ const reportedAgent = message.agent;
343
+ const previousAgent = tuiCurrentAgent;
344
+ tuiCurrentAgent = reportedAgent;
345
+ preferredAgent = reportedAgent;
346
+ if (previousAgent !== reportedAgent) {
347
+ void sendAgentSwitchToUi(vicoaClient, (level, message) => log(client, level, message), reportedAgent);
348
+ }
349
+ }
350
+ // message.updated fires on every update (user messages, intermediate
351
+ // assistant updates without completed, etc.). Only act — and only
352
+ // clean up accumulated part state — once the assistant message has
353
+ // actually finished.
354
+ if (message.role !== 'assistant' || !message.time?.completed)
355
+ return;
356
+ const trimmedText = buildMessageContent(message.id);
357
+ messagePartsById.delete(message.id);
358
+ if (trimmedText.length > 0 && !sentAssistantMessageIds.has(message.id)) {
359
+ await vicoaClient.sendMessage(trimmedText);
360
+ trackSentMessage(sentAssistantMessageIds, sentAssistantMessageQueue, message.id);
361
+ }
362
+ return;
363
+ }
364
+ // ── session lifecycle ─────────────────────────────────────
365
+ case 'session.created': {
366
+ const session = event.properties.info;
367
+ lastSessionTitle = session.title;
368
+ currentSessionId = session.id;
369
+ log(client, 'info', `[Vicoa] Session created: ${session.id}`);
370
+ await vicoaClient.updateStatus('ACTIVE');
371
+ return;
372
+ }
373
+ case 'session.updated': {
374
+ const nextTitle = event.properties.info.title;
375
+ if (nextTitle && nextTitle !== lastSessionTitle) {
376
+ lastSessionTitle = nextTitle;
377
+ await vicoaClient.updateAgentInstanceName(nextTitle);
378
+ log(client, 'info', `[Vicoa] Updated session title: ${nextTitle}`);
379
+ }
380
+ return;
381
+ }
382
+ case 'session.deleted': {
383
+ currentSessionId = undefined;
384
+ await vicoaClient.endSession();
385
+ return;
386
+ }
387
+ case 'session.idle': {
388
+ log(client, 'info', '[Vicoa] Session idle');
389
+ await vicoaClient.updateStatus('AWAITING_INPUT');
390
+ if (vicoaClient.lastMessageId) {
391
+ await vicoaClient.requestUserInput(vicoaClient.lastMessageId);
392
+ }
393
+ return;
394
+ }
395
+ case 'session.status': {
396
+ const statusType = event.properties.status.type;
397
+ if (statusType === 'busy' || statusType === 'retry') {
398
+ await vicoaClient.updateStatus('ACTIVE');
399
+ }
400
+ else if (statusType === 'idle') {
401
+ await vicoaClient.updateStatus('AWAITING_INPUT');
402
+ }
403
+ return;
404
+ }
405
+ case 'session.error': {
406
+ const errorObj = event.properties.error;
407
+ const errorMsg = errorObj && typeof errorObj === 'object' && 'message' in errorObj
408
+ ? String(errorObj.message)
409
+ : 'Unknown error';
410
+ log(client, 'error', `[Vicoa] Session error: ${errorMsg}`);
411
+ // Only send error message to UI if it's not the generic "Unknown error"
412
+ if (errorMsg !== 'Unknown error') {
413
+ // Check if it's a rate limiting error
414
+ const errorMsgLower = errorMsg.toLowerCase();
415
+ const isRateLimitError = errorMsgLower.includes('too many request') ||
416
+ errorMsgLower.includes('rate limit') ||
417
+ errorMsg.includes('429');
418
+ if (isRateLimitError) {
419
+ await vicoaClient.sendMessage(`Too Many Requests: Rate limit exceeded.`);
420
+ }
421
+ else {
422
+ await vicoaClient.sendMessage(`Error: ${errorMsg}`);
423
+ }
424
+ await vicoaClient.updateStatus('AWAITING_INPUT');
425
+ }
426
+ return;
427
+ }
428
+ // ── permissions ───────────────────────────────────────────
429
+ // OpenCode emits "permission.asked" at runtime (confirmed by bus
430
+ // logs) even though the SDK typedef names it "permission.updated".
431
+ // Handle both so we work regardless of SDK version drift.
432
+ case 'permission.asked': {
433
+ const permission = event.properties;
434
+ const options = buildPermissionOptions(permission);
435
+ const messageId = await vicoaClient.sendMessage(formatPermissionRequest(permission, options), true);
436
+ // Record so the poller callback can reply via OpenCode API
437
+ pendingPermissions.set(permission.id, {
438
+ permission,
439
+ options,
440
+ vicoaMessageId: messageId,
441
+ });
442
+ log(client, 'info', `[Vicoa] Tracked pending permission: ${permission.id}`);
443
+ return;
444
+ }
445
+ case 'permission.replied': {
446
+ // Clean up — reply already sent by the poller handler
447
+ const { permissionID } = event.properties;
448
+ pendingPermissions.delete(permissionID);
449
+ log(client, 'debug', `[Vicoa] Cleaned up replied permission: ${permissionID}`);
450
+ return;
451
+ }
452
+ // ── server ────────────────────────────────────────────────
453
+ case 'server.connected': {
454
+ log(client, 'info', '[Vicoa] OpenCode server connected');
455
+ return;
456
+ }
457
+ // ── shutdown ──────────────────────────────────────────────
458
+ // Fires before the process exits (/exit, app.exit, etc.).
459
+ // This is the only path where the event loop is still live,
460
+ // so async endSession() can actually complete.
461
+ case 'server.instance.disposed':
462
+ case 'global.disposed': {
463
+ log(client, 'info', `[Vicoa] ${event.type} — ending session`);
464
+ messagePoller.stop();
465
+ try {
466
+ await vicoaClient.endSession();
467
+ }
468
+ catch (error) {
469
+ log(client, 'warn', `[Vicoa] endSession during dispose failed: ${error}`);
470
+ }
471
+ return;
472
+ }
473
+ default: {
474
+ // Log unhandled events for debugging
475
+ if (event.type.includes('error')) {
476
+ log(client, 'warn', `[Vicoa] Unhandled error event: ${event.type}`);
477
+ // Try to extract error message from various possible structures
478
+ const props = event.properties;
479
+ const errorMsg = props?.error?.message || props?.error || props?.message || 'Unknown error';
480
+ const errorStr = typeof errorMsg === 'string' ? errorMsg : String(errorMsg);
481
+ log(client, 'error', `[Vicoa] Error details: ${errorStr}`);
482
+ // Check if it's a rate limit error
483
+ const errorMsgLower = errorStr.toLowerCase();
484
+ const isRateLimitError = errorMsgLower.includes('too many request') ||
485
+ errorMsgLower.includes('rate limit') ||
486
+ errorStr.includes('429');
487
+ if (isRateLimitError) {
488
+ await vicoaClient.sendMessage(`Too Many Requests: Rate limit exceeded.`);
489
+ }
490
+ else if (errorStr !== 'Unknown error') {
491
+ await vicoaClient.sendMessage(`Error: ${errorStr}`);
492
+ }
493
+ await vicoaClient.updateStatus('AWAITING_INPUT');
494
+ }
495
+ return;
496
+ }
497
+ }
498
+ }
499
+ catch (error) {
500
+ log(client, 'error', `[Vicoa] Error in event hook: ${error}`);
501
+ }
502
+ },
503
+ 'chat.message': async (input, output) => {
504
+ try {
505
+ const { message, parts } = output;
506
+ if (message.role === 'user' && parts.length > 0) {
507
+ // Keep only TextParts. From each one strip any tool-result block
508
+ // that OpenCode appends (e.g. @filename resolution injects
509
+ // "Called the Read tool …\n<file>…</file>"). Non-text parts
510
+ // (auto-attached context files) are ignored entirely.
511
+ const fullText = parts
512
+ .filter((part) => part.type === 'text')
513
+ .map((part) => stripToolResults(part.text))
514
+ .filter((text) => text.trim().length > 0)
515
+ .join('\n');
516
+ if (fullText.length === 0)
517
+ return;
518
+ if (isFromUI(fullText)) {
519
+ log(client, 'debug', `[Vicoa] Skipping message from UI: ${fullText.substring(0, 80)}...`);
520
+ return;
521
+ }
522
+ log(client, 'info', `[Vicoa] User message from terminal: ${fullText.substring(0, 80)}${fullText.length > 80 ? '...' : ''}`);
523
+ await vicoaClient.sendUserMessage(fullText);
524
+ }
525
+ }
526
+ catch (error) {
527
+ log(client, 'error', `[Vicoa] Error in chat.message hook: ${error}`);
528
+ }
529
+ },
530
+ 'tool.execute.before': async (input) => {
531
+ log(client, 'debug', `[Vicoa] Tool executing: ${input.tool}`);
532
+ },
533
+ 'tool.execute.after': async (input) => {
534
+ log(client, 'debug', `[Vicoa] Tool completed: ${input.tool}`);
535
+ },
536
+ };
537
+ };
538
+ export default VicoaPlugin;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Polls Vicoa backend for user messages and sends them to OpenCode
3
+ *
4
+ * This mimics the Claude wrapper's message queue and polling functionality.
5
+ */
6
+ import type { VicoaClient } from './vicoa-client.js';
7
+ export declare class MessagePoller {
8
+ private client;
9
+ private interval;
10
+ private pollIntervalMs;
11
+ private onMessage;
12
+ private log;
13
+ constructor(client: VicoaClient, onMessage: (content: string) => Promise<void>, logFunc?: (level: string, msg: string) => void, pollIntervalMs?: number);
14
+ start(): void;
15
+ stop(): void;
16
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Polls Vicoa backend for user messages and sends them to OpenCode
3
+ *
4
+ * This mimics the Claude wrapper's message queue and polling functionality.
5
+ */
6
+ export class MessagePoller {
7
+ client;
8
+ interval = null;
9
+ pollIntervalMs;
10
+ onMessage;
11
+ log;
12
+ constructor(client, onMessage, logFunc, pollIntervalMs = 1000) {
13
+ this.client = client;
14
+ this.onMessage = onMessage;
15
+ this.pollIntervalMs = pollIntervalMs;
16
+ this.log = logFunc || ((level, msg) => console.log(`[${level}] ${msg}`));
17
+ }
18
+ start() {
19
+ if (this.interval) {
20
+ return;
21
+ }
22
+ this.log('info', 'Starting message poller');
23
+ this.interval = setInterval(async () => {
24
+ try {
25
+ const messages = await this.client.getPendingMessages();
26
+ for (const msg of messages) {
27
+ if (msg.sender_type === 'USER' && msg.content) {
28
+ this.log('debug', `Received user message: ${msg.content.substring(0, 100)}...`);
29
+ await this.onMessage(msg.content);
30
+ }
31
+ }
32
+ }
33
+ catch (error) {
34
+ this.log('warn', `Error polling messages: ${error}`);
35
+ }
36
+ }, this.pollIntervalMs);
37
+ }
38
+ stop() {
39
+ if (this.interval) {
40
+ clearInterval(this.interval);
41
+ this.interval = null;
42
+ this.log('info', 'Stopped message poller');
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,24 @@
1
+ import type { VicoaClient } from './vicoa-client.js';
2
+ export declare const OPENCODE_SLASH_AGENT_TYPE = "opencode";
3
+ export declare const OPENCODE_SLASH_COMMAND_ACTIONS: Record<string, string>;
4
+ export declare const OPENCODE_EXECUTE_COMMAND_KEYS: Record<string, string>;
5
+ /**
6
+ * Execute a TUI command via the OpenCode client.
7
+ */
8
+ export declare function executeTuiCommand(client: any, command: string): Promise<void>;
9
+ export type OpencodeCommandMap = Record<string, {
10
+ description: string;
11
+ }>;
12
+ export type ParsedSlashCommand = {
13
+ name: string;
14
+ rawName: string;
15
+ arguments: string;
16
+ };
17
+ export declare function parseSlashCommand(input: string): ParsedSlashCommand | null;
18
+ export declare function scanOpencodeCommands(projectDir: string | undefined, homeDir: string): Promise<OpencodeCommandMap>;
19
+ /**
20
+ * Handle slash command execution. Returns true if the command was executed
21
+ * directly (built-in with no arguments), false if it should be submitted as
22
+ * a prompt (built-in with args, custom command, or unknown command).
23
+ */
24
+ export declare function handleSlashCommand(userMessage: string, client: any, currentSessionId?: string, vicoaClient?: VicoaClient): Promise<boolean>;