ds-harness-remote 0.5.8 → 0.5.10

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 (211) hide show
  1. package/dist/acp/adapter.d.ts +1 -1
  2. package/dist/acp/adapter.d.ts.map +1 -1
  3. package/dist/acp/adapters/claude-history.d.ts +3 -0
  4. package/dist/acp/adapters/claude-history.d.ts.map +1 -0
  5. package/dist/acp/adapters/claude-history.js +118 -0
  6. package/dist/acp/adapters/claude-history.js.map +1 -0
  7. package/dist/acp/adapters/claude-process.d.ts +21 -0
  8. package/dist/acp/adapters/claude-process.d.ts.map +1 -0
  9. package/dist/acp/adapters/claude-process.js +147 -0
  10. package/dist/acp/adapters/claude-process.js.map +1 -0
  11. package/dist/acp/adapters/cursor-process.d.ts +9 -4
  12. package/dist/acp/adapters/cursor-process.d.ts.map +1 -1
  13. package/dist/acp/adapters/cursor-process.js +31 -3
  14. package/dist/acp/adapters/cursor-process.js.map +1 -1
  15. package/dist/acp/adapters/opencode-process.d.ts +67 -0
  16. package/dist/acp/adapters/opencode-process.d.ts.map +1 -0
  17. package/dist/acp/adapters/opencode-process.js +272 -0
  18. package/dist/acp/adapters/opencode-process.js.map +1 -0
  19. package/dist/acp/adapters/opencode.d.ts +8 -0
  20. package/dist/acp/adapters/opencode.d.ts.map +1 -0
  21. package/dist/acp/adapters/opencode.js +8 -0
  22. package/dist/acp/adapters/opencode.js.map +1 -0
  23. package/dist/acp/adapters/pi-process.d.ts +42 -0
  24. package/dist/acp/adapters/pi-process.d.ts.map +1 -0
  25. package/dist/acp/adapters/pi-process.js +410 -0
  26. package/dist/acp/adapters/pi-process.js.map +1 -0
  27. package/dist/acp/gateway.d.ts +8 -0
  28. package/dist/acp/gateway.d.ts.map +1 -1
  29. package/dist/acp/gateway.js +299 -67
  30. package/dist/acp/gateway.js.map +1 -1
  31. package/dist/acp/method-policy.d.ts +19 -19
  32. package/dist/acp/method-policy.js +5 -5
  33. package/dist/acp/method-policy.js.map +1 -1
  34. package/dist/acp/pi-tool-result.d.ts +9 -0
  35. package/dist/acp/pi-tool-result.d.ts.map +1 -0
  36. package/dist/acp/pi-tool-result.js +22 -0
  37. package/dist/acp/pi-tool-result.js.map +1 -0
  38. package/dist/acp/virtual-catalog.d.ts +50 -0
  39. package/dist/acp/virtual-catalog.d.ts.map +1 -0
  40. package/dist/acp/virtual-catalog.js +103 -0
  41. package/dist/acp/virtual-catalog.js.map +1 -0
  42. package/dist/acp/virtual-contract.d.ts +165 -0
  43. package/dist/acp/virtual-contract.d.ts.map +1 -0
  44. package/dist/acp/virtual-contract.js +216 -0
  45. package/dist/acp/virtual-contract.js.map +1 -0
  46. package/dist/acp/virtual-event-carrier.d.ts +50 -0
  47. package/dist/acp/virtual-event-carrier.d.ts.map +1 -0
  48. package/dist/acp/virtual-event-carrier.js +222 -0
  49. package/dist/acp/virtual-event-carrier.js.map +1 -0
  50. package/dist/acp/virtual-harness.d.ts +13 -112
  51. package/dist/acp/virtual-harness.d.ts.map +1 -1
  52. package/dist/acp/virtual-harness.js +129 -911
  53. package/dist/acp/virtual-harness.js.map +1 -1
  54. package/dist/acp/virtual-live.d.ts +78 -0
  55. package/dist/acp/virtual-live.d.ts.map +1 -0
  56. package/dist/acp/virtual-live.js +513 -0
  57. package/dist/acp/virtual-live.js.map +1 -0
  58. package/dist/acp/workspace-tools.js +1 -1
  59. package/dist/acp/workspace-tools.js.map +1 -1
  60. package/dist/acp.d.ts.map +1 -1
  61. package/dist/acp.js +1 -0
  62. package/dist/acp.js.map +1 -1
  63. package/dist/client/header.d.ts +10 -0
  64. package/dist/client/header.d.ts.map +1 -0
  65. package/dist/client/header.js +169 -0
  66. package/dist/client/header.js.map +1 -0
  67. package/dist/client/icons.d.ts +3 -0
  68. package/dist/client/icons.d.ts.map +1 -0
  69. package/dist/client/icons.js +3 -0
  70. package/dist/client/icons.js.map +1 -0
  71. package/dist/client/navigation.d.ts +5 -0
  72. package/dist/client/navigation.d.ts.map +1 -0
  73. package/dist/client/navigation.js +30 -0
  74. package/dist/client/navigation.js.map +1 -0
  75. package/dist/client/presentation.d.ts +17 -0
  76. package/dist/client/presentation.d.ts.map +1 -0
  77. package/dist/client/presentation.js +208 -0
  78. package/dist/client/presentation.js.map +1 -0
  79. package/dist/client/progress.d.ts +10 -0
  80. package/dist/client/progress.d.ts.map +1 -0
  81. package/dist/client/progress.js +82 -0
  82. package/dist/client/progress.js.map +1 -0
  83. package/dist/client/settings.d.ts +10 -0
  84. package/dist/client/settings.d.ts.map +1 -0
  85. package/dist/client/settings.js +335 -0
  86. package/dist/client/settings.js.map +1 -0
  87. package/dist/client/shell.d.ts +6 -0
  88. package/dist/client/shell.d.ts.map +1 -0
  89. package/dist/client/shell.js +128 -0
  90. package/dist/client/shell.js.map +1 -0
  91. package/dist/client/types.d.ts +264 -0
  92. package/dist/client/types.d.ts.map +1 -0
  93. package/dist/client/types.js +2 -0
  94. package/dist/client/types.js.map +1 -0
  95. package/dist/client/workspaces.d.ts +11 -0
  96. package/dist/client/workspaces.d.ts.map +1 -0
  97. package/dist/client/workspaces.js +1179 -0
  98. package/dist/client/workspaces.js.map +1 -0
  99. package/dist/client-capabilities.d.ts +6 -0
  100. package/dist/client-capabilities.d.ts.map +1 -0
  101. package/dist/client-capabilities.js +88 -0
  102. package/dist/client-capabilities.js.map +1 -0
  103. package/dist/client-codex-streams.d.ts +23 -0
  104. package/dist/client-codex-streams.d.ts.map +1 -0
  105. package/dist/client-codex-streams.js +156 -0
  106. package/dist/client-codex-streams.js.map +1 -0
  107. package/dist/client-connection.d.ts +38 -0
  108. package/dist/client-connection.d.ts.map +1 -0
  109. package/dist/client-connection.js +210 -0
  110. package/dist/client-connection.js.map +1 -0
  111. package/dist/client-control.d.ts +48 -0
  112. package/dist/client-control.d.ts.map +1 -0
  113. package/dist/client-control.js +273 -0
  114. package/dist/client-control.js.map +1 -0
  115. package/dist/client-locale.d.ts +8 -0
  116. package/dist/client-locale.d.ts.map +1 -1
  117. package/dist/client-locale.js +16 -0
  118. package/dist/client-locale.js.map +1 -1
  119. package/dist/client-runtime-contract.d.ts +161 -0
  120. package/dist/client-runtime-contract.d.ts.map +1 -0
  121. package/dist/client-runtime-contract.js +127 -0
  122. package/dist/client-runtime-contract.js.map +1 -0
  123. package/dist/client-runtime.d.ts +24 -126
  124. package/dist/client-runtime.d.ts.map +1 -1
  125. package/dist/client-runtime.js +171 -867
  126. package/dist/client-runtime.js.map +1 -1
  127. package/dist/client-secure-transport.d.ts +1 -0
  128. package/dist/client-secure-transport.d.ts.map +1 -1
  129. package/dist/client-secure-transport.js +35 -16
  130. package/dist/client-secure-transport.js.map +1 -1
  131. package/dist/client-workspace-queries.d.ts +12 -0
  132. package/dist/client-workspace-queries.d.ts.map +1 -0
  133. package/dist/client-workspace-queries.js +66 -0
  134. package/dist/client-workspace-queries.js.map +1 -0
  135. package/dist/client.d.ts.map +1 -1
  136. package/dist/client.github.js +2091 -1896
  137. package/dist/client.js +2091 -1896
  138. package/dist/client.js.map +1 -1
  139. package/dist/codex/domain.d.ts.map +1 -1
  140. package/dist/codex/domain.js +67 -46
  141. package/dist/codex/domain.js.map +1 -1
  142. package/dist/codex/method-policy.d.ts +9 -9
  143. package/dist/codex/virtual-catalog.d.ts +90 -0
  144. package/dist/codex/virtual-catalog.d.ts.map +1 -0
  145. package/dist/codex/virtual-catalog.js +317 -0
  146. package/dist/codex/virtual-catalog.js.map +1 -0
  147. package/dist/codex/virtual-contract.d.ts +96 -0
  148. package/dist/codex/virtual-contract.d.ts.map +1 -0
  149. package/dist/codex/virtual-contract.js +192 -0
  150. package/dist/codex/virtual-contract.js.map +1 -0
  151. package/dist/codex/virtual-event-carrier.d.ts +52 -0
  152. package/dist/codex/virtual-event-carrier.d.ts.map +1 -0
  153. package/dist/codex/virtual-event-carrier.js +212 -0
  154. package/dist/codex/virtual-event-carrier.js.map +1 -0
  155. package/dist/codex/virtual-harness.d.ts +16 -117
  156. package/dist/codex/virtual-harness.d.ts.map +1 -1
  157. package/dist/codex/virtual-harness.js +123 -1909
  158. package/dist/codex/virtual-harness.js.map +1 -1
  159. package/dist/codex/virtual-history.d.ts +74 -0
  160. package/dist/codex/virtual-history.d.ts.map +1 -0
  161. package/dist/codex/virtual-history.js +546 -0
  162. package/dist/codex/virtual-history.js.map +1 -0
  163. package/dist/codex/virtual-live.d.ts +75 -0
  164. package/dist/codex/virtual-live.d.ts.map +1 -0
  165. package/dist/codex/virtual-live.js +563 -0
  166. package/dist/codex/virtual-live.js.map +1 -0
  167. package/dist/codex/virtual-values.d.ts +29 -0
  168. package/dist/codex/virtual-values.d.ts.map +1 -0
  169. package/dist/codex/virtual-values.js +75 -0
  170. package/dist/codex/virtual-values.js.map +1 -0
  171. package/dist/config.js +4 -4
  172. package/dist/config.js.map +1 -1
  173. package/dist/control-runtime.js +2 -2
  174. package/dist/control-runtime.js.map +1 -1
  175. package/dist/harness-remote-bridge.d.ts +2 -1
  176. package/dist/harness-remote-bridge.d.ts.map +1 -1
  177. package/dist/harness-remote-bridge.js +11 -3
  178. package/dist/harness-remote-bridge.js.map +1 -1
  179. package/dist/index.d.ts.map +1 -1
  180. package/dist/index.js +16044 -13878
  181. package/dist/index.js.map +4 -4
  182. package/dist/plugin-route-dispatcher.d.ts +2 -2
  183. package/dist/plugin-route-dispatcher.d.ts.map +1 -1
  184. package/dist/plugin-route-dispatcher.js +2 -2
  185. package/dist/plugin-route-dispatcher.js.map +1 -1
  186. package/dist/remote-typert-gateway.js +7 -1
  187. package/dist/remote-typert-gateway.js.map +1 -1
  188. package/dist/server-connection.d.ts +1 -0
  189. package/dist/server-connection.d.ts.map +1 -1
  190. package/dist/server-connection.js +6 -1
  191. package/dist/server-connection.js.map +1 -1
  192. package/dist/service.d.ts +1 -1
  193. package/dist/service.d.ts.map +1 -1
  194. package/dist/service.js +6 -5
  195. package/dist/service.js.map +1 -1
  196. package/dist/terminal-carrier-compat.d.ts +14 -0
  197. package/dist/terminal-carrier-compat.d.ts.map +1 -0
  198. package/dist/terminal-carrier-compat.js +259 -0
  199. package/dist/terminal-carrier-compat.js.map +1 -0
  200. package/dist/typert-gateway-contract.d.ts +6 -1
  201. package/dist/typert-gateway-contract.d.ts.map +1 -1
  202. package/dist/typert-gateway-switch.d.ts +2 -1
  203. package/dist/typert-gateway-switch.d.ts.map +1 -1
  204. package/dist/typert-gateway-switch.js +31 -15
  205. package/dist/typert-gateway-switch.js.map +1 -1
  206. package/dist/version.d.ts +1 -1
  207. package/dist/version.d.ts.map +1 -1
  208. package/dist/version.js +1 -1
  209. package/dist/version.js.map +1 -1
  210. package/dsh-plugin.json +1 -1
  211. package/package.json +21 -19
