remote-opencode 1.0.7 → 1.0.10
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/README.md +72 -27
- package/dist/src/__tests__/queueManager.test.js +72 -0
- package/dist/src/__tests__/serveManager.test.js +85 -5
- package/dist/src/__tests__/sessionManager.test.js +17 -2
- package/dist/src/__tests__/sseClient.test.js +13 -13
- package/dist/src/cli.js +11 -1
- package/dist/src/commands/index.js +6 -0
- package/dist/src/commands/model.js +85 -0
- package/dist/src/commands/opencode.js +11 -172
- package/dist/src/commands/queue.js +85 -0
- package/dist/src/commands/setports.js +33 -0
- package/dist/src/handlers/buttonHandler.js +20 -13
- package/dist/src/handlers/interactionHandler.js +17 -5
- package/dist/src/handlers/messageHandler.js +9 -174
- package/dist/src/services/configStore.js +8 -0
- package/dist/src/services/dataStore.js +66 -2
- package/dist/src/services/executionService.js +202 -0
- package/dist/src/services/queueManager.js +20 -0
- package/dist/src/services/serveManager.js +134 -37
- package/dist/src/services/sessionManager.js +26 -9
- package/package.json +2 -2
|
@@ -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,202 @@
|
|
|
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 } 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 buttons = new ActionRowBuilder()
|
|
57
|
+
.addComponents(new ButtonBuilder()
|
|
58
|
+
.setCustomId(`interrupt_${threadId}`)
|
|
59
|
+
.setLabel('⏸️ Interrupt')
|
|
60
|
+
.setStyle(ButtonStyle.Secondary));
|
|
61
|
+
let streamMessage;
|
|
62
|
+
try {
|
|
63
|
+
streamMessage = await channel.send({
|
|
64
|
+
content: `📌 **Prompt**: ${prompt}\n\n🚀 Starting OpenCode server... (Model: ${modelDisplay})`,
|
|
65
|
+
components: [buttons]
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let port;
|
|
72
|
+
let sessionId;
|
|
73
|
+
let updateInterval = null;
|
|
74
|
+
let accumulatedText = '';
|
|
75
|
+
let lastContent = '';
|
|
76
|
+
let tick = 0;
|
|
77
|
+
const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
78
|
+
const updateStreamMessage = async (content, components) => {
|
|
79
|
+
try {
|
|
80
|
+
await streamMessage.edit({ content, components });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
port = await serveManager.spawnServe(effectivePath, preferredModel);
|
|
87
|
+
await updateStreamMessage(`📌 **Prompt**: ${prompt}\n\n⏳ Waiting for OpenCode server... (Model: ${modelDisplay})`, [buttons]);
|
|
88
|
+
await serveManager.waitForReady(port, 30000, effectivePath, preferredModel);
|
|
89
|
+
const settings = dataStore.getQueueSettings(threadId);
|
|
90
|
+
// If fresh context is enabled, we always clear the session before starting
|
|
91
|
+
if (settings.freshContext) {
|
|
92
|
+
sessionManager.clearSessionForThread(threadId);
|
|
93
|
+
}
|
|
94
|
+
const existingSession = sessionManager.getSessionForThread(threadId);
|
|
95
|
+
if (existingSession && existingSession.projectPath === effectivePath) {
|
|
96
|
+
const isValid = await sessionManager.validateSession(port, existingSession.sessionId);
|
|
97
|
+
if (isValid) {
|
|
98
|
+
sessionId = existingSession.sessionId;
|
|
99
|
+
sessionManager.updateSessionLastUsed(threadId);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
sessionId = await sessionManager.createSession(port);
|
|
103
|
+
sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
sessionId = await sessionManager.createSession(port);
|
|
108
|
+
sessionManager.setSessionForThread(threadId, sessionId, effectivePath, port);
|
|
109
|
+
}
|
|
110
|
+
const sseClient = new SSEClient();
|
|
111
|
+
sseClient.connect(`http://127.0.0.1:${port}`);
|
|
112
|
+
sessionManager.setSseClient(threadId, sseClient);
|
|
113
|
+
sseClient.onPartUpdated((part) => {
|
|
114
|
+
accumulatedText = part.text;
|
|
115
|
+
});
|
|
116
|
+
sseClient.onSessionIdle(() => {
|
|
117
|
+
if (updateInterval) {
|
|
118
|
+
clearInterval(updateInterval);
|
|
119
|
+
updateInterval = null;
|
|
120
|
+
}
|
|
121
|
+
(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const formatted = formatOutput(accumulatedText);
|
|
124
|
+
const disabledButtons = new ActionRowBuilder()
|
|
125
|
+
.addComponents(new ButtonBuilder()
|
|
126
|
+
.setCustomId(`interrupt_${threadId}`)
|
|
127
|
+
.setLabel('⏸️ Interrupt')
|
|
128
|
+
.setStyle(ButtonStyle.Secondary)
|
|
129
|
+
.setDisabled(true));
|
|
130
|
+
await updateStreamMessage(`📌 **Prompt**: ${prompt}\n\n\`\`\`\n${formatted}\n\`\`\``, [disabledButtons]);
|
|
131
|
+
await channel.send({ content: '✅ Done' });
|
|
132
|
+
sseClient.disconnect();
|
|
133
|
+
sessionManager.clearSseClient(threadId);
|
|
134
|
+
// Trigger next in queue
|
|
135
|
+
await processNextInQueue(channel, threadId, parentChannelId);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error('Error in onSessionIdle:', error);
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
});
|
|
142
|
+
sseClient.onError((error) => {
|
|
143
|
+
if (updateInterval) {
|
|
144
|
+
clearInterval(updateInterval);
|
|
145
|
+
updateInterval = null;
|
|
146
|
+
}
|
|
147
|
+
(async () => {
|
|
148
|
+
try {
|
|
149
|
+
await updateStreamMessage(`📌 **Prompt**: ${prompt}\n\n❌ Connection error: ${error.message}`, []);
|
|
150
|
+
sseClient.disconnect();
|
|
151
|
+
sessionManager.clearSseClient(threadId);
|
|
152
|
+
const settings = dataStore.getQueueSettings(threadId);
|
|
153
|
+
if (settings.continueOnFailure) {
|
|
154
|
+
await processNextInQueue(channel, threadId, parentChannelId);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
dataStore.clearQueue(threadId);
|
|
158
|
+
await channel.send('❌ Execution failed. Queue cleared. Use `/queue settings` to change this behavior.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
});
|
|
165
|
+
updateInterval = setInterval(async () => {
|
|
166
|
+
tick++;
|
|
167
|
+
try {
|
|
168
|
+
const formatted = formatOutput(accumulatedText);
|
|
169
|
+
const spinnerChar = spinner[tick % spinner.length];
|
|
170
|
+
const newContent = formatted || 'Processing...';
|
|
171
|
+
if (newContent !== lastContent || tick % 2 === 0) {
|
|
172
|
+
lastContent = newContent;
|
|
173
|
+
await updateStreamMessage(`📌 **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n\`\`\`\n${newContent}\n\`\`\``, [buttons]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
}
|
|
178
|
+
}, 1000);
|
|
179
|
+
await updateStreamMessage(`📌 **Prompt**: ${prompt}\n\n📝 Sending prompt...`, [buttons]);
|
|
180
|
+
await sessionManager.sendPrompt(port, sessionId, prompt, preferredModel);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (updateInterval) {
|
|
184
|
+
clearInterval(updateInterval);
|
|
185
|
+
}
|
|
186
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
187
|
+
await updateStreamMessage(`📌 **Prompt**: ${prompt}\n\n❌ OpenCode execution failed: ${errorMessage}`, []);
|
|
188
|
+
const client = sessionManager.getSseClient(threadId);
|
|
189
|
+
if (client) {
|
|
190
|
+
client.disconnect();
|
|
191
|
+
sessionManager.clearSseClient(threadId);
|
|
192
|
+
}
|
|
193
|
+
const settings = dataStore.getQueueSettings(threadId);
|
|
194
|
+
if (settings.continueOnFailure) {
|
|
195
|
+
await processNextInQueue(channel, threadId, parentChannelId);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
dataStore.clearQueue(threadId);
|
|
199
|
+
await channel.send('❌ Execution failed. Queue cleared.');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { Server } from 'node:net';
|
|
3
|
-
|
|
4
|
-
const
|
|
3
|
+
import { getPortConfig } from './configStore.js';
|
|
4
|
+
const DEFAULT_PORT_MIN = 14097;
|
|
5
|
+
const DEFAULT_PORT_MAX = 14200;
|
|
5
6
|
const instances = new Map();
|
|
6
7
|
function isPortAvailable(port) {
|
|
7
8
|
return new Promise((resolve) => {
|
|
@@ -14,21 +15,45 @@ function isPortAvailable(port) {
|
|
|
14
15
|
resolve(true);
|
|
15
16
|
});
|
|
16
17
|
});
|
|
17
|
-
|
|
18
|
+
// Bind to 127.0.0.1 explicitly to match opencode serve's default binding
|
|
19
|
+
server.listen(port, '127.0.0.1');
|
|
18
20
|
});
|
|
19
21
|
}
|
|
22
|
+
async function isOrphanedServerRunning(port) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(`http://127.0.0.1:${port}/session`, {
|
|
25
|
+
signal: AbortSignal.timeout(1000),
|
|
26
|
+
});
|
|
27
|
+
// If we get any response, there's already a server running
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
20
34
|
async function findAvailablePort() {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
const config = getPortConfig();
|
|
36
|
+
const min = config?.min ?? DEFAULT_PORT_MIN;
|
|
37
|
+
const max = config?.max ?? DEFAULT_PORT_MAX;
|
|
38
|
+
for (let port = min; port <= max; port++) {
|
|
39
|
+
const usedPorts = new Set(Array.from(instances.values()).filter(i => !i.exited).map(i => i.port));
|
|
40
|
+
if (usedPorts.has(port)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Check if there's an orphaned opencode server on this port
|
|
44
|
+
if (await isOrphanedServerRunning(port)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Check if we can bind to this port
|
|
48
|
+
if (await isPortAvailable(port)) {
|
|
24
49
|
return port;
|
|
25
50
|
}
|
|
26
51
|
}
|
|
27
|
-
throw new Error(`No available ports in range ${
|
|
52
|
+
throw new Error(`No available ports in range ${min}-${max}`);
|
|
28
53
|
}
|
|
29
54
|
async function isServerResponding(port) {
|
|
30
55
|
try {
|
|
31
|
-
const response = await fetch(`http://
|
|
56
|
+
const response = await fetch(`http://127.0.0.1:${port}/session`, {
|
|
32
57
|
signal: AbortSignal.timeout(2000),
|
|
33
58
|
});
|
|
34
59
|
return response.ok;
|
|
@@ -37,58 +62,110 @@ async function isServerResponding(port) {
|
|
|
37
62
|
return false;
|
|
38
63
|
}
|
|
39
64
|
}
|
|
40
|
-
function cleanupInstance(
|
|
41
|
-
instances.delete(
|
|
65
|
+
function cleanupInstance(key) {
|
|
66
|
+
instances.delete(key);
|
|
42
67
|
}
|
|
43
|
-
export async function spawnServe(projectPath) {
|
|
44
|
-
const
|
|
45
|
-
|
|
68
|
+
export async function spawnServe(projectPath, model) {
|
|
69
|
+
const key = model ? `${projectPath}:${model}` : projectPath;
|
|
70
|
+
const existing = instances.get(key);
|
|
71
|
+
if (existing && !existing.exited) {
|
|
46
72
|
return existing.port;
|
|
47
73
|
}
|
|
74
|
+
// Clean up any exited instance before spawning a new one
|
|
75
|
+
if (existing?.exited) {
|
|
76
|
+
cleanupInstance(key);
|
|
77
|
+
}
|
|
48
78
|
const port = await findAvailablePort();
|
|
49
|
-
|
|
79
|
+
// Note: opencode serve doesn't support --model flag
|
|
80
|
+
// Model selection must happen at session/prompt level, not server startup
|
|
81
|
+
const args = ['serve', '--port', port.toString()];
|
|
82
|
+
console.log(`[opencode] Spawning: opencode ${args.join(' ')}`);
|
|
83
|
+
console.log(`[opencode] Working directory: ${projectPath}`);
|
|
84
|
+
const child = spawn('opencode', args, {
|
|
50
85
|
cwd: projectPath,
|
|
51
86
|
env: { ...process.env },
|
|
52
87
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
88
|
+
shell: true,
|
|
53
89
|
});
|
|
54
|
-
child.stdout?.on('data', () => { });
|
|
55
|
-
child.stderr?.on('data', () => { });
|
|
56
90
|
const instance = {
|
|
57
91
|
port,
|
|
58
92
|
process: child,
|
|
59
93
|
startTime: Date.now(),
|
|
94
|
+
exited: false,
|
|
60
95
|
};
|
|
61
|
-
instances.set(
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
instances.set(key, instance);
|
|
97
|
+
let stderrBuffer = '';
|
|
98
|
+
let stdoutBuffer = '';
|
|
99
|
+
child.stdout?.on('data', (data) => {
|
|
100
|
+
const text = data.toString();
|
|
101
|
+
stdoutBuffer += text;
|
|
102
|
+
if (stdoutBuffer.length > 2000) {
|
|
103
|
+
stdoutBuffer = stdoutBuffer.slice(-2000);
|
|
104
|
+
}
|
|
105
|
+
console.log(`[opencode stdout] ${text.trim()}`);
|
|
106
|
+
});
|
|
107
|
+
child.stderr?.on('data', (data) => {
|
|
108
|
+
const text = data.toString();
|
|
109
|
+
stderrBuffer += text;
|
|
110
|
+
if (stderrBuffer.length > 2000) {
|
|
111
|
+
stderrBuffer = stderrBuffer.slice(-2000);
|
|
112
|
+
}
|
|
113
|
+
console.error(`[opencode stderr] ${text.trim()}`);
|
|
114
|
+
});
|
|
115
|
+
child.on('exit', (code) => {
|
|
116
|
+
const inst = instances.get(key);
|
|
64
117
|
if (inst) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
118
|
+
inst.exited = true;
|
|
119
|
+
inst.exitCode = code;
|
|
120
|
+
if (code !== 0 && code !== null) {
|
|
121
|
+
// Combine stdout and stderr for error message
|
|
122
|
+
const combinedOutput = (stderrBuffer.trim() || stdoutBuffer.trim());
|
|
123
|
+
inst.exitError = combinedOutput || `Process exited with code ${code}`;
|
|
124
|
+
console.error(`[opencode] Process exited with code ${code}`);
|
|
125
|
+
if (combinedOutput) {
|
|
126
|
+
console.error(`[opencode] Output: ${combinedOutput}`);
|
|
127
|
+
}
|
|
68
128
|
}
|
|
69
129
|
}
|
|
70
130
|
});
|
|
71
|
-
child.on('error', () => {
|
|
72
|
-
|
|
131
|
+
child.on('error', (error) => {
|
|
132
|
+
console.error(`[opencode] Spawn error: ${error.message}`);
|
|
133
|
+
const inst = instances.get(key);
|
|
134
|
+
if (inst) {
|
|
135
|
+
inst.exited = true;
|
|
136
|
+
inst.exitError = error.message || 'Failed to spawn opencode process';
|
|
137
|
+
}
|
|
73
138
|
});
|
|
74
139
|
return port;
|
|
75
140
|
}
|
|
76
|
-
export function getPort(projectPath) {
|
|
77
|
-
|
|
141
|
+
export function getPort(projectPath, model) {
|
|
142
|
+
const key = model ? `${projectPath}:${model}` : projectPath;
|
|
143
|
+
return instances.get(key)?.port;
|
|
78
144
|
}
|
|
79
|
-
export function stopServe(projectPath) {
|
|
80
|
-
const
|
|
145
|
+
export function stopServe(projectPath, model) {
|
|
146
|
+
const key = model ? `${projectPath}:${model}` : projectPath;
|
|
147
|
+
const instance = instances.get(key);
|
|
81
148
|
if (!instance) {
|
|
82
149
|
return false;
|
|
83
150
|
}
|
|
84
151
|
instance.process.kill();
|
|
85
|
-
cleanupInstance(
|
|
152
|
+
cleanupInstance(key);
|
|
86
153
|
return true;
|
|
87
154
|
}
|
|
88
|
-
export async function waitForReady(port, timeout =
|
|
155
|
+
export async function waitForReady(port, timeout = 30000, projectPath, model) {
|
|
89
156
|
const start = Date.now();
|
|
90
|
-
const url = `http://
|
|
157
|
+
const url = `http://127.0.0.1:${port}/session`;
|
|
158
|
+
const key = projectPath ? (model ? `${projectPath}:${model}` : projectPath) : null;
|
|
91
159
|
while (Date.now() - start < timeout) {
|
|
160
|
+
// Check if the process has exited early
|
|
161
|
+
if (key) {
|
|
162
|
+
const instance = instances.get(key);
|
|
163
|
+
if (instance?.exited) {
|
|
164
|
+
const errorMsg = instance.exitError || `opencode serve exited with code ${instance.exitCode}`;
|
|
165
|
+
cleanupInstance(key);
|
|
166
|
+
throw new Error(`opencode serve failed to start: ${errorMsg}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
92
169
|
try {
|
|
93
170
|
const response = await fetch(url);
|
|
94
171
|
if (response.ok) {
|
|
@@ -97,19 +174,39 @@ export async function waitForReady(port, timeout = 10000) {
|
|
|
97
174
|
}
|
|
98
175
|
catch {
|
|
99
176
|
}
|
|
100
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
178
|
+
}
|
|
179
|
+
// Final check - did the process exit?
|
|
180
|
+
if (key) {
|
|
181
|
+
const instance = instances.get(key);
|
|
182
|
+
if (instance?.exited) {
|
|
183
|
+
const errorMsg = instance.exitError || `opencode serve exited with code ${instance.exitCode}`;
|
|
184
|
+
cleanupInstance(key);
|
|
185
|
+
throw new Error(`opencode serve failed to start: ${errorMsg}`);
|
|
186
|
+
}
|
|
101
187
|
}
|
|
102
|
-
throw new Error(`Service at port ${port} failed to become ready within ${timeout}ms
|
|
188
|
+
throw new Error(`Service at port ${port} failed to become ready within ${timeout}ms. Check if 'opencode serve' is working correctly.`);
|
|
103
189
|
}
|
|
104
190
|
export function stopAll() {
|
|
105
|
-
for (const [
|
|
191
|
+
for (const [key, instance] of instances) {
|
|
106
192
|
instance.process.kill();
|
|
107
|
-
cleanupInstance(
|
|
193
|
+
cleanupInstance(key);
|
|
108
194
|
}
|
|
109
195
|
}
|
|
110
196
|
export function getAllInstances() {
|
|
111
|
-
return Array.from(instances.entries()).map(([
|
|
112
|
-
|
|
197
|
+
return Array.from(instances.entries()).map(([key, instance]) => ({
|
|
198
|
+
key,
|
|
113
199
|
port: instance.port,
|
|
114
200
|
}));
|
|
115
201
|
}
|
|
202
|
+
export function getInstanceState(projectPath, model) {
|
|
203
|
+
const key = model ? `${projectPath}:${model}` : projectPath;
|
|
204
|
+
const instance = instances.get(key);
|
|
205
|
+
if (!instance)
|
|
206
|
+
return undefined;
|
|
207
|
+
return {
|
|
208
|
+
exited: instance.exited ?? false,
|
|
209
|
+
exitCode: instance.exitCode,
|
|
210
|
+
exitError: instance.exitError,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as dataStore from './dataStore.js';
|
|
2
2
|
const threadSseClients = new Map();
|
|
3
3
|
export async function createSession(port) {
|
|
4
|
-
const url = `http://
|
|
4
|
+
const url = `http://127.0.0.1:${port}/session`;
|
|
5
5
|
const response = await fetch(url, {
|
|
6
6
|
method: 'POST',
|
|
7
7
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -16,14 +16,31 @@ export async function createSession(port) {
|
|
|
16
16
|
}
|
|
17
17
|
return data.id;
|
|
18
18
|
}
|
|
19
|
-
|
|
20
|
-
const
|
|
19
|
+
function parseModelString(model) {
|
|
20
|
+
const slashIndex = model.indexOf('/');
|
|
21
|
+
if (slashIndex === -1) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
providerID: model.slice(0, slashIndex),
|
|
26
|
+
modelID: model.slice(slashIndex + 1),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function sendPrompt(port, sessionId, text, model) {
|
|
30
|
+
const url = `http://127.0.0.1:${port}/session/${sessionId}/prompt_async`;
|
|
31
|
+
const body = {
|
|
32
|
+
parts: [{ type: 'text', text }],
|
|
33
|
+
};
|
|
34
|
+
if (model) {
|
|
35
|
+
const parsedModel = parseModelString(model);
|
|
36
|
+
if (parsedModel) {
|
|
37
|
+
body.model = parsedModel;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
21
40
|
const response = await fetch(url, {
|
|
22
41
|
method: 'POST',
|
|
23
42
|
headers: { 'Content-Type': 'application/json' },
|
|
24
|
-
body: JSON.stringify(
|
|
25
|
-
parts: [{ type: 'text', text }],
|
|
26
|
-
}),
|
|
43
|
+
body: JSON.stringify(body),
|
|
27
44
|
});
|
|
28
45
|
if (!response.ok) {
|
|
29
46
|
throw new Error(`Failed to send prompt: ${response.status} ${response.statusText}`);
|
|
@@ -31,7 +48,7 @@ export async function sendPrompt(port, sessionId, text) {
|
|
|
31
48
|
}
|
|
32
49
|
export async function validateSession(port, sessionId) {
|
|
33
50
|
try {
|
|
34
|
-
const url = `http://
|
|
51
|
+
const url = `http://127.0.0.1:${port}/session/${sessionId}`;
|
|
35
52
|
const response = await fetch(url, {
|
|
36
53
|
method: 'GET',
|
|
37
54
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -44,7 +61,7 @@ export async function validateSession(port, sessionId) {
|
|
|
44
61
|
}
|
|
45
62
|
export async function listSessions(port) {
|
|
46
63
|
try {
|
|
47
|
-
const url = `http://
|
|
64
|
+
const url = `http://127.0.0.1:${port}/session`;
|
|
48
65
|
const response = await fetch(url, {
|
|
49
66
|
method: 'GET',
|
|
50
67
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -64,7 +81,7 @@ export async function listSessions(port) {
|
|
|
64
81
|
}
|
|
65
82
|
export async function abortSession(port, sessionId) {
|
|
66
83
|
try {
|
|
67
|
-
const url = `http://
|
|
84
|
+
const url = `http://127.0.0.1:${port}/session/${sessionId}/abort`;
|
|
68
85
|
const response = await fetch(url, {
|
|
69
86
|
method: 'POST',
|
|
70
87
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-opencode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "Discord bot for remote OpenCode CLI access",
|
|
5
5
|
"main": "dist/src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc",
|
|
18
|
-
"start": "node dist/src/cli.js",
|
|
18
|
+
"start": "node --no-deprecation dist/src/cli.js start",
|
|
19
19
|
"dev": "node --loader ts-node/esm src/cli.ts",
|
|
20
20
|
"deploy-commands": "npm run build && node dist/src/cli.js deploy",
|
|
21
21
|
"test": "vitest",
|