@jawkx1999/opencr 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
Files changed (34) hide show
  1. package/dist/client/assets/index-C9jeaZcr.css +1 -0
  2. package/dist/client/assets/index-DF6LDO7P.js +1978 -0
  3. package/dist/client/index.html +3 -3
  4. package/dist/server/cli/index.js +32 -1
  5. package/dist/server/cli/index.js.map +1 -1
  6. package/dist/server/core/agents/opencode.js +685 -0
  7. package/dist/server/core/agents/opencode.js.map +1 -0
  8. package/dist/server/core/reviewDiagnostics.js +116 -0
  9. package/dist/server/core/reviewDiagnostics.js.map +1 -0
  10. package/dist/server/core/reviewPayload.js +7 -0
  11. package/dist/server/core/reviewPayload.js.map +1 -1
  12. package/dist/server/core/reviewRequest.js +4 -1
  13. package/dist/server/core/reviewRequest.js.map +1 -1
  14. package/dist/server/core/reviewSource/commitish.js +1 -1
  15. package/dist/server/core/reviewSource/commitish.js.map +1 -1
  16. package/dist/server/core/reviewSource/githubPr.js +31 -0
  17. package/dist/server/core/reviewSource/githubPr.js.map +1 -1
  18. package/dist/server/core/reviewSource/index.js +2 -2
  19. package/dist/server/core/reviewSource/index.js.map +1 -1
  20. package/dist/server/core/server.js +27 -6
  21. package/dist/server/core/server.js.map +1 -1
  22. package/dist/server/core/workspace/identity.js +47 -0
  23. package/dist/server/core/workspace/identity.js.map +1 -0
  24. package/dist/server/core/workspace/repository.js +399 -0
  25. package/dist/server/core/workspace/repository.js.map +1 -0
  26. package/dist/server/server/app.js +204 -2
  27. package/dist/server/server/app.js.map +1 -1
  28. package/dist/server/shared/types.js +1 -1
  29. package/dist/server/shared/types.js.map +1 -1
  30. package/package.json +5 -4
  31. package/dist/client/assets/index-CMvi92Od.js +0 -1974
  32. package/dist/client/assets/index-dwKNSbA7.css +0 -1
  33. package/dist/server/cli/utils.js +0 -2
  34. package/dist/server/cli/utils.js.map +0 -1
