@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/LICENSE +201 -0
- package/README.md +104 -0
- package/dist/commands.d.ts +24 -0
- package/dist/commands.js +228 -0
- package/dist/credentials.d.ts +27 -0
- package/dist/credentials.js +56 -0
- package/dist/format-utils.d.ts +10 -0
- package/dist/format-utils.js +335 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +538 -0
- package/dist/message-poller.d.ts +16 -0
- package/dist/message-poller.js +45 -0
- package/dist/plugin/commands.d.ts +24 -0
- package/dist/plugin/commands.js +228 -0
- package/dist/plugin/control.d.ts +15 -0
- package/dist/plugin/control.js +140 -0
- package/dist/plugin/credentials.d.ts +27 -0
- package/dist/plugin/credentials.js +56 -0
- package/dist/plugin/file-sync.d.ts +5 -0
- package/dist/plugin/file-sync.js +187 -0
- package/dist/plugin/format-utils.d.ts +10 -0
- package/dist/plugin/format-utils.js +339 -0
- package/dist/plugin/message-poller.d.ts +16 -0
- package/dist/plugin/message-poller.js +45 -0
- package/dist/plugin/path-utils.d.ts +45 -0
- package/dist/plugin/path-utils.js +62 -0
- package/dist/plugin/permission.d.ts +30 -0
- package/dist/plugin/permission.js +125 -0
- package/dist/plugin/utils.d.ts +7 -0
- package/dist/plugin/utils.js +15 -0
- package/dist/plugin/vicoa-client.d.ts +67 -0
- package/dist/plugin/vicoa-client.js +259 -0
- package/dist/vicoa-client.d.ts +67 -0
- package/dist/vicoa-client.js +259 -0
- package/package.json +51 -0
|
@@ -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 ``;
|
|
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
|
+
}
|