ds-harness-remote 0.5.7 → 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 (234) 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/antigravity/transcript-loader.d.ts.map +1 -1
  4. package/dist/acp/adapters/antigravity/transcript-loader.js +4 -1
  5. package/dist/acp/adapters/antigravity/transcript-loader.js.map +1 -1
  6. package/dist/acp/adapters/claude-history.d.ts +3 -0
  7. package/dist/acp/adapters/claude-history.d.ts.map +1 -0
  8. package/dist/acp/adapters/claude-history.js +118 -0
  9. package/dist/acp/adapters/claude-history.js.map +1 -0
  10. package/dist/acp/adapters/claude-process.d.ts +21 -0
  11. package/dist/acp/adapters/claude-process.d.ts.map +1 -0
  12. package/dist/acp/adapters/claude-process.js +147 -0
  13. package/dist/acp/adapters/claude-process.js.map +1 -0
  14. package/dist/acp/adapters/cursor-process.d.ts +9 -4
  15. package/dist/acp/adapters/cursor-process.d.ts.map +1 -1
  16. package/dist/acp/adapters/cursor-process.js +31 -3
  17. package/dist/acp/adapters/cursor-process.js.map +1 -1
  18. package/dist/acp/adapters/opencode-process.d.ts +67 -0
  19. package/dist/acp/adapters/opencode-process.d.ts.map +1 -0
  20. package/dist/acp/adapters/opencode-process.js +272 -0
  21. package/dist/acp/adapters/opencode-process.js.map +1 -0
  22. package/dist/acp/adapters/opencode.d.ts +8 -0
  23. package/dist/acp/adapters/opencode.d.ts.map +1 -0
  24. package/dist/acp/adapters/opencode.js +8 -0
  25. package/dist/acp/adapters/opencode.js.map +1 -0
  26. package/dist/acp/adapters/pi-process.d.ts +42 -0
  27. package/dist/acp/adapters/pi-process.d.ts.map +1 -0
  28. package/dist/acp/adapters/pi-process.js +410 -0
  29. package/dist/acp/adapters/pi-process.js.map +1 -0
  30. package/dist/acp/gateway.d.ts +10 -0
  31. package/dist/acp/gateway.d.ts.map +1 -1
  32. package/dist/acp/gateway.js +344 -103
  33. package/dist/acp/gateway.js.map +1 -1
  34. package/dist/acp/method-policy.d.ts +19 -19
  35. package/dist/acp/method-policy.js +5 -5
  36. package/dist/acp/method-policy.js.map +1 -1
  37. package/dist/acp/pi-tool-result.d.ts +9 -0
  38. package/dist/acp/pi-tool-result.d.ts.map +1 -0
  39. package/dist/acp/pi-tool-result.js +22 -0
  40. package/dist/acp/pi-tool-result.js.map +1 -0
  41. package/dist/acp/virtual-catalog.d.ts +50 -0
  42. package/dist/acp/virtual-catalog.d.ts.map +1 -0
  43. package/dist/acp/virtual-catalog.js +103 -0
  44. package/dist/acp/virtual-catalog.js.map +1 -0
  45. package/dist/acp/virtual-contract.d.ts +165 -0
  46. package/dist/acp/virtual-contract.d.ts.map +1 -0
  47. package/dist/acp/virtual-contract.js +216 -0
  48. package/dist/acp/virtual-contract.js.map +1 -0
  49. package/dist/acp/virtual-event-carrier.d.ts +50 -0
  50. package/dist/acp/virtual-event-carrier.d.ts.map +1 -0
  51. package/dist/acp/virtual-event-carrier.js +222 -0
  52. package/dist/acp/virtual-event-carrier.js.map +1 -0
  53. package/dist/acp/virtual-harness.d.ts +13 -112
  54. package/dist/acp/virtual-harness.d.ts.map +1 -1
  55. package/dist/acp/virtual-harness.js +129 -911
  56. package/dist/acp/virtual-harness.js.map +1 -1
  57. package/dist/acp/virtual-live.d.ts +78 -0
  58. package/dist/acp/virtual-live.d.ts.map +1 -0
  59. package/dist/acp/virtual-live.js +513 -0
  60. package/dist/acp/virtual-live.js.map +1 -0
  61. package/dist/acp/workspace-tools.js +2 -2
  62. package/dist/acp/workspace-tools.js.map +1 -1
  63. package/dist/acp.d.ts.map +1 -1
  64. package/dist/acp.js +1 -0
  65. package/dist/acp.js.map +1 -1
  66. package/dist/client/header.d.ts +10 -0
  67. package/dist/client/header.d.ts.map +1 -0
  68. package/dist/client/header.js +169 -0
  69. package/dist/client/header.js.map +1 -0
  70. package/dist/client/icons.d.ts +3 -0
  71. package/dist/client/icons.d.ts.map +1 -0
  72. package/dist/client/icons.js +3 -0
  73. package/dist/client/icons.js.map +1 -0
  74. package/dist/client/navigation.d.ts +5 -0
  75. package/dist/client/navigation.d.ts.map +1 -0
  76. package/dist/client/navigation.js +30 -0
  77. package/dist/client/navigation.js.map +1 -0
  78. package/dist/client/presentation.d.ts +17 -0
  79. package/dist/client/presentation.d.ts.map +1 -0
  80. package/dist/client/presentation.js +208 -0
  81. package/dist/client/presentation.js.map +1 -0
  82. package/dist/client/progress.d.ts +10 -0
  83. package/dist/client/progress.d.ts.map +1 -0
  84. package/dist/client/progress.js +82 -0
  85. package/dist/client/progress.js.map +1 -0
  86. package/dist/client/settings.d.ts +10 -0
  87. package/dist/client/settings.d.ts.map +1 -0
  88. package/dist/client/settings.js +335 -0
  89. package/dist/client/settings.js.map +1 -0
  90. package/dist/client/shell.d.ts +6 -0
  91. package/dist/client/shell.d.ts.map +1 -0
  92. package/dist/client/shell.js +128 -0
  93. package/dist/client/shell.js.map +1 -0
  94. package/dist/client/types.d.ts +264 -0
  95. package/dist/client/types.d.ts.map +1 -0
  96. package/dist/client/types.js +2 -0
  97. package/dist/client/types.js.map +1 -0
  98. package/dist/client/workspaces.d.ts +11 -0
  99. package/dist/client/workspaces.d.ts.map +1 -0
  100. package/dist/client/workspaces.js +1179 -0
  101. package/dist/client/workspaces.js.map +1 -0
  102. package/dist/client-capabilities.d.ts +6 -0
  103. package/dist/client-capabilities.d.ts.map +1 -0
  104. package/dist/client-capabilities.js +88 -0
  105. package/dist/client-capabilities.js.map +1 -0
  106. package/dist/client-codex-streams.d.ts +23 -0
  107. package/dist/client-codex-streams.d.ts.map +1 -0
  108. package/dist/client-codex-streams.js +156 -0
  109. package/dist/client-codex-streams.js.map +1 -0
  110. package/dist/client-connection.d.ts +38 -0
  111. package/dist/client-connection.d.ts.map +1 -0
  112. package/dist/client-connection.js +210 -0
  113. package/dist/client-connection.js.map +1 -0
  114. package/dist/client-control.d.ts +48 -0
  115. package/dist/client-control.d.ts.map +1 -0
  116. package/dist/client-control.js +273 -0
  117. package/dist/client-control.js.map +1 -0
  118. package/dist/client-locale.d.ts +8 -0
  119. package/dist/client-locale.d.ts.map +1 -1
  120. package/dist/client-locale.js +16 -0
  121. package/dist/client-locale.js.map +1 -1
  122. package/dist/client-runtime-contract.d.ts +161 -0
  123. package/dist/client-runtime-contract.d.ts.map +1 -0
  124. package/dist/client-runtime-contract.js +127 -0
  125. package/dist/client-runtime-contract.js.map +1 -0
  126. package/dist/client-runtime.d.ts +24 -126
  127. package/dist/client-runtime.d.ts.map +1 -1
  128. package/dist/client-runtime.js +171 -867
  129. package/dist/client-runtime.js.map +1 -1
  130. package/dist/client-secure-transport.d.ts +1 -0
  131. package/dist/client-secure-transport.d.ts.map +1 -1
  132. package/dist/client-secure-transport.js +35 -16
  133. package/dist/client-secure-transport.js.map +1 -1
  134. package/dist/client-workspace-queries.d.ts +12 -0
  135. package/dist/client-workspace-queries.d.ts.map +1 -0
  136. package/dist/client-workspace-queries.js +66 -0
  137. package/dist/client-workspace-queries.js.map +1 -0
  138. package/dist/client.d.ts.map +1 -1
  139. package/dist/client.github.js +2091 -1896
  140. package/dist/client.js +2091 -1896
  141. package/dist/client.js.map +1 -1
  142. package/dist/codex/domain.d.ts.map +1 -1
  143. package/dist/codex/domain.js +67 -46
  144. package/dist/codex/domain.js.map +1 -1
  145. package/dist/codex/method-policy.d.ts +9 -9
  146. package/dist/codex/virtual-catalog.d.ts +90 -0
  147. package/dist/codex/virtual-catalog.d.ts.map +1 -0
  148. package/dist/codex/virtual-catalog.js +317 -0
  149. package/dist/codex/virtual-catalog.js.map +1 -0
  150. package/dist/codex/virtual-contract.d.ts +96 -0
  151. package/dist/codex/virtual-contract.d.ts.map +1 -0
  152. package/dist/codex/virtual-contract.js +192 -0
  153. package/dist/codex/virtual-contract.js.map +1 -0
  154. package/dist/codex/virtual-event-carrier.d.ts +52 -0
  155. package/dist/codex/virtual-event-carrier.d.ts.map +1 -0
  156. package/dist/codex/virtual-event-carrier.js +212 -0
  157. package/dist/codex/virtual-event-carrier.js.map +1 -0
  158. package/dist/codex/virtual-harness.d.ts +16 -117
  159. package/dist/codex/virtual-harness.d.ts.map +1 -1
  160. package/dist/codex/virtual-harness.js +123 -1909
  161. package/dist/codex/virtual-harness.js.map +1 -1
  162. package/dist/codex/virtual-history.d.ts +74 -0
  163. package/dist/codex/virtual-history.d.ts.map +1 -0
  164. package/dist/codex/virtual-history.js +546 -0
  165. package/dist/codex/virtual-history.js.map +1 -0
  166. package/dist/codex/virtual-live.d.ts +75 -0
  167. package/dist/codex/virtual-live.d.ts.map +1 -0
  168. package/dist/codex/virtual-live.js +563 -0
  169. package/dist/codex/virtual-live.js.map +1 -0
  170. package/dist/codex/virtual-values.d.ts +29 -0
  171. package/dist/codex/virtual-values.d.ts.map +1 -0
  172. package/dist/codex/virtual-values.js +75 -0
  173. package/dist/codex/virtual-values.js.map +1 -0
  174. package/dist/config.js +4 -4
  175. package/dist/config.js.map +1 -1
  176. package/dist/control-runtime.js +2 -2
  177. package/dist/control-runtime.js.map +1 -1
  178. package/dist/harness-remote-bridge.d.ts +2 -1
  179. package/dist/harness-remote-bridge.d.ts.map +1 -1
  180. package/dist/harness-remote-bridge.js +13 -5
  181. package/dist/harness-remote-bridge.js.map +1 -1
  182. package/dist/index.d.ts.map +1 -1
  183. package/dist/index.js +23304 -15332
  184. package/dist/index.js.map +4 -4
  185. package/dist/plugin-route-dispatcher.d.ts +2 -2
  186. package/dist/plugin-route-dispatcher.d.ts.map +1 -1
  187. package/dist/plugin-route-dispatcher.js +2 -2
  188. package/dist/plugin-route-dispatcher.js.map +1 -1
  189. package/dist/remote-terminal-controller.d.ts +90 -0
  190. package/dist/remote-terminal-controller.d.ts.map +1 -0
  191. package/dist/remote-terminal-controller.js +494 -0
  192. package/dist/remote-terminal-controller.js.map +1 -0
  193. package/dist/remote-terminal-provider.d.ts +46 -0
  194. package/dist/remote-terminal-provider.d.ts.map +1 -0
  195. package/dist/remote-terminal-provider.js +147 -0
  196. package/dist/remote-terminal-provider.js.map +1 -0
  197. package/dist/remote-typert-gateway.js +7 -1
  198. package/dist/remote-typert-gateway.js.map +1 -1
  199. package/dist/server-api.d.ts +3 -0
  200. package/dist/server-api.d.ts.map +1 -1
  201. package/dist/server-api.js +24 -9
  202. package/dist/server-api.js.map +1 -1
  203. package/dist/server-connection.d.ts +1 -0
  204. package/dist/server-connection.d.ts.map +1 -1
  205. package/dist/server-connection.js +8 -1
  206. package/dist/server-connection.js.map +1 -1
  207. package/dist/service.d.ts +1 -1
  208. package/dist/service.d.ts.map +1 -1
  209. package/dist/service.js +15 -8
  210. package/dist/service.js.map +1 -1
  211. package/dist/terminal-carrier-compat.d.ts +14 -0
  212. package/dist/terminal-carrier-compat.d.ts.map +1 -0
  213. package/dist/terminal-carrier-compat.js +259 -0
  214. package/dist/terminal-carrier-compat.js.map +1 -0
  215. package/dist/terminal-policy.d.ts +6 -0
  216. package/dist/terminal-policy.d.ts.map +1 -1
  217. package/dist/terminal-policy.js +24 -6
  218. package/dist/terminal-policy.js.map +1 -1
  219. package/dist/typert-gateway-contract.d.ts +6 -1
  220. package/dist/typert-gateway-contract.d.ts.map +1 -1
  221. package/dist/typert-gateway-switch.d.ts +2 -1
  222. package/dist/typert-gateway-switch.d.ts.map +1 -1
  223. package/dist/typert-gateway-switch.js +39 -18
  224. package/dist/typert-gateway-switch.js.map +1 -1
  225. package/dist/version.d.ts +1 -1
  226. package/dist/version.d.ts.map +1 -1
  227. package/dist/version.js +1 -1
  228. package/dist/version.js.map +1 -1
  229. package/dist/workspace-bridge.d.ts +6 -89
  230. package/dist/workspace-bridge.d.ts.map +1 -1
  231. package/dist/workspace-bridge.js +37 -294
  232. package/dist/workspace-bridge.js.map +1 -1
  233. package/dsh-plugin.json +1 -1
  234. package/package.json +24 -20
