@lasso-ai/cli 1.5.0 → 2.0.0

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 (52) hide show
  1. package/dist/cli/agent.d.ts +1 -0
  2. package/dist/cli/backends.d.ts +53 -0
  3. package/dist/cli/backends.js +267 -0
  4. package/dist/cli/bridge.d.ts +8 -5
  5. package/dist/cli/bridge.js +357 -151
  6. package/dist/cli/host/install.js +12 -1
  7. package/dist/cli/local-runtime.d.ts +28 -0
  8. package/dist/cli/local-runtime.js +294 -0
  9. package/dist/cli/oneshot.js +28 -10
  10. package/dist/cli/runtime-client.d.ts +91 -10
  11. package/dist/cli/runtime-client.js +38 -3
  12. package/dist/overlay.js +44 -28
  13. package/dist/runtime/__tests__/adapter-normalization.test.d.ts +1 -0
  14. package/dist/runtime/__tests__/adapter-normalization.test.js +392 -0
  15. package/dist/runtime/__tests__/state-machine.test.d.ts +1 -0
  16. package/dist/runtime/__tests__/state-machine.test.js +107 -0
  17. package/dist/runtime/__tests__/verification-engine.test.d.ts +1 -0
  18. package/dist/runtime/__tests__/verification-engine.test.js +91 -0
  19. package/dist/runtime/__tests__/workspace-guard.test.d.ts +1 -0
  20. package/dist/runtime/__tests__/workspace-guard.test.js +104 -0
  21. package/dist/runtime/adapters/claude-code-adapter.d.ts +46 -0
  22. package/dist/runtime/adapters/claude-code-adapter.js +230 -0
  23. package/dist/runtime/adapters/codex-cli-adapter.d.ts +44 -0
  24. package/dist/runtime/adapters/codex-cli-adapter.js +202 -0
  25. package/dist/runtime/adapters/cursor-adapter.d.ts +47 -0
  26. package/dist/runtime/adapters/cursor-adapter.js +214 -0
  27. package/dist/runtime/adapters/factory.d.ts +19 -0
  28. package/dist/runtime/adapters/factory.js +48 -0
  29. package/dist/runtime/adapters/local-agent-adapter.d.ts +143 -0
  30. package/dist/runtime/adapters/local-agent-adapter.js +408 -0
  31. package/dist/runtime/adapters/opencode-adapter.d.ts +43 -0
  32. package/dist/runtime/adapters/opencode-adapter.js +210 -0
  33. package/dist/runtime/agent-runtime.d.ts +286 -0
  34. package/dist/runtime/agent-runtime.js +8 -0
  35. package/dist/runtime/agent-state-machine.d.ts +67 -0
  36. package/dist/runtime/agent-state-machine.js +234 -0
  37. package/dist/runtime/backend-interface.d.ts +272 -0
  38. package/dist/runtime/backend-interface.js +38 -0
  39. package/dist/runtime/event-mapper.d.ts +11 -0
  40. package/dist/runtime/event-mapper.js +40 -0
  41. package/dist/runtime/index.d.ts +20 -0
  42. package/dist/runtime/index.js +43 -0
  43. package/dist/runtime/local-session-runner.d.ts +60 -0
  44. package/dist/runtime/local-session-runner.js +185 -0
  45. package/dist/runtime/review.d.ts +24 -0
  46. package/dist/runtime/review.js +30 -0
  47. package/dist/runtime/verification-engine.d.ts +71 -0
  48. package/dist/runtime/verification-engine.js +374 -0
  49. package/dist/runtime/workspace-guard.d.ts +70 -0
  50. package/dist/runtime/workspace-guard.js +321 -0
  51. package/docs/local-coding-agents.md +183 -0
  52. package/package.json +22 -3
