remote-codex 0.1.10 → 0.11.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.
Files changed (46) hide show
  1. package/apps/supervisor-api/dist/chunk-6M32PPHZ.js +24507 -0
  2. package/apps/supervisor-api/dist/chunk-7AA2MFXK.js +24499 -0
  3. package/apps/supervisor-api/dist/chunk-HKBFCPHH.js +24511 -0
  4. package/apps/supervisor-api/dist/index.js +12525 -28436
  5. package/apps/supervisor-api/dist/worker-index.d.ts +2 -0
  6. package/apps/supervisor-api/dist/worker-index.js +33 -0
  7. package/apps/supervisor-web/dist/assets/{highlighted-body-OFNGDK62-CyMcatlD.js → highlighted-body-OFNGDK62-p31aS0f0.js} +1 -1
  8. package/apps/supervisor-web/dist/assets/index-BiuFei_K.css +32 -0
  9. package/apps/supervisor-web/dist/assets/index-D1R9CUnx.js +2161 -0
  10. package/apps/supervisor-web/dist/assets/{xterm-DbYWMNQ0.js → xterm-D92BViLH.js} +1 -1
  11. package/apps/supervisor-web/dist/index.html +2 -2
  12. package/package.json +2 -3
  13. package/packages/agent-runtime/src/index.ts +4 -0
  14. package/packages/agent-runtime/src/management-errors.ts +11 -0
  15. package/packages/agent-runtime/src/model-pricing.ts +325 -0
  16. package/packages/agent-runtime/src/registry.ts +19 -4
  17. package/packages/agent-runtime/src/runtime-errors.ts +97 -0
  18. package/packages/agent-runtime/src/types.ts +36 -3
  19. package/packages/agent-runtime/src/unavailable-runtime.ts +169 -0
  20. package/packages/claude/src/historyItems.ts +41 -5
  21. package/packages/claude/src/runtimeAdapter.test.ts +117 -6
  22. package/packages/claude/src/runtimeAdapter.ts +421 -65
  23. package/packages/codex/src/historyItems.test.ts +137 -0
  24. package/packages/codex/src/historyItems.ts +135 -17
  25. package/packages/codex/src/hookHistory.test.ts +59 -0
  26. package/packages/codex/src/index.ts +7 -0
  27. package/packages/codex/src/local-session-store.ts +390 -0
  28. package/packages/codex/src/management/codex-management-service.ts +454 -0
  29. package/packages/codex/src/management/codexHostConfig.test.ts +88 -0
  30. package/packages/codex/src/management/codexHostConfig.ts +188 -0
  31. package/packages/codex/src/management/errors.ts +20 -0
  32. package/packages/codex/src/modelPricing.test.ts +235 -0
  33. package/packages/codex/src/modelPricing.ts +9 -0
  34. package/packages/codex/src/runtime-errors.test.ts +72 -0
  35. package/packages/codex/src/runtime-errors.ts +37 -0
  36. package/packages/codex/src/runtimeAdapter.ts +15 -0
  37. package/packages/codex/src/thread-title.ts +1 -0
  38. package/packages/opencode/src/historyItems.test.ts +504 -0
  39. package/packages/opencode/src/historyItems.ts +896 -0
  40. package/packages/opencode/src/index.ts +2 -0
  41. package/packages/opencode/src/runtimeAdapter.test.ts +1444 -0
  42. package/packages/opencode/src/runtimeAdapter.ts +1473 -0
  43. package/packages/shared/src/agent-providers.ts +56 -0
  44. package/packages/shared/src/index.ts +240 -35
  45. package/apps/supervisor-web/dist/assets/index-BlAhoIuq.js +0 -379
  46. package/apps/supervisor-web/dist/assets/index-DI0NRNgr.css +0 -32