@@ -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';
@@ -41,7 +44,7 @@ export class AcpRemoteGateway {
41
44
  terminalSpawner;
42
45
  backendInstances = new Map();
43
46
  sessionCwds = new Map();
44
- workspaceState = new WorkspaceBridgeState();
47
+ workspaceState = new WorkspaceBridgeState(key => TerminalPolicy.releaseOwner(this.terminalOwners, key));
45
48
  terminalOwners = new Map();
46
49
  workspaceTools = new Map();
47
50
  peers = new Map();
@@ -60,13 +63,26 @@ 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();
73
+ /** Desired configuration is saved immediately; unfinished cleanup must remain retryable. */
74
+ pendingReconfiguration = new Set();
66
75
  constructor(config, logger, createAcp = (binary, targetLogger, backend) => {
67
76
  if (backend.id === 'antigravity') {
68
77
  return new AntigravityAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
69
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
+ }
70
86
  return new CursorAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
71
87
  }, restartDelaysMs = DEFAULT_RESTART_DELAYS_MS, terminalEnabled = () => false, terminalSpawner) {
72
88
  this.config = config;
@@ -80,12 +96,12 @@ export class AcpRemoteGateway {
80
96
  enabledBackends() {
81
97
  if (!this.config.enabled)
82
98
  return [];
83
- 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);
84
100
  }
85
101
  start() {
86
102
  if (this.closed)
87
103
  return Promise.reject(new RpcError('CURSOR_CLOSED', 'The ACP Remote domain is closed.'));
88
- if (!this.config.enabled || this.enabledBackends().length === 0)
104
+ if (!this.config.enabled || !this.enabledBackends().some(id => !this.pendingReconfiguration.has(id)))
89
105
  return Promise.resolve();
90
106
  this.startPromise ??= this.startOnce().finally(() => { this.startPromise = undefined; });
91
107
  return this.startPromise;
@@ -106,12 +122,12 @@ export class AcpRemoteGateway {
106
122
  }
107
123
  }
108
124
  isAvailable() {
109
- return this.config.enabled && this.available && [...this.backendInstances.values()].some(inst => inst.client.isReady());
125
+ return this.config.enabled && this.available && this.availableBackends().length > 0;
110
126
  }
111
127
  availableBackends() {
112
128
  const list = [];
113
129
  for (const [id, inst] of this.backendInstances) {
114
- if (this.enabledBackends().includes(id) && inst.client.isReady())
130
+ if (!this.pendingReconfiguration.has(id) && this.enabledBackends().includes(id) && inst.client.isReady())
115
131
  list.push(id);
116
132
  }
117
133
  return list;
@@ -134,17 +150,17 @@ export class AcpRemoteGateway {
134
150
  const bridge = new AcpPeerBridge(this, context, publish, this.logger);
135
151
  this.peers.set(context.connectionId, bridge);
136
152
  const workspaceBridge = new WorkspaceBridge(async (rawId) => {
137
- const match = rawId.match(/^(acp|cursor):(.+)$/);
153
+ const match = rawId.match(/^(acp|cursor|pi|opencode):(.+)$/);
138
154
  const session = match ? this.sessionCwds.get(match[2]) : undefined;
139
- const backend = match?.[1] === 'acp' ? 'antigravity' : 'cursor';
155
+ const backend = match?.[1] === 'acp' ? 'antigravity' : match?.[1] === 'pi' ? 'pi' : match?.[1] === 'opencode' ? 'opencode' : 'cursor';
140
156
  if (!session || session.backend !== backend)
141
157
  throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
142
158
  this.requireAcp(backend);
143
159
  this.requireSessionOwner(context.connectionId, match[2]);
144
160
  return session.cwd;
145
161
  }, this.terminalEnabled, this.workspaceState, this.terminalSpawner, {
146
- domain: 'ACP', owns: value => typeof value === 'string' && /^(acp|cursor):/.test(value),
147
- 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)
148
164
  ? { sessionId: value, nativeId: value } : undefined,
149
165
  });
150
166
  this.workspaceTools.set(context.connectionId, new AcpWorkspaceTools(workspaceBridge, new TerminalPolicy(this.terminalEnabled, context.peerDeviceId, this.terminalOwners)));
@@ -158,8 +174,12 @@ export class AcpRemoteGateway {
158
174
  sessionId: typeof call.params?.sessionId === 'string' ? shortSessionId(call.params.sessionId) : undefined,
159
175
  });
160
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
+ }
161
181
  const sessionId = call.method === 'dsh/sessionHistory'
162
- ? String(call.params.sessionId).replace(/^(acp|cursor):/, '')
182
+ ? String(call.params.sessionId).replace(/^(acp|cursor|pi|claude|opencode):/, '')
163
183
  : sessionIdFromParams(call.method, call.params);
164
184
  const boundBackend = sessionId === undefined ? undefined : this.sessionBackends.get(sessionId);
165
185
  if (boundBackend !== undefined && boundBackend !== call.params.backend) {
@@ -176,6 +196,11 @@ export class AcpRemoteGateway {
176
196
  return this.listDirectory(String(call.params.path));
177
197
  }
178
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
+ }
179
204
  const candidates = new Set();
180
205
  const cwd = process.cwd();
181
206
  if (cwd)
@@ -241,23 +266,41 @@ export class AcpRemoteGateway {
241
266
  this.logger.info('ACP session list fetched', { count: items.length });
242
267
  return { items };
243
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
+ }
244
274
  return { items: [] };
245
275
  }
