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