ds-harness-remote 0.5.8 → 0.5.11

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 (232) 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 +12 -4
  12. package/dist/acp/adapters/cursor-process.d.ts.map +1 -1
  13. package/dist/acp/adapters/cursor-process.js +86 -4
  14. package/dist/acp/adapters/cursor-process.js.map +1 -1
  15. package/dist/acp/adapters/isolated-process.d.ts +45 -0
  16. package/dist/acp/adapters/isolated-process.d.ts.map +1 -0
  17. package/dist/acp/adapters/isolated-process.js +299 -0
  18. package/dist/acp/adapters/isolated-process.js.map +1 -0
  19. package/dist/acp/adapters/opencode-native-api.d.ts +16 -0
  20. package/dist/acp/adapters/opencode-native-api.d.ts.map +1 -0
  21. package/dist/acp/adapters/opencode-native-api.js +103 -0
  22. package/dist/acp/adapters/opencode-native-api.js.map +1 -0
  23. package/dist/acp/adapters/opencode-process.d.ts +86 -0
  24. package/dist/acp/adapters/opencode-process.d.ts.map +1 -0
  25. package/dist/acp/adapters/opencode-process.js +483 -0
  26. package/dist/acp/adapters/opencode-process.js.map +1 -0
  27. package/dist/acp/adapters/opencode.d.ts +8 -0
  28. package/dist/acp/adapters/opencode.d.ts.map +1 -0
  29. package/dist/acp/adapters/opencode.js +8 -0
  30. package/dist/acp/adapters/opencode.js.map +1 -0
  31. package/dist/acp/adapters/pi-process.d.ts +5 -0
  32. package/dist/acp/adapters/pi-process.d.ts.map +1 -0
  33. package/dist/acp/adapters/pi-process.js +5 -0
  34. package/dist/acp/adapters/pi-process.js.map +1 -0
  35. package/dist/acp/adapters/session-models.d.ts +33 -0
  36. package/dist/acp/adapters/session-models.d.ts.map +1 -0
  37. package/dist/acp/adapters/session-models.js +78 -0
  38. package/dist/acp/adapters/session-models.js.map +1 -0
  39. package/dist/acp/gateway.d.ts +10 -0
  40. package/dist/acp/gateway.d.ts.map +1 -1
  41. package/dist/acp/gateway.js +405 -71
  42. package/dist/acp/gateway.js.map +1 -1
  43. package/dist/acp/history-events.d.ts +8 -0
  44. package/dist/acp/history-events.d.ts.map +1 -0
  45. package/dist/acp/history-events.js +119 -0
  46. package/dist/acp/history-events.js.map +1 -0
  47. package/dist/acp/method-policy.d.ts +30 -20
  48. package/dist/acp/method-policy.d.ts.map +1 -1
  49. package/dist/acp/method-policy.js +6 -5
  50. package/dist/acp/method-policy.js.map +1 -1
  51. package/dist/acp/peer-bridge.d.ts +3 -2
  52. package/dist/acp/peer-bridge.d.ts.map +1 -1
  53. package/dist/acp/peer-bridge.js +4 -0
  54. package/dist/acp/peer-bridge.js.map +1 -1
  55. package/dist/acp/pi-tool-result.d.ts +9 -0
  56. package/dist/acp/pi-tool-result.d.ts.map +1 -0
  57. package/dist/acp/pi-tool-result.js +22 -0
  58. package/dist/acp/pi-tool-result.js.map +1 -0
  59. package/dist/acp/virtual-catalog.d.ts +50 -0
  60. package/dist/acp/virtual-catalog.d.ts.map +1 -0
  61. package/dist/acp/virtual-catalog.js +90 -0
  62. package/dist/acp/virtual-catalog.js.map +1 -0
  63. package/dist/acp/virtual-contract.d.ts +172 -0
  64. package/dist/acp/virtual-contract.d.ts.map +1 -0
  65. package/dist/acp/virtual-contract.js +216 -0
  66. package/dist/acp/virtual-contract.js.map +1 -0
  67. package/dist/acp/virtual-event-carrier.d.ts +51 -0
  68. package/dist/acp/virtual-event-carrier.d.ts.map +1 -0
  69. package/dist/acp/virtual-event-carrier.js +222 -0
  70. package/dist/acp/virtual-event-carrier.js.map +1 -0
  71. package/dist/acp/virtual-harness.d.ts +18 -112
  72. package/dist/acp/virtual-harness.d.ts.map +1 -1
  73. package/dist/acp/virtual-harness.js +238 -952
  74. package/dist/acp/virtual-harness.js.map +1 -1
  75. package/dist/acp/virtual-live.d.ts +79 -0
  76. package/dist/acp/virtual-live.d.ts.map +1 -0
  77. package/dist/acp/virtual-live.js +514 -0
  78. package/dist/acp/virtual-live.js.map +1 -0
  79. package/dist/acp/workspace-tools.js +1 -1
  80. package/dist/acp/workspace-tools.js.map +1 -1
  81. package/dist/acp.d.ts.map +1 -1
  82. package/dist/acp.js +1 -0
  83. package/dist/acp.js.map +1 -1
  84. package/dist/client/header.d.ts +10 -0
  85. package/dist/client/header.d.ts.map +1 -0
  86. package/dist/client/header.js +169 -0
  87. package/dist/client/header.js.map +1 -0
  88. package/dist/client/icons.d.ts +3 -0
  89. package/dist/client/icons.d.ts.map +1 -0
  90. package/dist/client/icons.js +3 -0
  91. package/dist/client/icons.js.map +1 -0
  92. package/dist/client/navigation.d.ts +5 -0
  93. package/dist/client/navigation.d.ts.map +1 -0
  94. package/dist/client/navigation.js +30 -0
  95. package/dist/client/navigation.js.map +1 -0
  96. package/dist/client/presentation.d.ts +17 -0
  97. package/dist/client/presentation.d.ts.map +1 -0
  98. package/dist/client/presentation.js +208 -0
  99. package/dist/client/presentation.js.map +1 -0
  100. package/dist/client/progress.d.ts +10 -0
  101. package/dist/client/progress.d.ts.map +1 -0
  102. package/dist/client/progress.js +82 -0
  103. package/dist/client/progress.js.map +1 -0
  104. package/dist/client/settings.d.ts +10 -0
  105. package/dist/client/settings.d.ts.map +1 -0
  106. package/dist/client/settings.js +335 -0
  107. package/dist/client/settings.js.map +1 -0
  108. package/dist/client/shell.d.ts +6 -0
  109. package/dist/client/shell.d.ts.map +1 -0
  110. package/dist/client/shell.js +129 -0
  111. package/dist/client/shell.js.map +1 -0
  112. package/dist/client/types.d.ts +265 -0
  113. package/dist/client/types.d.ts.map +1 -0
  114. package/dist/client/types.js +2 -0
  115. package/dist/client/types.js.map +1 -0
  116. package/dist/client/workspaces.d.ts +11 -0
  117. package/dist/client/workspaces.d.ts.map +1 -0
  118. package/dist/client/workspaces.js +1261 -0
  119. package/dist/client/workspaces.js.map +1 -0
  120. package/dist/client-capabilities.d.ts +6 -0
  121. package/dist/client-capabilities.d.ts.map +1 -0
  122. package/dist/client-capabilities.js +90 -0
  123. package/dist/client-capabilities.js.map +1 -0
  124. package/dist/client-codex-streams.d.ts +23 -0
  125. package/dist/client-codex-streams.d.ts.map +1 -0
  126. package/dist/client-codex-streams.js +156 -0
  127. package/dist/client-codex-streams.js.map +1 -0
  128. package/dist/client-connection.d.ts +38 -0
  129. package/dist/client-connection.d.ts.map +1 -0
  130. package/dist/client-connection.js +210 -0
  131. package/dist/client-connection.js.map +1 -0
  132. package/dist/client-control.d.ts +48 -0
  133. package/dist/client-control.d.ts.map +1 -0
  134. package/dist/client-control.js +293 -0
  135. package/dist/client-control.js.map +1 -0
  136. package/dist/client-locale.d.ts +12 -0
  137. package/dist/client-locale.d.ts.map +1 -1
  138. package/dist/client-locale.js +24 -0
  139. package/dist/client-locale.js.map +1 -1
  140. package/dist/client-runtime-contract.d.ts +162 -0
  141. package/dist/client-runtime-contract.d.ts.map +1 -0
  142. package/dist/client-runtime-contract.js +127 -0
  143. package/dist/client-runtime-contract.js.map +1 -0
  144. package/dist/client-runtime.d.ts +24 -126
  145. package/dist/client-runtime.d.ts.map +1 -1
  146. package/dist/client-runtime.js +171 -867
  147. package/dist/client-runtime.js.map +1 -1
  148. package/dist/client-secure-transport.d.ts +1 -0
  149. package/dist/client-secure-transport.d.ts.map +1 -1
  150. package/dist/client-secure-transport.js +35 -16
  151. package/dist/client-secure-transport.js.map +1 -1
  152. package/dist/client-workspace-queries.d.ts +12 -0
  153. package/dist/client-workspace-queries.d.ts.map +1 -0
  154. package/dist/client-workspace-queries.js +66 -0
  155. package/dist/client-workspace-queries.js.map +1 -0
  156. package/dist/client.d.ts.map +1 -1
  157. package/dist/client.github.js +2158 -1905
  158. package/dist/client.js +2158 -1905
  159. package/dist/client.js.map +1 -1
  160. package/dist/codex/domain.d.ts.map +1 -1
  161. package/dist/codex/domain.js +67 -46
  162. package/dist/codex/domain.js.map +1 -1
  163. package/dist/codex/method-policy.d.ts +7 -7
  164. package/dist/codex/virtual-catalog.d.ts +90 -0
  165. package/dist/codex/virtual-catalog.d.ts.map +1 -0
  166. package/dist/codex/virtual-catalog.js +317 -0
  167. package/dist/codex/virtual-catalog.js.map +1 -0
  168. package/dist/codex/virtual-contract.d.ts +96 -0
  169. package/dist/codex/virtual-contract.d.ts.map +1 -0
  170. package/dist/codex/virtual-contract.js +192 -0
  171. package/dist/codex/virtual-contract.js.map +1 -0
  172. package/dist/codex/virtual-event-carrier.d.ts +52 -0
  173. package/dist/codex/virtual-event-carrier.d.ts.map +1 -0
  174. package/dist/codex/virtual-event-carrier.js +212 -0
  175. package/dist/codex/virtual-event-carrier.js.map +1 -0
  176. package/dist/codex/virtual-harness.d.ts +16 -117
  177. package/dist/codex/virtual-harness.d.ts.map +1 -1
  178. package/dist/codex/virtual-harness.js +123 -1909
  179. package/dist/codex/virtual-harness.js.map +1 -1
  180. package/dist/codex/virtual-history.d.ts +74 -0
  181. package/dist/codex/virtual-history.d.ts.map +1 -0
  182. package/dist/codex/virtual-history.js +546 -0
  183. package/dist/codex/virtual-history.js.map +1 -0
  184. package/dist/codex/virtual-live.d.ts +75 -0
  185. package/dist/codex/virtual-live.d.ts.map +1 -0
  186. package/dist/codex/virtual-live.js +563 -0
  187. package/dist/codex/virtual-live.js.map +1 -0
  188. package/dist/codex/virtual-values.d.ts +29 -0
  189. package/dist/codex/virtual-values.d.ts.map +1 -0
  190. package/dist/codex/virtual-values.js +75 -0
  191. package/dist/codex/virtual-values.js.map +1 -0
  192. package/dist/config.js +4 -4
  193. package/dist/config.js.map +1 -1
  194. package/dist/control-runtime.js +2 -2
  195. package/dist/control-runtime.js.map +1 -1
  196. package/dist/harness-remote-bridge.d.ts +2 -1
  197. package/dist/harness-remote-bridge.d.ts.map +1 -1
  198. package/dist/harness-remote-bridge.js +11 -3
  199. package/dist/harness-remote-bridge.js.map +1 -1
  200. package/dist/index.d.ts.map +1 -1
  201. package/dist/index.js +17301 -14516
  202. package/dist/index.js.map +4 -4
  203. package/dist/plugin-route-dispatcher.d.ts +2 -2
  204. package/dist/plugin-route-dispatcher.d.ts.map +1 -1
  205. package/dist/plugin-route-dispatcher.js +2 -2
  206. package/dist/plugin-route-dispatcher.js.map +1 -1
  207. package/dist/remote-typert-gateway.js +49 -2
  208. package/dist/remote-typert-gateway.js.map +1 -1
  209. package/dist/server-connection.d.ts +1 -0
  210. package/dist/server-connection.d.ts.map +1 -1
  211. package/dist/server-connection.js +6 -1
  212. package/dist/server-connection.js.map +1 -1
  213. package/dist/service.d.ts +1 -1
  214. package/dist/service.d.ts.map +1 -1
  215. package/dist/service.js +16 -5
  216. package/dist/service.js.map +1 -1
  217. package/dist/terminal-carrier-compat.d.ts +14 -0
  218. package/dist/terminal-carrier-compat.d.ts.map +1 -0
  219. package/dist/terminal-carrier-compat.js +259 -0
  220. package/dist/terminal-carrier-compat.js.map +1 -0
  221. package/dist/typert-gateway-contract.d.ts +6 -1
  222. package/dist/typert-gateway-contract.d.ts.map +1 -1
  223. package/dist/typert-gateway-switch.d.ts +2 -1
  224. package/dist/typert-gateway-switch.d.ts.map +1 -1
  225. package/dist/typert-gateway-switch.js +31 -15
  226. package/dist/typert-gateway-switch.js.map +1 -1
  227. package/dist/version.d.ts +1 -1
  228. package/dist/version.d.ts.map +1 -1
  229. package/dist/version.js +1 -1
  230. package/dist/version.js.map +1 -1
  231. package/dsh-plugin.json +1 -1
  232. package/package.json +21 -19