246
276
  if (call.method === 'dsh/sessionHistory') {
247
277
  const rawSessionId = String(call.params.sessionId);
248
- const conversationId = rawSessionId.startsWith('acp:')
278
+ const conversationId = rawSessionId.startsWith('claude:') ? rawSessionId.slice(7) : rawSessionId.startsWith('pi:') ? rawSessionId.slice(3) : rawSessionId.startsWith('acp:')
249
279
  ? rawSessionId.slice('acp:'.length)
250
280
  : rawSessionId.startsWith('cursor:')
251
281
  ? rawSessionId.slice('cursor:'.length)
252
- : rawSessionId;
282
+ : rawSessionId.startsWith('opencode:')
283
+ ? rawSessionId.slice('opencode:'.length)
284
+ : rawSessionId;
253
285
  const boundBackend = this.sessionBackends.get(conversationId);
254
286
  const backend = typeof call.params.backend === 'string' ? call.params.backend : boundBackend ?? this.defaultBackend();
255
287
  if ((boundBackend !== undefined && boundBackend !== backend)
288
+ || (rawSessionId.startsWith('claude:') && backend !== 'claude')
289
+ || (rawSessionId.startsWith('pi:') && backend !== 'pi')
256
290
  || (rawSessionId.startsWith('cursor:') && backend !== 'cursor')
291
+ || (rawSessionId.startsWith('opencode:') && backend !== 'opencode')
257
292
  || (rawSessionId.startsWith('acp:') && backend !== 'antigravity')) {
258
293
  throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
259
294
  }
260
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
+ }
261
304
  if (backend !== 'antigravity')
