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.
- package/README.md +76 -34
- package/dist/src/__tests__/messageFormatter.test.js +15 -1
- 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 +204 -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/dist/src/services/worktreeManager.js +9 -0
- package/dist/src/utils/messageFormatter.js +3 -0
- package/package.json +3 -2
|
@@ -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
|
});
|
|
@@ -67,6 +67,15 @@ export async function removeWorktree(worktreePath, deleteBranch) {
|
|
|
67
67
|
throw new Error(`Failed to remove worktree: ${error.message}`);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
export async function getCurrentBranch(cwd) {
|
|
71
|
+
try {
|
|
72
|
+
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd });
|
|
73
|
+
return stdout.trim() || null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
70
79
|
export function worktreeExists(worktreePath) {
|
|
71
80
|
return existsSync(worktreePath);
|
|
72
81
|
}
|
|
@@ -54,6 +54,9 @@ export function parseOpenCodeOutput(buffer) {
|
|
|
54
54
|
}
|
|
55
55
|
return result;
|
|
56
56
|
}
|
|
57
|
+
export function buildContextHeader(branchName, modelName) {
|
|
58
|
+
return `๐ฟ \`${branchName}\` ยท ๐ค \`${modelName}\``;
|
|
59
|
+
}
|
|
57
60
|
export function formatOutput(buffer, maxLength = 1900) {
|
|
58
61
|
const parsed = parseOpenCodeOutput(buffer);
|
|
59
62
|
if (!parsed.trim()) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-opencode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"eventsource": "^4.1.0",
|
|
43
43
|
"node-pty": "^1.1.0",
|
|
44
44
|
"open": "^10.1.0",
|
|
45
|
+
"opencode-antigravity-auth": "^1.4.6",
|
|
45
46
|
"picocolors": "^1.1.1",
|
|
46
47
|
"update-notifier": "^7.3.1"
|
|
47
48
|
},
|