@opencomputer/cli 0.3.3 → 0.3.5
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 +26 -3
- package/dist/commands.js +4 -4
- package/dist/commands.js.map +1 -1
- package/dist/dev-ui.d.ts +1 -0
- package/dist/dev-ui.js +220 -0
- package/dist/dev-ui.js.map +1 -0
- package/dist/dev-ui.test.d.ts +1 -0
- package/dist/dev-ui.test.js +12 -0
- package/dist/dev-ui.test.js.map +1 -0
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/local.d.ts +1 -1
- package/dist/local.js +604 -67
- package/dist/local.js.map +1 -1
- package/package.json +5 -1
package/dist/local.js
CHANGED
|
@@ -1,130 +1,667 @@
|
|
|
1
|
+
import { createOpencode, } from "@opencode-ai/sdk/v2";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import { createServer } from "node:http";
|
|
5
|
-
import { resolve } from "node:path";
|
|
6
|
-
import {
|
|
4
|
+
import { mkdir, readFile, rm, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { createServer, } from "node:http";
|
|
6
|
+
import { delimiter, dirname, resolve } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { renderDevUI } from "./dev-ui.js";
|
|
10
|
+
import { findAgentRoot, prepareAgent, readManifest } from "./project.js";
|
|
7
11
|
function sameToken(left, right) {
|
|
8
12
|
const leftBytes = Buffer.from(left);
|
|
9
13
|
const rightBytes = Buffer.from(right);
|
|
10
14
|
return (leftBytes.length === rightBytes.length &&
|
|
11
15
|
timingSafeEqual(leftBytes, rightBytes));
|
|
12
16
|
}
|
|
13
|
-
async function readBody(request) {
|
|
17
|
+
async function readBody(request, limit = 2 * 1024 * 1024) {
|
|
14
18
|
const chunks = [];
|
|
15
19
|
let size = 0;
|
|
16
20
|
for await (const chunk of request) {
|
|
17
21
|
const buffer = Buffer.from(chunk);
|
|
18
22
|
size += buffer.byteLength;
|
|
19
|
-
if (size >
|
|
20
|
-
throw new Error("
|
|
21
|
-
}
|
|
23
|
+
if (size > limit)
|
|
24
|
+
throw new Error("Request is too large");
|
|
22
25
|
chunks.push(buffer);
|
|
23
26
|
}
|
|
24
27
|
return Buffer.concat(chunks);
|
|
25
28
|
}
|
|
26
|
-
|
|
29
|
+
function authorized(request, token) {
|
|
30
|
+
const header = request.headers.authorization;
|
|
31
|
+
return (typeof header === "string" &&
|
|
32
|
+
header.startsWith("Bearer ") &&
|
|
33
|
+
sameToken(header.slice(7), token));
|
|
34
|
+
}
|
|
35
|
+
function sendJSON(response, status, body) {
|
|
36
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
37
|
+
response.end(JSON.stringify(body));
|
|
38
|
+
}
|
|
39
|
+
function openBrowser(url) {
|
|
40
|
+
if (process.env.OPENCOMPUTER_NO_OPEN === "1")
|
|
41
|
+
return;
|
|
42
|
+
const command = process.platform === "darwin"
|
|
43
|
+
? { file: "open", args: [url] }
|
|
44
|
+
: process.platform === "win32"
|
|
45
|
+
? { file: "cmd", args: ["/c", "start", "", url] }
|
|
46
|
+
: { file: "xdg-open", args: [url] };
|
|
47
|
+
const child = spawn(command.file, command.args, {
|
|
48
|
+
detached: true,
|
|
49
|
+
stdio: "ignore",
|
|
50
|
+
});
|
|
51
|
+
child.on("error", () => undefined);
|
|
52
|
+
child.unref();
|
|
53
|
+
}
|
|
54
|
+
async function startGateway(config) {
|
|
27
55
|
if (!config.apiKey) {
|
|
28
|
-
throw new Error("Not logged in. Run `opencomputer login` before
|
|
56
|
+
throw new Error("Not logged in. Run `opencomputer login` before starting dev mode.");
|
|
29
57
|
}
|
|
30
58
|
const token = randomBytes(32).toString("base64url");
|
|
31
59
|
const server = createServer((request, response) => {
|
|
32
60
|
void (async () => {
|
|
33
|
-
|
|
34
|
-
|
|
61
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
62
|
+
const header = request.headers.authorization;
|
|
63
|
+
if (!header?.startsWith("Bearer ") ||
|
|
64
|
+
!sameToken(header.slice(7), token)) {
|
|
65
|
+
response.writeHead(401).end();
|
|
35
66
|
return;
|
|
36
67
|
}
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
68
|
+
let target;
|
|
69
|
+
if (request.method === "POST" && url.pathname === "/google/fetch") {
|
|
70
|
+
target = `${config.apiUrl}/api/managed-agents/connections/google/fetch`;
|
|
71
|
+
}
|
|
72
|
+
else if (url.pathname.startsWith("/openrouter/")) {
|
|
73
|
+
target =
|
|
74
|
+
`${config.apiUrl}/api/managed-agents/openrouter` +
|
|
75
|
+
`${url.pathname.slice("/openrouter".length)}${url.search}`;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
response.writeHead(404).end();
|
|
41
79
|
return;
|
|
42
80
|
}
|
|
43
|
-
const upstream = await fetch(
|
|
44
|
-
method:
|
|
81
|
+
const upstream = await fetch(target, {
|
|
82
|
+
method: request.method,
|
|
45
83
|
headers: {
|
|
46
|
-
"content-type": "application/json",
|
|
84
|
+
"content-type": request.headers["content-type"] ?? "application/json",
|
|
47
85
|
"x-api-key": config.apiKey,
|
|
48
86
|
},
|
|
49
|
-
body:
|
|
50
|
-
|
|
87
|
+
body: request.method === "GET" || request.method === "HEAD"
|
|
88
|
+
? undefined
|
|
89
|
+
: await readBody(request),
|
|
90
|
+
signal: AbortSignal.timeout(60_000),
|
|
51
91
|
});
|
|
52
92
|
response.writeHead(upstream.status, {
|
|
53
93
|
"content-type": upstream.headers.get("content-type") ?? "application/json",
|
|
54
94
|
});
|
|
55
95
|
response.end(Buffer.from(await upstream.arrayBuffer()));
|
|
56
96
|
})().catch((error) => {
|
|
57
|
-
response
|
|
58
|
-
response.end(JSON.stringify({
|
|
97
|
+
sendJSON(response, 502, {
|
|
59
98
|
error: {
|
|
60
|
-
message: error instanceof Error
|
|
61
|
-
? error.message
|
|
62
|
-
: "Connection proxy failed",
|
|
99
|
+
message: error instanceof Error ? error.message : "Gateway request failed",
|
|
63
100
|
},
|
|
64
|
-
})
|
|
101
|
+
});
|
|
65
102
|
});
|
|
66
103
|
});
|
|
67
|
-
await new Promise((
|
|
104
|
+
await new Promise((done, reject) => {
|
|
68
105
|
server.once("error", reject);
|
|
69
|
-
server.listen(0, "127.0.0.1",
|
|
106
|
+
server.listen(0, "127.0.0.1", done);
|
|
70
107
|
});
|
|
71
108
|
const address = server.address();
|
|
72
109
|
if (!address || typeof address === "string") {
|
|
73
110
|
server.close();
|
|
74
|
-
throw new Error("Could not start the local
|
|
111
|
+
throw new Error("Could not start the local OpenComputer gateway");
|
|
75
112
|
}
|
|
76
113
|
return {
|
|
77
114
|
url: `http://127.0.0.1:${String(address.port)}`,
|
|
78
115
|
token,
|
|
79
|
-
close: () =>
|
|
116
|
+
close: () => {
|
|
117
|
+
server.closeAllConnections();
|
|
118
|
+
return new Promise((done) => server.close(() => done()));
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function addBundledRuntimeToPath() {
|
|
123
|
+
const packagePath = fileURLToPath(import.meta.resolve("opencode-ai/package.json"));
|
|
124
|
+
const bin = resolve(dirname(packagePath), "..", ".bin");
|
|
125
|
+
const current = process.env.PATH ?? "";
|
|
126
|
+
if (!current.split(delimiter).includes(bin)) {
|
|
127
|
+
process.env.PATH = current ? `${bin}${delimiter}${current}` : bin;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function modelParts() {
|
|
131
|
+
const full = process.env.OPENCOMPUTER_MODEL ??
|
|
132
|
+
"openrouter/anthropic/claude-sonnet-4.6";
|
|
133
|
+
const separator = full.indexOf("/");
|
|
134
|
+
return {
|
|
135
|
+
providerID: separator === -1 ? "openrouter" : full.slice(0, separator),
|
|
136
|
+
modelID: separator === -1 ? full : full.slice(separator + 1),
|
|
137
|
+
full,
|
|
80
138
|
};
|
|
81
139
|
}
|
|
82
|
-
|
|
140
|
+
async function streamTurn(client, directory, sessionID, prompt, emit) {
|
|
141
|
+
const subscription = await client.event.subscribe({ directory });
|
|
142
|
+
const assistantMessages = new Set();
|
|
143
|
+
const textByPart = new Map();
|
|
144
|
+
const messageByPart = new Map();
|
|
145
|
+
const emittedLengths = new Map();
|
|
146
|
+
const tools = new Map();
|
|
147
|
+
const toolStates = new Map();
|
|
148
|
+
const completedText = [];
|
|
149
|
+
const emitText = (partID) => {
|
|
150
|
+
const messageID = messageByPart.get(partID);
|
|
151
|
+
if (!messageID || !assistantMessages.has(messageID))
|
|
152
|
+
return;
|
|
153
|
+
const text = textByPart.get(partID) ?? "";
|
|
154
|
+
const offset = emittedLengths.get(partID) ?? 0;
|
|
155
|
+
if (text.length > offset) {
|
|
156
|
+
emit({ type: "message.delta", data: { text: text.slice(offset) } });
|
|
157
|
+
emittedLengths.set(partID, text.length);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const emitTool = (part) => {
|
|
161
|
+
if (!assistantMessages.has(part.messageID))
|
|
162
|
+
return;
|
|
163
|
+
const previous = toolStates.get(part.callID);
|
|
164
|
+
const current = part.state.status;
|
|
165
|
+
if (previous === current)
|
|
166
|
+
return;
|
|
167
|
+
toolStates.set(part.callID, current);
|
|
168
|
+
if (current === "running") {
|
|
169
|
+
emit({
|
|
170
|
+
type: "tool.started",
|
|
171
|
+
data: { tool: part.tool, input: part.state.input },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
else if (current === "completed") {
|
|
175
|
+
emit({ type: "tool.completed", data: { tool: part.tool } });
|
|
176
|
+
}
|
|
177
|
+
else if (current === "error") {
|
|
178
|
+
emit({
|
|
179
|
+
type: "tool.failed",
|
|
180
|
+
data: { tool: part.tool, message: part.state.error },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
const model = modelParts();
|
|
186
|
+
const started = await client.session.promptAsync({
|
|
187
|
+
sessionID,
|
|
188
|
+
directory,
|
|
189
|
+
model: { providerID: model.providerID, modelID: model.modelID },
|
|
190
|
+
parts: [{ type: "text", text: prompt }],
|
|
191
|
+
});
|
|
192
|
+
if (started.error)
|
|
193
|
+
throw new Error(JSON.stringify(started.error));
|
|
194
|
+
for await (const event of subscription.stream) {
|
|
195
|
+
if (event.type === "message.updated") {
|
|
196
|
+
const info = event.properties.info;
|
|
197
|
+
if (info.sessionID === sessionID && info.role === "assistant") {
|
|
198
|
+
assistantMessages.add(info.id);
|
|
199
|
+
for (const [partID, messageID] of messageByPart) {
|
|
200
|
+
if (messageID === info.id)
|
|
201
|
+
emitText(partID);
|
|
202
|
+
}
|
|
203
|
+
for (const part of tools.values()) {
|
|
204
|
+
if (part.messageID === info.id)
|
|
205
|
+
emitTool(part);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else if (event.type === "message.part.updated") {
|
|
210
|
+
const part = event.properties.part;
|
|
211
|
+
if (part.sessionID !== sessionID)
|
|
212
|
+
continue;
|
|
213
|
+
if (part.type === "text") {
|
|
214
|
+
messageByPart.set(part.id, part.messageID);
|
|
215
|
+
textByPart.set(part.id, part.text);
|
|
216
|
+
emitText(part.id);
|
|
217
|
+
}
|
|
218
|
+
else if (part.type === "tool") {
|
|
219
|
+
tools.set(part.callID, part);
|
|
220
|
+
emitTool(part);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if (event.type === "session.error" &&
|
|
224
|
+
event.properties.sessionID === sessionID) {
|
|
225
|
+
throw new Error(JSON.stringify(event.properties.error));
|
|
226
|
+
}
|
|
227
|
+
else if (event.type === "session.idle" &&
|
|
228
|
+
event.properties.sessionID === sessionID) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
await subscription.stream.return(undefined);
|
|
235
|
+
}
|
|
236
|
+
for (const [partID, text] of textByPart) {
|
|
237
|
+
const messageID = messageByPart.get(partID);
|
|
238
|
+
if (messageID && assistantMessages.has(messageID))
|
|
239
|
+
completedText.push(text);
|
|
240
|
+
}
|
|
241
|
+
const text = completedText.join("");
|
|
242
|
+
emit({ type: "message.completed", data: { text } });
|
|
243
|
+
return text;
|
|
244
|
+
}
|
|
245
|
+
async function createRuntimeSession(client, directory) {
|
|
246
|
+
const created = await client.session.create({ directory });
|
|
247
|
+
if (!created.data)
|
|
248
|
+
throw new Error("The local agent session did not start");
|
|
249
|
+
return created.data.id;
|
|
250
|
+
}
|
|
251
|
+
function statePath(root) {
|
|
252
|
+
return resolve(root, ".opencomputer", "dev.json");
|
|
253
|
+
}
|
|
254
|
+
async function readDevState(root) {
|
|
255
|
+
try {
|
|
256
|
+
const state = JSON.parse(await readFile(statePath(root), "utf8"));
|
|
257
|
+
if (state.version !== 1 || !state.url || !state.token)
|
|
258
|
+
return null;
|
|
259
|
+
const response = await fetch(`${state.url}/health`, {
|
|
260
|
+
headers: { authorization: `Bearer ${state.token}` },
|
|
261
|
+
signal: AbortSignal.timeout(1_000),
|
|
262
|
+
});
|
|
263
|
+
return response.ok ? state : null;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function startDevService(config) {
|
|
83
270
|
const root = await findAgentRoot();
|
|
84
271
|
if (!root) {
|
|
85
272
|
throw new Error("No OpenComputer agent repository found. Run `opencomputer init <template>` first.");
|
|
86
273
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
process.env.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX ??
|
|
108
|
-
"16384",
|
|
274
|
+
const existing = await readDevState(root);
|
|
275
|
+
if (existing) {
|
|
276
|
+
throw new Error(`OpenComputer dev is already running at ${existing.url}`);
|
|
277
|
+
}
|
|
278
|
+
const directory = await prepareAgent(root);
|
|
279
|
+
const manifest = await readManifest(root);
|
|
280
|
+
const gateway = await startGateway(config);
|
|
281
|
+
addBundledRuntimeToPath();
|
|
282
|
+
const abortController = new AbortController();
|
|
283
|
+
const model = modelParts();
|
|
284
|
+
const instance = await createOpencode({
|
|
285
|
+
signal: abortController.signal,
|
|
286
|
+
port: 0,
|
|
287
|
+
timeout: 45_000,
|
|
288
|
+
config: {
|
|
289
|
+
model: model.full,
|
|
290
|
+
enabled_providers: ["openrouter"],
|
|
291
|
+
provider: {
|
|
292
|
+
openrouter: {
|
|
293
|
+
options: { baseURL: `${gateway.url}/openrouter/api/v1` },
|
|
109
294
|
},
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
295
|
+
},
|
|
296
|
+
autoupdate: false,
|
|
297
|
+
share: "disabled",
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const authenticated = await instance.client.auth.set({
|
|
301
|
+
providerID: "openrouter",
|
|
302
|
+
auth: { type: "api", key: gateway.token },
|
|
303
|
+
});
|
|
304
|
+
if (authenticated.error || authenticated.data !== true) {
|
|
305
|
+
throw new Error("The embedded agent runtime rejected its local credential");
|
|
306
|
+
}
|
|
307
|
+
const token = randomBytes(32).toString("base64url");
|
|
308
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
309
|
+
const sessions = new Map();
|
|
310
|
+
const running = new Set();
|
|
311
|
+
const server = createServer((request, response) => {
|
|
312
|
+
void (async () => {
|
|
313
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
314
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
315
|
+
response.writeHead(200, {
|
|
316
|
+
"content-type": "text/html; charset=utf-8",
|
|
317
|
+
"cache-control": "no-store",
|
|
318
|
+
"content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; ` +
|
|
319
|
+
"style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'",
|
|
320
|
+
});
|
|
321
|
+
response.end(renderDevUI(manifest.name, nonce));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!authorized(request, token)) {
|
|
325
|
+
sendJSON(response, 401, { message: "Unauthorized" });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
329
|
+
sendJSON(response, 200, {
|
|
330
|
+
ok: true,
|
|
331
|
+
agentId: manifest.id,
|
|
332
|
+
sessions: sessions.size,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const streamSession = async (session, prompt, created = false) => {
|
|
337
|
+
if (running.has(session.id)) {
|
|
338
|
+
sendJSON(response, 409, { message: "This session is already running" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
running.add(session.id);
|
|
342
|
+
session.messages.push({ role: "user", text: prompt });
|
|
343
|
+
if (!session.title)
|
|
344
|
+
session.title = prompt.slice(0, 60);
|
|
345
|
+
session.updatedAt = new Date().toISOString();
|
|
346
|
+
response.writeHead(200, {
|
|
347
|
+
"content-type": "application/x-ndjson",
|
|
348
|
+
"cache-control": "no-store",
|
|
349
|
+
});
|
|
350
|
+
const emit = (event) => {
|
|
351
|
+
response.write(`${JSON.stringify(event)}\n`);
|
|
352
|
+
};
|
|
353
|
+
if (created) {
|
|
354
|
+
emit({ type: "session.created", data: { sessionId: session.id } });
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
const text = await streamTurn(instance.client, directory, session.id, prompt, emit);
|
|
358
|
+
session.messages.push({ role: "assistant", text });
|
|
359
|
+
session.updatedAt = new Date().toISOString();
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
emit({
|
|
363
|
+
type: "session.failed",
|
|
364
|
+
data: {
|
|
365
|
+
message: error instanceof Error ? error.message : String(error),
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
running.delete(session.id);
|
|
371
|
+
}
|
|
372
|
+
response.end();
|
|
373
|
+
};
|
|
374
|
+
if (request.method === "GET" && url.pathname === "/sessions") {
|
|
375
|
+
sendJSON(response, 200, {
|
|
376
|
+
sessions: [...sessions.values()]
|
|
377
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
378
|
+
.map(({ id, title, createdAt, updatedAt, messages }) => ({
|
|
379
|
+
id,
|
|
380
|
+
title,
|
|
381
|
+
createdAt,
|
|
382
|
+
updatedAt,
|
|
383
|
+
messageCount: messages.length,
|
|
384
|
+
})),
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (request.method === "POST" && url.pathname === "/sessions") {
|
|
389
|
+
const raw = (await readBody(request)).toString("utf8");
|
|
390
|
+
const body = (raw ? JSON.parse(raw) : {});
|
|
391
|
+
const id = await createRuntimeSession(instance.client, directory);
|
|
392
|
+
const now = new Date().toISOString();
|
|
393
|
+
const session = {
|
|
394
|
+
id,
|
|
395
|
+
title: "",
|
|
396
|
+
createdAt: now,
|
|
397
|
+
updatedAt: now,
|
|
398
|
+
messages: [],
|
|
399
|
+
};
|
|
400
|
+
sessions.set(id, session);
|
|
401
|
+
if (typeof body.prompt === "string" && body.prompt.trim()) {
|
|
402
|
+
response.setHeader("x-opencomputer-session-id", id);
|
|
403
|
+
await streamSession(session, body.prompt.trim(), true);
|
|
404
|
+
}
|
|
120
405
|
else {
|
|
121
|
-
|
|
406
|
+
sendJSON(response, 201, session);
|
|
122
407
|
}
|
|
123
|
-
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
|
|
411
|
+
if (sessionMatch?.[1]) {
|
|
412
|
+
const id = decodeURIComponent(sessionMatch[1]);
|
|
413
|
+
const session = sessions.get(id);
|
|
414
|
+
if (!session) {
|
|
415
|
+
sendJSON(response, 404, { message: "Session not found" });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (request.method === "GET") {
|
|
419
|
+
sendJSON(response, 200, session);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (request.method === "POST") {
|
|
423
|
+
const body = JSON.parse((await readBody(request)).toString("utf8"));
|
|
424
|
+
if (typeof body.prompt !== "string" || !body.prompt.trim()) {
|
|
425
|
+
sendJSON(response, 400, { message: "A prompt is required" });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
await streamSession(session, body.prompt.trim());
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (request.method === "GET" && url.pathname === "/api") {
|
|
433
|
+
sendJSON(response, 200, {
|
|
434
|
+
service: "OpenComputer local agent",
|
|
435
|
+
agentId: manifest.id,
|
|
436
|
+
endpoints: ["GET /sessions", "POST /sessions", "POST /sessions/:id"],
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
sendJSON(response, 404, { message: "Route not found" });
|
|
441
|
+
})().catch((error) => {
|
|
442
|
+
if (!response.headersSent) {
|
|
443
|
+
sendJSON(response, 500, {
|
|
444
|
+
message: error instanceof Error ? error.message : String(error),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
response.end();
|
|
449
|
+
}
|
|
124
450
|
});
|
|
451
|
+
});
|
|
452
|
+
await new Promise((done, reject) => {
|
|
453
|
+
server.once("error", reject);
|
|
454
|
+
server.listen(Number(process.env.OPENCOMPUTER_DEV_PORT ?? 0), "127.0.0.1", done);
|
|
455
|
+
});
|
|
456
|
+
const address = server.address();
|
|
457
|
+
if (!address || typeof address === "string") {
|
|
458
|
+
throw new Error("The OpenComputer dev service did not receive a port");
|
|
459
|
+
}
|
|
460
|
+
const state = {
|
|
461
|
+
version: 1,
|
|
462
|
+
pid: process.pid,
|
|
463
|
+
url: `http://127.0.0.1:${String(address.port)}`,
|
|
464
|
+
token,
|
|
465
|
+
agentRoot: root,
|
|
466
|
+
agentId: manifest.id,
|
|
467
|
+
startedAt: new Date().toISOString(),
|
|
468
|
+
};
|
|
469
|
+
await mkdir(dirname(statePath(root)), { recursive: true, mode: 0o700 });
|
|
470
|
+
await writeFile(statePath(root), `${JSON.stringify(state, null, 2)}\n`, {
|
|
471
|
+
mode: 0o600,
|
|
472
|
+
});
|
|
473
|
+
const webUrl = `${state.url}/#token=${encodeURIComponent(token)}`;
|
|
474
|
+
process.stdout.write(`OpenComputer dev service ready\n` +
|
|
475
|
+
`Agent: ${manifest.name} (${manifest.id})\n` +
|
|
476
|
+
`Web: ${webUrl}\n` +
|
|
477
|
+
`Local API: ${state.url}\n` +
|
|
478
|
+
`Session: opencomputer session\n`);
|
|
479
|
+
openBrowser(webUrl);
|
|
480
|
+
await new Promise((done) => {
|
|
481
|
+
process.once("SIGINT", done);
|
|
482
|
+
process.once("SIGTERM", done);
|
|
483
|
+
});
|
|
484
|
+
await rm(statePath(root), { force: true });
|
|
485
|
+
server.closeAllConnections();
|
|
486
|
+
await new Promise((done) => server.close(() => done()));
|
|
487
|
+
abortController.abort();
|
|
488
|
+
instance.server.close();
|
|
489
|
+
await gateway.close();
|
|
490
|
+
}
|
|
491
|
+
async function runLocalSession(prompt, state, sessionID) {
|
|
492
|
+
const endpoint = sessionID
|
|
493
|
+
? `${state.url}/sessions/${encodeURIComponent(sessionID)}`
|
|
494
|
+
: `${state.url}/sessions`;
|
|
495
|
+
const response = await fetch(endpoint, {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers: {
|
|
498
|
+
authorization: `Bearer ${state.token}`,
|
|
499
|
+
"content-type": "application/json",
|
|
500
|
+
},
|
|
501
|
+
body: JSON.stringify({ prompt }),
|
|
502
|
+
});
|
|
503
|
+
if (!response.ok || !response.body) {
|
|
504
|
+
const detail = await response.text().catch(() => "");
|
|
505
|
+
throw new Error(`The local agent service returned ${String(response.status)}` +
|
|
506
|
+
(detail ? `: ${detail}` : ""));
|
|
507
|
+
}
|
|
508
|
+
let resolvedSessionID = sessionID ?? response.headers.get("x-opencomputer-session-id") ?? undefined;
|
|
509
|
+
const decoder = new TextDecoder();
|
|
510
|
+
let buffered = "";
|
|
511
|
+
let streamedText = false;
|
|
512
|
+
for await (const chunk of response.body) {
|
|
513
|
+
buffered += decoder.decode(chunk, { stream: true });
|
|
514
|
+
const lines = buffered.split("\n");
|
|
515
|
+
buffered = lines.pop() ?? "";
|
|
516
|
+
for (const line of lines) {
|
|
517
|
+
if (!line.trim())
|
|
518
|
+
continue;
|
|
519
|
+
const event = JSON.parse(line);
|
|
520
|
+
if (event.type === "session.created") {
|
|
521
|
+
resolvedSessionID = String(event.data.sessionId);
|
|
522
|
+
process.stderr.write(`Session ${resolvedSessionID}\n`);
|
|
523
|
+
}
|
|
524
|
+
else if (event.type === "message.delta") {
|
|
525
|
+
streamedText = true;
|
|
526
|
+
process.stdout.write(String(event.data.text ?? ""));
|
|
527
|
+
}
|
|
528
|
+
else if (event.type === "message.completed") {
|
|
529
|
+
if (!streamedText)
|
|
530
|
+
process.stdout.write(String(event.data.text ?? ""));
|
|
531
|
+
process.stdout.write("\n");
|
|
532
|
+
}
|
|
533
|
+
else if (event.type === "tool.started") {
|
|
534
|
+
process.stderr.write(`⚙ ${String(event.data.tool ?? "tool")} ${JSON.stringify(event.data.input ?? {})}\n`);
|
|
535
|
+
}
|
|
536
|
+
else if (event.type === "tool.completed") {
|
|
537
|
+
process.stderr.write(`✓ ${String(event.data.tool ?? "tool")}\n`);
|
|
538
|
+
}
|
|
539
|
+
else if (event.type === "tool.failed") {
|
|
540
|
+
process.stderr.write(`✗ ${String(event.data.tool ?? "tool")}: ${String(event.data.message ?? "failed")}\n`);
|
|
541
|
+
}
|
|
542
|
+
else if (event.type === "session.failed") {
|
|
543
|
+
throw new Error(String(event.data.message ?? "Local session failed"));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!resolvedSessionID)
|
|
548
|
+
throw new Error("The local session did not return an ID");
|
|
549
|
+
return resolvedSessionID;
|
|
550
|
+
}
|
|
551
|
+
async function stopOwnedDev(child) {
|
|
552
|
+
if (!child || child.exitCode !== null)
|
|
553
|
+
return;
|
|
554
|
+
child.kill("SIGTERM");
|
|
555
|
+
await Promise.race([
|
|
556
|
+
new Promise((done) => child.once("exit", () => done())),
|
|
557
|
+
new Promise((done) => setTimeout(done, 3_000)),
|
|
558
|
+
]);
|
|
559
|
+
if (child.exitCode === null)
|
|
560
|
+
child.kill("SIGKILL");
|
|
561
|
+
}
|
|
562
|
+
async function ensureDevService(config) {
|
|
563
|
+
const root = await findAgentRoot();
|
|
564
|
+
if (!root) {
|
|
565
|
+
throw new Error("No OpenComputer agent repository found. Run `opencomputer init <template>` first.");
|
|
566
|
+
}
|
|
567
|
+
const existing = await readDevState(root);
|
|
568
|
+
if (existing)
|
|
569
|
+
return { state: existing };
|
|
570
|
+
const environment = {
|
|
571
|
+
...process.env,
|
|
572
|
+
OPENCOMPUTER_API_URL: config.apiUrl,
|
|
573
|
+
OPENCOMPUTER_NO_OPEN: "1",
|
|
574
|
+
};
|
|
575
|
+
if (config.apiKey)
|
|
576
|
+
environment.OPENCOMPUTER_API_KEY = config.apiKey;
|
|
577
|
+
const child = spawn(process.execPath, [process.argv[1], "dev"], {
|
|
578
|
+
cwd: root,
|
|
579
|
+
env: environment,
|
|
580
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
581
|
+
});
|
|
582
|
+
let errors = "";
|
|
583
|
+
child.stderr?.on("data", (chunk) => {
|
|
584
|
+
errors = `${errors}${chunk.toString("utf8")}`.slice(-8_000);
|
|
585
|
+
});
|
|
586
|
+
const deadline = Date.now() + 60_000;
|
|
587
|
+
while (Date.now() < deadline) {
|
|
588
|
+
const state = await readDevState(root);
|
|
589
|
+
if (state)
|
|
590
|
+
return { state, owned: child };
|
|
591
|
+
if (child.exitCode !== null) {
|
|
592
|
+
throw new Error(errors.trim() || "The local development service exited");
|
|
593
|
+
}
|
|
594
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
595
|
+
}
|
|
596
|
+
await stopOwnedDev(child);
|
|
597
|
+
throw new Error("Timed out starting the local development service");
|
|
598
|
+
}
|
|
599
|
+
async function runSessionShell(config) {
|
|
600
|
+
const target = await ensureDevService(config);
|
|
601
|
+
const readline = createInterface({
|
|
602
|
+
input: process.stdin,
|
|
603
|
+
output: process.stdout,
|
|
604
|
+
terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
605
|
+
});
|
|
606
|
+
let sessionID;
|
|
607
|
+
process.stdout.write("Local agent ready. Enter a prompt; use /exit or Ctrl-C to quit.\n\n");
|
|
608
|
+
readline.on("SIGINT", () => readline.close());
|
|
609
|
+
try {
|
|
610
|
+
for (;;) {
|
|
611
|
+
let line;
|
|
612
|
+
try {
|
|
613
|
+
line = await readline.question("opencomputer> ");
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
const prompt = line.trim();
|
|
619
|
+
if (prompt === "/exit" || prompt === "/quit")
|
|
620
|
+
break;
|
|
621
|
+
if (!prompt)
|
|
622
|
+
continue;
|
|
623
|
+
try {
|
|
624
|
+
sessionID = await runLocalSession(prompt, target.state, sessionID);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
process.stderr.write(`turn failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
readline.close();
|
|
633
|
+
await stopOwnedDev(target.owned);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function runOneShotSession(prompt, config) {
|
|
637
|
+
const target = await ensureDevService(config);
|
|
638
|
+
try {
|
|
639
|
+
await runLocalSession(prompt, target.state);
|
|
125
640
|
}
|
|
126
641
|
finally {
|
|
127
|
-
await
|
|
642
|
+
await stopOwnedDev(target.owned);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
export async function runLocalAgent(args, config) {
|
|
646
|
+
if (args[0] === "dev") {
|
|
647
|
+
if (args.length > 1)
|
|
648
|
+
throw new Error(`Unexpected local argument: ${args[1]}`);
|
|
649
|
+
await startDevService(config);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (args[0] === "run") {
|
|
653
|
+
const prompt = args.slice(1).join(" ").trim();
|
|
654
|
+
if (!prompt)
|
|
655
|
+
throw new Error("A prompt is required");
|
|
656
|
+
await runOneShotSession(prompt, config);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (args[0] === "shell") {
|
|
660
|
+
if (args.length > 1)
|
|
661
|
+
throw new Error(`Unexpected local argument: ${args[1]}`);
|
|
662
|
+
await runSessionShell(config);
|
|
663
|
+
return;
|
|
128
664
|
}
|
|
665
|
+
throw new Error(`Unexpected local argument: ${args[0] ?? "none"}`);
|
|
129
666
|
}
|
|
130
667
|
//# sourceMappingURL=local.js.map
|