262
305
  return { events: [] };
263
306
  const events = await loadTranscriptEvents(conversationId, rawSessionId);
@@ -280,6 +323,10 @@ export class AcpRemoteGateway {
280
323
  });
281
324
  const sessionId = readSessionId(result);
282
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
+ }
283
330
  this.sessionOwners.set(sessionId, connectionId);
284
331
  this.sessionBackends.set(sessionId, requestedBackend);
285
332
  this.sessionCwds.set(sessionId, { backend: requestedBackend, cwd });
@@ -287,7 +334,7 @@ export class AcpRemoteGateway {
287
334
  return sanitizeSessionResult(result);
288
335
  }
289
336
  const sessionId = sessionIdFromParams(call.method, call.params);
290
- if (sessionId !== undefined)
337
+ if (sessionId !== undefined && call.method !== 'session/load')
291
338
  this.requireSessionAccess(connectionId, sessionId, call.method);
292
339
  if (call.method === 'dsh/toolCall') {
293
340
  this.requireSessionOwner(connectionId, sessionId);
@@ -305,14 +352,24 @@ export class AcpRemoteGateway {
305
352
  }
306
353
  if (call.method === 'session/load') {
307
354
  const boundBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
308
- 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.');
309
360
  if (boundBackend !== undefined && targetBackend !== boundBackend) {
310
361
  throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
311
362
  }
363
+ if (targetBackend === 'pi' || targetBackend === 'claude')
364
+ await this.catalogSessionCwd(targetBackend, sessionId);
365
+ this.requireSessionOwner(connectionId, sessionId);
312
366
  const acp = this.requireAcp(targetBackend);
313
- 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: [] } : {}) });
314
368
  const loadedId = readSessionId(result) ?? sessionId;
