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.
- package/README.md +6 -6
- package/dist/acp/adapter.d.ts +19 -0
- package/dist/acp/adapter.d.ts.map +1 -0
- package/dist/acp/adapter.js +14 -0
- package/dist/acp/adapter.js.map +1 -0
- package/dist/acp/adapters/antigravity/executor.d.ts +20 -0
- package/dist/acp/adapters/antigravity/executor.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/executor.js +99 -0
- package/dist/acp/adapters/antigravity/executor.js.map +1 -0
- package/dist/acp/adapters/antigravity/image-store.d.ts +9 -0
- package/dist/acp/adapters/antigravity/image-store.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/image-store.js +116 -0
- package/dist/acp/adapters/antigravity/image-store.js.map +1 -0
- package/dist/acp/adapters/antigravity/index.d.ts +5 -0
- package/dist/acp/adapters/antigravity/index.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/index.js +5 -0
- package/dist/acp/adapters/antigravity/index.js.map +1 -0
- package/dist/acp/adapters/antigravity/models.d.ts +35 -0
- package/dist/acp/adapters/antigravity/models.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/models.js +59 -0
- package/dist/acp/adapters/antigravity/models.js.map +1 -0
- package/dist/acp/adapters/antigravity/service.d.ts +35 -0
- package/dist/acp/adapters/antigravity/service.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/service.js +67 -0
- package/dist/acp/adapters/antigravity/service.js.map +1 -0
- package/dist/acp/adapters/antigravity/transcript-loader.d.ts +21 -0
- package/dist/acp/adapters/antigravity/transcript-loader.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/transcript-loader.js +356 -0
- package/dist/acp/adapters/antigravity/transcript-loader.js.map +1 -0
- package/dist/acp/adapters/antigravity/transcript-watcher.d.ts +28 -0
- package/dist/acp/adapters/antigravity/transcript-watcher.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/transcript-watcher.js +157 -0
- package/dist/acp/adapters/antigravity/transcript-watcher.js.map +1 -0
- package/dist/acp/adapters/antigravity/types.d.ts +56 -0
- package/dist/acp/adapters/antigravity/types.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity/types.js +2 -0
- package/dist/acp/adapters/antigravity/types.js.map +1 -0
- package/dist/acp/adapters/antigravity-process.d.ts +69 -0
- package/dist/acp/adapters/antigravity-process.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity-process.js +566 -0
- package/dist/acp/adapters/antigravity-process.js.map +1 -0
- package/dist/acp/adapters/antigravity.d.ts +8 -0
- package/dist/acp/adapters/antigravity.d.ts.map +1 -0
- package/dist/acp/adapters/antigravity.js +9 -0
- package/dist/acp/adapters/antigravity.js.map +1 -0
- package/dist/acp/adapters/cursor-process.d.ts +75 -0
- package/dist/acp/adapters/cursor-process.d.ts.map +1 -0
- package/dist/acp/adapters/cursor-process.js +273 -0
- package/dist/acp/adapters/cursor-process.js.map +1 -0
- package/dist/acp/adapters/cursor.d.ts +8 -0
- package/dist/acp/adapters/cursor.d.ts.map +1 -0
- package/dist/acp/adapters/cursor.js +8 -0
- package/dist/acp/adapters/cursor.js.map +1 -0
- package/dist/acp/gateway.d.ts +108 -0
- package/dist/acp/gateway.d.ts.map +1 -0
- package/dist/acp/gateway.js +1040 -0
- package/dist/acp/gateway.js.map +1 -0
- package/dist/acp/image-content.d.ts +19 -0
- package/dist/acp/image-content.d.ts.map +1 -0
- package/dist/acp/image-content.js +76 -0
- package/dist/acp/image-content.js.map +1 -0
- package/dist/acp/method-policy.d.ts +239 -0
- package/dist/acp/method-policy.d.ts.map +1 -0
- package/dist/acp/method-policy.js +102 -0
- package/dist/acp/method-policy.js.map +1 -0
- package/dist/acp/peer-bridge.d.ts +59 -0
- package/dist/acp/peer-bridge.d.ts.map +1 -0
- package/dist/acp/peer-bridge.js +367 -0
- package/dist/acp/peer-bridge.js.map +1 -0
- package/dist/acp/virtual-harness.d.ts +181 -0
- package/dist/acp/virtual-harness.d.ts.map +1 -0
- package/dist/acp/virtual-harness.js +1556 -0
- package/dist/acp/virtual-harness.js.map +1 -0
- package/dist/acp/workspace-tools.d.ts +13 -0
- package/dist/acp/workspace-tools.d.ts.map +1 -0
- package/dist/acp/workspace-tools.js +46 -0
- package/dist/acp/workspace-tools.js.map +1 -0
- package/dist/acp.d.ts.map +1 -1
- package/dist/acp.js +1 -0
- package/dist/acp.js.map +1 -1
- package/dist/api-proxy-switch.d.ts +2 -0
- package/dist/api-proxy-switch.d.ts.map +1 -1
- package/dist/api-proxy-switch.js +9 -0
- package/dist/api-proxy-switch.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -3
- package/dist/cli.js.map +1 -1
- package/dist/client-runtime.d.ts +25 -1
- package/dist/client-runtime.d.ts.map +1 -1
- package/dist/client-runtime.js +222 -8
- package/dist/client-runtime.js.map +1 -1
- package/dist/client.github.js +327 -190
- package/dist/client.js +327 -190
- package/dist/client.js.map +1 -1
- package/dist/codex/domain.d.ts +4 -1
- package/dist/codex/domain.d.ts.map +1 -1
- package/dist/codex/domain.js +54 -4
- package/dist/codex/domain.js.map +1 -1
- package/dist/codex/virtual-harness.d.ts.map +1 -1
- package/dist/codex/virtual-harness.js +3 -0
- package/dist/codex/virtual-harness.js.map +1 -1
- package/dist/codex-workspace-bridge.d.ts +7 -105
- package/dist/codex-workspace-bridge.d.ts.map +1 -1
- package/dist/codex-workspace-bridge.js +10 -498
- package/dist/codex-workspace-bridge.js.map +1 -1
- package/dist/config.d.ts +28 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +49 -5
- package/dist/config.js.map +1 -1
- package/dist/connection-controller.d.ts.map +1 -1
- package/dist/connection-controller.js +7 -4
- package/dist/connection-controller.js.map +1 -1
- package/dist/control-runtime.d.ts +1 -0
- package/dist/control-runtime.d.ts.map +1 -1
- package/dist/control-runtime.js +65 -10
- package/dist/control-runtime.js.map +1 -1
- package/dist/harness-version.d.ts.map +1 -1
- package/dist/harness-version.js +16 -3
- package/dist/harness-version.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13197 -8204
- package/dist/index.js.map +4 -4
- package/dist/loopback-host.d.ts +2 -2
- package/dist/loopback-host.d.ts.map +1 -1
- package/dist/loopback-host.js +41 -16
- package/dist/loopback-host.js.map +1 -1
- package/dist/remote-typert-gateway.d.ts.map +1 -1
- package/dist/remote-typert-gateway.js +7 -4
- package/dist/remote-typert-gateway.js.map +1 -1
- package/dist/rpc-router.d.ts +6 -5
- package/dist/rpc-router.d.ts.map +1 -1
- package/dist/rpc-router.js +49 -18
- package/dist/rpc-router.js.map +1 -1
- package/dist/server-api.d.ts +2 -0
- package/dist/server-api.d.ts.map +1 -1
- package/dist/server-api.js +5 -0
- package/dist/server-api.js.map +1 -1
- package/dist/server-connection.d.ts.map +1 -1
- package/dist/server-connection.js +15 -0
- package/dist/server-connection.js.map +1 -1
- package/dist/service.d.ts +30 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +114 -12
- package/dist/service.js.map +1 -1
- package/dist/session-format-compat.d.ts +1 -0
- package/dist/session-format-compat.d.ts.map +1 -1
- package/dist/session-format-compat.js +14 -0
- package/dist/session-format-compat.js.map +1 -1
- package/dist/tui-command.js +2 -0
- package/dist/tui-command.js.map +1 -1
- package/dist/typert-gateway-switch.d.ts +2 -0
- package/dist/typert-gateway-switch.d.ts.map +1 -1
- package/dist/typert-gateway-switch.js +16 -0
- package/dist/typert-gateway-switch.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/workspace-bridge.d.ts +117 -0
- package/dist/workspace-bridge.d.ts.map +1 -0
- package/dist/workspace-bridge.js +512 -0
- package/dist/workspace-bridge.js.map +1 -0
- package/dsh-plugin.json +1 -1
- 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
|