@@ -1,3 +1,4 @@
1
+ import { IsolatedAcpClient } from './adapters/isolated-process.js';
1
2
  import { WorkspaceBridge, WorkspaceBridgeState } from '../workspace-bridge.js';
2
3
  import { TerminalPolicy } from '../terminal-policy.js';
3
4
  import { AcpWorkspaceTools } from './workspace-tools.js';
@@ -7,8 +8,11 @@ import { homedir } from 'node:os';
7
8
  import { basename, isAbsolute, join, relative, resolve } from 'node:path';
8
9
  import { RpcError } from '../safe-error.js';
9
10
  import { PLUGIN_VERSION } from '../version.js';
10
- import { CursorAcpClient, CursorAcpError, } from './adapters/cursor-process.js';
11
+ import { CursorAcpError, } from './adapters/cursor-process.js';
12
+ import { ClaudeAcpClient } from './adapters/claude-process.js';
13
+ import { PiAcpClient } from './adapters/pi-process.js';
11
14
  import { AntigravityAcpClient } from './adapters/antigravity-process.js';
15
+ import { OpencodeAcpClient, OpencodeAcpError } from './adapters/opencode-process.js';
12
16
  import { ACP_METHOD_ALLOWLIST, isSessionMutation, parseAcpCall, sessionIdFromParams, } from './method-policy.js';