315
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
+ }
316
373
  this.sessionOwners.set(loadedId, connectionId);
317
374
  if (targetBackend)
318
375
  this.sessionBackends.set(loadedId, targetBackend);
@@ -322,6 +379,11 @@ export class AcpRemoteGateway {
322
379
  if (isSessionMutation(call.method) && sessionId !== undefined) {
323
380
  this.requireSessionOwner(connectionId, sessionId);
324
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
+ }
325
387
  if (call.method === 'dsh/selectModel') {
326
388
  const session = this.sessionCwds.get(sessionId);
327
389
  if (!session || session.backend !== call.params.backend) {
@@ -338,6 +400,35 @@ export class AcpRemoteGateway {
338
400
  // interleaved agent.acp.frame events (Android stays on "正在回复" with no
339
401
  // thought/text). Accept immediately and finish via stream updates.
340
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
+ }
341
432
  const ownerPeer = this.peers.get(connectionId);
342
433
  if (ownerPeer !== undefined && !ownerPeer.hasStreamFor(sessionId)) {
343
434
  this.logger?.warn('Cursor prompt started without an open ACP stream', {
@@ -348,6 +439,29 @@ export class AcpRemoteGateway {
348
439
  void this.runPromptInBackground(sessionId, call.params);
349
440
  return { accepted: true, stopReason: 'in_progress' };
350
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
+ }
351
465
  const sessionBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
352
466
  return sanitizeSessionResult(await this.requireAcp(typeof call.params.backend === 'string' ? call.params.backend : sessionBackend).call(call.method, call.params));
353
467
  }
@@ -397,6 +511,9 @@ export class AcpRemoteGateway {
397
511
  },
398
512
  }).catch(() => undefined);
399
513
  }
514
+ finally {
515
+ this.promptsInProgress.delete(sessionId);
516
+ }
400
517
  }
401
518
  async respond(connectionId, input) {
402
519
  const params = parseRespondEnvelope(input);
@@ -408,13 +525,18 @@ export class AcpRemoteGateway {
408
525
  if (pending.connectionId !== connectionId) {
409
526
  throw new RpcError('CURSOR_APPROVAL_NOT_FOUND', 'The Cursor approval is missing, expired, or belongs to another connection.');
410
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);
411
534
  this.approvals.delete(params.requestHandle);
412
- const acp = this.requireAcp(pending.backend);
535
+ const acp = pending.client;
413
536
  if (params.decision === 'cancel') {
414
537
  await acp.respondError(pending.upstreamId, -32800, 'Cancelled by Remote client.');
415
538
  return { resolved: true };
416
539
  }
417
- const result = params.result ?? mapPermissionDecision(params.decision, pending.method);
418
540
  await acp.respond(pending.upstreamId, result);
419
541
  return { resolved: true };
420
542
  }
@@ -429,7 +551,7 @@ export class AcpRemoteGateway {
429
551
  for (const [handle, approval] of this.approvals) {
430
552
  if (approval.connectionId === connectionId) {
431
553
  this.approvals.delete(handle);
432
- 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);
433
555
  }
434
556
  }
435
557
  }
@@ -489,18 +611,20 @@ export class AcpRemoteGateway {
489
611
  throw new RpcError('CURSOR_CLOSED', 'The ACP Remote domain is closed.');
490
612
  const previous = this.config;
491
613
  const implemented = (value) => ({ enabled: value.enabled,
492
- backends: value.backends.filter(item => ['cursor', 'antigravity'].includes(item.id)) });
614
+ backends: value.backends.filter(item => ['cursor', 'antigravity', 'pi', 'claude', 'opencode'].includes(item.id)) });
493
615
  this.config = config;
