@opencomputer/cli 0.3.4 → 0.3.7

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/dist/local.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { createOpencode, } from "@opencode-ai/sdk/v2";
2
+ import { spawn } from "node:child_process";
2
3
  import { randomBytes, timingSafeEqual } from "node:crypto";
3
4
  import { mkdir, readFile, rm, writeFile, } from "node:fs/promises";
4
5
  import { createServer, } from "node:http";
5
6
  import { delimiter, dirname, resolve } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
8
+ import { renderDevUI } from "./dev-ui.js";
7
9
  import { findAgentRoot, prepareAgent, readManifest } from "./project.js";
8
10
  function sameToken(left, right) {
9
11
  const leftBytes = Buffer.from(left);
@@ -33,6 +35,21 @@ function sendJSON(response, status, body) {
33
35
  response.writeHead(status, { "content-type": "application/json" });
34
36
  response.end(JSON.stringify(body));
35
37
  }
38
+ function openBrowser(url) {
39
+ if (process.env.OPENCOMPUTER_NO_OPEN === "1")
40
+ return;
41
+ const command = process.platform === "darwin"
42
+ ? { file: "open", args: [url] }
43
+ : process.platform === "win32"
44
+ ? { file: "cmd", args: ["/c", "start", "", url] }
45
+ : { file: "xdg-open", args: [url] };
46
+ const child = spawn(command.file, command.args, {
47
+ detached: true,
48
+ stdio: "ignore",
49
+ });
50
+ child.on("error", () => undefined);
51
+ child.unref();
52
+ }
36
53
  async function startGateway(config) {
37
54
  if (!config.apiKey) {
38
55
  throw new Error("Not logged in. Run `opencomputer login` before starting dev mode.");
@@ -95,7 +112,10 @@ async function startGateway(config) {
95
112
  return {
96
113
  url: `http://127.0.0.1:${String(address.port)}`,
97
114
  token,
98
- close: () => new Promise((done) => server.close(() => done())),
115
+ close: () => {
116
+ server.closeAllConnections();
117
+ return new Promise((done) => server.close(() => done()));
118
+ },
99
119
  };
100
120
  }
101
121
  function addBundledRuntimeToPath() {
@@ -116,29 +136,50 @@ function modelParts() {
116
136
  full,
117
137
  };
118
138
  }
119
- async function streamTurn(client, directory, prompt, emit) {
120
- const created = await client.session.create({ directory });
121
- if (!created.data)
122
- throw new Error("The local agent session did not start");
123
- const sessionID = created.data.id;
124
- emit({ type: "session.created", data: { sessionId: sessionID } });
139
+ async function streamTurn(client, directory, sessionID, prompt, emit) {
125
140
  const subscription = await client.event.subscribe({ directory });
126
- const assistantMessages = new Set();
127
- const textByPart = new Map();
128
- const messageByPart = new Map();
129
- const emittedLengths = new Map();
141
+ const textParts = new Map();
142
+ const reasoningParts = new Map();
143
+ const partTypes = new Map();
144
+ const pendingDeltas = new Map();
130
145
  const tools = new Map();
131
146
  const toolStates = new Map();
132
- const completedText = [];
133
- const emitText = (partID) => {
134
- const messageID = messageByPart.get(partID);
135
- if (!messageID || !assistantMessages.has(messageID))
136
- return;
137
- const text = textByPart.get(partID) ?? "";
138
- const offset = emittedLengths.get(partID) ?? 0;
139
- if (text.length > offset) {
140
- emit({ type: "message.delta", data: { text: text.slice(offset) } });
141
- emittedLengths.set(partID, text.length);
147
+ const assistantMessages = new Set();
148
+ const streamedTextParts = new Set();
149
+ const consumePendingDeltas = (partID, messageID) => {
150
+ for (const [candidatePartID, deltas] of pendingDeltas) {
151
+ if (partID && candidatePartID !== partID)
152
+ continue;
153
+ const partType = partTypes.get(candidatePartID);
154
+ if (!partType)
155
+ continue;
156
+ const remaining = [];
157
+ for (const pending of deltas) {
158
+ if ((messageID && pending.messageID !== messageID) ||
159
+ !assistantMessages.has(pending.messageID)) {
160
+ remaining.push(pending);
161
+ continue;
162
+ }
163
+ const parts = partType === "text" ? textParts : reasoningParts;
164
+ parts.set(candidatePartID, `${parts.get(candidatePartID) ?? ""}${pending.delta}`);
165
+ if (partType === "text" && !streamedTextParts.has(candidatePartID)) {
166
+ if (streamedTextParts.size > 0) {
167
+ emit({
168
+ type: "message.delta",
169
+ data: { text: "\n\n", partId: candidatePartID },
170
+ });
171
+ }
172
+ streamedTextParts.add(candidatePartID);
173
+ }
174
+ emit({
175
+ type: partType === "text" ? "message.delta" : "reasoning.delta",
176
+ data: { text: pending.delta, partId: candidatePartID },
177
+ });
178
+ }
179
+ if (remaining.length)
180
+ pendingDeltas.set(candidatePartID, remaining);
181
+ else
182
+ pendingDeltas.delete(candidatePartID);
142
183
  }
143
184
  };
144
185
  const emitTool = (part) => {
@@ -152,20 +193,38 @@ async function streamTurn(client, directory, prompt, emit) {
152
193
  if (current === "running") {
153
194
  emit({
154
195
  type: "tool.started",
155
- data: { tool: part.tool, input: part.state.input },
196
+ data: {
197
+ callId: part.callID,
198
+ tool: part.tool,
199
+ title: part.state.title,
200
+ input: part.state.input,
201
+ },
156
202
  });
157
203
  }
158
204
  else if (current === "completed") {
159
- emit({ type: "tool.completed", data: { tool: part.tool } });
205
+ emit({
206
+ type: "tool.completed",
207
+ data: { callId: part.callID, tool: part.tool, title: part.state.title },
208
+ });
160
209
  }
161
210
  else if (current === "error") {
162
211
  emit({
163
212
  type: "tool.failed",
164
- data: { tool: part.tool, message: part.state.error },
213
+ data: {
214
+ callId: part.callID,
215
+ tool: part.tool,
216
+ title: "title" in part.state ? part.state.title : undefined,
217
+ message: part.state.error,
218
+ },
165
219
  });
166
220
  }
167
221
  };
168
222
  try {
223
+ const firstEvent = subscription.stream.next();
224
+ await Promise.race([
225
+ firstEvent.then(() => undefined),
226
+ new Promise((done) => setTimeout(done, 100)),
227
+ ]);
169
228
  const model = modelParts();
170
229
  const started = await client.session.promptAsync({
171
230
  sessionID,
@@ -175,15 +234,28 @@ async function streamTurn(client, directory, prompt, emit) {
175
234
  });
176
235
  if (started.error)
177
236
  throw new Error(JSON.stringify(started.error));
178
- for await (const event of subscription.stream) {
237
+ async function* events() {
238
+ const first = await firstEvent;
239
+ if (!first.done)
240
+ yield first.value;
241
+ yield* subscription.stream;
242
+ }
243
+ for await (const event of events()) {
244
+ if (event.type === "message.part.delta") {
245
+ const { sessionID: eventSessionID, messageID, partID, field, delta } = event.properties;
246
+ if (eventSessionID !== sessionID || field !== "text" || !delta)
247
+ continue;
248
+ const pending = pendingDeltas.get(partID) ?? [];
249
+ pending.push({ messageID, delta });
250
+ pendingDeltas.set(partID, pending);
251
+ consumePendingDeltas(partID, messageID);
252
+ continue;
253
+ }
179
254
  if (event.type === "message.updated") {
180
255
  const info = event.properties.info;
181
256
  if (info.sessionID === sessionID && info.role === "assistant") {
182
257
  assistantMessages.add(info.id);
183
- for (const [partID, messageID] of messageByPart) {
184
- if (messageID === info.id)
185
- emitText(partID);
186
- }
258
+ consumePendingDeltas(undefined, info.id);
187
259
  for (const part of tools.values()) {
188
260
  if (part.messageID === info.id)
189
261
  emitTool(part);
@@ -195,9 +267,20 @@ async function streamTurn(client, directory, prompt, emit) {
195
267
  if (part.sessionID !== sessionID)
196
268
  continue;
197
269
  if (part.type === "text") {
198
- messageByPart.set(part.id, part.messageID);
199
- textByPart.set(part.id, part.text);
200
- emitText(part.id);
270
+ partTypes.set(part.id, "text");
271
+ if (assistantMessages.has(part.messageID) &&
272
+ part.text.length >= (textParts.get(part.id)?.length ?? 0)) {
273
+ textParts.set(part.id, part.text);
274
+ }
275
+ consumePendingDeltas(part.id, part.messageID);
276
+ }
277
+ else if (part.type === "reasoning") {
278
+ partTypes.set(part.id, "reasoning");
279
+ if (assistantMessages.has(part.messageID) &&
280
+ part.text.length >= (reasoningParts.get(part.id)?.length ?? 0)) {
281
+ reasoningParts.set(part.id, part.text);
282
+ }
283
+ consumePendingDeltas(part.id, part.messageID);
201
284
  }
202
285
  else if (part.type === "tool") {
203
286
  tools.set(part.callID, part);
@@ -217,14 +300,22 @@ async function streamTurn(client, directory, prompt, emit) {
217
300
  finally {
218
301
  await subscription.stream.return(undefined);
219
302
  }
220
- for (const [partID, text] of textByPart) {
221
- const messageID = messageByPart.get(partID);
222
- if (messageID && assistantMessages.has(messageID))
223
- completedText.push(text);
303
+ const reasoning = [...reasoningParts.values()].join("");
304
+ if (reasoning) {
305
+ emit({ type: "reasoning.completed", data: { text: reasoning } });
224
306
  }
225
- const text = completedText.join("");
307
+ const text = [...textParts.values()]
308
+ .map((part) => part.trim())
309
+ .filter(Boolean)
310
+ .join("\n\n");
226
311
  emit({ type: "message.completed", data: { text } });
227
- return sessionID;
312
+ return text;
313
+ }
314
+ async function createRuntimeSession(client, directory) {
315
+ const created = await client.session.create({ directory });
316
+ if (!created.data)
317
+ throw new Error("The local agent session did not start");
318
+ return created.data.id;
228
319
  }
229
320
  function statePath(root) {
230
321
  return resolve(root, ".opencomputer", "dev.json");
@@ -259,22 +350,43 @@ async function startDevService(config) {
259
350
  addBundledRuntimeToPath();
260
351
  const abortController = new AbortController();
261
352
  const model = modelParts();
262
- const instance = await createOpencode({
263
- signal: abortController.signal,
264
- port: 0,
265
- timeout: 45_000,
266
- config: {
267
- model: model.full,
268
- enabled_providers: ["openrouter"],
269
- provider: {
270
- openrouter: {
271
- options: { baseURL: `${gateway.url}/openrouter/api/v1` },
353
+ const previousConnectionsURL = process.env.OPENCOMPUTER_CONNECTIONS_URL;
354
+ const previousConnectionToken = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
355
+ process.env.OPENCOMPUTER_CONNECTIONS_URL = gateway.url;
356
+ process.env.OPENCOMPUTER_CONNECTION_TOKEN = gateway.token;
357
+ let instance;
358
+ try {
359
+ instance = await createOpencode({
360
+ signal: abortController.signal,
361
+ port: 0,
362
+ timeout: 45_000,
363
+ config: {
364
+ model: model.full,
365
+ enabled_providers: ["openrouter"],
366
+ provider: {
367
+ openrouter: {
368
+ options: { baseURL: `${gateway.url}/openrouter/api/v1` },
369
+ },
272
370
  },
371
+ autoupdate: false,
372
+ share: "disabled",
273
373
  },
274
- autoupdate: false,
275
- share: "disabled",
276
- },
277
- });
374
+ });
375
+ }
376
+ finally {
377
+ if (previousConnectionsURL === undefined) {
378
+ delete process.env.OPENCOMPUTER_CONNECTIONS_URL;
379
+ }
380
+ else {
381
+ process.env.OPENCOMPUTER_CONNECTIONS_URL = previousConnectionsURL;
382
+ }
383
+ if (previousConnectionToken === undefined) {
384
+ delete process.env.OPENCOMPUTER_CONNECTION_TOKEN;
385
+ }
386
+ else {
387
+ process.env.OPENCOMPUTER_CONNECTION_TOKEN = previousConnectionToken;
388
+ }
389
+ }
278
390
  const authenticated = await instance.client.auth.set({
279
391
  providerID: "openrouter",
280
392
  auth: { type: "api", key: gateway.token },
@@ -283,15 +395,32 @@ async function startDevService(config) {
283
395
  throw new Error("The embedded agent runtime rejected its local credential");
284
396
  }
285
397
  const token = randomBytes(32).toString("base64url");
398
+ const sessions = new Map();
399
+ const running = new Set();
286
400
  const server = createServer((request, response) => {
287
401
  void (async () => {
288
402
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
289
403
  if (request.method === "GET" && url.pathname === "/") {
290
- sendJSON(response, 200, {
291
- service: "OpenComputer local agent",
292
- agentId: manifest.id,
293
- endpoints: ["GET /health", "POST /sessions"],
404
+ response.writeHead(200, {
405
+ "content-type": "text/html; charset=utf-8",
406
+ "cache-control": "no-store",
407
+ "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; " +
408
+ "connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'",
294
409
  });
410
+ response.end(renderDevUI(manifest.name));
411
+ return;
412
+ }
413
+ const assetMatch = url.pathname.match(/^\/assets\/([A-Za-z0-9_.-]+\.(?:js|css))$/);
414
+ if (request.method === "GET" && assetMatch?.[1]) {
415
+ const filename = assetMatch[1];
416
+ const asset = await readFile(fileURLToPath(new URL(`./ui/${filename}`, import.meta.url)));
417
+ response.writeHead(200, {
418
+ "content-type": filename.endsWith(".css")
419
+ ? "text/css; charset=utf-8"
420
+ : "text/javascript; charset=utf-8",
421
+ "cache-control": "no-store",
422
+ });
423
+ response.end(asset);
295
424
  return;
296
425
  }
297
426
  if (!authorized(request, token)) {
@@ -299,15 +428,23 @@ async function startDevService(config) {
299
428
  return;
300
429
  }
301
430
  if (request.method === "GET" && url.pathname === "/health") {
302
- sendJSON(response, 200, { ok: true, agentId: manifest.id });
431
+ sendJSON(response, 200, {
432
+ ok: true,
433
+ agentId: manifest.id,
434
+ sessions: sessions.size,
435
+ });
303
436
  return;
304
437
  }
305
- if (request.method === "POST" && url.pathname === "/sessions") {
306
- const body = JSON.parse((await readBody(request)).toString("utf8"));
307
- if (typeof body.prompt !== "string" || !body.prompt.trim()) {
308
- sendJSON(response, 400, { message: "A prompt is required" });
438
+ const streamSession = async (session, prompt, created = false) => {
439
+ if (running.has(session.id)) {
440
+ sendJSON(response, 409, { message: "This session is already running" });
309
441
  return;
310
442
  }
443
+ running.add(session.id);
444
+ session.messages.push({ role: "user", text: prompt });
445
+ if (!session.title)
446
+ session.title = prompt.slice(0, 60);
447
+ session.updatedAt = new Date().toISOString();
311
448
  response.writeHead(200, {
312
449
  "content-type": "application/x-ndjson",
313
450
  "cache-control": "no-store",
@@ -315,8 +452,13 @@ async function startDevService(config) {
315
452
  const emit = (event) => {
316
453
  response.write(`${JSON.stringify(event)}\n`);
317
454
  };
455
+ if (created) {
456
+ emit({ type: "session.created", data: { sessionId: session.id } });
457
+ }
318
458
  try {
319
- await streamTurn(instance.client, directory, body.prompt.trim(), emit);
459
+ const text = await streamTurn(instance.client, directory, session.id, prompt, emit);
460
+ session.messages.push({ role: "assistant", text });
461
+ session.updatedAt = new Date().toISOString();
320
462
  }
321
463
  catch (error) {
322
464
  emit({
@@ -326,7 +468,75 @@ async function startDevService(config) {
326
468
  },
327
469
  });
328
470
  }
471
+ finally {
472
+ running.delete(session.id);
473
+ }
329
474
  response.end();
475
+ };
476
+ if (request.method === "GET" && url.pathname === "/sessions") {
477
+ sendJSON(response, 200, {
478
+ sessions: [...sessions.values()]
479
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
480
+ .map(({ id, title, createdAt, updatedAt, messages }) => ({
481
+ id,
482
+ title,
483
+ createdAt,
484
+ updatedAt,
485
+ messageCount: messages.length,
486
+ })),
487
+ });
488
+ return;
489
+ }
490
+ if (request.method === "POST" && url.pathname === "/sessions") {
491
+ const raw = (await readBody(request)).toString("utf8");
492
+ const body = (raw ? JSON.parse(raw) : {});
493
+ const id = await createRuntimeSession(instance.client, directory);
494
+ const now = new Date().toISOString();
495
+ const session = {
496
+ id,
497
+ title: "",
498
+ createdAt: now,
499
+ updatedAt: now,
500
+ messages: [],
501
+ };
502
+ sessions.set(id, session);
503
+ if (typeof body.prompt === "string" && body.prompt.trim()) {
504
+ response.setHeader("x-opencomputer-session-id", id);
505
+ await streamSession(session, body.prompt.trim(), true);
506
+ }
507
+ else {
508
+ sendJSON(response, 201, session);
509
+ }
510
+ return;
511
+ }
512
+ const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
513
+ if (sessionMatch?.[1]) {
514
+ const id = decodeURIComponent(sessionMatch[1]);
515
+ const session = sessions.get(id);
516
+ if (!session) {
517
+ sendJSON(response, 404, { message: "Session not found" });
518
+ return;
519
+ }
520
+ if (request.method === "GET") {
521
+ sendJSON(response, 200, session);
522
+ return;
523
+ }
524
+ if (request.method === "POST") {
525
+ const body = JSON.parse((await readBody(request)).toString("utf8"));
526
+ if (typeof body.prompt !== "string" || !body.prompt.trim()) {
527
+ sendJSON(response, 400, { message: "A prompt is required" });
528
+ return;
529
+ }
530
+ await streamSession(session, body.prompt.trim());
531
+ return;
532
+ }
533
+ }
534
+ if (request.method === "GET" && url.pathname === "/api") {
535
+ sendJSON(response, 200, {
536
+ service: "OpenComputer local agent",
537
+ agentId: manifest.id,
538
+ endpoints: ["GET /sessions", "POST /sessions", "POST /sessions/:id"],
539
+ });
330
540
  return;
331
541
  }
332
542
  sendJSON(response, 404, { message: "Route not found" });
@@ -362,30 +572,29 @@ async function startDevService(config) {
362
572
  await writeFile(statePath(root), `${JSON.stringify(state, null, 2)}\n`, {
363
573
  mode: 0o600,
364
574
  });
575
+ const webUrl = `${state.url}/#token=${encodeURIComponent(token)}`;
365
576
  process.stdout.write(`OpenComputer dev service ready\n` +
366
577
  `Agent: ${manifest.name} (${manifest.id})\n` +
367
- `Local API: ${state.url}\n\n` +
368
- `Start a session in another terminal:\n` +
369
- ` opencomputer session create "Your prompt"\n`);
578
+ `Web: ${webUrl}\n` +
579
+ `Local API: ${state.url}\n` +
580
+ `Session: opencomputer session\n`);
581
+ openBrowser(webUrl);
370
582
  await new Promise((done) => {
371
583
  process.once("SIGINT", done);
372
584
  process.once("SIGTERM", done);
373
585
  });
374
586
  await rm(statePath(root), { force: true });
587
+ server.closeAllConnections();
375
588
  await new Promise((done) => server.close(() => done()));
376
589
  abortController.abort();
377
590
  instance.server.close();
378
591
  await gateway.close();
379
592
  }
380
- async function runLocalSession(prompt) {
381
- const root = await findAgentRoot();
382
- if (!root)
383
- throw new Error("No OpenComputer agent repository found.");
384
- const state = await readDevState(root);
385
- if (!state) {
386
- throw new Error("OpenComputer dev is not running. Start `opencomputer dev` in another terminal.");
387
- }
388
- const response = await fetch(`${state.url}/sessions`, {
593
+ async function runLocalSession(prompt, state, sessionID, onEvent) {
594
+ const endpoint = sessionID
595
+ ? `${state.url}/sessions/${encodeURIComponent(sessionID)}`
596
+ : `${state.url}/sessions`;
597
+ const response = await fetch(endpoint, {
389
598
  method: "POST",
390
599
  headers: {
391
600
  authorization: `Bearer ${state.token}`,
@@ -394,8 +603,11 @@ async function runLocalSession(prompt) {
394
603
  body: JSON.stringify({ prompt }),
395
604
  });
396
605
  if (!response.ok || !response.body) {
397
- throw new Error(`The local agent service returned ${String(response.status)}`);
606
+ const detail = await response.text().catch(() => "");
607
+ throw new Error(`The local agent service returned ${String(response.status)}` +
608
+ (detail ? `: ${detail}` : ""));
398
609
  }
610
+ let resolvedSessionID = sessionID ?? response.headers.get("x-opencomputer-session-id") ?? undefined;
399
611
  const decoder = new TextDecoder();
400
612
  let buffered = "";
401
613
  let streamedText = false;
@@ -407,43 +619,139 @@ async function runLocalSession(prompt) {
407
619
  if (!line.trim())
408
620
  continue;
409
621
  const event = JSON.parse(line);
622
+ onEvent?.(event);
410
623
  if (event.type === "session.created") {
411
- process.stderr.write(`Session ${String(event.data.sessionId)}\n`);
624
+ resolvedSessionID = String(event.data.sessionId);
625
+ if (!onEvent)
626
+ process.stderr.write(`Session ${resolvedSessionID}\n`);
412
627
  }
413
628
  else if (event.type === "message.delta") {
414
629
  streamedText = true;
415
- process.stdout.write(String(event.data.text ?? ""));
630
+ if (!onEvent)
631
+ process.stdout.write(String(event.data.text ?? ""));
416
632
  }
417
633
  else if (event.type === "message.completed") {
418
- if (!streamedText)
419
- process.stdout.write(String(event.data.text ?? ""));
420
- process.stdout.write("\n");
634
+ if (!onEvent) {
635
+ if (!streamedText)
636
+ process.stdout.write(String(event.data.text ?? ""));
637
+ process.stdout.write("\n");
638
+ }
421
639
  }
422
640
  else if (event.type === "tool.started") {
423
- process.stderr.write(`⚙ ${String(event.data.tool ?? "tool")} ${JSON.stringify(event.data.input ?? {})}\n`);
641
+ if (!onEvent) {
642
+ process.stderr.write(`⚙ ${String(event.data.tool ?? "tool")} ${JSON.stringify(event.data.input ?? {})}\n`);
643
+ }
424
644
  }
425
645
  else if (event.type === "tool.completed") {
426
- process.stderr.write(`✓ ${String(event.data.tool ?? "tool")}\n`);
646
+ if (!onEvent) {
647
+ process.stderr.write(`✓ ${String(event.data.tool ?? "tool")}\n`);
648
+ }
427
649
  }
428
650
  else if (event.type === "tool.failed") {
429
- process.stderr.write(`✗ ${String(event.data.tool ?? "tool")}: ${String(event.data.message ?? "failed")}\n`);
651
+ if (!onEvent) {
652
+ process.stderr.write(`✗ ${String(event.data.tool ?? "tool")}: ${String(event.data.message ?? "failed")}\n`);
653
+ }
430
654
  }
431
655
  else if (event.type === "session.failed") {
432
656
  throw new Error(String(event.data.message ?? "Local session failed"));
433
657
  }
434
658
  }
435
659
  }
660
+ if (!resolvedSessionID)
661
+ throw new Error("The local session did not return an ID");
662
+ return resolvedSessionID;
663
+ }
664
+ async function stopOwnedDev(child) {
665
+ if (!child || child.exitCode !== null)
666
+ return;
667
+ child.kill("SIGTERM");
668
+ await Promise.race([
669
+ new Promise((done) => child.once("exit", () => done())),
670
+ new Promise((done) => setTimeout(done, 3_000)),
671
+ ]);
672
+ if (child.exitCode === null)
673
+ child.kill("SIGKILL");
674
+ }
675
+ async function ensureDevService(config) {
676
+ const root = await findAgentRoot();
677
+ if (!root) {
678
+ throw new Error("No OpenComputer agent repository found. Run `opencomputer init <template>` first.");
679
+ }
680
+ const existing = await readDevState(root);
681
+ if (existing)
682
+ return { state: existing };
683
+ const environment = {
684
+ ...process.env,
685
+ OPENCOMPUTER_API_URL: config.apiUrl,
686
+ OPENCOMPUTER_NO_OPEN: "1",
687
+ };
688
+ if (config.apiKey)
689
+ environment.OPENCOMPUTER_API_KEY = config.apiKey;
690
+ const child = spawn(process.execPath, [process.argv[1], "dev"], {
691
+ cwd: root,
692
+ env: environment,
693
+ stdio: ["ignore", "ignore", "pipe"],
694
+ });
695
+ let errors = "";
696
+ child.stderr?.on("data", (chunk) => {
697
+ errors = `${errors}${chunk.toString("utf8")}`.slice(-8_000);
698
+ });
699
+ const deadline = Date.now() + 60_000;
700
+ while (Date.now() < deadline) {
701
+ const state = await readDevState(root);
702
+ if (state)
703
+ return { state, owned: child };
704
+ if (child.exitCode !== null) {
705
+ throw new Error(errors.trim() || "The local development service exited");
706
+ }
707
+ await new Promise((done) => setTimeout(done, 100));
708
+ }
709
+ await stopOwnedDev(child);
710
+ throw new Error("Timed out starting the local development service");
711
+ }
712
+ async function runSessionShell(config) {
713
+ const target = await ensureDevService(config);
714
+ try {
715
+ const manifest = await readManifest(target.state.agentRoot);
716
+ const { runSessionTUI } = await import("./tui.js");
717
+ await runSessionTUI({
718
+ agentName: manifest.name,
719
+ send: (prompt, sessionId, emit) => runLocalSession(prompt, target.state, sessionId, emit),
720
+ });
721
+ }
722
+ finally {
723
+ await stopOwnedDev(target.owned);
724
+ }
725
+ }
726
+ async function runOneShotSession(prompt, config) {
727
+ const target = await ensureDevService(config);
728
+ try {
729
+ await runLocalSession(prompt, target.state);
730
+ }
731
+ finally {
732
+ await stopOwnedDev(target.owned);
733
+ }
436
734
  }
437
735
  export async function runLocalAgent(args, config) {
736
+ if (args[0] === "dev") {
737
+ if (args.length > 1)
738
+ throw new Error(`Unexpected local argument: ${args[1]}`);
739
+ await startDevService(config);
740
+ return;
741
+ }
438
742
  if (args[0] === "run") {
439
743
  const prompt = args.slice(1).join(" ").trim();
440
744
  if (!prompt)
441
745
  throw new Error("A prompt is required");
442
- await runLocalSession(prompt);
746
+ await runOneShotSession(prompt, config);
747
+ return;
748
+ }
749
+ if (args[0] === "shell") {
750
+ if (args.length > 1)
751
+ throw new Error(`Unexpected local argument: ${args[1]}`);
752
+ await runSessionShell(config);
443
753
  return;
444
754
  }
445
- if (args.length)
446
- throw new Error(`Unexpected local argument: ${args[0]}`);
447
- await startDevService(config);
755
+ throw new Error(`Unexpected local argument: ${args[0] ?? "none"}`);
448
756
  }
449
757
  //# sourceMappingURL=local.js.map