13
17
  import { discoverAntigravitySessions, discoverAntigravityWorkspaces, loadTranscriptEvents } from './adapters/antigravity/transcript-loader.js';
14
18
  import { AcpPeerBridge } from './peer-bridge.js';
@@ -60,16 +64,29 @@ export class AcpRemoteGateway {
60
64
  /** Catch-up buffer for turns that finish while the Client is reconnecting. */
61
65
  recentFrames = new Map();
62
66
  /** Per-prompt live updates; attached to prompt_completed when streaming was lossy. */
67
+ promptsInProgress = new Set();
68
+ cancelledSessions = new Set();
69
+ /** Identity fences asynchronous Pi preparation from a later retry of the same session. */
70
+ preparingPrompts = new Map();
63
71
  turnCatchUp = new Map();
64
72
  startPromise;
65
73
  startingClients = new Map();
66
74
  /** Desired configuration is saved immediately; unfinished cleanup must remain retryable. */
67
75
  pendingReconfiguration = new Set();
76
+ archivingSessions = new Set();
77
+ opencodeOperations = new Map();
68
78
  constructor(config, logger, createAcp = (binary, targetLogger, backend) => {
69
79
  if (backend.id === 'antigravity') {
70
80
  return new AntigravityAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
71
81
  }
72
- return new CursorAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
82
+ if (backend.id === 'claude')
83
+ return new ClaudeAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
84
+ if (backend.id === 'pi')
85
+ return new PiAcpClient(binary, targetLogger, { args: backend.args, cwd: backend.cwd });
86
+ if (backend.id === 'opencode') {
87
+ return new OpencodeAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
88
+ }
89
+ return new IsolatedAcpClient(binary, targetLogger, { args: backend.args, cwd: backend.cwd, backend: 'cursor' });
73
90
  }, restartDelaysMs = DEFAULT_RESTART_DELAYS_MS, terminalEnabled = () => false, terminalSpawner) {
74
91
  this.config = config;
75
92
  this.logger = logger;
@@ -82,7 +99,7 @@ export class AcpRemoteGateway {
82
99
  enabledBackends() {
83
100
  if (!this.config.enabled)
84
101
  return [];
85
- return this.config.backends.filter(item => item.enabled && ['antigravity', 'cursor'].includes(item.id)).map(item => item.id);
102
+ return this.config.backends.filter(item => item.enabled && ['antigravity', 'cursor', 'pi', 'claude', 'opencode'].includes(item.id)).map(item => item.id);
86
103
  }
87
104
  start() {
88
105
  if (this.closed)
@@ -136,17 +153,17 @@ export class AcpRemoteGateway {
136
153
  const bridge = new AcpPeerBridge(this, context, publish, this.logger);
137
154
  this.peers.set(context.connectionId, bridge);
138
155
  const workspaceBridge = new WorkspaceBridge(async (rawId) => {
139
- const match = rawId.match(/^(acp|cursor):(.+)$/);
156
+ const match = rawId.match(/^(acp|cursor|pi|opencode):(.+)$/);
140
157
  const session = match ? this.sessionCwds.get(match[2]) : undefined;
141
- const backend = match?.[1] === 'acp' ? 'antigravity' : 'cursor';
158
+ const backend = match?.[1] === 'acp' ? 'antigravity' : match?.[1] === 'pi' ? 'pi' : match?.[1] === 'opencode' ? 'opencode' : 'cursor';
142
159
  if (!session || session.backend !== backend)
143
160
  throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
144
161
  this.requireAcp(backend);
145
162
  this.requireSessionOwner(context.connectionId, match[2]);
146
163
  return session.cwd;
147
164
  }, 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)
165
+ domain: 'ACP', owns: value => typeof value === 'string' && /^(acp|cursor|pi|opencode):/.test(value),
166
+ parse: value => typeof value === 'string' && /^(acp|cursor|pi|opencode):[A-Za-z0-9_-]{1,240}$/.test(value)
150
167
  ? { sessionId: value, nativeId: value } : undefined,
151
168
  });
152
169
  this.workspaceTools.set(context.connectionId, new AcpWorkspaceTools(workspaceBridge, new TerminalPolicy(this.terminalEnabled, context.peerDeviceId, this.terminalOwners)));
@@ -155,13 +172,39 @@ export class AcpRemoteGateway {
155
172
  async call(connectionId, input) {
156
173
  const envelope = parseCallEnvelope(input);
157
174
  const call = parseAcpCall(envelope.method, envelope.params);
175
+ const sessionId = sessionIdFromParams(call.method, call.params);
176
+ const backend = call.params.backend ?? (sessionId ? this.sessionBackends.get(sessionId) ?? this.defaultBackend() : undefined);
177
+ const reserve = backend === 'opencode' && sessionId !== undefined && ['session/load', 'session/prompt', 'dsh/selectModel'].includes(call.method);
178
+ if (reserve) {
179
+ if (this.archivingSessions.has(sessionId))
180
+ throw new RpcError('ACP_SESSION_BUSY', 'The OpenCode session is being archived.');
181
+ this.opencodeOperations.set(sessionId, (this.opencodeOperations.get(sessionId) ?? 0) + 1);
182
+ }
183
+ try {
184
+ return await this.callInternal(connectionId, call);
185
+ }
186
+ finally {
187
+ if (reserve) {
188
+ const remaining = (this.opencodeOperations.get(sessionId) ?? 1) - 1;
189
+ if (remaining)
190
+ this.opencodeOperations.set(sessionId, remaining);
191
+ else
192
+ this.opencodeOperations.delete(sessionId);
193
+ }
194
+ }
195
+ }
196
+ async callInternal(connectionId, call) {
158
197
  this.logger.info('ACP call received', {
159
198
  method: call.method,
160
199
  sessionId: typeof call.params?.sessionId === 'string' ? shortSessionId(call.params.sessionId) : undefined,
161
200
  });
162
201
  if (typeof call.params.backend === 'string') {
202
+ const prefix = typeof call.params.sessionId === 'string' ? /^(acp|cursor|pi|claude|opencode):/.exec(call.params.sessionId)?.[1] : undefined;
203
+ if (prefix && (prefix === 'acp' ? 'antigravity' : prefix) !== call.params.backend) {
204
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
205
+ }
163
206
  const sessionId = call.method === 'dsh/sessionHistory'
164
- ? String(call.params.sessionId).replace(/^(acp|cursor):/, '')
207
+ ? String(call.params.sessionId).replace(/^(acp|cursor|pi|claude|opencode):/, '')
165
208
  : sessionIdFromParams(call.method, call.params);
166
209
  const boundBackend = sessionId === undefined ? undefined : this.sessionBackends.get(sessionId);
167
210
  if (boundBackend !== undefined && boundBackend !== call.params.backend) {
@@ -174,10 +217,82 @@ export class AcpRemoteGateway {
174
217
  this.requireAcp(requestedBackend);
175
218
  return this.initializeResult(call.params, requestedBackend);
176
219
  }
220
+ if (call.method === 'dsh/sessionArchive') {
221
+ const sessionId = String(call.params.sessionId);
222
+ if (this.archivingSessions.has(sessionId) || this.opencodeOperations.has(sessionId))
223
+ throw new RpcError('ACP_SESSION_BUSY', 'The OpenCode session is busy.');
224
+ this.archivingSessions.add(sessionId);
225
+ try {
226
+ const backend = 'opencode';
227
+ const client = this.requireAcp(backend);
228
+ const instance = this.backendInstances.get(backend);
229
+ const cwd = await this.catalogSessionCwd(backend, sessionId);
230
+ if (this.closed || this.requireAcp(backend) !== client || this.backendInstances.get(backend) !== instance)
231
+ throw new RpcError('OPENCODE_ARCHIVE_UNAVAILABLE', 'The OpenCode backend changed. Refresh the session list.');
232
+ if (this.promptsInProgress.has(sessionId) || [...this.approvals.values()].some(item => item.sessionId === sessionId))
233
+ throw new RpcError('ACP_SESSION_BUSY', 'Stop the OpenCode reply and resolve its approvals before archiving.');
234
+ if (!client.archiveSession)
235
+ throw new RpcError('OPENCODE_ARCHIVE_UNAVAILABLE', 'The Host OpenCode adapter does not support native archiving.');
236
+ await client.archiveSession(sessionId, cwd);
237
+ if (this.closed || this.requireAcp(backend) !== client || this.backendInstances.get(backend) !== instance)
238
+ throw new RpcError('OPENCODE_ARCHIVE_UNAVAILABLE', 'The OpenCode backend changed. Refresh the session list to confirm the archive state.');
239
+ const result = { backend, sessionId, archived: true };
240
+ this.inboundChain = this.inboundChain.then(async () => {
241
+ if (!this.closed && this.backendInstances.get(backend) === instance) {
242
+ await Promise.allSettled([...this.peers.values()].map(peer => peer.publishArchived(result)));
243
+ }
244
+ });
245
+ await this.inboundChain;
246
+ if (this.closed || this.backendInstances.get(backend) !== instance)
247
+ throw new RpcError('OPENCODE_ARCHIVE_UNAVAILABLE', 'The OpenCode backend changed. Refresh the session list to confirm the archive state.');
248
+ return result;
249
+ }
250
+ finally {
251
+ this.archivingSessions.delete(sessionId);
252
+ }
253
+ }
254
+ if (typeof call.params.sessionId === 'string' && this.archivingSessions.has(call.params.sessionId)
255
+ && ['session/load', 'session/prompt', 'dsh/selectModel'].includes(call.method))
256
+ throw new RpcError('ACP_SESSION_BUSY', 'The OpenCode session is being archived.');
257
+ const controlId = sessionIdFromParams(call.method, call.params);
258
+ if (controlId && (call.params.backend ?? this.sessionBackends.get(controlId) ?? this.defaultBackend()) === 'opencode'
259
+ && ['session/load', 'session/prompt', 'dsh/selectModel'].includes(call.method)) {
260
+ const client = this.requireAcp('opencode');
261
+ if (client.isSessionArchived) {
262
+ const cwd = await this.catalogSessionCwd('opencode', controlId);
263
+ const archived = await client.isSessionArchived(controlId, cwd);
264
+ if (this.closed || this.requireAcp('opencode') !== client)
265
+ throw new RpcError('OPENCODE_ARCHIVE_UNAVAILABLE', 'The OpenCode backend changed.');
266
+ if (archived)
267
+ throw new RpcError('ACP_SESSION_ARCHIVED', 'This OpenCode session is archived.');
268
+ }
269
+ }
177
270
  if (call.method === 'dsh/directoryList') {
178
271
  return this.listDirectory(String(call.params.path));
179
272
  }
180
273
  if (call.method === 'dsh/workspaceList') {
274
+ const requestedBackend = typeof call.params.backend === 'string' ? call.params.backend : this.defaultBackend();
275
+ if (requestedBackend === 'pi' || requestedBackend === 'claude' || requestedBackend === 'opencode') {
276
+ const sessions = await this.listCatalogSessions(requestedBackend);
277
+ const discovered = [...new Set(sessions.map(item => item.cwd))].map(path => ({ path, title: basename(path) || 'workspace' }));
278
+ if (discovered.length > 0 || requestedBackend !== 'opencode')
279
+ return discovered;
280
+ const defaultCandidates = new Set();
281
+ const cwd = process.cwd();
282
+ if (cwd)
283
+ defaultCandidates.add(cwd);
284
+ for (const candidate of ['/var/lib/dsh/workspace/ds-harness-remote', '/srv/dsh-workspace', '/var/lib/dsh/local']) {
285
+ try {
286
+ const s = await stat(candidate);
287
+ if (s.isDirectory())
288
+ defaultCandidates.add(candidate);
289
+ }
290
+ catch {
291
+ // ignore
292
+ }
293
+ }
294
+ return [...defaultCandidates].map(path => ({ path, title: basename(path) || 'workspace' }));
295
+ }
181
296
  const candidates = new Set();
182
297
  const cwd = process.cwd();
183
298
  if (cwd)
@@ -243,23 +358,44 @@ export class AcpRemoteGateway {
243
358
  this.logger.info('ACP session list fetched', { count: items.length });
244
359
  return { items };
245
360
  }
361
+ if (backend === 'pi' || backend === 'claude' || backend === 'opencode') {
362
+ const cwd = path ? await this.requireExistingDirectory(path) : undefined;
363
+ const sessions = await this.listCatalogSessions(backend, cwd);
364
+ sessions.sort((a, b) => (Date.parse(b.updatedAt ?? '') || 0) - (Date.parse(a.updatedAt ?? '') || 0)
365
+ || a.sessionId.localeCompare(b.sessionId));
366
+ 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 })) };
367
+ }
246
368
  return { items: [] };
247
369
  }
248
370
  if (call.method === 'dsh/sessionHistory') {
249
371
  const rawSessionId = String(call.params.sessionId);
250
- const conversationId = rawSessionId.startsWith('acp:')
372
+ const conversationId = rawSessionId.startsWith('claude:') ? rawSessionId.slice(7) : rawSessionId.startsWith('pi:') ? rawSessionId.slice(3) : rawSessionId.startsWith('acp:')
251
373
  ? rawSessionId.slice('acp:'.length)
252
374
  : rawSessionId.startsWith('cursor:')
253
375
  ? rawSessionId.slice('cursor:'.length)
254
- : rawSessionId;
376
+ : rawSessionId.startsWith('opencode:')
377
+ ? rawSessionId.slice('opencode:'.length)
378
+ : rawSessionId;
255
379
  const boundBackend = this.sessionBackends.get(conversationId);
256
380
  const backend = typeof call.params.backend === 'string' ? call.params.backend : boundBackend ?? this.defaultBackend();
257
381
  if ((boundBackend !== undefined && boundBackend !== backend)
382
+ || (rawSessionId.startsWith('claude:') && backend !== 'claude')
383
+ || (rawSessionId.startsWith('pi:') && backend !== 'pi')
258
384
  || (rawSessionId.startsWith('cursor:') && backend !== 'cursor')
385
+ || (rawSessionId.startsWith('opencode:') && backend !== 'opencode')
259
386
  || (rawSessionId.startsWith('acp:') && backend !== 'antigravity')) {
260
387
  throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
261
388
  }
262
389
  this.requireAcp(backend);
390
+ if (backend === 'pi' || backend === 'claude' || backend === 'opencode') {
391
+ const cwd = await this.catalogSessionCwd(backend, conversationId);
392
+ // History uses an isolated reader and must neither require nor claim
393
+ // the live session's control ownership.
394
+ const client = this.requireAcp(backend);
395
+ if (!client.history)
396
+ throw new RpcError('METHOD_NOT_ALLOWED', 'The ACP backend does not support history.');
397
+ return { events: await client.history(conversationId, cwd) };
398
+ }
263
399
  if (backend !== 'antigravity')
264
400
  return { events: [] };
265
401
  const events = await loadTranscriptEvents(conversationId, rawSessionId);
@@ -282,6 +418,10 @@ export class AcpRemoteGateway {
282
418
  });
283
419
  const sessionId = readSessionId(result);
284
420
  if (sessionId !== undefined) {
421
+ const bound = this.sessionBackends.get(sessionId);
422
+ if (this.sessionOwners.has(sessionId) || this.sessionCwds.has(sessionId) || (bound !== undefined && bound !== requestedBackend)) {
423
+ throw new RpcError('ACP_SESSION_COLLISION', 'The ACP backend returned an existing session identifier.');
424
+ }
285
425
  this.sessionOwners.set(sessionId, connectionId);
286
426
  this.sessionBackends.set(sessionId, requestedBackend);
287
427
  this.sessionCwds.set(sessionId, { backend: requestedBackend, cwd });
@@ -289,7 +429,7 @@ export class AcpRemoteGateway {
289
429
  return sanitizeSessionResult(result);
290
430
  }
291
431
  const sessionId = sessionIdFromParams(call.method, call.params);
292
- if (sessionId !== undefined)
432
+ if (sessionId !== undefined && call.method !== 'session/load')
293
433
  this.requireSessionAccess(connectionId, sessionId, call.method);
294
434
  if (call.method === 'dsh/toolCall') {
295
435
  this.requireSessionOwner(connectionId, sessionId);
@@ -307,14 +447,24 @@ export class AcpRemoteGateway {
307
447
  }
308
448
  if (call.method === 'session/load') {
309
449
  const boundBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
310
- const targetBackend = typeof call.params.backend === 'string' ? call.params.backend : boundBackend;
450
+ if (typeof call.params.backend !== 'string')
451
+ throw new RpcError('INVALID_MESSAGE', 'Session load requires an explicit backend.');
452
+ const targetBackend = call.params.backend;
453
+ if (targetBackend === 'claude' && this.promptsInProgress.has(sessionId))
454
+ throw new RpcError('PROMPT_IN_PROGRESS', 'An ACP reply is already in progress.');
311
455
  if (boundBackend !== undefined && targetBackend !== boundBackend) {
312
456
  throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
313
457
  }
458
+ if (targetBackend === 'pi' || targetBackend === 'claude' || targetBackend === 'opencode')
459
+ await this.catalogSessionCwd(targetBackend, sessionId);
460
+ this.requireSessionOwner(connectionId, sessionId);
314
461
  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 } : {}) });
462
+ const result = await acp.call(call.method, { ...call.params, ...(sessionId && this.sessionCwds.get(sessionId) ? { cwd: this.sessionCwds.get(sessionId).cwd, mcpServers: [] } : {}) });
316
463
  const loadedId = readSessionId(result) ?? sessionId;
317
464
  if (loadedId !== undefined) {
465
+ if (loadedId !== sessionId || (this.sessionBackends.has(loadedId) && targetBackend !== undefined && this.sessionBackends.get(loadedId) !== targetBackend)) {
466
+ throw new RpcError('ACP_SESSION_COLLISION', 'The ACP backend returned a different session identifier.');
467
+ }
318
468
  this.sessionOwners.set(loadedId, connectionId);
319
469
  if (targetBackend)
320
470
  this.sessionBackends.set(loadedId, targetBackend);
@@ -324,7 +474,15 @@ export class AcpRemoteGateway {
324
474
  if (isSessionMutation(call.method) && sessionId !== undefined) {
325
475
  this.requireSessionOwner(connectionId, sessionId);
326
476
  }
477
+ if ((call.method === 'dsh/sessionModels' || call.method === 'dsh/selectModel') && (call.params.backend === 'pi' || call.params.backend === 'claude' || call.params.backend === 'opencode')) {
478
+ this.requireSessionOwner(connectionId, sessionId);
479
+ const backend = call.params.backend;
480
+ const cwd = await this.catalogSessionCwd(backend, sessionId);
481
+ await this.requireAcp(backend).call('session/load', { sessionId, cwd, mcpServers: [] });
482
+ }
327
483
  if (call.method === 'dsh/selectModel') {
484
+ if (this.promptsInProgress.has(sessionId))
485
+ throw new RpcError('PROMPT_IN_PROGRESS', 'Stop the ACP reply before changing its model.');
328
486
  const session = this.sessionCwds.get(sessionId);
329
487
  if (!session || session.backend !== call.params.backend) {
330
488
  throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
@@ -340,6 +498,36 @@ export class AcpRemoteGateway {
340
498
  // interleaved agent.acp.frame events (Android stays on "正在回复" with no
341
499
  // thought/text). Accept immediately and finish via stream updates.
342
500
  if (call.method === 'session/prompt' && sessionId !== undefined) {
501
+ if (this.promptsInProgress.has(sessionId))
502
+ throw new RpcError('PROMPT_IN_PROGRESS', 'An ACP reply is already in progress.');
503
+ this.promptsInProgress.add(sessionId);
504
+ this.cancelledSessions.delete(sessionId);
505
+ const preparationBackend = this.sessionBackends.get(sessionId);
506
+ if (preparationBackend === 'pi' || preparationBackend === 'claude') {
507
+ const preparation = { cancelled: false };
508
+ this.preparingPrompts.set(sessionId, preparation);
509
+ try {
510
+ const cwd = await this.catalogSessionCwd(preparationBackend, sessionId);
511
+ if (preparation.cancelled)
512
+ return { accepted: false, stopReason: 'cancelled' };
513
+ await this.requireAcp(preparationBackend).call('session/load', { sessionId, cwd, mcpServers: [] });
514
+ if (preparation.cancelled)
515
+ return { accepted: false, stopReason: 'cancelled' };
516
+ }
517
+ catch (error) {
518
+ if (preparation.cancelled)
519
+ return { accepted: false, stopReason: 'cancelled' };
520
+ this.promptsInProgress.delete(sessionId);
521
+ throw error;
522
+ }
523
+ finally {
524
+ if (this.preparingPrompts.get(sessionId) === preparation) {
525
+ this.preparingPrompts.delete(sessionId);
526
+ if (preparation.cancelled)
527
+ this.promptsInProgress.delete(sessionId);
528
+ }
529
+ }
530
+ }
343
531
  const ownerPeer = this.peers.get(connectionId);
344
532
  if (ownerPeer !== undefined && !ownerPeer.hasStreamFor(sessionId)) {
345
533
  this.logger?.warn('Cursor prompt started without an open ACP stream', {
@@ -350,6 +538,29 @@ export class AcpRemoteGateway {
350
538
  void this.runPromptInBackground(sessionId, call.params);
351
539
  return { accepted: true, stopReason: 'in_progress' };
352
540
  }
541
+ if (call.method === 'session/cancel' && sessionId !== undefined) {
542
+ this.cancelledSessions.add(sessionId);
543
+ const preparation = this.preparingPrompts.get(sessionId);
544
+ if (preparation) {
545
+ // No turn exists upstream yet. Do not send a late cancel into a retry
546
+ // sharing this load: cancel only this preparation identity.
547
+ preparation.cancelled = true;
548
+ this.preparingPrompts.delete(sessionId);
549
+ this.promptsInProgress.delete(sessionId);
550
+ return { stopReason: 'cancelled' };
551
+ }
552
+ const pending = [...this.approvals].filter(([, approval]) => approval.sessionId === sessionId && approval.backend === this.sessionBackends.get(sessionId));
553
+ for (const [handle] of pending)
554
+ this.approvals.delete(handle);
555
+ // Send cancellation before rejecting approvals: rejecting a blocked tool first
556
+ // can otherwise complete the turn as end_turn before upstream sees cancel.
557
+ try {
558
+ return sanitizeSessionResult(await this.requireAcp(this.sessionBackends.get(sessionId)).call(call.method, call.params));
559
+ }
560
+ finally {
561
+ await Promise.all(pending.map(([, approval]) => approval.client.respondError(approval.upstreamId, -32800, 'ACP session cancelled.').catch(() => undefined)));
562
+ }
563
+ }
353
564
  const sessionBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
354
565
  return sanitizeSessionResult(await this.requireAcp(typeof call.params.backend === 'string' ? call.params.backend : sessionBackend).call(call.method, call.params));
355
566
  }
@@ -399,6 +610,9 @@ export class AcpRemoteGateway {
399
610
  },
400
611
  }).catch(() => undefined);
401
612
  }
613
+ finally {
614
+ this.promptsInProgress.delete(sessionId);
615
+ }
402
616
  }
403
617
  async respond(connectionId, input) {
404
618
  const params = parseRespondEnvelope(input);
@@ -410,13 +624,18 @@ export class AcpRemoteGateway {
410
624
  if (pending.connectionId !== connectionId) {
411
625
  throw new RpcError('CURSOR_APPROVAL_NOT_FOUND', 'The Cursor approval is missing, expired, or belongs to another connection.');
412
626
  }
627
+ if (this.sessionOwners.get(pending.sessionId) !== connectionId
628
+ || this.sessionBackends.get(pending.sessionId) !== pending.backend
629
+ || this.requireAcp(pending.backend) !== pending.client) {
630
+ throw new RpcError('CURSOR_APPROVAL_NOT_FOUND', 'The ACP approval scope is no longer active.');
631
+ }
632
+ const result = mapPermissionDecision(params, pending);
413
633
  this.approvals.delete(params.requestHandle);
414
- const acp = this.requireAcp(pending.backend);
634
+ const acp = pending.client;
415
635
  if (params.decision === 'cancel') {
416
636
  await acp.respondError(pending.upstreamId, -32800, 'Cancelled by Remote client.');
417
637
  return { resolved: true };
418
638
  }
419
- const result = params.result ?? mapPermissionDecision(params.decision, pending.method);
420
639
  await acp.respond(pending.upstreamId, result);
421
640
  return { resolved: true };
422
641
  }
@@ -431,7 +650,7 @@ export class AcpRemoteGateway {
431
650
  for (const [handle, approval] of this.approvals) {
432
651
  if (approval.connectionId === connectionId) {
433
652
  this.approvals.delete(handle);
434
- void this.requireAcp(approval.backend).respondError(approval.upstreamId, -32800, 'Remote peer disconnected.').catch(() => undefined);
653
+ void approval.client.respondError(approval.upstreamId, -32800, 'Remote peer disconnected.').catch(() => undefined);
435
654
  }
436
655
  }
437
656
  }
@@ -465,7 +684,7 @@ export class AcpRemoteGateway {
465
684
  return;
466
685
  }
467
686
  if (owner !== connectionId) {
468
- throw new RpcError('CURSOR_SESSION_OWNED', 'Another Remote connection owns this Cursor session.');
687
+ throw new RpcError('CURSOR_SESSION_OWNED', 'Another Remote connection owns this session.');
469
688
  }
470
689
  }
471
690
  /** Replay frames buffered while no healthy peer could receive them. */
@@ -491,7 +710,7 @@ export class AcpRemoteGateway {
491
710
  throw new RpcError('CURSOR_CLOSED', 'The ACP Remote domain is closed.');
492
711
  const previous = this.config;
493
712
  const implemented = (value) => ({ enabled: value.enabled,
494
- backends: value.backends.filter(item => ['cursor', 'antigravity'].includes(item.id)) });
713
+ backends: value.backends.filter(item => ['cursor', 'antigravity', 'pi', 'claude', 'opencode'].includes(item.id)) });
495
714
  this.config = config;
496
715
  if (JSON.stringify(implemented(previous)) === JSON.stringify(implemented(config)) && this.pendingReconfiguration.size === 0)
497
716
  return;
@@ -512,7 +731,7 @@ export class AcpRemoteGateway {
512
731
  for (const [id, session] of this.sessionCwds)
513
732
  if (changed.has(session.backend))
514
733
  this.sessionCwds.delete(id);
515
- const terminalError = await this.workspaceState.dispose(sessionId => changed.has(sessionId.startsWith('acp:') ? 'antigravity' : 'cursor'))
734
+ const terminalError = await this.workspaceState.dispose(sessionId => changed.has(sessionId.startsWith('acp:') ? 'antigravity' : sessionId.startsWith('pi:') ? 'pi' : sessionId.startsWith('opencode:') ? 'opencode' : 'cursor'))
516
735
  .then(() => undefined, () => new RpcError('TERMINAL_CLEANUP_FAILED', 'Some ACP terminal processes could not be stopped. Save the backend settings again to retry.'));
517
736
  for (const id of sessions) {
518
737
  this.sessionOwners.delete(id);
@@ -635,7 +854,7 @@ export class AcpRemoteGateway {
635
854
  }
636
855
  const unsubscribeInbound = acp.onInbound(message => {
637
856
  this.inboundChain = this.inboundChain
638
- .then(() => this.handleInbound(id, message))
857
+ .then(() => this.backendInstances.get(id)?.client === acp ? this.handleInbound(id, message) : undefined)
639
858
  .catch(error => {
640
859
  this.logger.warn('ACP inbound fanout failed', { id, code: errorCode(error) });
641
860
  });
@@ -660,6 +879,13 @@ export class AcpRemoteGateway {
660
879
  });
661
880
  }
662
881
  async handleInbound(backend, message) {
882
+ const incomingId = readSessionId(message.params) ?? readNestedSessionId(message.params);
883
+ const boundBackend = incomingId === undefined ? undefined : this.sessionBackends.get(incomingId);
884
+ if (boundBackend !== undefined && boundBackend !== backend) {
885
+ if (message.kind === 'request')
886
+ await this.requireAcp(backend).respondError(message.id, -32602, 'The ACP session backend does not match.');
887
+ return;
888
+ }
663
889
  // session/update is a stream notification even if a buggy agent attaches an id.
664
890
  if (message.kind === 'notification' || message.method === 'session/update') {
665
891
  const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params);
@@ -671,38 +897,44 @@ export class AcpRemoteGateway {
671
897
  await this.publishToSession(sessionId, { method: message.method, params: message.params });
672
898
  return;
673
899
  }
674
- const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params) ?? 'unknown';
675
- if (sessionId !== 'unknown' && !this.sessionBackends.has(sessionId)) {
676
- this.sessionBackends.set(sessionId, backend);
900
+ const sessionId = incomingId;
901
+ const owner = sessionId === undefined ? undefined : this.sessionOwners.get(sessionId);
902
+ const peer = owner === undefined ? undefined : this.peers.get(owner);
903
+ const client = this.requireAcp(backend);
904
+ if (sessionId === undefined || this.cancelledSessions.has(sessionId) || owner === undefined || peer === undefined || boundBackend !== backend
905
+ || !['session/request_permission', 'cursor/create_plan', 'cursor/ask_question'].includes(message.method)
906
+ || (message.method.startsWith('cursor/') && backend !== 'cursor')) {
907
+ await client.respondError(message.id, -32602, 'The ACP request has no active owner or supported method.');
908
+ return;
909
+ }
910
+ let options = [];
911
+ try {
912
+ if (message.method === 'session/request_permission')
913
+ options = permissionOptions(message.params);
914
+ }
915
+ catch {
916
+ await client.respondError(message.id, -32602, 'The ACP permission options are invalid.');
917
+ return;
677
918
  }
678
919
  const requestHandle = randomUUID();
679
920
  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,
921
+ upstreamId: message.id, connectionId: owner, sessionId, method: message.method,
922
+ expiresAt: Date.now() + APPROVAL_TTL_MS, backend, client, options,
686
923
  });
687
924
  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
- }
925
+ try {
926
+ await peer.publishInbound(sessionId, {
927
+ method: message.method,
928
+ params: {
929
+ ...(isRecord(message.params) ? message.params : {}),
930
+ requestHandle, sessionId, upstreamMethod: message.method,
931
+ },
932
+ });
933
+ }
934
+ catch {
935
+ this.approvals.delete(requestHandle);
936
+ await client.respondError(message.id, -32800, 'Remote approval delivery failed.');
704
937
  }
705
- await this.publishToSession(sessionId, frame);
706
938
  }
707
939
  async publishToSession(sessionId, frame) {
708
940
  this.recordTurnCatchUp(sessionId, frame);
@@ -808,7 +1040,7 @@ export class AcpRemoteGateway {
808
1040
  for (const [handle, approval] of this.approvals) {
809
1041
  if (approval.expiresAt <= now) {
810
1042
  this.approvals.delete(handle);
811
- void this.requireAcp(approval.backend).respondError(approval.upstreamId, -32800, 'Cursor approval expired.').catch(() => undefined);
1043
+ void approval.client.respondError(approval.upstreamId, -32800, 'Cursor approval expired.').catch(() => undefined);
812
1044
  }
813
1045
  }
814
1046
  this.scheduleApprovalExpiry();
@@ -853,8 +1085,10 @@ export class AcpRemoteGateway {
853
1085
  return this.requireAcp().call(method, params);
854
1086
  }
855
1087
  requireSessionAccess(connectionId, sessionId, method) {
856
- if (method === 'session/load')
1088
+ if (method === 'session/load') {
1089
+ this.requireSessionOwner(connectionId, sessionId);
857
1090
  return;
1091
+ }
858
1092
  const owner = this.sessionOwners.get(sessionId);
859
1093
  if (owner === undefined) {
860
1094
  // Allow reclaim after the owning peer disconnected; the ACP process still
@@ -863,12 +1097,81 @@ export class AcpRemoteGateway {
863
1097
  return;
864
1098
  }
865
1099
  if (owner !== connectionId && isSessionMutation(method)) {
866
- throw new RpcError('CURSOR_SESSION_OWNED', 'Another Remote connection owns this Cursor session.');
1100
+ throw new RpcError('CURSOR_SESSION_OWNED', 'Another Remote connection owns this session.');
867
1101
  }
868
1102
  }
869
1103
  requireSessionOwner(connectionId, sessionId) {
870
1104
  this.claimSession(connectionId, sessionId);
871
1105
  }
1106
+ captureCatalogClient(backend) {
1107
+ const client = this.requireAcp(backend);
1108
+ const instance = this.backendInstances.get(backend);
1109
+ return { client, assertCurrent: () => {
1110
+ if (this.closed || this.backendInstances.get(backend) !== instance || this.requireAcp(backend) !== client) {
1111
+ throw new RpcError('CURSOR_UNAVAILABLE', 'The ACP backend changed while resolving session authority.');
1112
+ }
1113
+ } };
1114
+ }
1115
+ async listCatalogSessions(backend, cwd) {
1116
+ const { client, assertCurrent } = this.captureCatalogClient(backend);
1117
+ const items = [];
1118
+ let cursor;
1119
+ const seen = new Set();
1120
+ for (let page = 0; page < 100; page++) {
1121
+ const result = await client.call('session/list', { ...(cwd ? { cwd } : {}), ...(cursor ? { cursor } : {}) });
1122
+ assertCurrent();
1123
+ if (!isRecord(result) || !Array.isArray(result.sessions)
1124
+ || (result.nextCursor != null && (typeof result.nextCursor !== 'string' || result.nextCursor.length === 0))) {
1125
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session catalog response is invalid.');
1126
+ }
1127
+ for (const item of result.sessions) {
1128
+ if (!isRecord(item) || typeof item.sessionId !== 'string' || typeof item.cwd !== 'string')
1129
+ continue;
1130
+ // Directory I/O can outlive a backend restart/reconfiguration. Only the
1131
+ // directory failure is skippable; a stale generation must reject the RPC.
1132
+ const canonical = await this.requireExistingDirectory(item.cwd).catch(() => undefined);
1133
+ assertCurrent();
1134
+ if (!canonical || (cwd && canonical !== cwd))
1135
+ continue;
1136
+ if (this.sessionBackends.has(item.sessionId) && this.sessionBackends.get(item.sessionId) !== backend)
1137
+ continue;
1138
+ const trusted = this.sessionCwds.get(item.sessionId);
1139
+ if (trusted && (trusted.backend !== backend || trusted.cwd !== canonical))
1140
+ continue;
1141
+ this.sessionCwds.set(item.sessionId, { backend, cwd: canonical });
1142
+ this.sessionBackends.set(item.sessionId, backend);
1143
+ if (backend === 'opencode' && item.archived === true)
1144
+ continue;
1145
+ items.push({ sessionId: item.sessionId, cwd: canonical,
1146
+ ...(typeof item.title === 'string' ? { title: item.title } : {}),
1147
+ ...(typeof item.updatedAt === 'string' ? { updatedAt: item.updatedAt } : {}) });
1148
+ }
1149
+ if (result.nextCursor == null)
1150
+ return items;
1151
+ if (seen.has(result.nextCursor) || page === 99) {
1152
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session catalog pagination did not complete.');
1153
+ }
1154
+ cursor = result.nextCursor;
1155
+ seen.add(cursor);
1156
+ }
1157
+ return items;
1158
+ }
1159
+ async catalogSessionCwd(backend, sessionId) {
1160
+ const { assertCurrent } = this.captureCatalogClient(backend);
1161
+ if (!this.sessionCwds.has(sessionId))
1162
+ await this.listCatalogSessions(backend);
1163
+ assertCurrent();
1164
+ const session = this.sessionCwds.get(sessionId);
1165
+ if (!session || session.backend !== backend)
1166
+ throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
1167
+ const canonical = await this.requireExistingDirectory(session.cwd);
1168
+ assertCurrent();
1169
+ const current = this.sessionCwds.get(sessionId);
1170
+ if (canonical !== session.cwd || current?.backend !== backend || current.cwd !== canonical) {
1171
+ throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session workspace authority changed.');
1172
+ }
1173
+ return canonical;
1174
+ }
872
1175
  async requireExistingDirectory(path) {
873
1176
  if (!isAbsolute(path)) {
874
1177
  throw new RpcError('CURSOR_PATH_NOT_ALLOWED', 'The Cursor working directory must be an absolute path.');
@@ -941,32 +1244,55 @@ function parseCallEnvelope(input) {
941
1244
  return { method: input.method, params: input.params ?? {} };
942
1245
  }
943
1246
  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.');
946
- }
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.');
1247
+ if (!isRecord(input) || typeof input.requestHandle !== 'string' || input.requestHandle.length === 0
1248
+ || Object.keys(input).some(key => !['requestHandle', 'decision', 'result'].includes(key))
1249
+ || typeof input.decision !== 'string' || !['allow-once', 'reject-once', 'cancel'].includes(input.decision)) {
1250
+ throw new RpcError('INVALID_MESSAGE', 'The ACP respond envelope is invalid.');
950
1251
  }
951
- return {
952
- requestHandle: input.requestHandle,
953
- decision,
954
- ...(input.result === undefined ? {} : { result: input.result }),
955
- };
1252
+ return input;
956
1253
  }
957
- function mapPermissionDecision(decision, method) {
958
- if (method === 'session/request_permission') {
959
- return { outcome: { outcome: 'selected', optionId: decision === 'cancel' ? 'reject-once' : decision } };
1254
+ function permissionOptions(params) {
1255
+ if (!isRecord(params) || !Array.isArray(params.options) || params.options.length === 0) {
1256
+ throw new Error('Missing permission options');
960
1257
  }
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' } };
1258
+ const ids = new Set();
1259
+ const options = params.options.map(option => {
1260
+ if (!isRecord(option) || typeof option.optionId !== 'string' || !option.optionId
1261
+ || typeof option.kind !== 'string' || !['allow_once', 'allow_always', 'reject_once', 'reject_always'].includes(option.kind)
1262
+ || ids.has(option.optionId))
1263
+ throw new Error('Invalid permission option');
1264
+ ids.add(option.optionId);
1265
+ return { kind: option.kind, optionId: option.optionId };
1266
+ });
1267
+ for (const kind of ['allow_once', 'reject_once']) {
1268
+ if (options.filter(option => option.kind === kind).length > 1)
1269
+ throw new Error('Ambiguous permission option');
965
1270
  }
966
- if (method === 'cursor/ask_question') {
967
- return { outcome: { outcome: 'cancelled' } };
1271
+ return options;
1272
+ }
1273
+ function mapPermissionDecision(params, pending) {
1274
+ const invalid = () => new RpcError('INVALID_MESSAGE', 'The ACP approval result is not allowed.');
1275
+ if (pending.method === 'session/request_permission') {
1276
+ if (params.result !== undefined)
1277
+ throw invalid();
1278
+ if (params.decision === 'cancel')
1279
+ return undefined;
1280
+ const kind = params.decision === 'allow-once' ? 'allow_once' : 'reject_once';
1281
+ const option = pending.options.find(item => item.kind === kind);
1282
+ if (!option)
1283
+ throw invalid();
1284
+ return { outcome: { outcome: 'selected', optionId: option.optionId } };
1285
+ }
1286
+ const outcome = pending.method === 'cursor/create_plan'
1287
+ ? params.decision === 'allow-once' ? 'accepted' : params.decision === 'cancel' ? 'cancelled' : 'rejected'
1288
+ : 'cancelled';
1289
+ if (params.result !== undefined) {
1290
+ if (!isRecord(params.result) || Object.keys(params.result).length !== 1
1291
+ || !isRecord(params.result.outcome) || Object.keys(params.result.outcome).length !== 1
1292
+ || params.result.outcome.outcome !== outcome)
1293
+ throw invalid();
968
1294
  }
969
- return { outcome: { outcome: 'selected', optionId: decision } };
1295
+ return { outcome: { outcome } };
970
1296
  }
971
1297
  function readSessionId(value) {
972
1298
  if (!isRecord(value))
@@ -1028,10 +1354,18 @@ export function cursorBinaryCandidates(configured) {
1028
1354
  'agent',
1029
1355
  ];
1030
1356
  }
1357
+ if (configured === 'opencode') {
1358
+ return [
1359
+ // Official installer location (curl -fsSL https://opencode.ai/install | bash).
1360
+ join(userHome, '.opencode', 'bin', 'opencode'),
1361
+ join(userHome, '.local', 'bin', 'opencode'),
1362
+ 'opencode',
1363
+ ];
1364
+ }
1031
1365
  return [configured];
1032
1366
  }
1033
1367
  function errorCode(error) {
1034
- if (error instanceof CursorAcpError || error instanceof RpcError)
1368
+ if (error instanceof CursorAcpError || error instanceof OpencodeAcpError || error instanceof RpcError)
1035
1369
  return error.code;
1036
1370
  return 'CURSOR_UNAVAILABLE';
1037
1371
  }