@@ -8,7 +8,10 @@ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
8
8
  import { RpcError } from '../safe-error.js';
9
9
  import { PLUGIN_VERSION } from '../version.js';
10
10
  import { CursorAcpClient, CursorAcpError, } from './adapters/cursor-process.js';
11
+ import { ClaudeAcpClient } from './adapters/claude-process.js';
12
+ import { PiAcpClient } from './adapters/pi-process.js';
11
13
  import { AntigravityAcpClient } from './adapters/antigravity-process.js';
14
+ import { OpencodeAcpClient, OpencodeAcpError } from './adapters/opencode-process.js';
12
15
  import { ACP_METHOD_ALLOWLIST, isSessionMutation, parseAcpCall, sessionIdFromParams, } from './method-policy.js';
13
16
  import { discoverAntigravitySessions, discoverAntigravityWorkspaces, loadTranscriptEvents } from './adapters/antigravity/transcript-loader.js';
14
17
  import { AcpPeerBridge } from './peer-bridge.js';
@@ -60,6 +63,10 @@ export class AcpRemoteGateway {
60
63
  /** Catch-up buffer for turns that finish while the Client is reconnecting. */
61
64
  recentFrames = new Map();
62
65
  /** Per-prompt live updates; attached to prompt_completed when streaming was lossy. */
66
+ promptsInProgress = new Set();
67
+ cancelledSessions = new Set();
68
+ /** Identity fences asynchronous Pi preparation from a later retry of the same session. */
69
+ preparingPrompts = new Map();
63
70
  turnCatchUp = new Map();
64
71
  startPromise;
65
72
  startingClients = new Map();
@@ -69,6 +76,13 @@ export class AcpRemoteGateway {
69
76
  if (backend.id === 'antigravity') {
70
77
  return new AntigravityAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
71
78
  }
79
+ if (backend.id === 'claude')
80
+ return new ClaudeAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
81
+ if (backend.id === 'pi')
82
+ return new PiAcpClient(binary, targetLogger, { args: backend.args, cwd: backend.cwd });
83
+ if (backend.id === 'opencode') {
84
+ return new OpencodeAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
85
+ }
72
86
  return new CursorAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
73
87
  }, restartDelaysMs = DEFAULT_RESTART_DELAYS_MS, terminalEnabled = () => false, terminalSpawner) {
74
88
  this.config = config;
@@ -82,7 +96,7 @@ export class AcpRemoteGateway {
82
96
  enabledBackends() {
83
97
  if (!this.config.enabled)
84
98
  return [];
85
- return this.config.backends.filter(item => item.enabled && ['antigravity', 'cursor'].includes(item.id)).map(item => item.id);
99
+ return this.config.backends.filter(item => item.enabled && ['antigravity', 'cursor', 'pi', 'claude', 'opencode'].includes(item.id)).map(item => item.id);
86
100
  }
87
101
  start() {
88
102
  if (this.closed)
@@ -136,17 +150,17 @@ export class AcpRemoteGateway {
136
150
  const bridge = new AcpPeerBridge(this, context, publish, this.logger);
137
151
  this.peers.set(context.connectionId, bridge);
138
152
  const workspaceBridge = new WorkspaceBridge(async (rawId) => {
139
- const match = rawId.match(/^(acp|cursor):(.+)$/);
153
+ const match = rawId.match(/^(acp|cursor|pi|opencode):(.+)$/);
140
154
  const session = match ? this.sessionCwds.get(match[2]) : undefined;
141
- const backend = match?.[1] === 'acp' ? 'antigravity' : 'cursor';
155
+ const backend = match?.[1] === 'acp' ? 'antigravity' : match?.[1] === 'pi' ? 'pi' : match?.[1] === 'opencode' ? 'opencode' : 'cursor';
142
156
  if (!session || session.backend !== backend)
143
157
  throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
144
158
  this.requireAcp(backend);
145
159
  this.requireSessionOwner(context.connectionId, match[2]);
146
160
  return session.cwd;
147
161
  }, this.terminalEnabled, this.workspaceState, this.terminalSpawner, {
148
- domain: 'ACP', owns: value => typeof value === 'string' && /^(acp|cursor):/.test(value),
149
- parse: value => typeof value === 'string' && /^(acp|cursor):[A-Za-z0-9_-]{1,240}$/.test(value)
162
+ domain: 'ACP', owns: value => typeof value === 'string' && /^(acp|cursor|pi|opencode):/.test(value),
163
+ parse: value => typeof value === 'string' && /^(acp|cursor|pi|opencode):[A-Za-z0-9_-]{1,240}$/.test(value)
150
164
  ? { sessionId: value, nativeId: value } : undefined,
151
165
  });
152
166
  this.workspaceTools.set(context.connectionId, new AcpWorkspaceTools(workspaceBridge, new TerminalPolicy(this.terminalEnabled, context.peerDeviceId, this.terminalOwners)));
@@ -160,8 +174,12 @@ export class AcpRemoteGateway {
160
174
  sessionId: typeof call.params?.sessionId === 'string' ? shortSessionId(call.params.sessionId) : undefined,
161
175
  });
162
176
  if (typeof call.params.backend === 'string') {
177
+ const prefix = typeof call.params.sessionId === 'string' ? /^(acp|cursor|pi|claude):/.exec(call.params.sessionId)?.[1] : undefined;
178
+ if (prefix && (prefix === 'acp' ? 'antigravity' : prefix) !== call.params.backend) {
179
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
180
+ }
163
181
  const sessionId = call.method === 'dsh/sessionHistory'
164
- ? String(call.params.sessionId).replace(/^(acp|cursor):/, '')
182
+ ? String(call.params.sessionId).replace(/^(acp|cursor|pi|claude|opencode):/, '')
165
183
  : sessionIdFromParams(call.method, call.params);
166
184
  const boundBackend = sessionId === undefined ? undefined : this.sessionBackends.get(sessionId);
167
185
  if (boundBackend !== undefined && boundBackend !== call.params.backend) {
@@ -178,6 +196,11 @@ export class AcpRemoteGateway {
178
196
  return this.listDirectory(String(call.params.path));
179
197
  }
180
198
  if (call.method === 'dsh/workspaceList') {
199
+ const requestedBackend = typeof call.params.backend === 'string' ? call.params.backend : this.defaultBackend();
200
+ if (requestedBackend === 'pi' || requestedBackend === 'claude') {
201
+ const sessions = await this.listCatalogSessions(requestedBackend);
202
+ return [...new Set(sessions.map(item => item.cwd))].map(path => ({ path, title: basename(path) || 'workspace' }));
203
+ }
181
204
  const candidates = new Set();
182
205
  const cwd = process.cwd();
183
206
  if (cwd)
@@ -243,23 +266,41 @@ export class AcpRemoteGateway {
243
266
  this.logger.info('ACP session list fetched', { count: items.length });
244
267
  return { items };
245
268
  }
269
+ if (backend === 'pi' || backend === 'claude') {
270
+ const cwd = path ? await this.requireExistingDirectory(path) : undefined;
271
+ const sessions = await this.listCatalogSessions(backend, cwd);
272
+ return { items: sessions.slice(0, limit).map(item => ({ conversationId: item.sessionId, title: item.title ?? 'Untitled Session', createdAt: Date.parse(item.updatedAt ?? '') || 0, updatedAt: Date.parse(item.updatedAt ?? '') || 0, cwd: item.cwd })) };
273
+ }
246
274
  return { items: [] };
247
275
  }
248
276
  if (call.method === 'dsh/sessionHistory') {
249
277
  const rawSessionId = String(call.params.sessionId);
250
- const conversationId = rawSessionId.startsWith('acp:')
278
+ const conversationId = rawSessionId.startsWith('claude:') ? rawSessionId.slice(7) : rawSessionId.startsWith('pi:') ? rawSessionId.slice(3) : rawSessionId.startsWith('acp:')
251
279
  ? rawSessionId.slice('acp:'.length)
252
280
  : rawSessionId.startsWith('cursor:')
253
281
  ? rawSessionId.slice('cursor:'.length)
254
- : rawSessionId;
282
+ : rawSessionId.startsWith('opencode:')
283
+ ? rawSessionId.slice('opencode:'.length)
284
+ : rawSessionId;
255
285
  const boundBackend = this.sessionBackends.get(conversationId);
256
286
  const backend = typeof call.params.backend === 'string' ? call.params.backend : boundBackend ?? this.defaultBackend();
257
287
  if ((boundBackend !== undefined && boundBackend !== backend)
288
+ || (rawSessionId.startsWith('claude:') && backend !== 'claude')
289
+ || (rawSessionId.startsWith('pi:') && backend !== 'pi')
258
290
  || (rawSessionId.startsWith('cursor:') && backend !== 'cursor')
291
+ || (rawSessionId.startsWith('opencode:') && backend !== 'opencode')
259
292
  || (rawSessionId.startsWith('acp:') && backend !== 'antigravity')) {
260
293
  throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
261
294
  }
262
295
  this.requireAcp(backend);
296
+ if (backend === 'pi' || backend === 'claude') {
297
+ const cwd = await this.catalogSessionCwd(backend, conversationId);
298
+ this.requireSessionOwner(connectionId, conversationId);
299
+ const client = this.requireAcp(backend);
300
+ if (!client.history)
301
+ throw new RpcError('METHOD_NOT_ALLOWED', 'The ACP backend does not support history.');
302
+ return { events: await client.history(conversationId, cwd) };
303
+ }
263
304
  if (backend !== 'antigravity')
264
305
  return { events: [] };
265
306
  const events = await loadTranscriptEvents(conversationId, rawSessionId);
@@ -282,6 +323,10 @@ export class AcpRemoteGateway {
282
323
  });
283
324
  const sessionId = readSessionId(result);
284
325
  if (sessionId !== undefined) {
326
+ const bound = this.sessionBackends.get(sessionId);
327
+ if (this.sessionOwners.has(sessionId) || this.sessionCwds.has(sessionId) || (bound !== undefined && bound !== requestedBackend)) {
328
+ throw new RpcError('ACP_SESSION_COLLISION', 'The ACP backend returned an existing session identifier.');
329
+ }
285
330
  this.sessionOwners.set(sessionId, connectionId);
286
331
  this.sessionBackends.set(sessionId, requestedBackend);
287
332
  this.sessionCwds.set(sessionId, { backend: requestedBackend, cwd });
@@ -289,7 +334,7 @@ export class AcpRemoteGateway {
289
334
  return sanitizeSessionResult(result);
290
335
  }
291
336
  const sessionId = sessionIdFromParams(call.method, call.params);
292
- if (sessionId !== undefined)
337
+ if (sessionId !== undefined && call.method !== 'session/load')
293
338
  this.requireSessionAccess(connectionId, sessionId, call.method);
294
339
  if (call.method === 'dsh/toolCall') {
295
340
  this.requireSessionOwner(connectionId, sessionId);
@@ -307,14 +352,24 @@ export class AcpRemoteGateway {
307
352
  }
308
353
  if (call.method === 'session/load') {
309
354
  const boundBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
310
- const targetBackend = typeof call.params.backend === 'string' ? call.params.backend : boundBackend;
355
+ if (typeof call.params.backend !== 'string')
356
+ throw new RpcError('INVALID_MESSAGE', 'Session load requires an explicit backend.');
357
+ const targetBackend = call.params.backend;
358
+ if (targetBackend === 'claude' && this.promptsInProgress.has(sessionId))
359
+ throw new RpcError('PROMPT_IN_PROGRESS', 'An ACP reply is already in progress.');
311
360
  if (boundBackend !== undefined && targetBackend !== boundBackend) {
312
361
  throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
313
362
  }
363
+ if (targetBackend === 'pi' || targetBackend === 'claude')
364
+ await this.catalogSessionCwd(targetBackend, sessionId);
365
+ this.requireSessionOwner(connectionId, sessionId);
314
366
  const acp = this.requireAcp(targetBackend);
315
- const result = await acp.call(call.method, { ...call.params, ...(sessionId && this.sessionCwds.get(sessionId) ? { cwd: this.sessionCwds.get(sessionId).cwd } : {}) });
367
+ const result = await acp.call(call.method, { ...call.params, ...(sessionId && this.sessionCwds.get(sessionId) ? { cwd: this.sessionCwds.get(sessionId).cwd, mcpServers: [] } : {}) });
316
368
  const loadedId = readSessionId(result) ?? sessionId;
317
369
  if (loadedId !== undefined) {
370
+ if (loadedId !== sessionId || (this.sessionBackends.has(loadedId) && targetBackend !== undefined && this.sessionBackends.get(loadedId) !== targetBackend)) {
371
+ throw new RpcError('ACP_SESSION_COLLISION', 'The ACP backend returned a different session identifier.');
372
+ }
318
373
  this.sessionOwners.set(loadedId, connectionId);
319
374
  if (targetBackend)
320
375
  this.sessionBackends.set(loadedId, targetBackend);
@@ -324,6 +379,11 @@ export class AcpRemoteGateway {
324
379
  if (isSessionMutation(call.method) && sessionId !== undefined) {
325
380
  this.requireSessionOwner(connectionId, sessionId);
326
381
  }
382
+ if ((call.method === 'dsh/sessionModels' || call.method === 'dsh/selectModel') && call.params.backend === 'pi') {
383
+ this.requireSessionOwner(connectionId, sessionId);
384
+ const cwd = await this.piSessionCwd(sessionId);
385
+ await this.requireAcp('pi').call('session/load', { sessionId, cwd, mcpServers: [] });
386
+ }
327
387
  if (call.method === 'dsh/selectModel') {
328
388
  const session = this.sessionCwds.get(sessionId);
329
389
  if (!session || session.backend !== call.params.backend) {
@@ -340,6 +400,35 @@ export class AcpRemoteGateway {
340
400
  // interleaved agent.acp.frame events (Android stays on "正在回复" with no
341
401
  // thought/text). Accept immediately and finish via stream updates.
342
402
  if (call.method === 'session/prompt' && sessionId !== undefined) {
403
+ if (this.promptsInProgress.has(sessionId))
404
+ throw new RpcError('PROMPT_IN_PROGRESS', 'An ACP reply is already in progress.');
405
+ this.promptsInProgress.add(sessionId);
406
+ this.cancelledSessions.delete(sessionId);
407
+ if (this.sessionBackends.get(sessionId) === 'pi') {
408
+ const preparation = { cancelled: false };
409
+ this.preparingPrompts.set(sessionId, preparation);
410
+ try {
411
+ const cwd = await this.piSessionCwd(sessionId);
412
+ if (preparation.cancelled)
413
+ return { accepted: false, stopReason: 'cancelled' };
414
+ await this.requireAcp('pi').call('session/load', { sessionId, cwd, mcpServers: [] });
415
+ if (preparation.cancelled)
416
+ return { accepted: false, stopReason: 'cancelled' };
417
+ }
418
+ catch (error) {
419
+ if (preparation.cancelled)
420
+ return { accepted: false, stopReason: 'cancelled' };
421
+ this.promptsInProgress.delete(sessionId);
422
+ throw error;
423
+ }
424
+ finally {
425
+ if (this.preparingPrompts.get(sessionId) === preparation) {
426
+ this.preparingPrompts.delete(sessionId);
427
+ if (preparation.cancelled)
428
+ this.promptsInProgress.delete(sessionId);
429
+ }
430
+ }
431
+ }
343
432
  const ownerPeer = this.peers.get(connectionId);
344
433
  if (ownerPeer !== undefined && !ownerPeer.hasStreamFor(sessionId)) {
345
434
  this.logger?.warn('Cursor prompt started without an open ACP stream', {
@@ -350,6 +439,29 @@ export class AcpRemoteGateway {
350
439
  void this.runPromptInBackground(sessionId, call.params);
351
440
  return { accepted: true, stopReason: 'in_progress' };
352
441
  }
442
+ if (call.method === 'session/cancel' && sessionId !== undefined) {
443
+ this.cancelledSessions.add(sessionId);
444
+ const preparation = this.preparingPrompts.get(sessionId);
445
+ if (preparation) {
446
+ // No turn exists upstream yet. Do not send a late cancel into a retry
447
+ // sharing this load: cancel only this preparation identity.
448
+ preparation.cancelled = true;
449
+ this.preparingPrompts.delete(sessionId);
450
+ this.promptsInProgress.delete(sessionId);
451
+ return { stopReason: 'cancelled' };
452
+ }
453
+ const pending = [...this.approvals].filter(([, approval]) => approval.sessionId === sessionId && approval.backend === this.sessionBackends.get(sessionId));
454
+ for (const [handle] of pending)
455
+ this.approvals.delete(handle);
456
+ // Send cancellation before rejecting approvals: rejecting a blocked tool first
457
+ // can otherwise complete the turn as end_turn before upstream sees cancel.
458
+ try {
459
+ return sanitizeSessionResult(await this.requireAcp(this.sessionBackends.get(sessionId)).call(call.method, call.params));
460
+ }
461
+ finally {
462
+ await Promise.all(pending.map(([, approval]) => approval.client.respondError(approval.upstreamId, -32800, 'ACP session cancelled.').catch(() => undefined)));
463
+ }
464
+ }
353
465
  const sessionBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
354
466
  return sanitizeSessionResult(await this.requireAcp(typeof call.params.backend === 'string' ? call.params.backend : sessionBackend).call(call.method, call.params));
355
467
  }
@@ -399,6 +511,9 @@ export class AcpRemoteGateway {
399
511
  },
400
512
  }).catch(() => undefined);
401
513
  }
514
+ finally {
515
+ this.promptsInProgress.delete(sessionId);
516
+ }
402
517
  }
403
518
  async respond(connectionId, input) {
404
519
  const params = parseRespondEnvelope(input);
@@ -410,13 +525,18 @@ export class AcpRemoteGateway {
410
525
  if (pending.connectionId !== connectionId) {
411
526
  throw new RpcError('CURSOR_APPROVAL_NOT_FOUND', 'The Cursor approval is missing, expired, or belongs to another connection.');
412
527
  }
528
+ if (this.sessionOwners.get(pending.sessionId) !== connectionId
529
+ || this.sessionBackends.get(pending.sessionId) !== pending.backend
530
+ || this.requireAcp(pending.backend) !== pending.client) {
531
+ throw new RpcError('CURSOR_APPROVAL_NOT_FOUND', 'The ACP approval scope is no longer active.');
532
+ }
533
+ const result = mapPermissionDecision(params, pending);
413
534
  this.approvals.delete(params.requestHandle);
414
- const acp = this.requireAcp(pending.backend);
535
+ const acp = pending.client;
415
536
  if (params.decision === 'cancel') {
416
537
  await acp.respondError(pending.upstreamId, -32800, 'Cancelled by Remote client.');
417
538
  return { resolved: true };
418
539
  }
419
- const result = params.result ?? mapPermissionDecision(params.decision, pending.method);
420
540
  await acp.respond(pending.upstreamId, result);
421
541
  return { resolved: true };
422
542
  }
@@ -431,7 +551,7 @@ export class AcpRemoteGateway {
431
551
  for (const [handle, approval] of this.approvals) {
432
552
  if (approval.connectionId === connectionId) {
433
553
  this.approvals.delete(handle);
434
- void this.requireAcp(approval.backend).respondError(approval.upstreamId, -32800, 'Remote peer disconnected.').catch(() => undefined);
554
+ void approval.client.respondError(approval.upstreamId, -32800, 'Remote peer disconnected.').catch(() => undefined);
435
555
  }
436
556
  }
437
557
  }
@@ -491,7 +611,7 @@ export class AcpRemoteGateway {
491
611
  throw new RpcError('CURSOR_CLOSED', 'The ACP Remote domain is closed.');
492
612
  const previous = this.config;
493
613
  const implemented = (value) => ({ enabled: value.enabled,
494
- backends: value.backends.filter(item => ['cursor', 'antigravity'].includes(item.id)) });
614
+ backends: value.backends.filter(item => ['cursor', 'antigravity', 'pi', 'claude', 'opencode'].includes(item.id)) });
495
615
  this.config = config;
496
616
  if (JSON.stringify(implemented(previous)) === JSON.stringify(implemented(config)) && this.pendingReconfiguration.size === 0)
497
617
  return;
@@ -512,7 +632,7 @@ export class AcpRemoteGateway {
512
632
  for (const [id, session] of this.sessionCwds)
513
633
  if (changed.has(session.backend))
514
634
  this.sessionCwds.delete(id);
515
- const terminalError = await this.workspaceState.dispose(sessionId => changed.has(sessionId.startsWith('acp:') ? 'antigravity' : 'cursor'))
635
+ const terminalError = await this.workspaceState.dispose(sessionId => changed.has(sessionId.startsWith('acp:') ? 'antigravity' : sessionId.startsWith('pi:') ? 'pi' : sessionId.startsWith('opencode:') ? 'opencode' : 'cursor'))
516
636
  .then(() => undefined, () => new RpcError('TERMINAL_CLEANUP_FAILED', 'Some ACP terminal processes could not be stopped. Save the backend settings again to retry.'));
517
637
  for (const id of sessions) {
518
638
  this.sessionOwners.delete(id);
@@ -635,7 +755,7 @@ export class AcpRemoteGateway {
635
755
  }
636
756
  const unsubscribeInbound = acp.onInbound(message => {
637
757
  this.inboundChain = this.inboundChain
638
- .then(() => this.handleInbound(id, message))
758
+ .then(() => this.backendInstances.get(id)?.client === acp ? this.handleInbound(id, message) : undefined)
639
759
  .catch(error => {
640
760
  this.logger.warn('ACP inbound fanout failed', { id, code: errorCode(error) });
641
761
  });
@@ -660,6 +780,13 @@ export class AcpRemoteGateway {
660
780
  });
661
781
  }
662
782
  async handleInbound(backend, message) {
783
+ const incomingId = readSessionId(message.params) ?? readNestedSessionId(message.params);
784
+ const boundBackend = incomingId === undefined ? undefined : this.sessionBackends.get(incomingId);
785
+ if (boundBackend !== undefined && boundBackend !== backend) {
786
+ if (message.kind === 'request')
787
+ await this.requireAcp(backend).respondError(message.id, -32602, 'The ACP session backend does not match.');
788
+ return;
789
+ }
663
790
  // session/update is a stream notification even if a buggy agent attaches an id.
664
791
  if (message.kind === 'notification' || message.method === 'session/update') {
665
792
  const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params);
@@ -671,38 +798,44 @@ export class AcpRemoteGateway {
671
798
  await this.publishToSession(sessionId, { method: message.method, params: message.params });
672
799
  return;
673
800
  }
674
- const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params) ?? 'unknown';
675
- if (sessionId !== 'unknown' && !this.sessionBackends.has(sessionId)) {
676
- this.sessionBackends.set(sessionId, backend);
801
+ const sessionId = incomingId;
802
+ const owner = sessionId === undefined ? undefined : this.sessionOwners.get(sessionId);
803
+ const peer = owner === undefined ? undefined : this.peers.get(owner);
804
+ const client = this.requireAcp(backend);
805
+ if (sessionId === undefined || this.cancelledSessions.has(sessionId) || owner === undefined || peer === undefined || boundBackend !== backend
806
+ || !['session/request_permission', 'cursor/create_plan', 'cursor/ask_question'].includes(message.method)
807
+ || (message.method.startsWith('cursor/') && backend !== 'cursor')) {
808
+ await client.respondError(message.id, -32602, 'The ACP request has no active owner or supported method.');
809
+ return;
810
+ }
811
+ let options = [];
812
+ try {
813
+ if (message.method === 'session/request_permission')
814
+ options = permissionOptions(message.params);
815
+ }
816
+ catch {
817
+ await client.respondError(message.id, -32602, 'The ACP permission options are invalid.');
818
+ return;
677
819
  }
678
820
  const requestHandle = randomUUID();
679
821
  this.approvals.set(requestHandle, {
680
- upstreamId: message.id,
681
- connectionId: this.sessionOwners.get(sessionId) ?? [...this.peers.keys()][0] ?? 'unknown',
682
- sessionId,
683
- method: message.method,
684
- expiresAt: Date.now() + APPROVAL_TTL_MS,
685
- backend,
822
+ upstreamId: message.id, connectionId: owner, sessionId, method: message.method,
823
+ expiresAt: Date.now() + APPROVAL_TTL_MS, backend, client, options,
686
824
  });
687
825
  this.scheduleApprovalExpiry();
688
- const owner = this.sessionOwners.get(sessionId);
689
- const frame = {
690
- method: message.method,
691
- params: {
692
- requestHandle,
693
- sessionId,
694
- upstreamMethod: message.method,
695
- ...(isRecord(message.params) ? message.params : {}),
696
- },
697
- };
698
- if (owner !== undefined) {
699
- const peer = this.peers.get(owner);
700
- if (peer !== undefined) {
701
- await peer.publishInbound(sessionId, frame);
702
- return;
703
- }
826
+ try {
827
+ await peer.publishInbound(sessionId, {
828
+ method: message.method,
829
+ params: {
830
+ ...(isRecord(message.params) ? message.params : {}),
831
+ requestHandle, sessionId, upstreamMethod: message.method,
832
+ },
833
+ });
834
+ }
835
+ catch {
836
+ this.approvals.delete(requestHandle);
837
+ await client.respondError(message.id, -32800, 'Remote approval delivery failed.');
704
838
  }
705
- await this.publishToSession(sessionId, frame);
706
839
  }
707
840
  async publishToSession(sessionId, frame) {
708
841
  this.recordTurnCatchUp(sessionId, frame);
@@ -808,7 +941,7 @@ export class AcpRemoteGateway {
808
941
  for (const [handle, approval] of this.approvals) {
809
942
  if (approval.expiresAt <= now) {
810
943
  this.approvals.delete(handle);
811
- void this.requireAcp(approval.backend).respondError(approval.upstreamId, -32800, 'Cursor approval expired.').catch(() => undefined);
944
+ void approval.client.respondError(approval.upstreamId, -32800, 'Cursor approval expired.').catch(() => undefined);
812
945
  }
813
946
  }
814
947
  this.scheduleApprovalExpiry();
@@ -853,8 +986,10 @@ export class AcpRemoteGateway {
853
986
  return this.requireAcp().call(method, params);
854
987
  }
855
988
  requireSessionAccess(connectionId, sessionId, method) {
856
- if (method === 'session/load')
989
+ if (method === 'session/load') {
990
+ this.requireSessionOwner(connectionId, sessionId);
857
991
  return;
992
+ }
858
993
  const owner = this.sessionOwners.get(sessionId);
859
994
  if (owner === undefined) {
860
995
  // Allow reclaim after the owning peer disconnected; the ACP process still
@@ -869,6 +1004,74 @@ export class AcpRemoteGateway {
869
1004
  requireSessionOwner(connectionId, sessionId) {
870
1005
  this.claimSession(connectionId, sessionId);
871
1006
  }
1007
+ captureCatalogClient(backend) {
1008
+ const client = this.requireAcp(backend);
1009
+ const instance = this.backendInstances.get(backend);
1010
+ return { client, assertCurrent: () => {
1011
+ if (this.closed || this.backendInstances.get(backend) !== instance || this.requireAcp(backend) !== client) {
1012
+ throw new RpcError('CURSOR_UNAVAILABLE', 'The ACP backend changed while resolving session authority.');
1013
+ }
1014
+ } };
1015
+ }
1016
+ async listCatalogSessions(backend, cwd) {
1017
+ const { client, assertCurrent } = this.captureCatalogClient(backend);
1018
+ const items = [];
1019
+ let cursor;
1020
+ const seen = new Set();
1021
+ for (let page = 0; page < 100; page++) {
1022
+ const result = await client.call('session/list', { ...(cwd ? { cwd } : {}), ...(cursor ? { cursor } : {}) });
1023
+ assertCurrent();
1024
+ if (!isRecord(result) || !Array.isArray(result.sessions)
1025
+ || (result.nextCursor != null && (typeof result.nextCursor !== 'string' || result.nextCursor.length === 0))) {
1026
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session catalog response is invalid.');
1027
+ }
1028
+ for (const item of result.sessions) {
1029
+ if (!isRecord(item) || typeof item.sessionId !== 'string' || typeof item.cwd !== 'string')
1030
+ continue;
1031
+ // Directory I/O can outlive a backend restart/reconfiguration. Only the
1032
+ // directory failure is skippable; a stale generation must reject the RPC.
1033
+ const canonical = await this.requireExistingDirectory(item.cwd).catch(() => undefined);
1034
+ assertCurrent();
1035
+ if (!canonical || (cwd && canonical !== cwd))
1036
+ continue;
1037
+ if (this.sessionBackends.has(item.sessionId) && this.sessionBackends.get(item.sessionId) !== backend)
1038
+ continue;
1039
+ const trusted = this.sessionCwds.get(item.sessionId);
1040
+ if (trusted && (trusted.backend !== backend || trusted.cwd !== canonical))
1041
+ continue;
1042
+ this.sessionCwds.set(item.sessionId, { backend, cwd: canonical });
1043
+ this.sessionBackends.set(item.sessionId, backend);
1044
+ items.push({ sessionId: item.sessionId, cwd: canonical,
1045
+ ...(typeof item.title === 'string' ? { title: item.title } : {}),
1046
+ ...(typeof item.updatedAt === 'string' ? { updatedAt: item.updatedAt } : {}) });
1047
+ }
1048
+ if (result.nextCursor == null)
1049
+ return items;
1050
+ if (seen.has(result.nextCursor) || page === 99) {
1051
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session catalog pagination did not complete.');
1052
+ }
1053
+ cursor = result.nextCursor;
1054
+ seen.add(cursor);
1055
+ }
1056
+ return items;
1057
+ }
1058
+ piSessionCwd(sessionId) { return this.catalogSessionCwd('pi', sessionId); }
1059
+ async catalogSessionCwd(backend, sessionId) {
1060
+ const { assertCurrent } = this.captureCatalogClient(backend);
1061
+ if (!this.sessionCwds.has(sessionId))
1062
+ await this.listCatalogSessions(backend);
1063
+ assertCurrent();
1064
+ const session = this.sessionCwds.get(sessionId);
1065
+ if (!session || session.backend !== backend)
1066
+ throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
1067
+ const canonical = await this.requireExistingDirectory(session.cwd);
1068
+ assertCurrent();
1069
+ const current = this.sessionCwds.get(sessionId);
1070
+ if (canonical !== session.cwd || current?.backend !== backend || current.cwd !== canonical) {
1071
+ throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session workspace authority changed.');
1072
+ }
1073
+ return canonical;
1074
+ }
872
1075
  async requireExistingDirectory(path) {
873
1076
  if (!isAbsolute(path)) {
874
1077
  throw new RpcError('CURSOR_PATH_NOT_ALLOWED', 'The Cursor working directory must be an absolute path.');
@@ -941,32 +1144,55 @@ function parseCallEnvelope(input) {
941
1144
  return { method: input.method, params: input.params ?? {} };
942
1145
  }
943
1146
  function parseRespondEnvelope(input) {
944
- if (!isRecord(input) || typeof input.requestHandle !== 'string' || typeof input.decision !== 'string') {
945
- throw new RpcError('INVALID_MESSAGE', 'The Cursor respond envelope is invalid.');
1147
+ if (!isRecord(input) || typeof input.requestHandle !== 'string' || input.requestHandle.length === 0
1148
+ || Object.keys(input).some(key => !['requestHandle', 'decision', 'result'].includes(key))
1149
+ || typeof input.decision !== 'string' || !['allow-once', 'reject-once', 'cancel'].includes(input.decision)) {
1150
+ throw new RpcError('INVALID_MESSAGE', 'The ACP respond envelope is invalid.');
946
1151
  }
947
- const decision = input.decision;
948
- if (decision !== 'allow-once' && decision !== 'allow-always' && decision !== 'reject-once' && decision !== 'cancel') {
949
- throw new RpcError('INVALID_MESSAGE', 'The Cursor respond envelope is invalid.');
950
- }
951
- return {
952
- requestHandle: input.requestHandle,
953
- decision,
954
- ...(input.result === undefined ? {} : { result: input.result }),
955
- };
1152
+ return input;
956
1153
  }
957
- function mapPermissionDecision(decision, method) {
958
- if (method === 'session/request_permission') {
959
- return { outcome: { outcome: 'selected', optionId: decision === 'cancel' ? 'reject-once' : decision } };
1154
+ function permissionOptions(params) {
1155
+ if (!isRecord(params) || !Array.isArray(params.options) || params.options.length === 0) {
1156
+ throw new Error('Missing permission options');
1157
+ }
1158
+ const ids = new Set();
1159
+ const options = params.options.map(option => {
1160
+ if (!isRecord(option) || typeof option.optionId !== 'string' || !option.optionId
1161
+ || typeof option.kind !== 'string' || !['allow_once', 'allow_always', 'reject_once', 'reject_always'].includes(option.kind)
1162
+ || ids.has(option.optionId))
1163
+ throw new Error('Invalid permission option');
1164
+ ids.add(option.optionId);
1165
+ return { kind: option.kind, optionId: option.optionId };
1166
+ });
1167
+ for (const kind of ['allow_once', 'reject_once']) {
1168
+ if (options.filter(option => option.kind === kind).length > 1)
1169
+ throw new Error('Ambiguous permission option');
960
1170
  }
961
- if (method === 'cursor/create_plan') {
962
- if (decision === 'allow-once' || decision === 'allow-always')
963
- return { outcome: { outcome: 'accepted' } };
964
- return { outcome: { outcome: decision === 'cancel' ? 'cancelled' : 'rejected' } };
1171
+ return options;
1172
+ }
1173
+ function mapPermissionDecision(params, pending) {
1174
+ const invalid = () => new RpcError('INVALID_MESSAGE', 'The ACP approval result is not allowed.');
1175
+ if (pending.method === 'session/request_permission') {
1176
+ if (params.result !== undefined)
1177
+ throw invalid();
1178
+ if (params.decision === 'cancel')
1179
+ return undefined;
1180
+ const kind = params.decision === 'allow-once' ? 'allow_once' : 'reject_once';
1181
+ const option = pending.options.find(item => item.kind === kind);
1182
+ if (!option)
1183
+ throw invalid();
1184
+ return { outcome: { outcome: 'selected', optionId: option.optionId } };
965
1185
  }
966
- if (method === 'cursor/ask_question') {
967
- return { outcome: { outcome: 'cancelled' } };
1186
+ const outcome = pending.method === 'cursor/create_plan'
1187
+ ? params.decision === 'allow-once' ? 'accepted' : params.decision === 'cancel' ? 'cancelled' : 'rejected'
1188
+ : 'cancelled';
1189
+ if (params.result !== undefined) {
1190
+ if (!isRecord(params.result) || Object.keys(params.result).length !== 1
1191
+ || !isRecord(params.result.outcome) || Object.keys(params.result.outcome).length !== 1
1192
+ || params.result.outcome.outcome !== outcome)
1193
+ throw invalid();
968
1194
  }
969
- return { outcome: { outcome: 'selected', optionId: decision } };
1195
+ return { outcome: { outcome } };
970
1196
  }
971
1197
  function readSessionId(value) {
972
1198
  if (!isRecord(value))
@@ -1028,10 +1254,16 @@ export function cursorBinaryCandidates(configured) {
1028
1254
  'agent',
1029
1255
  ];
1030
1256
  }
1257
+ if (configured === 'opencode') {
1258
+ return [
1259
+ join(userHome, '.local', 'bin', 'opencode'),
1260
+ 'opencode',
1261
+ ];
1262
+ }
1031
1263
  return [configured];
1032
1264
  }
1033
1265
  function errorCode(error) {
1034
- if (error instanceof CursorAcpError || error instanceof RpcError)
1266
+ if (error instanceof CursorAcpError || error instanceof OpencodeAcpError || error instanceof RpcError)
1035
1267
  return error.code;
1036
1268
  return 'CURSOR_UNAVAILABLE';
1037
1269
  }