@pruddiman/hem 0.0.1-beta-5671db0

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agents/arbiter-agent.d.ts +72 -0
  3. package/dist/agents/arbiter-agent.js +149 -0
  4. package/dist/agents/architecture-agent.d.ts +148 -0
  5. package/dist/agents/architecture-agent.js +459 -0
  6. package/dist/agents/base-agent.d.ts +44 -0
  7. package/dist/agents/base-agent.js +57 -0
  8. package/dist/agents/crossref-agent.d.ts +140 -0
  9. package/dist/agents/crossref-agent.js +560 -0
  10. package/dist/agents/crossref-arbiter-agent.d.ts +72 -0
  11. package/dist/agents/crossref-arbiter-agent.js +147 -0
  12. package/dist/agents/documentation-agent.d.ts +55 -0
  13. package/dist/agents/documentation-agent.js +159 -0
  14. package/dist/agents/exploration-agent.d.ts +58 -0
  15. package/dist/agents/exploration-agent.js +102 -0
  16. package/dist/agents/grouping-agent.d.ts +167 -0
  17. package/dist/agents/grouping-agent.js +557 -0
  18. package/dist/agents/index-agent.d.ts +86 -0
  19. package/dist/agents/index-agent.js +360 -0
  20. package/dist/agents/organization-agent.d.ts +144 -0
  21. package/dist/agents/organization-agent.js +607 -0
  22. package/dist/auth.d.ts +372 -0
  23. package/dist/auth.js +1072 -0
  24. package/dist/broadcast-mcp.d.ts +21 -0
  25. package/dist/broadcast-mcp.js +59 -0
  26. package/dist/changelog.d.ts +85 -0
  27. package/dist/changelog.js +223 -0
  28. package/dist/decision-queue.d.ts +173 -0
  29. package/dist/decision-queue.js +265 -0
  30. package/dist/diff-scope.d.ts +24 -0
  31. package/dist/diff-scope.js +28 -0
  32. package/dist/discovery.d.ts +54 -0
  33. package/dist/discovery.js +405 -0
  34. package/dist/grouping.d.ts +37 -0
  35. package/dist/grouping.js +343 -0
  36. package/dist/helpers/format.d.ts +5 -0
  37. package/dist/helpers/format.js +13 -0
  38. package/dist/helpers/index.d.ts +11 -0
  39. package/dist/helpers/index.js +11 -0
  40. package/dist/helpers/parsing.d.ts +52 -0
  41. package/dist/helpers/parsing.js +128 -0
  42. package/dist/helpers/paths.d.ts +41 -0
  43. package/dist/helpers/paths.js +67 -0
  44. package/dist/helpers/strings.d.ts +45 -0
  45. package/dist/helpers/strings.js +97 -0
  46. package/dist/index.d.ts +135 -0
  47. package/dist/index.js +1087 -0
  48. package/dist/merge-utils.d.ts +22 -0
  49. package/dist/merge-utils.js +34 -0
  50. package/dist/orchestrator.d.ts +194 -0
  51. package/dist/orchestrator.js +1169 -0
  52. package/dist/output.d.ts +106 -0
  53. package/dist/output.js +243 -0
  54. package/dist/progress.d.ts +228 -0
  55. package/dist/progress.js +644 -0
  56. package/dist/providers/copilot.d.ts +247 -0
  57. package/dist/providers/copilot.js +598 -0
  58. package/dist/providers/index.d.ts +15 -0
  59. package/dist/providers/index.js +12 -0
  60. package/dist/providers/opencode.d.ts +156 -0
  61. package/dist/providers/opencode.js +416 -0
  62. package/dist/providers/types.d.ts +156 -0
  63. package/dist/providers/types.js +16 -0
  64. package/dist/resources.d.ts +76 -0
  65. package/dist/resources.js +151 -0
  66. package/dist/search-index.d.ts +71 -0
  67. package/dist/search-index.js +187 -0
  68. package/dist/search-mcp.d.ts +25 -0
  69. package/dist/search-mcp.js +100 -0
  70. package/dist/server-utils.d.ts +56 -0
  71. package/dist/server-utils.js +135 -0
  72. package/dist/session.d.ts +227 -0
  73. package/dist/session.js +370 -0
  74. package/dist/types.d.ts +272 -0
  75. package/dist/types.js +5 -0
  76. package/dist/worktree.d.ts +82 -0
  77. package/dist/worktree.js +187 -0
  78. package/package.json +45 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * OpenCode SDK provider implementation for Hem.
