@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,1732 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Unified ACP (Agent Client Protocol) adapter for all ACP-compatible coding agents.
|
|
4
|
+
* Supports: codex-acp and other ACP-compliant agent processes.
|
|
5
|
+
*
|
|
6
|
+
* Protocol: JSON-RPC 2.0 over stdio (newline-delimited JSON)
|
|
7
|
+
* Spec: https://github.com/agentclientprotocol/typescript-sdk
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.AcpAdapter = void 0;
|
|
11
|
+
/**
|
|
12
|
+
* Extracted verbatim from 20x `src/main/adapters/acp-adapter.ts`.
|
|
13
|
+
* CHANGED: imports only — `../child-stream-guards` ->
|
|
14
|
+
* `../runtime/child-stream-guards`. The `app.asar` -> `app.asar.unpacked`
|
|
15
|
+
* path rewrite is a plain string replace with no Electron import and is left
|
|
16
|
+
* exactly as it was. Source formatting (no semicolons) preserved.
|
|
17
|
+
*/
|
|
18
|
+
const child_process_1 = require("child_process");
|
|
19
|
+
const crypto_1 = require("crypto");
|
|
20
|
+
const child_stream_guards_1 = require("../runtime/child-stream-guards");
|
|
21
|
+
const fs_1 = require("fs");
|
|
22
|
+
const os_1 = require("os");
|
|
23
|
+
const path_1 = require("path");
|
|
24
|
+
const util_1 = require("util");
|
|
25
|
+
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
26
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
27
|
+
const CURSOR_ACP_MODEL_VALUES = {
|
|
28
|
+
'composer-2.5': 'composer-2.5[fast=true]',
|
|
29
|
+
'grok-4.5': 'grok-4.5[effort=high,fast=true]'
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Unified ACP adapter for all ACP-compatible agents
|
|
33
|
+
*/
|
|
34
|
+
class AcpAdapter {
|
|
35
|
+
agentType;
|
|
36
|
+
agentConfig;
|
|
37
|
+
sessions = new Map();
|
|
38
|
+
debugRpcLogs;
|
|
39
|
+
/**
|
|
40
|
+
* Maximum number of raw JSON-RPC messages to keep in permanent history.
|
|
41
|
+
* This is used for replaying transcripts upon session resume.
|
|
42
|
+
* 1,000 events is enough for ~50-100 turns. Lowered from 2,000 to reduce
|
|
43
|
+
* memory footprint — each event can be multi-KB (tool outputs).
|
|
44
|
+
*/
|
|
45
|
+
static MAX_PERMANENT_MESSAGES = 1000;
|
|
46
|
+
/**
|
|
47
|
+
* Maximum number of entries in toolCallMetadata before pruning.
|
|
48
|
+
* This map caches tool_call metadata for in-progress tool calls and should
|
|
49
|
+
* be pruned if it grows beyond what's reasonable (e.g. due to un-cleaned
|
|
50
|
+
* entries from tool calls that never completed).
|
|
51
|
+
*/
|
|
52
|
+
static MAX_TOOL_CALL_METADATA = 500;
|
|
53
|
+
/** Callback set by agent-manager to trigger an immediate poll cycle */
|
|
54
|
+
onDataAvailable;
|
|
55
|
+
constructor(agentType) {
|
|
56
|
+
this.agentType = agentType;
|
|
57
|
+
this.agentConfig = this.getAgentConfig(agentType);
|
|
58
|
+
this.debugRpcLogs = AcpAdapter.isDebugLogLevel(process.env.LOG_LEVEL);
|
|
59
|
+
}
|
|
60
|
+
static isDebugLogLevel(value) {
|
|
61
|
+
if (!value)
|
|
62
|
+
return false;
|
|
63
|
+
const normalized = value.trim().toLowerCase();
|
|
64
|
+
return normalized === 'debug' || normalized === 'trace';
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the path to the codex-acp native binary for the current platform.
|
|
68
|
+
*
|
|
69
|
+
* codex-acp ships as a native binary per platform (e.g. codex-acp-darwin-arm64).
|
|
70
|
+
* The JS wrapper (codex-acp.js) uses import.meta.resolve() which returns an ASAR
|
|
71
|
+
* path — but the OS can't execute a native binary from inside an ASAR archive.
|
|
72
|
+
* We bypass the wrapper and resolve the native binary directly, replacing the
|
|
73
|
+
* ASAR path with the unpacked path (electron-builder auto-unpacks native binaries).
|
|
74
|
+
*
|
|
75
|
+
* pnpm note: the platform-specific package (e.g. codex-acp-darwin-arm64) is an
|
|
76
|
+
* optionalDependency of codex-acp, so under pnpm it is NOT hoisted to the root
|
|
77
|
+
* node_modules. We must resolve it from codex-acp's own directory using the
|
|
78
|
+
* `{ paths }` option of require.resolve. In a packaged app (ASAR), electron-builder
|
|
79
|
+
* flattens the structure so a plain require.resolve would also work.
|
|
80
|
+
*/
|
|
81
|
+
resolveCodexBinary() {
|
|
82
|
+
const platformMap = {
|
|
83
|
+
darwin: { arm64: 'codex-acp-darwin-arm64', x64: 'codex-acp-darwin-x64' },
|
|
84
|
+
linux: { arm64: 'codex-acp-linux-arm64', x64: 'codex-acp-linux-x64' },
|
|
85
|
+
win32: { arm64: 'codex-acp-win32-arm64', x64: 'codex-acp-win32-x64' },
|
|
86
|
+
};
|
|
87
|
+
const packages = platformMap[process.platform];
|
|
88
|
+
if (!packages)
|
|
89
|
+
throw new Error(`Unsupported platform for codex-acp: ${process.platform}`);
|
|
90
|
+
const packageName = packages[process.arch];
|
|
91
|
+
if (!packageName)
|
|
92
|
+
throw new Error(`Unsupported arch for codex-acp: ${process.arch} on ${process.platform}`);
|
|
93
|
+
const binaryName = process.platform === 'win32' ? 'codex-acp.exe' : 'codex-acp';
|
|
94
|
+
// Resolve from codex-acp's own directory so pnpm's nested optional deps are found.
|
|
95
|
+
const codexAcpDir = (0, path_1.dirname)(require.resolve('@zed-industries/codex-acp/package.json'));
|
|
96
|
+
let binaryPath = require.resolve(`@zed-industries/${packageName}/bin/${binaryName}`, { paths: [codexAcpDir] });
|
|
97
|
+
// In a packaged Electron app, require.resolve returns an ASAR path like:
|
|
98
|
+
// /Applications/20x.app/Contents/Resources/app.asar/node_modules/.../bin/codex-acp
|
|
99
|
+
// The native binary is auto-unpacked to app.asar.unpacked, so fix the path.
|
|
100
|
+
if (binaryPath.includes('app.asar')) {
|
|
101
|
+
binaryPath = binaryPath.replace('app.asar', 'app.asar.unpacked');
|
|
102
|
+
}
|
|
103
|
+
console.log(`[AcpAdapter/codex] Resolved native binary: ${binaryPath}`);
|
|
104
|
+
return binaryPath;
|
|
105
|
+
}
|
|
106
|
+
getAgentConfig(agentType) {
|
|
107
|
+
switch (agentType) {
|
|
108
|
+
case 'codex':
|
|
109
|
+
// Codex ships as a native binary — spawn it directly, bypassing the JS wrapper
|
|
110
|
+
// which fails inside ASAR (see resolveCodexBinary docs for details).
|
|
111
|
+
return {
|
|
112
|
+
command: this.resolveCodexBinary(),
|
|
113
|
+
args: [],
|
|
114
|
+
// Auth env is decided per-session by configureCodexAuthEnv(), which
|
|
115
|
+
// gives the user's Codex CLI login (ChatGPT subscription) priority over
|
|
116
|
+
// any ambient OPENAI_API_KEY/CODEX_API_KEY. Do NOT inject ambient keys
|
|
117
|
+
// here — that previously hijacked subscription users into API-key auth.
|
|
118
|
+
env: {}
|
|
119
|
+
};
|
|
120
|
+
case 'cursor':
|
|
121
|
+
return {
|
|
122
|
+
command: 'cursor-agent',
|
|
123
|
+
args: ['acp'],
|
|
124
|
+
env: {}
|
|
125
|
+
};
|
|
126
|
+
default:
|
|
127
|
+
throw new Error(`Unsupported ACP agent type: ${agentType}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Decide how Codex authenticates for this session and mutate `env` accordingly.
|
|
132
|
+
* Returns true when API-key auth is used, false when the ChatGPT subscription /
|
|
133
|
+
* Codex CLI login is used.
|
|
134
|
+
*
|
|
135
|
+
* The user's explicit choice in the agent configuration (config.authMethod) is
|
|
136
|
+
* the source of truth:
|
|
137
|
+
* - 'subscription' -> use the Codex CLI login (~/.codex), exactly like running
|
|
138
|
+
* `codex` in a terminal. Any ambient OPENAI_API_KEY/CODEX_API_KEY inherited
|
|
139
|
+
* from the user's shell is STRIPPED so it cannot hijack auth into API-key
|
|
140
|
+
* mode (which bills a separate, often-exhausted quota and made 20x show
|
|
141
|
+
* "out of rate limits" even though the terminal subscription worked fine).
|
|
142
|
+
* - 'api_key' -> use an API key (the per-agent config key if set, otherwise the
|
|
143
|
+
* ambient one) in an isolated temp CODEX_HOME so keys can differ per session.
|
|
144
|
+
*
|
|
145
|
+
* When authMethod is unset (legacy agents), fall back to the historical rule but
|
|
146
|
+
* WITHOUT the ambient-key hijack: only an explicitly-configured per-agent API
|
|
147
|
+
* key implies API-key mode; otherwise default to the subscription/login path.
|
|
148
|
+
*/
|
|
149
|
+
configureCodexAuthEnv(env, config) {
|
|
150
|
+
if (this.agentType !== 'codex')
|
|
151
|
+
return false;
|
|
152
|
+
const explicitApiKey = config.apiKeys?.openai;
|
|
153
|
+
const useApiKey = config.authMethod === 'api_key' ? true
|
|
154
|
+
: config.authMethod === 'subscription' ? false
|
|
155
|
+
: !!explicitApiKey; // legacy default: only an explicit key opts into API-key mode
|
|
156
|
+
if (useApiKey) {
|
|
157
|
+
const key = explicitApiKey || env.OPENAI_API_KEY || env.CODEX_API_KEY;
|
|
158
|
+
if (key) {
|
|
159
|
+
env.OPENAI_API_KEY = key;
|
|
160
|
+
env.CODEX_API_KEY = key;
|
|
161
|
+
}
|
|
162
|
+
env.NO_BROWSER = '1';
|
|
163
|
+
env.CODEX_HOME = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'codex-session-'));
|
|
164
|
+
console.log(`[AcpAdapter/codex] Auth: API key (authMethod=${config.authMethod ?? 'legacy'}, isolated CODEX_HOME)`);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
// Subscription / Codex CLI login path. Strip ambient keys so they can't
|
|
168
|
+
// hijack auth away from the ChatGPT subscription in the default CODEX_HOME.
|
|
169
|
+
delete env.OPENAI_API_KEY;
|
|
170
|
+
delete env.CODEX_API_KEY;
|
|
171
|
+
// Pin CODEX_HOME to the same dir the `codex` CLI uses in a terminal, so
|
|
172
|
+
// codex-acp reads the exact subscription login the user already has. We only
|
|
173
|
+
// set it when not already provided (a user who runs `codex` with a custom
|
|
174
|
+
// CODEX_HOME will have it in their shell env, which we inherit and preserve).
|
|
175
|
+
if (!env.CODEX_HOME) {
|
|
176
|
+
env.CODEX_HOME = (0, path_1.join)((0, os_1.homedir)(), '.codex');
|
|
177
|
+
}
|
|
178
|
+
console.log(`[AcpAdapter/codex] Auth: ChatGPT subscription / Codex CLI login (authMethod=${config.authMethod ?? 'legacy'}, CODEX_HOME=${env.CODEX_HOME})`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
configureCursorAuthEnv(env, config) {
|
|
182
|
+
if (this.agentType !== 'cursor')
|
|
183
|
+
return;
|
|
184
|
+
const explicitApiKey = config.apiKeys?.cursor;
|
|
185
|
+
const useApiKey = config.authMethod === 'api_key'
|
|
186
|
+
|| (config.authMethod !== 'subscription' && !!explicitApiKey);
|
|
187
|
+
if (useApiKey) {
|
|
188
|
+
const key = explicitApiKey || env.CURSOR_API_KEY;
|
|
189
|
+
if (!key) {
|
|
190
|
+
throw new Error('Cursor API-key authentication requires a configured key or CURSOR_API_KEY');
|
|
191
|
+
}
|
|
192
|
+
env.CURSOR_API_KEY = key;
|
|
193
|
+
delete env.CURSOR_AUTH_TOKEN;
|
|
194
|
+
console.log('[AcpAdapter/cursor] Auth: API key');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// CLI login is authoritative in subscription mode; ambient keys must not
|
|
198
|
+
// silently switch billing/authentication away from the logged-in account.
|
|
199
|
+
delete env.CURSOR_API_KEY;
|
|
200
|
+
delete env.CURSOR_AUTH_TOKEN;
|
|
201
|
+
console.log('[AcpAdapter/cursor] Auth: Cursor CLI login');
|
|
202
|
+
}
|
|
203
|
+
async initialize() {
|
|
204
|
+
// Verify agent process is available
|
|
205
|
+
const health = await this.checkHealth();
|
|
206
|
+
if (!health.available) {
|
|
207
|
+
throw new Error(health.reason || 'ACP agent not available');
|
|
208
|
+
}
|
|
209
|
+
console.log(`[AcpAdapter/${this.agentType}] Initialized successfully`);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Authenticate the session if required
|
|
213
|
+
*/
|
|
214
|
+
async authenticateSession(session, initResult) {
|
|
215
|
+
const initObj = initResult;
|
|
216
|
+
const authMethods = (Array.isArray(initObj?.authMethods) ? initObj.authMethods : []);
|
|
217
|
+
if (authMethods.length > 0) {
|
|
218
|
+
// Use the auth mode decided in configureCodexAuthEnv() rather than sniffing
|
|
219
|
+
// ambient env vars. An ambient OPENAI_API_KEY in the user's shell must not
|
|
220
|
+
// flip a subscription user into API-key auth (and filter out "chatgpt").
|
|
221
|
+
const hasOpenAiKey = session.codexUseApiKey;
|
|
222
|
+
// When using an API key, filter out "chatgpt" browser-based auth so
|
|
223
|
+
// codex-acp uses the key instead of opening an OAuth popup. On the
|
|
224
|
+
// subscription path, allow all methods including chatgpt (Codex CLI login).
|
|
225
|
+
const usableMethods = (this.agentType === 'codex' && hasOpenAiKey)
|
|
226
|
+
? authMethods.filter((m) => m.id !== 'chatgpt')
|
|
227
|
+
: authMethods;
|
|
228
|
+
const apiKeyMethod = usableMethods.find((m) => m.id === 'openai-api-key' || m.id === 'codex-api-key');
|
|
229
|
+
// Select an API-key method when a key is available, otherwise fall back to
|
|
230
|
+
// any non-key auth method (e.g. existing Codex CLI login via chatgpt).
|
|
231
|
+
const authMethod = hasOpenAiKey
|
|
232
|
+
? (apiKeyMethod || usableMethods[0])
|
|
233
|
+
: (usableMethods.find((m) => m.id !== 'openai-api-key' && m.id !== 'codex-api-key') || apiKeyMethod || null);
|
|
234
|
+
console.log(`[AcpAdapter/${this.agentType}] Available auth methods: [${authMethods.map((m) => m.id).join(', ')}]; codexUseApiKey=${session.codexUseApiKey}`);
|
|
235
|
+
if (!authMethod) {
|
|
236
|
+
console.log(`[AcpAdapter/${this.agentType}] No usable auth method found; skipping authenticate`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// Note: we do NOT call `logout` before authenticate. When an API key is
|
|
240
|
+
// provided, CODEX_HOME points to a per-session temp directory so there are
|
|
241
|
+
// no stale disk credentials. This allows different API keys per session.
|
|
242
|
+
console.log(`[AcpAdapter/${this.agentType}] Authenticating with method: ${authMethod.id}`);
|
|
243
|
+
session.codexAuthSummary = `${session.codexUseApiKey ? 'API key' : 'subscription'} via authenticate(${authMethod.id})`;
|
|
244
|
+
await this.sendRpcRequest(session, 'authenticate', {
|
|
245
|
+
methodId: authMethod.id
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
console.log(`[AcpAdapter/${this.agentType}] No auth methods advertised by agent (already authenticated); codexUseApiKey=${session.codexUseApiKey}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async createSession(config) {
|
|
253
|
+
const sessionId = config.taskId;
|
|
254
|
+
console.log(`[AcpAdapter/${this.agentType}] Creating session ${sessionId}`);
|
|
255
|
+
// Prepare environment with API keys
|
|
256
|
+
const env = {
|
|
257
|
+
...process.env,
|
|
258
|
+
...this.agentConfig.env
|
|
259
|
+
};
|
|
260
|
+
if (config.apiKeys?.anthropic) {
|
|
261
|
+
env.ANTHROPIC_API_KEY = config.apiKeys.anthropic;
|
|
262
|
+
}
|
|
263
|
+
// Inject secret env vars directly into process environment
|
|
264
|
+
if (config.secretEnvVars && Object.keys(config.secretEnvVars).length > 0) {
|
|
265
|
+
for (const [key, value] of Object.entries(config.secretEnvVars)) {
|
|
266
|
+
env[key] = value;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Decide Codex auth (subscription vs API key) LAST so it is authoritative.
|
|
270
|
+
// It must run after secret-env injection: a user secret named OPENAI_API_KEY /
|
|
271
|
+
// CODEX_API_KEY would otherwise be re-added to env after subscription mode
|
|
272
|
+
// stripped it, re-hijacking Codex into API-key auth (stale "out of rate
|
|
273
|
+
// limits" that survives restarts because the secret lives in the DB).
|
|
274
|
+
const codexUseApiKey = this.configureCodexAuthEnv(env, config);
|
|
275
|
+
this.configureCursorAuthEnv(env, config);
|
|
276
|
+
const codexAuthSummary = this.agentType === 'codex'
|
|
277
|
+
? `${codexUseApiKey ? 'API key' : 'subscription'} (authMethod=${config.authMethod ?? 'legacy'}, CODEX_HOME=${env.CODEX_HOME ?? 'default'})`
|
|
278
|
+
: '';
|
|
279
|
+
// Spawn ACP agent process
|
|
280
|
+
// On Windows, .cmd/.bat wrappers need shell:true to resolve
|
|
281
|
+
const needsShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(this.agentConfig.command);
|
|
282
|
+
const acpProcess = (0, child_process_1.spawn)(this.agentConfig.command, this.agentConfig.args, {
|
|
283
|
+
cwd: config.workspaceDir,
|
|
284
|
+
env,
|
|
285
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
286
|
+
...(needsShell ? { shell: true } : {})
|
|
287
|
+
});
|
|
288
|
+
// Every pipe needs an error listener before the first write. The agent can
|
|
289
|
+
// exit at any moment, and an unhandled EPIPE on its stdin takes the whole
|
|
290
|
+
// main process down with a crash dialog.
|
|
291
|
+
(0, child_stream_guards_1.guardChildStreams)(acpProcess, `AcpAdapter/${this.agentType}`);
|
|
292
|
+
const session = {
|
|
293
|
+
sessionId,
|
|
294
|
+
acpSessionId: null,
|
|
295
|
+
process: acpProcess,
|
|
296
|
+
stdoutBuffer: '',
|
|
297
|
+
status: coding_agent_adapter_1.SessionStatusType.IDLE,
|
|
298
|
+
messageBuffer: [],
|
|
299
|
+
permanentMessages: [],
|
|
300
|
+
pendingRequests: new Map(),
|
|
301
|
+
nextRequestId: 1,
|
|
302
|
+
pendingApproval: null,
|
|
303
|
+
config,
|
|
304
|
+
promptRequestId: null,
|
|
305
|
+
responseCounter: 0,
|
|
306
|
+
currentUserTurnId: 0,
|
|
307
|
+
lastChunkTime: null,
|
|
308
|
+
currentTurnId: 0,
|
|
309
|
+
lastSessionUpdateType: null,
|
|
310
|
+
activeTurnId: null,
|
|
311
|
+
pendingAssistantTurnSplit: false,
|
|
312
|
+
toolCallMetadata: new Map(),
|
|
313
|
+
lastError: null,
|
|
314
|
+
codexUseApiKey,
|
|
315
|
+
codexAuthSummary
|
|
316
|
+
};
|
|
317
|
+
// Temporarily store with workspace ID, will re-key after getting ACP session ID
|
|
318
|
+
this.sessions.set(sessionId, session);
|
|
319
|
+
// Set up stdout parser
|
|
320
|
+
this.setupStdoutParser(acpProcess, session);
|
|
321
|
+
// Set up stderr logging
|
|
322
|
+
acpProcess.stderr?.on('data', (chunk) => {
|
|
323
|
+
console.log(`[AcpAdapter/${this.agentType}] stderr:`, chunk.toString());
|
|
324
|
+
});
|
|
325
|
+
// Handle process exit
|
|
326
|
+
acpProcess.on('exit', (code, signal) => {
|
|
327
|
+
console.log(`[AcpAdapter/${this.agentType}] Process exited: code=${code}, signal=${signal}`);
|
|
328
|
+
session.status = code === 0 ? coding_agent_adapter_1.SessionStatusType.IDLE : coding_agent_adapter_1.SessionStatusType.ERROR;
|
|
329
|
+
});
|
|
330
|
+
// Initialize ACP protocol
|
|
331
|
+
const initResult = await this.sendRpcRequest(session, 'initialize', {
|
|
332
|
+
protocolVersion: 1,
|
|
333
|
+
clientCapabilities: {
|
|
334
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
335
|
+
terminal: false
|
|
336
|
+
},
|
|
337
|
+
clientInfo: {
|
|
338
|
+
name: 'pf-desktop',
|
|
339
|
+
version: '0.0.1'
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
// Authenticate if required
|
|
343
|
+
await this.authenticateSession(session, initResult);
|
|
344
|
+
// Create ACP session (only accepts cwd and mcpServers per ACP spec)
|
|
345
|
+
const convertedMcpServers = this.convertMcpServers(config.mcpServers) || [];
|
|
346
|
+
console.log(`[AcpAdapter/${this.agentType}] session/new mcpServers:`, JSON.stringify(convertedMcpServers));
|
|
347
|
+
const result = await this.sendRpcRequest(session, 'session/new', {
|
|
348
|
+
cwd: config.workspaceDir,
|
|
349
|
+
mcpServers: convertedMcpServers
|
|
350
|
+
});
|
|
351
|
+
// Extract session ID from result
|
|
352
|
+
const acpSessionId = this.extractAcpSessionId(result);
|
|
353
|
+
if (acpSessionId) {
|
|
354
|
+
session.acpSessionId = acpSessionId;
|
|
355
|
+
// Re-key the session map with the ACP session ID
|
|
356
|
+
this.sessions.delete(sessionId);
|
|
357
|
+
this.sessions.set(acpSessionId, session);
|
|
358
|
+
}
|
|
359
|
+
// Set model if specified
|
|
360
|
+
if (config.model && acpSessionId) {
|
|
361
|
+
try {
|
|
362
|
+
const modelValue = this.agentType === 'cursor'
|
|
363
|
+
? CURSOR_ACP_MODEL_VALUES[config.model] ?? config.model
|
|
364
|
+
: config.model;
|
|
365
|
+
await this.sendRpcRequest(session, 'session/set_config_option', {
|
|
366
|
+
sessionId: acpSessionId,
|
|
367
|
+
configId: 'model',
|
|
368
|
+
value: modelValue
|
|
369
|
+
});
|
|
370
|
+
console.log(`[AcpAdapter/${this.agentType}] Model set to: ${config.model}`);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
374
|
+
console.warn(`[AcpAdapter/${this.agentType}] Failed to set model: ${errMsg}`);
|
|
375
|
+
// Continue even if model setting fails (agent will use default)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (config.reasoningEffort && config.reasoningEffort !== 'max' && acpSessionId) {
|
|
379
|
+
try {
|
|
380
|
+
await this.sendRpcRequest(session, 'session/set_config_option', {
|
|
381
|
+
sessionId: acpSessionId,
|
|
382
|
+
configId: 'model_reasoning_effort',
|
|
383
|
+
value: config.reasoningEffort
|
|
384
|
+
});
|
|
385
|
+
console.log(`[AcpAdapter/${this.agentType}] Reasoning effort set to: ${config.reasoningEffort}`);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
389
|
+
console.warn(`[AcpAdapter/${this.agentType}] Failed to set reasoning effort: ${errMsg}`);
|
|
390
|
+
// Continue even if reasoning effort setting fails (agent will use default)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
console.log(`[AcpAdapter/${this.agentType}] Session created: ${sessionId} (ACP: ${acpSessionId})`);
|
|
394
|
+
// Return the ACP session ID so it can be persisted and used for resuming
|
|
395
|
+
return acpSessionId || sessionId;
|
|
396
|
+
}
|
|
397
|
+
async resumeSession(sessionId, config) {
|
|
398
|
+
console.log(`[AcpAdapter/${this.agentType}] Resuming session ${sessionId}`);
|
|
399
|
+
console.log(`[AcpAdapter/${this.agentType}] Config API keys:`, {
|
|
400
|
+
hasApiKeys: !!config.apiKeys,
|
|
401
|
+
hasOpenai: !!config.apiKeys?.openai,
|
|
402
|
+
hasAnthropic: !!config.apiKeys?.anthropic,
|
|
403
|
+
anthropicKeyLength: config.apiKeys?.anthropic?.length
|
|
404
|
+
});
|
|
405
|
+
// Prepare environment with API keys
|
|
406
|
+
const env = {
|
|
407
|
+
...process.env,
|
|
408
|
+
...this.agentConfig.env
|
|
409
|
+
};
|
|
410
|
+
if (config.apiKeys?.anthropic) {
|
|
411
|
+
env.ANTHROPIC_API_KEY = config.apiKeys.anthropic;
|
|
412
|
+
}
|
|
413
|
+
// Inject secret env vars directly into process environment
|
|
414
|
+
if (config.secretEnvVars && Object.keys(config.secretEnvVars).length > 0) {
|
|
415
|
+
for (const [key, value] of Object.entries(config.secretEnvVars)) {
|
|
416
|
+
env[key] = value;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Decide Codex auth (subscription vs API key) LAST so it is authoritative —
|
|
420
|
+
// see createSession for why this must run after secret-env injection.
|
|
421
|
+
const codexUseApiKey = this.configureCodexAuthEnv(env, config);
|
|
422
|
+
this.configureCursorAuthEnv(env, config);
|
|
423
|
+
const codexAuthSummary = this.agentType === 'codex'
|
|
424
|
+
? `${codexUseApiKey ? 'API key' : 'subscription'} (authMethod=${config.authMethod ?? 'legacy'}, CODEX_HOME=${env.CODEX_HOME ?? 'default'})`
|
|
425
|
+
: '';
|
|
426
|
+
console.log(`[AcpAdapter/${this.agentType}] Environment after config:`, {
|
|
427
|
+
hasAnthropicInEnv: !!env.ANTHROPIC_API_KEY,
|
|
428
|
+
anthropicKeyLength: env.ANTHROPIC_API_KEY?.length,
|
|
429
|
+
anthropicKeyPrefix: env.ANTHROPIC_API_KEY?.substring(0, 5)
|
|
430
|
+
});
|
|
431
|
+
// Spawn ACP agent process
|
|
432
|
+
// On Windows, .cmd/.bat wrappers need shell:true to resolve
|
|
433
|
+
const needsShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(this.agentConfig.command);
|
|
434
|
+
const acpProcess = (0, child_process_1.spawn)(this.agentConfig.command, this.agentConfig.args, {
|
|
435
|
+
cwd: config.workspaceDir,
|
|
436
|
+
env,
|
|
437
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
438
|
+
...(needsShell ? { shell: true } : {})
|
|
439
|
+
});
|
|
440
|
+
// Every pipe needs an error listener before the first write. The agent can
|
|
441
|
+
// exit at any moment, and an unhandled EPIPE on its stdin takes the whole
|
|
442
|
+
// main process down with a crash dialog.
|
|
443
|
+
(0, child_stream_guards_1.guardChildStreams)(acpProcess, `AcpAdapter/${this.agentType}`);
|
|
444
|
+
const session = {
|
|
445
|
+
sessionId,
|
|
446
|
+
acpSessionId: sessionId, // sessionId is now the Codex UUID from database
|
|
447
|
+
process: acpProcess,
|
|
448
|
+
stdoutBuffer: '',
|
|
449
|
+
status: coding_agent_adapter_1.SessionStatusType.IDLE,
|
|
450
|
+
messageBuffer: [],
|
|
451
|
+
permanentMessages: [],
|
|
452
|
+
pendingRequests: new Map(),
|
|
453
|
+
nextRequestId: 1,
|
|
454
|
+
pendingApproval: null,
|
|
455
|
+
config,
|
|
456
|
+
promptRequestId: null,
|
|
457
|
+
responseCounter: 0,
|
|
458
|
+
currentUserTurnId: 0,
|
|
459
|
+
lastChunkTime: null,
|
|
460
|
+
currentTurnId: 0,
|
|
461
|
+
lastSessionUpdateType: null,
|
|
462
|
+
activeTurnId: null,
|
|
463
|
+
pendingAssistantTurnSplit: false,
|
|
464
|
+
toolCallMetadata: new Map(),
|
|
465
|
+
lastError: null,
|
|
466
|
+
codexUseApiKey,
|
|
467
|
+
codexAuthSummary
|
|
468
|
+
};
|
|
469
|
+
// Store with the Codex UUID (same as sessionId since we now return UUID from createSession)
|
|
470
|
+
this.sessions.set(sessionId, session);
|
|
471
|
+
// Set up stdout parser
|
|
472
|
+
this.setupStdoutParser(acpProcess, session);
|
|
473
|
+
// Set up stderr logging
|
|
474
|
+
acpProcess.stderr?.on('data', (chunk) => {
|
|
475
|
+
console.log(`[AcpAdapter/${this.agentType}] stderr:`, chunk.toString());
|
|
476
|
+
});
|
|
477
|
+
// Handle process exit
|
|
478
|
+
acpProcess.on('exit', (code, signal) => {
|
|
479
|
+
console.log(`[AcpAdapter/${this.agentType}] Process exited: code=${code}, signal=${signal}`);
|
|
480
|
+
session.status = code === 0 ? coding_agent_adapter_1.SessionStatusType.IDLE : coding_agent_adapter_1.SessionStatusType.ERROR;
|
|
481
|
+
});
|
|
482
|
+
// Initialize ACP protocol
|
|
483
|
+
const initResult = await this.sendRpcRequest(session, 'initialize', {
|
|
484
|
+
protocolVersion: 1,
|
|
485
|
+
clientCapabilities: {
|
|
486
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
487
|
+
terminal: false
|
|
488
|
+
},
|
|
489
|
+
clientInfo: {
|
|
490
|
+
name: 'pf-desktop',
|
|
491
|
+
version: '0.0.1'
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
// Authenticate if required
|
|
495
|
+
await this.authenticateSession(session, initResult);
|
|
496
|
+
// Try to load existing session
|
|
497
|
+
try {
|
|
498
|
+
await this.sendRpcRequest(session, 'session/load', {
|
|
499
|
+
sessionId: sessionId,
|
|
500
|
+
cwd: config.workspaceDir,
|
|
501
|
+
mcpServers: this.convertMcpServers(config.mcpServers) || []
|
|
502
|
+
});
|
|
503
|
+
console.log(`[AcpAdapter/${this.agentType}] Session loaded successfully: ${sessionId}`);
|
|
504
|
+
// Convert replayed notifications (buffered during session/load) to SessionMessages.
|
|
505
|
+
// Without this, the renderer sees status:'idle' + messages:[] and hides the panel.
|
|
506
|
+
const messages = await this.getAllMessages(sessionId, config);
|
|
507
|
+
// Drain replayed notifications from the live poll buffer. Otherwise the
|
|
508
|
+
// first poll after resume/send will emit the whole old transcript again.
|
|
509
|
+
session.messageBuffer = [];
|
|
510
|
+
return messages;
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
// Check if session not found
|
|
514
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
515
|
+
if (errMsg.includes('not found') || errMsg.includes('does not exist')) {
|
|
516
|
+
// Clean up process
|
|
517
|
+
acpProcess.kill('SIGTERM');
|
|
518
|
+
this.sessions.delete(sessionId);
|
|
519
|
+
throw new Error(`INCOMPATIBLE_SESSION_ID: This ${this.agentType} session does not exist or has expired. Please start a new session.`);
|
|
520
|
+
}
|
|
521
|
+
// Re-throw other errors
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
async sendPrompt(sessionId, parts, _config) {
|
|
526
|
+
const session = this.sessions.get(sessionId);
|
|
527
|
+
if (!session) {
|
|
528
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
529
|
+
}
|
|
530
|
+
// Extract text from message parts
|
|
531
|
+
const promptText = parts
|
|
532
|
+
.filter(p => p.type === 'text' && p.text)
|
|
533
|
+
.map(p => p.text)
|
|
534
|
+
.join('\n');
|
|
535
|
+
if (!promptText) {
|
|
536
|
+
throw new Error('No text content in message parts');
|
|
537
|
+
}
|
|
538
|
+
console.log(`[AcpAdapter/${this.agentType}] Sending prompt to session ${sessionId}:`);
|
|
539
|
+
console.log(promptText.slice(0, 200) + (promptText.length > 200 ? '...' : ''));
|
|
540
|
+
// Clear stale buffered events from the previous turn. Between the last
|
|
541
|
+
// pollMessages() call (which empties the buffer) and the idle detection
|
|
542
|
+
// that stops polling, new ACP notifications can still arrive. When the
|
|
543
|
+
// next prompt triggers a fresh PollingEntry (with empty seenPartIds),
|
|
544
|
+
// those stale events would be re-processed with a new turnId, creating
|
|
545
|
+
// duplicate messages in the transcript.
|
|
546
|
+
session.messageBuffer = [];
|
|
547
|
+
// Store a synthetic user_message event in permanentMessages so that the
|
|
548
|
+
// user's prompt survives session resume / replay. During session/load,
|
|
549
|
+
// the ACP agent may not echo user_message_chunk events, which means
|
|
550
|
+
// getAllMessages() would return no user parts and the transcript would
|
|
551
|
+
// show only agent responses.
|
|
552
|
+
this.addToPermanentMessages(session, {
|
|
553
|
+
jsonrpc: '2.0',
|
|
554
|
+
method: 'session/update',
|
|
555
|
+
params: {
|
|
556
|
+
update: {
|
|
557
|
+
sessionUpdate: 'user_message',
|
|
558
|
+
content: { type: 'text', text: promptText },
|
|
559
|
+
messageId: `user-prompt-${session.currentTurnId + 1}`
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
session.status = coding_agent_adapter_1.SessionStatusType.BUSY;
|
|
564
|
+
session.lastError = null; // Clear any previous error (e.g., quota limit) for recovery
|
|
565
|
+
session.currentTurnId++;
|
|
566
|
+
session.activeTurnId = session.currentTurnId;
|
|
567
|
+
session.lastChunkTime = null;
|
|
568
|
+
// Send prompt via ACP (prompt must be an array of ContentBlock objects)
|
|
569
|
+
// Note: session/prompt is a long-running operation that responds via session/update notifications
|
|
570
|
+
// We send the request but don't await the response to avoid timeout
|
|
571
|
+
this.sendRpcRequestNoWait(session, 'session/prompt', {
|
|
572
|
+
sessionId: session.acpSessionId,
|
|
573
|
+
prompt: [
|
|
574
|
+
{
|
|
575
|
+
type: 'text',
|
|
576
|
+
text: promptText
|
|
577
|
+
}
|
|
578
|
+
]
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async getStatus(sessionId, _config) {
|
|
582
|
+
const session = this.sessions.get(sessionId);
|
|
583
|
+
if (!session) {
|
|
584
|
+
return { type: coding_agent_adapter_1.SessionStatusType.ERROR, message: 'Session not found' };
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
type: session.status,
|
|
588
|
+
message: session.status === 'error' ? (session.lastError || 'Process error') : undefined
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async pollMessages(sessionId, seenMessageIds, seenPartIds, partContentLengths, _config) {
|
|
592
|
+
const session = this.sessions.get(sessionId);
|
|
593
|
+
if (!session) {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
const newParts = [];
|
|
597
|
+
// Process buffered ACP events
|
|
598
|
+
for (const event of session.messageBuffer) {
|
|
599
|
+
const converted = this.convertAcpEventToMessageParts(event, seenMessageIds, seenPartIds, partContentLengths, session);
|
|
600
|
+
newParts.push(...converted);
|
|
601
|
+
}
|
|
602
|
+
// Clear processed events
|
|
603
|
+
session.messageBuffer = [];
|
|
604
|
+
return newParts;
|
|
605
|
+
}
|
|
606
|
+
async abortPrompt(sessionId, _config) {
|
|
607
|
+
const session = this.sessions.get(sessionId);
|
|
608
|
+
if (!session) {
|
|
609
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
610
|
+
}
|
|
611
|
+
console.log(`[AcpAdapter/${this.agentType}] Sending session/cancel for ${sessionId}`);
|
|
612
|
+
// Send session/cancel notification (not a request - no response expected)
|
|
613
|
+
this.sendRpcNotification(session, 'session/cancel', {
|
|
614
|
+
sessionId: session.acpSessionId
|
|
615
|
+
});
|
|
616
|
+
// The agent will respond to the original session/prompt with stopReason: cancelled
|
|
617
|
+
// Status will be updated when we receive that response
|
|
618
|
+
}
|
|
619
|
+
async destroySession(sessionId, _config) {
|
|
620
|
+
const session = this.sessions.get(sessionId);
|
|
621
|
+
if (!session) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
console.log(`[AcpAdapter/${this.agentType}] Destroying session ${sessionId}`);
|
|
625
|
+
// Kill process
|
|
626
|
+
session.process.kill('SIGTERM');
|
|
627
|
+
// Wait a bit, then force kill if needed
|
|
628
|
+
setTimeout(() => {
|
|
629
|
+
if (!session.process.killed) {
|
|
630
|
+
session.process.kill('SIGKILL');
|
|
631
|
+
}
|
|
632
|
+
}, 1000);
|
|
633
|
+
// Eagerly clear large data structures to free memory immediately
|
|
634
|
+
session.permanentMessages.length = 0;
|
|
635
|
+
session.messageBuffer.length = 0;
|
|
636
|
+
session.toolCallMetadata.clear();
|
|
637
|
+
session.pendingRequests.clear();
|
|
638
|
+
// Remove session
|
|
639
|
+
this.sessions.delete(sessionId);
|
|
640
|
+
}
|
|
641
|
+
async getAllMessages(sessionId, _config) {
|
|
642
|
+
const session = this.sessions.get(sessionId);
|
|
643
|
+
if (!session) {
|
|
644
|
+
return [];
|
|
645
|
+
}
|
|
646
|
+
// Convert all permanent messages to SessionMessages
|
|
647
|
+
const seenMessageIds = new Set();
|
|
648
|
+
const seenPartIds = new Set();
|
|
649
|
+
const partContentLengths = new Map();
|
|
650
|
+
// Use a Map to keep only the latest version of each part (by ID)
|
|
651
|
+
const partsByIdAndRole = new Map();
|
|
652
|
+
// Snapshot turn-related session state before processing.
|
|
653
|
+
// convertAcpEventToMessageParts() mutates turn counters (currentTurnId,
|
|
654
|
+
// activeTurnId, pendingAssistantTurnSplit, etc.) as it processes events.
|
|
655
|
+
// When getAllMessages() is called from replayMissedTranscriptPartsBeforeIdle
|
|
656
|
+
// AFTER a follow-up response, the session state has already advanced.
|
|
657
|
+
// Re-processing all permanent messages from the beginning with that
|
|
658
|
+
// advanced state produces DIFFERENT turn-based IDs (e.g. agent-response-5
|
|
659
|
+
// instead of agent-response-1), which bypass the seenPartIds dedup and
|
|
660
|
+
// cause every historical message to appear again in the transcript.
|
|
661
|
+
// By resetting to zero and restoring afterward, we ensure getAllMessages()
|
|
662
|
+
// always generates deterministic, stable IDs from the permanent history.
|
|
663
|
+
const savedTurnState = {
|
|
664
|
+
currentTurnId: session.currentTurnId,
|
|
665
|
+
activeTurnId: session.activeTurnId,
|
|
666
|
+
currentUserTurnId: session.currentUserTurnId,
|
|
667
|
+
lastChunkTime: session.lastChunkTime,
|
|
668
|
+
lastSessionUpdateType: session.lastSessionUpdateType,
|
|
669
|
+
pendingAssistantTurnSplit: session.pendingAssistantTurnSplit,
|
|
670
|
+
toolCallMetadata: new Map(session.toolCallMetadata),
|
|
671
|
+
};
|
|
672
|
+
// Reset to initial state so turn IDs are computed deterministically
|
|
673
|
+
session.currentTurnId = 0;
|
|
674
|
+
session.activeTurnId = null;
|
|
675
|
+
session.currentUserTurnId = 0;
|
|
676
|
+
session.lastChunkTime = null;
|
|
677
|
+
session.lastSessionUpdateType = null;
|
|
678
|
+
session.pendingAssistantTurnSplit = false;
|
|
679
|
+
session.toolCallMetadata = new Map();
|
|
680
|
+
// Process all permanent messages
|
|
681
|
+
for (const event of session.permanentMessages) {
|
|
682
|
+
const parts = this.convertAcpEventToMessageParts(event, seenMessageIds, seenPartIds, partContentLengths, session);
|
|
683
|
+
// Propagate original arrival time from the permanent event to each part
|
|
684
|
+
const receivedAt = event?._receivedAt;
|
|
685
|
+
// Keep only latest version of each part
|
|
686
|
+
for (const part of parts) {
|
|
687
|
+
if (receivedAt)
|
|
688
|
+
part.receivedAt = receivedAt;
|
|
689
|
+
const key = `${part.id}-${part.role || 'assistant'}`;
|
|
690
|
+
// Only keep if newer or doesn't exist
|
|
691
|
+
if (!partsByIdAndRole.has(key) || part.update) {
|
|
692
|
+
partsByIdAndRole.set(key, part);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// Restore live session state so polling / follow-up prompts continue
|
|
697
|
+
// from where they left off. The turn IDs computed above are only used
|
|
698
|
+
// for the returned messages — they must not leak into the live session.
|
|
699
|
+
session.currentTurnId = savedTurnState.currentTurnId;
|
|
700
|
+
session.activeTurnId = savedTurnState.activeTurnId;
|
|
701
|
+
session.currentUserTurnId = savedTurnState.currentUserTurnId;
|
|
702
|
+
session.lastChunkTime = savedTurnState.lastChunkTime;
|
|
703
|
+
session.lastSessionUpdateType = savedTurnState.lastSessionUpdateType;
|
|
704
|
+
session.pendingAssistantTurnSplit = savedTurnState.pendingAssistantTurnSplit;
|
|
705
|
+
session.toolCallMetadata = savedTurnState.toolCallMetadata;
|
|
706
|
+
const allParts = Array.from(partsByIdAndRole.values());
|
|
707
|
+
// Convert MessageParts to SessionMessages
|
|
708
|
+
const messages = [];
|
|
709
|
+
// Start a new message whenever the role changes OR the part itself is a
|
|
710
|
+
// new top-level transcript item. This keeps assistant text that arrives
|
|
711
|
+
// after tool calls in a separate message instead of appending it to the
|
|
712
|
+
// earlier assistant text during resume reconstruction.
|
|
713
|
+
let currentMessage = null;
|
|
714
|
+
let previousPart = null;
|
|
715
|
+
let messageIdCounter = 0;
|
|
716
|
+
for (const part of allParts) {
|
|
717
|
+
const roleStr = part.role || 'assistant';
|
|
718
|
+
const role = roleStr === 'user' ? coding_agent_adapter_1.MessageRole.USER :
|
|
719
|
+
roleStr === 'system' ? coding_agent_adapter_1.MessageRole.SYSTEM :
|
|
720
|
+
coding_agent_adapter_1.MessageRole.ASSISTANT;
|
|
721
|
+
const startsNewMessage = !currentMessage
|
|
722
|
+
|| currentMessage.role !== role
|
|
723
|
+
|| !previousPart
|
|
724
|
+
|| part.id !== previousPart.id;
|
|
725
|
+
if (startsNewMessage) {
|
|
726
|
+
if (currentMessage) {
|
|
727
|
+
messages.push(currentMessage);
|
|
728
|
+
}
|
|
729
|
+
currentMessage = {
|
|
730
|
+
id: `msg-${messageIdCounter++}`,
|
|
731
|
+
role,
|
|
732
|
+
parts: []
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
currentMessage.parts.push(part);
|
|
736
|
+
previousPart = part;
|
|
737
|
+
}
|
|
738
|
+
if (currentMessage) {
|
|
739
|
+
messages.push(currentMessage);
|
|
740
|
+
}
|
|
741
|
+
return messages;
|
|
742
|
+
}
|
|
743
|
+
async registerMcpServer(_serverName, _mcpConfig, _workspaceDir) {
|
|
744
|
+
// MCP server registration is handled during session creation
|
|
745
|
+
console.log(`[AcpAdapter/${this.agentType}] MCP server registration deferred to session creation`);
|
|
746
|
+
}
|
|
747
|
+
async checkHealth() {
|
|
748
|
+
try {
|
|
749
|
+
if (this.agentType === 'cursor') {
|
|
750
|
+
await execFileAsync(this.agentConfig.command, ['--version'], {
|
|
751
|
+
timeout: 10000,
|
|
752
|
+
windowsHide: true,
|
|
753
|
+
shell: process.platform === 'win32'
|
|
754
|
+
});
|
|
755
|
+
return { available: true };
|
|
756
|
+
}
|
|
757
|
+
// Verify the ACP agent package is installed
|
|
758
|
+
const packageName = '@zed-industries/codex-acp';
|
|
759
|
+
try {
|
|
760
|
+
// Different packages have different entry points
|
|
761
|
+
const entryPoint = `${packageName}/bin/codex-acp.js`;
|
|
762
|
+
require.resolve(entryPoint);
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return {
|
|
766
|
+
available: false,
|
|
767
|
+
reason: `${packageName} not found. Install with: pnpm add ${packageName}`
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
// Note: We don't check API keys here because they can be provided
|
|
771
|
+
// via UI configuration (agent.config.api_keys) at session creation time
|
|
772
|
+
return { available: true };
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
776
|
+
return {
|
|
777
|
+
available: false,
|
|
778
|
+
reason: this.agentType === 'cursor'
|
|
779
|
+
? `Cursor Agent CLI is unavailable. Install it and run cursor-agent login. (${errMsg})`
|
|
780
|
+
: errMsg
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Get pending approval request for a session
|
|
786
|
+
*/
|
|
787
|
+
getPendingApproval(sessionId) {
|
|
788
|
+
const session = this.sessions.get(sessionId);
|
|
789
|
+
return session?.pendingApproval || null;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Respond to a pending approval request
|
|
793
|
+
*/
|
|
794
|
+
async respondToApproval(sessionId, approved, optionId) {
|
|
795
|
+
const session = this.sessions.get(sessionId);
|
|
796
|
+
if (!session || !session.pendingApproval) {
|
|
797
|
+
console.warn(`[AcpAdapter/${this.agentType}] No pending approval for session ${sessionId}`);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const approval = session.pendingApproval;
|
|
801
|
+
// Determine the outcome based on user choice
|
|
802
|
+
let selectedOptionId = approval.options.some((option) => option.optionId === optionId)
|
|
803
|
+
? optionId
|
|
804
|
+
: undefined;
|
|
805
|
+
if (!selectedOptionId) {
|
|
806
|
+
selectedOptionId = approved
|
|
807
|
+
? approval.options.find((option) => option.optionId === 'allow-once')?.optionId || 'approved'
|
|
808
|
+
: approval.options.find((option) => option.optionId === 'reject-once')?.optionId || 'abort';
|
|
809
|
+
}
|
|
810
|
+
console.log(`[AcpAdapter/${this.agentType}] Responding to approval with: ${selectedOptionId}`);
|
|
811
|
+
// Send response to agent with correct ACP format
|
|
812
|
+
// Based on TypeScript SDK: outcome has double nesting with outcome field
|
|
813
|
+
this.sendRpcResponse(session, approval.requestId, {
|
|
814
|
+
result: {
|
|
815
|
+
outcome: {
|
|
816
|
+
outcome: 'selected',
|
|
817
|
+
optionId: selectedOptionId
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
// Clear pending approval
|
|
822
|
+
session.pendingApproval = null;
|
|
823
|
+
}
|
|
824
|
+
// ========================================================================
|
|
825
|
+
// Private Helper Methods
|
|
826
|
+
// ========================================================================
|
|
827
|
+
setupStdoutParser(process, session) {
|
|
828
|
+
process.stdout?.on('data', (chunk) => {
|
|
829
|
+
session.stdoutBuffer += chunk.toString();
|
|
830
|
+
// Parse newline-delimited JSON messages
|
|
831
|
+
let newlineIndex;
|
|
832
|
+
while ((newlineIndex = session.stdoutBuffer.indexOf('\n')) !== -1) {
|
|
833
|
+
const line = session.stdoutBuffer.slice(0, newlineIndex).trim();
|
|
834
|
+
session.stdoutBuffer = session.stdoutBuffer.slice(newlineIndex + 1);
|
|
835
|
+
if (!line)
|
|
836
|
+
continue;
|
|
837
|
+
try {
|
|
838
|
+
const message = JSON.parse(line);
|
|
839
|
+
this.handleRpcMessage(session, message);
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
console.error(`[AcpAdapter/${this.agentType}] Failed to parse JSON-RPC message:`, line, error);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
handleRpcMessage(session, message) {
|
|
848
|
+
if (this.debugRpcLogs) {
|
|
849
|
+
console.log(`[AcpAdapter/${this.agentType}] Received RPC message:`, JSON.stringify(message));
|
|
850
|
+
}
|
|
851
|
+
// Handle responses to our requests
|
|
852
|
+
if ('id' in message && message.id !== undefined && !('method' in message)) {
|
|
853
|
+
const pending = session.pendingRequests.get(message.id);
|
|
854
|
+
if (pending) {
|
|
855
|
+
session.pendingRequests.delete(message.id);
|
|
856
|
+
const response = message;
|
|
857
|
+
if ('error' in response && response.error) {
|
|
858
|
+
// Check for quota/usage limit errors from the provider
|
|
859
|
+
const errorInfo = this.extractCodexErrorInfo(response.error);
|
|
860
|
+
if (errorInfo) {
|
|
861
|
+
this.handleQuotaError(session, errorInfo);
|
|
862
|
+
}
|
|
863
|
+
pending.reject(new Error(response.error.message));
|
|
864
|
+
}
|
|
865
|
+
else if ('result' in response) {
|
|
866
|
+
pending.resolve(response.result);
|
|
867
|
+
}
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
// No pending request - could be error for permission response or other async operation
|
|
871
|
+
const response = message;
|
|
872
|
+
if ('error' in response && response.error) {
|
|
873
|
+
// Check for quota/usage limit errors from the provider
|
|
874
|
+
const errorInfo = this.extractCodexErrorInfo(response.error);
|
|
875
|
+
if (errorInfo) {
|
|
876
|
+
this.handleQuotaError(session, errorInfo);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
console.error(`[AcpAdapter/${this.agentType}] Unexpected error response:`, response.error);
|
|
880
|
+
// Push error to message buffers
|
|
881
|
+
const errorEvent = {
|
|
882
|
+
_isError: true,
|
|
883
|
+
message: response.error.message,
|
|
884
|
+
data: response.error.data
|
|
885
|
+
};
|
|
886
|
+
session.messageBuffer.push(errorEvent);
|
|
887
|
+
this.addToPermanentMessages(session, errorEvent);
|
|
888
|
+
this.onDataAvailable?.(session.sessionId);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
// Check if this is a response to session/prompt
|
|
892
|
+
if (session.promptRequestId === message.id && 'result' in response) {
|
|
893
|
+
const result = response.result;
|
|
894
|
+
if (result?.stopReason) {
|
|
895
|
+
console.log(`[AcpAdapter/${this.agentType}] Prompt completed with stopReason: ${result.stopReason}`);
|
|
896
|
+
session.status = coding_agent_adapter_1.SessionStatusType.IDLE;
|
|
897
|
+
session.activeTurnId = null;
|
|
898
|
+
// Don't clear promptRequestId - keep it for late-arriving events
|
|
899
|
+
// It will be updated when the next prompt is sent
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// Handle requests from agent (e.g., session/request_permission)
|
|
905
|
+
if ('method' in message && 'id' in message && message.id !== undefined) {
|
|
906
|
+
const request = message;
|
|
907
|
+
if (this.debugRpcLogs) {
|
|
908
|
+
console.log(`[AcpAdapter/${this.agentType}] << Request: ${request.method}`);
|
|
909
|
+
}
|
|
910
|
+
if (request.method === 'session/request_permission') {
|
|
911
|
+
this.handlePermissionRequest(session, request);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (this.agentType === 'cursor' && request.method === 'cursor/ask_question') {
|
|
915
|
+
this.sendRpcResponse(session, request.id, {
|
|
916
|
+
result: { outcome: { outcome: 'skipped', reason: 'Cursor questions are not supported by 20x yet' } }
|
|
917
|
+
});
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (this.agentType === 'cursor' && request.method === 'cursor/create_plan') {
|
|
921
|
+
this.sendRpcResponse(session, request.id, {
|
|
922
|
+
result: { outcome: { outcome: 'cancelled' } }
|
|
923
|
+
});
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// Unknown request - send error response
|
|
927
|
+
this.sendRpcResponse(session, request.id, {
|
|
928
|
+
error: { code: -32601, message: `Method not found: ${request.method}` }
|
|
929
|
+
});
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// Handle notifications from agent
|
|
933
|
+
if ('method' in message && !('id' in message)) {
|
|
934
|
+
const notification = message;
|
|
935
|
+
if (this.debugRpcLogs) {
|
|
936
|
+
console.log(`[AcpAdapter/${this.agentType}] << Notification: ${notification.method}`);
|
|
937
|
+
if (notification.method === 'session/update') {
|
|
938
|
+
const params = notification.params;
|
|
939
|
+
console.log(`[AcpAdapter/${this.agentType}] sessionUpdate: ${params?.update?.sessionUpdate}`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// Store notification directly (no wrapping needed)
|
|
943
|
+
// Turn detection happens automatically based on time gaps and tool calls
|
|
944
|
+
// Buffer notification for polling (gets cleared after each poll)
|
|
945
|
+
session.messageBuffer.push(notification);
|
|
946
|
+
// Also store permanently for getAllMessages (replayed on resume).
|
|
947
|
+
this.addToPermanentMessages(session, notification);
|
|
948
|
+
this.onDataAvailable?.(session.sessionId);
|
|
949
|
+
// Update session status based on notification
|
|
950
|
+
this.updateSessionStatus(session, notification);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Extract codex-specific error info from an RPC error response.
|
|
955
|
+
* Returns a user-friendly error message if this is a known codex error type,
|
|
956
|
+
* or null if it's a generic error that should be handled normally.
|
|
957
|
+
*/
|
|
958
|
+
extractCodexErrorInfo(error) {
|
|
959
|
+
const data = error.data;
|
|
960
|
+
if (!data?.codex_error_info)
|
|
961
|
+
return null;
|
|
962
|
+
const errorType = String(data.codex_error_info);
|
|
963
|
+
const providerMessage = typeof data.message === 'string' ? data.message : error.message;
|
|
964
|
+
switch (errorType) {
|
|
965
|
+
case 'usage_limit_exceeded':
|
|
966
|
+
return {
|
|
967
|
+
errorType,
|
|
968
|
+
userMessage: `Quota exceeded: ${providerMessage}. Please check your Codex plan and billing details to continue.`
|
|
969
|
+
};
|
|
970
|
+
case 'rate_limit_exceeded':
|
|
971
|
+
return {
|
|
972
|
+
errorType,
|
|
973
|
+
userMessage: `Rate limit reached: ${providerMessage}. Please wait a moment before trying again.`
|
|
974
|
+
};
|
|
975
|
+
default:
|
|
976
|
+
return {
|
|
977
|
+
errorType,
|
|
978
|
+
userMessage: `Codex error (${errorType}): ${providerMessage}`
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Handle a quota/usage limit error by setting session state and surfacing
|
|
984
|
+
* a clear, actionable error message to the user.
|
|
985
|
+
*/
|
|
986
|
+
handleQuotaError(session, errorInfo) {
|
|
987
|
+
// Append the auth identity 20x used so the user (and we) can see whether the
|
|
988
|
+
// limit came from their subscription or an API key — the whole point of the
|
|
989
|
+
// "terminal works but 20x doesn't" investigation.
|
|
990
|
+
const authNote = session.codexAuthSummary ? ` [20x auth: ${session.codexAuthSummary}]` : '';
|
|
991
|
+
const userMessage = `${errorInfo.userMessage}${authNote}`;
|
|
992
|
+
console.warn(`[AcpAdapter/${this.agentType}] Provider error (${errorInfo.errorType}):`, userMessage);
|
|
993
|
+
session.status = coding_agent_adapter_1.SessionStatusType.ERROR;
|
|
994
|
+
session.lastError = userMessage;
|
|
995
|
+
session.activeTurnId = null;
|
|
996
|
+
// Push a user-friendly error event to the LIVE message buffer only.
|
|
997
|
+
//
|
|
998
|
+
// IMPORTANT: Quota/rate-limit errors are TRANSIENT conditions tied to a
|
|
999
|
+
// point in time. They must NOT be written to permanentMessages, because
|
|
1000
|
+
// permanentMessages is replayed on every session resume (getAllMessages).
|
|
1001
|
+
// If we persisted them, the "Quota exceeded / Rate limit reached" message
|
|
1002
|
+
// would re-appear at the end of the transcript forever — even after the
|
|
1003
|
+
// user upgrades their Codex plan, re-logs in, or simply waits for the
|
|
1004
|
+
// window to reset. That stale message is exactly what makes 20x keep
|
|
1005
|
+
// "showing limits" after the underlying limit is gone. session.lastError
|
|
1006
|
+
// is already cleared on the next sendPrompt() for recovery, so keeping the
|
|
1007
|
+
// event out of the permanent history fully clears the stale state on resume.
|
|
1008
|
+
const errorEvent = {
|
|
1009
|
+
_isError: true,
|
|
1010
|
+
message: userMessage,
|
|
1011
|
+
data: null // Don't expose raw error data for known error types
|
|
1012
|
+
};
|
|
1013
|
+
session.messageBuffer.push(errorEvent);
|
|
1014
|
+
this.onDataAvailable?.(session.sessionId);
|
|
1015
|
+
}
|
|
1016
|
+
updateSessionStatus(session, notification) {
|
|
1017
|
+
// Handle session/update notifications
|
|
1018
|
+
if (notification.method === 'session/update') {
|
|
1019
|
+
const params = notification.params;
|
|
1020
|
+
const update = params?.update;
|
|
1021
|
+
if (!update)
|
|
1022
|
+
return;
|
|
1023
|
+
// Update status based on update type.
|
|
1024
|
+
// IMPORTANT: Do NOT modify turn-related state (activeTurnId, pendingAssistantTurnSplit)
|
|
1025
|
+
// here — this runs in real-time when notifications arrive, but pollMessages processes
|
|
1026
|
+
// events in buffer order. Modifying turn state here would "leak" future event state
|
|
1027
|
+
// into earlier events during polling, causing incorrect turn splits and duplication.
|
|
1028
|
+
// Turn state is managed exclusively in convertAcpEventToMessageParts().
|
|
1029
|
+
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
|
|
1030
|
+
session.status = coding_agent_adapter_1.SessionStatusType.BUSY;
|
|
1031
|
+
}
|
|
1032
|
+
else if (update.sessionUpdate === 'error' || update.sessionUpdate === 'failed') {
|
|
1033
|
+
session.status = coding_agent_adapter_1.SessionStatusType.ERROR;
|
|
1034
|
+
}
|
|
1035
|
+
else if (update.sessionUpdate === 'completed' || update.sessionUpdate === 'finished') {
|
|
1036
|
+
session.status = coding_agent_adapter_1.SessionStatusType.IDLE;
|
|
1037
|
+
}
|
|
1038
|
+
// For 'available_commands_update' and other notifications, keep current status
|
|
1039
|
+
}
|
|
1040
|
+
// Legacy: Handle other notification types by method name
|
|
1041
|
+
else if (notification.method.includes('completed') || notification.method.includes('finished')) {
|
|
1042
|
+
session.status = coding_agent_adapter_1.SessionStatusType.IDLE;
|
|
1043
|
+
}
|
|
1044
|
+
else if (notification.method.includes('error') || notification.method.includes('failed')) {
|
|
1045
|
+
session.status = coding_agent_adapter_1.SessionStatusType.ERROR;
|
|
1046
|
+
}
|
|
1047
|
+
else if (notification.method.includes('started') || notification.method.includes('working')) {
|
|
1048
|
+
session.status = coding_agent_adapter_1.SessionStatusType.BUSY;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Adds a notification or event to the permanent message history, with
|
|
1053
|
+
* consolidation and size-based pruning to prevent OOM.
|
|
1054
|
+
*
|
|
1055
|
+
* IMPORTANT: events are stored as deep clones. The incoming `event` object is
|
|
1056
|
+
* the SAME object that handleRpcMessage() pushed into session.messageBuffer
|
|
1057
|
+
* for live polling. This method mutates stored events in two ways:
|
|
1058
|
+
* 1. chunk consolidation rewrites content.text on the last stored event
|
|
1059
|
+
* 2. tool outputs are truncated to 100KB for history
|
|
1060
|
+
* Without cloning, those mutations corrupted the un-polled live buffer:
|
|
1061
|
+
* when several agent_message_chunk deltas arrived between polls (always the
|
|
1062
|
+
* case at the START of a response — the first token burst lands before the
|
|
1063
|
+
* first poll cycle), consolidation silently rewrote the FIRST buffered chunk
|
|
1064
|
+
* into the full accumulated text while the later buffered chunks stayed raw
|
|
1065
|
+
* deltas. pollMessages() then appended those deltas again after the already
|
|
1066
|
+
* complete text, scrambling the beginning of the message
|
|
1067
|
+
* ("Hello world, how are you? world, how are you?").
|
|
1068
|
+
*/
|
|
1069
|
+
addToPermanentMessages(session, event) {
|
|
1070
|
+
const notification = event;
|
|
1071
|
+
const lastMsg = session.permanentMessages[session.permanentMessages.length - 1];
|
|
1072
|
+
let consolidated = false;
|
|
1073
|
+
if (lastMsg?.method === 'session/update' && notification.method === 'session/update') {
|
|
1074
|
+
const lastParams = lastMsg.params;
|
|
1075
|
+
const nextParams = notification.params;
|
|
1076
|
+
const lastUpdate = lastParams?.update;
|
|
1077
|
+
const nextUpdate = nextParams?.update;
|
|
1078
|
+
if (lastUpdate && nextUpdate && lastUpdate.sessionUpdate === nextUpdate.sessionUpdate && this.isAssistantChunkUpdateType(nextUpdate.sessionUpdate)) {
|
|
1079
|
+
// Merge consecutive assistant/thought chunks
|
|
1080
|
+
const lastText = this.extractTextFromUpdateContent(lastUpdate.content);
|
|
1081
|
+
const nextText = this.extractTextFromUpdateContent(nextUpdate.content);
|
|
1082
|
+
if (typeof lastUpdate.content === 'object' && lastUpdate.content !== null) {
|
|
1083
|
+
lastUpdate.content.text = this.mergeStreamingText(lastText, nextText);
|
|
1084
|
+
consolidated = true;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (!consolidated) {
|
|
1089
|
+
// Deep-clone before storing so history-only mutations (chunk
|
|
1090
|
+
// consolidation above on later events, output truncation below) never
|
|
1091
|
+
// leak into the identical object still queued in session.messageBuffer.
|
|
1092
|
+
const stored = structuredClone(notification);
|
|
1093
|
+
// Capping tool output size in permanent messages (replayed on resume).
|
|
1094
|
+
// The full output is still delivered to the UI during the live session,
|
|
1095
|
+
// but truncated here to keep the history bounded.
|
|
1096
|
+
if (stored.method === 'session/update') {
|
|
1097
|
+
const update = stored.params?.update;
|
|
1098
|
+
if (update?.rawOutput && typeof update.rawOutput === 'object') {
|
|
1099
|
+
const ro = update.rawOutput;
|
|
1100
|
+
const MAX_HISTORY_OUTPUT_CHARS = 100_000; // 100KB per tool output in history
|
|
1101
|
+
if (typeof ro.stdout === 'string' && ro.stdout.length > MAX_HISTORY_OUTPUT_CHARS) {
|
|
1102
|
+
ro.stdout = ro.stdout.slice(0, MAX_HISTORY_OUTPUT_CHARS) + '\n... (truncated in history)';
|
|
1103
|
+
}
|
|
1104
|
+
if (typeof ro.formatted_output === 'string' && ro.formatted_output.length > MAX_HISTORY_OUTPUT_CHARS) {
|
|
1105
|
+
ro.formatted_output = ro.formatted_output.slice(0, MAX_HISTORY_OUTPUT_CHARS) + '\n... (truncated in history)';
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
// Stamp with arrival time so replays can preserve original timing
|
|
1110
|
+
;
|
|
1111
|
+
stored._receivedAt = Date.now();
|
|
1112
|
+
session.permanentMessages.push(stored);
|
|
1113
|
+
}
|
|
1114
|
+
// Enforce absolute limit on permanent history to prevent OOM.
|
|
1115
|
+
// Discard oldest 25% to amortise pruning cost (was 10% which caused frequent re-pruning).
|
|
1116
|
+
if (session.permanentMessages.length > AcpAdapter.MAX_PERMANENT_MESSAGES) {
|
|
1117
|
+
session.permanentMessages.splice(0, Math.ceil(AcpAdapter.MAX_PERMANENT_MESSAGES * 0.25));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
async sendRpcRequest(session, method, params) {
|
|
1121
|
+
const id = session.nextRequestId++;
|
|
1122
|
+
const request = {
|
|
1123
|
+
jsonrpc: '2.0',
|
|
1124
|
+
id,
|
|
1125
|
+
method,
|
|
1126
|
+
params
|
|
1127
|
+
};
|
|
1128
|
+
return new Promise((resolve, reject) => {
|
|
1129
|
+
// Register pending request
|
|
1130
|
+
session.pendingRequests.set(id, { resolve, reject });
|
|
1131
|
+
// Send request
|
|
1132
|
+
const jsonString = JSON.stringify(request) + '\n';
|
|
1133
|
+
session.process.stdin?.write(jsonString, (error) => {
|
|
1134
|
+
if (error) {
|
|
1135
|
+
session.pendingRequests.delete(id);
|
|
1136
|
+
reject(new Error(`Failed to send request: ${error.message}`));
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
// Timeout after 30 seconds
|
|
1140
|
+
setTimeout(() => {
|
|
1141
|
+
if (session.pendingRequests.has(id)) {
|
|
1142
|
+
session.pendingRequests.delete(id);
|
|
1143
|
+
reject(new Error(`Request timeout: ${method}`));
|
|
1144
|
+
}
|
|
1145
|
+
}, 30000);
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
sendRpcRequestNoWait(session, method, params) {
|
|
1149
|
+
const id = session.nextRequestId++;
|
|
1150
|
+
// Track session/prompt request ID so we can handle the response
|
|
1151
|
+
if (method === 'session/prompt') {
|
|
1152
|
+
session.promptRequestId = id;
|
|
1153
|
+
}
|
|
1154
|
+
const request = {
|
|
1155
|
+
jsonrpc: '2.0',
|
|
1156
|
+
id,
|
|
1157
|
+
method,
|
|
1158
|
+
params
|
|
1159
|
+
};
|
|
1160
|
+
// Send request without waiting for response
|
|
1161
|
+
// The response will be handled in handleRpcMessage when it arrives
|
|
1162
|
+
const jsonString = JSON.stringify(request) + '\n';
|
|
1163
|
+
session.process.stdin?.write(jsonString, (error) => {
|
|
1164
|
+
if (error) {
|
|
1165
|
+
console.error(`[AcpAdapter/${this.agentType}] Error sending ${method}:`, error);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
sendRpcResponse(session, id, response) {
|
|
1170
|
+
const rpcResponse = {
|
|
1171
|
+
jsonrpc: '2.0',
|
|
1172
|
+
id,
|
|
1173
|
+
...(response.error ? { error: response.error } : { result: response.result })
|
|
1174
|
+
};
|
|
1175
|
+
const jsonString = JSON.stringify(rpcResponse) + '\n';
|
|
1176
|
+
console.log(`[AcpAdapter/${this.agentType}] Sending RPC response:`, jsonString.trim());
|
|
1177
|
+
session.process.stdin?.write(jsonString, (error) => {
|
|
1178
|
+
if (error)
|
|
1179
|
+
console.error(`[AcpAdapter/${this.agentType}] Error sending response:`, error);
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
sendRpcNotification(session, method, params) {
|
|
1183
|
+
const notification = {
|
|
1184
|
+
jsonrpc: '2.0',
|
|
1185
|
+
method,
|
|
1186
|
+
params
|
|
1187
|
+
};
|
|
1188
|
+
const jsonString = JSON.stringify(notification) + '\n';
|
|
1189
|
+
session.process.stdin?.write(jsonString, (error) => {
|
|
1190
|
+
if (error)
|
|
1191
|
+
console.error(`[AcpAdapter/${this.agentType}] Error sending notification ${method}:`, error);
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
handlePermissionRequest(session, request) {
|
|
1195
|
+
const params = request.params;
|
|
1196
|
+
// Extract permission details
|
|
1197
|
+
const toolCall = params?.toolCall;
|
|
1198
|
+
const options = params?.options || [];
|
|
1199
|
+
// Get the question/reason from the tool call
|
|
1200
|
+
const question = toolCall?.rawInput?.reason ||
|
|
1201
|
+
toolCall?.content?.[0]?.content?.text ||
|
|
1202
|
+
`Execute: ${toolCall?.title || 'unknown command'}`;
|
|
1203
|
+
console.log(`[AcpAdapter/${this.agentType}] Permission request:`);
|
|
1204
|
+
console.log(` Question: ${question}`);
|
|
1205
|
+
console.log(` Tool: ${toolCall?.kind} - ${toolCall?.toolCallId}`);
|
|
1206
|
+
console.log(` Options: ${options.map((o) => `${o.name} (${o.optionId})`).join(', ')}`);
|
|
1207
|
+
const approvalOptions = options.map((o) => ({
|
|
1208
|
+
optionId: o.optionId,
|
|
1209
|
+
name: o.name,
|
|
1210
|
+
kind: o.kind
|
|
1211
|
+
}));
|
|
1212
|
+
if (session.config.permissionMode === 'allow') {
|
|
1213
|
+
const autoApprovedOptionId = approvalOptions.find((option) => option.optionId === 'approved-for-session')?.optionId
|
|
1214
|
+
|| approvalOptions.find((option) => option.optionId === 'allow-always')?.optionId
|
|
1215
|
+
|| approvalOptions.find((option) => option.optionId === 'approved')?.optionId
|
|
1216
|
+
|| approvalOptions.find((option) => option.optionId === 'allow-once')?.optionId
|
|
1217
|
+
|| (this.agentType === 'cursor' ? 'allow-once' : 'approved');
|
|
1218
|
+
console.log(`[AcpAdapter/${this.agentType}] Auto-approving permission with: ${autoApprovedOptionId}`);
|
|
1219
|
+
this.sendRpcResponse(session, request.id, {
|
|
1220
|
+
result: {
|
|
1221
|
+
outcome: {
|
|
1222
|
+
outcome: 'selected',
|
|
1223
|
+
optionId: autoApprovedOptionId
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
// Store the permission request for UI to handle
|
|
1230
|
+
session.pendingApproval = {
|
|
1231
|
+
requestId: request.id,
|
|
1232
|
+
toolCallId: toolCall?.toolCallId || '',
|
|
1233
|
+
question,
|
|
1234
|
+
options: approvalOptions
|
|
1235
|
+
};
|
|
1236
|
+
// Update session status to waiting for approval
|
|
1237
|
+
session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
|
|
1238
|
+
console.log(`[AcpAdapter/${this.agentType}] Awaiting user approval...`);
|
|
1239
|
+
}
|
|
1240
|
+
extractAcpSessionId(result) {
|
|
1241
|
+
if (!result || typeof result !== 'object')
|
|
1242
|
+
return null;
|
|
1243
|
+
// Try common session ID field names
|
|
1244
|
+
const obj = result;
|
|
1245
|
+
return (obj.sessionId || obj.session_id || obj.id);
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Convert internal McpServerConfig map to ACP-spec McpServer array.
|
|
1249
|
+
* ACP spec: https://agentclientprotocol.com/protocol/schema
|
|
1250
|
+
*
|
|
1251
|
+
* - mcpServers is Vec<McpServer> (array, not map)
|
|
1252
|
+
* - stdio variant: { name, command, args: string[], env: EnvVariable[] }
|
|
1253
|
+
* - http variant: { type: "http", name, url, headers: HttpHeader[] }
|
|
1254
|
+
* - EnvVariable / HttpHeader = { name: string, value: string }
|
|
1255
|
+
*/
|
|
1256
|
+
convertMcpServers(servers) {
|
|
1257
|
+
if (!servers)
|
|
1258
|
+
return [];
|
|
1259
|
+
const result = [];
|
|
1260
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
1261
|
+
if (config.type === 'stdio') {
|
|
1262
|
+
// Convert env from Record<string,string> to ACP EnvVariable[]
|
|
1263
|
+
const envArray = config.env
|
|
1264
|
+
? Object.entries(config.env).map(([k, v]) => ({ name: k, value: v }))
|
|
1265
|
+
: [];
|
|
1266
|
+
result.push({
|
|
1267
|
+
name,
|
|
1268
|
+
command: config.command,
|
|
1269
|
+
args: config.args || [],
|
|
1270
|
+
env: envArray
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
// http or sse — ACP requires a "type" discriminator for non-stdio variants
|
|
1275
|
+
// Convert headers from Record<string,string> to ACP HttpHeader[]
|
|
1276
|
+
const headersArray = config.headers
|
|
1277
|
+
? Object.entries(config.headers).map(([k, v]) => ({ name: k, value: v }))
|
|
1278
|
+
: [];
|
|
1279
|
+
result.push({
|
|
1280
|
+
type: config.type,
|
|
1281
|
+
name,
|
|
1282
|
+
url: config.url,
|
|
1283
|
+
headers: headersArray
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return result;
|
|
1288
|
+
}
|
|
1289
|
+
extractTextFromUpdateContent(content) {
|
|
1290
|
+
if (!content)
|
|
1291
|
+
return '';
|
|
1292
|
+
if (typeof content === 'string') {
|
|
1293
|
+
return content;
|
|
1294
|
+
}
|
|
1295
|
+
if (Array.isArray(content)) {
|
|
1296
|
+
return content
|
|
1297
|
+
.map((entry) => this.extractTextFromUpdateContent(entry))
|
|
1298
|
+
.filter(Boolean)
|
|
1299
|
+
.join('\n');
|
|
1300
|
+
}
|
|
1301
|
+
if (typeof content !== 'object') {
|
|
1302
|
+
return '';
|
|
1303
|
+
}
|
|
1304
|
+
const value = content;
|
|
1305
|
+
if (typeof value.text === 'string') {
|
|
1306
|
+
return value.text;
|
|
1307
|
+
}
|
|
1308
|
+
if (typeof value.content === 'string') {
|
|
1309
|
+
return value.content;
|
|
1310
|
+
}
|
|
1311
|
+
if (value.content) {
|
|
1312
|
+
const nestedContent = this.extractTextFromUpdateContent(value.content);
|
|
1313
|
+
if (nestedContent)
|
|
1314
|
+
return nestedContent;
|
|
1315
|
+
}
|
|
1316
|
+
if (value.message) {
|
|
1317
|
+
const nestedMessage = this.extractTextFromUpdateContent(value.message);
|
|
1318
|
+
if (nestedMessage)
|
|
1319
|
+
return nestedMessage;
|
|
1320
|
+
}
|
|
1321
|
+
return '';
|
|
1322
|
+
}
|
|
1323
|
+
mergeStreamingText(currentText, incomingChunk) {
|
|
1324
|
+
if (!currentText)
|
|
1325
|
+
return incomingChunk;
|
|
1326
|
+
if (!incomingChunk)
|
|
1327
|
+
return currentText;
|
|
1328
|
+
if (incomingChunk.startsWith(currentText)) {
|
|
1329
|
+
return incomingChunk;
|
|
1330
|
+
}
|
|
1331
|
+
if (currentText.endsWith(incomingChunk)) {
|
|
1332
|
+
return currentText;
|
|
1333
|
+
}
|
|
1334
|
+
const MIN_OVERLAP_CHARS = 8;
|
|
1335
|
+
const maxSkippedPrefix = Math.min(32, currentText.length - MIN_OVERLAP_CHARS);
|
|
1336
|
+
for (let skipped = 1; skipped <= maxSkippedPrefix; skipped++) {
|
|
1337
|
+
const replayedText = currentText.slice(skipped);
|
|
1338
|
+
if (incomingChunk.startsWith(replayedText)) {
|
|
1339
|
+
return currentText.slice(0, skipped) + incomingChunk;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const maxOverlap = Math.min(currentText.length, incomingChunk.length);
|
|
1343
|
+
for (let length = maxOverlap; length > 0; length--) {
|
|
1344
|
+
if (length >= MIN_OVERLAP_CHARS && currentText.endsWith(incomingChunk.slice(0, length))) {
|
|
1345
|
+
return currentText + incomingChunk.slice(length);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
return currentText + incomingChunk;
|
|
1349
|
+
}
|
|
1350
|
+
isUserUpdateType(sessionUpdate) {
|
|
1351
|
+
return sessionUpdate === 'user_message_chunk'
|
|
1352
|
+
|| sessionUpdate === 'human_message_chunk'
|
|
1353
|
+
|| sessionUpdate === 'user_message'
|
|
1354
|
+
|| sessionUpdate === 'human_message';
|
|
1355
|
+
}
|
|
1356
|
+
isAssistantChunkUpdateType(sessionUpdate) {
|
|
1357
|
+
return sessionUpdate === 'agent_message_chunk'
|
|
1358
|
+
|| sessionUpdate === 'assistant_message_chunk'
|
|
1359
|
+
|| sessionUpdate === 'agent_thought_chunk';
|
|
1360
|
+
}
|
|
1361
|
+
isToolingUpdateType(sessionUpdate) {
|
|
1362
|
+
// Only actual tool calls should trigger turn splits.
|
|
1363
|
+
// `plan` and `available_commands_update` do NOT produce visible message parts
|
|
1364
|
+
// and must NOT cause turn splits — otherwise every such notification fragments
|
|
1365
|
+
// the assistant response into a separate message bubble.
|
|
1366
|
+
return sessionUpdate === 'tool_call'
|
|
1367
|
+
|| sessionUpdate === 'tool_call_update';
|
|
1368
|
+
}
|
|
1369
|
+
getAssistantTurnId(session) {
|
|
1370
|
+
const now = Date.now();
|
|
1371
|
+
const previousType = session.lastSessionUpdateType;
|
|
1372
|
+
const mustSplitAfterTool = session.pendingAssistantTurnSplit;
|
|
1373
|
+
if (session.activeTurnId &&
|
|
1374
|
+
!mustSplitAfterTool &&
|
|
1375
|
+
!this.isToolingUpdateType(previousType) &&
|
|
1376
|
+
!this.isUserUpdateType(previousType)) {
|
|
1377
|
+
session.lastChunkTime = now;
|
|
1378
|
+
return session.activeTurnId;
|
|
1379
|
+
}
|
|
1380
|
+
const timeSinceLastChunk = session.lastChunkTime ? now - session.lastChunkTime : Infinity;
|
|
1381
|
+
const TIME_GAP_THRESHOLD = 2000;
|
|
1382
|
+
const shouldStartNewTurn = session.currentTurnId === 0
|
|
1383
|
+
|| mustSplitAfterTool
|
|
1384
|
+
|| this.isUserUpdateType(previousType)
|
|
1385
|
+
|| this.isToolingUpdateType(previousType)
|
|
1386
|
+
|| (this.isAssistantChunkUpdateType(previousType) && timeSinceLastChunk > TIME_GAP_THRESHOLD);
|
|
1387
|
+
if (shouldStartNewTurn) {
|
|
1388
|
+
session.currentTurnId += 1;
|
|
1389
|
+
console.log(`[AcpAdapter] Detected NEW assistant turn #${session.currentTurnId} (prev=${previousType}, gap=${timeSinceLastChunk}ms, split=${mustSplitAfterTool})`);
|
|
1390
|
+
}
|
|
1391
|
+
session.pendingAssistantTurnSplit = false;
|
|
1392
|
+
if (session.activeTurnId) {
|
|
1393
|
+
session.activeTurnId = session.currentTurnId;
|
|
1394
|
+
}
|
|
1395
|
+
session.lastChunkTime = now;
|
|
1396
|
+
return session.currentTurnId;
|
|
1397
|
+
}
|
|
1398
|
+
getUserTurnId(session) {
|
|
1399
|
+
if (!this.isUserUpdateType(session.lastSessionUpdateType)) {
|
|
1400
|
+
session.currentUserTurnId += 1;
|
|
1401
|
+
console.log(`[AcpAdapter] Detected NEW user turn #${session.currentUserTurnId} (prev=${session.lastSessionUpdateType})`);
|
|
1402
|
+
}
|
|
1403
|
+
return session.currentUserTurnId;
|
|
1404
|
+
}
|
|
1405
|
+
normalizeToolName(rawToolName) {
|
|
1406
|
+
switch (rawToolName) {
|
|
1407
|
+
case 'exec_command':
|
|
1408
|
+
return 'command';
|
|
1409
|
+
case 'write_stdin':
|
|
1410
|
+
return 'stdin';
|
|
1411
|
+
case 'update_plan':
|
|
1412
|
+
return 'plan';
|
|
1413
|
+
default:
|
|
1414
|
+
return rawToolName || 'tool';
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
buildToolTitle(rawToolName, rawInput, fallback) {
|
|
1418
|
+
const trimmedFallback = fallback?.trim();
|
|
1419
|
+
switch (rawToolName) {
|
|
1420
|
+
case 'exec_command': {
|
|
1421
|
+
const command = rawInput?.command;
|
|
1422
|
+
if (Array.isArray(command))
|
|
1423
|
+
return command.join(' ') || trimmedFallback;
|
|
1424
|
+
if (typeof command === 'string' && command.trim())
|
|
1425
|
+
return command.trim();
|
|
1426
|
+
const cmd = typeof rawInput?.cmd === 'string' ? rawInput.cmd.trim() : '';
|
|
1427
|
+
if (cmd)
|
|
1428
|
+
return cmd;
|
|
1429
|
+
return trimmedFallback;
|
|
1430
|
+
}
|
|
1431
|
+
case 'write_stdin': {
|
|
1432
|
+
const chars = typeof rawInput?.chars === 'string' ? rawInput.chars.trim() : '';
|
|
1433
|
+
if (chars)
|
|
1434
|
+
return chars.replace(/\s+/g, ' ').slice(0, 80);
|
|
1435
|
+
return 'poll';
|
|
1436
|
+
}
|
|
1437
|
+
case 'update_plan': {
|
|
1438
|
+
const plan = Array.isArray(rawInput?.plan) ? rawInput.plan : [];
|
|
1439
|
+
const firstStep = plan.find((item) => typeof item === 'object' && item !== null && typeof item.step === 'string');
|
|
1440
|
+
if (firstStep)
|
|
1441
|
+
return `${plan.length} steps: ${firstStep.step}`;
|
|
1442
|
+
const explanation = typeof rawInput?.explanation === 'string' ? rawInput.explanation.trim() : '';
|
|
1443
|
+
if (explanation)
|
|
1444
|
+
return explanation.slice(0, 80);
|
|
1445
|
+
return trimmedFallback;
|
|
1446
|
+
}
|
|
1447
|
+
default:
|
|
1448
|
+
return trimmedFallback;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
convertAcpEventToMessageParts(event, _seenMessageIds, seenPartIds, partContentLengths, session) {
|
|
1452
|
+
const parts = [];
|
|
1453
|
+
// Unwrap event (events may be wrapped with metadata)
|
|
1454
|
+
const wrappedEvent = event;
|
|
1455
|
+
const actualEvent = (wrappedEvent._notification || event);
|
|
1456
|
+
// Handle error events from adapter
|
|
1457
|
+
if (actualEvent._isError) {
|
|
1458
|
+
const errorId = `error-${Date.now()}`;
|
|
1459
|
+
if (!seenPartIds.has(errorId)) {
|
|
1460
|
+
seenPartIds.add(errorId);
|
|
1461
|
+
parts.push({
|
|
1462
|
+
id: errorId,
|
|
1463
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1464
|
+
text: `Error: ${actualEvent.message}${actualEvent.data ? ` - ${actualEvent.data}` : ''}`,
|
|
1465
|
+
role: 'assistant'
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
return parts;
|
|
1469
|
+
}
|
|
1470
|
+
const notification = actualEvent;
|
|
1471
|
+
// Handle session/update notifications (primary ACP notification type)
|
|
1472
|
+
if (notification.method === 'session/update') {
|
|
1473
|
+
const params = notification.params;
|
|
1474
|
+
const update = params?.update;
|
|
1475
|
+
if (!update)
|
|
1476
|
+
return [];
|
|
1477
|
+
// Use toolCallId as unique identifier for tool calls
|
|
1478
|
+
const partId = update.toolCallId || (0, crypto_1.randomUUID)();
|
|
1479
|
+
// Handle different update types
|
|
1480
|
+
if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
|
|
1481
|
+
if (session) {
|
|
1482
|
+
session.pendingAssistantTurnSplit = true;
|
|
1483
|
+
}
|
|
1484
|
+
// Cache metadata from initial tool_call events (in_progress) so we can
|
|
1485
|
+
// use it when the completed tool_call_update arrives (which may lack
|
|
1486
|
+
// name/input fields).
|
|
1487
|
+
const rawInput = update.rawInput;
|
|
1488
|
+
if (update.status !== 'completed' && session && partId) {
|
|
1489
|
+
const commandFromInput = Array.isArray(rawInput?.command)
|
|
1490
|
+
? rawInput.command.join(' ')
|
|
1491
|
+
: rawInput?.command;
|
|
1492
|
+
const commandFromCmd = rawInput?.cmd;
|
|
1493
|
+
const commandFromParsed = rawInput?.parsed_cmd?.map((c) => c.cmd).join('; ');
|
|
1494
|
+
const cachedInput = commandFromInput || commandFromCmd || commandFromParsed || update.title || '';
|
|
1495
|
+
// Derive tool name: kind > rawInput.tool (with server prefix) > title (strip "Tool: " prefix)
|
|
1496
|
+
const toolFromRawInput = rawInput?.tool
|
|
1497
|
+
? (rawInput.server ? `${rawInput.server}/${rawInput.tool}` : rawInput.tool)
|
|
1498
|
+
: undefined;
|
|
1499
|
+
const toolFromTitle = update.title?.startsWith('Tool: ') ? update.title.slice(6) : undefined;
|
|
1500
|
+
const cachedName = update.kind || toolFromRawInput || toolFromTitle || '';
|
|
1501
|
+
const cachedTitle = this.buildToolTitle(cachedName || update.title, rawInput, cachedInput);
|
|
1502
|
+
if (cachedName || cachedInput || cachedTitle) {
|
|
1503
|
+
// Cap toolCallMetadata to prevent unbounded growth from orphaned entries
|
|
1504
|
+
if (session.toolCallMetadata.size >= AcpAdapter.MAX_TOOL_CALL_METADATA) {
|
|
1505
|
+
// Evict oldest half (Maps preserve insertion order)
|
|
1506
|
+
const toEvict = Math.ceil(AcpAdapter.MAX_TOOL_CALL_METADATA * 0.5);
|
|
1507
|
+
let evicted = 0;
|
|
1508
|
+
for (const key of session.toolCallMetadata.keys()) {
|
|
1509
|
+
if (evicted >= toEvict)
|
|
1510
|
+
break;
|
|
1511
|
+
session.toolCallMetadata.delete(key);
|
|
1512
|
+
evicted++;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
session.toolCallMetadata.set(partId, { name: cachedName, input: cachedInput, title: cachedTitle });
|
|
1516
|
+
}
|
|
1517
|
+
// Emit an in-progress tool part so the user sees the tool call
|
|
1518
|
+
// immediately (with a spinner) instead of waiting for completion.
|
|
1519
|
+
if (!seenPartIds.has(partId)) {
|
|
1520
|
+
seenPartIds.add(partId);
|
|
1521
|
+
const inProgressToolName = this.normalizeToolName(cachedName || update.title || 'tool');
|
|
1522
|
+
parts.push({
|
|
1523
|
+
id: partId,
|
|
1524
|
+
type: coding_agent_adapter_1.MessagePartType.TOOL,
|
|
1525
|
+
tool: {
|
|
1526
|
+
name: inProgressToolName,
|
|
1527
|
+
title: cachedTitle,
|
|
1528
|
+
status: 'running',
|
|
1529
|
+
input: cachedInput || undefined
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
// Create / update the tool part when completed (with output)
|
|
1535
|
+
if (update.status === 'completed') {
|
|
1536
|
+
const alreadySeen = seenPartIds.has(partId);
|
|
1537
|
+
seenPartIds.add(partId);
|
|
1538
|
+
// Look up cached metadata from initial tool_call event
|
|
1539
|
+
const cachedMeta = session?.toolCallMetadata.get(partId);
|
|
1540
|
+
// Extract command and output from rawInput/rawOutput
|
|
1541
|
+
const rawOutput = update.rawOutput;
|
|
1542
|
+
// Try to get command from multiple sources (tool_call has rawInput, tool_call_update has it in rawOutput)
|
|
1543
|
+
const commandFromInput = Array.isArray(rawInput?.command)
|
|
1544
|
+
? rawInput.command.join(' ')
|
|
1545
|
+
: rawInput?.command;
|
|
1546
|
+
const commandFromCmd = rawInput?.cmd;
|
|
1547
|
+
const commandFromOutput = Array.isArray(rawOutput?.command)
|
|
1548
|
+
? rawOutput.command.join(' ')
|
|
1549
|
+
: rawOutput?.command;
|
|
1550
|
+
const commandFromParsed = rawInput?.parsed_cmd?.map((c) => c.cmd).join('; ');
|
|
1551
|
+
// Extract from content array: [{type:"content", content:{type:"text", text:"..."}}]
|
|
1552
|
+
const contentArray = update.content;
|
|
1553
|
+
const inputFromContent = Array.isArray(contentArray)
|
|
1554
|
+
? contentArray.map((c) => c.content?.text || c.text || '').filter(Boolean).join('\n')
|
|
1555
|
+
: undefined;
|
|
1556
|
+
const command = commandFromInput || commandFromCmd || commandFromOutput || commandFromParsed || update.title || cachedMeta?.input || inputFromContent || 'Unknown';
|
|
1557
|
+
// Extract output - handle Codex format: {content: [{text: "...", type: "text"}], isError: false}
|
|
1558
|
+
const outputFromContent = Array.isArray(rawOutput?.content)
|
|
1559
|
+
? rawOutput.content.map((c) => c.text || '').filter(Boolean).join('\n')
|
|
1560
|
+
: undefined;
|
|
1561
|
+
const output = rawOutput?.formatted_output || rawOutput?.stdout || rawOutput?.stderr || outputFromContent || '';
|
|
1562
|
+
// Clean up cached metadata
|
|
1563
|
+
if (session) {
|
|
1564
|
+
session.toolCallMetadata.delete(partId);
|
|
1565
|
+
}
|
|
1566
|
+
// Derive tool name from multiple sources
|
|
1567
|
+
const completedToolFromTitle = update.title?.startsWith('Tool: ') ? update.title.slice(6) : undefined;
|
|
1568
|
+
const rawToolName = update.kind || cachedMeta?.name || completedToolFromTitle || update.title || 'tool';
|
|
1569
|
+
const toolName = this.normalizeToolName(rawToolName);
|
|
1570
|
+
const toolTitle = this.buildToolTitle(rawToolName, rawInput, cachedMeta?.title || (command && command !== rawToolName ? command : undefined));
|
|
1571
|
+
parts.push({
|
|
1572
|
+
id: partId,
|
|
1573
|
+
type: coding_agent_adapter_1.MessagePartType.TOOL,
|
|
1574
|
+
tool: {
|
|
1575
|
+
name: toolName,
|
|
1576
|
+
title: toolTitle,
|
|
1577
|
+
status: update.status,
|
|
1578
|
+
input: command,
|
|
1579
|
+
output: output
|
|
1580
|
+
},
|
|
1581
|
+
// If we already emitted an in-progress part for this tool,
|
|
1582
|
+
// mark as update so the renderer replaces rather than adds.
|
|
1583
|
+
update: alreadySeen
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
else if (update.sessionUpdate === 'agent_message_chunk' || update.sessionUpdate === 'assistant_message_chunk') {
|
|
1588
|
+
// Handle streaming text response from agent
|
|
1589
|
+
// Format: { content: { type: 'text', text: '...' } }
|
|
1590
|
+
const turnId = session ? this.getAssistantTurnId(session) : 0;
|
|
1591
|
+
const messageId = turnId > 0 ? `agent-response-${turnId}` : 'agent-response';
|
|
1592
|
+
if (this.debugRpcLogs) {
|
|
1593
|
+
console.log(`[AcpAdapter] agent_message_chunk: turnId=${turnId}, messageId=${messageId}`);
|
|
1594
|
+
}
|
|
1595
|
+
const chunk = this.extractTextFromUpdateContent(update.content);
|
|
1596
|
+
if (chunk) {
|
|
1597
|
+
// Accumulate text across multiple chunks
|
|
1598
|
+
const currentText = partContentLengths.get(messageId) || '';
|
|
1599
|
+
const newText = this.mergeStreamingText(currentText, chunk);
|
|
1600
|
+
partContentLengths.set(messageId, newText);
|
|
1601
|
+
// Return update part with accumulated text
|
|
1602
|
+
parts.push({
|
|
1603
|
+
id: messageId,
|
|
1604
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1605
|
+
text: newText,
|
|
1606
|
+
role: 'assistant',
|
|
1607
|
+
update: seenPartIds.has(messageId) // Mark as update if we've seen this ID before
|
|
1608
|
+
});
|
|
1609
|
+
seenPartIds.add(messageId);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
else if (update.sessionUpdate === 'agent_thought_chunk') {
|
|
1613
|
+
// Handle reasoning/thinking chunks
|
|
1614
|
+
// Format: { content: { type: 'text', text: '...' } }
|
|
1615
|
+
const turnId = session ? this.getAssistantTurnId(session) : 0;
|
|
1616
|
+
const thinkingId = turnId > 0 ? `agent-thinking-${turnId}` : 'agent-thinking';
|
|
1617
|
+
const chunk = this.extractTextFromUpdateContent(update.content);
|
|
1618
|
+
if (chunk) {
|
|
1619
|
+
// Accumulate text across multiple chunks
|
|
1620
|
+
const currentText = partContentLengths.get(thinkingId) || '';
|
|
1621
|
+
const newText = this.mergeStreamingText(currentText, chunk);
|
|
1622
|
+
partContentLengths.set(thinkingId, newText);
|
|
1623
|
+
// Return update part with accumulated text
|
|
1624
|
+
parts.push({
|
|
1625
|
+
id: thinkingId,
|
|
1626
|
+
type: coding_agent_adapter_1.MessagePartType.REASONING,
|
|
1627
|
+
text: newText,
|
|
1628
|
+
role: 'assistant',
|
|
1629
|
+
update: seenPartIds.has(thinkingId)
|
|
1630
|
+
});
|
|
1631
|
+
seenPartIds.add(thinkingId);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
else if (update.sessionUpdate === 'user_message_chunk' || update.sessionUpdate === 'human_message_chunk') {
|
|
1635
|
+
// Handle streaming user message chunks (usually when user types)
|
|
1636
|
+
const turnId = session ? this.getUserTurnId(session) : 0;
|
|
1637
|
+
const userId = turnId > 0 ? `user-message-${turnId}` : 'user-message';
|
|
1638
|
+
const chunk = this.extractTextFromUpdateContent(update.content);
|
|
1639
|
+
if (chunk) {
|
|
1640
|
+
// Accumulate text across multiple chunks
|
|
1641
|
+
const currentText = partContentLengths.get(userId) || '';
|
|
1642
|
+
const newText = this.mergeStreamingText(currentText, chunk);
|
|
1643
|
+
partContentLengths.set(userId, newText);
|
|
1644
|
+
// Return update part with accumulated text
|
|
1645
|
+
parts.push({
|
|
1646
|
+
id: userId,
|
|
1647
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1648
|
+
text: newText,
|
|
1649
|
+
role: 'user',
|
|
1650
|
+
update: seenPartIds.has(userId)
|
|
1651
|
+
});
|
|
1652
|
+
seenPartIds.add(userId);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
else if (update.sessionUpdate === 'agent_message' ||
|
|
1656
|
+
update.sessionUpdate === 'assistant_message' ||
|
|
1657
|
+
update.sessionUpdate === 'user_message' ||
|
|
1658
|
+
update.sessionUpdate === 'human_message') {
|
|
1659
|
+
const text = this.extractTextFromUpdateContent(update.content);
|
|
1660
|
+
if (!text)
|
|
1661
|
+
return parts;
|
|
1662
|
+
const role = update.sessionUpdate === 'user_message' || update.sessionUpdate === 'human_message'
|
|
1663
|
+
? 'user'
|
|
1664
|
+
: 'assistant';
|
|
1665
|
+
// For assistant messages without a stable messageId, derive a
|
|
1666
|
+
// deterministic ID from the current turn so it matches the streaming
|
|
1667
|
+
// chunk ID (`agent-response-{turnId}`). This prevents duplicates
|
|
1668
|
+
// where the same text shows up once from chunks and again from
|
|
1669
|
+
// the final agent_message event with a random UUID.
|
|
1670
|
+
let partId;
|
|
1671
|
+
if (update.messageId) {
|
|
1672
|
+
partId = update.messageId;
|
|
1673
|
+
}
|
|
1674
|
+
else if (role === 'assistant' && session) {
|
|
1675
|
+
const turnId = this.getAssistantTurnId(session);
|
|
1676
|
+
partId = turnId > 0 ? `agent-response-${turnId}` : 'agent-response';
|
|
1677
|
+
}
|
|
1678
|
+
else {
|
|
1679
|
+
partId = `${update.sessionUpdate}-${(0, crypto_1.randomUUID)()}`;
|
|
1680
|
+
}
|
|
1681
|
+
const alreadySeen = seenPartIds.has(partId);
|
|
1682
|
+
if (!alreadySeen) {
|
|
1683
|
+
seenPartIds.add(partId);
|
|
1684
|
+
partContentLengths.set(partId, text);
|
|
1685
|
+
parts.push({
|
|
1686
|
+
id: partId,
|
|
1687
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1688
|
+
text,
|
|
1689
|
+
role
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
else if (role === 'assistant') {
|
|
1693
|
+
// Final agent_message may have more complete text than the
|
|
1694
|
+
// accumulated chunks — update the existing entry so the
|
|
1695
|
+
// renderer gets the definitive version.
|
|
1696
|
+
const existingText = partContentLengths.get(partId) || '';
|
|
1697
|
+
if (text.length > existingText.length) {
|
|
1698
|
+
partContentLengths.set(partId, text);
|
|
1699
|
+
parts.push({
|
|
1700
|
+
id: partId,
|
|
1701
|
+
type: coding_agent_adapter_1.MessagePartType.TEXT,
|
|
1702
|
+
text,
|
|
1703
|
+
role,
|
|
1704
|
+
update: true
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
else if (update.sessionUpdate === 'plan') {
|
|
1710
|
+
// Handle agent plan - we can ignore this for now or display it later
|
|
1711
|
+
// Format: { entries: [{ content: '...', priority: 'high', status: 'pending' }] }
|
|
1712
|
+
console.log(`[AcpAdapter/${this.agentType}] Received plan with ${(update.entries || []).length} entries`);
|
|
1713
|
+
}
|
|
1714
|
+
// Only update lastSessionUpdateType for events that affect turn detection.
|
|
1715
|
+
// Non-content events like `plan` and `available_commands_update` must NOT
|
|
1716
|
+
// pollute turn state — otherwise they cause the next assistant chunk to
|
|
1717
|
+
// start a spurious new turn, fragmenting the response into multiple bubbles.
|
|
1718
|
+
if (session && update.sessionUpdate) {
|
|
1719
|
+
const isTurnRelevant = this.isAssistantChunkUpdateType(update.sessionUpdate)
|
|
1720
|
+
|| this.isToolingUpdateType(update.sessionUpdate)
|
|
1721
|
+
|| this.isUserUpdateType(update.sessionUpdate)
|
|
1722
|
+
|| update.sessionUpdate === 'agent_message'
|
|
1723
|
+
|| update.sessionUpdate === 'assistant_message';
|
|
1724
|
+
if (isTurnRelevant) {
|
|
1725
|
+
session.lastSessionUpdateType = update.sessionUpdate;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return parts;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
exports.AcpAdapter = AcpAdapter;
|