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