remote-opencode 1.0.8 → 1.1.1

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.
@@ -1,10 +1,6 @@
1
- import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
2
1
  import * as dataStore from '../services/dataStore.js';
3
- import * as sessionManager from '../services/sessionManager.js';
4
- import * as serveManager from '../services/serveManager.js';
5
- import * as worktreeManager from '../services/worktreeManager.js';
6
- import { SSEClient } from '../services/sseClient.js';
7
- import { formatOutput } from '../utils/messageFormatter.js';
2
+ import { runPrompt } from '../services/executionService.js';
3
+ import { isBusy } from '../services/queueManager.js';
8
4
  export async function handleMessageCreate(message) {
9
5
  if (message.author.bot)
10
6
  return;
@@ -19,178 +15,17 @@ export async function handleMessageCreate(message) {
19
15
  const parentChannelId = channel.parentId;
20
16
  if (!parentChannelId)
21
17
  return;
22
- const projectPath = dataStore.getChannelProjectPath(parentChannelId);
23
- if (!projectPath) {
24
- await message.reply('āŒ No project bound to parent channel. Disable passthrough and use `/use` first.');
25
- return;
26
- }
27
- let worktreeMapping = dataStore.getWorktreeMapping(threadId);
28
- // Auto-create worktree if enabled and no mapping exists for this thread
29
- if (!worktreeMapping) {
30
- const projectAlias = dataStore.getChannelBinding(parentChannelId);
31
- if (projectAlias && dataStore.getProjectAutoWorktree(projectAlias)) {
32
- try {
33
- const branchName = worktreeManager.sanitizeBranchName(`auto/${threadId.slice(0, 8)}-${Date.now()}`);
34
- const worktreePath = await worktreeManager.createWorktree(projectPath, branchName);
35
- const prompt = message.content.trim();
36
- const newMapping = {
37
- threadId,
38
- branchName,
39
- worktreePath,
40
- projectPath,
41
- description: prompt.slice(0, 50) + (prompt.length > 50 ? '...' : ''),
42
- createdAt: Date.now()
43
- };
44
- dataStore.setWorktreeMapping(newMapping);
45
- worktreeMapping = newMapping;
46
- const embed = new EmbedBuilder()
47
- .setTitle(`🌳 Auto-Worktree: ${branchName}`)
48
- .setDescription('Automatically created for this session')
49
- .addFields({ name: 'Branch', value: branchName, inline: true }, { name: 'Path', value: worktreePath, inline: true })
50
- .setColor(0x2ecc71);
51
- const worktreeButtons = new ActionRowBuilder()
52
- .addComponents(new ButtonBuilder()
53
- .setCustomId(`delete_${threadId}`)
54
- .setLabel('Delete')
55
- .setStyle(ButtonStyle.Danger), new ButtonBuilder()
56
- .setCustomId(`pr_${threadId}`)
57
- .setLabel('Create PR')
58
- .setStyle(ButtonStyle.Primary));
59
- await channel.send({ embeds: [embed], components: [worktreeButtons] });
60
- }
61
- catch (error) {
62
- console.error('Auto-worktree creation failed:', error);
63
- // Continue with main project path (graceful degradation)
64
- }
65
- }
66
- }
67
- const effectivePath = worktreeMapping?.worktreePath ?? projectPath;
68
- const existingClient = sessionManager.getSseClient(threadId);
69
- if (existingClient && existingClient.isConnected()) {
70
- await message.react('ā³');
71
- return;
72
- }
73
18
  const prompt = message.content.trim();
74
19
  if (!prompt)
75
20
  return;
76
- const buttons = new ActionRowBuilder()
77
- .addComponents(new ButtonBuilder()
78
- .setCustomId(`interrupt_${threadId}`)
79
- .setLabel('āøļø Interrupt')
80
- .setStyle(ButtonStyle.Secondary));
81
- let streamMessage;
82
- try {
83
- streamMessage = await channel.send({
84
- content: `šŸ“Œ **Prompt**: ${prompt}\n\nšŸš€ Starting OpenCode server...`,
85
- components: [buttons]
21
+ if (isBusy(threadId)) {
22
+ dataStore.addToQueue(threadId, {
23
+ prompt,
24
+ userId: message.author.id,
25
+ timestamp: Date.now()
86
26
  });
87
- }
88
- catch {
27
+ await message.react('šŸ“„');
89
28
  return;
90
29
  }
91
- let port;
92
- let sessionId;
93
- let updateInterval = null;
94
- let accumulatedText = '';
95
- let lastContent = '';
96
- let tick = 0;
97
- const spinner = ['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā '];
98
- const updateStreamMessage = async (content, components) => {
99
- try {
100
- await streamMessage.edit({ content, components });
101
- }
102
- catch {
103
- }
104
- };
105
- try {
106
- port = await serveManager.spawnServe(effectivePath);
107
- await updateStreamMessage(`šŸ“Œ **Prompt**: ${prompt}\n\nā³ Waiting for OpenCode server...`, [buttons]);
108
- await serveManager.waitForReady(port);
109
- const existingSession = sessionManager.getSessionForThread(threadId);
110
- if (existingSession && existingSession.projectPath === effectivePath) {
111
- const isValid = await sessionManager.validateSession(port, existingSession.sessionId);
112
- if (isValid) {
113
- sessionId = existingSession.sessionId;
114
- sessionManager.updateSessionLastUsed(threadId);
115
- }
116
- else {
117
- sessionId = await sessionManager.createSession(port);
118
- sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
119
- }
120
- }
121
- else {
122
- sessionId = await sessionManager.createSession(port);
123
- sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
124
- }
125
- const sseClient = new SSEClient();
126
- sseClient.connect(`http://localhost:${port}`);
127
- sessionManager.setSseClient(threadId, sseClient);
128
- sseClient.onPartUpdated((part) => {
129
- accumulatedText = part.text;
130
- });
131
- sseClient.onSessionIdle(() => {
132
- if (updateInterval) {
133
- clearInterval(updateInterval);
134
- updateInterval = null;
135
- }
136
- (async () => {
137
- try {
138
- const formatted = formatOutput(accumulatedText);
139
- const disabledButtons = new ActionRowBuilder()
140
- .addComponents(new ButtonBuilder()
141
- .setCustomId(`interrupt_${threadId}`)
142
- .setLabel('āøļø Interrupt')
143
- .setStyle(ButtonStyle.Secondary)
144
- .setDisabled(true));
145
- await updateStreamMessage(`šŸ“Œ **Prompt**: ${prompt}\n\n\`\`\`\n${formatted}\n\`\`\``, [disabledButtons]);
146
- await channel.send({ content: 'āœ… Done' });
147
- sseClient.disconnect();
148
- sessionManager.clearSseClient(threadId);
149
- }
150
- catch {
151
- }
152
- })();
153
- });
154
- sseClient.onError((error) => {
155
- if (updateInterval) {
156
- clearInterval(updateInterval);
157
- updateInterval = null;
158
- }
159
- (async () => {
160
- try {
161
- await updateStreamMessage(`šŸ“Œ **Prompt**: ${prompt}\n\nāŒ Connection error: ${error.message}`, []);
162
- }
163
- catch {
164
- }
165
- })();
166
- });
167
- updateInterval = setInterval(async () => {
168
- tick++;
169
- try {
170
- const formatted = formatOutput(accumulatedText);
171
- const spinnerChar = spinner[tick % spinner.length];
172
- const newContent = formatted || 'Processing...';
173
- if (newContent !== lastContent || tick % 2 === 0) {
174
- lastContent = newContent;
175
- await updateStreamMessage(`šŸ“Œ **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n\`\`\`\n${newContent}\n\`\`\``, [buttons]);
176
- }
177
- }
178
- catch {
179
- }
180
- }, 1000);
181
- await updateStreamMessage(`šŸ“Œ **Prompt**: ${prompt}\n\nšŸ“ Sending prompt...`, [buttons]);
182
- await sessionManager.sendPrompt(port, sessionId, prompt);
183
- }
184
- catch (error) {
185
- if (updateInterval) {
186
- clearInterval(updateInterval);
187
- }
188
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
189
- await updateStreamMessage(`šŸ“Œ **Prompt**: ${prompt}\n\nāŒ OpenCode execution failed: ${errorMessage}`, []);
190
- const client = sessionManager.getSseClient(threadId);
191
- if (client) {
192
- client.disconnect();
193
- sessionManager.clearSseClient(threadId);
194
- }
195
- }
30
+ await runPrompt(channel, threadId, prompt, parentChannelId);
196
31
  }
@@ -36,6 +36,14 @@ export function setBotConfig(bot) {
36
36
  config.bot = bot;
37
37
  saveConfig(config);
38
38
  }
39
+ export function getPortConfig() {
40
+ return loadConfig().ports;
41
+ }
42
+ export function setPortConfig(ports) {
43
+ const config = loadConfig();
44
+ config.ports = ports;
45
+ saveConfig(config);
46
+ }
39
47
  export function hasBotConfig() {
40
48
  const bot = getBotConfig();
41
49
  return !!(bot?.discordToken && bot?.clientId && bot?.guildId);
@@ -52,17 +52,34 @@ export function removeProject(alias) {
52
52
  saveData(data);
53
53
  return true;
54
54
  }
55
- export function setChannelBinding(channelId, projectAlias) {
55
+ export function setChannelBinding(channelId, projectAlias, model) {
56
56
  const data = loadData();
57
57
  const existing = data.bindings.findIndex(b => b.channelId === channelId);
58
58
  if (existing >= 0) {
59
59
  data.bindings[existing].projectAlias = projectAlias;
60
+ if (model !== undefined) {
61
+ data.bindings[existing].model = model;
62
+ }
60
63
  }
61
64
  else {
62
- data.bindings.push({ channelId, projectAlias });
65
+ data.bindings.push({ channelId, projectAlias, model });
63
66
  }
64
67
  saveData(data);
65
68
  }
69
+ export function setChannelModel(channelId, model) {
70
+ const data = loadData();
71
+ const existing = data.bindings.findIndex(b => b.channelId === channelId);
72
+ if (existing >= 0) {
73
+ data.bindings[existing].model = model;
74
+ saveData(data);
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ export function getChannelModel(channelId) {
80
+ const binding = loadData().bindings.find(b => b.channelId === channelId);
81
+ return binding?.model;
82
+ }
66
83
  export function getChannelBinding(channelId) {
67
84
  const binding = loadData().bindings.find(b => b.channelId === channelId);
68
85
  return binding?.projectAlias;
@@ -206,3 +223,50 @@ export function getProjectAutoWorktree(alias) {
206
223
  const project = getProject(alias);
207
224
  return project?.autoWorktree ?? false;
208
225
  }
226
+ // Queue Management
227
+ export function getQueue(threadId) {
228
+ const data = loadData();
229
+ return data.queues?.[threadId] ?? [];
230
+ }
231
+ export function addToQueue(threadId, message) {
232
+ const data = loadData();
233
+ if (!data.queues)
234
+ data.queues = {};
235
+ if (!data.queues[threadId])
236
+ data.queues[threadId] = [];
237
+ data.queues[threadId].push(message);
238
+ saveData(data);
239
+ }
240
+ export function popFromQueue(threadId) {
241
+ const data = loadData();
242
+ if (!data.queues?.[threadId] || data.queues[threadId].length === 0)
243
+ return undefined;
244
+ const message = data.queues[threadId].shift();
245
+ saveData(data);
246
+ return message;
247
+ }
248
+ export function clearQueue(threadId) {
249
+ const data = loadData();
250
+ if (data.queues?.[threadId]) {
251
+ delete data.queues[threadId];
252
+ saveData(data);
253
+ }
254
+ }
255
+ export function getQueueSettings(threadId) {
256
+ const data = loadData();
257
+ return data.queueSettings?.[threadId] ?? {
258
+ paused: false,
259
+ continueOnFailure: false,
260
+ freshContext: true
261
+ };
262
+ }
263
+ export function updateQueueSettings(threadId, settings) {
264
+ const data = loadData();
265
+ if (!data.queueSettings)
266
+ data.queueSettings = {};
267
+ data.queueSettings[threadId] = {
268
+ ...getQueueSettings(threadId),
269
+ ...settings
270
+ };
271
+ saveData(data);
272
+ }
@@ -0,0 +1,204 @@
1
+ import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js';
2
+ import * as dataStore from './dataStore.js';
3
+ import * as sessionManager from './sessionManager.js';
4
+ import * as serveManager from './serveManager.js';
5
+ import * as worktreeManager from './worktreeManager.js';
6
+ import { SSEClient } from './sseClient.js';
7
+ import { formatOutput, buildContextHeader } from '../utils/messageFormatter.js';
8
+ import { processNextInQueue } from './queueManager.js';
9
+ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
10
+ const projectPath = dataStore.getChannelProjectPath(parentChannelId);
11
+ if (!projectPath) {
12
+ await channel.send('āŒ No project bound to parent channel.');
13
+ return;
14
+ }
15
+ let worktreeMapping = dataStore.getWorktreeMapping(threadId);
16
+ // Auto-create worktree if enabled and no mapping exists for this thread
17
+ if (!worktreeMapping) {
18
+ const projectAlias = dataStore.getChannelBinding(parentChannelId);
19
+ if (projectAlias && dataStore.getProjectAutoWorktree(projectAlias)) {
20
+ try {
21
+ const branchName = worktreeManager.sanitizeBranchName(`auto/${threadId.slice(0, 8)}-${Date.now()}`);
22
+ const worktreePath = await worktreeManager.createWorktree(projectPath, branchName);
23
+ const newMapping = {
24
+ threadId,
25
+ branchName,
26
+ worktreePath,
27
+ projectPath,
28
+ description: prompt.slice(0, 50) + (prompt.length > 50 ? '...' : ''),
29
+ createdAt: Date.now()
30
+ };
31
+ dataStore.setWorktreeMapping(newMapping);
32
+ worktreeMapping = newMapping;
33
+ const embed = new EmbedBuilder()
34
+ .setTitle(`🌳 Auto-Worktree: ${branchName}`)
35
+ .setDescription('Automatically created for this session')
36
+ .addFields({ name: 'Branch', value: branchName, inline: true }, { name: 'Path', value: worktreePath, inline: true })
37
+ .setColor(0x2ecc71);
38
+ const worktreeButtons = new ActionRowBuilder()
39
+ .addComponents(new ButtonBuilder()
40
+ .setCustomId(`delete_${threadId}`)
41
+ .setLabel('Delete')
42
+ .setStyle(ButtonStyle.Danger), new ButtonBuilder()
43
+ .setCustomId(`pr_${threadId}`)
44
+ .setLabel('Create PR')
45
+ .setStyle(ButtonStyle.Primary));
46
+ await channel.send({ embeds: [embed], components: [worktreeButtons] });
47
+ }
48
+ catch (error) {
49
+ console.error('Auto-worktree creation failed:', error);
50
+ }
51
+ }
52
+ }
53
+ const effectivePath = worktreeMapping?.worktreePath ?? projectPath;
54
+ const preferredModel = dataStore.getChannelModel(parentChannelId);
55
+ const modelDisplay = preferredModel ? `${preferredModel}` : 'default';
56
+ const branchName = worktreeMapping?.branchName ?? await worktreeManager.getCurrentBranch(effectivePath) ?? 'main';
57
+ const contextHeader = buildContextHeader(branchName, modelDisplay);
58
+ const buttons = new ActionRowBuilder()
59
+ .addComponents(new ButtonBuilder()
60
+ .setCustomId(`interrupt_${threadId}`)
61
+ .setLabel('āøļø Interrupt')
62
+ .setStyle(ButtonStyle.Secondary));
63
+ let streamMessage;
64
+ try {
65
+ streamMessage = await channel.send({
66
+ content: `${contextHeader}\nšŸ“Œ **Prompt**: ${prompt}\n\nšŸš€ Starting OpenCode server...`,
67
+ components: [buttons]
68
+ });
69
+ }
70
+ catch {
71
+ return;
72
+ }
73
+ let port;
74
+ let sessionId;
75
+ let updateInterval = null;
76
+ let accumulatedText = '';
77
+ let lastContent = '';
78
+ let tick = 0;
79
+ const spinner = ['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā '];
80
+ const updateStreamMessage = async (content, components) => {
81
+ try {
82
+ await streamMessage.edit({ content, components });
83
+ }
84
+ catch {
85
+ }
86
+ };
87
+ try {
88
+ port = await serveManager.spawnServe(effectivePath, preferredModel);
89
+ await updateStreamMessage(`${contextHeader}\nšŸ“Œ **Prompt**: ${prompt}\n\nā³ Waiting for OpenCode server...`, [buttons]);
90
+ await serveManager.waitForReady(port, 30000, effectivePath, preferredModel);
91
+ const settings = dataStore.getQueueSettings(threadId);
92
+ // If fresh context is enabled, we always clear the session before starting
93
+ if (settings.freshContext) {
94
+ sessionManager.clearSessionForThread(threadId);
95
+ }
96
+ const existingSession = sessionManager.getSessionForThread(threadId);
97
+ if (existingSession && existingSession.projectPath === effectivePath) {
98
+ const isValid = await sessionManager.validateSession(port, existingSession.sessionId);
99
+ if (isValid) {
100
+ sessionId = existingSession.sessionId;
101
+ sessionManager.updateSessionLastUsed(threadId);
102
+ }
103
+ else {
104
+ sessionId = await sessionManager.createSession(port);
105
+ sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
106
+ }
107
+ }
108
+ else {
109
+ sessionId = await sessionManager.createSession(port);
110
+ sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
111
+ }
112
+ const sseClient = new SSEClient();
113
+ sseClient.connect(`http://127.0.0.1:${port}`);
114
+ sessionManager.setSseClient(threadId, sseClient);
115
+ sseClient.onPartUpdated((part) => {
116
+ accumulatedText = part.text;
117
+ });
118
+ sseClient.onSessionIdle(() => {
119
+ if (updateInterval) {
120
+ clearInterval(updateInterval);
121
+ updateInterval = null;
122
+ }
123
+ (async () => {
124
+ try {
125
+ const formatted = formatOutput(accumulatedText);
126
+ const disabledButtons = new ActionRowBuilder()
127
+ .addComponents(new ButtonBuilder()
128
+ .setCustomId(`interrupt_${threadId}`)
129
+ .setLabel('āøļø Interrupt')
130
+ .setStyle(ButtonStyle.Secondary)
131
+ .setDisabled(true));
132
+ await updateStreamMessage(`${contextHeader}\nšŸ“Œ **Prompt**: ${prompt}\n\n\`\`\`\n${formatted}\n\`\`\``, [disabledButtons]);
133
+ await channel.send({ content: 'āœ… Done' });
134
+ sseClient.disconnect();
135
+ sessionManager.clearSseClient(threadId);
136
+ // Trigger next in queue
137
+ await processNextInQueue(channel, threadId, parentChannelId);
138
+ }
139
+ catch (error) {
140
+ console.error('Error in onSessionIdle:', error);
141
+ }
142
+ })();
143
+ });
144
+ sseClient.onError((error) => {
145
+ if (updateInterval) {
146
+ clearInterval(updateInterval);
147
+ updateInterval = null;
148
+ }
149
+ (async () => {
150
+ try {
151
+ await updateStreamMessage(`${contextHeader}\nšŸ“Œ **Prompt**: ${prompt}\n\nāŒ Connection error: ${error.message}`, []);
152
+ sseClient.disconnect();
153
+ sessionManager.clearSseClient(threadId);
154
+ const settings = dataStore.getQueueSettings(threadId);
155
+ if (settings.continueOnFailure) {
156
+ await processNextInQueue(channel, threadId, parentChannelId);
157
+ }
158
+ else {
159
+ dataStore.clearQueue(threadId);
160
+ await channel.send('āŒ Execution failed. Queue cleared. Use `/queue settings` to change this behavior.');
161
+ }
162
+ }
163
+ catch {
164
+ }
165
+ })();
166
+ });
167
+ updateInterval = setInterval(async () => {
168
+ tick++;
169
+ try {
170
+ const formatted = formatOutput(accumulatedText);
171
+ const spinnerChar = spinner[tick % spinner.length];
172
+ const newContent = formatted || 'Processing...';
173
+ if (newContent !== lastContent || tick % 2 === 0) {
174
+ lastContent = newContent;
175
+ await updateStreamMessage(`${contextHeader}\nšŸ“Œ **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n\`\`\`\n${newContent}\n\`\`\``, [buttons]);
176
+ }
177
+ }
178
+ catch {
179
+ }
180
+ }, 1000);
181
+ await updateStreamMessage(`${contextHeader}\nšŸ“Œ **Prompt**: ${prompt}\n\nšŸ“ Sending prompt...`, [buttons]);
182
+ await sessionManager.sendPrompt(port, sessionId, prompt, preferredModel);
183
+ }
184
+ catch (error) {
185
+ if (updateInterval) {
186
+ clearInterval(updateInterval);
187
+ }
188
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
189
+ await updateStreamMessage(`${contextHeader}\nšŸ“Œ **Prompt**: ${prompt}\n\nāŒ OpenCode execution failed: ${errorMessage}`, []);
190
+ const client = sessionManager.getSseClient(threadId);
191
+ if (client) {
192
+ client.disconnect();
193
+ sessionManager.clearSseClient(threadId);
194
+ }
195
+ const settings = dataStore.getQueueSettings(threadId);
196
+ if (settings.continueOnFailure) {
197
+ await processNextInQueue(channel, threadId, parentChannelId);
198
+ }
199
+ else {
200
+ dataStore.clearQueue(threadId);
201
+ await channel.send('āŒ Execution failed. Queue cleared.');
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,20 @@
1
+ import * as dataStore from './dataStore.js';
2
+ import { runPrompt } from './executionService.js';
3
+ import * as sessionManager from './sessionManager.js';
4
+ export async function processNextInQueue(channel, threadId, parentChannelId) {
5
+ const settings = dataStore.getQueueSettings(threadId);
6
+ if (settings.paused)
7
+ return;
8
+ const next = dataStore.popFromQueue(threadId);
9
+ if (!next)
10
+ return;
11
+ // Visual indication that we are starting the next one
12
+ if ('send' in channel) {
13
+ await channel.send(`šŸ”„ **Queue**: Starting next task...\n> ${next.prompt}`);
14
+ }
15
+ await runPrompt(channel, threadId, next.prompt, parentChannelId);
16
+ }
17
+ export function isBusy(threadId) {
18
+ const sseClient = sessionManager.getSseClient(threadId);
19
+ return !!(sseClient && sseClient.isConnected());
20
+ }