3
+ *
4
+ * Wraps the OpenCode server lifecycle, permission configuration,
5
+ * MCP tool registration, and model string formatting behind the
6
+ * {@link Provider} interface.
7
+ *
8
+ * Aligns with Dispatch's ProviderInstance pattern:
9
+ * - `createSession()` — delegates to `client.session.create()`
10
+ * - `prompt(sessionId, text)` — uses `promptAsync` + SSE wait (avoids
11
+ * HTTP timeout on slow LLM responses)
12
+ * - `cleanup()` — stops the server
13
+ *
14
+ * Also exposes low-level `session` and `event` properties that delegate
15
+ * directly to the underlying OpenCode client, for agents that need
16
+ * fire-and-forget prompting or SSE monitoring (OrganizationAgent etc.).
17
+ *
18
+ * Reference: FR-005, FR-006, FR-007.
19
+ */
20
+ import { createOpencode } from "@opencode-ai/sdk";
21
+ import type { Provider, ProviderConfig } from "./types.js";
22
+ /**
23
+ * Read-only bash permission set for agents that need filesystem inspection
24
+ * but must never modify files or run arbitrary commands.
25
+ *
26
+ * Reference: T005.
27
+ */
28
+ export declare const READ_ONLY_BASH: {
29
+ readonly "cat *": "allow";
30
+ readonly "head *": "allow";
31
+ readonly "tail *": "allow";
32
+ readonly "grep *": "allow";
33
+ readonly "find *": "allow";
34
+ readonly "ls *": "allow";
35
+ readonly "wc *": "allow";
36
+ readonly "file *": "allow";
37
+ readonly "tree *": "allow";
38
+ readonly "du *": "allow";
39
+ readonly "git status *": "allow";
40
+ readonly "git diff *": "allow";
41
+ readonly "*": "deny";
42
+ };
43
+ /**
44
+ * Extended bash permission set for organization workers. Inherits all
45
+ * read-only commands from {@link READ_ONLY_BASH} and additionally allows
46
+ * `rm` so workers can delete files when executing arbiter DELETE decisions
47
+ * instead of writing empty content as a deletion proxy.
48
+ */
49
+ export declare const ORG_AGENT_BASH: {
50
+ readonly "rm *": "allow";
51
+ readonly "cat *": "allow";
52
+ readonly "head *": "allow";
53
+ readonly "tail *": "allow";
54
+ readonly "grep *": "allow";
55
+ readonly "find *": "allow";
56
+ readonly "ls *": "allow";
57
+ readonly "wc *": "allow";
58
+ readonly "file *": "allow";
59
+ readonly "tree *": "allow";
60
+ readonly "du *": "allow";
61
+ readonly "git status *": "allow";
62
+ readonly "git diff *": "allow";
63
+ readonly "*": "deny";
64
+ };
65
+ /** Overridable factory for `createOpencode`, used for testing. */
66
+ export type CreateOpencodeFn = typeof createOpencode;
67
+ /**
68
+ * Provider implementation backed by the OpenCode SDK.
69
+ *
70
+ * Manages the full OpenCode server lifecycle: port discovery, server
71
+ * startup (with retry), permission scoping, MCP tool registration,
72
+ * and clean shutdown.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const provider = await OpenCodeProvider.create(config);
77
+ * const sessionId = await provider.createSession();
78
+ * const result = await provider.prompt(sessionId, "Explain this code");
79
+ * await provider.cleanup();
80
+ * ```
81
+ */
82
+ export declare class OpenCodeProvider implements Provider {
83
+ private _config;
84
+ private _client;
85
+ private _shutdownFn;
86
+ private _factory;
87
+ private _findPort;
88
+ private _modelOverride;
89
+ /** Low-level session operations (implementing Provider interface). */
90
+ readonly session: Provider["session"];
91
+ /** SSE event subscription (implementing Provider interface). */
92
+ readonly event: Provider["event"];
93
+ /**
94
+ * Creates a new OpenCode provider.
95
+ *
96
+ * @param config - Provider configuration (model, destination, permissions).
97
+ * @param factory - Optional `createOpencode` override for testing.
98
+ * @param findPort - Optional port finder override for testing.
99
+ */
100
+ constructor(config: ProviderConfig, factory?: CreateOpencodeFn, findPort?: () => Promise<number>);
101
+ get name(): string;
102
+ get model(): string | undefined;
103
+ get config(): ProviderConfig;
104
+ /**
105
+ * Creates and initializes an OpenCodeProvider.
106
+ * Mirrors Dispatch's `boot()` pattern — callers receive a ready-to-use
107
+ * provider without calling `initialize()` separately.
108
+ */
109
+ static create(config: ProviderConfig, factory?: CreateOpencodeFn, findPort?: () => Promise<number>): Promise<OpenCodeProvider>;
110
+ /**
111
+ * Starts the OpenCode server and configures permissions and MCP tools.
112
+ *
113
+ * Must be called exactly once before using the provider. Subsequent
114
+ * calls are no-ops if already initialized.
115
+ *
116
+ * @throws {Error} If `destinationPath` is empty or blank.
117
+ * @throws {Error} If the OpenCode server fails to start.
118
+ */
119
+ initialize(): Promise<void>;
120
+ /**
121
+ * Create a new OpenCode session.
122
+ *
123
+ * @returns The session ID.
124
+ * @throws {Error} If the provider is not initialized.
125
+ */
126
+ createSession(): Promise<string>;
127
+ /**
128
+ * Send a prompt to an OpenCode session and wait for the response.
129
+ *
130
+ * Uses the async prompt flow to avoid HTTP timeout issues on slow LLM
131
+ * responses (mirrors Dispatch's OpenCode provider):
132
+ * 1. `promptAsync()` — fire-and-forget POST that returns 204 immediately
133
+ * 2. `event.subscribe()` — SSE stream for session lifecycle events
134
+ * 3. Wait for `session.idle` (success) or `session.error` (failure)
135
+ * 4. `session.messages()` — fetch the completed response
136
+ *
137
+ * @param sessionId - The session ID returned by `createSession()`.
138
+ * @param text - The prompt text.
139
+ * @param options - Optional: `agent` selects the permission profile.
140
+ */
141
+ prompt(sessionId: string, text: string, options?: {
142
+ agent?: string;
143
+ }): Promise<string | null>;
144
+ /**
145
+ * Shuts down the OpenCode server and releases all resources.
146
+ *
147
+ * After cleanup, the provider instance must not be reused.
148
+ * No-op if the provider was never initialized or already shut down.
149
+ */
150
+ cleanup(): Promise<void>;
151
+ /**
152
+ * Alias for `cleanup()` — kept for backward compatibility with callers
153
+ * that use the old `shutdown()` name.
154
+ */
155
+ shutdown(): Promise<void>;
156
+ }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * OpenCode SDK provider implementation for Hem.
3
+ *
4
+ * Wraps the OpenCode server lifecycle, permission configuration,
5
+ * MCP tool registration, and model string formatting behind the
6
+ * {@link Provider} interface.
7
+ *
8
+ * Aligns with Dispatch's ProviderInstance pattern:
9
+ * - `createSession()` — delegates to `client.session.create()`
10
+ * - `prompt(sessionId, text)` — uses `promptAsync` + SSE wait (avoids
11
+ * HTTP timeout on slow LLM responses)
12
+ * - `cleanup()` — stops the server
13
+ *
14
+ * Also exposes low-level `session` and `event` properties that delegate
15
+ * directly to the underlying OpenCode client, for agents that need
16
+ * fire-and-forget prompting or SSE monitoring (OrganizationAgent etc.).
17
+ *
18
+ * Reference: FR-005, FR-006, FR-007.
19
+ */
20
+ import { resolve, join, dirname } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { createOpencode } from "@opencode-ai/sdk";
23
+ import { findFreePort, trackServer, untrackServer, startWithRetry } from "../server-utils.js";
24
+ // ── Permission Constants ────────────────────────────────────────────────
25
+ /**
26
+ * Read-only bash permission set for agents that need filesystem inspection
27
+ * but must never modify files or run arbitrary commands.
28
+ *
29
+ * Reference: T005.
30
+ */
31
+ export const READ_ONLY_BASH = {
32
+ "cat *": "allow",
33
+ "head *": "allow",
34
+ "tail *": "allow",
35
+ "grep *": "allow",
36
+ "find *": "allow",
37
+ "ls *": "allow",
38
+ "wc *": "allow",
39
+ "file *": "allow",
40
+ "tree *": "allow",
41
+ "du *": "allow",
42
+ "git status *": "allow",
43
+ "git diff *": "allow",
44
+ "*": "deny",
45
+ };
46
+ /**
47
+ * Extended bash permission set for organization workers. Inherits all
48
+ * read-only commands from {@link READ_ONLY_BASH} and additionally allows
49
+ * `rm` so workers can delete files when executing arbiter DELETE decisions
50
+ * instead of writing empty content as a deletion proxy.
51
+ */
52
+ export const ORG_AGENT_BASH = {
53
+ ...READ_ONLY_BASH,
54
+ "rm *": "allow",
55
+ };
56
+ // ── OpenCode Provider ───────────────────────────────────────────────────
57
+ /**
58
+ * Provider implementation backed by the OpenCode SDK.
59
+ *
60
+ * Manages the full OpenCode server lifecycle: port discovery, server
61
+ * startup (with retry), permission scoping, MCP tool registration,
62
+ * and clean shutdown.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const provider = await OpenCodeProvider.create(config);
67
+ * const sessionId = await provider.createSession();
68
+ * const result = await provider.prompt(sessionId, "Explain this code");
69
+ * await provider.cleanup();
70
+ * ```
71
+ */
72
+ export class OpenCodeProvider {
73
+ _config;
74
+ _client = null;
75
+ _shutdownFn = null;
76
+ _factory;
77
+ _findPort;
78
+ _modelOverride;
79
+ /** Low-level session operations (implementing Provider interface). */
80
+ session;
81
+ /** SSE event subscription (implementing Provider interface). */
82
+ event;
83
+ /**
84
+ * Creates a new OpenCode provider.
85
+ *
86
+ * @param config - Provider configuration (model, destination, permissions).
87
+ * @param factory - Optional `createOpencode` override for testing.
88
+ * @param findPort - Optional port finder override for testing.
89
+ */
90
+ constructor(config, factory = createOpencode, findPort = findFreePort) {
91
+ this._config = config;
92
+ this._factory = factory;
93
+ this._findPort = findPort;
94
+ // Parse model override from config for use in prompt() calls.
95
+ // The SDK concatenates providerID + "/" + modelID, so for encoded modelIDs like
96
+ // "github-copilot/claude-opus-4.6" (opencode sub-provider), split them so the
97
+ // SDK receives { providerID: "github-copilot", modelID: "claude-opus-4.6" }.
98
+ if (config.model.modelID !== "default") {
99
+ const { providerID, modelID } = config.model;
100
+ if (modelID.includes("/")) {
101
+ const slashIdx = modelID.indexOf("/");
102
+ this._modelOverride = {
103
+ providerID: modelID.slice(0, slashIdx),
104
+ modelID: modelID.slice(slashIdx + 1),
105
+ };
106
+ }
107
+ else {
108
+ this._modelOverride = { providerID, modelID };
109
+ }
110
+ }
111
+ // Low-level session ops — delegate to underlying client
112
+ const self = this;
113
+ this.session = {
114
+ async promptAsync(options) {
115
+ if (!self._client)
116
+ throw new Error("OpenCodeProvider not initialized");
117
+ return self._client.session.promptAsync(options);
118
+ },
119
+ async abort(options) {
120
+ if (!self._client)
121
+ throw new Error("OpenCodeProvider not initialized");
122
+ return self._client.session.abort(options);
123
+ },
124
+ async delete(options) {
125
+ if (!self._client)
126
+ throw new Error("OpenCodeProvider not initialized");
127
+ return self._client.session.delete(options);
128
+ },
129
+ };
130
+ this.event = {
131
+ async subscribe(options) {
132
+ if (!self._client)
133
+ throw new Error("OpenCodeProvider not initialized");
134
+ // The underlying SDK returns SdkEvent; cast to SseEvent (same shape).
135
+ const result = await self._client.event.subscribe(options);
136
+ return result;
137
+ },
138
+ };
139
+ }
140
+ // ── Identity ───────────────────────────────────────────────────────
141
+ get name() { return "opencode"; }
142
+ get model() {
143
+ if (this._config.model.modelID === "default")
144
+ return undefined;
145
+ const { providerID, modelID } = this._config.model;
146
+ // modelID may already include a sub-provider prefix (e.g. "github-copilot/opus-4.6")
147
+ // when the user picked an opencode sub-provider model via `hem config`. Use it as-is.
148
+ return modelID.includes("/") ? modelID : `${providerID}/${modelID}`;
149
+ }
150
+ get config() { return this._config; }
151
+ // ── Static factory ─────────────────────────────────────────────────
152
+ /**
153
+ * Creates and initializes an OpenCodeProvider.
154
+ * Mirrors Dispatch's `boot()` pattern — callers receive a ready-to-use
155
+ * provider without calling `initialize()` separately.
156
+ */
157
+ static async create(config, factory, findPort) {
158
+ const provider = new OpenCodeProvider(config, factory, findPort);
159
+ await provider.initialize();
160
+ return provider;
161
+ }
162
+ // ── Lifecycle ──────────────────────────────────────────────────────
163
+ /**
164
+ * Starts the OpenCode server and configures permissions and MCP tools.
165
+ *
166
+ * Must be called exactly once before using the provider. Subsequent
167
+ * calls are no-ops if already initialized.
168
+ *
169
+ * @throws {Error} If `destinationPath` is empty or blank.
170
+ * @throws {Error} If the OpenCode server fails to start.
171
+ */
172
+ async initialize() {
173
+ if (this._client)
174
+ return;
175
+ const absoluteDestination = resolve(this._config.destinationPath);
176
+ if (absoluteDestination.trim() === "") {
177
+ throw new Error("destinationPath must be a non-empty string. " +
178
+ "Provide a valid directory path for generated documentation.");
179
+ }
180
+ const { providerID, modelID } = this._config.model;
181
+ const modelString = modelID === "default"
182
+ ? undefined
183
+ // modelID may already include a sub-provider prefix (e.g. "github-copilot/opus-4.6")
184
+ // when the user picked an opencode sub-provider model via `hem config`. Use it as-is.
185
+ : modelID.includes("/") ? modelID : `${providerID}/${modelID}`;
186
+ // Writing agents get scoped access to the destination directory.
187
+ const writingAgentPermission = {
188
+ edit: "allow",
189
+ bash: READ_ONLY_BASH,
190
+ webfetch: "allow",
191
+ external_directory: "allow",
192
+ };
193
+ // Organization workers additionally need `rm` to execute arbiter DELETE
194
+ // decisions (instead of writing empty content as a deletion proxy).
195
+ const orgAgentPermission = {
196
+ edit: "allow",
197
+ bash: ORG_AGENT_BASH,
198
+ webfetch: "allow",
199
+ external_directory: "allow",
200
+ };
201
+ // Resolve the path to the compiled broadcast MCP server script.
202
+ const thisDir = dirname(fileURLToPath(import.meta.url));
203
+ const broadcastMcpPath = join(thisDir, "..", "broadcast-mcp.js");
204
+ const searchMcpPath = join(thisDir, "..", "search-mcp.js");
205
+ const searchDbPath = this._config.searchDbPath;
206
+ try {
207
+ const { client, server } = await startWithRetry(async () => {
208
+ const port = await this._findPort();
209
+ return this._factory({
210
+ port,
211
+ config: {
212
+ ...(modelString != null && { model: modelString }),
213
+ mcp: {
214
+ "hem-broadcast": {
215
+ type: "local",
216
+ command: ["node", broadcastMcpPath],
217
+ enabled: true,
218
+ },
219
+ ...(searchDbPath && {
220
+ "hem-search": {
221
+ type: "local",
222
+ command: ["node", searchMcpPath, searchDbPath],
223
+ enabled: true,
224
+ },
225
+ }),
226
+ },
227
+ permission: {
228
+ edit: "deny",
229
+ bash: "deny",
230
+ webfetch: "deny",
231
+ },
232
+ agent: {
233
+ "hem-explore": {
234
+ permission: {
235
+ edit: "deny",
236
+ bash: READ_ONLY_BASH,
237
+ webfetch: "allow",
238
+ },
239
+ },
240
+ "hem-doc": {
241
+ permission: writingAgentPermission,
242
+ },
243
+ "hem-arch": {
244
+ permission: writingAgentPermission,
245
+ },
246
+ "hem-index": {
247
+ permission: writingAgentPermission,
248
+ },
249
+ "hem-org": {
250
+ permission: orgAgentPermission,
251
+ },
252
+ "hem-xref": {
253
+ permission: writingAgentPermission,
254
+ },
255
+ "hem-group": {
256
+ permission: {
257
+ edit: "deny",
258
+ bash: READ_ONLY_BASH,
259
+ webfetch: "deny",
260
+ },
261
+ },
262
+ },
263
+ },
264
+ });
265
+ });
266
+ trackServer(() => server.close());
267
+ this._client = client;
268
+ this._shutdownFn = async () => {
269
+ server.close();
270
+ untrackServer();
271
+ };
272
+ }
273
+ catch (error) {
274
+ const message = error instanceof Error ? error.message : String(error);
275
+ const isPortConflict = /EADDRINUSE|port.*in use|already.*listening/i.test(message);
276
+ const hint = isPortConflict
277
+ ? ` A process is already using this port. Kill stale OpenCode processes or restart your terminal.\n`
278
+ : ` This may be a Node.js version issue (requires 20+) or a network problem.\n`;
279
+ throw new Error(`Failed to start OpenCode server: ${message}\n` +
280
+ hint +
281
+ ` Run with --verbose for detailed logs.`);
282
+ }
283
+ }
284
+ // ── Provider interface methods ─────────────────────────────────────
285
+ /**
286
+ * Create a new OpenCode session.
287
+ *
288
+ * @returns The session ID.
289
+ * @throws {Error} If the provider is not initialized.
290
+ */
291
+ async createSession() {
292
+ if (!this._client) {
293
+ throw new Error("OpenCodeProvider not initialized. Call initialize() first.");
294
+ }
295
+ const { data: session, error } = await this._client.session.create();
296
+ if (error || !session) {
297
+ throw new Error(`Failed to create OpenCode session: ${String(error ?? "unknown error")}`);
298
+ }
299
+ return session.id;
300
+ }
301
+ /**
302
+ * Send a prompt to an OpenCode session and wait for the response.
303
+ *
304
+ * Uses the async prompt flow to avoid HTTP timeout issues on slow LLM
305
+ * responses (mirrors Dispatch's OpenCode provider):
306
+ * 1. `promptAsync()` — fire-and-forget POST that returns 204 immediately
307
+ * 2. `event.subscribe()` — SSE stream for session lifecycle events
308
+ * 3. Wait for `session.idle` (success) or `session.error` (failure)
309
+ * 4. `session.messages()` — fetch the completed response
310
+ *
311
+ * @param sessionId - The session ID returned by `createSession()`.
312
+ * @param text - The prompt text.
313
+ * @param options - Optional: `agent` selects the permission profile.
314
+ */
315
+ async prompt(sessionId, text, options) {
316
+ if (!this._client) {
317
+ throw new Error("OpenCodeProvider not initialized. Call initialize() first.");
318
+ }
319
+ // 1. Fire-and-forget prompt
320
+ const { error: promptError } = await this._client.session.promptAsync({
321
+ path: { id: sessionId },
322
+ body: {
323
+ parts: [{ type: "text", text }],
324
+ agent: options?.agent,
325
+ ...(this._modelOverride ? { model: this._modelOverride } : {}),
326
+ },
327
+ });
328
+ if (promptError) {
329
+ throw new Error(`OpenCode promptAsync failed: ${JSON.stringify(promptError)}`);
330
+ }
331
+ // 2. Subscribe to SSE events
332
+ const controller = new AbortController();
333
+ const { stream } = await this._client.event.subscribe({
334
+ signal: controller.signal,
335
+ });
336
+ // 3. Wait for session.idle or session.error
337
+ try {
338
+ for await (const event of stream) {
339
+ if (!isSessionEvent(event, sessionId))
340
+ continue;
341
+ if (event.type === "session.error") {
342
+ const err = event.properties?.error;
343
+ throw new Error(`OpenCode session error: ${err ? JSON.stringify(err) : "unknown error"}`);
344
+ }
345
+ if (event.type === "session.idle") {
346
+ break;
347
+ }
348
+ }
349
+ }
350
+ finally {
351
+ controller.abort();
352
+ }
353
+ // 4. Fetch completed messages
354
+ const { data: messages } = await this._client.session.messages({
355
+ path: { id: sessionId },
356
+ });
357
+ if (!messages || messages.length === 0)
358
+ return null;
359
+ const lastAssistant = [...messages]
360
+ .reverse()
361
+ .find((m) => m.info.role === "assistant");
362
+ if (!lastAssistant)
363
+ return null;
364
+ // Check for assistant-level error
365
+ if (lastAssistant.info.error) {
366
+ throw new Error(`OpenCode assistant error: ${JSON.stringify(lastAssistant.info.error)}`);
367
+ }
368
+ // Extract text parts
369
+ const textParts = lastAssistant.parts.filter((p) => p.type === "text" && "text" in p);
370
+ return textParts.map((p) => p.text).join("\n") || null;
371
+ }
372
+ /**
373
+ * Shuts down the OpenCode server and releases all resources.
374
+ *
375
+ * After cleanup, the provider instance must not be reused.
376
+ * No-op if the provider was never initialized or already shut down.
377
+ */
378
+ async cleanup() {
379
+ if (this._shutdownFn) {
380
+ await this._shutdownFn();
381
+ this._client = null;
382
+ this._shutdownFn = null;
383
+ }
384
+ }
385
+ /**
386
+ * Alias for `cleanup()` — kept for backward compatibility with callers
387
+ * that use the old `shutdown()` name.
388
+ */
389
+ async shutdown() {
390
+ return this.cleanup();
391
+ }
392
+ }
393
+ // ── SSE event filter ────────────────────────────────────────────────────
394
+ /**
395
+ * Check whether an SSE event belongs to the given session.
396
+ *
397
+ * Different event types store the session ID in different places:
398
+ * - `session.*` events → `properties.sessionID`
399
+ * - `message.*` events → `properties.info.sessionID` or `properties.part.sessionID`
400
+ */
401
+ function isSessionEvent(event, sessionId) {
402
+ const props = event.properties;
403
+ if (props.sessionID === sessionId)
404
+ return true;
405
+ if (props.info &&
406
+ typeof props.info === "object" &&
407
+ props.info.sessionID === sessionId) {
408
+ return true;
409
+ }
410
+ if (props.part &&
411
+ typeof props.part === "object" &&
412
+ props.part.sessionID === sessionId) {
413
+ return true;
414
+ }
415
+ return false;
416
+ }