@mono-agent/agent-runtime 0.6.2 → 0.9.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 (40) hide show
  1. package/README.md +36 -16
  2. package/package.json +14 -7
  3. package/src/agent/approval.js +52 -17
  4. package/src/agent/sandbox-seam.js +1 -0
  5. package/src/agent/tools/pi-bridge.js +7 -0
  6. package/src/agent/tools/shared/ripgrep.js +12 -8
  7. package/src/ai/index.js +8 -0
  8. package/src/ai/providers/claude-cli.js +109 -5
  9. package/src/ai/providers/claude-sandbox.js +71 -0
  10. package/src/ai/providers/claude-sdk-discovery-worker.js +53 -0
  11. package/src/ai/providers/claude-sdk-discovery.js +352 -0
  12. package/src/ai/providers/claude-sdk.js +313 -35
  13. package/src/ai/providers/codex-app.js +823 -78
  14. package/src/ai/providers/opencode-app.js +682 -96
  15. package/src/ai/providers/opencode-server.js +508 -0
  16. package/src/ai/providers/pi-native/turn-runner.js +8 -0
  17. package/src/ai/runtime/capabilities.js +12 -0
  18. package/src/ai/runtime/context-windows.js +8 -0
  19. package/src/ai/runtime/registry.js +8 -2
  20. package/src/ai/runtime/router.js +627 -29
  21. package/src/ai/types.js +29 -2
  22. package/src/index.js +6 -0
  23. package/src/runtime.js +17 -1
  24. package/types/agent/approval.d.ts +4 -7
  25. package/types/agent/sandbox-seam.d.ts +5 -0
  26. package/types/ai/backend.d.ts +16 -0
  27. package/types/ai/index.d.ts +1 -0
  28. package/types/ai/providers/claude-cli.d.ts +116 -0
  29. package/types/ai/providers/claude-sandbox.d.ts +79 -0
  30. package/types/ai/providers/claude-sdk-discovery-worker.d.ts +1 -0
  31. package/types/ai/providers/claude-sdk-discovery.d.ts +97 -0
  32. package/types/ai/providers/claude-sdk.d.ts +81 -5
  33. package/types/ai/providers/codex-app.d.ts +11 -7
  34. package/types/ai/providers/opencode-app.d.ts +15 -16
  35. package/types/ai/providers/opencode-server.d.ts +20 -0
  36. package/types/ai/runtime/capabilities.d.ts +19 -0
  37. package/types/ai/runtime/context-windows.d.ts +1 -0
  38. package/types/ai/runtime/router.d.ts +24 -23
  39. package/types/ai/types.d.ts +75 -2
  40. package/types/index.d.ts +1 -0
@@ -1,6 +1,9 @@
1
- import { createOpencode } from "@opencode-ai/sdk";
1
+ import { randomUUID } from "node:crypto";
2
2
  import { estimateCost } from "../cost.js";
3
3
  import { buildCapabilitiesUsed } from "../runtime/capabilities-used.js";
