@ai-sdk/harness-opencode 0.0.0-07500555-20260730161446

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.
@@ -0,0 +1,270 @@
1
+ export type OpenCodeEvent = {
2
+ id?: string;
3
+ type?: string;
4
+ properties?: Record<string, any>;
5
+ };
6
+
7
+ type Emit = (msg: Record<string, unknown>) => void;
8
+
9
+ export type TranslationState = {
10
+ textDeltas: Map<string, string>;
11
+ reasoningDeltas: Map<string, string>;
12
+ toolInputs: Map<string, string>;
13
+ toolNames: Map<string, { rawToolName: string; toolName: string }>;
14
+ toolCallsEmitted: Set<string>;
15
+ toolResultsEmitted: Set<string>;
16
+ hostToolCallsAuthorized: Set<string>;
17
+ shellCommands: Map<string, string>;
18
+ messageRoles: Map<string, string>;
19
+ turnUsage: Record<string, unknown> | undefined;
20
+ legacyTextPartIds: Set<string>;
21
+ legacyReasoningPartIds: Set<string>;
22
+ legacyStepFinishPartIds: Set<string>;
23
+ };
24
+
25
+ export function createTranslationState(): TranslationState {
26
+ return {
27
+ textDeltas: new Map(),
28
+ reasoningDeltas: new Map(),
29
+ toolInputs: new Map(),
30
+ toolNames: new Map(),
31
+ toolCallsEmitted: new Set(),
32
+ toolResultsEmitted: new Set(),
33
+ hostToolCallsAuthorized: new Set(),
34
+ shellCommands: new Map(),
35
+ messageRoles: new Map(),
36
+ turnUsage: undefined,
37
+ legacyTextPartIds: new Set(),
38
+ legacyReasoningPartIds: new Set(),
39
+ legacyStepFinishPartIds: new Set(),
40
+ };
41
+ }
42
+
43
+ export function unwrapOpenCodeEvent(
44
+ rawEvent: unknown,
45
+ ): OpenCodeEvent | undefined {
46
+ if (!rawEvent || typeof rawEvent !== 'object') return undefined;
47
+ const raw = rawEvent as Record<string, any>;
48
+ if (raw.type === 'sync' && raw.syncEvent) {
49
+ const sync = raw.syncEvent as Record<string, any>;
50
+ return {
51
+ id: String(sync.id ?? raw.id ?? ''),
52
+ type: stripSyncVersion(String(sync.type ?? '')),
53
+ properties: asRecord(sync.data) ?? {},
54
+ };
55
+ }
56
+ return {
57
+ id: typeof raw.id === 'string' ? raw.id : undefined,
58
+ type: typeof raw.type === 'string' ? stripSyncVersion(raw.type) : undefined,
59
+ properties: asRecord(raw.properties) ?? asRecord(raw.data) ?? {},
60
+ };
61
+ }
62
+
63
+ export function getOpenCodeEventSessionId(
64
+ event: OpenCodeEvent,
65
+ ): string | undefined {
66
+ const props = event.properties;
67
+ if (!props) return undefined;
68
+ if (typeof props.sessionID === 'string') return props.sessionID;
69
+ if (typeof props.sessionId === 'string') return props.sessionId;
70
+ if (event.type?.startsWith('session.') && typeof props.id === 'string') {
71
+ return props.id;
72
+ }
73
+ const part = props.part;
74
+ if (
75
+ part &&
76
+ typeof part === 'object' &&
77
+ !Array.isArray(part) &&
78
+ typeof (part as { sessionID?: unknown }).sessionID === 'string'
79
+ ) {
80
+ return (part as { sessionID: string }).sessionID;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ export function isStepSettlementEvent(event: OpenCodeEvent): boolean {
86
+ return (
87
+ event.type === 'session.next.step.ended' ||
88
+ event.type === 'session.next.step.failed' ||
89
+ event.type === 'session.error'
90
+ );
91
+ }
92
+
93
+ export function emitMissingFinalDelta({
94
+ id,
95
+ fullText,
96
+ emittedText,
97
+ emit,
98
+ type,
99
+ }: {
100
+ id: string;
101
+ fullText: string | undefined;
102
+ emittedText: string;
103
+ emit: (msg: Record<string, unknown>) => void;
104
+ type: 'text-delta' | 'reasoning-delta';
105
+ }): void {
106
+ if (
107
+ !fullText ||
108
+ fullText === emittedText ||
109
+ !fullText.startsWith(emittedText)
110
+ ) {
111
+ return;
112
+ }
113
+ emit({ type, id, delta: fullText.slice(emittedText.length) });
114
+ }
115
+
116
+ /**
117
+ * Translates an OpenCode `message.part.delta` event (a streaming text or
118
+ * reasoning delta) into legacy stream parts.
119
+ */
120
+ export function emitLegacyPartDelta({
121
+ props,
122
+ state,
123
+ emit,
124
+ }: {
125
+ props: Record<string, unknown>;
126
+ state: TranslationState;
127
+ emit: Emit;
128
+ }): void {
129
+ const field = String(props.field ?? '');
130
+ const delta = String(props.delta ?? '');
131
+ if (!delta) return;
132
+ const messageID = stringValue(props.messageID);
133
+ if (messageID && state.messageRoles.get(messageID) === 'user') return;
134
+ if (field === 'text') {
135
+ const id = legacyPartId({ value: props, fallback: 'legacy-text' });
136
+ // OpenCode publishes reasoning deltas with field:"text". If this id was
137
+ // already announced as a reasoning part, route it to reasoning so it is not
138
+ // duplicated as a text part.
139
+ if (state.legacyReasoningPartIds.has(id)) {
140
+ state.reasoningDeltas.set(
141
+ id,
142
+ `${state.reasoningDeltas.get(id) ?? ''}${delta}`,
143
+ );
144
+ emit({ type: 'reasoning-delta', id, delta });
145
+ return;
146
+ }
147
+ startLegacyPart({ ids: state.legacyTextPartIds, id, emit, type: 'text' });
148
+ state.textDeltas.set(id, `${state.textDeltas.get(id) ?? ''}${delta}`);
149
+ emit({ type: 'text-delta', id, delta });
150
+ return;
151
+ }
152
+ if (field === 'reasoning') {
153
+ const id = legacyPartId({ value: props, fallback: 'legacy-reasoning' });
154
+ startLegacyPart({
155
+ ids: state.legacyReasoningPartIds,
156
+ id,
157
+ emit,
158
+ type: 'reasoning',
159
+ });
160
+ state.reasoningDeltas.set(
161
+ id,
162
+ `${state.reasoningDeltas.get(id) ?? ''}${delta}`,
163
+ );
164
+ emit({ type: 'reasoning-delta', id, delta });
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Translates an OpenCode `message.part.updated` event for a text or reasoning
170
+ * part. Returns `true` when it handled the part.
171
+ */
172
+ export function emitLegacyTextPartUpdate({
173
+ part,
174
+ state,
175
+ emit,
176
+ }: {
177
+ part: unknown;
178
+ state: TranslationState;
179
+ emit: Emit;
180
+ }): boolean {
181
+ if (!isRecord(part)) return false;
182
+ if (part.type !== 'text' && part.type !== 'reasoning') return false;
183
+ const id = stringValue(part.id);
184
+ if (!id) return true;
185
+
186
+ const messageID = stringValue(part.messageID);
187
+ if (messageID && state.messageRoles.get(messageID) === 'user') return true;
188
+
189
+ const isReasoning = part.type === 'reasoning';
190
+ const ids = isReasoning
191
+ ? state.legacyReasoningPartIds
192
+ : state.legacyTextPartIds;
193
+ const deltaMap = isReasoning ? state.reasoningDeltas : state.textDeltas;
194
+ const deltaType = isReasoning ? 'reasoning-delta' : 'text-delta';
195
+ const text = typeof part.text === 'string' ? part.text : undefined;
196
+
197
+ startLegacyPart({
198
+ ids,
199
+ id,
200
+ emit,
201
+ type: isReasoning ? 'reasoning' : 'text',
202
+ });
203
+
204
+ if (text !== undefined) {
205
+ emitMissingFinalDelta({
206
+ id,
207
+ fullText: text,
208
+ emittedText: deltaMap.get(id) ?? '',
209
+ emit,
210
+ type: deltaType,
211
+ });
212
+ deltaMap.set(id, text);
213
+ }
214
+
215
+ if (legacyPartEnded(part)) {
216
+ ids.delete(id);
217
+ deltaMap.delete(id);
218
+ emit({ type: isReasoning ? 'reasoning-end' : 'text-end', id });
219
+ }
220
+
221
+ return true;
222
+ }
223
+
224
+ function stripSyncVersion(type: string): string {
225
+ return type.replace(/\.\d+$/, '');
226
+ }
227
+
228
+ function asRecord(value: unknown): Record<string, any> | undefined {
229
+ if (!value || typeof value !== 'object' || Array.isArray(value))
230
+ return undefined;
231
+ return value as Record<string, any>;
232
+ }
233
+
234
+ function isRecord(value: unknown): value is Record<string, unknown> {
235
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
236
+ }
237
+
238
+ function stringValue(value: unknown): string | undefined {
239
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
240
+ }
241
+
242
+ function legacyPartId({
243
+ value,
244
+ fallback,
245
+ }: {
246
+ value: Record<string, unknown>;
247
+ fallback: string;
248
+ }): string {
249
+ return stringValue(value.partID) ?? stringValue(value.id) ?? fallback;
250
+ }
251
+
252
+ function startLegacyPart({
253
+ ids,
254
+ id,
255
+ emit,
256
+ type,
257
+ }: {
258
+ ids: Set<string>;
259
+ id: string;
260
+ emit: Emit;
261
+ type: 'text' | 'reasoning';
262
+ }): void {
263
+ if (ids.has(id)) return;
264
+ ids.add(id);
265
+ emit({ type: `${type}-start`, id });
266
+ }
267
+
268
+ function legacyPartEnded(part: Record<string, unknown>): boolean {
269
+ return isRecord(part.time) && part.time.end != null;
270
+ }
@@ -0,0 +1,39 @@
1
+ import type { HarnessV1StreamPart } from '@ai-sdk/harness';
2
+ import { mapUsage } from './opencode-usage';
3
+
4
+ type FinishStepEvent = Extract<HarnessV1StreamPart, { type: 'finish-step' }>;
5
+
6
+ export function mapOpenCodeFinishReason(
7
+ reason: string,
8
+ ): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' {
9
+ const normalized = reason.toLowerCase();
10
+ if (normalized.includes('length')) return 'length';
11
+ if (normalized.includes('filter')) return 'content-filter';
12
+ if (normalized.includes('tool')) return 'tool-calls';
13
+ if (normalized.includes('error') || normalized.includes('fail'))
14
+ return 'error';
15
+ if (normalized === 'stop' || normalized === 'end') return 'stop';
16
+ return 'other';
17
+ }
18
+
19
+ export function legacyStepFinishPartToFinishStep(
20
+ part: unknown,
21
+ ): FinishStepEvent | undefined {
22
+ if (!isRecord(part) || part.type !== 'step-finish') return undefined;
23
+ const rawFinish = typeof part.reason === 'string' ? part.reason : 'stop';
24
+ return {
25
+ type: 'finish-step',
26
+ finishReason: {
27
+ unified: mapOpenCodeFinishReason(rawFinish),
28
+ raw: rawFinish,
29
+ },
30
+ usage: mapUsage(part.tokens),
31
+ ...(typeof part.cost === 'number'
32
+ ? { harnessMetadata: { opencode: { cost: part.cost } } }
33
+ : {}),
34
+ };
35
+ }
36
+
37
+ function isRecord(value: unknown): value is Record<string, unknown> {
38
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
39
+ }
@@ -0,0 +1,17 @@
1
+ import path from 'node:path';
2
+
3
+ const fallbackPath =
4
+ '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
5
+
6
+ export function prependOpenCodeBinToPath({
7
+ bootstrapDir,
8
+ env,
9
+ }: {
10
+ bootstrapDir: string;
11
+ env: NodeJS.ProcessEnv;
12
+ }): void {
13
+ env.PATH = [
14
+ path.join(bootstrapDir, 'node_modules', '.bin'),
15
+ env.PATH || fallbackPath,
16
+ ].join(path.delimiter);
17
+ }
@@ -0,0 +1,160 @@
1
+ import type { HarnessV1StreamPart } from '@ai-sdk/harness';
2
+
3
+ type FinishStepEvent = Extract<HarnessV1StreamPart, { type: 'finish-step' }>;
4
+
5
+ export type OpenCodeTokenUsage = {
6
+ readonly input: number;
7
+ readonly output: number;
8
+ readonly reasoning: number;
9
+ readonly cache: {
10
+ readonly read: number;
11
+ readonly write: number;
12
+ };
13
+ };
14
+
15
+ export type HarnessUsage = FinishStepEvent['usage'];
16
+
17
+ export function mapUsage(tokens: unknown): HarnessUsage {
18
+ const value = extractOpenCodeTokens(tokens) ?? zeroOpenCodeTokens();
19
+ const cacheRead = value.cache.read;
20
+ return {
21
+ inputTokens: {
22
+ total: value.input,
23
+ noCache: Math.max(0, value.input - cacheRead),
24
+ cacheRead,
25
+ cacheWrite: value.cache.write,
26
+ },
27
+ outputTokens: {
28
+ total: value.output + value.reasoning,
29
+ text: value.output,
30
+ reasoning: value.reasoning,
31
+ },
32
+ };
33
+ }
34
+
35
+ export function defaultUsage(): HarnessUsage {
36
+ return mapUsage(zeroOpenCodeTokens());
37
+ }
38
+
39
+ export function extractSessionTokens(
40
+ value: unknown,
41
+ ): OpenCodeTokenUsage | undefined {
42
+ const record = asRecord(value);
43
+ if (!record) return undefined;
44
+ const tokens =
45
+ extractOpenCodeTokens(record.tokens) ??
46
+ extractOpenCodeTokens(asRecord(record.info)?.tokens) ??
47
+ extractOpenCodeTokens(asRecord(record.data)?.tokens) ??
48
+ extractOpenCodeTokens(asRecord(asRecord(record.data)?.data)?.tokens);
49
+ return tokens;
50
+ }
51
+
52
+ export function subtractSessionTokens({
53
+ before,
54
+ after,
55
+ }: {
56
+ before: OpenCodeTokenUsage;
57
+ after: OpenCodeTokenUsage;
58
+ }): OpenCodeTokenUsage {
59
+ return {
60
+ input: diff({ before: before.input, after: after.input }),
61
+ output: diff({ before: before.output, after: after.output }),
62
+ reasoning: diff({ before: before.reasoning, after: after.reasoning }),
63
+ cache: {
64
+ read: diff({ before: before.cache.read, after: after.cache.read }),
65
+ write: diff({ before: before.cache.write, after: after.cache.write }),
66
+ },
67
+ };
68
+ }
69
+
70
+ export function addUsage({
71
+ left,
72
+ right,
73
+ }: {
74
+ left: HarnessUsage | undefined;
75
+ right: HarnessUsage;
76
+ }): HarnessUsage {
77
+ if (left == null) return right;
78
+ const leftInput = asTokenGroup(left.inputTokens);
79
+ const rightInput = asTokenGroup(right.inputTokens);
80
+ const leftOutput = asTokenGroup(left.outputTokens);
81
+ const rightOutput = asTokenGroup(right.outputTokens);
82
+ return {
83
+ inputTokens: {
84
+ total: add({ left: leftInput.total, right: rightInput.total }),
85
+ noCache: add({ left: leftInput.noCache, right: rightInput.noCache }),
86
+ cacheRead: add({
87
+ left: leftInput.cacheRead,
88
+ right: rightInput.cacheRead,
89
+ }),
90
+ cacheWrite: add({
91
+ left: leftInput.cacheWrite,
92
+ right: rightInput.cacheWrite,
93
+ }),
94
+ },
95
+ outputTokens: {
96
+ total: add({ left: leftOutput.total, right: rightOutput.total }),
97
+ text: add({ left: leftOutput.text, right: rightOutput.text }),
98
+ reasoning: add({
99
+ left: leftOutput.reasoning,
100
+ right: rightOutput.reasoning,
101
+ }),
102
+ },
103
+ };
104
+ }
105
+
106
+ function extractOpenCodeTokens(value: unknown): OpenCodeTokenUsage | undefined {
107
+ const record = asRecord(value);
108
+ const cache = asRecord(record?.cache);
109
+ if (!record || !cache) return undefined;
110
+ return {
111
+ input: numberValue(record.input),
112
+ output: numberValue(record.output),
113
+ reasoning: numberValue(record.reasoning),
114
+ cache: {
115
+ read: numberValue(cache.read),
116
+ write: numberValue(cache.write),
117
+ },
118
+ };
119
+ }
120
+
121
+ function zeroOpenCodeTokens(): OpenCodeTokenUsage {
122
+ return {
123
+ input: 0,
124
+ output: 0,
125
+ reasoning: 0,
126
+ cache: { read: 0, write: 0 },
127
+ };
128
+ }
129
+
130
+ function asTokenGroup(value: unknown): Record<string, number | undefined> {
131
+ return asRecord(value) ?? {};
132
+ }
133
+
134
+ function asRecord(value: unknown): Record<string, any> | undefined {
135
+ if (!value || typeof value !== 'object' || Array.isArray(value))
136
+ return undefined;
137
+ return value as Record<string, any>;
138
+ }
139
+
140
+ function numberValue(value: unknown): number {
141
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
142
+ }
143
+
144
+ function diff({ before, after }: { before: number; after: number }): number {
145
+ return Math.max(0, after - before);
146
+ }
147
+
148
+ function add({
149
+ left,
150
+ right,
151
+ }: {
152
+ left: unknown;
153
+ right: unknown;
154
+ }): number | undefined {
155
+ const leftNumber = typeof left === 'number' ? left : undefined;
156
+ const rightNumber = typeof right === 'number' ? right : undefined;
157
+ return leftNumber == null && rightNumber == null
158
+ ? undefined
159
+ : (leftNumber ?? 0) + (rightNumber ?? 0);
160
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "harness-opencode-bridge",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@modelcontextprotocol/sdk": "1.29.0",
8
+ "@opencode-ai/sdk": "1.18.3",
9
+ "opencode-ai": "1.18.3",
10
+ "ws": "8.21.0",
11
+ "zod": "3.25.76"
12
+ }
13
+ }