494
- if (JSON.stringify(implemented(previous)) === JSON.stringify(implemented(config)))
616
+ if (JSON.stringify(implemented(previous)) === JSON.stringify(implemented(config)) && this.pendingReconfiguration.size === 0)
495
617
  return;
496
618
  if (this.restartTimer !== undefined)
497
619
  clearTimeout(this.restartTimer);
498
620
  this.restartTimer = undefined;
499
- const changed = new Set(previous.backends.filter(item => {
500
- const next = config.backends.find(candidate => candidate.id === item.id);
501
- return previous.enabled !== config.enabled || JSON.stringify(item) !== JSON.stringify(next);
502
- }).map(item => item.id));
503
- await Promise.all([...this.startingClients].filter(([, backend]) => changed.has(backend.id)).map(([client]) => client.close()));
621
+ const changed = new Set([...this.pendingReconfiguration, ...previous.backends.filter(item => {
622
+ const next = config.backends.find(candidate => candidate.id === item.id);
623
+ return previous.enabled !== config.enabled || JSON.stringify(item) !== JSON.stringify(next);
624
+ }).map(item => item.id)]);
625
+ for (const id of changed)
626
+ this.pendingReconfiguration.add(id);
627
+ const stopping = await Promise.allSettled([...this.startingClients].filter(([, backend]) => changed.has(backend.id)).map(([client]) => client.close()));
504
628
  await this.startPromise?.catch(() => undefined);
505
629
  const sessions = new Set([...this.sessionBackends].filter(([, backend]) => changed.has(backend)).map(([id]) => id));
506
630
  if (sessions.size > 0)