@@ -0,0 +1,1473 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { execFile } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import net from 'node:net';
5
+ import { createRequire } from 'node:module';
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { promisify } from 'node:util';
9
+
10
+ import type {
11
+ AgentHistoryItem,
12
+ AgentModel,
13
+ AgentProviderCapabilities,
14
+ AgentRuntime,
15
+ AgentRuntimeEvent,
16
+ AgentRuntimeManagementSchema,
17
+ AgentRuntimeStatus,
18
+ AgentSessionDetail,
19
+ AgentSessionSummary,
20
+ AgentTurn,
21
+ InterruptAgentTurnInput,
22
+ ReadAgentSessionOptions,
23
+ ResumeAgentSessionInput,
24
+ StartAgentSessionInput,
25
+ StartAgentSessionResult,
26
+ StartAgentTurnInput,
27
+ } from '../../agent-runtime/src/index';
28
+ import type {
29
+ AgentBackendInstallationDto,
30
+ } from '../../shared/src/index';
31
+ import {
32
+ AgentRuntimeError,
33
+ contextWindowForModel,
34
+ } from '../../agent-runtime/src/index';
35
+ import {
36
+ openCodeMessagesToTurns,
37
+ openCodeMessageToHistoryItems,
38
+ openCodeMessagesToPlanUpdate,
39
+ } from './historyItems';
40
+
41
+ const execFileAsync = promisify(execFile);
42
+ const openCodeWaitTimeoutMs = 1_500;
43
+ const openCodePromptPollIntervalMs = 500;
44
+ const openCodePromptTimeoutMs = 120_000;
45
+
46
+ interface OpenCodeSdkModule {
47
+ createOpencode(options?: unknown): Promise<{
48
+ client: OpenCodeClient;
49
+ server: {
50
+ url: string;
51
+ close(): void;
52
+ };
53
+ }>;
54
+ }
55
+
56
+ interface OpenCodeClient {
57
+ config?: {
58
+ get(parameters?: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
59
+ providers(parameters?: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
60
+ };
61
+ v2?: {
62
+ session?: {
63
+ messages(parameters: unknown, options?: unknown): Promise<OpenCodeResult<{ items?: unknown[] } | unknown[]>>;
64
+ prompt?(parameters: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
65
+ wait?(parameters: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
66
+ };
67
+ };
68
+ model?: {
69
+ list(parameters?: unknown, options?: unknown): Promise<OpenCodeResult<unknown[]>>;
70
+ };
71
+ provider?: {
72
+ list(parameters?: unknown, options?: unknown): Promise<OpenCodeResult<unknown[]>>;
73
+ };
74
+ session: {
75
+ list(parameters?: unknown, options?: unknown): Promise<OpenCodeResult<{ items?: unknown[] } | unknown[]>>;
76
+ create(parameters?: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
77
+ status?(parameters?: unknown, options?: unknown): Promise<OpenCodeResult<Record<string, unknown>>>;
78
+ get(parameters: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
79
+ update?(parameters: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
80
+ messages(parameters: unknown, options?: unknown): Promise<OpenCodeResult<{ items?: unknown[] } | unknown[]>>;
81
+ prompt(parameters: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
82
+ wait?(parameters: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
83
+ abort(parameters: unknown, options?: unknown): Promise<OpenCodeResult<unknown>>;
84
+ };
85
+ }
86
+
87
+ type OpenCodeResult<T> =
88
+ | T
89
+ | {
90
+ data?: T;
91
+ error?: unknown;
92
+ };
93
+ type OpenCodePromptInput = {
94
+ sessionID: string;
95
+ directory?: string | null | undefined;
96
+ model?: {
97
+ providerID: string;
98
+ modelID: string;
99
+ };
100
+ agent?: string;
101
+ variant?: string;
102
+ parts?: Array<{
103
+ type: 'text';
104
+ text: string;
105
+ }>;
106
+ };
107
+
108
+ type OpenCodePermissionRule = {
109
+ permission: string;
110
+ pattern: string;
111
+ action: 'allow' | 'deny' | 'ask';
112
+ };
113
+
114
+ type OpenCodeLocationInput = {
115
+ sessionID?: string;
116
+ directory?: string | null | undefined;
117
+ workspace?: string | null | undefined;
118
+ };
119
+
120
+ export interface OpenCodeRuntimeAdapterOptions {
121
+ home: string;
122
+ command?: string;
123
+ clientInfo?: {
124
+ name: string;
125
+ title?: string;
126
+ version?: string;
127
+ };
128
+ sdk?: OpenCodeSdkModule;
129
+ }
130
+
131
+ const opencodeCapabilities: AgentProviderCapabilities = {
132
+ sessions: {
133
+ list: true,
134
+ read: true,
135
+ resume: true,
136
+ importLocal: false,
137
+ },
138
+ turns: {
139
+ start: true,
140
+ streamInput: false,
141
+ steer: false,
142
+ interrupt: true,
143
+ compact: true,
144
+ },
145
+ branching: {
146
+ fork: true,
147
+ hardRollback: false,
148
+ resumeAt: false,
149
+ rewindFiles: false,
150
+ },
151
+ controls: {
152
+ planMode: true,
153
+ permissionRequests: false,
154
+ sandboxMode: true,
155
+ performanceMode: false,
156
+ goals: false,
157
+ },
158
+ management: {
159
+ models: true,
160
+ mcpStatus: false,
161
+ skills: false,
162
+ hooks: false,
163
+ hookTrust: false,
164
+ hostConfigFiles: false,
165
+ providerSettings: false,
166
+ },
167
+ usage: {
168
+ contextWindow: true,
169
+ tokenUsage: true,
170
+ costUsd: true,
171
+ },
172
+ };
173
+
174
+ function isRecord(value: unknown): value is Record<string, unknown> {
175
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
176
+ }
177
+
178
+ function stringValue(value: unknown): string | null {
179
+ return typeof value === 'string' && value.trim() ? value : null;
180
+ }
181
+
182
+ function numberValue(value: unknown): number | null {
183
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
184
+ }
185
+
186
+ function nonNegativeNumberValue(value: unknown) {
187
+ const number = numberValue(value);
188
+ return number !== null && number >= 0 ? number : null;
189
+ }
190
+
191
+ function isoFromMs(value: unknown) {
192
+ const time = numberValue(value);
193
+ return time === null ? null : new Date(time).toISOString();
194
+ }
195
+
196
+ function unwrapResult<T>(result: OpenCodeResult<T>): T {
197
+ if (isRecord(result) && 'error' in result && result.error !== undefined) {
198
+ const error = result.error;
199
+ const message = isRecord(error) && typeof error.message === 'string'
200
+ ? error.message
201
+ : String(error);
202
+ throw new Error(message);
203
+ }
204
+ if (isRecord(result) && 'data' in result) {
205
+ return result.data as T;
206
+ }
207
+ return result as T;
208
+ }
209
+
210
+ function resultItems(result: { items?: unknown[] } | unknown[]) {
211
+ return Array.isArray(result) ? result : result.items ?? [];
212
+ }
213
+
214
+ function modelKey(providerID: string, modelID: string, variant?: string | null) {
215
+ return variant ? `${providerID}/${modelID}@${variant}` : `${providerID}/${modelID}`;
216
+ }
217
+
218
+ function providerModelKey(providerID: string, modelID: string) {
219
+ return `${providerID}/${modelID}`;
220
+ }
221
+
222
+ function parseModelKey(value: string | null | undefined) {
223
+ const [providerAndModel = '', variant] = (value ?? '').split('@', 2);
224
+ const [providerID, modelID] = providerAndModel.split('/', 2);
225
+ if (!providerID || !modelID) {
226
+ return null;
227
+ }
228
+ return {
229
+ providerID,
230
+ modelID,
231
+ ...(variant ? { variant } : {}),
232
+ };
233
+ }
234
+
235
+ function mapModel(record: unknown, index: number): AgentModel | null {
236
+ if (!isRecord(record)) {
237
+ return null;
238
+ }
239
+ const providerID = stringValue(record.providerID);
240
+ const id = stringValue(record.id);
241
+ if (!providerID || !id) {
242
+ return null;
243
+ }
244
+ const name = stringValue(record.name) ?? id;
245
+ const variants = Array.isArray(record.variants) ? record.variants : [];
246
+ const defaultVariant = isRecord(record.options) ? stringValue(record.options.variant) : null;
247
+ const variantIds = variants
248
+ .map((variant) => (isRecord(variant) ? stringValue(variant.id) : null))
249
+ .filter((variant): variant is string => Boolean(variant));
250
+ const firstVariant = defaultVariant ?? variantIds[0] ?? null;
251
+ const model = modelKey(providerID, id, firstVariant);
252
+ const enabled = record.enabled !== false;
253
+ return {
254
+ id: model,
255
+ model,
256
+ displayName: `${name} (${providerID})`,
257
+ description: `OpenCode ${providerID}/${id}${firstVariant ? ` variant ${firstVariant}` : ''}`,
258
+ isDefault: index === 0,
259
+ hidden: !enabled,
260
+ supportedReasoningEfforts: [],
261
+ defaultReasoningEffort: null,
262
+ };
263
+ }
264
+
265
+ function configuredProviderModelRecords(config: unknown) {
266
+ const data = isRecord(config) && isRecord(config.data) ? config.data : config;
267
+ const providerConfig = isRecord(data) && isRecord(data.provider)
268
+ ? data.provider
269
+ : isRecord(data) && isRecord(data.providers)
270
+ ? data.providers
271
+ : null;
272
+ const configured = new Map<string, Record<string, unknown>>();
273
+ if (!providerConfig) {
274
+ return configured;
275
+ }
276
+ Object.entries(providerConfig).forEach(([providerID, provider]) => {
277
+ if (!isRecord(provider) || !isRecord(provider.models)) {
278
+ return;
279
+ }
280
+ Object.entries(provider.models).forEach(([modelID, model]) => {
281
+ configured.set(providerModelKey(providerID, modelID), isRecord(model) ? model : {});
282
+ });
283
+ });
284
+ return configured;
285
+ }
286
+
287
+ function mapProviderModel(
288
+ provider: unknown,
289
+ record: unknown,
290
+ index: number,
291
+ configuredRecord?: Record<string, unknown>,
292
+ ): AgentModel | null {
293
+ if (!isRecord(provider) || !isRecord(record)) {
294
+ return null;
295
+ }
296
+ const providerID = stringValue(record.providerID) ?? stringValue(provider.id);
297
+ const id = stringValue(record.id);
298
+ if (!providerID || !id) {
299
+ return null;
300
+ }
301
+ const name = stringValue(configuredRecord?.name) ?? stringValue(record.name) ?? id;
302
+ const variants = configuredRecord
303
+ ? isRecord(configuredRecord.variants)
304
+ ? Object.keys(configuredRecord.variants)
305
+ : []
306
+ : isRecord(record.variants)
307
+ ? Object.keys(record.variants)
308
+ : [];
309
+ const model = providerModelKey(providerID, id);
310
+ const providerName = stringValue(provider.name) ?? providerID;
311
+ const disabled = configuredRecord?.status === 'disabled' || record.status === 'disabled' || provider.disabled === true;
312
+ const reasoningEfforts = variants
313
+ .map((variant) => ({
314
+ reasoningEffort: variant,
315
+ description: variant === 'none'
316
+ ? 'No reasoning'
317
+ : variant === 'xhigh'
318
+ ? 'Maximum reasoning'
319
+ : `${variant[0]?.toUpperCase() ?? ''}${variant.slice(1)} reasoning`,
320
+ }));
321
+ return {
322
+ id: model,
323
+ model,
324
+ displayName: `${name} (${providerName})`,
325
+ description: variants.length > 0
326
+ ? `OpenCode ${providerID}/${id} variants ${variants.join(', ')}`
327
+ : `OpenCode ${providerID}/${id}`,
328
+ isDefault: index === 0,
329
+ hidden: disabled,
330
+ supportedReasoningEfforts: reasoningEfforts,
331
+ defaultReasoningEffort: variants.length > 0
332
+ ? variants.includes('medium')
333
+ ? 'medium'
334
+ : variants[0] ?? null
335
+ : null,
336
+ };
337
+ }
338
+
339
+ function providerModels(result: unknown, configuredModels?: Map<string, Record<string, unknown>>) {
340
+ const data = isRecord(result) && Array.isArray(result.providers)
341
+ ? result
342
+ : isRecord(result) && isRecord(result.data) && Array.isArray(result.data.providers)
343
+ ? result.data
344
+ : null;
345
+ const providers = Array.isArray(data?.providers) ? data.providers : [];
346
+ const models: AgentModel[] = [];
347
+ providers.forEach((provider: unknown) => {
348
+ if (!isRecord(provider) || !isRecord(provider.models)) {
349
+ return;
350
+ }
351
+ Object.values(provider.models).forEach((model) => {
352
+ if (!isRecord(model)) {
353
+ return;
354
+ }
355
+ const providerID = stringValue(model.providerID) ?? stringValue(provider.id);
356
+ const modelID = stringValue(model.id);
357
+ if (!providerID || !modelID) {
358
+ return;
359
+ }
360
+ const configuredRecord = configuredModels?.get(providerModelKey(providerID, modelID));
361
+ if (configuredModels && configuredModels.size > 0 && !configuredRecord) {
362
+ return;
363
+ }
364
+ const mapped = mapProviderModel(provider, model, models.length, configuredRecord);
365
+ if (mapped) {
366
+ models.push(mapped);
367
+ }
368
+ });
369
+ });
370
+ return models;
371
+ }
372
+
373
+ function promptModel(model: ReturnType<typeof parseModelKey>) {
374
+ return model
375
+ ? {
376
+ providerID: model.providerID,
377
+ modelID: model.modelID,
378
+ }
379
+ : null;
380
+ }
381
+
382
+ function promptInput(
383
+ input: StartAgentTurnInput,
384
+ directory: string | null | undefined,
385
+ model: ReturnType<typeof parseModelKey>,
386
+ ): OpenCodePromptInput {
387
+ const modelSelection = promptModel(model);
388
+ const variant = input.reasoningEffort ?? model?.variant;
389
+ const common = {
390
+ sessionID: input.providerSessionId,
391
+ directory,
392
+ ...(input.collaborationMode === 'plan' ? { agent: 'plan' } : {}),
393
+ ...(modelSelection
394
+ ? {
395
+ model: modelSelection,
396
+ ...(variant ? { variant } : {}),
397
+ }
398
+ : {}),
399
+ };
400
+
401
+ return {
402
+ ...common,
403
+ parts: [{ type: 'text', text: input.prompt }],
404
+ };
405
+ }
406
+
407
+ function locationInput(
408
+ providerSessionId?: string,
409
+ directory?: string | null | undefined,
410
+ ): OpenCodeLocationInput {
411
+ return {
412
+ ...(providerSessionId ? { sessionID: providerSessionId } : {}),
413
+ directory,
414
+ };
415
+ }
416
+
417
+ function modelContextWindowFromTokens(tokens: Record<string, unknown>) {
418
+ return nonNegativeNumberValue(
419
+ tokens.contextWindow ??
420
+ tokens.context_window ??
421
+ tokens.contextWindowTokens ??
422
+ tokens.context_window_tokens ??
423
+ tokens.modelContextWindow ??
424
+ tokens.model_context_window,
425
+ );
426
+ }
427
+
428
+ function openCodeUsageFromTokens(tokens: unknown) {
429
+ if (!isRecord(tokens)) {
430
+ return null;
431
+ }
432
+
433
+ const inputTokens = nonNegativeNumberValue(tokens.input ?? tokens.inputTokens ?? tokens.input_tokens) ?? 0;
434
+ const outputTokens = nonNegativeNumberValue(tokens.output ?? tokens.outputTokens ?? tokens.output_tokens) ?? 0;
435
+ const reasoningOutputTokens = nonNegativeNumberValue(
436
+ tokens.reasoning ?? tokens.reasoningOutputTokens ?? tokens.reasoning_output_tokens,
437
+ ) ?? 0;
438
+ const cache = isRecord(tokens.cache) ? tokens.cache : null;
439
+ const cachedInputTokens = nonNegativeNumberValue(
440
+ tokens.cachedInputTokens ?? tokens.cached_input_tokens ?? cache?.read,
441
+ ) ?? 0;
442
+ const totalTokens = nonNegativeNumberValue(tokens.total ?? tokens.totalTokens ?? tokens.total_tokens)
443
+ ?? inputTokens + outputTokens;
444
+
445
+ if (totalTokens <= 0) {
446
+ return null;
447
+ }
448
+
449
+ return {
450
+ usage: {
451
+ totalTokens,
452
+ inputTokens,
453
+ cachedInputTokens,
454
+ outputTokens,
455
+ reasoningOutputTokens,
456
+ },
457
+ modelContextWindow: modelContextWindowFromTokens(tokens),
458
+ };
459
+ }
460
+
461
+ function messageTokenUsage(message: unknown) {
462
+ if (!isRecord(message)) {
463
+ return null;
464
+ }
465
+
466
+ const legacyMessage = isRecord(message.info) && Array.isArray(message.parts)
467
+ ? {
468
+ type: stringValue(message.info.role) ?? stringValue(message.info.type),
469
+ content: message.parts,
470
+ tokens: message.info.tokens,
471
+ }
472
+ : null;
473
+ if (legacyMessage) {
474
+ return messageTokenUsage(legacyMessage);
475
+ }
476
+
477
+ const directTokens = openCodeUsageFromTokens(message.tokens);
478
+ if (directTokens) {
479
+ return directTokens;
480
+ }
481
+
482
+ const parts = Array.isArray(message.parts)
483
+ ? message.parts
484
+ : Array.isArray(message.content)
485
+ ? message.content
486
+ : [];
487
+ for (const part of parts) {
488
+ if (!isRecord(part)) {
489
+ continue;
490
+ }
491
+ const partType = stringValue(part.type);
492
+ if (partType !== 'step-finish') {
493
+ continue;
494
+ }
495
+ const usage = openCodeUsageFromTokens(part.tokens);
496
+ if (usage) {
497
+ return usage;
498
+ }
499
+ }
500
+ return null;
501
+ }
502
+
503
+ function modelContextWindowFromModel(model: ReturnType<typeof parseModelKey>) {
504
+ if (!model) {
505
+ return null;
506
+ }
507
+ return contextWindowForModel(providerModelKey(model.providerID, model.modelID))
508
+ ?? contextWindowForModel(model.modelID);
509
+ }
510
+
511
+ function turnTokenUsage(messages: unknown[], model: ReturnType<typeof parseModelKey>) {
512
+ const usageRecords = messages
513
+ .map(messageTokenUsage)
514
+ .filter((usage): usage is NonNullable<ReturnType<typeof messageTokenUsage>> => Boolean(usage));
515
+ if (usageRecords.length === 0) {
516
+ return null;
517
+ }
518
+
519
+ const [firstRecord, ...remainingRecords] = usageRecords.map((record) => record.usage);
520
+ if (!firstRecord) {
521
+ return null;
522
+ }
523
+ const total = remainingRecords.reduce((sum, usage) => ({
524
+ totalTokens: sum.totalTokens + usage.totalTokens,
525
+ inputTokens: sum.inputTokens + usage.inputTokens,
526
+ cachedInputTokens: sum.cachedInputTokens + usage.cachedInputTokens,
527
+ outputTokens: sum.outputTokens + usage.outputTokens,
528
+ reasoningOutputTokens: sum.reasoningOutputTokens + usage.reasoningOutputTokens,
529
+ }), firstRecord);
530
+ const modelContextWindow = usageRecords.find((record) => record.modelContextWindow)?.modelContextWindow
531
+ ?? modelContextWindowFromModel(model);
532
+
533
+ return {
534
+ total,
535
+ last: total,
536
+ modelContextWindow,
537
+ cumulative: false,
538
+ };
539
+ }
540
+
541
+ function openCodeStatusType(value: unknown): 'idle' | 'busy' | 'retry' | null {
542
+ const type =
543
+ typeof value === 'string'
544
+ ? value
545
+ : isRecord(value)
546
+ ? stringValue(value.type) ??
547
+ stringValue(value.status) ??
548
+ (isRecord(value.status) ? stringValue(value.status.type) : null)
549
+ : null;
550
+ return type === 'idle' || type === 'busy' || type === 'retry' ? type : null;
551
+ }
552
+
553
+ function permissionRule(
554
+ permission: string,
555
+ action: OpenCodePermissionRule['action'],
556
+ pattern = '*',
557
+ ): OpenCodePermissionRule {
558
+ return { permission, pattern, action };
559
+ }
560
+
561
+ function openCodePermissionsForSandboxMode(
562
+ sandboxMode: StartAgentTurnInput['sandboxMode'],
563
+ ): OpenCodePermissionRule[] | undefined {
564
+ switch (sandboxMode) {
565
+ case 'read-only':
566
+ return [
567
+ permissionRule('read', 'allow'),
568
+ permissionRule('list', 'allow'),
569
+ permissionRule('glob', 'allow'),
570
+ permissionRule('grep', 'allow'),
571
+ permissionRule('edit', 'deny'),
572
+ permissionRule('bash', 'deny'),
573
+ permissionRule('task', 'deny'),
574
+ permissionRule('external_directory', 'deny'),
575
+ permissionRule('repo_clone', 'deny'),
576
+ permissionRule('repo_overview', 'allow'),
577
+ permissionRule('webfetch', 'allow'),
578
+ permissionRule('websearch', 'allow'),
579
+ permissionRule('todowrite', 'allow'),
580
+ permissionRule('question', 'allow'),
581
+ permissionRule('skill', 'allow'),
582
+ permissionRule('lsp', 'allow'),
583
+ permissionRule('doom_loop', 'deny'),
584
+ ];
585
+ case 'workspace-write':
586
+ return [
587
+ permissionRule('read', 'allow'),
588
+ permissionRule('list', 'allow'),
589
+ permissionRule('glob', 'allow'),
590
+ permissionRule('grep', 'allow'),
591
+ permissionRule('edit', 'allow'),
592
+ permissionRule('bash', 'ask'),
593
+ permissionRule('task', 'ask'),
594
+ permissionRule('external_directory', 'ask'),
595
+ permissionRule('repo_clone', 'ask'),
596
+ permissionRule('repo_overview', 'allow'),
597
+ permissionRule('webfetch', 'allow'),
598
+ permissionRule('websearch', 'allow'),
599
+ permissionRule('todowrite', 'allow'),
600
+ permissionRule('question', 'allow'),
601
+ permissionRule('skill', 'allow'),
602
+ permissionRule('lsp', 'allow'),
603
+ permissionRule('doom_loop', 'ask'),
604
+ ];
605
+ case 'danger-full-access':
606
+ return [
607
+ permissionRule('read', 'allow'),
608
+ permissionRule('list', 'allow'),
609
+ permissionRule('glob', 'allow'),
610
+ permissionRule('grep', 'allow'),
611
+ permissionRule('edit', 'allow'),
612
+ permissionRule('bash', 'allow'),
613
+ permissionRule('task', 'allow'),
614
+ permissionRule('external_directory', 'allow'),
615
+ permissionRule('repo_clone', 'allow'),
616
+ permissionRule('repo_overview', 'allow'),
617
+ permissionRule('webfetch', 'allow'),
618
+ permissionRule('websearch', 'allow'),
619
+ permissionRule('todowrite', 'allow'),
620
+ permissionRule('question', 'allow'),
621
+ permissionRule('skill', 'allow'),
622
+ permissionRule('lsp', 'allow'),
623
+ permissionRule('doom_loop', 'allow'),
624
+ ];
625
+ default:
626
+ return undefined;
627
+ }
628
+ }
629
+
630
+ function hasMeaningfulTurnResult(turn: AgentTurn | null) {
631
+ return Boolean(turn?.error) || Boolean(turn?.items.some(isTerminalRuntimeItem));
632
+ }
633
+
634
+ function liveHistoryItemsForTurn(turn: AgentTurn | null): AgentHistoryItem[] {
635
+ if (!turn) {
636
+ return [];
637
+ }
638
+ return turn.items.filter(isLiveRuntimeItem);
639
+ }
640
+
641
+ function isLiveRuntimeItem(item: AgentHistoryItem) {
642
+ return (
643
+ item.kind !== 'userMessage' &&
644
+ item.kind !== 'other' &&
645
+ item.kind !== 'contextCompaction'
646
+ );
647
+ }
648
+
649
+ function isTerminalRuntimeItem(item: AgentHistoryItem) {
650
+ return item.kind === 'agentMessage' || item.kind === 'plan' || item.status === 'failed';
651
+ }
652
+
653
+ function turnWithPlanItemForCollaborationMode(
654
+ turn: AgentTurn,
655
+ collaborationMode: StartAgentTurnInput['collaborationMode'],
656
+ ): AgentTurn {
657
+ if (collaborationMode !== 'plan' || turn.items.some((item) => item.kind === 'plan')) {
658
+ return turn;
659
+ }
660
+
661
+ const lastAgentMessageIndex = turn.items.findLastIndex((item) => item.kind === 'agentMessage');
662
+ if (lastAgentMessageIndex < 0) {
663
+ return turn;
664
+ }
665
+
666
+ return {
667
+ ...turn,
668
+ items: turn.items.map((item, index) => (
669
+ index === lastAgentMessageIndex
670
+ ? {
671
+ ...item,
672
+ kind: 'plan' as const,
673
+ previewText: item.previewText ?? 'Plan ready for review.',
674
+ }
675
+ : item
676
+ )),
677
+ };
678
+ }
679
+
680
+ function messageId(message: unknown) {
681
+ if (!isRecord(message)) {
682
+ return null;
683
+ }
684
+ return stringValue(message.id) ?? (isRecord(message.info) ? stringValue(message.info.id) : null);
685
+ }
686
+
687
+ function sessionSummary(record: unknown): AgentSessionSummary | null {
688
+ if (!isRecord(record)) {
689
+ return null;
690
+ }
691
+ const id = stringValue(record.id);
692
+ if (!id) {
693
+ return null;
694
+ }
695
+ const time = isRecord(record.time) ? record.time : {};
696
+ const model = isRecord(record.model)
697
+ ? modelKey(
698
+ stringValue(record.model.providerID) ?? 'unknown',
699
+ stringValue(record.model.id) ?? 'unknown',
700
+ stringValue(record.model.variant),
701
+ )
702
+ : null;
703
+ return {
704
+ provider: 'opencode',
705
+ providerSessionId: id,
706
+ cwd: stringValue(record.directory) ?? stringValue(record.path) ?? '',
707
+ title: stringValue(record.title),
708
+ preview: model,
709
+ createdAt: isoFromMs(time.created),
710
+ updatedAt: isoFromMs(time.updated),
711
+ status: 'idle',
712
+ rawSession: record,
713
+ };
714
+ }
715
+
716
+ function errorMessage(error: unknown) {
717
+ return error instanceof Error ? error.message : String(error);
718
+ }
719
+
720
+ function sleep(ms: number) {
721
+ return new Promise((resolve) => {
722
+ setTimeout(resolve, ms);
723
+ });
724
+ }
725
+
726
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
727
+ let timeout: NodeJS.Timeout | null = null;
728
+ try {
729
+ return await Promise.race([
730
+ promise,
731
+ new Promise<T>((_, reject) => {
732
+ timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
733
+ }),
734
+ ]);
735
+ } finally {
736
+ if (timeout) {
737
+ clearTimeout(timeout);
738
+ }
739
+ }
740
+ }
741
+
742
+ async function availablePort() {
743
+ return new Promise<number>((resolve, reject) => {
744
+ const server = net.createServer();
745
+ server.unref();
746
+ server.on('error', reject);
747
+ server.listen(0, '127.0.0.1', () => {
748
+ const address = server.address();
749
+ server.close(() => {
750
+ if (address && typeof address === 'object') {
751
+ resolve(address.port);
752
+ } else {
753
+ reject(new Error('Unable to allocate a local OpenCode server port.'));
754
+ }
755
+ });
756
+ });
757
+ });
758
+ }
759
+
760
+ export class OpenCodeRuntimeAdapter extends EventEmitter implements AgentRuntime {
761
+ readonly provider = 'opencode' as const;
762
+ readonly displayName = 'OpenCode';
763
+ readonly description = 'Local OpenCode runtime.';
764
+ readonly capabilities = opencodeCapabilities;
765
+ readonly installation: AgentBackendInstallationDto = {
766
+ packageName: 'opencode-ai',
767
+ installed: false,
768
+ installedVersion: null,
769
+ latestVersion: null,
770
+ installCommand: 'npm install -g opencode-ai @opencode-ai/sdk',
771
+ updateCommand: 'npm install -g opencode-ai@latest @opencode-ai/sdk@latest',
772
+ busy: false,
773
+ lastError: null,
774
+ };
775
+ readonly managementSchema: AgentRuntimeManagementSchema = {
776
+ hostConfigFiles: [],
777
+ toolboxItems: [
778
+ { action: 'compact', command: '/compact', label: 'Compact' },
779
+ { action: 'fork', command: '/fork', label: 'Fork', panel: 'fork' },
780
+ { action: 'mcp', command: '/mcp', label: 'MCP', panel: 'mcp' },
781
+ ],
782
+ hookCommandTemplates: [],
783
+ providerConfigFormat: 'json',
784
+ mcpConfigFormat: 'none',
785
+ configArchives: false,
786
+ buildRestart: false,
787
+ };
788
+
789
+ private status: AgentRuntimeStatus = {
790
+ state: 'stopped',
791
+ transport: 'sdk',
792
+ lastStartedAt: null,
793
+ lastError: null,
794
+ restartCount: 0,
795
+ };
796
+ private client: OpenCodeClient | null = null;
797
+ private server: { url: string; close(): void } | null = null;
798
+ private readonly sessionCwds = new Map<string, string>();
799
+ private readonly sessionModels = new Map<string, string | null>();
800
+ private readonly sessionSandboxModes = new Map<string, StartAgentTurnInput['sandboxMode']>();
801
+ private readonly activeTurns = new Map<string, {
802
+ providerSessionId: string;
803
+ aborted: boolean;
804
+ completedItemIds: Set<string>;
805
+ runningItemIds: Set<string>;
806
+ lastPlanSignature: string | null;
807
+ }>();
808
+
809
+ constructor(private readonly options: OpenCodeRuntimeAdapterOptions) {
810
+ super();
811
+ }
812
+
813
+ getStatus(): AgentRuntimeStatus {
814
+ return { ...this.status };
815
+ }
816
+
817
+ async start() {
818
+ try {
819
+ await fs.mkdir(this.options.home, { recursive: true });
820
+ const sdk = this.options.sdk ?? await this.loadSdk();
821
+ const port = this.options.sdk ? undefined : await availablePort();
822
+ const instance = await sdk.createOpencode({
823
+ hostname: '127.0.0.1',
824
+ ...(port ? { port } : {}),
825
+ });
826
+ this.installation.installed = true;
827
+ this.installation.lastError = null;
828
+ this.client = instance.client;
829
+ this.server = instance.server;
830
+ this.status = {
831
+ ...this.status,
832
+ state: 'ready',
833
+ lastStartedAt: new Date().toISOString(),
834
+ lastError: null,
835
+ restartCount: this.status.state === 'stopped' ? this.status.restartCount : this.status.restartCount + 1,
836
+ };
837
+ this.emit('status', this.getStatus());
838
+ } catch (error) {
839
+ this.client = null;
840
+ this.server = null;
841
+ this.installation.installed = false;
842
+ this.installation.lastError = errorMessage(error);
843
+ this.status = {
844
+ ...this.status,
845
+ state: 'stopped',
846
+ lastError: `OpenCode is not installed or could not start. ${errorMessage(error)}`,
847
+ };
848
+ this.emit('status', this.getStatus());
849
+ }
850
+ }
851
+
852
+ async stop() {
853
+ this.server?.close();
854
+ this.server = null;
855
+ this.client = null;
856
+ this.sessionSandboxModes.clear();
857
+ this.activeTurns.clear();
858
+ this.status = {
859
+ ...this.status,
860
+ state: 'stopped',
861
+ lastError: null,
862
+ };
863
+ this.emit('status', this.getStatus());
864
+ }
865
+
866
+ async listModels(): Promise<AgentModel[]> {
867
+ const client = await this.requireClient();
868
+ if (client.config?.providers) {
869
+ const configuredModels = client.config.get
870
+ ? configuredProviderModelRecords(unwrapResult(await client.config.get()))
871
+ : undefined;
872
+ const providers = providerModels(
873
+ unwrapResult(await client.config.providers()),
874
+ configuredModels,
875
+ );
876
+ if (providers.length > 0) {
877
+ return providers.map((model, index) => ({ ...model, isDefault: index === 0 }));
878
+ }
879
+ }
880
+ const result = client.model?.list
881
+ ? unwrapResult(await client.model.list({ query: { location: {} } }))
882
+ : [];
883
+ return result
884
+ .map(mapModel)
885
+ .filter((model): model is AgentModel => Boolean(model))
886
+ .map((model, index) => ({ ...model, isDefault: index === 0 }));
887
+ }
888
+
889
+ async listSessions(): Promise<AgentSessionSummary[]> {
890
+ const client = await this.requireClient();
891
+ const sessions = resultItems(unwrapResult(await client.session.list({ limit: 100 })));
892
+ return sessions
893
+ .map(sessionSummary)
894
+ .filter((session): session is AgentSessionSummary => Boolean(session));
895
+ }
896
+
897
+ async listLoadedSessions(): Promise<string[]> {
898
+ return (await this.listSessions()).map((session) => session.providerSessionId);
899
+ }
900
+
901
+ async readSession(providerSessionId: string, options: ReadAgentSessionOptions = {}): Promise<AgentSessionDetail> {
902
+ const client = await this.requireClient();
903
+ const directory = options.workspacePath ?? this.sessionCwds.get(providerSessionId);
904
+ const [sessionRecord, messages] = await Promise.all([
905
+ client.session.get(locationInput(providerSessionId, directory)),
906
+ this.readSessionMessages(client, providerSessionId, directory),
907
+ ]);
908
+ const summary = sessionSummary(unwrapResult(sessionRecord)) ?? {
909
+ provider: 'opencode' as const,
910
+ providerSessionId,
911
+ cwd: options.workspacePath ?? this.sessionCwds.get(providerSessionId) ?? '',
912
+ title: null,
913
+ preview: null,
914
+ createdAt: null,
915
+ updatedAt: null,
916
+ status: 'idle' as const,
917
+ rawSession: null,
918
+ };
919
+ return {
920
+ ...summary,
921
+ turns: openCodeMessagesToTurns(messages, {
922
+ workspacePath: directory ?? summary.cwd,
923
+ }),
924
+ };
925
+ }
926
+
927
+ async startSession(input: StartAgentSessionInput): Promise<StartAgentSessionResult> {
928
+ const client = await this.requireClient();
929
+ const model = parseModelKey(input.model);
930
+ const permission = openCodePermissionsForSandboxMode(input.sandboxMode);
931
+ const session = unwrapResult(await client.session.create({
932
+ ...locationInput(undefined, input.cwd),
933
+ ...(model
934
+ ? {
935
+ model: {
936
+ id: model.modelID,
937
+ providerID: model.providerID,
938
+ ...(model.variant ? { variant: model.variant } : {}),
939
+ },
940
+ }
941
+ : {}),
942
+ ...(permission ? { permission } : {}),
943
+ }));
944
+ const summary = sessionSummary(session);
945
+ if (!summary) {
946
+ throw new AgentRuntimeError('OpenCode did not return a session id.', 'opencode', 'invalid_response');
947
+ }
948
+ this.sessionCwds.set(summary.providerSessionId, input.cwd);
949
+ this.sessionModels.set(summary.providerSessionId, input.model);
950
+ if (input.sandboxMode !== undefined) {
951
+ this.sessionSandboxModes.set(summary.providerSessionId, input.sandboxMode);
952
+ }
953
+ return {
954
+ provider: 'opencode',
955
+ providerSessionId: summary.providerSessionId,
956
+ model: input.model,
957
+ reasoningEffort: null,
958
+ sandboxMode: input.sandboxMode ?? null,
959
+ session: {
960
+ ...summary,
961
+ cwd: summary.cwd || input.cwd,
962
+ turns: [],
963
+ },
964
+ rawSession: session,
965
+ };
966
+ }
967
+
968
+ async resumeSession(input: ResumeAgentSessionInput): Promise<StartAgentSessionResult> {
969
+ const session = await this.readSession(input.providerSessionId);
970
+ if (input.model !== undefined) {
971
+ this.sessionModels.set(input.providerSessionId, input.model);
972
+ }
973
+ if (input.sandboxMode !== undefined) {
974
+ this.sessionSandboxModes.set(input.providerSessionId, input.sandboxMode);
975
+ }
976
+ return {
977
+ provider: 'opencode',
978
+ providerSessionId: input.providerSessionId,
979
+ model: input.model ?? this.sessionModels.get(input.providerSessionId) ?? null,
980
+ reasoningEffort: null,
981
+ sandboxMode: input.sandboxMode ?? null,
982
+ session,
983
+ rawSession: session.rawSession,
984
+ };
985
+ }
986
+
987
+ async startTurn(input: StartAgentTurnInput): Promise<AgentTurn> {
988
+ const client = await this.requireClient();
989
+ const providerTurnId = input.displayTurnId ?? crypto.randomUUID();
990
+ const startedAt = new Date().toISOString();
991
+ const model = parseModelKey(input.model ?? this.sessionModels.get(input.providerSessionId));
992
+ this.activeTurns.set(providerTurnId, {
993
+ providerSessionId: input.providerSessionId,
994
+ aborted: false,
995
+ completedItemIds: new Set(),
996
+ runningItemIds: new Set(),
997
+ lastPlanSignature: null,
998
+ });
999
+ const initialItems = input.hidden
1000
+ ? []
1001
+ : [{
1002
+ id: `${providerTurnId}:user`,
1003
+ kind: 'userMessage' as const,
1004
+ text: input.displayPrompt ?? input.prompt,
1005
+ }];
1006
+ const startedTurn: AgentTurn = {
1007
+ providerTurnId,
1008
+ startedAt,
1009
+ status: 'inProgress',
1010
+ error: null,
1011
+ items: initialItems,
1012
+ };
1013
+ this.emitRuntimeEvent({
1014
+ type: 'turn.started',
1015
+ provider: 'opencode',
1016
+ providerSessionId: input.providerSessionId,
1017
+ turn: startedTurn,
1018
+ });
1019
+ void this.runPrompt(client, input, providerTurnId, startedAt, model);
1020
+ return startedTurn;
1021
+ }
1022
+
1023
+ async interruptTurn(input: InterruptAgentTurnInput): Promise<AgentTurn | null> {
1024
+ const client = await this.requireClient();
1025
+ const active = this.activeTurns.get(input.providerTurnId);
1026
+ if (!active) {
1027
+ return null;
1028
+ }
1029
+ active.aborted = true;
1030
+ await client.session.abort(locationInput(input.providerSessionId, this.sessionCwds.get(input.providerSessionId)));
1031
+ this.activeTurns.delete(input.providerTurnId);
1032
+ return {
1033
+ providerTurnId: input.providerTurnId,
1034
+ status: 'interrupted',
1035
+ error: null,
1036
+ items: [],
1037
+ };
1038
+ }
1039
+
1040
+ async compactSession(providerSessionId: string) {
1041
+ const client = await this.requireClient();
1042
+ if ('compact' in client.session && typeof client.session.compact === 'function') {
1043
+ await (client.session.compact as (input: unknown) => Promise<unknown>)({
1044
+ ...locationInput(providerSessionId, this.sessionCwds.get(providerSessionId)),
1045
+ });
1046
+ }
1047
+ }
1048
+
1049
+ private async runPrompt(
1050
+ client: OpenCodeClient,
1051
+ input: StartAgentTurnInput,
1052
+ providerTurnId: string,
1053
+ startedAt: string,
1054
+ model: ReturnType<typeof parseModelKey>,
1055
+ ) {
1056
+ try {
1057
+ const directory = input.workspacePath ?? this.sessionCwds.get(input.providerSessionId);
1058
+ await this.updateSessionSandboxMode(client, input.providerSessionId, directory, input.sandboxMode);
1059
+ const baselineMessages = await this.readSessionMessages(client, input.providerSessionId, directory);
1060
+ const baselineMessageIds = new Set(baselineMessages.map(messageId).filter((id): id is string => Boolean(id)));
1061
+ let promptResponse: unknown = null;
1062
+ let promptError: unknown = null;
1063
+ const promptPromise = client.session.prompt(promptInput(input, directory, model))
1064
+ .then((response) => {
1065
+ promptResponse = unwrapResult(response);
1066
+ })
1067
+ .catch((error) => {
1068
+ promptError = error;
1069
+ });
1070
+ void promptPromise;
1071
+ this.emitRuntimeEvent({
1072
+ type: 'session.status.changed',
1073
+ provider: 'opencode',
1074
+ providerSessionId: input.providerSessionId,
1075
+ status: 'running',
1076
+ });
1077
+ await this.waitForPrompt(client, input.providerSessionId, directory);
1078
+ const active = this.activeTurns.get(providerTurnId);
1079
+ if (active?.aborted) {
1080
+ return;
1081
+ }
1082
+ const readOptions: ReadAgentSessionOptions = {};
1083
+ const workspacePath = input.workspacePath ?? undefined;
1084
+ if (workspacePath) {
1085
+ readOptions.workspacePath = workspacePath;
1086
+ }
1087
+ const turn = await this.waitForTurnResult(
1088
+ input.providerSessionId,
1089
+ readOptions,
1090
+ providerTurnId,
1091
+ baselineMessageIds,
1092
+ model,
1093
+ promptPromise,
1094
+ () => promptResponse,
1095
+ () => promptError,
1096
+ );
1097
+ const completedTurn = turnWithPlanItemForCollaborationMode(turn ?? {
1098
+ providerTurnId,
1099
+ startedAt,
1100
+ status: 'completed' as const,
1101
+ error: promptError ? { message: errorMessage(promptError) } : null,
1102
+ items: openCodeMessageToHistoryItems(
1103
+ promptResponse,
1104
+ directory ? { workspacePath: directory } : {},
1105
+ ),
1106
+ }, input.collaborationMode);
1107
+ this.activeTurns.delete(providerTurnId);
1108
+ this.emitRuntimeEvent({
1109
+ type: 'turn.completed',
1110
+ provider: 'opencode',
1111
+ providerSessionId: input.providerSessionId,
1112
+ turn: {
1113
+ ...completedTurn,
1114
+ providerTurnId,
1115
+ startedAt: completedTurn.startedAt ?? startedAt,
1116
+ },
1117
+ });
1118
+ } catch (error) {
1119
+ this.activeTurns.delete(providerTurnId);
1120
+ this.emitRuntimeEvent({
1121
+ type: 'turn.failed',
1122
+ provider: 'opencode',
1123
+ providerSessionId: input.providerSessionId,
1124
+ providerTurnId,
1125
+ error: errorMessage(error),
1126
+ });
1127
+ }
1128
+ }
1129
+
1130
+ private async waitForTurnResult(
1131
+ providerSessionId: string,
1132
+ readOptions: ReadAgentSessionOptions,
1133
+ providerTurnId: string,
1134
+ baselineMessageIds: Set<string>,
1135
+ model: ReturnType<typeof parseModelKey>,
1136
+ promptPromise: Promise<void>,
1137
+ promptResponse: () => unknown,
1138
+ promptError: () => unknown,
1139
+ ) {
1140
+ const deadline = Date.now() + openCodePromptTimeoutMs;
1141
+ let promptSettled = false;
1142
+ promptPromise.finally(() => {
1143
+ promptSettled = true;
1144
+ });
1145
+
1146
+ while (Date.now() < deadline) {
1147
+ const active = this.activeTurns.get(providerTurnId);
1148
+ if (active?.aborted) {
1149
+ return null;
1150
+ }
1151
+
1152
+ const client = await this.requireClient();
1153
+ const directory = readOptions.workspacePath ?? this.sessionCwds.get(providerSessionId);
1154
+ const messages = await this.readSessionMessages(client, providerSessionId, directory);
1155
+ const newMessages = messages.filter((message) => {
1156
+ const id = messageId(message);
1157
+ return !id || !baselineMessageIds.has(id);
1158
+ });
1159
+ const turns = openCodeMessagesToTurns(
1160
+ newMessages,
1161
+ directory ? { workspacePath: directory } : {},
1162
+ );
1163
+ const turn = turns[turns.length - 1] ?? null;
1164
+ this.emitPlanUpdate(providerSessionId, providerTurnId, newMessages);
1165
+ this.emitLiveTurnItems(providerSessionId, providerTurnId, turn);
1166
+ const sessionStatus = await this.readOpenCodeSessionStatus(client, providerSessionId, directory);
1167
+ const sessionIdle = sessionStatus === 'idle';
1168
+ if (sessionIdle && hasMeaningfulTurnResult(turn)) {
1169
+ this.emitTurnUsage(providerSessionId, providerTurnId, turnTokenUsage(newMessages, model));
1170
+ return turn;
1171
+ }
1172
+
1173
+ if (promptSettled && promptError()) {
1174
+ throw promptError();
1175
+ }
1176
+ if (promptSettled) {
1177
+ const responseItems = openCodeMessageToHistoryItems(
1178
+ promptResponse(),
1179
+ directory ? { workspacePath: directory } : {},
1180
+ );
1181
+ if (responseItems.length > 0) {
1182
+ responseItems.forEach((item) => {
1183
+ if (isLiveRuntimeItem(item)) {
1184
+ this.emitRuntimeItem(providerSessionId, providerTurnId, item);
1185
+ }
1186
+ });
1187
+ if (!sessionIdle && sessionStatus !== null) {
1188
+ await sleep(openCodePromptPollIntervalMs);
1189
+ continue;
1190
+ }
1191
+ if (!responseItems.some(isTerminalRuntimeItem)) {
1192
+ await sleep(openCodePromptPollIntervalMs);
1193
+ continue;
1194
+ }
1195
+ this.emitTurnUsage(providerSessionId, providerTurnId, turnTokenUsage([promptResponse()], model));
1196
+ return {
1197
+ providerTurnId,
1198
+ startedAt: null,
1199
+ status: 'completed' as const,
1200
+ error: null,
1201
+ items: responseItems,
1202
+ };
1203
+ }
1204
+ }
1205
+ await sleep(openCodePromptPollIntervalMs);
1206
+ }
1207
+
1208
+ throw new Error('Timed out waiting for OpenCode to write a response.');
1209
+ }
1210
+
1211
+ private async readOpenCodeSessionStatus(
1212
+ client: OpenCodeClient,
1213
+ providerSessionId: string,
1214
+ directory: string | null | undefined,
1215
+ ) {
1216
+ if (!client.session.status) {
1217
+ return null;
1218
+ }
1219
+ try {
1220
+ const statusMap = unwrapResult(await client.session.status({
1221
+ query: {
1222
+ ...(directory ? { directory } : {}),
1223
+ },
1224
+ }));
1225
+ if (!isRecord(statusMap)) {
1226
+ return null;
1227
+ }
1228
+ return openCodeStatusType(statusMap[providerSessionId]);
1229
+ } catch {
1230
+ return null;
1231
+ }
1232
+ }
1233
+
1234
+ private emitTurnUsage(
1235
+ providerSessionId: string,
1236
+ providerTurnId: string,
1237
+ usage: ReturnType<typeof turnTokenUsage>,
1238
+ ) {
1239
+ if (!usage) {
1240
+ return;
1241
+ }
1242
+ this.emitRuntimeEvent({
1243
+ type: 'usage.updated',
1244
+ provider: 'opencode',
1245
+ providerSessionId,
1246
+ providerTurnId,
1247
+ usage,
1248
+ });
1249
+ }
1250
+
1251
+ private emitPlanUpdate(
1252
+ providerSessionId: string,
1253
+ providerTurnId: string,
1254
+ messages: unknown[],
1255
+ ) {
1256
+ const active = this.activeTurns.get(providerTurnId);
1257
+ if (!active) {
1258
+ return;
1259
+ }
1260
+ const planUpdate = openCodeMessagesToPlanUpdate(messages);
1261
+ if (!planUpdate) {
1262
+ return;
1263
+ }
1264
+ const signature = JSON.stringify(planUpdate);
1265
+ if (active.lastPlanSignature === signature) {
1266
+ return;
1267
+ }
1268
+ active.lastPlanSignature = signature;
1269
+ this.emitRuntimeEvent({
1270
+ type: 'plan.updated',
1271
+ provider: 'opencode',
1272
+ providerSessionId,
1273
+ providerTurnId,
1274
+ explanation: planUpdate.explanation,
1275
+ plan: planUpdate.plan,
1276
+ });
1277
+ }
1278
+
1279
+ private emitLiveTurnItems(
1280
+ providerSessionId: string,
1281
+ providerTurnId: string,
1282
+ turn: AgentTurn | null,
1283
+ ) {
1284
+ for (const item of liveHistoryItemsForTurn(turn)) {
1285
+ this.emitRuntimeItem(providerSessionId, providerTurnId, item);
1286
+ }
1287
+ }
1288
+
1289
+ private emitRuntimeItem(
1290
+ providerSessionId: string,
1291
+ providerTurnId: string,
1292
+ item: AgentHistoryItem,
1293
+ ) {
1294
+ const active = this.activeTurns.get(providerTurnId);
1295
+ if (!active || !isLiveRuntimeItem(item)) {
1296
+ return;
1297
+ }
1298
+ const isRunning = item.status === 'running';
1299
+ if (isRunning) {
1300
+ if (active.completedItemIds.has(item.id) || active.runningItemIds.has(item.id)) {
1301
+ return;
1302
+ }
1303
+ active.runningItemIds.add(item.id);
1304
+ } else if (active.completedItemIds.has(item.id)) {
1305
+ return;
1306
+ } else {
1307
+ active.completedItemIds.add(item.id);
1308
+ }
1309
+ this.emitRuntimeEvent({
1310
+ type: isRunning ? 'item.started' : 'item.completed',
1311
+ provider: 'opencode',
1312
+ providerSessionId,
1313
+ providerTurnId,
1314
+ item,
1315
+ });
1316
+ }
1317
+
1318
+ private async updateSessionSandboxMode(
1319
+ client: OpenCodeClient,
1320
+ providerSessionId: string,
1321
+ directory: string | null | undefined,
1322
+ sandboxMode: StartAgentTurnInput['sandboxMode'],
1323
+ ) {
1324
+ const permission = openCodePermissionsForSandboxMode(sandboxMode);
1325
+ if (!permission || !client.session.update) {
1326
+ return;
1327
+ }
1328
+ if (this.sessionSandboxModes.get(providerSessionId) === sandboxMode) {
1329
+ return;
1330
+ }
1331
+ unwrapResult(await client.session.update({
1332
+ ...locationInput(providerSessionId, directory),
1333
+ permission,
1334
+ }));
1335
+ this.sessionSandboxModes.set(providerSessionId, sandboxMode);
1336
+ }
1337
+
1338
+ private async readSessionMessages(
1339
+ client: OpenCodeClient,
1340
+ providerSessionId: string,
1341
+ directory: string | null | undefined,
1342
+ ) {
1343
+ const parameters = {
1344
+ ...locationInput(providerSessionId, directory),
1345
+ };
1346
+ const legacyMessages = async () => resultItems(unwrapResult(await client.session.messages(parameters)));
1347
+ try {
1348
+ const messages = await legacyMessages();
1349
+ if (messages.length > 0 || !client.v2?.session?.messages) {
1350
+ return messages;
1351
+ }
1352
+ } catch (legacyError) {
1353
+ if (!client.v2?.session?.messages) {
1354
+ throw legacyError;
1355
+ }
1356
+ try {
1357
+ return resultItems(unwrapResult(await client.v2.session.messages(parameters)));
1358
+ } catch (v2Error) {
1359
+ throw new Error(
1360
+ `OpenCode session messages failed. legacy: ${errorMessage(legacyError)}; v2: ${errorMessage(v2Error)}`,
1361
+ );
1362
+ }
1363
+ }
1364
+ return resultItems(unwrapResult(await client.v2!.session!.messages(parameters)));
1365
+ }
1366
+
1367
+ private async waitForPrompt(
1368
+ client: OpenCodeClient,
1369
+ providerSessionId: string,
1370
+ directory: string | null | undefined,
1371
+ ) {
1372
+ const parameters = {
1373
+ ...locationInput(providerSessionId, directory),
1374
+ };
1375
+ if (client.v2?.session?.wait) {
1376
+ try {
1377
+ unwrapResult(await withTimeout(client.v2.session.wait(parameters), openCodeWaitTimeoutMs));
1378
+ return;
1379
+ } catch {
1380
+ // OpenCode's v2 session surface is still incomplete in some releases.
1381
+ }
1382
+ }
1383
+ if (client.session.wait) {
1384
+ try {
1385
+ unwrapResult(await withTimeout(client.session.wait(parameters), openCodeWaitTimeoutMs));
1386
+ } catch {
1387
+ // Prompt polling below is the source of truth; OpenCode wait can outlive useful response writes.
1388
+ }
1389
+ }
1390
+ }
1391
+
1392
+ private async requireClient() {
1393
+ if (!this.client) {
1394
+ await this.start();
1395
+ }
1396
+ if (!this.client) {
1397
+ throw new AgentRuntimeError(
1398
+ this.status.lastError ?? 'OpenCode is unavailable.',
1399
+ 'opencode',
1400
+ 'provider_unavailable',
1401
+ );
1402
+ }
1403
+ return this.client;
1404
+ }
1405
+
1406
+ private async loadSdk(): Promise<OpenCodeSdkModule> {
1407
+ try {
1408
+ return await importOptionalPackage('@opencode-ai/sdk/v2') as OpenCodeSdkModule;
1409
+ } catch (error) {
1410
+ throw new AgentRuntimeError(
1411
+ 'Install OpenCode support with npm install -g opencode-ai and npm install -g @opencode-ai/sdk, or add @opencode-ai/sdk to this checkout.',
1412
+ 'opencode',
1413
+ 'provider_unavailable',
1414
+ undefined,
1415
+ error,
1416
+ );
1417
+ }
1418
+ }
1419
+
1420
+ private emitRuntimeEvent(event: AgentRuntimeEvent) {
1421
+ this.emit('event', event);
1422
+ }
1423
+ }
1424
+
1425
+ async function importOptionalPackage(specifier: string) {
1426
+ const dynamicImport = new Function('specifier', 'return import(specifier);') as (
1427
+ specifier: string,
1428
+ ) => Promise<unknown>;
1429
+ try {
1430
+ return await dynamicImport(specifier);
1431
+ } catch (localError) {
1432
+ const globalRoot = await npmGlobalRoot();
1433
+ if (!globalRoot) {
1434
+ throw localError;
1435
+ }
1436
+ try {
1437
+ const requireFromGlobal = createRequire(path.join(globalRoot, 'remote-codex-global.cjs'));
1438
+ const resolved = resolveOptionalPackage(requireFromGlobal, globalRoot, specifier);
1439
+ return await dynamicImport(pathToFileURL(resolved).href);
1440
+ } catch {
1441
+ throw localError;
1442
+ }
1443
+ }
1444
+ }
1445
+
1446
+ function resolveOptionalPackage(
1447
+ requireFromGlobal: ReturnType<typeof createRequire>,
1448
+ globalRoot: string,
1449
+ specifier: string,
1450
+ ) {
1451
+ try {
1452
+ return requireFromGlobal.resolve(specifier);
1453
+ } catch (error) {
1454
+ if (
1455
+ (error as NodeJS.ErrnoException).code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' &&
1456
+ specifier === '@opencode-ai/sdk/v2'
1457
+ ) {
1458
+ return path.join(globalRoot, '@opencode-ai', 'sdk', 'dist', 'v2', 'index.js');
1459
+ }
1460
+ throw error;
1461
+ }
1462
+ }
1463
+
1464
+ async function npmGlobalRoot() {
1465
+ try {
1466
+ const { stdout } = await execFileAsync('npm', ['root', '-g'], {
1467
+ timeout: 3_000,
1468
+ });
1469
+ return stdout.trim() || null;
1470
+ } catch {
1471
+ return null;
1472
+ }
1473
+ }