4
+ import { createApprovalManager, RISK_TIERS } from "../../agent/approval.js";
5
+ import { resolveSandboxPolicy } from "../../agent/tools/shared/tool-context.js";
6
+ import { createIsolatedOpencode } from "./opencode-server.js";
4
7
  import {
5
8
  toolUseEvent,
6
9
  toolResultEvent,
@@ -10,15 +13,15 @@ import {
10
13
  } from "../streaming/opencode-events.js";
11
14
 
12
15
  // OpenCode agent backend (sdk='opencode', execution_mode='cli'). Drives the local
13
- // `opencode` server via @opencode-ai/sdk and resolves provider credentials from
16
+ // `opencode` server via @opencode-ai/sdk/v2 and resolves provider credentials from
14
17
  // OpenCode's own auth.json (Copilot / ChatGPT / Zen / 75+ providers). Structurally
15
18
  // modeled on codex-app.js, but over the SDK's HTTP + event-stream surface.
16
19
  //
17
- // Capability notes (verified against @opencode-ai/sdk 1.15.x):
20
+ // Capability notes (verified against @opencode-ai/sdk/v2):
18
21
  // - `session.prompt` blocks until the turn is done and returns the final message.
19
- // - The published SDK has NO structured-output (`format`) field, so we do not
20
- // enforce a host-specific result schema here; the system prompt asks for
21
- // JSON and the host can recover it (same as codex-app).
22
+ // - The v2 prompt accepts a `format` field, but this bridge does not yet enforce
23
+ // structured output; the system prompt asks for JSON and the host can recover
24
+ // it (same as codex-app).
22
25
  // - No mid-turn steering primitive and no native-subagent injection in this SDK
23
26
  // revision, so supports_live_input / supports_native_subagents are false.
24
27
  const OPENCODE_APP_CAPABILITIES = {
@@ -26,9 +29,9 @@ const OPENCODE_APP_CAPABILITIES = {
26
29
  runtime: "app-server",
27
30
  streaming: true,
28
31
  structured_output: false,
29
- supports_session_resume: true,
32
+ supports_session_resume: false,
30
33
  native_runtime_config: null,
31
- supports_mcp: true,
34
+ supports_mcp: false,
32
35
  supports_skills: false,
33
36
  supports_builtin_tools: true,
34
37
  supports_live_input: false,
@@ -39,6 +42,35 @@ const OPENCODE_APP_CAPABILITIES = {
39
42
  // How long to keep draining the event stream after session.prompt resolves, in
40
43
  // case the terminal session.idle event lands just after the HTTP response.
41
44
  const POST_PROMPT_DRAIN_MS = 1500;
45
+ const OPENCODE_ERROR_MESSAGE_MAX_CHARS = 1000;
46
+ const OPENCODE_PERMISSION_TOOL_NAMES = {
47
+ read: "Read",
48
+ edit: "Edit",
49
+ glob: "Glob",
50
+ grep: "Grep",
51
+ list: "List",
52
+ bash: "Bash",
53
+ task: "Task",
54
+ external_directory: "ExternalDirectory",
55
+ todowrite: "TodoWrite",
56
+ question: "Question",
57
+ webfetch: "WebFetch",
58
+ websearch: "WebSearch",
59
+ lsp: "LSP",
60
+ doom_loop: "DoomLoop",
61
+ skill: "Skill",
62
+ repo_clone: "RepoClone",
63
+ repo_overview: "RepoOverview",
64
+ plan_enter: "PlanEnter",
65
+ plan_exit: "PlanExit",
66
+ };
67
+ const OPENCODE_UNSUPPORTED_PERMISSIONS = new Set([
68
+ "question",
69
+ "task",
70
+ "plan_enter",
71
+ "plan_exit",
72
+ ]);
73
+ const OPENCODE_RUN_AGENT = "mono-agent-run";
42
74
 
43
75
  function delay(ms) {
44
76
  return new Promise((resolve) => {
@@ -59,14 +91,195 @@ function promptFromMessages(messages) {
59
91
  : String(messages || "");
60
92
  }
61
93
 
94
+ function normalizePermissionMode(permissionMode) {
95
+ return ["plan", "acceptEdits", "bypassPermissions"].includes(permissionMode)
96
+ ? permissionMode
97
+ : "default";
98
+ }
99
+
100
+ function opencodePermissionConfig(permissionMode) {
101
+ const mode = normalizePermissionMode(permissionMode);
102
+ const unsupported = {
103
+ question: "deny",
104
+ task: "deny",
105
+ plan_enter: "deny",
106
+ plan_exit: "deny",
107
+ };
108
+ if (mode === "plan") {
109
+ return {
110
+ "*": "deny",
111
+ read: {
112
+ "*": "allow",
113
+ "*.env": "deny",
114
+ "*.env.*": "deny",
115
+ "*.env.example": "allow",
116
+ },
117
+ ...unsupported,
118
+ };
119
+ }
120
+ if (mode === "bypassPermissions") {
121
+ return { "*": "allow", ...unsupported };
122
+ }
123
+ return {
124
+ "*": "ask",
125
+ ...unsupported,
126
+ ...(mode === "acceptEdits" ? { edit: "allow" } : {}),
127
+ };
128
+ }
129
+
130
+ function opencodeRunAgent(permission) {
131
+ return {
132
+ name: `${OPENCODE_RUN_AGENT}-${randomUUID()}`,
133
+ config: {
134
+ description: "mono-agent isolated run",
135
+ mode: "primary",
136
+ permission,
137
+ },
138
+ };
139
+ }
140
+
141
+ function opencodePermissionToolName(permissionType) {
142
+ const raw = typeof permissionType === "string" && permissionType.length > 0
143
+ ? permissionType
144
+ : "unknown_permission";
145
+ return OPENCODE_PERMISSION_TOOL_NAMES[raw] || raw;
146
+ }
147
+
148
+ function normalizeApprovalToolName(toolName) {
149
+ if (typeof toolName !== "string" || toolName.trim().length === 0) return null;
150
+ const normalized = toolName.trim();
151
+ const rawPermissionType = Object.keys(OPENCODE_PERMISSION_TOOL_NAMES)
152
+ .find((key) => key.toLowerCase() === normalized.toLowerCase());
153
+ if (rawPermissionType !== undefined) return OPENCODE_PERMISSION_TOOL_NAMES[rawPermissionType];
154
+ const canonical = Object.values(OPENCODE_PERMISSION_TOOL_NAMES)
155
+ .find((name) => name.toLowerCase() === normalized.toLowerCase());
156
+ return canonical || normalized;
157
+ }
158
+
159
+ function opencodeAlwaysAllowTools(value) {
160
+ if (!Array.isArray(value)) return [];
161
+ return [...new Set(value.map(normalizeApprovalToolName).filter(Boolean))];
162
+ }
163
+
164
+ function opencodeRiskTiersByTool(value) {
165
+ const source = value && typeof value === "object" ? value : {};
166
+ const normalized = { ...source };
167
+ for (const [permissionType, toolName] of Object.entries(OPENCODE_PERMISSION_TOOL_NAMES)) {
168
+ if (normalized[toolName] === undefined && RISK_TIERS.includes(source[permissionType])) {
169
+ normalized[toolName] = source[permissionType];
170
+ }
171
+ }
172
+ return normalized;
173
+ }
174
+
175
+ function opencodeRiskTier(options, permissionType, toolName) {
176
+ const configured = options.toolRiskTiers?.[toolName]
177
+ || options.toolRiskTiers?.[permissionType]
178
+ || options.approvalDefaultRiskTier;
179
+ return RISK_TIERS.includes(configured) ? configured : "medium";
180
+ }
181
+
182
+ function strictOpenCodeApprovalCallback(callback) {
183
+ return async (payload) => {
184
+ const response = await callback(payload);
185
+ if (response?.decision === "approve" || response?.decision === "always" || response?.decision === "deny") {
186
+ return response;
187
+ }
188
+ return { decision: "deny", reason: "invalid_host_response" };
189
+ };
190
+ }
191
+
192
+ function emitForcedApproval(emit, perm, toolName, riskTier, decision, reason) {
193
+ emit({
194
+ type: decision === "deny" ? "tool_approval_denied" : "tool_approval_granted",
195
+ requestId: typeof perm.id === "string" && perm.id.trim().length > 0 ? perm.id : null,
196
+ toolName,
197
+ toolUseId: perm.tool?.callID || null,
198
+ decision,
199
+ reason,
200
+ riskTier,
201
+ });
202
+ }
203
+
204
+ async function opencodePermissionDecision(perm, context) {
205
+ const {
206
+ options,
207
+ reference,
208
+ emit,
209
+ approvalManager,
210
+ alwaysAllowTools,
211
+ } = context;
212
+ const permissionMode = normalizePermissionMode(options.permissionMode);
213
+ const permissionType = typeof perm.permission === "string" ? perm.permission : "unknown_permission";
214
+ const toolName = opencodePermissionToolName(permissionType);
215
+ const riskTier = approvalManager?.riskTierFor(toolName)
216
+ || opencodeRiskTier(options, permissionType, toolName);
217
+
218
+ if (permissionMode === "plan") {
219
+ emitForcedApproval(emit, perm, toolName, riskTier, "deny", "permission_mode_plan");
220
+ return "reject";
221
+ }
222
+ if (OPENCODE_UNSUPPORTED_PERMISSIONS.has(permissionType)) {
223
+ emitForcedApproval(emit, perm, toolName, riskTier, "deny", "unsupported_permission_type");
224
+ return "reject";
225
+ }
226
+ if (permissionMode === "bypassPermissions") {
227
+ emitForcedApproval(emit, perm, toolName, riskTier, "always", "permission_mode_bypass");
228
+ return "once";
229
+ }
230
+ if (permissionMode === "acceptEdits" && permissionType === "edit") {
231
+ emitForcedApproval(emit, perm, toolName, riskTier, "always", "permission_mode_accept_edits");
232
+ return "once";
233
+ }
234
+
235
+ if (approvalManager === null) {
236
+ if (alwaysAllowTools.has(toolName)) {
237
+ emitForcedApproval(emit, perm, toolName, riskTier, "always", "session_allowed");
238
+ return "once";
239
+ }
240
+ emitForcedApproval(emit, perm, toolName, riskTier, "deny", "no_host_callback");
241
+ return "reject";
242
+ }
243
+
244
+ const verdict = await approvalManager.request({
245
+ requestId: perm.id,
246
+ toolName,
247
+ toolUseId: perm.tool?.callID || null,
248
+ input: {
249
+ patterns: Array.isArray(perm.patterns) ? perm.patterns : [],
250
+ metadata: perm.metadata || {},
251
+ },
252
+ model: reference,
253
+ });
254
+ if (verdict.decision === "deny") return "reject";
255
+ // OpenCode's `always` reply persists a project-wide approval. Mono-agent keeps
256
+ // `always` decisions inside this run's ApprovalManager and sends only `once`
257
+ // to the isolated provider server so no user project policy is mutated.
258
+ if (verdict.decision === "always" || approvalManager.isAlwaysAllowed(toolName)) return "once";
259
+ return "once";
260
+ }
261
+
262
+ function opencodePermissionReplyError(code, permissionId = null) {
263
+ return Object.assign(
264
+ new Error(code === "opencode_permission_invalid"
265
+ ? "OpenCode emitted an invalid permission request."
266
+ : "OpenCode could not accept the permission decision; the turn was aborted."),
267
+ {
268
+ opencodeFailureKind: "tool_failure",
269
+ opencodeErrorCode: code,
270
+ opencodePermissionId: typeof permissionId === "string" && permissionId.length > 0
271
+ ? permissionId
272
+ : null,
273
+ },
274
+ );
275
+ }
276
+
62
277
  // hey-api RequestResult resolves to { data, error }. Surface errors as throws so
63
278
  // the caller's try/catch maps them to a failure kind.
64
279
  function unwrap(result) {
65
280
  if (result && typeof result === "object" && ("data" in result || "error" in result)) {
66
281
  if (result.error) {
67
- const message = typeof result.error === "string"
68
- ? result.error
69
- : (result.error?.message || JSON.stringify(result.error));
282
+ const message = safeOpenCodeErrorMessage(result.error, "OpenCode request failed.");
70
283
  throw Object.assign(new Error(message), { opencodeError: result.error });
71
284
  }
72
285
  return result.data;
@@ -74,27 +287,25 @@ function unwrap(result) {
74
287
  return result;
75
288
  }
76
289
 
77
- export function opencodeMcpConfig(mcpServers = {}) {
78
- const out = {};
79
- for (const [name, cfg] of Object.entries(mcpServers || {})) {
80
- if (!/^[A-Za-z0-9_-]+$/.test(name)) continue;
81
- if (cfg?.command) {
82
- out[name] = {
83
- type: "local",
84
- command: [cfg.command, ...(Array.isArray(cfg.args) ? cfg.args : [])],
85
- ...(cfg.env && typeof cfg.env === "object" ? { environment: cfg.env } : {}),
86
- enabled: true,
87
- };
88
- } else if (cfg?.url) {
89
- out[name] = {
90
- type: "remote",
91
- url: cfg.url,
92
- ...(cfg.headers && typeof cfg.headers === "object" ? { headers: cfg.headers } : {}),
93
- enabled: true,
94
- };
95
- }
290
+ /**
291
+ * Extract only SDK-declared human-readable fields. In particular, never
292
+ * stringify API error objects: `responseBody`, response headers, and metadata
293
+ * can contain provider credentials or echoed request secrets.
294
+ */
295
+ export function safeOpenCodeErrorMessage(error, fallback = "OpenCode request failed.") {
296
+ let candidate;
297
+ try {
298
+ candidate = typeof error?.message === "string" && error.message.trim().length > 0
299
+ ? error.message
300
+ : typeof error?.data?.message === "string" && error.data.message.trim().length > 0
301
+ ? error.data.message
302
+ : undefined;
303
+ } catch {
304
+ candidate = undefined;
96
305
  }
97
- return out;
306
+ const text = (candidate || fallback).trim();
307
+ if (text.length <= OPENCODE_ERROR_MESSAGE_MAX_CHARS) return text;
308
+ return `${text.slice(0, OPENCODE_ERROR_MESSAGE_MAX_CHARS - 1)}…`;
98
309
  }
99
310
 
100
311
  function usageFromInfo(info) {
@@ -108,6 +319,39 @@ function usageFromInfo(info) {
108
319
  };
109
320
  }
110
321
 
322
+ function aggregateAssistantInfos(infos) {
323
+ const entries = [...infos];
324
+ const totals = {
325
+ input_tokens: 0,
326
+ output_tokens: 0,
327
+ cache_read_tokens: 0,
328
+ cache_creation_tokens: 0,
329
+ };
330
+ const seenUsage = new Set();
331
+ let reportedCost = 0;
332
+ let hasReportedCost = false;
333
+ for (const info of entries) {
334
+ const next = usageFromInfo(info);
335
+ for (const key of Object.keys(totals)) {
336
+ if (Number.isFinite(next[key])) {
337
+ totals[key] += next[key];
338
+ seenUsage.add(key);
339
+ }
340
+ }
341
+ const cost = num(info?.cost);
342
+ if (cost !== null) {
343
+ reportedCost += cost;
344
+ hasReportedCost = true;
345
+ }
346
+ }
347
+ return {
348
+ usage: entries.length === 0
349
+ ? null
350
+ : Object.fromEntries(Object.keys(totals).map((key) => [key, seenUsage.has(key) ? totals[key] : null])),
351
+ reportedCost: hasReportedCost ? reportedCost : null,
352
+ };
353
+ }
354
+
111
355
  function finalTextFromParts(parts) {
112
356
  const text = (Array.isArray(parts) ? parts : [])
113
357
  .filter((p) => p?.type === "text")
@@ -120,7 +364,8 @@ function finalTextFromParts(parts) {
120
364
  export function mapErrorFailureKind(error) {
121
365
  const name = error?.name || error?.data?.name || "";
122
366
  if (name === "MessageAbortedError") return "cancelled";
123
- if (name === "MessageOutputLengthError") return "usage_limit";
367
+ if (name === "MessageOutputLengthError" || name === "ContextOverflowError") return "usage_limit";
368
+ if (name === "ProviderAuthError") return "provider_auth";
124
369
  return "provider_unavailable";
125
370
  }
126
371
 
@@ -130,6 +375,53 @@ export function mapSpawnFailureKind(err) {
130
375
  return "provider_unavailable";
131
376
  }
132
377
 
378
+ function opencodeToolPolicyProblem(options) {
379
+ const allowedTools = Array.isArray(options.allowedTools) ? options.allowedTools : null;
380
+ const disallowedTools = Array.isArray(options.disallowedTools) ? options.disallowedTools : [];
381
+ const exactAllowAll = allowedTools === null
382
+ || (allowedTools.length === 1 && allowedTools[0] === "*");
383
+ return exactAllowAll && disallowedTools.length === 0
384
+ ? null
385
+ : "Direct OpenCode cannot enforce allowedTools/disallowedTools. Use exact allow-all ([\"*\"] with no disallowedTools) or a Pi runtime (including pi:opencode-go:*).";
386
+ }
387
+
388
+ function opencodeCapabilityMismatchResult({
389
+ reference,
390
+ outputSchema,
391
+ start,
392
+ error,
393
+ code,
394
+ }) {
395
+ return {
396
+ text: null,
397
+ structuredResult: undefined,
398
+ structuredResultSource: null,
399
+ events: [],
400
+ usage: {},
401
+ durationMs: Date.now() - start,
402
+ numTurns: 0,
403
+ model: reference,
404
+ effort: null,
405
+ sdk: "opencode",
406
+ providerSessionId: null,
407
+ provider_session_id: null,
408
+ cancelled: false,
409
+ error,
410
+ failureKind: "skipped_capability_mismatch",
411
+ diagnostics: { opencode_error_code: code },
412
+ capabilitiesUsed: buildCapabilitiesUsed({
413
+ promptCacheActive: null,
414
+ thinkingEnabled: null,
415
+ structuredOutputEnforced: !!outputSchema,
416
+ subagentInvoked: null,
417
+ mcpServersUsed: [],
418
+ nativeSubagentsUsed: [],
419
+ toolCompactionApplied: false,
420
+ contextCompactionApplied: null,
421
+ }),
422
+ };
423
+ }
424
+
133
425
  async function generateOpencodeAppResponse(systemPrompt, options = {}) {
134
426
  const start = Date.now();
135
427
  const resolved = options.model?.sdk
@@ -138,6 +430,115 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
138
430
  const providerID = resolved.provider;
139
431
  const modelID = resolved.model;
140
432
  const reference = resolved.reference || `opencode:${providerID}:${modelID}`;
433
+ const requestedSessionId = (typeof options.providerSessionId === "string" && options.providerSessionId)
434
+ || (typeof options.sessionId === "string" && options.sessionId)
435
+ || null;
436
+
437
+ if (requestedSessionId !== null) {
438
+ return opencodeCapabilityMismatchResult({
439
+ reference,
440
+ outputSchema: options.outputSchema,
441
+ start,
442
+ error: "Direct OpenCode session resume is disabled because each run uses isolated provider state. Start a fresh run or use pi:opencode-go:* for resumable sessions.",
443
+ code: "opencode_session_resume_unsupported",
444
+ });
445
+ }
446
+
447
+ if (
448
+ options.outputSchema !== undefined
449
+ && options.outputSchema !== null
450
+ ) {
451
+ return opencodeCapabilityMismatchResult({
452
+ reference,
453
+ outputSchema: options.outputSchema,
454
+ start,
455
+ error: "Direct OpenCode cannot enforce structured output for outputSchema. Remove outputSchema or use a runtime that advertises structured_output.",
456
+ code: "opencode_structured_output_unsupported",
457
+ });
458
+ }
459
+ if (Array.isArray(options.nativeSubagents?.teammates) && options.nativeSubagents.teammates.length > 0) {
460
+ return opencodeCapabilityMismatchResult({
461
+ reference,
462
+ outputSchema: options.outputSchema,
463
+ start,
464
+ error: "Direct OpenCode does not expose native subagents through this bridge. Remove nativeSubagents or use a capable runtime.",
465
+ code: "opencode_native_subagents_unsupported",
466
+ });
467
+ }
468
+ if (options.liveInput) {
469
+ return opencodeCapabilityMismatchResult({
470
+ reference,
471
+ outputSchema: options.outputSchema,
472
+ start,
473
+ error: "Direct OpenCode does not support live input through this bridge. Remove liveInput or use a capable runtime.",
474
+ code: "opencode_live_input_unsupported",
475
+ });
476
+ }
477
+ if (options.fastMode === true) {
478
+ return opencodeCapabilityMismatchResult({
479
+ reference,
480
+ outputSchema: options.outputSchema,
481
+ start,
482
+ error: "Direct OpenCode does not support fastMode through this bridge. Disable fastMode or use a capable runtime.",
483
+ code: "opencode_fast_mode_unsupported",
484
+ });
485
+ }
486
+ if (Array.isArray(options.skills) && options.skills.length > 0) {
487
+ return opencodeCapabilityMismatchResult({
488
+ reference,
489
+ outputSchema: options.outputSchema,
490
+ start,
491
+ error: "Direct OpenCode cannot expose runtime skill metadata because external skills are disabled. Use full prompt disclosure, remove skills, or use a Pi runtime.",
492
+ code: "opencode_skills_unsupported",
493
+ });
494
+ }
495
+
496
+ if (resolveSandboxPolicy(options.toolContext, options.sandboxPolicy) !== undefined) {
497
+ return opencodeCapabilityMismatchResult({
498
+ reference,
499
+ outputSchema: options.outputSchema,
500
+ start,
501
+ error: "Direct OpenCode cannot enforce mono-agent's native srt sandbox scopes. Remove the mono-agent sandbox policy or use a Pi runtime (including pi:opencode-go:*) for exact readableRoots, writableRoots, denyWrite, and network rules.",
502
+ code: "opencode_sandbox_policy_unsupported",
503
+ });
504
+ }
505
+ const toolPolicyProblem = opencodeToolPolicyProblem(options);
506
+ if (toolPolicyProblem !== null) {
507
+ return opencodeCapabilityMismatchResult({
508
+ reference,
509
+ outputSchema: options.outputSchema,
510
+ start,
511
+ error: toolPolicyProblem,
512
+ code: "opencode_tool_policy_unsupported",
513
+ });
514
+ }
515
+ if (Object.keys(options.mcpServers || {}).length > 0) {
516
+ return opencodeCapabilityMismatchResult({
517
+ reference,
518
+ outputSchema: options.outputSchema,
519
+ start,
520
+ error: "Direct OpenCode cannot safely inject MCP configuration because provider-owned shell tools inherit the server environment. Remove mcpServers or use a Pi runtime (including pi:opencode-go:*).",
521
+ code: "opencode_mcp_unsupported",
522
+ });
523
+ }
524
+ if (typeof options.effort === "string" && options.effort.trim().length > 0) {
525
+ return opencodeCapabilityMismatchResult({
526
+ reference,
527
+ outputSchema: options.outputSchema,
528
+ start,
529
+ error: "Direct OpenCode has no reasoning-effort input, so runtime.effort cannot be enforced. Remove the effort setting or use a runtime that supports it.",
530
+ code: "opencode_effort_unsupported",
531
+ });
532
+ }
533
+ if (Number.isFinite(Number(options.maxTurns)) && Number(options.maxTurns) > 0) {
534
+ return opencodeCapabilityMismatchResult({
535
+ reference,
536
+ outputSchema: options.outputSchema,
537
+ start,
538
+ error: "Direct OpenCode has no enforceable hard turn cap, so runtime.maxTurns cannot be enforced. Omit maxTurns, set it to 0, or use a runtime that supports a hard cap.",
539
+ code: "opencode_max_turns_unsupported",
540
+ });
541
+ }
141
542
 
142
543
  const events = [];
143
544
  const emit = (event) => {
@@ -146,26 +547,71 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
146
547
  try { options.onEvent?.(event); } catch { /* listener errors must not abort the run */ }
147
548
  };
148
549
 
149
- const mcp = opencodeMcpConfig(options.mcpServers);
150
- let sessionId = (typeof options.providerSessionId === "string" && options.providerSessionId)
151
- || (typeof options.sessionId === "string" && options.sessionId)
152
- || null;
550
+ const permission = opencodePermissionConfig(options.permissionMode);
551
+ const alwaysAllowToolNames = opencodeAlwaysAllowTools(options.approvalAlwaysAllowTools);
552
+ const alwaysAllowTools = new Set(alwaysAllowToolNames);
553
+ const approvalManager = typeof options.onToolApprovalRequest === "function"
554
+ ? createApprovalManager({
555
+ onToolApprovalRequest: strictOpenCodeApprovalCallback(options.onToolApprovalRequest),
556
+ defaultRiskTier: options.approvalDefaultRiskTier,
557
+ timeoutMs: options.approvalTimeoutMs,
558
+ onEvent: emit,
559
+ riskTiersByTool: opencodeRiskTiersByTool(options.toolRiskTiers),
560
+ alwaysAllowTools: alwaysAllowToolNames,
561
+ // An OpenCode permission.asked event is an explicit ask. Even a low-risk
562
+ // tier must receive a strict host answer rather than silently widening it.
563
+ autoApproveLowRisk: false,
564
+ })
565
+ : null;
566
+ const runAgent = opencodeRunAgent(permission);
567
+ const directoryParams = typeof options.cwd === "string" && options.cwd.length > 0
568
+ ? { directory: options.cwd }
569
+ : {};
570
+ let sessionId = requestedSessionId;
153
571
 
154
572
  let client = null;
155
573
  let server = null;
156
574
  let usage = null;
157
575
  let errorMessage = null;
158
576
  let failureKind = null;
577
+ let permissionErrorCode = null;
578
+ let permissionErrorId = null;
159
579
  let finalText = null;
160
580
  const seenToolUse = new Set();
581
+ const seenToolResult = new Set();
582
+ // v2 sends text/reasoning content as `message.part.delta` events that only
583
+ // identify the part by ID. Remember the owning run + part type from the
584
+ // preceding `message.part.updated` event before accepting any delta.
585
+ const streamParts = new Map();
586
+ const textDeltaPartIds = new Set();
587
+ const reasoningDeltaPartIds = new Set();
588
+ const assistantInfos = new Map();
589
+ const recordAssistantInfo = (info, fallbackKey) => {
590
+ if (info?.role !== "assistant") return;
591
+ const key = typeof info.id === "string" && info.id.length > 0 ? info.id : fallbackKey;
592
+ assistantInfos.set(key, info);
593
+ usage = aggregateAssistantInfos(assistantInfos.values()).usage;
594
+ };
161
595
 
162
596
  const abortHandler = () => {
163
- try { if (sessionId) client?.session?.abort?.({ path: { id: sessionId } }); } catch { /* best effort */ }
597
+ try {
598
+ const abortResult = sessionId
599
+ ? client?.session?.abort?.({ sessionID: sessionId, ...directoryParams })
600
+ : undefined;
601
+ if (abortResult !== undefined) {
602
+ void Promise.resolve(abortResult).catch(() => undefined);
603
+ }
604
+ } catch { /* best effort */ }
164
605
  };
165
606
 
166
607
  try {
167
- const opencode = await createOpencode(/** @type {any} */ ({
168
- ...(Object.keys(mcp).length ? { config: { mcp } } : { config: {} }),
608
+ const opencode = await createIsolatedOpencode(/** @type {any} */ ({
609
+ hostname: "127.0.0.1",
610
+ port: 0,
611
+ config: {
612
+ permission,
613
+ agent: { [runAgent.name]: runAgent.config },
614
+ },
169
615
  ...(options.abortSignal ? { signal: options.abortSignal } : {}),
170
616
  }));
171
617
  client = opencode.client;
@@ -173,38 +619,86 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
173
619
  options.abortSignal?.addEventListener?.("abort", abortHandler, { once: true });
174
620
 
175
621
  if (!sessionId) {
176
- const created = unwrap(await client.session.create({ body: {} }));
622
+ const created = unwrap(await client.session.create(directoryParams));
177
623
  sessionId = created?.id;
178
624
  if (!sessionId) throw new Error("opencode did not return a session id");
179
625
  }
180
626
 
181
627
  let pumpDone = false;
628
+ let rejectPermissionFailure;
629
+ const permissionFailure = new Promise((_, reject) => {
630
+ rejectPermissionFailure = reject;
631
+ });
632
+ // The prompt race below observes this rejection. Keep a second handler so a
633
+ // very fast prompt resolution cannot turn a later permission failure into
634
+ // an unhandled rejection while the event pump drains.
635
+ void permissionFailure.catch(() => undefined);
636
+
637
+ const failPermissionReply = (error) => {
638
+ if (permissionErrorCode !== null) return;
639
+ permissionErrorCode = error.opencodeErrorCode || "opencode_permission_reply_failed";
640
+ permissionErrorId = error.opencodePermissionId || null;
641
+ errorMessage = error.message;
642
+ failureKind = error.opencodeFailureKind || "tool_failure";
643
+ pumpDone = true;
644
+ rejectPermissionFailure(error);
645
+ try {
646
+ const abortResult = client.session.abort({
647
+ sessionID: sessionId,
648
+ ...directoryParams,
649
+ });
650
+ void Promise.resolve(abortResult).catch(() => undefined);
651
+ } catch { /* the raced failure already terminates the run */ }
652
+ };
182
653
 
183
654
  const respondToPermission = async (perm) => {
184
655
  if (perm.sessionID && perm.sessionID !== sessionId) return;
185
- let decision = "once";
186
- if (typeof options.onToolApprovalRequest === "function") {
187
- try {
188
- const verdict = await options.onToolApprovalRequest({
189
- id: perm.id,
190
- tool: perm.type,
191
- title: perm.title,
192
- input: perm.metadata,
193
- riskTier: options.approvalDefaultRiskTier,
194
- });
195
- if (verdict === false || verdict?.approved === false) decision = "reject";
196
- else if (verdict?.always) decision = "always";
197
- else decision = "once";
198
- } catch {
199
- decision = "reject";
200
- }
656
+ if (typeof perm.id !== "string" || perm.id.trim().length === 0) {
657
+ const permissionType = typeof perm.permission === "string" ? perm.permission : "unknown_permission";
658
+ const toolName = opencodePermissionToolName(permissionType);
659
+ emitForcedApproval(
660
+ emit,
661
+ perm,
662
+ toolName,
663
+ approvalManager?.riskTierFor(toolName) || opencodeRiskTier(options, permissionType, toolName),
664
+ "deny",
665
+ "invalid_permission_id",
666
+ );
667
+ failPermissionReply(opencodePermissionReplyError("opencode_permission_invalid"));
668
+ return;
201
669
  }
670
+ if (typeof perm.permission !== "string" || perm.permission.trim().length === 0) {
671
+ const toolName = opencodePermissionToolName(perm.permission);
672
+ emitForcedApproval(
673
+ emit,
674
+ perm,
675
+ toolName,
676
+ approvalManager?.riskTierFor(toolName) || opencodeRiskTier(options, perm.permission, toolName),
677
+ "deny",
678
+ "invalid_permission_type",
679
+ );
680
+ failPermissionReply(opencodePermissionReplyError("opencode_permission_invalid", perm.id));
681
+ return;
682
+ }
683
+ const decision = await opencodePermissionDecision(perm, {
684
+ options,
685
+ reference,
686
+ emit,
687
+ approvalManager,
688
+ alwaysAllowTools,
689
+ });
202
690
  try {
203
- await client.postSessionIdPermissionsPermissionId({
204
- path: { id: sessionId, permissionID: perm.id },
205
- body: { response: decision },
206
- });
207
- } catch { /* the turn will surface the denial on its own */ }
691
+ const processed = unwrap(await client.permission.reply({
692
+ requestID: perm.id,
693
+ reply: decision,
694
+ ...directoryParams,
695
+ }));
696
+ if (processed !== true) {
697
+ throw opencodePermissionReplyError("opencode_permission_reply_failed", perm.id);
698
+ }
699
+ } catch {
700
+ failPermissionReply(opencodePermissionReplyError("opencode_permission_reply_failed", perm.id));
701
+ }
208
702
  };
209
703
 
210
704
  const handleEvent = async (event) => {
@@ -213,29 +707,62 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
213
707
  switch (event.type) {
214
708
  case "message.part.updated": {
215
709
  const part = props.part;
216
- if (!part || (part.sessionID && part.sessionID !== sessionId)) return;
710
+ if (
711
+ !part
712
+ || (props.sessionID && props.sessionID !== sessionId)
713
+ || (part.sessionID && part.sessionID !== sessionId)
714
+ ) return;
715
+ if (typeof part.id === "string" && part.id.length > 0) {
716
+ streamParts.set(part.id, {
717
+ type: part.type,
718
+ sessionID: part.sessionID || props.sessionID || sessionId,
719
+ ...(part.type === "reasoning" && typeof part.text === "string"
720
+ ? { latestText: part.text }
721
+ : {}),
722
+ });
723
+ }
217
724
  if (part.type === "tool") {
218
725
  if (!seenToolUse.has(part.callID)) {
219
726
  seenToolUse.add(part.callID);
220
727
  emit(toolUseEvent(part));
221
728
  }
222
- if (toolPartSettled(part)) emit(toolResultEvent(part));
223
- } else if (part.type === "reasoning" && part.text) {
224
- emit(thinkingEvent(part));
729
+ if (toolPartSettled(part) && !seenToolResult.has(part.callID)) {
730
+ seenToolResult.add(part.callID);
731
+ emit(toolResultEvent(part));
732
+ }
733
+ }
734
+ return;
735
+ }
736
+ case "message.part.delta": {
737
+ if (
738
+ props.sessionID !== sessionId
739
+ || props.field !== "text"
740
+ || typeof props.partID !== "string"
741
+ || typeof props.delta !== "string"
742
+ || props.delta.length === 0
743
+ ) return;
744
+ const tracked = streamParts.get(props.partID);
745
+ if (!tracked || tracked.sessionID !== sessionId) return;
746
+ if (tracked.type === "text") {
747
+ textDeltaPartIds.add(props.partID);
748
+ emit(assistantTextEvent(props.delta));
749
+ } else if (tracked.type === "reasoning") {
750
+ reasoningDeltaPartIds.add(props.partID);
751
+ emit(thinkingEvent({ text: props.delta }));
225
752
  }
226
753
  return;
227
754
  }
228
755
  case "message.updated": {
229
756
  const info = props.info;
230
- if (info?.role === "assistant" && info?.tokens) usage = usageFromInfo(info);
757
+ recordAssistantInfo(info, "event-anonymous");
231
758
  return;
232
759
  }
233
- case "permission.updated":
760
+ case "permission.asked":
234
761
  await respondToPermission(props);
235
762
  return;
236
763
  case "session.error":
237
764
  if (props.sessionID && props.sessionID !== sessionId) return;
238
- errorMessage = props.error?.message || "opencode session error";
765
+ errorMessage = safeOpenCodeErrorMessage(props.error, "OpenCode session error.");
239
766
  failureKind = mapErrorFailureKind(props.error);
240
767
  pumpDone = true;
241
768
  return;
@@ -247,7 +774,7 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
247
774
  }
248
775
  };
249
776
 
250
- const subscription = await client.event.subscribe();
777
+ const subscription = await client.event.subscribe(directoryParams);
251
778
  const pump = (async () => {
252
779
  try {
253
780
  for await (const event of subscription.stream) {
@@ -257,29 +784,60 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
257
784
  } catch { /* stream closed; teardown handles the rest */ }
258
785
  })();
259
786
 
260
- const promptResult = unwrap(await client.session.prompt({
261
- path: { id: sessionId },
262
- body: {
263
- model: { providerID, modelID },
264
- system: systemPrompt,
265
- parts: [{ type: "text", text: promptFromMessages(options.messages) }],
266
- },
267
- }));
787
+ const promptRequest = client.session.prompt({
788
+ sessionID: sessionId,
789
+ ...directoryParams,
790
+ model: { providerID, modelID },
791
+ system: systemPrompt,
792
+ agent: runAgent.name,
793
+ parts: [{ type: "text", text: promptFromMessages(options.messages) }],
794
+ });
795
+ void Promise.resolve(promptRequest).catch(() => undefined);
796
+ const promptResult = unwrap(await Promise.race([promptRequest, permissionFailure]));
268
797
 
269
798
  // Let the pump drain to the terminal session.idle (which lands around when
270
799
  // prompt resolves); don't force-stop it or in-flight tool events are lost.
271
800
  await Promise.race([pump, delay(POST_PROMPT_DRAIN_MS)]);
272
801
 
802
+ // Some providers send only completed reasoning-part updates, while others
803
+ // stream deltas and then repeat the completed text. Keep the live deltas;
804
+ // emit the latest completed text only for parts that never streamed.
805
+ for (const [partID, part] of streamParts) {
806
+ if (
807
+ part.type === "reasoning"
808
+ && !reasoningDeltaPartIds.has(partID)
809
+ && typeof part.latestText === "string"
810
+ && part.latestText.length > 0
811
+ ) {
812
+ emit(thinkingEvent({ text: part.latestText }));
813
+ }
814
+ }
815
+
273
816
  const info = promptResult?.info || {};
274
- if (!usage) usage = usageFromInfo(info);
817
+ recordAssistantInfo(info, "prompt-final");
275
818
  if (info.error && !errorMessage) {
276
- errorMessage = info.error?.message || "opencode turn failed";
819
+ errorMessage = safeOpenCodeErrorMessage(info.error, "OpenCode turn failed.");
277
820
  failureKind = mapErrorFailureKind(info.error);
278
821
  }
279
- finalText = finalTextFromParts(promptResult?.parts);
280
- if (finalText) emit(assistantTextEvent(finalText));
822
+ const finalParts = Array.isArray(promptResult?.parts) ? promptResult.parts : [];
823
+ finalText = finalTextFromParts(finalParts);
824
+ // `session.prompt` returns complete parts after streaming. Deduplicate by
825
+ // part ID rather than globally: a multi-part response may stream one part
826
+ // while another arrives only in the final response.
827
+ for (const part of finalParts) {
828
+ if (
829
+ part?.type === "text"
830
+ && typeof part.text === "string"
831
+ && part.text.length > 0
832
+ && !(typeof part.id === "string" && textDeltaPartIds.has(part.id))
833
+ ) {
834
+ emit(assistantTextEvent(part.text));
835
+ }
836
+ }
281
837
 
282
- const reportedCost = num(info.cost);
838
+ const aggregate = aggregateAssistantInfos(assistantInfos.values());
839
+ usage = aggregate.usage;
840
+ const reportedCost = aggregate.reportedCost;
283
841
  const costUsd = reportedCost !== null ? reportedCost : estimateCost({
284
842
  resolveCustomPricing: options.resolveCustomPricing,
285
843
  model: reference,
@@ -296,54 +854,82 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
296
854
  events,
297
855
  usage: { ...(usage || {}), cost_usd: costUsd },
298
856
  durationMs: Date.now() - start,
299
- numTurns: 1,
857
+ numTurns: Math.max(1, assistantInfos.size),
300
858
  model: reference,
301
- effort: options.effort || null,
859
+ effort: null,
302
860
  sdk: "opencode",
303
- providerSessionId: sessionId || null,
304
- provider_session_id: sessionId || null,
861
+ providerSessionId: null,
862
+ provider_session_id: null,
305
863
  cancelled: !!options.abortSignal?.aborted,
306
864
  error: errorMessage,
307
865
  failureKind,
308
- diagnostics: {},
866
+ diagnostics: permissionErrorCode === null
867
+ ? {}
868
+ : {
869
+ opencode_error_code: permissionErrorCode,
870
+ ...(permissionErrorId === null ? {} : { opencode_permission_id: permissionErrorId }),
871
+ },
309
872
  capabilitiesUsed: buildCapabilitiesUsed({
310
873
  promptCacheActive: (usage?.cache_read_tokens || 0) > 0 || (usage?.cache_creation_tokens || 0) > 0,
311
874
  thinkingEnabled: null,
312
875
  structuredOutputEnforced: false,
313
876
  subagentInvoked: null,
314
- mcpServersUsed: Object.keys(options.mcpServers || {}),
877
+ mcpServersUsed: [],
315
878
  nativeSubagentsUsed: [],
316
879
  toolCompactionApplied: false,
317
880
  contextCompactionApplied: null,
318
881
  }),
319
882
  };
320
883
  } catch (err) {
884
+ const partialAggregate = aggregateAssistantInfos(assistantInfos.values());
885
+ const partialUsage = partialAggregate.usage ?? usage;
886
+ const partialCost = partialAggregate.reportedCost !== null
887
+ ? partialAggregate.reportedCost
888
+ : partialUsage === null
889
+ ? 0
890
+ : estimateCost({
891
+ resolveCustomPricing: options.resolveCustomPricing,
892
+ model: reference,
893
+ inputTokens: Math.max(0, (partialUsage.input_tokens || 0) - (partialUsage.cache_read_tokens || 0)),
894
+ outputTokens: partialUsage.output_tokens || 0,
895
+ cachedTokens: partialUsage.cache_read_tokens || 0,
896
+ cacheWriteTokens: partialUsage.cache_creation_tokens || 0,
897
+ });
321
898
  return {
322
899
  text: finalText,
323
900
  structuredResult: undefined,
324
901
  structuredResultSource: null,
325
902
  events,
326
- usage: usage ? { ...usage, cost_usd: 0 } : null,
903
+ usage: partialUsage ? { ...partialUsage, cost_usd: partialCost } : null,
327
904
  durationMs: Date.now() - start,
328
- numTurns: events.length ? 1 : 0,
905
+ numTurns: assistantInfos.size > 0 ? assistantInfos.size : (events.length ? 1 : 0),
329
906
  model: reference,
330
- effort: options.effort || null,
907
+ effort: null,
331
908
  sdk: "opencode",
332
- providerSessionId: sessionId || null,
333
- provider_session_id: sessionId || null,
909
+ providerSessionId: null,
910
+ provider_session_id: null,
334
911
  cancelled: !!options.abortSignal?.aborted,
335
912
  error: err?.message || String(err),
336
913
  failureKind: failureKind
914
+ || err?.opencodeFailureKind
337
915
  || (err?.opencodeError ? mapErrorFailureKind(err.opencodeError) : mapSpawnFailureKind(err)),
338
- diagnostics: { ...(events.length ? { had_partial_progress: true } : {}) },
916
+ diagnostics: {
917
+ ...(events.length ? { had_partial_progress: true } : {}),
918
+ ...((err?.opencodeErrorCode || permissionErrorCode) === null || (err?.opencodeErrorCode || permissionErrorCode) === undefined
919
+ ? {}
920
+ : { opencode_error_code: err?.opencodeErrorCode || permissionErrorCode }),
921
+ ...((err?.opencodePermissionId || permissionErrorId) === null || (err?.opencodePermissionId || permissionErrorId) === undefined
922
+ ? {}
923
+ : { opencode_permission_id: err?.opencodePermissionId || permissionErrorId }),
924
+ },
339
925
  capabilitiesUsed: buildCapabilitiesUsed({
340
926
  structuredOutputEnforced: false,
341
- mcpServersUsed: Object.keys(options.mcpServers || {}),
927
+ mcpServersUsed: [],
342
928
  }),
343
929
  };
344
930
  } finally {
345
931
  options.abortSignal?.removeEventListener?.("abort", abortHandler);
346
- try { server?.close?.(); } catch { /* best effort */ }
932
+ try { await server?.close?.(); } catch { /* best effort */ }
347
933
  }
348
934
  }
349
935