@@ -508,14 +632,8 @@ export class AcpRemoteGateway {
508
632
  for (const [id, session] of this.sessionCwds)
509
633
  if (changed.has(session.backend))
510
634
  this.sessionCwds.delete(id);
511
- for (const [key, terminal] of this.workspaceState.terminals) {
512
- if (changed.has(terminal.sessionId.startsWith('acp:') ? 'antigravity' : 'cursor')) {
513
- void Promise.resolve(terminal.process?.terminate()).catch(() => undefined);
514
- for (const queue of terminal.subscribers)
515
- queue.end();
516
- this.workspaceState.terminals.delete(key);
517
- }
518
- }
635
+ const terminalError = await this.workspaceState.dispose(sessionId => changed.has(sessionId.startsWith('acp:') ? 'antigravity' : sessionId.startsWith('pi:') ? 'pi' : sessionId.startsWith('opencode:') ? 'opencode' : 'cursor'))
636
+ .then(() => undefined, () => new RpcError('TERMINAL_CLEANUP_FAILED', 'Some ACP terminal processes could not be stopped. Save the backend settings again to retry.'));
519
637
  for (const id of sessions) {
520
638
  this.sessionOwners.delete(id);
521
639
  this.sessionBackends.delete(id);
@@ -525,17 +643,27 @@ export class AcpRemoteGateway {
525
643
  for (const [id, approval] of this.approvals)
526
644
  if (approval.backend !== undefined && changed.has(approval.backend))
527
645
  this.approvals.delete(id);
528
- for (const id of changed) {
646
+ // A terminal cleanup failure must not prevent stopping any changed backend.
647
+ // Keep failed backend instances fenced from RPC/startup until a later retry.
648
+ const processes = await Promise.allSettled([...changed].map(async (id) => {
529
649
  const instance = this.backendInstances.get(id);
530
650
  if (instance === undefined)
531
- continue;
532
- this.backendInstances.delete(id);
651
+ return;
533
652
  await this.disposeInstance(instance);
534
- }
653
+ if (this.backendInstances.get(id) === instance)
654
+ this.backendInstances.delete(id);
655
+ }));
535
656
  this.available = this.availableBackends().length > 0;
536
- this.state = this.available ? 'ready' : 'disabled';
657
+ const cleanupFailed = terminalError !== undefined || [...stopping, ...processes].some(result => result.status === 'rejected');
658
+ this.state = this.available ? 'ready' : cleanupFailed && this.enabledBackends().length > 0 ? 'unavailable' : 'disabled';
537
659
  this.restartAttempt = 0;
538
- this.unavailableCode = undefined;
660
+ this.unavailableCode = terminalError?.code ?? (cleanupFailed ? 'ACP_RECONFIGURE_FAILED' : undefined);
661
+ if (terminalError)
662
+ throw terminalError;
663
+ if (cleanupFailed)
664
+ throw new RpcError('ACP_RECONFIGURE_FAILED', 'Some ACP backend processes could not be stopped. Save the backend settings again to retry.');
665
+ for (const id of changed)
666
+ this.pendingReconfiguration.delete(id);
539
667
  await this.start();
540
668
  }
541
669
  async close() {
@@ -550,25 +678,25 @@ export class AcpRemoteGateway {
550
678
  await peer.closeAll();
551
679
  this.peers.clear();
552
680
  this.sessionCwds.clear();
553
- for (const terminal of this.workspaceState.terminals.values()) {
554
- void Promise.resolve(terminal.process?.terminate()).catch(() => undefined);
555
- for (const queue of terminal.subscribers)
556
- queue.end();
557
- }
558
- this.workspaceState.terminals.clear();
559
- this.terminalOwners.clear();
681
+ const terminals = this.workspaceState.dispose().then(() => undefined, () => new RpcError('TERMINAL_CLEANUP_FAILED', 'Some ACP terminal processes could not be stopped.'));
560
682
  this.sessionOwners.clear();
561
683
  this.sessionBackends.clear();
562
684
  this.recentFrames.clear();
563
685
  this.turnCatchUp.clear();
564
686
  this.approvals.clear();
565
- await Promise.all([...this.startingClients.keys()].map(client => client.close()));
566
- await this.disposeAllInstances();
687
+ const processes = await Promise.allSettled([...this.startingClients.keys()].map(client => client.close()).concat(this.disposeAllInstances()));
688
+ const terminalError = await terminals;
689
+ if (!terminalError)
690
+ this.terminalOwners.clear();
567
691
  this.available = false;
568
692
  this.state = 'disabled';
693
+ if (terminalError)
694
+ throw terminalError;
695
+ if (processes.some(result => result.status === 'rejected'))
696
+ throw new RpcError('ACP_SHUTDOWN_FAILED', 'Some ACP backend processes could not be stopped.');
569
697
  }
570
698
  async launchAcp() {
571
- const candidates = this.config.backends.filter(item => this.enabledBackends().includes(item.id));
699
+ const candidates = this.config.backends.filter(item => !this.pendingReconfiguration.has(item.id) && this.enabledBackends().includes(item.id));
572
700
  const results = await Promise.allSettled(candidates.map(async (candidate) => {
573
701
  if (this.backendInstances.get(candidate.id)?.client.isReady())
574
702
  return;
@@ -595,7 +723,8 @@ export class AcpRemoteGateway {
595
723
  }
596
724
  if (this.isAvailable()) {
597
725
  this.state = 'ready';
598
- this.unavailableCode = undefined;
726
+ if (this.pendingReconfiguration.size === 0)
727
+ this.unavailableCode = undefined;
599
728
  this.restartAttempt = 0;
600
729
  return;
601
730
  }
@@ -603,7 +732,7 @@ export class AcpRemoteGateway {
603
732
  throw failure?.status === 'rejected' ? failure.reason : new CursorAcpError('CURSOR_BINARY_UNAVAILABLE', 'No ACP backend is available.');
604
733
  }
605
734
  backendCurrent(backend) {
606
- return this.config.enabled && backend.enabled
735
+ return this.config.enabled && backend.enabled && !this.pendingReconfiguration.has(backend.id)
607
736
  && JSON.stringify(this.config.backends.find(item => item.id === backend.id)) === JSON.stringify(backend);
608
737
  }
609
738
  async launchBackendCandidate(backend, binary) {
@@ -626,7 +755,7 @@ export class AcpRemoteGateway {
626
755
  }
627
756
  const unsubscribeInbound = acp.onInbound(message => {
628
757
  this.inboundChain = this.inboundChain
629
- .then(() => this.handleInbound(id, message))
758
+ .then(() => this.backendInstances.get(id)?.client === acp ? this.handleInbound(id, message) : undefined)
630
759
  .catch(error => {
631
760
  this.logger.warn('ACP inbound fanout failed', { id, code: errorCode(error) });
632
761
  });
@@ -651,6 +780,13 @@ export class AcpRemoteGateway {
651
780
  });
652
781
  }
653
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
+ }
654
790
  // session/update is a stream notification even if a buggy agent attaches an id.
655
791
  if (message.kind === 'notification' || message.method === 'session/update') {
656
792
  const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params);
@@ -662,38 +798,44 @@ export class AcpRemoteGateway {
662
798
  await this.publishToSession(sessionId, { method: message.method, params: message.params });
663
799
  return;
664
800
  }
665
- const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params) ?? 'unknown';
666
- if (sessionId !== 'unknown' && !this.sessionBackends.has(sessionId)) {
667
- 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;
668
819
  }
669
820
  const requestHandle = randomUUID();
670
821
  this.approvals.set(requestHandle, {
671
- upstreamId: message.id,
672
- connectionId: this.sessionOwners.get(sessionId) ?? [...this.peers.keys()][0] ?? 'unknown',
673
- sessionId,
674
- method: message.method,
675
- expiresAt: Date.now() + APPROVAL_TTL_MS,
676
- backend,
822
+ upstreamId: message.id, connectionId: owner, sessionId, method: message.method,
823
+ expiresAt: Date.now() + APPROVAL_TTL_MS, backend, client, options,
677
824
  });
678
825
  this.scheduleApprovalExpiry();
679
- const owner = this.sessionOwners.get(sessionId);
680
- const frame = {
681
- method: message.method,
682
- params: {
683
- requestHandle,
684
- sessionId,
685
- upstreamMethod: message.method,
686
- ...(isRecord(message.params) ? message.params : {}),
687
- },
688
- };
689
- if (owner !== undefined) {
690
- const peer = this.peers.get(owner);
691
- if (peer !== undefined) {
692
- await peer.publishInbound(sessionId, frame);
693
- return;
694
- }
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.');
695
838
  }
696
- await this.publishToSession(sessionId, frame);
697
839
  }
698
840
  async publishToSession(sessionId, frame) {
699
841
  this.recordTurnCatchUp(sessionId, frame);
@@ -799,7 +941,7 @@ export class AcpRemoteGateway {
799
941
  for (const [handle, approval] of this.approvals) {
800
942
  if (approval.expiresAt <= now) {
801
943
  this.approvals.delete(handle);
802
- 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);
803
945
  }
804
946
  }
805
947
  this.scheduleApprovalExpiry();
@@ -807,7 +949,7 @@ export class AcpRemoteGateway {
807
949
  this.approvalExpiryTimer.unref?.();
808
950
  }
809
951
  requireAcp(backend) {
810
- if (!this.config.enabled || (backend !== undefined && !this.enabledBackends().includes(backend)) || !this.isAvailable()) {
952
+ if (!this.config.enabled || (backend !== undefined && (!this.enabledBackends().includes(backend) || this.pendingReconfiguration.has(backend))) || !this.isAvailable()) {
811
953
  throw new RpcError('CURSOR_UNAVAILABLE', 'Cursor ACP is disabled or unavailable on this Host.');
812
954
  }
813
955
  if (backend !== undefined) {
@@ -844,8 +986,10 @@ export class AcpRemoteGateway {
844
986
  return this.requireAcp().call(method, params);
845
987
  }
846
988
  requireSessionAccess(connectionId, sessionId, method) {
847
- if (method === 'session/load')
989
+ if (method === 'session/load') {
990
+ this.requireSessionOwner(connectionId, sessionId);
848
991
  return;
992
+ }
849
993
  const owner = this.sessionOwners.get(sessionId);
850
994
  if (owner === undefined) {
851
995
  // Allow reclaim after the owning peer disconnected; the ACP process still
@@ -860,6 +1004,74 @@ export class AcpRemoteGateway {
860
1004
  requireSessionOwner(connectionId, sessionId) {
861
1005
  this.claimSession(connectionId, sessionId);
862
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
+ }
863
1075
  async requireExistingDirectory(path) {
864
1076
  if (!isAbsolute(path)) {
865
1077
  throw new RpcError('CURSOR_PATH_NOT_ALLOWED', 'The Cursor working directory must be an absolute path.');
@@ -932,32 +1144,55 @@ function parseCallEnvelope(input) {
932
1144
  return { method: input.method, params: input.params ?? {} };
933
1145
  }
934
1146
  function parseRespondEnvelope(input) {
935
- if (!isRecord(input) || typeof input.requestHandle !== 'string' || typeof input.decision !== 'string') {
936
- 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.');
937
1151
  }
938
- const decision = input.decision;
939
- if (decision !== 'allow-once' && decision !== 'allow-always' && decision !== 'reject-once' && decision !== 'cancel') {
940
- throw new RpcError('INVALID_MESSAGE', 'The Cursor respond envelope is invalid.');
941
- }
942
- return {
943
- requestHandle: input.requestHandle,
944
- decision,
945
- ...(input.result === undefined ? {} : { result: input.result }),
946
- };
1152
+ return input;
947
1153
  }
948
- function mapPermissionDecision(decision, method) {
949
- if (method === 'session/request_permission') {
950
- 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');
951
1157
  }
952
- if (method === 'cursor/create_plan') {
953
- if (decision === 'allow-once' || decision === 'allow-always')
954
- return { outcome: { outcome: 'accepted' } };
955
- return { outcome: { outcome: decision === 'cancel' ? 'cancelled' : 'rejected' } };
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');
956
1170
  }
957
- if (method === 'cursor/ask_question') {
958
- return { outcome: { outcome: 'cancelled' } };
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 } };
959
1185
  }
960
- return { outcome: { outcome: 'selected', optionId: decision } };
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();
1194
+ }
1195
+ return { outcome: { outcome } };
961
1196
  }
962
1197
  function readSessionId(value) {
963
1198
  if (!isRecord(value))
@@ -1019,10 +1254,16 @@ export function cursorBinaryCandidates(configured) {
1019
1254
  'agent',
1020
1255
  ];
1021
1256
  }
1257
+ if (configured === 'opencode') {
1258
+ return [
1259
+ join(userHome, '.local', 'bin', 'opencode'),
1260
+ 'opencode',
1261
+ ];
1262
+ }
1022
1263
  return [configured];
1023
1264
  }
1024
1265
  function errorCode(error) {
1025
- if (error instanceof CursorAcpError || error instanceof RpcError)
1266
+ if (error instanceof CursorAcpError || error instanceof OpencodeAcpError || error instanceof RpcError)
1026
1267
  return error.code;
1027
1268
  return 'CURSOR_UNAVAILABLE';
1028
1269
  }