ds-harness-remote 0.4.26 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +6 -6
  2. package/dist/acp/adapter.d.ts +19 -0
  3. package/dist/acp/adapter.d.ts.map +1 -0
  4. package/dist/acp/adapter.js +14 -0
  5. package/dist/acp/adapter.js.map +1 -0
  6. package/dist/acp/adapters/antigravity/executor.d.ts +20 -0
  7. package/dist/acp/adapters/antigravity/executor.d.ts.map +1 -0
  8. package/dist/acp/adapters/antigravity/executor.js +99 -0
  9. package/dist/acp/adapters/antigravity/executor.js.map +1 -0
  10. package/dist/acp/adapters/antigravity/image-store.d.ts +9 -0
  11. package/dist/acp/adapters/antigravity/image-store.d.ts.map +1 -0
  12. package/dist/acp/adapters/antigravity/image-store.js +116 -0
  13. package/dist/acp/adapters/antigravity/image-store.js.map +1 -0
  14. package/dist/acp/adapters/antigravity/index.d.ts +5 -0
  15. package/dist/acp/adapters/antigravity/index.d.ts.map +1 -0
  16. package/dist/acp/adapters/antigravity/index.js +5 -0
  17. package/dist/acp/adapters/antigravity/index.js.map +1 -0
  18. package/dist/acp/adapters/antigravity/models.d.ts +35 -0
  19. package/dist/acp/adapters/antigravity/models.d.ts.map +1 -0
  20. package/dist/acp/adapters/antigravity/models.js +59 -0
  21. package/dist/acp/adapters/antigravity/models.js.map +1 -0
  22. package/dist/acp/adapters/antigravity/service.d.ts +35 -0
  23. package/dist/acp/adapters/antigravity/service.d.ts.map +1 -0
  24. package/dist/acp/adapters/antigravity/service.js +67 -0
  25. package/dist/acp/adapters/antigravity/service.js.map +1 -0
  26. package/dist/acp/adapters/antigravity/transcript-loader.d.ts +21 -0
  27. package/dist/acp/adapters/antigravity/transcript-loader.d.ts.map +1 -0
  28. package/dist/acp/adapters/antigravity/transcript-loader.js +356 -0
  29. package/dist/acp/adapters/antigravity/transcript-loader.js.map +1 -0
  30. package/dist/acp/adapters/antigravity/transcript-watcher.d.ts +28 -0
  31. package/dist/acp/adapters/antigravity/transcript-watcher.d.ts.map +1 -0
  32. package/dist/acp/adapters/antigravity/transcript-watcher.js +157 -0
  33. package/dist/acp/adapters/antigravity/transcript-watcher.js.map +1 -0
  34. package/dist/acp/adapters/antigravity/types.d.ts +56 -0
  35. package/dist/acp/adapters/antigravity/types.d.ts.map +1 -0
  36. package/dist/acp/adapters/antigravity/types.js +2 -0
  37. package/dist/acp/adapters/antigravity/types.js.map +1 -0
  38. package/dist/acp/adapters/antigravity-process.d.ts +69 -0
  39. package/dist/acp/adapters/antigravity-process.d.ts.map +1 -0
  40. package/dist/acp/adapters/antigravity-process.js +566 -0
  41. package/dist/acp/adapters/antigravity-process.js.map +1 -0
  42. package/dist/acp/adapters/antigravity.d.ts +8 -0
  43. package/dist/acp/adapters/antigravity.d.ts.map +1 -0
  44. package/dist/acp/adapters/antigravity.js +9 -0
  45. package/dist/acp/adapters/antigravity.js.map +1 -0
  46. package/dist/acp/adapters/cursor-process.d.ts +75 -0
  47. package/dist/acp/adapters/cursor-process.d.ts.map +1 -0
  48. package/dist/acp/adapters/cursor-process.js +273 -0
  49. package/dist/acp/adapters/cursor-process.js.map +1 -0
  50. package/dist/acp/adapters/cursor.d.ts +8 -0
  51. package/dist/acp/adapters/cursor.d.ts.map +1 -0
  52. package/dist/acp/adapters/cursor.js +8 -0
  53. package/dist/acp/adapters/cursor.js.map +1 -0
  54. package/dist/acp/gateway.d.ts +108 -0
  55. package/dist/acp/gateway.d.ts.map +1 -0
  56. package/dist/acp/gateway.js +1040 -0
  57. package/dist/acp/gateway.js.map +1 -0
  58. package/dist/acp/image-content.d.ts +19 -0
  59. package/dist/acp/image-content.d.ts.map +1 -0
  60. package/dist/acp/image-content.js +76 -0
  61. package/dist/acp/image-content.js.map +1 -0
  62. package/dist/acp/method-policy.d.ts +239 -0
  63. package/dist/acp/method-policy.d.ts.map +1 -0
  64. package/dist/acp/method-policy.js +102 -0
  65. package/dist/acp/method-policy.js.map +1 -0
  66. package/dist/acp/peer-bridge.d.ts +59 -0
  67. package/dist/acp/peer-bridge.d.ts.map +1 -0
  68. package/dist/acp/peer-bridge.js +367 -0
  69. package/dist/acp/peer-bridge.js.map +1 -0
  70. package/dist/acp/virtual-harness.d.ts +181 -0
  71. package/dist/acp/virtual-harness.d.ts.map +1 -0
  72. package/dist/acp/virtual-harness.js +1556 -0
  73. package/dist/acp/virtual-harness.js.map +1 -0
  74. package/dist/acp/workspace-tools.d.ts +13 -0
  75. package/dist/acp/workspace-tools.d.ts.map +1 -0
  76. package/dist/acp/workspace-tools.js +46 -0
  77. package/dist/acp/workspace-tools.js.map +1 -0
  78. package/dist/acp.d.ts.map +1 -1
  79. package/dist/acp.js +1 -0
  80. package/dist/acp.js.map +1 -1
  81. package/dist/api-proxy-switch.d.ts +2 -0
  82. package/dist/api-proxy-switch.d.ts.map +1 -1
  83. package/dist/api-proxy-switch.js +9 -0
  84. package/dist/api-proxy-switch.js.map +1 -1
  85. package/dist/cli.d.ts.map +1 -1
  86. package/dist/cli.js +4 -3
  87. package/dist/cli.js.map +1 -1
  88. package/dist/client-runtime.d.ts +25 -1
  89. package/dist/client-runtime.d.ts.map +1 -1
  90. package/dist/client-runtime.js +222 -8
  91. package/dist/client-runtime.js.map +1 -1
  92. package/dist/client.github.js +327 -190
  93. package/dist/client.js +327 -190
  94. package/dist/client.js.map +1 -1
  95. package/dist/codex/domain.d.ts +4 -1
  96. package/dist/codex/domain.d.ts.map +1 -1
  97. package/dist/codex/domain.js +54 -4
  98. package/dist/codex/domain.js.map +1 -1
  99. package/dist/codex/virtual-harness.d.ts.map +1 -1
  100. package/dist/codex/virtual-harness.js +3 -0
  101. package/dist/codex/virtual-harness.js.map +1 -1
  102. package/dist/codex-workspace-bridge.d.ts +7 -105
  103. package/dist/codex-workspace-bridge.d.ts.map +1 -1
  104. package/dist/codex-workspace-bridge.js +10 -498
  105. package/dist/codex-workspace-bridge.js.map +1 -1
  106. package/dist/config.d.ts +28 -10
  107. package/dist/config.d.ts.map +1 -1
  108. package/dist/config.js +49 -5
  109. package/dist/config.js.map +1 -1
  110. package/dist/connection-controller.d.ts.map +1 -1
  111. package/dist/connection-controller.js +7 -4
  112. package/dist/connection-controller.js.map +1 -1
  113. package/dist/control-runtime.d.ts +1 -0
  114. package/dist/control-runtime.d.ts.map +1 -1
  115. package/dist/control-runtime.js +65 -10
  116. package/dist/control-runtime.js.map +1 -1
  117. package/dist/harness-version.d.ts.map +1 -1
  118. package/dist/harness-version.js +16 -3
  119. package/dist/harness-version.js.map +1 -1
  120. package/dist/index.d.ts +4 -0
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +13197 -8204
  123. package/dist/index.js.map +4 -4
  124. package/dist/loopback-host.d.ts +2 -2
  125. package/dist/loopback-host.d.ts.map +1 -1
  126. package/dist/loopback-host.js +41 -16
  127. package/dist/loopback-host.js.map +1 -1
  128. package/dist/remote-typert-gateway.d.ts.map +1 -1
  129. package/dist/remote-typert-gateway.js +7 -4
  130. package/dist/remote-typert-gateway.js.map +1 -1
  131. package/dist/rpc-router.d.ts +6 -5
  132. package/dist/rpc-router.d.ts.map +1 -1
  133. package/dist/rpc-router.js +49 -18
  134. package/dist/rpc-router.js.map +1 -1
  135. package/dist/server-api.d.ts +2 -0
  136. package/dist/server-api.d.ts.map +1 -1
  137. package/dist/server-api.js +5 -0
  138. package/dist/server-api.js.map +1 -1
  139. package/dist/server-connection.d.ts.map +1 -1
  140. package/dist/server-connection.js +15 -0
  141. package/dist/server-connection.js.map +1 -1
  142. package/dist/service.d.ts +30 -1
  143. package/dist/service.d.ts.map +1 -1
  144. package/dist/service.js +114 -12
  145. package/dist/service.js.map +1 -1
  146. package/dist/session-format-compat.d.ts +1 -0
  147. package/dist/session-format-compat.d.ts.map +1 -1
  148. package/dist/session-format-compat.js +14 -0
  149. package/dist/session-format-compat.js.map +1 -1
  150. package/dist/tui-command.js +2 -0
  151. package/dist/tui-command.js.map +1 -1
  152. package/dist/typert-gateway-switch.d.ts +2 -0
  153. package/dist/typert-gateway-switch.d.ts.map +1 -1
  154. package/dist/typert-gateway-switch.js +16 -0
  155. package/dist/typert-gateway-switch.js.map +1 -1
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +1 -1
  159. package/dist/version.js.map +1 -1
  160. package/dist/workspace-bridge.d.ts +117 -0
  161. package/dist/workspace-bridge.d.ts.map +1 -0
  162. package/dist/workspace-bridge.js +512 -0
  163. package/dist/workspace-bridge.js.map +1 -0
  164. package/dsh-plugin.json +1 -1
  165. package/package.json +1 -1
