@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. 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;