@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.
@@ -0,0 +1,339 @@
1
+ const LANGUAGE_MAP = {
2
+ py: 'python',
3
+ js: 'javascript',
4
+ ts: 'typescript',
5
+ jsx: 'jsx',
6
+ tsx: 'tsx',
7
+ java: 'java',
8
+ cpp: 'cpp',
9
+ c: 'c',
10
+ cs: 'csharp',
11
+ rb: 'ruby',
12
+ go: 'go',
13
+ rs: 'rust',
14
+ php: 'php',
15
+ swift: 'swift',
16
+ kt: 'kotlin',
17
+ yaml: 'yaml',
18
+ yml: 'yaml',
19
+ json: 'json',
20
+ xml: 'xml',
21
+ html: 'html',
22
+ css: 'css',
23
+ scss: 'scss',
24
+ sql: 'sql',
25
+ sh: 'bash',
26
+ bash: 'bash',
27
+ md: 'markdown',
28
+ txt: 'text',
29
+ };
30
+ function truncateText(text, maxLength = 100) {
31
+ if (text.length <= maxLength) {
32
+ return text;
33
+ }
34
+ return `${text.slice(0, maxLength)}...`;
35
+ }
36
+ function detectLanguage(filePath) {
37
+ const extension = filePath.includes('.') ? filePath.split('.').pop() ?? '' : '';
38
+ return LANGUAGE_MAP[extension] ?? '';
39
+ }
40
+ function getString(input, keys, fallback = '') {
41
+ for (const key of keys) {
42
+ const value = input[key];
43
+ if (typeof value === 'string') {
44
+ return value;
45
+ }
46
+ }
47
+ return fallback;
48
+ }
49
+ function getBoolean(input, keys, fallback = false) {
50
+ for (const key of keys) {
51
+ const value = input[key];
52
+ if (typeof value === 'boolean') {
53
+ return value;
54
+ }
55
+ }
56
+ return fallback;
57
+ }
58
+ function getArray(input, keys) {
59
+ for (const key of keys) {
60
+ const value = input[key];
61
+ if (Array.isArray(value)) {
62
+ return value;
63
+ }
64
+ }
65
+ return [];
66
+ }
67
+ function formatDiffBlock(oldText, newText) {
68
+ const diffLines = ['```diff'];
69
+ if (!oldText && newText) {
70
+ for (const line of newText.split('\n')) {
71
+ diffLines.push(`+ ${line}`);
72
+ }
73
+ }
74
+ else if (oldText && !newText) {
75
+ for (const line of oldText.split('\n')) {
76
+ diffLines.push(`- ${line}`);
77
+ }
78
+ }
79
+ else {
80
+ const oldLines = oldText.split('\n');
81
+ const newLines = newText.split('\n');
82
+ const commonPrefix = [];
83
+ const commonSuffix = [];
84
+ for (let i = 0; i < Math.min(oldLines.length, newLines.length); i += 1) {
85
+ if (oldLines[i] === newLines[i]) {
86
+ commonPrefix.push(oldLines[i]);
87
+ }
88
+ else {
89
+ break;
90
+ }
91
+ }
92
+ const oldRemaining = oldLines.slice(commonPrefix.length);
93
+ const newRemaining = newLines.slice(commonPrefix.length);
94
+ if (oldRemaining.length && newRemaining.length) {
95
+ for (let i = 1; i <= Math.min(oldRemaining.length, newRemaining.length); i += 1) {
96
+ if (oldRemaining[oldRemaining.length - i] === newRemaining[newRemaining.length - i]) {
97
+ commonSuffix.unshift(oldRemaining[oldRemaining.length - i]);
98
+ }
99
+ else {
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ const changedOld = commonSuffix.length
105
+ ? oldRemaining.slice(0, oldRemaining.length - commonSuffix.length)
106
+ : oldRemaining;
107
+ const changedNew = commonSuffix.length
108
+ ? newRemaining.slice(0, newRemaining.length - commonSuffix.length)
109
+ : newRemaining;
110
+ if ((commonPrefix.length || commonSuffix.length) && (changedOld.length || changedNew.length)) {
111
+ const contextBefore = commonPrefix.slice(-2);
112
+ const contextAfter = commonSuffix.slice(0, 2);
113
+ for (const line of contextBefore) {
114
+ diffLines.push(` ${line}`);
115
+ }
116
+ for (const line of changedOld) {
117
+ diffLines.push(`- ${line}`);
118
+ }
119
+ for (const line of changedNew) {
120
+ diffLines.push(`+ ${line}`);
121
+ }
122
+ for (const line of contextAfter) {
123
+ diffLines.push(` ${line}`);
124
+ }
125
+ }
126
+ else {
127
+ for (const line of oldLines) {
128
+ diffLines.push(`- ${line}`);
129
+ }
130
+ for (const line of newLines) {
131
+ diffLines.push(`+ ${line}`);
132
+ }
133
+ }
134
+ }
135
+ diffLines.push('```');
136
+ return diffLines;
137
+ }
138
+ export function formatToolUsage(toolName, inputData) {
139
+ if (toolName.startsWith('mcp__vicoa__')) {
140
+ return `Using tool: ${toolName}`;
141
+ }
142
+ const normalizedTool = toolName.toLowerCase();
143
+ if (normalizedTool === 'write') {
144
+ const filePath = getString(inputData, ['file_path', 'filePath', 'path', 'filename'], 'unknown');
145
+ const content = getString(inputData, ['content', 'text', 'value']);
146
+ const lang = detectLanguage(filePath);
147
+ return [`Using tool: Write - \`${filePath}\``, `\`\`\`${lang}`, content, '```']
148
+ .filter((line) => line.length > 0)
149
+ .join('\n');
150
+ }
151
+ if (normalizedTool === 'read' || normalizedTool === 'notebookread' || normalizedTool === 'notebookedit') {
152
+ const filePath = getString(inputData, ['file_path', 'filePath', 'path', 'notebook_path'], 'unknown');
153
+ return `Using tool: ${toolName} - \`${filePath}\``;
154
+ }
155
+ if (normalizedTool === 'edit') {
156
+ const filePath = getString(inputData, ['file_path', 'filePath', 'path'], 'unknown');
157
+ const oldString = getString(inputData, ['old_string', 'oldString']);
158
+ const newString = getString(inputData, ['new_string', 'newString']);
159
+ const replaceAll = getBoolean(inputData, ['replace_all', 'replaceAll']);
160
+ const diffLines = [`Using tool: **Edit** - \`${filePath}\``];
161
+ if (replaceAll) {
162
+ diffLines.push('*Replacing all occurrences*');
163
+ }
164
+ diffLines.push('');
165
+ diffLines.push(...formatDiffBlock(oldString, newString));
166
+ return diffLines.join('\n');
167
+ }
168
+ if (normalizedTool === 'multiedit') {
169
+ const filePath = getString(inputData, ['file_path', 'filePath', 'path'], 'unknown');
170
+ const edits = getArray(inputData, ['edits']);
171
+ const lines = [
172
+ `Using tool: **MultiEdit** - \`${filePath}\``,
173
+ `*Making ${edits.length} edit${edits.length === 1 ? '' : 's'}:*`,
174
+ '',
175
+ ];
176
+ edits.forEach((edit, index) => {
177
+ const editIndex = index + 1;
178
+ const oldString = typeof edit.old_string === 'string' ? edit.old_string : edit.oldString ?? '';
179
+ const newString = typeof edit.new_string === 'string' ? edit.new_string : edit.newString ?? '';
180
+ const replaceAll = Boolean(edit.replace_all ?? edit.replaceAll ?? false);
181
+ lines.push(replaceAll ? `### Edit ${editIndex} *(replacing all occurrences)*` : `### Edit ${editIndex}`);
182
+ lines.push('');
183
+ lines.push(...formatDiffBlock(oldString, newString));
184
+ lines.push('');
185
+ });
186
+ return lines.join('\n');
187
+ }
188
+ if (normalizedTool === 'bash') {
189
+ const command = getString(inputData, ['command', 'cmd']);
190
+ return `Using tool: Bash - \`${command}\``;
191
+ }
192
+ if (normalizedTool === 'grep' || normalizedTool === 'glob') {
193
+ const pattern = getString(inputData, ['pattern', 'query'], 'unknown');
194
+ const path = getString(inputData, ['path', 'directory'], 'current directory');
195
+ return `Using tool: ${toolName} - \`${truncateText(pattern, 50)}\` in ${path}`;
196
+ }
197
+ if (normalizedTool === 'list' || normalizedTool === 'ls') {
198
+ const path = getString(inputData, ['path'], 'unknown');
199
+ return `Using tool: list - \`${path}\``;
200
+ }
201
+ if (normalizedTool === 'patch') {
202
+ const file = getString(inputData, ['file', 'path'], 'unknown');
203
+ return `Using tool: patch - \`${file}\``;
204
+ }
205
+ if (normalizedTool === 'skill') {
206
+ const name = getString(inputData, ['name', 'skill'], 'unknown');
207
+ return `Using tool: skill - \`${name}\``;
208
+ }
209
+ if (normalizedTool === 'question') {
210
+ const text = getString(inputData, ['text', 'question', 'message'], '');
211
+ return text ? `Asking: ${truncateText(text, 100)}` : 'Using tool: question';
212
+ }
213
+ if (normalizedTool === 'lsp') {
214
+ const command = getString(inputData, ['command', 'method'], 'unknown');
215
+ const file = getString(inputData, ['file', 'path'], '');
216
+ return file ? `Using tool: lsp - ${command} on \`${file}\`` : `Using tool: lsp - ${command}`;
217
+ }
218
+ if (normalizedTool === 'todowrite') {
219
+ const todos = getArray(inputData, ['todos']);
220
+ if (!todos.length) {
221
+ return 'Using tool: TodoWrite - clearing todo list';
222
+ }
223
+ const statusSymbol = {
224
+ pending: '○',
225
+ in_progress: '◐',
226
+ completed: '●',
227
+ };
228
+ const lines = ['Using tool: TodoWrite - Todo List', ''];
229
+ for (const todo of todos) {
230
+ const status = typeof todo.status === 'string' ? todo.status : 'pending';
231
+ const content = typeof todo.content === 'string' ? todo.content : '';
232
+ const symbol = statusSymbol[status] ?? '•';
233
+ lines.push(`${symbol} ${truncateText(content, 100)}`);
234
+ }
235
+ return lines.join('\n');
236
+ }
237
+ if (normalizedTool === 'todoread') {
238
+ return 'Using tool: todoread';
239
+ }
240
+ if (normalizedTool === 'task') {
241
+ const description = getString(inputData, ['description'], 'unknown task');
242
+ const subagentType = getString(inputData, ['subagent_type', 'subagentType', 'agent'], 'unknown');
243
+ return `Using tool: Task - ${truncateText(description, 50)} (agent: ${subagentType})`;
244
+ }
245
+ if (normalizedTool === 'webfetch') {
246
+ const url = getString(inputData, ['url'], 'unknown');
247
+ return `Using tool: WebFetch - \`${truncateText(url, 80)}\``;
248
+ }
249
+ if (normalizedTool === 'websearch') {
250
+ const query = getString(inputData, ['query'], 'unknown');
251
+ return `Using tool: WebSearch - ${truncateText(query, 80)}`;
252
+ }
253
+ if (toolName === 'ListMcpResourcesTool') {
254
+ return 'Using tool: List MCP Resources';
255
+ }
256
+ const defaultKeys = ['file', 'path', 'query', 'content', 'message', 'description', 'name'];
257
+ for (const key of defaultKeys) {
258
+ if (typeof inputData[key] === 'string') {
259
+ return `Using tool: ${toolName} - ${truncateText(String(inputData[key]), 50)}`;
260
+ }
261
+ }
262
+ return `Using tool: ${toolName}`;
263
+ }
264
+ // Most tool outputs don't need truncation - show them in full.
265
+ // Only truncate for tools that tend to produce very large/noisy output.
266
+ const TRUNCATE_OUTPUT_TOOLS = new Set(['']); // set to empty for not to filter message now.
267
+ const TRUNCATE_LIMIT = 500;
268
+ export function formatToolResult(output, toolName) {
269
+ try {
270
+ const parsed = JSON.parse(output);
271
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
272
+ const keys = Object.keys(parsed).slice(0, 3);
273
+ const summary = `JSON object with keys: ${keys.join(', ')}`;
274
+ return keys.length < Object.keys(parsed).length ? `${summary} and ${Object.keys(parsed).length - keys.length} more` : summary;
275
+ }
276
+ }
277
+ catch {
278
+ // not JSON
279
+ }
280
+ // Default: don't truncate tool output unless it's in the truncate list
281
+ if (toolName && TRUNCATE_OUTPUT_TOOLS.has(toolName.toLowerCase())) {
282
+ return truncateText(output, TRUNCATE_LIMIT);
283
+ }
284
+ return output;
285
+ }
286
+ // Tools whose Result line is noise: either raw file/list content (read-side)
287
+ // or a boilerplate success confirmation like "Edit applied successfully."
288
+ // (write-side). Only the usage line is shown for these.
289
+ const SUPPRESS_OUTPUT_TOOLS = new Set([
290
+ 'read', 'notebookread', 'list', 'ls', 'glob', 'grep', 'todoread', 'lsp',
291
+ 'write', 'edit', 'multiedit', 'patch', 'notebookedit', 'todowrite',
292
+ ]);
293
+ export function shouldSuppressToolOutput(toolName) {
294
+ return SUPPRESS_OUTPUT_TOOLS.has(toolName.toLowerCase());
295
+ }
296
+ export function formatToolPart(toolPart) {
297
+ const base = formatToolUsage(toolPart.tool, toolPart.state.input ?? {});
298
+ if (toolPart.state.status === 'completed') {
299
+ if (shouldSuppressToolOutput(toolPart.tool)) {
300
+ return base;
301
+ }
302
+ const result = toolPart.state.output ? formatToolResult(toolPart.state.output, toolPart.tool) : '[empty]';
303
+ // Filter out empty results - just show the tool usage
304
+ if (result === '[empty]') {
305
+ return base;
306
+ }
307
+ return `${base}\nResult: ${result}`;
308
+ }
309
+ if (toolPart.state.status === 'error') {
310
+ const error = toolPart.state.error ? truncateText(toolPart.state.error, 200) : 'Unknown error';
311
+ return `${base}\n${error}`;
312
+ }
313
+ return base;
314
+ }
315
+ export function formatReasoningPart(part) {
316
+ if (!part.text) {
317
+ return '';
318
+ }
319
+ return `[Thinking: ${truncateText(part.text, 200)}]`;
320
+ }
321
+ export function formatFilePart(part) {
322
+ if (part.mime?.startsWith('image/') && part.url) {
323
+ const altText = part.filename ?? 'image';
324
+ return `![${altText}](${part.url})`;
325
+ }
326
+ const source = part.source?.text?.value ?? '';
327
+ const path = part.source?.path ?? part.filename ?? part.url ?? 'file';
328
+ if (source) {
329
+ const lang = detectLanguage(path);
330
+ return [`File: \`${path}\``, `\`\`\`${lang}`, source, '```'].join('\n');
331
+ }
332
+ return `File: \`${path}\``;
333
+ }
334
+ export function formatPatchPart(part) {
335
+ if (part.files?.length) {
336
+ return `Patch updated: ${part.files.join(', ')}`;
337
+ }
338
+ return 'Patch updated';
339
+ }
@@ -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,45 @@
1
+ /**
2
+ * Path utilities for normalizing project paths
3
+ *
4
+ * Matches the behavior of Python's vicoa.utils.get_project_path()
5
+ * to ensure consistent path representation across agents.
6
+ */
7
+ /**
8
+ * Format a project path to use ~ for home directory.
9
+ *
10
+ * This creates a more readable path representation by replacing the home
11
+ * directory prefix with ~, consistent with how paths are displayed across
12
+ * agent instances.
13
+ *
14
+ * @param projectPath - The path to format. Can be absolute or relative.
15
+ * @returns The formatted path with ~ replacing home directory if applicable.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * formatProjectPath("/Users/john/projects/myapp")
20
+ * // Returns: "~/projects/myapp"
21
+ *
22
+ * formatProjectPath("/opt/app")
23
+ * // Returns: "/opt/app"
24
+ * ```
25
+ */
26
+ export declare function formatProjectPath(projectPath: string): string;
27
+ /**
28
+ * Expand ~ in a path to the full home directory path.
29
+ *
30
+ * This is the inverse of formatProjectPath - it converts tilde paths
31
+ * back to absolute paths for use in file operations.
32
+ *
33
+ * @param projectPath - The path to expand (may contain ~)
34
+ * @returns The absolute path with ~ expanded
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * expandProjectPath("~/projects/myapp")
39
+ * // Returns: "/Users/john/projects/myapp"
40
+ *
41
+ * expandProjectPath("/opt/app")
42
+ * // Returns: "/opt/app"
43
+ * ```
44
+ */
45
+ export declare function expandProjectPath(projectPath: string): string;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Path utilities for normalizing project paths
3
+ *
4
+ * Matches the behavior of Python's vicoa.utils.get_project_path()
5
+ * to ensure consistent path representation across agents.
6
+ */
7
+ import * as os from 'os';
8
+ import * as path from 'path';
9
+ /**
10
+ * Format a project path to use ~ for home directory.
11
+ *
12
+ * This creates a more readable path representation by replacing the home
13
+ * directory prefix with ~, consistent with how paths are displayed across
14
+ * agent instances.
15
+ *
16
+ * @param projectPath - The path to format. Can be absolute or relative.
17
+ * @returns The formatted path with ~ replacing home directory if applicable.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * formatProjectPath("/Users/john/projects/myapp")
22
+ * // Returns: "~/projects/myapp"
23
+ *
24
+ * formatProjectPath("/opt/app")
25
+ * // Returns: "/opt/app"
26
+ * ```
27
+ */
28
+ export function formatProjectPath(projectPath) {
29
+ // Resolve to absolute path first
30
+ const absolutePath = path.resolve(projectPath);
31
+ const homeDir = os.homedir();
32
+ // Replace home directory with tilde
33
+ if (absolutePath.startsWith(homeDir)) {
34
+ return '~' + absolutePath.slice(homeDir.length);
35
+ }
36
+ return absolutePath;
37
+ }
38
+ /**
39
+ * Expand ~ in a path to the full home directory path.
40
+ *
41
+ * This is the inverse of formatProjectPath - it converts tilde paths
42
+ * back to absolute paths for use in file operations.
43
+ *
44
+ * @param projectPath - The path to expand (may contain ~)
45
+ * @returns The absolute path with ~ expanded
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * expandProjectPath("~/projects/myapp")
50
+ * // Returns: "/Users/john/projects/myapp"
51
+ *
52
+ * expandProjectPath("/opt/app")
53
+ * // Returns: "/opt/app"
54
+ * ```
55
+ */
56
+ export function expandProjectPath(projectPath) {
57
+ if (projectPath.startsWith('~/') || projectPath === '~') {
58
+ const homeDir = os.homedir();
59
+ return path.join(homeDir, projectPath.slice(2));
60
+ }
61
+ return path.resolve(projectPath);
62
+ }
@@ -0,0 +1,30 @@
1
+ import type { Permission } from '@opencode-ai/sdk';
2
+ /**
3
+ * Permission option for user selection
4
+ */
5
+ export type PermissionOption = {
6
+ label: string;
7
+ response: 'once' | 'always' | 'reject';
8
+ };
9
+ /**
10
+ * Default options shown when the permission metadata doesn't supply its own.
11
+ * Mirrors the three choices Claude Code's own TUI presents.
12
+ */
13
+ export declare const DEFAULT_PERMISSION_OPTIONS: PermissionOption[];
14
+ /**
15
+ * Build the option list for a permission request. If the permission's
16
+ * metadata includes an `options` array we use those labels (mapped to the
17
+ * standard responses in order); otherwise fall back to the defaults.
18
+ */
19
+ export declare function buildPermissionOptions(permission: Permission): PermissionOption[];
20
+ /**
21
+ * Format a permission request for display in Vicoa UI.
22
+ * Shows the permission type, patterns, and code diff/preview if available.
23
+ */
24
+ export declare function formatPermissionRequest(permission: Permission, options: PermissionOption[]): string;
25
+ /**
26
+ * Given a user reply string (from the Vicoa poller) and the options that were
27
+ * sent for that permission, return the matching OpenCode permission response
28
+ * value, or null if it doesn't match.
29
+ */
30
+ export declare function parsePermissionReply(userReply: string, options: PermissionOption[]): 'once' | 'always' | 'reject' | null;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Default options shown when the permission metadata doesn't supply its own.
3
+ * Mirrors the three choices Claude Code's own TUI presents.
4
+ */
5
+ export const DEFAULT_PERMISSION_OPTIONS = [
6
+ { label: 'Allow', response: 'once' },
7
+ { label: 'Allow always', response: 'always' },
8
+ { label: 'Reject', response: 'reject' },
9
+ ];
10
+ /**
11
+ * Build the option list for a permission request. If the permission's
12
+ * metadata includes an `options` array we use those labels (mapped to the
13
+ * standard responses in order); otherwise fall back to the defaults.
14
+ */
15
+ export function buildPermissionOptions(permission) {
16
+ const meta = permission.metadata;
17
+ const metaOptions = meta?.options;
18
+ // If metadata supplies an options array, use those labels but keep standard responses
19
+ if (Array.isArray(metaOptions) && metaOptions.length > 0) {
20
+ const responses = ['once', 'always', 'reject'];
21
+ return metaOptions.slice(0, 3).map((label, i) => ({
22
+ label: String(label),
23
+ response: responses[i] ?? 'reject',
24
+ }));
25
+ }
26
+ return DEFAULT_PERMISSION_OPTIONS;
27
+ }
28
+ /**
29
+ * Format a permission request for display in Vicoa UI.
30
+ * Shows the permission type, patterns, and code diff/preview if available.
31
+ */
32
+ export function formatPermissionRequest(permission, options) {
33
+ // Handle both V1 (type/pattern) and V2 (permission/patterns) SDK structures
34
+ const permissionType = permission.permission || permission.type || 'unknown';
35
+ // Extract patterns from either V1 (pattern) or V2 (patterns) format
36
+ let patterns = [];
37
+ if (permission.patterns) {
38
+ patterns = permission.patterns;
39
+ }
40
+ else if (permission.pattern) {
41
+ patterns = Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
42
+ }
43
+ // Format as: **Type** (`pattern`) - consistent with tool use format
44
+ let message = '**Permission Required**\n\n';
45
+ if (patterns.length === 0) {
46
+ message += `**${permissionType}**`;
47
+ }
48
+ else if (patterns.length === 1) {
49
+ message += `**${permissionType}** (\`${patterns[0]}\`)`;
50
+ }
51
+ else {
52
+ message += `**${permissionType}** (${patterns.length} patterns)\n`;
53
+ patterns.forEach(p => {
54
+ message += ` • \`${p}\`\n`;
55
+ });
56
+ }
57
+ // Add code diff or content preview if available in metadata
58
+ const meta = permission.metadata;
59
+ if (meta?.input && typeof meta.input === 'object') {
60
+ const input = meta.input;
61
+ // For Edit tool - show diff
62
+ if (permissionType === 'edit' && input.old_string && input.new_string) {
63
+ const oldStr = String(input.old_string);
64
+ const newStr = String(input.new_string);
65
+ message += '\n```diff\n';
66
+ // Simple diff: show removed and added lines
67
+ for (const line of oldStr.split('\n')) {
68
+ message += `- ${line}\n`;
69
+ }
70
+ for (const line of newStr.split('\n')) {
71
+ message += `+ ${line}\n`;
72
+ }
73
+ message += '```';
74
+ }
75
+ // For Write tool - show content preview
76
+ else if (permissionType === 'write' && input.content) {
77
+ const content = String(input.content);
78
+ const preview = content.length > 500 ? content.slice(0, 500) + '...' : content;
79
+ message += '\n```\n' + preview + '\n```';
80
+ }
81
+ // For Bash tool - show command
82
+ else if (permissionType === 'bash' && input.command) {
83
+ message += '\n```bash\n' + input.command + '\n```';
84
+ }
85
+ }
86
+ // Add additional context from metadata if available
87
+ if (meta?.description && typeof meta.description === 'string') {
88
+ message += `\n\n${meta.description}`;
89
+ }
90
+ // Format options in the same [OPTIONS] envelope the Python wrapper uses,
91
+ // so the Vicoa UI renders them as actionable buttons/choices.
92
+ const optionLines = options.map((opt, i) => `${i + 1}. ${opt.label}`);
93
+ message += `\n\n[OPTIONS]\n${optionLines.join('\n')}\n[/OPTIONS]`;
94
+ return message;
95
+ }
96
+ /**
97
+ * Given a user reply string (from the Vicoa poller) and the options that were
98
+ * sent for that permission, return the matching OpenCode permission response
99
+ * value, or null if it doesn't match.
100
+ */
101
+ export function parsePermissionReply(userReply, options) {
102
+ const trimmed = userReply.trim().toLowerCase();
103
+ // Try direct label match (case-insensitive)
104
+ for (const opt of options) {
105
+ if (opt.label.toLowerCase() === trimmed) {
106
+ return opt.response;
107
+ }
108
+ }
109
+ // Try numeric match (1, 2, 3)
110
+ const num = Number.parseInt(trimmed, 10);
111
+ if (!Number.isNaN(num) && num >= 1 && num <= options.length) {
112
+ return options[num - 1].response;
113
+ }
114
+ // Fallback: look for keywords
115
+ if (/^(allow|yes|y|ok|approve|1)$/i.test(trimmed)) {
116
+ return 'once';
117
+ }
118
+ if (/^(always|forever|permanent|2)$/i.test(trimmed)) {
119
+ return 'always';
120
+ }
121
+ if (/^(reject|no|n|deny|cancel|3)$/i.test(trimmed)) {
122
+ return 'reject';
123
+ }
124
+ return null;
125
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Shared utility functions for the OpenCode Vicoa plugin
3
+ */
4
+ /**
5
+ * Helper to log messages using OpenCode's app.log format
6
+ */
7
+ export declare function log(client: any, level: 'debug' | 'info' | 'warn' | 'error', message: string): void;