@@ -0,0 +1,408 @@
1
+ "use strict";
2
+ /**
3
+ * LocalAgentAdapter - Shared base for external coding agent backends.
4
+ *
5
+ * Claude Code, OpenCode, Codex CLI, and Cursor are full coding agents with
6
+ * their own internal loops. Lasso does NOT wrap them in a competing model
7
+ * loop: it delegates a task, streams their native structured output,
8
+ * normalizes it into BackendEvents, and lets the orchestrator own
9
+ * diff review, verification, and session state.
10
+ *
11
+ * Every adapter in this package:
12
+ * - invokes the agent's official non-interactive CLI with structured output
13
+ * (JSON/JSONL), never human-readable terminal scraping,
14
+ * - checks installation and authentication separately from execution,
15
+ * - relies on WorkspaceGuard (owned by the orchestrator) for rollback, so
16
+ * no agent's changes ever persist before user approval.
17
+ */
18
+ var __importDefault = (this && this.__importDefault) || function (mod) {
19
+ return (mod && mod.__esModule) ? mod : { "default": mod };
20
+ };
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.LocalAgentAdapter = exports.EventSink = void 0;
23
+ exports.newRunState = newRunState;
24
+ const node_child_process_1 = require("node:child_process");
25
+ const node_util_1 = require("node:util");
26
+ const node_crypto_1 = __importDefault(require("node:crypto"));
27
+ const node_path_1 = __importDefault(require("node:path"));
28
+ const execFileAsync = (0, node_util_1.promisify)(require("node:child_process").execFile);
29
+ function newRunState() {
30
+ return {
31
+ errors: [],
32
+ conversation: [],
33
+ tools: [],
34
+ receivedTerminalSuccess: false,
35
+ cancelled: false,
36
+ };
37
+ }
38
+ /** Sink used by adapters while parsing native events → normalized BackendEvents */
39
+ class EventSink {
40
+ taskId;
41
+ emitRaw;
42
+ state;
43
+ constructor(taskId, emitRaw, state) {
44
+ this.taskId = taskId;
45
+ this.emitRaw = emitRaw;
46
+ this.state = state;
47
+ }
48
+ emit(type, data) {
49
+ this.emitRaw({ type, taskId: this.taskId, timestamp: Date.now(), data });
50
+ }
51
+ progress(message) {
52
+ this.emit("task_progress", { message });
53
+ }
54
+ toolStarted(name, detail) {
55
+ this.emit("tool_started", { tool: name, detail });
56
+ }
57
+ toolCompleted(name, success = true, detail) {
58
+ this.emit("tool_completed", { tool: name, success, detail });
59
+ this.state.tools.push({ name, success, detail });
60
+ }
61
+ fileChange(operation, filePath) {
62
+ if (operation === "deleted")
63
+ this.emit("file_deleted", { filePath });
64
+ else if (operation === "created")
65
+ this.emit("file_created", { filePath });
66
+ else
67
+ this.emit("file_modified", { filePath });
68
+ }
69
+ error(message, options = {}) {
70
+ const agentError = {
71
+ id: node_crypto_1.default.randomUUID(),
72
+ type: options.authFailure ? "permission" : "execution",
73
+ message,
74
+ phase: "execute",
75
+ timestamp: Date.now(),
76
+ recoverable: options.recoverable ?? true,
77
+ };
78
+ this.state.errors.push(agentError);
79
+ if (options.authFailure)
80
+ this.state.authFailure = true;
81
+ if (options.asEvent !== false) {
82
+ this.emit("error", { error: agentError, message });
83
+ }
84
+ }
85
+ }
86
+ exports.EventSink = EventSink;
87
+ class LocalAgentAdapter {
88
+ id;
89
+ type = "local_agent";
90
+ modelId;
91
+ config;
92
+ process;
93
+ currentTaskId;
94
+ killTimer;
95
+ constructor(config) {
96
+ this.id = `${this.constructor.name}-${node_crypto_1.default.randomUUID()}`;
97
+ this.config = config;
98
+ this.modelId = config.modelId || "";
99
+ }
100
+ /**
101
+ * Optional: extra stderr/stdout text inspection for failures
102
+ * (e.g. "Authentication required" messages).
103
+ */
104
+ inspectFailureText(text) {
105
+ return null;
106
+ }
107
+ /** Whether this adapter can resume its own external session */
108
+ get supportsResume() {
109
+ return false;
110
+ }
111
+ // ── availability ─────────────────────────────────────────────────────
112
+ async checkAvailability() {
113
+ let version;
114
+ try {
115
+ const { stdout } = (await execFileAsync(this.binary, this.versionArgs, {
116
+ timeout: 8000,
117
+ windowsHide: true,
118
+ }));
119
+ version = stdout.trim().split("\n")[0]?.trim() || undefined;
120
+ }
121
+ catch {
122
+ return {
123
+ available: false,
124
+ installed: false,
125
+ authenticated: false,
126
+ error: `${this.provider} is not installed (${this.binary} not found on PATH).`,
127
+ fixInstructions: this.installInstructions,
128
+ };
129
+ }
130
+ const auth = await this.checkAuthentication();
131
+ if (!auth.authenticated) {
132
+ return {
133
+ available: false,
134
+ installed: true,
135
+ authenticated: false,
136
+ version,
137
+ error: auth.error || `${this.provider} is installed but not authenticated.`,
138
+ fixInstructions: auth.fixInstructions || this.installInstructions,
139
+ };
140
+ }
141
+ return { available: true, installed: true, authenticated: true, version };
142
+ }
143
+ // ── execution ────────────────────────────────────────────────────────
144
+ async executeTask(context, onEvent, signal) {
145
+ this.currentTaskId = context.taskId;
146
+ const state = newRunState();
147
+ const sink = new EventSink(context.taskId, onEvent, state);
148
+ sink.emit("task_started", { provider: this.provider, model: this.modelId || undefined });
149
+ sink.state.conversation.push({ role: "user", content: context.userPrompt, timestamp: Date.now() });
150
+ if (signal?.aborted) {
151
+ state.cancelled = true;
152
+ return this.buildResult(state, context, -1);
153
+ }
154
+ let command;
155
+ try {
156
+ command = this.buildCommand(context);
157
+ }
158
+ catch (error) {
159
+ const message = error instanceof Error ? error.message : "Failed to build agent command";
160
+ sink.error(message, { recoverable: false });
161
+ sink.emit("task_failed", { error: message });
162
+ return this.buildResult(state, context, -1);
163
+ }
164
+ const spawnEnv = { ...process.env, ...(command.env || {}) };
165
+ return new Promise((resolve) => {
166
+ let stdoutBuffer = "";
167
+ let stderrBuffer = "";
168
+ let settled = false;
169
+ const child = (0, node_child_process_1.spawn)(command.bin, command.args, {
170
+ cwd: command.cwd,
171
+ env: spawnEnv,
172
+ stdio: ["ignore", "pipe", "pipe"],
173
+ // Agents inherit PATH-resolved binaries; do not open a shell.
174
+ });
175
+ this.process = child;
176
+ const onAbort = () => {
177
+ void this.cancelTask(context.taskId);
178
+ };
179
+ signal?.addEventListener("abort", onAbort, { once: true });
180
+ const processLine = (line) => {
181
+ if (!line.trim())
182
+ return;
183
+ try {
184
+ this.handleLine(line, context, sink);
185
+ }
186
+ catch {
187
+ // A single malformed event must not kill the run; record it.
188
+ sink.state.errors.push({
189
+ id: node_crypto_1.default.randomUUID(),
190
+ type: "unknown",
191
+ message: `Skipped unparseable agent event: ${line.slice(0, 300)}`,
192
+ phase: "execute",
193
+ timestamp: Date.now(),
194
+ recoverable: true,
195
+ });
196
+ }
197
+ };
198
+ child.stdout?.on("data", (chunk) => {
199
+ stdoutBuffer += chunk.toString("utf8");
200
+ const lines = stdoutBuffer.split("\n");
201
+ stdoutBuffer = lines.pop() || "";
202
+ for (const line of lines)
203
+ processLine(line);
204
+ });
205
+ child.stderr?.on("data", (chunk) => {
206
+ stderrBuffer = (stderrBuffer + chunk.toString("utf8")).slice(-16000);
207
+ });
208
+ const finish = (exitCode) => {
209
+ if (settled)
210
+ return;
211
+ settled = true;
212
+ signal?.removeEventListener("abort", onAbort);
213
+ if (this.killTimer)
214
+ clearTimeout(this.killTimer);
215
+ this.process = undefined;
216
+ if (stdoutBuffer.trim())
217
+ processLine(stdoutBuffer);
218
+ // Determine outcome. Success requires BOTH a clean exit and the
219
+ // agent's own terminal success signal (when the format provides one).
220
+ const failureText = [state.failureMessage, stderrBuffer.trim()].filter(Boolean).join("\n");
221
+ const inspected = !state.failureMessage ? this.inspectFailureText(failureText) : null;
222
+ if (inspected && !state.errors.some((e) => e.message === inspected.message)) {
223
+ sink.error(inspected.message, { recoverable: false, authFailure: inspected.authFailure });
224
+ }
225
+ if (state.cancelled || signal?.aborted) {
226
+ state.cancelled = true;
227
+ sink.emit("task_cancelled", {});
228
+ }
229
+ else if (state.receivedTerminalSuccess && exitCode === 0 && !state.failureMessage) {
230
+ sink.emit("task_completed", { summary: state.summary });
231
+ }
232
+ else {
233
+ const message = state.failureMessage ||
234
+ inspected?.message ||
235
+ (exitCode === 0
236
+ ? `${this.provider} finished without reporting a successful result.`
237
+ : `${this.provider} exited with code ${exitCode}${stderrBuffer ? `: ${stderrBuffer.trim().slice(-500)}` : ""}`);
238
+ if (!state.errors.some((e) => e.message === message)) {
239
+ sink.error(message, { recoverable: false, authFailure: state.authFailure });
240
+ }
241
+ sink.emit("task_failed", { error: message, exitCode });
242
+ }
243
+ resolve(this.buildResult(state, context, exitCode));
244
+ };
245
+ child.on("close", (code) => finish(code ?? -1));
246
+ child.on("error", (error) => {
247
+ if (settled)
248
+ return;
249
+ settled = true;
250
+ signal?.removeEventListener("abort", onAbort);
251
+ this.process = undefined;
252
+ sink.error(error.message, { recoverable: false });
253
+ sink.emit("task_failed", { error: error.message });
254
+ resolve(this.buildResult(state, context, -1));
255
+ });
256
+ });
257
+ }
258
+ async cancelTask(taskId) {
259
+ if (!this.process || this.currentTaskId !== taskId)
260
+ return;
261
+ const child = this.process;
262
+ try {
263
+ child.kill("SIGTERM");
264
+ }
265
+ catch {
266
+ // Already exited.
267
+ }
268
+ this.killTimer = setTimeout(() => {
269
+ try {
270
+ child.kill("SIGKILL");
271
+ }
272
+ catch {
273
+ // Already exited.
274
+ }
275
+ }, 3000);
276
+ this.killTimer.unref?.();
277
+ }
278
+ async switchBackend(newBackend, context) {
279
+ const lostContext = this.supportsResume
280
+ ? [
281
+ `${this.provider} maintains its own internal session state that cannot be transferred to ${newBackend.provider}.`,
282
+ "Tool call history and per-file context held by the previous agent are not portable.",
283
+ ]
284
+ : [
285
+ `${this.provider} does not expose resumable session state for transfer.`,
286
+ "Tool call history and per-file context held by the previous agent are not portable.",
287
+ ];
288
+ return {
289
+ transferableContext: {
290
+ conversation: context.conversation,
291
+ observations: context.previousObservations,
292
+ errors: context.previousErrors,
293
+ userPrompt: context.userPrompt,
294
+ project: context.project,
295
+ externalSessionId: context.sessionRef?.id,
296
+ },
297
+ lostContext,
298
+ };
299
+ }
300
+ // ── helpers for subclasses ───────────────────────────────────────────
301
+ /** Build the shared task prompt (project context + continuation context). */
302
+ buildTaskPrompt(context) {
303
+ const lines = [];
304
+ lines.push("You are working inside a Lasso-managed review workflow.");
305
+ lines.push("Complete the following task by editing this project directly:");
306
+ lines.push("");
307
+ lines.push(context.userPrompt);
308
+ lines.push("");
309
+ lines.push("Project context:");
310
+ lines.push(`- Path: ${this.config.projectPath}`);
311
+ lines.push(`- Framework: ${context.project.framework || "unknown"}`);
312
+ lines.push(`- TypeScript: ${context.project.hasTypeScript ? "yes" : "no"}`);
313
+ lines.push(`- React: ${context.project.hasReact ? "yes" : "no"}`);
314
+ if (context.selection) {
315
+ lines.push("");
316
+ lines.push("Selected element:");
317
+ if (context.selection.element) {
318
+ lines.push(`- Tag: ${context.selection.element.tag}`);
319
+ if (context.selection.element.text)
320
+ lines.push(`- Text: ${context.selection.element.text}`);
321
+ }
322
+ if (context.selection.route)
323
+ lines.push(`- Route: ${context.selection.route}`);
324
+ }
325
+ if (context.conversation.length > 1) {
326
+ lines.push("");
327
+ lines.push("Conversation so far:");
328
+ for (const msg of context.conversation.slice(-6)) {
329
+ lines.push(`${msg.role}: ${msg.content}`);
330
+ }
331
+ }
332
+ if (context.previousObservations.length > 0) {
333
+ lines.push("");
334
+ lines.push("Previous observations:");
335
+ for (const obs of context.previousObservations.slice(-5)) {
336
+ lines.push(`- ${obs.type}: ${obs.summary}`);
337
+ }
338
+ }
339
+ if (context.previousErrors.length > 0) {
340
+ lines.push("");
341
+ lines.push("Errors from previous attempts (fix these):");
342
+ for (const err of context.previousErrors.slice(-5)) {
343
+ lines.push(`- [${err.type}] ${err.message}`);
344
+ }
345
+ }
346
+ lines.push("");
347
+ lines.push("Notes:");
348
+ lines.push("- Your file changes will be captured as a diff, reviewed by the user, and only applied if approved.");
349
+ lines.push("- Do not commit, push, or perform operations outside this project directory.");
350
+ return lines.join("\n");
351
+ }
352
+ /** Resume reference from Lasso's canonical session state (if any). */
353
+ resumeSessionId(context) {
354
+ return context.sessionRef?.id;
355
+ }
356
+ /** Convert an absolute path reported by the agent to a project-relative one. */
357
+ relPath(absoluteOrRelative) {
358
+ if (!node_path_1.default.isAbsolute(absoluteOrRelative))
359
+ return absoluteOrRelative;
360
+ const rel = node_path_1.default.relative(this.config.projectPath, absoluteOrRelative);
361
+ if (!rel || rel.startsWith(".."))
362
+ return absoluteOrRelative;
363
+ return rel;
364
+ }
365
+ buildResult(state, context, exitCode) {
366
+ const cancelled = state.cancelled;
367
+ const success = !cancelled && state.receivedTerminalSuccess && exitCode === 0 && !state.failureMessage;
368
+ const observations = [
369
+ {
370
+ id: node_crypto_1.default.randomUUID(),
371
+ type: "tool",
372
+ summary: `${this.provider} ${cancelled ? "was cancelled" : success ? "completed the task" : "failed the task"} (${state.tools.length} tool call${state.tools.length === 1 ? "" : "s"})`,
373
+ data: {
374
+ provider: this.provider,
375
+ model: this.modelId || undefined,
376
+ tools: state.tools,
377
+ exitCode,
378
+ },
379
+ timestamp: Date.now(),
380
+ phase: "execute",
381
+ },
382
+ ];
383
+ if (state.summary) {
384
+ state.conversation.push({ role: "assistant", content: state.summary, timestamp: Date.now() });
385
+ }
386
+ return {
387
+ success,
388
+ status: cancelled ? "cancelled" : success ? "completed" : "failed",
389
+ summary: state.summary || (cancelled ? "Task cancelled" : success ? `${this.provider} completed the task` : state.failureMessage || `${this.provider} failed`),
390
+ // File changes are authoritative from WorkspaceGuard (orchestrator-side diff),
391
+ // not from the agent's own claims.
392
+ fileChanges: [],
393
+ actions: [],
394
+ observations,
395
+ errors: state.errors,
396
+ conversation: state.conversation,
397
+ metadata: {
398
+ provider: this.provider,
399
+ model: this.modelId || undefined,
400
+ exitCode,
401
+ externalSessionId: state.sessionId,
402
+ resumable: Boolean(state.sessionId) && this.supportsResume,
403
+ tools: state.tools,
404
+ },
405
+ };
406
+ }
407
+ }
408
+ exports.LocalAgentAdapter = LocalAgentAdapter;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * OpenCode Adapter
3
+ *
4
+ * Adapter for the OpenCode CLI (`opencode run`), an open-source local coding agent.
5
+ *
6
+ * Invocation: opencode run --format json -m <provider/model> [--session <ses_...>] <message>
7
+ * with cwd set to the project path. Output is a raw JSON event stream
8
+ * (step_start / tool_use / text / step_finish) normalized into BackendEvents.
9
+ *
10
+ * File changes are not trusted from the agent's report — the orchestrator's
11
+ * WorkspaceGuard diffs the project after the run and restores it before review.
12
+ *
13
+ * Reference: https://opencode.ai/docs/cli
14
+ */
15
+ import { LocalAgentAdapter, LocalAgentConfig, CommandSpec, EventSink } from "./local-agent-adapter";
16
+ import { BackendCapabilities, TaskContext } from "../backend-interface";
17
+ interface OpenCodeConfig extends LocalAgentConfig {
18
+ provider: "opencode";
19
+ }
20
+ export declare class OpenCodeAdapter extends LocalAgentAdapter {
21
+ readonly provider: "opencode";
22
+ constructor(config: OpenCodeConfig);
23
+ getCapabilities(): BackendCapabilities;
24
+ protected get binary(): string;
25
+ protected get installInstructions(): string;
26
+ protected get versionArgs(): string[];
27
+ protected get supportsResume(): boolean;
28
+ protected checkAuthentication(): Promise<{
29
+ authenticated: boolean;
30
+ error?: string;
31
+ fixInstructions?: string;
32
+ }>;
33
+ protected buildCommand(context: TaskContext): CommandSpec;
34
+ protected handleLine(line: string, context: TaskContext, sink: EventSink): boolean;
35
+ private describeInput;
36
+ private describeError;
37
+ private reportFileOperation;
38
+ protected inspectFailureText(text: string): {
39
+ message: string;
40
+ authFailure?: boolean;
41
+ } | null;
42
+ }
43
+ export {};
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode Adapter
4
+ *
5
+ * Adapter for the OpenCode CLI (`opencode run`), an open-source local coding agent.
6
+ *
7
+ * Invocation: opencode run --format json -m <provider/model> [--session <ses_...>] <message>
8
+ * with cwd set to the project path. Output is a raw JSON event stream
9
+ * (step_start / tool_use / text / step_finish) normalized into BackendEvents.
10
+ *
11
+ * File changes are not trusted from the agent's report — the orchestrator's
12
+ * WorkspaceGuard diffs the project after the run and restores it before review.
13
+ *
14
+ * Reference: https://opencode.ai/docs/cli
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.OpenCodeAdapter = void 0;
18
+ const node_util_1 = require("node:util");
19
+ const local_agent_adapter_1 = require("./local-agent-adapter");
20
+ const execFileAsync = (0, node_util_1.promisify)(require("node:child_process").execFile);
21
+ const FILE_INPUT_KEYS = ["filePath", "file_path", "path", "filename"];
22
+ class OpenCodeAdapter extends local_agent_adapter_1.LocalAgentAdapter {
23
+ provider = "opencode";
24
+ constructor(config) {
25
+ super({ ...config, type: "local_agent", provider: "opencode" });
26
+ }
27
+ getCapabilities() {
28
+ return {
29
+ canStreamEvents: true,
30
+ canIsolateWorkspace: false,
31
+ providesStructuredDiffs: false,
32
+ supportsSessionContinuation: true,
33
+ supportsCancellation: true,
34
+ hasInternalLoop: true,
35
+ supportedFileOperations: "both",
36
+ canRunCommands: true,
37
+ authMethod: "cli_session",
38
+ forbiddenCommands: ["rm -rf /", "mkfs"],
39
+ };
40
+ }
41
+ get binary() {
42
+ return "opencode";
43
+ }
44
+ get installInstructions() {
45
+ return "Install OpenCode: npm install -g opencode-ai (or https://opencode.ai/docs). Then authenticate with 'opencode auth login'.";
46
+ }
47
+ get versionArgs() {
48
+ return ["--version"];
49
+ }
50
+ get supportsResume() {
51
+ return true;
52
+ }
53
+ async checkAuthentication() {
54
+ try {
55
+ const { stdout, stderr } = (await execFileAsync("opencode", ["providers", "list"], {
56
+ timeout: 12000,
57
+ windowsHide: true,
58
+ }));
59
+ const text = `${stdout}\n${stderr}`
60
+ .replace(/\u001b\[[0-9;]*m/g, "")
61
+ .replace(/\u001b\][^\u0007]*\u0007/g, "");
62
+ // The credentials section lists a "●" bullet per configured credential.
63
+ const credentialsStart = text.indexOf("Credentials");
64
+ const environmentStart = text.indexOf("Environment");
65
+ if (credentialsStart >= 0) {
66
+ const section = text.slice(credentialsStart, environmentStart > credentialsStart ? environmentStart : undefined);
67
+ if (!section.includes("●")) {
68
+ return {
69
+ authenticated: false,
70
+ error: "OpenCode has no stored credentials.",
71
+ fixInstructions: "Run 'opencode auth login' or set a provider API key (e.g. OPENCODE / OPENAI / ANTHROPIC keys).",
72
+ };
73
+ }
74
+ }
75
+ return { authenticated: true };
76
+ }
77
+ catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ return {
80
+ authenticated: false,
81
+ error: `Could not verify OpenCode authentication: ${message}`,
82
+ fixInstructions: "Run 'opencode providers list' manually and configure credentials with 'opencode auth login'.",
83
+ };
84
+ }
85
+ }
86
+ buildCommand(context) {
87
+ const resumeId = this.resumeSessionId(context);
88
+ const args = ["run", "--format", "json"];
89
+ if (this.modelId)
90
+ args.push("-m", this.modelId);
91
+ if (resumeId)
92
+ args.push("--session", resumeId);
93
+ args.push(this.buildTaskPrompt(context));
94
+ return { bin: "opencode", args, cwd: this.config.projectPath };
95
+ }
96
+ handleLine(line, context, sink) {
97
+ let event;
98
+ try {
99
+ event = JSON.parse(line);
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ if (event.sessionID)
105
+ sink.state.sessionId = event.sessionID;
106
+ switch (event.type) {
107
+ case "step_start": {
108
+ sink.progress("OpenCode is working…");
109
+ return true;
110
+ }
111
+ case "tool_use": {
112
+ const part = event.part || {};
113
+ const toolName = part.tool || "tool";
114
+ const status = part.state?.status;
115
+ const input = part.state?.input || {};
116
+ if (status === "pending" || status === "running") {
117
+ sink.toolStarted(toolName, this.describeInput(toolName, input));
118
+ }
119
+ else if (status === "completed") {
120
+ sink.toolCompleted(toolName, true, this.describeInput(toolName, input));
121
+ this.reportFileOperation(toolName, input, sink);
122
+ }
123
+ else if (status === "error") {
124
+ sink.toolCompleted(toolName, false, this.describeInput(toolName, input));
125
+ const errText = this.describeError(part.state?.error) || `${toolName} failed`;
126
+ sink.error(errText, { recoverable: true });
127
+ }
128
+ return true;
129
+ }
130
+ case "text": {
131
+ const text = event.part?.text;
132
+ if (text)
133
+ sink.progress(text);
134
+ return true;
135
+ }
136
+ case "step_finish": {
137
+ // A finished step marks the turn as complete; the exit code decides success.
138
+ sink.state.receivedTerminalSuccess = true;
139
+ return true;
140
+ }
141
+ case "error": {
142
+ const message = this.describeError(event.error) || "OpenCode reported an error";
143
+ sink.state.failureMessage = message;
144
+ sink.error(message, { recoverable: false });
145
+ return true;
146
+ }
147
+ default:
148
+ return true;
149
+ }
150
+ }
151
+ describeInput(toolName, input) {
152
+ if (typeof input.command === "string")
153
+ return input.command.slice(0, 200);
154
+ const key = FILE_INPUT_KEYS.find((k) => typeof input[k] === "string");
155
+ if (key)
156
+ return String(input[key]);
157
+ if (typeof input.pattern === "string")
158
+ return input.pattern;
159
+ return undefined;
160
+ }
161
+ describeError(error) {
162
+ if (!error)
163
+ return undefined;
164
+ if (typeof error === "string")
165
+ return error;
166
+ if (typeof error === "object") {
167
+ const e = error;
168
+ if (typeof e.message === "string")
169
+ return e.message;
170
+ if (typeof e.name === "string")
171
+ return e.name;
172
+ try {
173
+ return JSON.stringify(error).slice(0, 400);
174
+ }
175
+ catch {
176
+ return undefined;
177
+ }
178
+ }
179
+ return String(error);
180
+ }
181
+ reportFileOperation(toolName, input, sink) {
182
+ const key = FILE_INPUT_KEYS.find((k) => typeof input[k] === "string");
183
+ if (!key)
184
+ return;
185
+ const rel = this.relPath(String(input[key]));
186
+ const normalized = toolName.toLowerCase();
187
+ if (normalized.includes("write")) {
188
+ // Guard diff decides create vs modify.
189
+ sink.fileChange("modified", rel);
190
+ }
191
+ else if (normalized.includes("edit") || normalized.includes("patch")) {
192
+ sink.fileChange("modified", rel);
193
+ }
194
+ else if (normalized.includes("remove") || normalized.includes("delete")) {
195
+ sink.fileChange("deleted", rel);
196
+ }
197
+ }
198
+ inspectFailureText(text) {
199
+ if (!text)
200
+ return null;
201
+ if (/not logged in|authentication required|invalid api key|401|unauthorized/i.test(text)) {
202
+ return { message: "OpenCode authentication failed.", authFailure: true };
203
+ }
204
+ if (/rate limit|usage limit|quota exceeded/i.test(text)) {
205
+ return { message: "OpenCode provider rate limit or quota exceeded.", authFailure: true };
206
+ }
207
+ return null;
208
+ }
209
+ }
210
+ exports.OpenCodeAdapter = OpenCodeAdapter;