@@ -0,0 +1,1040 @@
1
+ import { WorkspaceBridge, WorkspaceBridgeState } from '../workspace-bridge.js';
2
+ import { TerminalPolicy } from '../terminal-policy.js';
3
+ import { AcpWorkspaceTools } from './workspace-tools.js';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { readdir, realpath, stat } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
8
+ import { RpcError } from '../safe-error.js';
9
+ import { PLUGIN_VERSION } from '../version.js';
10
+ import { CursorAcpClient, CursorAcpError, } from './adapters/cursor-process.js';
11
+ import { AntigravityAcpClient } from './adapters/antigravity-process.js';
12
+ import { ACP_METHOD_ALLOWLIST, isSessionMutation, parseAcpCall, sessionIdFromParams, } from './method-policy.js';
13
+ import { discoverAntigravitySessions, discoverAntigravityWorkspaces, loadTranscriptEvents } from './adapters/antigravity/transcript-loader.js';
14
+ import { AcpPeerBridge } from './peer-bridge.js';
15
+ const APPROVAL_TTL_MS = 5 * 60_000;
16
+ const DEFAULT_RESTART_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000];
17
+ const ACP_DIRECTORY_ENTRY_LIMIT = 500;
18
+ const MAX_BUFFERED_ACP_FRAMES = 200;
19
+ const ACP_FRAME_BUFFER_TTL_MS = 5 * 60_000;
20
+ const MAX_TURN_CATCH_UP_FRAMES = 120;
21
+ const CATCH_UP_SESSION_UPDATES = new Set([
22
+ 'agent_thought_chunk',
23
+ 'agent_thought',
24
+ 'agent_message_chunk',
25
+ 'agent_message',
26
+ 'user_message_chunk',
27
+ 'tool_call',
28
+ 'tool_call_update',
29
+ ]);
30
+ /**
31
+ * Host-side Agent ACP gateway (#65). Reuses the authenticated Remote channel and
32
+ * delegates to independently configured Cursor/Antigravity adapters. Owns method policy,
33
+ * session ownership, subscriptions, and permission handles per connection.
34
+ */
35
+ export class AcpRemoteGateway {
36
+ config;
37
+ logger;
38
+ createAcp;
39
+ restartDelaysMs;
40
+ terminalEnabled;
41
+ terminalSpawner;
42
+ backendInstances = new Map();
43
+ sessionCwds = new Map();
44
+ workspaceState = new WorkspaceBridgeState();
45
+ terminalOwners = new Map();
46
+ workspaceTools = new Map();
47
+ peers = new Map();
48
+ sessionOwners = new Map();
49
+ sessionBackends = new Map();
50
+ approvals = new Map();
51
+ approvalExpiryTimer;
52
+ restartTimer;
53
+ restartAttempt = 0;
54
+ available = false;
55
+ closed = false;
56
+ state = 'disabled';
57
+ unavailableCode;
58
+ /** Keep ACP → Client fanout ordered; concurrent publish races Noise sends. */
59
+ inboundChain = Promise.resolve();
60
+ /** Catch-up buffer for turns that finish while the Client is reconnecting. */
61
+ recentFrames = new Map();
62
+ /** Per-prompt live updates; attached to prompt_completed when streaming was lossy. */
63
+ turnCatchUp = new Map();
64
+ startPromise;
65
+ startingClients = new Map();
66
+ constructor(config, logger, createAcp = (binary, targetLogger, backend) => {
67
+ if (backend.id === 'antigravity') {
68
+ return new AntigravityAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
69
+ }
70
+ return new CursorAcpClient(binary, targetLogger, undefined, { args: backend.args, cwd: backend.cwd });
71
+ }, restartDelaysMs = DEFAULT_RESTART_DELAYS_MS, terminalEnabled = () => false, terminalSpawner) {
72
+ this.config = config;
73
+ this.logger = logger;
74
+ this.createAcp = createAcp;
75
+ this.restartDelaysMs = restartDelaysMs;
76
+ this.terminalEnabled = terminalEnabled;
77
+ this.terminalSpawner = terminalSpawner;
78
+ }
79
+ /** Only implemented adapters are advertised; registry entries never select an adapter by executable name. */
80
+ enabledBackends() {
81
+ if (!this.config.enabled)
82
+ return [];
83
+ return this.config.backends.filter(item => item.enabled && ['antigravity', 'cursor'].includes(item.id)).map(item => item.id);
84
+ }
85
+ start() {
86
+ if (this.closed)
87
+ return Promise.reject(new RpcError('CURSOR_CLOSED', 'The ACP Remote domain is closed.'));
88
+ if (!this.config.enabled || this.enabledBackends().length === 0)
89
+ return Promise.resolve();
90
+ this.startPromise ??= this.startOnce().finally(() => { this.startPromise = undefined; });
91
+ return this.startPromise;
92
+ }
93
+ async startOnce() {
94
+ try {
95
+ this.state = 'starting';
96
+ await this.launchAcp();
97
+ }
98
+ catch (error) {
99
+ if (this.closed)
100
+ return;
101
+ this.available = false;
102
+ this.state = 'unavailable';
103
+ this.unavailableCode = errorCode(error);
104
+ await this.disposeAllInstances();
105
+ this.logger.warn('ACP Remote domain unavailable', { code: this.unavailableCode });
106
+ }
107
+ }
108
+ isAvailable() {
109
+ return this.config.enabled && this.available && [...this.backendInstances.values()].some(inst => inst.client.isReady());
110
+ }
111
+ availableBackends() {
112
+ const list = [];
113
+ for (const [id, inst] of this.backendInstances) {
114
+ if (this.enabledBackends().includes(id) && inst.client.isReady())
115
+ list.push(id);
116
+ }
117
+ return list;
118
+ }
119
+ defaultBackend() {
120
+ const ready = this.availableBackends();
121
+ return this.enabledBackends().find(id => ready.includes(id)) ?? this.enabledBackends()[0] ?? 'antigravity';
122
+ }
123
+ status() {
124
+ return {
125
+ enabled: this.config.enabled,
126
+ available: this.isAvailable(),
127
+ state: this.state,
128
+ restartAttempt: this.restartAttempt,
129
+ availableBackends: this.availableBackends(),
130
+ ...(this.unavailableCode === undefined ? {} : { error: this.unavailableCode }),
131
+ };
132
+ }
133
+ createPeer(context, publish) {
134
+ const bridge = new AcpPeerBridge(this, context, publish, this.logger);
135
+ this.peers.set(context.connectionId, bridge);
136
+ const workspaceBridge = new WorkspaceBridge(async (rawId) => {
137
+ const match = rawId.match(/^(acp|cursor):(.+)$/);
138
+ const session = match ? this.sessionCwds.get(match[2]) : undefined;
139
+ const backend = match?.[1] === 'acp' ? 'antigravity' : 'cursor';
140
+ if (!session || session.backend !== backend)
141
+ throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
142
+ this.requireAcp(backend);
143
+ this.requireSessionOwner(context.connectionId, match[2]);
144
+ return session.cwd;
145
+ }, 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)
148
+ ? { sessionId: value, nativeId: value } : undefined,
149
+ });
150
+ this.workspaceTools.set(context.connectionId, new AcpWorkspaceTools(workspaceBridge, new TerminalPolicy(this.terminalEnabled, context.peerDeviceId, this.terminalOwners)));
151
+ return bridge;
152
+ }
153
+ async call(connectionId, input) {
154
+ const envelope = parseCallEnvelope(input);
155
+ const call = parseAcpCall(envelope.method, envelope.params);
156
+ this.logger.info('ACP call received', {
157
+ method: call.method,
158
+ sessionId: typeof call.params?.sessionId === 'string' ? shortSessionId(call.params.sessionId) : undefined,
159
+ });
160
+ if (typeof call.params.backend === 'string') {
161
+ const sessionId = call.method === 'dsh/sessionHistory'
162
+ ? String(call.params.sessionId).replace(/^(acp|cursor):/, '')
163
+ : sessionIdFromParams(call.method, call.params);
164
+ const boundBackend = sessionId === undefined ? undefined : this.sessionBackends.get(sessionId);
165
+ if (boundBackend !== undefined && boundBackend !== call.params.backend) {
166
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
167
+ }
168
+ this.requireAcp(call.params.backend);
169
+ }
170
+ if (call.method === 'initialize') {
171
+ const requestedBackend = typeof call.params?.backend === 'string' ? call.params.backend : undefined;
172
+ this.requireAcp(requestedBackend);
173
+ return this.initializeResult(call.params, requestedBackend);
174
+ }
175
+ if (call.method === 'dsh/directoryList') {
176
+ return this.listDirectory(String(call.params.path));
177
+ }
178
+ if (call.method === 'dsh/workspaceList') {
179
+ const candidates = new Set();
180
+ const cwd = process.cwd();
181
+ if (cwd)
182
+ candidates.add(cwd);
183
+ for (const candidate of ['/var/lib/dsh/workspace/ds-harness-remote', '/var/lib/dsh/local']) {
184
+ try {
185
+ const s = await stat(candidate);
186
+ if (s.isDirectory())
187
+ candidates.add(candidate);
188
+ }
189
+ catch {
190
+ // ignore
191
+ }
192
+ }
193
+ const backend = typeof call.params?.backend === 'string' ? call.params.backend : undefined;
194
+ if (backend === 'antigravity') {
195
+ try {
196
+ const agyDirs = await discoverAntigravityWorkspaces();
197
+ for (const d of agyDirs) {
198
+ candidates.add(d);
199
+ }
200
+ }
201
+ catch {
202
+ // ignore
203
+ }
204
+ }
205
+ const results = await Promise.all([...candidates].map(async (p) => {
206
+ let sessionCount = 0;
207
+ if (backend === 'antigravity') {
208
+ try {
209
+ const sessions = await discoverAntigravitySessions(p, 100);
210
+ sessionCount = sessions.length;
211
+ }
212
+ catch {
213
+ // ignore
214
+ }
215
+ }
216
+ return {
217
+ path: p,
218
+ title: basename(p) || 'workspace',
219
+ ...(sessionCount > 0 ? { sessionCount } : {}),
220
+ };
221
+ }));
222
+ return results;
223
+ }
224
+ if (call.method === 'dsh/sessionList') {
225
+ const limit = typeof call.params.limit === 'number' ? call.params.limit : 30;
226
+ const path = String(call.params.path || '');
227
+ const backend = typeof call.params.backend === 'string' ? call.params.backend : this.defaultBackend();
228
+ if (backend === 'antigravity') {
229
+ // Workspace selection loads history before the user presses New.
230
+ // Prepare one idle process in that directory without awaiting startup.
231
+ if (path.trim() !== '' && call.params.prewarm !== false) {
232
+ const cwd = await this.requireExistingDirectory(path);
233
+ this.requireAcp('antigravity').prewarmSession?.(cwd);
234
+ }
235
+ const items = await discoverAntigravitySessions(path, limit);
236
+ if (path) {
237
+ const cwd = await this.requireExistingDirectory(path);
238
+ for (const item of items)
239
+ this.sessionCwds.set(item.conversationId, { backend, cwd });
240
+ }
241
+ this.logger.info('ACP session list fetched', { count: items.length });
242
+ return { items };
243
+ }
244
+ return { items: [] };
245
+ }
246
+ if (call.method === 'dsh/sessionHistory') {
247
+ const rawSessionId = String(call.params.sessionId);
248
+ const conversationId = rawSessionId.startsWith('acp:')
249
+ ? rawSessionId.slice('acp:'.length)
250
+ : rawSessionId.startsWith('cursor:')
251
+ ? rawSessionId.slice('cursor:'.length)
252
+ : rawSessionId;
253
+ const boundBackend = this.sessionBackends.get(conversationId);
254
+ const backend = typeof call.params.backend === 'string' ? call.params.backend : boundBackend ?? this.defaultBackend();
255
+ if ((boundBackend !== undefined && boundBackend !== backend)
256
+ || (rawSessionId.startsWith('cursor:') && backend !== 'cursor')
257
+ || (rawSessionId.startsWith('acp:') && backend !== 'antigravity')) {
258
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
259
+ }
260
+ this.requireAcp(backend);
261
+ if (backend !== 'antigravity')
262
+ return { events: [] };
263
+ const events = await loadTranscriptEvents(conversationId, rawSessionId);
264
+ this.logger.info('ACP session history fetched', {
265
+ sessionId: shortSessionId(rawSessionId),
266
+ eventCount: events.length,
267
+ });
268
+ return { events };
269
+ }
270
+ if (call.method === 'session/new') {
271
+ const cwd = await this.requireExistingDirectory(String(call.params.cwd));
272
+ const requestedBackend = typeof call.params.backend === 'string'
273
+ ? call.params.backend
274
+ : this.defaultBackend();
275
+ const acp = this.requireAcp(requestedBackend);
276
+ const result = await acp.call('session/new', {
277
+ cwd,
278
+ mcpServers: [],
279
+ ...(typeof call.params.mode === 'string' ? { mode: call.params.mode } : {}),
280
+ });
281
+ const sessionId = readSessionId(result);
282
+ if (sessionId !== undefined) {
283
+ this.sessionOwners.set(sessionId, connectionId);
284
+ this.sessionBackends.set(sessionId, requestedBackend);
285
+ this.sessionCwds.set(sessionId, { backend: requestedBackend, cwd });
286
+ }
287
+ return sanitizeSessionResult(result);
288
+ }
289
+ const sessionId = sessionIdFromParams(call.method, call.params);
290
+ if (sessionId !== undefined)
291
+ this.requireSessionAccess(connectionId, sessionId, call.method);
292
+ if (call.method === 'dsh/toolCall') {
293
+ this.requireSessionOwner(connectionId, sessionId);
294
+ return this.tools(connectionId).call(sessionId, String(call.params.backend), String(call.params.endpoint), call.params.args);
295
+ }
296
+ if (call.method === 'session/prompt') {
297
+ const selected = this.sessionBackends.get(sessionId);
298
+ const requested = typeof call.params.backend === 'string' ? call.params.backend : selected ?? this.defaultBackend();
299
+ if (selected !== undefined && selected !== requested)
300
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
301
+ this.requireAcp(requested);
302
+ if (Array.isArray(call.params.prompt) && call.params.prompt.some(part => isRecord(part) && part.type === 'image') && requested !== 'antigravity')
303
+ throw new RpcError('METHOD_NOT_ALLOWED', 'This ACP backend does not accept image prompts.');
304
+ this.sessionBackends.set(sessionId, requested);
305
+ }
306
+ if (call.method === 'session/load') {
307
+ const boundBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
308
+ const targetBackend = typeof call.params.backend === 'string' ? call.params.backend : boundBackend;
309
+ if (boundBackend !== undefined && targetBackend !== boundBackend) {
310
+ throw new RpcError('INVALID_MESSAGE', 'The ACP session backend does not match.');
311
+ }
312
+ 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 } : {}) });
314
+ const loadedId = readSessionId(result) ?? sessionId;
315
+ if (loadedId !== undefined) {
316
+ this.sessionOwners.set(loadedId, connectionId);
317
+ if (targetBackend)
318
+ this.sessionBackends.set(loadedId, targetBackend);
319
+ }
320
+ return sanitizeSessionResult(result);
321
+ }
322
+ if (isSessionMutation(call.method) && sessionId !== undefined) {
323
+ this.requireSessionOwner(connectionId, sessionId);
324
+ }
325
+ if (call.method === 'dsh/selectModel') {
326
+ const session = this.sessionCwds.get(sessionId);
327
+ if (!session || session.backend !== call.params.backend) {
328
+ throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
329
+ }
330
+ // Resume straight into the target model. Loading the old model first
331
+ // starts a second CLI process that would immediately be discarded.
332
+ const result = await this.requireAcp(session.backend).call(call.method, { ...call.params, cwd: session.cwd });
333
+ this.sessionBackends.set(sessionId, session.backend);
334
+ return sanitizeSessionResult(result);
335
+ }
336
+ // session/prompt blocks until the upstream turn ends. Returning that RPC
337
+ // only after completion prevents some Client transports from delivering
338
+ // interleaved agent.acp.frame events (Android stays on "正在回复" with no
339
+ // thought/text). Accept immediately and finish via stream updates.
340
+ if (call.method === 'session/prompt' && sessionId !== undefined) {
341
+ const ownerPeer = this.peers.get(connectionId);
342
+ if (ownerPeer !== undefined && !ownerPeer.hasStreamFor(sessionId)) {
343
+ this.logger?.warn('Cursor prompt started without an open ACP stream', {
344
+ sessionId: shortSessionId(sessionId),
345
+ });
346
+ }
347
+ this.turnCatchUp.set(sessionId, []);
348
+ void this.runPromptInBackground(sessionId, call.params);
349
+ return { accepted: true, stopReason: 'in_progress' };
350
+ }
351
+ const sessionBackend = sessionId !== undefined ? this.sessionBackends.get(sessionId) : undefined;
352
+ return sanitizeSessionResult(await this.requireAcp(typeof call.params.backend === 'string' ? call.params.backend : sessionBackend).call(call.method, call.params));
353
+ }
354
+ async runPromptInBackground(sessionId, params) {
355
+ try {
356
+ const sessionBackend = this.sessionBackends.get(sessionId);
357
+ const acp = this.requireAcp(sessionBackend);
358
+ const result = await acp.call('session/prompt', params);
359
+ // Cursor emits final session/update lines before the JSON-RPC result.
360
+ // Those notifications are queued on inboundChain; drain it before we
361
+ // snapshot catch-up or the Client only sees an empty prompt_completed.
362
+ await this.inboundChain;
363
+ const stopReason = isRecord(result) && typeof result.stopReason === 'string'
364
+ ? result.stopReason
365
+ : 'end_turn';
366
+ const catchUp = takeTurnCatchUp(this.turnCatchUp, sessionId);
367
+ this.logger.info('ACP prompt finished', {
368
+ sessionId: shortSessionId(sessionId),
369
+ stopReason,
370
+ catchUp: catchUp.length,
371
+ });
372
+ await this.publishToSession(sessionId, {
373
+ method: 'session/update',
374
+ params: {
375
+ sessionId,
376
+ update: {
377
+ sessionUpdate: 'prompt_completed',
378
+ stopReason,
379
+ ...(catchUp.length > 0 ? { catchUp } : {}),
380
+ },
381
+ },
382
+ });
383
+ }
384
+ catch (error) {
385
+ this.logger?.warn('ACP session/prompt failed', { code: errorCode(error) });
386
+ await this.inboundChain.catch(() => undefined);
387
+ const catchUp = takeTurnCatchUp(this.turnCatchUp, sessionId);
388
+ await this.publishToSession(sessionId, {
389
+ method: 'session/update',
390
+ params: {
391
+ sessionId,
392
+ update: {
393
+ sessionUpdate: 'prompt_failed',
394
+ code: errorCode(error),
395
+ ...(catchUp.length > 0 ? { catchUp } : {}),
396
+ },
397
+ },
398
+ }).catch(() => undefined);
399
+ }
400
+ }
401
+ async respond(connectionId, input) {
402
+ const params = parseRespondEnvelope(input);
403
+ const pending = this.approvals.get(params.requestHandle);
404
+ if (pending === undefined || pending.expiresAt <= Date.now()) {
405
+ this.approvals.delete(params.requestHandle);
406
+ throw new RpcError('CURSOR_APPROVAL_NOT_FOUND', 'The Cursor approval is missing, expired, or belongs to another connection.');
407
+ }
408
+ if (pending.connectionId !== connectionId) {
409
+ throw new RpcError('CURSOR_APPROVAL_NOT_FOUND', 'The Cursor approval is missing, expired, or belongs to another connection.');
410
+ }
411
+ this.approvals.delete(params.requestHandle);
412
+ const acp = this.requireAcp(pending.backend);
413
+ if (params.decision === 'cancel') {
414
+ await acp.respondError(pending.upstreamId, -32800, 'Cancelled by Remote client.');
415
+ return { resolved: true };
416
+ }
417
+ const result = params.result ?? mapPermissionDecision(params.decision, pending.method);
418
+ await acp.respond(pending.upstreamId, result);
419
+ return { resolved: true };
420
+ }
421
+ dropPeer(connectionId) {
422
+ this.peers.delete(connectionId);
423
+ void this.workspaceTools.get(connectionId)?.close();
424
+ this.workspaceTools.delete(connectionId);
425
+ for (const [sessionId, owner] of this.sessionOwners) {
426
+ if (owner === connectionId)
427
+ this.sessionOwners.delete(sessionId);
428
+ }
429
+ for (const [handle, approval] of this.approvals) {
430
+ if (approval.connectionId === connectionId) {
431
+ this.approvals.delete(handle);
432
+ void this.requireAcp(approval.backend).respondError(approval.upstreamId, -32800, 'Remote peer disconnected.').catch(() => undefined);
433
+ }
434
+ }
435
+ }
436
+ tools(connectionId) {
437
+ const tools = this.workspaceTools.get(connectionId);
438
+ if (!tools)
439
+ throw new RpcError('ACP_CONNECTION_CLOSED', 'The ACP workspace connection is closed.');
440
+ return tools;
441
+ }
442
+ async openToolStream(connectionId, sessionId, backend, endpoint, args, signal) {
443
+ const session = this.sessionCwds.get(sessionId);
444
+ if (!session || session.backend !== backend)
445
+ throw new RpcError('ACP_WORKSPACE_UNAVAILABLE', 'The ACP session has no trusted workspace.');
446
+ this.requireAcp(backend);
447
+ this.requireSessionOwner(connectionId, sessionId);
448
+ return this.tools(connectionId).open(sessionId, backend, endpoint, args, signal);
449
+ }
450
+ /** Used by peer stream open to prove this connection may observe the session. */
451
+ assertStreamable(connectionId, sessionId) {
452
+ this.claimSession(connectionId, sessionId);
453
+ }
454
+ /**
455
+ * After a Client reconnect, session ownership may have been cleared with the
456
+ * old peer. Reclaim the in-memory ACP session for the new connection so
457
+ * stream open / prompt can resume and buffered frames can replay.
458
+ */
459
+ claimSession(connectionId, sessionId) {
460
+ const owner = this.sessionOwners.get(sessionId);
461
+ if (owner === undefined) {
462
+ this.sessionOwners.set(sessionId, connectionId);
463
+ return;
464
+ }
465
+ if (owner !== connectionId) {
466
+ throw new RpcError('CURSOR_SESSION_OWNED', 'Another Remote connection owns this Cursor session.');
467
+ }
468
+ }
469
+ /** Replay frames buffered while no healthy peer could receive them. */
470
+ async replayBufferedFrames(connectionId, sessionId) {
471
+ const peer = this.peers.get(connectionId);
472
+ if (peer === undefined)
473
+ return;
474
+ this.pruneFrameBuffer(sessionId);
475
+ const buffered = this.recentFrames.get(sessionId) ?? [];
476
+ if (buffered.length === 0)
477
+ return;
478
+ this.recentFrames.delete(sessionId);
479
+ this.logger.info('Replaying buffered ACP frames', {
480
+ sessionId: shortSessionId(sessionId),
481
+ count: buffered.length,
482
+ });
483
+ for (const frame of buffered) {
484
+ await peer.publishInbound(sessionId, { method: frame.method, params: frame.params });
485
+ }
486
+ }
487
+ async reconfigure(config) {
488
+ if (this.closed)
489
+ throw new RpcError('CURSOR_CLOSED', 'The ACP Remote domain is closed.');
490
+ const previous = this.config;
491
+ const implemented = (value) => ({ enabled: value.enabled,
492
+ backends: value.backends.filter(item => ['cursor', 'antigravity'].includes(item.id)) });
493
+ this.config = config;
494
+ if (JSON.stringify(implemented(previous)) === JSON.stringify(implemented(config)))
495
+ return;
496
+ if (this.restartTimer !== undefined)
497
+ clearTimeout(this.restartTimer);
498
+ 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()));
504
+ await this.startPromise?.catch(() => undefined);
505
+ const sessions = new Set([...this.sessionBackends].filter(([, backend]) => changed.has(backend)).map(([id]) => id));
506
+ if (sessions.size > 0)
507
+ await Promise.all([...this.peers.values()].map(peer => peer.failStreams('failed', sessions)));
508
+ for (const [id, session] of this.sessionCwds)
509
+ if (changed.has(session.backend))
510
+ 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
+ }
519
+ for (const id of sessions) {
520
+ this.sessionOwners.delete(id);
521
+ this.sessionBackends.delete(id);
522
+ this.recentFrames.delete(id);
523
+ this.turnCatchUp.delete(id);
524
+ }
525
+ for (const [id, approval] of this.approvals)
526
+ if (approval.backend !== undefined && changed.has(approval.backend))
527
+ this.approvals.delete(id);
528
+ for (const id of changed) {
529
+ const instance = this.backendInstances.get(id);
530
+ if (instance === undefined)
531
+ continue;
532
+ this.backendInstances.delete(id);
533
+ await this.disposeInstance(instance);
534
+ }
535
+ this.available = this.availableBackends().length > 0;
536
+ this.state = this.available ? 'ready' : 'disabled';
537
+ this.restartAttempt = 0;
538
+ this.unavailableCode = undefined;
539
+ await this.start();
540
+ }
541
+ async close() {
542
+ if (this.closed)
543
+ return;
544
+ this.closed = true;
545
+ if (this.restartTimer !== undefined)
546
+ clearTimeout(this.restartTimer);
547
+ if (this.approvalExpiryTimer !== undefined)
548
+ clearTimeout(this.approvalExpiryTimer);
549
+ for (const peer of this.peers.values())
550
+ await peer.closeAll();
551
+ this.peers.clear();
552
+ 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();
560
+ this.sessionOwners.clear();
561
+ this.sessionBackends.clear();
562
+ this.recentFrames.clear();
563
+ this.turnCatchUp.clear();
564
+ this.approvals.clear();
565
+ await Promise.all([...this.startingClients.keys()].map(client => client.close()));
566
+ await this.disposeAllInstances();
567
+ this.available = false;
568
+ this.state = 'disabled';
569
+ }
570
+ async launchAcp() {
571
+ const candidates = this.config.backends.filter(item => this.enabledBackends().includes(item.id));
572
+ const results = await Promise.allSettled(candidates.map(async (candidate) => {
573
+ if (this.backendInstances.get(candidate.id)?.client.isReady())
574
+ return;
575
+ let lastError;
576
+ for (const binary of cursorBinaryCandidates(candidate.command)) {
577
+ if (this.closed || !this.backendCurrent(candidate))
578
+ return;
579
+ try {
580
+ await this.launchBackendCandidate(candidate, binary);
581
+ return;
582
+ }
583
+ catch (error) {
584
+ lastError = error;
585
+ this.logger.debug?.('ACP candidate failed', { id: candidate.id, code: errorCode(error) });
586
+ }
587
+ }
588
+ throw lastError;
589
+ }));
590
+ if (this.closed)
591
+ return;
592
+ if (this.enabledBackends().length === 0) {
593
+ this.state = 'disabled';
594
+ return;
595
+ }
596
+ if (this.isAvailable()) {
597
+ this.state = 'ready';
598
+ this.unavailableCode = undefined;
599
+ this.restartAttempt = 0;
600
+ return;
601
+ }
602
+ const failure = results.find(result => result.status === 'rejected');
603
+ throw failure?.status === 'rejected' ? failure.reason : new CursorAcpError('CURSOR_BINARY_UNAVAILABLE', 'No ACP backend is available.');
604
+ }
605
+ backendCurrent(backend) {
606
+ return this.config.enabled && backend.enabled
607
+ && JSON.stringify(this.config.backends.find(item => item.id === backend.id)) === JSON.stringify(backend);
608
+ }
609
+ async launchBackendCandidate(backend, binary) {
610
+ const id = backend.id;
611
+ const acp = this.createAcp(binary, this.logger, backend);
612
+ this.startingClients.set(acp, backend);
613
+ try {
614
+ await acp.start();
615
+ if (this.closed || !this.backendCurrent(backend)) {
616
+ await acp.close();
617
+ return;
618
+ }
619
+ }
620
+ catch (error) {
621
+ await acp.close();
622
+ throw error;
623
+ }
624
+ finally {
625
+ this.startingClients.delete(acp);
626
+ }
627
+ const unsubscribeInbound = acp.onInbound(message => {
628
+ this.inboundChain = this.inboundChain
629
+ .then(() => this.handleInbound(id, message))
630
+ .catch(error => {
631
+ this.logger.warn('ACP inbound fanout failed', { id, code: errorCode(error) });
632
+ });
633
+ });
634
+ const unsubscribeUnavailable = acp.onUnavailable(code => { void this.handleUnavailable(code); });
635
+ const prev = this.backendInstances.get(id);
636
+ if (prev !== undefined) {
637
+ await this.disposeInstance(prev);
638
+ }
639
+ if (this.closed || !this.backendCurrent(backend)) {
640
+ unsubscribeInbound();
641
+ unsubscribeUnavailable();
642
+ await acp.close();
643
+ return;
644
+ }
645
+ this.available = true;
646
+ this.backendInstances.set(id, {
647
+ id,
648
+ client: acp,
649
+ unsubscribeInbound,
650
+ unsubscribeUnavailable,
651
+ });
652
+ }
653
+ async handleInbound(backend, message) {
654
+ // session/update is a stream notification even if a buggy agent attaches an id.
655
+ if (message.kind === 'notification' || message.method === 'session/update') {
656
+ const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params);
657
+ if (sessionId === undefined)
658
+ return;
659
+ if (!this.sessionBackends.has(sessionId)) {
660
+ this.sessionBackends.set(sessionId, backend);
661
+ }
662
+ await this.publishToSession(sessionId, { method: message.method, params: message.params });
663
+ return;
664
+ }
665
+ const sessionId = readSessionId(message.params) ?? readNestedSessionId(message.params) ?? 'unknown';
666
+ if (sessionId !== 'unknown' && !this.sessionBackends.has(sessionId)) {
667
+ this.sessionBackends.set(sessionId, backend);
668
+ }
669
+ const requestHandle = randomUUID();
670
+ 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,
677
+ });
678
+ 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
+ }
695
+ }
696
+ await this.publishToSession(sessionId, frame);
697
+ }
698
+ async publishToSession(sessionId, frame) {
699
+ this.recordTurnCatchUp(sessionId, frame);
700
+ const ownerId = this.sessionOwners.get(sessionId);
701
+ const entries = [...this.peers.entries()];
702
+ if (entries.length === 0) {
703
+ this.bufferFrame(sessionId, frame);
704
+ return;
705
+ }
706
+ const deliveries = await Promise.all(entries.map(async ([connectionId, peer]) => {
707
+ try {
708
+ await peer.publishInbound(sessionId, frame);
709
+ return { connectionId, ok: true };
710
+ }
711
+ catch {
712
+ return { connectionId, ok: false };
713
+ }
714
+ }));
715
+ const ownerDelivered = ownerId !== undefined
716
+ && deliveries.some(item => item.connectionId === ownerId && item.ok);
717
+ // Loopback peers resolve successfully while swallowing events. Only treat
718
+ // the session owner's delivery as proof the Remote Client received the frame.
719
+ if (ownerId !== undefined ? !ownerDelivered : deliveries.every(item => !item.ok)) {
720
+ this.bufferFrame(sessionId, frame);
721
+ }
722
+ }
723
+ recordTurnCatchUp(sessionId, frame) {
724
+ const list = this.turnCatchUp.get(sessionId);
725
+ if (list === undefined || frame.method !== 'session/update')
726
+ return;
727
+ const params = isRecord(frame.params) ? frame.params : undefined;
728
+ const update = params !== undefined && isRecord(params.update) ? params.update : params;
729
+ const kind = update !== undefined && typeof update.sessionUpdate === 'string'
730
+ ? update.sessionUpdate
731
+ : undefined;
732
+ if (kind === undefined || !CATCH_UP_SESSION_UPDATES.has(kind))
733
+ return;
734
+ list.push({ method: frame.method, params: frame.params });
735
+ while (list.length > MAX_TURN_CATCH_UP_FRAMES)
736
+ list.shift();
737
+ }
738
+ bufferFrame(sessionId, frame) {
739
+ this.pruneFrameBuffer(sessionId);
740
+ const list = this.recentFrames.get(sessionId) ?? [];
741
+ list.push({ method: frame.method, params: frame.params, at: Date.now() });
742
+ while (list.length > MAX_BUFFERED_ACP_FRAMES)
743
+ list.shift();
744
+ this.recentFrames.set(sessionId, list);
745
+ this.logger.warn('Buffered ACP frame for later replay', {
746
+ sessionId: shortSessionId(sessionId),
747
+ method: frame.method,
748
+ buffered: list.length,
749
+ });
750
+ }
751
+ pruneFrameBuffer(sessionId) {
752
+ const list = this.recentFrames.get(sessionId);
753
+ if (list === undefined)
754
+ return;
755
+ const validAfter = Date.now() - ACP_FRAME_BUFFER_TTL_MS;
756
+ const next = list.filter(frame => frame.at >= validAfter);
757
+ if (next.length === 0)
758
+ this.recentFrames.delete(sessionId);
759
+ else
760
+ this.recentFrames.set(sessionId, next);
761
+ }
762
+ async handleUnavailable(code) {
763
+ if (this.closed)
764
+ return;
765
+ this.available = false;
766
+ this.state = 'restarting';
767
+ this.unavailableCode = code;
768
+ await Promise.all([...this.peers.values()].map(peer => peer.failStreams('failed')));
769
+ this.scheduleRestart();
770
+ }
771
+ scheduleRestart() {
772
+ if (this.closed || !this.config.enabled)
773
+ return;
774
+ if (this.restartAttempt >= this.restartDelaysMs.length) {
775
+ this.state = 'unavailable';
776
+ return;
777
+ }
778
+ const delay = this.restartDelaysMs[this.restartAttempt];
779
+ this.restartAttempt += 1;
780
+ if (this.restartTimer !== undefined)
781
+ clearTimeout(this.restartTimer);
782
+ this.restartTimer = setTimeout(() => {
783
+ void this.start().catch(() => undefined);
784
+ }, delay);
785
+ this.restartTimer.unref?.();
786
+ }
787
+ scheduleApprovalExpiry() {
788
+ if (this.approvalExpiryTimer !== undefined)
789
+ clearTimeout(this.approvalExpiryTimer);
790
+ const next = [...this.approvals.values()].reduce((min, item) => {
791
+ if (min === undefined || item.expiresAt < min)
792
+ return item.expiresAt;
793
+ return min;
794
+ }, undefined);
795
+ if (next === undefined)
796
+ return;
797
+ this.approvalExpiryTimer = setTimeout(() => {
798
+ const now = Date.now();
799
+ for (const [handle, approval] of this.approvals) {
800
+ if (approval.expiresAt <= now) {
801
+ this.approvals.delete(handle);
802
+ void this.requireAcp(approval.backend).respondError(approval.upstreamId, -32800, 'Cursor approval expired.').catch(() => undefined);
803
+ }
804
+ }
805
+ this.scheduleApprovalExpiry();
806
+ }, Math.max(0, next - Date.now()));
807
+ this.approvalExpiryTimer.unref?.();
808
+ }
809
+ requireAcp(backend) {
810
+ if (!this.config.enabled || (backend !== undefined && !this.enabledBackends().includes(backend)) || !this.isAvailable()) {
811
+ throw new RpcError('CURSOR_UNAVAILABLE', 'Cursor ACP is disabled or unavailable on this Host.');
812
+ }
813
+ if (backend !== undefined) {
814
+ const instance = this.backendInstances.get(backend);
815
+ if (instance !== undefined && instance.client.isReady())
816
+ return instance.client;
817
+ throw new RpcError('CURSOR_UNAVAILABLE', 'The requested ACP backend is disabled or unavailable on this Host.');
818
+ }
819
+ const selected = this.backendInstances.get(this.defaultBackend());
820
+ if (selected !== undefined && selected.client.isReady())
821
+ return selected.client;
822
+ throw new RpcError('CURSOR_UNAVAILABLE', 'Cursor ACP is disabled or unavailable on this Host.');
823
+ }
824
+ initializeResult(params, backend) {
825
+ const requested = typeof params.protocolVersion === 'number' ? params.protocolVersion : 1;
826
+ const actualBackend = backend ?? this.defaultBackend();
827
+ return {
828
+ protocolVersion: requested,
829
+ agentInfo: {
830
+ name: actualBackend === 'antigravity' ? 'antigravity' : 'dsh-remote-acp',
831
+ version: PLUGIN_VERSION,
832
+ },
833
+ backend: actualBackend,
834
+ availableBackends: this.availableBackends(),
835
+ authMethods: [],
836
+ capabilities: {
837
+ loadSession: true,
838
+ promptTypes: actualBackend === 'antigravity' ? ['text', 'image'] : ['text'],
839
+ methods: [...ACP_METHOD_ALLOWLIST],
840
+ },
841
+ };
842
+ }
843
+ callUpstream(method, params) {
844
+ return this.requireAcp().call(method, params);
845
+ }
846
+ requireSessionAccess(connectionId, sessionId, method) {
847
+ if (method === 'session/load')
848
+ return;
849
+ const owner = this.sessionOwners.get(sessionId);
850
+ if (owner === undefined) {
851
+ // Allow reclaim after the owning peer disconnected; the ACP process still
852
+ // holds the session.
853
+ this.sessionOwners.set(sessionId, connectionId);
854
+ return;
855
+ }
856
+ if (owner !== connectionId && isSessionMutation(method)) {
857
+ throw new RpcError('CURSOR_SESSION_OWNED', 'Another Remote connection owns this Cursor session.');
858
+ }
859
+ }
860
+ requireSessionOwner(connectionId, sessionId) {
861
+ this.claimSession(connectionId, sessionId);
862
+ }
863
+ async requireExistingDirectory(path) {
864
+ if (!isAbsolute(path)) {
865
+ throw new RpcError('CURSOR_PATH_NOT_ALLOWED', 'The Cursor working directory must be an absolute path.');
866
+ }
867
+ try {
868
+ const canonical = await realpath(path);
869
+ const info = await stat(canonical);
870
+ if (!info.isDirectory()) {
871
+ throw new RpcError('CURSOR_PATH_NOT_ALLOWED', 'The Cursor working directory must be an existing directory.');
872
+ }
873
+ return canonical;
874
+ }
875
+ catch (error) {
876
+ if (error instanceof RpcError)
877
+ throw error;
878
+ throw new RpcError('CURSOR_PATH_NOT_ALLOWED', 'The Cursor working directory must be an existing directory.');
879
+ }
880
+ }
881
+ async listDirectory(path) {
882
+ const home = homedir();
883
+ const target = path.trim() === '~' || path.trim() === ''
884
+ ? home
885
+ : path.startsWith('~/')
886
+ ? join(home, path.slice(2))
887
+ : path;
888
+ const canonical = await this.requireExistingDirectory(isAbsolute(target) ? target : resolve(target));
889
+ const names = await readdir(canonical);
890
+ const entries = [];
891
+ let truncated = false;
892
+ for (const name of names.sort((a, b) => a.localeCompare(b))) {
893
+ if (entries.length >= ACP_DIRECTORY_ENTRY_LIMIT) {
894
+ truncated = true;
895
+ break;
896
+ }
897
+ const child = join(canonical, name);
898
+ try {
899
+ const info = await stat(child);
900
+ if (!info.isDirectory())
901
+ continue;
902
+ entries.push({ name, path: child, hidden: name.startsWith('.') });
903
+ }
904
+ catch {
905
+ // Skip unreadable entries.
906
+ }
907
+ }
908
+ return {
909
+ path: canonical,
910
+ home,
911
+ crumbs: buildCrumbs(canonical, home),
912
+ entries,
913
+ truncated,
914
+ };
915
+ }
916
+ async disposeInstance(inst) {
917
+ inst.unsubscribeInbound();
918
+ inst.unsubscribeUnavailable();
919
+ await inst.client.close();
920
+ }
921
+ async disposeAllInstances() {
922
+ for (const inst of this.backendInstances.values()) {
923
+ await this.disposeInstance(inst);
924
+ }
925
+ this.backendInstances.clear();
926
+ }
927
+ }
928
+ function parseCallEnvelope(input) {
929
+ if (!isRecord(input) || typeof input.method !== 'string') {
930
+ throw new RpcError('INVALID_MESSAGE', 'The Cursor call envelope is invalid.');
931
+ }
932
+ return { method: input.method, params: input.params ?? {} };
933
+ }
934
+ 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.');
937
+ }
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
+ };
947
+ }
948
+ function mapPermissionDecision(decision, method) {
949
+ if (method === 'session/request_permission') {
950
+ return { outcome: { outcome: 'selected', optionId: decision === 'cancel' ? 'reject-once' : decision } };
951
+ }
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' } };
956
+ }
957
+ if (method === 'cursor/ask_question') {
958
+ return { outcome: { outcome: 'cancelled' } };
959
+ }
960
+ return { outcome: { outcome: 'selected', optionId: decision } };
961
+ }
962
+ function readSessionId(value) {
963
+ if (!isRecord(value))
964
+ return undefined;
965
+ return typeof value.sessionId === 'string' ? value.sessionId : undefined;
966
+ }
967
+ function readNestedSessionId(value) {
968
+ if (!isRecord(value))
969
+ return undefined;
970
+ if (isRecord(value.update) && typeof value.update.sessionId === 'string')
971
+ return value.update.sessionId;
972
+ return undefined;
973
+ }
974
+ function sanitizeSessionResult(value) {
975
+ if (!isRecord(value))
976
+ return value;
977
+ const next = {};
978
+ for (const [key, entry] of Object.entries(value)) {
979
+ if (key === 'sessionId' || key === 'stopReason' || key === 'mode')
980
+ next[key] = entry;
981
+ }
982
+ return Object.keys(next).length > 0 ? next : value;
983
+ }
984
+ function buildCrumbs(path, home) {
985
+ const crumbs = [];
986
+ let current = path;
987
+ while (true) {
988
+ crumbs.unshift({
989
+ name: current === home ? '~' : basename(current) || current,
990
+ path: current,
991
+ hidden: false,
992
+ });
993
+ const parent = resolve(current, '..');
994
+ if (parent === current)
995
+ break;
996
+ if (home !== '' && relative(home, current) === '' && current !== home)
997
+ break;
998
+ current = parent;
999
+ if (crumbs.length >= 32)
1000
+ break;
1001
+ }
1002
+ return crumbs;
1003
+ }
1004
+ /**
1005
+ * Prefer `~/.local/bin/agent` when the user kept the default command. Explicit
1006
+ * binary configuration is never rewritten.
1007
+ */
1008
+ export function cursorBinaryCandidates(configured) {
1009
+ const userHome = homedir();
1010
+ if (configured === 'agy' || configured === 'antigravity') {
1011
+ return [
1012
+ join(userHome, '.local', 'bin', 'agy'),
1013
+ 'agy',
1014
+ ];
1015
+ }
1016
+ if (configured === 'agent' || configured === 'cursor') {
1017
+ return [
1018
+ join(userHome, '.local', 'bin', 'agent'),
1019
+ 'agent',
1020
+ ];
1021
+ }
1022
+ return [configured];
1023
+ }
1024
+ function errorCode(error) {
1025
+ if (error instanceof CursorAcpError || error instanceof RpcError)
1026
+ return error.code;
1027
+ return 'CURSOR_UNAVAILABLE';
1028
+ }
1029
+ function shortSessionId(sessionId) {
1030
+ return sessionId.length <= 16 ? sessionId : `${sessionId.slice(0, 8)}…${sessionId.slice(-4)}`;
1031
+ }
1032
+ function takeTurnCatchUp(turnCatchUp, sessionId) {
1033
+ const list = turnCatchUp.get(sessionId) ?? [];
1034
+ turnCatchUp.delete(sessionId);
1035
+ return list;
1036
+ }
1037
+ function isRecord(value) {
1038
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1039
+ }
1040
+ //# sourceMappingURL=gateway.js.map