@@ -0,0 +1,685 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { once } from 'node:events';
3
+ import { resolve } from 'node:path';
4
+ const OPENCODE_BACKEND_ID = 'opencode';
5
+ const CODEX_BACKEND_ID = 'codex';
6
+ const SERVER_LISTENING_PATTERN = /opencode server listening on\s+(https?:\/\/\S+)/i;
7
+ const MAX_DIFF_CONTEXT_CHARS = 14000;
8
+ const EVENT_STREAM_RECONNECT_DELAY_MS = 350;
9
+ const toErrorMessage = (error) => {
10
+ if (error instanceof Error) {
11
+ return error.message;
12
+ }
13
+ return String(error);
14
+ };
15
+ const isObject = (value) => {
16
+ return typeof value === 'object' && value !== null;
17
+ };
18
+ const toIdleStatus = () => ({
19
+ type: 'idle',
20
+ });
21
+ const parseSessionStatus = (value) => {
22
+ if (!isObject(value) || typeof value.type !== 'string') {
23
+ return toIdleStatus();
24
+ }
25
+ if (value.type === 'busy') {
26
+ return {
27
+ type: 'busy',
28
+ };
29
+ }
30
+ if (value.type === 'retry' &&
31
+ typeof value.attempt === 'number' &&
32
+ typeof value.message === 'string' &&
33
+ typeof value.next === 'number') {
34
+ return {
35
+ type: 'retry',
36
+ attempt: value.attempt,
37
+ message: value.message,
38
+ next: value.next,
39
+ };
40
+ }
41
+ return toIdleStatus();
42
+ };
43
+ const parseModelReference = (modelId) => {
44
+ const trimmed = modelId.trim();
45
+ const slashIndex = trimmed.indexOf('/');
46
+ if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
47
+ throw new Error(`Invalid model id: ${modelId}. Expected provider/model.`);
48
+ }
49
+ return {
50
+ providerID: trimmed.slice(0, slashIndex),
51
+ modelID: trimmed.slice(slashIndex + 1),
52
+ };
53
+ };
54
+ const normalizeTarget = (target, defaultModelId) => {
55
+ const backendId = target?.backendId ?? OPENCODE_BACKEND_ID;
56
+ return {
57
+ backendId,
58
+ modelId: target?.modelId ?? defaultModelId,
59
+ variant: target?.variant ?? null,
60
+ };
61
+ };
62
+ const assertOpencodeBackend = (backendId) => {
63
+ if (backendId === OPENCODE_BACKEND_ID) {
64
+ return;
65
+ }
66
+ if (backendId === CODEX_BACKEND_ID) {
67
+ throw new Error('Codex backend is not implemented yet.');
68
+ }
69
+ throw new Error(`Unsupported agent backend: ${backendId}`);
70
+ };
71
+ const formatRangeLabel = (range) => {
72
+ const startSide = range.side === 'deletions' ? 'L' : 'R';
73
+ const endSide = range.endSide === 'deletions' ? 'L' : range.endSide === 'additions' ? 'R' : startSide;
74
+ const start = `${startSide}${range.start}`;
75
+ const end = `${endSide}${range.end}`;
76
+ return start === end ? start : `${start} to ${end}`;
77
+ };
78
+ const truncateDiff = (patch) => {
79
+ if (patch.length <= MAX_DIFF_CONTEXT_CHARS) {
80
+ return patch;
81
+ }
82
+ return `${patch.slice(0, MAX_DIFF_CONTEXT_CHARS)}\n\n[diff truncated by opencr]`;
83
+ };
84
+ const buildSessionTitle = (input) => {
85
+ const fileLabel = input.selection.fileLabel.split('/').at(-1) ?? input.selection.fileLabel;
86
+ const trimmedPrompt = input.prompt.trim().replace(/\s+/g, ' ');
87
+ if (trimmedPrompt.length === 0) {
88
+ return `Review ${fileLabel}`;
89
+ }
90
+ const suffix = trimmedPrompt.length > 46 ? `${trimmedPrompt.slice(0, 46)}...` : trimmedPrompt;
91
+ return `Review ${fileLabel}: ${suffix}`;
92
+ };
93
+ const buildSystemContext = (input) => {
94
+ return [
95
+ 'You are assisting a code review session launched from opencr.',
96
+ `Selected file: ${input.selection.fileLabel}`,
97
+ `Selected range: ${formatRangeLabel(input.selection.range)}`,
98
+ 'Focus your answer on the selected range, but use the full diff context when needed.',
99
+ '',
100
+ 'Full diff context:',
101
+ truncateDiff(input.diffPatch),
102
+ ].join('\n');
103
+ };
104
+ const toUnavailableCatalog = (reason) => {
105
+ return {
106
+ backends: [
107
+ {
108
+ id: OPENCODE_BACKEND_ID,
109
+ label: 'OpenCode',
110
+ available: false,
111
+ reason,
112
+ defaultModelId: null,
113
+ models: [],
114
+ },
115
+ {
116
+ id: CODEX_BACKEND_ID,
117
+ label: 'Codex',
118
+ available: false,
119
+ reason: 'Codex backend is not implemented yet.',
120
+ defaultModelId: null,
121
+ models: [],
122
+ },
123
+ ],
124
+ defaultTarget: null,
125
+ };
126
+ };
127
+ class OpencodeUnavailableService {
128
+ reason;
129
+ constructor(reason) {
130
+ this.reason = reason;
131
+ }
132
+ getStatus() {
133
+ return {
134
+ available: false,
135
+ reason: this.reason,
136
+ };
137
+ }
138
+ async getCatalog() {
139
+ return toUnavailableCatalog(this.reason);
140
+ }
141
+ async createSession() {
142
+ throw new Error(this.reason);
143
+ }
144
+ async getSessionStatus() {
145
+ throw new Error(this.reason);
146
+ }
147
+ async listMessages() {
148
+ throw new Error(this.reason);
149
+ }
150
+ async sendMessage() {
151
+ throw new Error(this.reason);
152
+ }
153
+ subscribeEvents() {
154
+ return () => {
155
+ return;
156
+ };
157
+ }
158
+ async stop() {
159
+ return;
160
+ }
161
+ }
162
+ class OpencodeService {
163
+ repositoryPath;
164
+ process = null;
165
+ baseUrl = null;
166
+ ensureReadyTask = null;
167
+ eventStreamTask = null;
168
+ eventStreamAbortController = null;
169
+ eventListeners = new Set();
170
+ sessionStatusById = new Map();
171
+ modelsCache = null;
172
+ isStopping = false;
173
+ lastFailureReason = null;
174
+ constructor(repositoryPath) {
175
+ this.repositoryPath = repositoryPath;
176
+ }
177
+ getStatus() {
178
+ if (this.lastFailureReason != null) {
179
+ return {
180
+ available: false,
181
+ reason: this.lastFailureReason,
182
+ };
183
+ }
184
+ return {
185
+ available: true,
186
+ reason: null,
187
+ };
188
+ }
189
+ async getCatalog() {
190
+ let models = [];
191
+ let modelError = null;
192
+ try {
193
+ models = this.listOpencodeModels();
194
+ }
195
+ catch (error) {
196
+ modelError = toErrorMessage(error);
197
+ }
198
+ const available = this.lastFailureReason == null && modelError == null;
199
+ const unavailableReason = this.lastFailureReason ?? modelError;
200
+ const defaultModelId = models.at(0) ?? null;
201
+ return {
202
+ backends: [
203
+ {
204
+ id: OPENCODE_BACKEND_ID,
205
+ label: 'OpenCode',
206
+ available,
207
+ reason: unavailableReason,
208
+ defaultModelId,
209
+ models: models.map((modelId) => ({
210
+ id: modelId,
211
+ label: modelId,
212
+ })),
213
+ },
214
+ {
215
+ id: CODEX_BACKEND_ID,
216
+ label: 'Codex',
217
+ available: false,
218
+ reason: 'Codex backend is not implemented yet.',
219
+ defaultModelId: null,
220
+ models: [],
221
+ },
222
+ ],
223
+ defaultTarget: available
224
+ ? {
225
+ backendId: OPENCODE_BACKEND_ID,
226
+ modelId: defaultModelId,
227
+ variant: null,
228
+ }
229
+ : null,
230
+ };
231
+ }
232
+ subscribeEvents(listener) {
233
+ this.eventListeners.add(listener);
234
+ void this.ensureEventStream();
235
+ listener({
236
+ kind: 'connected',
237
+ });
238
+ return () => {
239
+ this.eventListeners.delete(listener);
240
+ if (this.eventListeners.size === 0) {
241
+ this.eventStreamAbortController?.abort();
242
+ this.eventStreamAbortController = null;
243
+ this.eventStreamTask = null;
244
+ }
245
+ };
246
+ }
247
+ async getSessionStatus(input) {
248
+ assertOpencodeBackend(input.backendId);
249
+ await this.ensureReady();
250
+ const statusBySessionId = await this.requestJson('/session/status', {
251
+ method: 'GET',
252
+ });
253
+ for (const [knownSessionId, status] of Object.entries(statusBySessionId)) {
254
+ this.sessionStatusById.set(knownSessionId, parseSessionStatus(status));
255
+ }
256
+ for (const knownSessionId of this.sessionStatusById.keys()) {
257
+ if (!(knownSessionId in statusBySessionId)) {
258
+ this.sessionStatusById.set(knownSessionId, toIdleStatus());
259
+ }
260
+ }
261
+ return this.sessionStatusById.get(input.sessionId) ?? toIdleStatus();
262
+ }
263
+ async createSession(input) {
264
+ const catalog = await this.getCatalog();
265
+ const opencodeCatalog = catalog.backends.find((backend) => backend.id === OPENCODE_BACKEND_ID) ?? null;
266
+ const target = normalizeTarget(input.target, opencodeCatalog?.defaultModelId ?? null);
267
+ assertOpencodeBackend(target.backendId);
268
+ const created = await this.requestJson('/session', {
269
+ method: 'POST',
270
+ body: {
271
+ title: buildSessionTitle(input),
272
+ },
273
+ });
274
+ const promptBody = {
275
+ system: buildSystemContext(input),
276
+ parts: [
277
+ {
278
+ type: 'text',
279
+ text: input.prompt,
280
+ },
281
+ ],
282
+ };
283
+ if (target.modelId != null) {
284
+ promptBody.model = parseModelReference(target.modelId);
285
+ }
286
+ if (target.variant != null) {
287
+ promptBody.variant = target.variant;
288
+ }
289
+ await this.requestJson(`/session/${created.id}/prompt_async`, {
290
+ method: 'POST',
291
+ body: promptBody,
292
+ });
293
+ const messages = await this.listMessages({
294
+ backendId: OPENCODE_BACKEND_ID,
295
+ sessionId: created.id,
296
+ });
297
+ this.sessionStatusById.set(created.id, {
298
+ type: 'busy',
299
+ });
300
+ return {
301
+ session: {
302
+ backendId: OPENCODE_BACKEND_ID,
303
+ backendSessionId: created.id,
304
+ modelId: target.modelId,
305
+ variant: target.variant,
306
+ title: created.title,
307
+ createdAt: created.time.created,
308
+ updatedAt: created.time.updated,
309
+ },
310
+ acceptedAt: Date.now(),
311
+ messages: messages.messages,
312
+ };
313
+ }
314
+ async listMessages(input) {
315
+ assertOpencodeBackend(input.backendId);
316
+ const result = await this.requestJson(`/session/${input.sessionId}/message`, {
317
+ method: 'GET',
318
+ });
319
+ const messages = result
320
+ .filter((entry) => entry.info.role === 'user' || entry.info.role === 'assistant')
321
+ .map((entry) => {
322
+ const textParts = entry.parts
323
+ .filter((part) => part.type === 'text' && typeof part.text === 'string' && !part.ignored)
324
+ .map((part) => part.text ?? '')
325
+ .filter((text) => text.trim().length > 0);
326
+ const joinedText = textParts.join('\n\n').trim();
327
+ const text = joinedText.length > 0
328
+ ? joinedText
329
+ : entry.info.role === 'assistant' && entry.info.error?.message
330
+ ? `Error: ${entry.info.error.message}`
331
+ : '[no text output]';
332
+ return {
333
+ id: entry.info.id,
334
+ role: entry.info.role,
335
+ text,
336
+ createdAt: entry.info.time.created,
337
+ completedAt: entry.info.role === 'assistant' ? (entry.info.time.completed ?? null) : null,
338
+ };
339
+ });
340
+ return {
341
+ messages,
342
+ };
343
+ }
344
+ async sendMessage(input) {
345
+ assertOpencodeBackend(input.backendId);
346
+ await this.requestJson(`/session/${input.sessionId}/prompt_async`, {
347
+ method: 'POST',
348
+ body: {
349
+ parts: [
350
+ {
351
+ type: 'text',
352
+ text: input.request.prompt,
353
+ },
354
+ ],
355
+ },
356
+ });
357
+ this.sessionStatusById.set(input.sessionId, {
358
+ type: 'busy',
359
+ });
360
+ const messages = await this.listMessages({
361
+ backendId: OPENCODE_BACKEND_ID,
362
+ sessionId: input.sessionId,
363
+ });
364
+ return {
365
+ acceptedAt: Date.now(),
366
+ messages: messages.messages,
367
+ };
368
+ }
369
+ async stop() {
370
+ this.isStopping = true;
371
+ this.ensureReadyTask = null;
372
+ this.baseUrl = null;
373
+ this.eventStreamTask = null;
374
+ this.eventStreamAbortController?.abort();
375
+ this.eventStreamAbortController = null;
376
+ this.eventListeners.clear();
377
+ const current = this.process;
378
+ this.process = null;
379
+ if (current == null || current.killed) {
380
+ return;
381
+ }
382
+ current.kill('SIGTERM');
383
+ try {
384
+ await Promise.race([
385
+ once(current, 'exit'),
386
+ new Promise((_, reject) => {
387
+ setTimeout(() => reject(new Error('Timed out while stopping opencode.')), 2500);
388
+ }),
389
+ ]);
390
+ }
391
+ catch {
392
+ current.kill('SIGKILL');
393
+ }
394
+ }
395
+ listOpencodeModels() {
396
+ if (this.modelsCache != null) {
397
+ return this.modelsCache;
398
+ }
399
+ const result = spawnSync('opencode', ['models'], {
400
+ encoding: 'utf8',
401
+ });
402
+ if (result.error) {
403
+ throw new Error(`Failed to list opencode models: ${toErrorMessage(result.error)}`);
404
+ }
405
+ if (result.status !== 0) {
406
+ const stderr = result.stderr?.trim() ?? '';
407
+ const stdout = result.stdout?.trim() ?? '';
408
+ throw new Error(stderr || stdout || `opencode models exited with code ${result.status}`);
409
+ }
410
+ this.modelsCache = result.stdout
411
+ .split(/\r?\n/)
412
+ .map((line) => line.trim())
413
+ .filter((line) => line.length > 0);
414
+ return this.modelsCache;
415
+ }
416
+ emitEvent(event) {
417
+ for (const listener of this.eventListeners) {
418
+ listener(event);
419
+ }
420
+ }
421
+ ensureEventStream() {
422
+ if (this.eventStreamTask != null) {
423
+ return this.eventStreamTask;
424
+ }
425
+ this.eventStreamTask = this.runEventStream().finally(() => {
426
+ this.eventStreamTask = null;
427
+ });
428
+ return this.eventStreamTask;
429
+ }
430
+ async runEventStream() {
431
+ while (!this.isStopping && this.eventListeners.size > 0) {
432
+ try {
433
+ await this.ensureReady();
434
+ if (this.baseUrl == null) {
435
+ throw new Error('opencode server URL is not available.');
436
+ }
437
+ const controller = new AbortController();
438
+ this.eventStreamAbortController = controller;
439
+ const requestUrl = new URL('/global/event', this.baseUrl);
440
+ const response = await fetch(requestUrl, {
441
+ method: 'GET',
442
+ signal: controller.signal,
443
+ });
444
+ if (!response.ok) {
445
+ const detail = (await response.text()).trim();
446
+ throw new Error(`opencode event stream failed (${response.status})${detail.length > 0 ? `: ${detail}` : ''}`);
447
+ }
448
+ if (response.body == null) {
449
+ throw new Error('opencode event stream body is empty.');
450
+ }
451
+ this.emitEvent({
452
+ kind: 'connected',
453
+ });
454
+ this.lastFailureReason = null;
455
+ const reader = response.body.getReader();
456
+ const decoder = new TextDecoder();
457
+ let buffer = '';
458
+ while (true) {
459
+ const next = await reader.read();
460
+ if (next.done) {
461
+ break;
462
+ }
463
+ buffer += decoder.decode(next.value, { stream: true });
464
+ while (true) {
465
+ const eventBoundaryMatch = buffer.match(/\r?\n\r?\n/);
466
+ if (eventBoundaryMatch == null || eventBoundaryMatch.index == null) {
467
+ break;
468
+ }
469
+ const chunk = buffer.slice(0, eventBoundaryMatch.index);
470
+ buffer = buffer.slice(eventBoundaryMatch.index + eventBoundaryMatch[0].length);
471
+ this.handleEventChunk(chunk);
472
+ }
473
+ }
474
+ }
475
+ catch (error) {
476
+ if (this.isStopping) {
477
+ return;
478
+ }
479
+ if (!this.isAbortError(error)) {
480
+ // no-op; stream loop will reconnect automatically
481
+ }
482
+ }
483
+ finally {
484
+ this.eventStreamAbortController = null;
485
+ }
486
+ await new Promise((resolveWait) => {
487
+ setTimeout(resolveWait, EVENT_STREAM_RECONNECT_DELAY_MS);
488
+ });
489
+ }
490
+ }
491
+ handleEventChunk(chunk) {
492
+ const lines = chunk.replace(/\r/g, '').split('\n');
493
+ const data = lines
494
+ .filter((line) => line.startsWith('data:'))
495
+ .map((line) => line.slice(5).trimStart())
496
+ .join('\n')
497
+ .trim();
498
+ if (data.length === 0) {
499
+ return;
500
+ }
501
+ let parsed;
502
+ try {
503
+ parsed = JSON.parse(data);
504
+ }
505
+ catch {
506
+ return;
507
+ }
508
+ this.handleGlobalEvent(parsed);
509
+ }
510
+ handleGlobalEvent(event) {
511
+ const payloadType = event.payload?.type;
512
+ if (typeof payloadType !== 'string') {
513
+ return;
514
+ }
515
+ if (event.directory && resolve(event.directory) !== this.repositoryPath) {
516
+ return;
517
+ }
518
+ if (payloadType === 'session.status') {
519
+ if (!isObject(event.payload?.properties)) {
520
+ return;
521
+ }
522
+ const sessionId = typeof event.payload.properties.sessionID === 'string'
523
+ ? event.payload.properties.sessionID
524
+ : null;
525
+ if (sessionId == null) {
526
+ return;
527
+ }
528
+ const status = parseSessionStatus(event.payload.properties.status);
529
+ this.sessionStatusById.set(sessionId, status);
530
+ this.emitEvent({
531
+ kind: 'session-status',
532
+ backendId: OPENCODE_BACKEND_ID,
533
+ sessionId,
534
+ status,
535
+ });
536
+ return;
537
+ }
538
+ if (payloadType !== 'message.updated') {
539
+ return;
540
+ }
541
+ if (!isObject(event.payload?.properties) || !isObject(event.payload.properties.info)) {
542
+ return;
543
+ }
544
+ const sessionId = typeof event.payload.properties.info.sessionID === 'string'
545
+ ? event.payload.properties.info.sessionID
546
+ : null;
547
+ if (sessionId == null) {
548
+ return;
549
+ }
550
+ this.emitEvent({
551
+ kind: 'message-updated',
552
+ backendId: OPENCODE_BACKEND_ID,
553
+ sessionId,
554
+ });
555
+ }
556
+ isAbortError(error) {
557
+ return isObject(error) && error.name === 'AbortError';
558
+ }
559
+ async requestJson(pathname, options) {
560
+ await this.ensureReady();
561
+ if (this.baseUrl == null) {
562
+ throw new Error('opencode server URL is not available.');
563
+ }
564
+ const requestUrl = new URL(pathname, this.baseUrl);
565
+ requestUrl.searchParams.set('directory', this.repositoryPath);
566
+ const hasBody = options.body !== undefined;
567
+ const requestInit = {
568
+ method: options.method,
569
+ };
570
+ if (hasBody) {
571
+ requestInit.headers = {
572
+ 'Content-Type': 'application/json',
573
+ };
574
+ requestInit.body = JSON.stringify(options.body);
575
+ }
576
+ const response = await fetch(requestUrl, requestInit);
577
+ if (!response.ok) {
578
+ const detail = (await response.text()).trim();
579
+ throw new Error(`opencode request failed (${response.status})${detail.length > 0 ? `: ${detail}` : ''}`);
580
+ }
581
+ const text = await response.text();
582
+ if (text.trim().length === 0) {
583
+ return undefined;
584
+ }
585
+ return JSON.parse(text);
586
+ }
587
+ async ensureReady() {
588
+ if (this.baseUrl != null) {
589
+ return;
590
+ }
591
+ if (this.ensureReadyTask) {
592
+ await this.ensureReadyTask;
593
+ return;
594
+ }
595
+ this.isStopping = false;
596
+ this.ensureReadyTask = this.startServer().finally(() => {
597
+ this.ensureReadyTask = null;
598
+ });
599
+ await this.ensureReadyTask;
600
+ }
601
+ async startServer() {
602
+ const processHandle = spawn('opencode', ['serve', '--hostname', '127.0.0.1', '--port', '0'], {
603
+ stdio: ['ignore', 'pipe', 'pipe'],
604
+ cwd: this.repositoryPath,
605
+ });
606
+ this.process = processHandle;
607
+ let combinedOutput = '';
608
+ let resolved = false;
609
+ await new Promise((resolveStart, rejectStart) => {
610
+ const settleReady = (baseUrl) => {
611
+ if (resolved) {
612
+ return;
613
+ }
614
+ resolved = true;
615
+ this.baseUrl = baseUrl;
616
+ this.lastFailureReason = null;
617
+ resolveStart();
618
+ };
619
+ const failStart = (reason) => {
620
+ if (resolved) {
621
+ return;
622
+ }
623
+ resolved = true;
624
+ this.baseUrl = null;
625
+ this.lastFailureReason = reason;
626
+ rejectStart(new Error(reason));
627
+ };
628
+ const parseChunk = (chunk) => {
629
+ combinedOutput += chunk;
630
+ const match = chunk.match(SERVER_LISTENING_PATTERN) ?? combinedOutput.match(SERVER_LISTENING_PATTERN);
631
+ if (match?.[1]) {
632
+ settleReady(match[1]);
633
+ }
634
+ };
635
+ processHandle.stdout?.setEncoding('utf8');
636
+ processHandle.stderr?.setEncoding('utf8');
637
+ processHandle.stdout?.on('data', parseChunk);
638
+ processHandle.stderr?.on('data', parseChunk);
639
+ processHandle.on('error', (error) => {
640
+ failStart(`Failed to start opencode: ${toErrorMessage(error)}`);
641
+ });
642
+ processHandle.on('exit', (code, signal) => {
643
+ const exitLabel = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`;
644
+ if (!resolved) {
645
+ const suffix = combinedOutput.trim().length > 0 ? ` (${combinedOutput.trim()})` : '';
646
+ failStart(`opencode server exited before startup (${exitLabel})${suffix}`);
647
+ return;
648
+ }
649
+ this.baseUrl = null;
650
+ this.process = null;
651
+ if (!this.isStopping) {
652
+ this.lastFailureReason = `opencode server exited unexpectedly (${exitLabel}).`;
653
+ }
654
+ });
655
+ setTimeout(() => {
656
+ if (!resolved) {
657
+ processHandle.kill('SIGTERM');
658
+ const suffix = combinedOutput.trim().length > 0 ? ` (${combinedOutput.trim()})` : '';
659
+ failStart(`Timed out while waiting for opencode server startup${suffix}`);
660
+ }
661
+ }, 12_000);
662
+ });
663
+ }
664
+ }
665
+ export const createAgentService = (repositoryPath) => {
666
+ const resolvedRepositoryPath = resolve(repositoryPath);
667
+ const checkResult = spawnSync('opencode', ['--version'], {
668
+ encoding: 'utf8',
669
+ });
670
+ if (checkResult.error) {
671
+ const error = checkResult.error;
672
+ if (error.code === 'ENOENT') {
673
+ return new OpencodeUnavailableService('opencode is not available on PATH.');
674
+ }
675
+ return new OpencodeUnavailableService(`Failed to run opencode: ${toErrorMessage(error)}`);
676
+ }
677
+ if (checkResult.status !== 0) {
678
+ const stderr = checkResult.stderr?.trim() ?? '';
679
+ const stdout = checkResult.stdout?.trim() ?? '';
680
+ const reason = stderr || stdout || `opencode returned exit code ${checkResult.status}`;
681
+ return new OpencodeUnavailableService(`opencode is unavailable: ${reason}`);
682
+ }
683
+ return new OpencodeService(resolvedRepositoryPath);
684
+ };
685
+ //# sourceMappingURL=opencode.js.map