@peakflo/agent-harness 0.2.9-rc.4 → 0.2.9-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,6 +98,33 @@ about what a third-party program prints.
98
98
  `PF_HARNESS_DISCOVERY=0`. The runner then starts none of these programs and
99
99
  reports no list. Running turns with Pi is not affected.
100
100
 
101
+ ## Permission modes on Codex, Claude Code and Cursor
102
+
103
+ An agent's permission mode is applied by the runner for each turn. A vendor
104
+ harness never runs with fewer questions than the mode asks for; where a mode
105
+ cannot be honoured exactly, the runner uses the stricter behaviour.
106
+
107
+ | Agent's mode | Codex | Claude Code | Cursor |
108
+ | ------------------------ | --------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
109
+ | Ask before each action | Asks before every command it does not know to be read-only | Asks before every tool that needs permission | Runs in Cursor's read-only "Ask" mode: reads and answers, changes nothing |
110
+ | Accept edits | Same as "Ask before each action" | Same as "Ask before each action" | Same as "Ask before each action" |
111
+ | Plan only | Read-only sandbox; asks before a command that would write, and a command you approve does run | Claude Code's own plan mode | Runs in Cursor's read-only "Plan" mode |
112
+ | Don't ask for permission | Runs without asking | Runs without asking | Runs without asking; each request Cursor raises is approved once |
113
+
114
+ Cursor is read-only in the asking modes because it asks only about commands
115
+ that are not on the allow-list in its own settings (`~/.cursor/cli-config.json`).
116
+ The runner does not read or change that file, so it cannot promise a question
117
+ before each action. If Cursor will not accept the read-only mode, the turn is
118
+ refused.
119
+
120
+ The runner never answers a request with "always allow", and does not offer
121
+ that answer to the person approving: Codex and Cursor would save it as a
122
+ standing rule in their own configuration.
123
+
124
+ Cursor reports an account limit as a normal reply ("Upgrade your plan to
125
+ continue"). The runner turns a reply that is only that sentence into a failed
126
+ turn that says it comes from the Cursor account on the runner.
127
+
101
128
  ## Troubleshooting
102
129
 
103
130
  **`npx @peakflo/agent-harness` fails with a 404.** Your `~/.npmrc` maps the
@@ -30,6 +30,16 @@ export declare function stopAtTurnEnd<T extends CodingAgentAdapter>(adapter: T,
30
30
  * (`agent-response-1`). See `pollMessages` below.
31
31
  */
32
32
  partIdsRestartEveryProcess?: boolean;
33
+ /**
34
+ * The vendor's way of saying "this account cannot run the turn" when it
35
+ * says so in the model's own reply and ends the turn normally. See
36
+ * `pollMessages` and `getStatus` below.
37
+ */
38
+ accountLimit?: {
39
+ isReply: (text: string | undefined) => boolean;
40
+ mayBecomeReply: (text: string | undefined) => boolean;
41
+ error: string;
42
+ };
33
43
  }): T;
34
44
  /**
35
45
  * A random id for THIS runner's vendor sessions, written once into the
@@ -8,6 +8,7 @@ const node_crypto_1 = require("node:crypto");
8
8
  const node_fs_1 = require("node:fs");
9
9
  const node_path_1 = require("node:path");
10
10
  const adapters_1 = require("./adapters");
11
+ const cursor_acp_1 = require("./adapters/cursor-acp");
11
12
  const vendor_backends_1 = require("./vendor-backends");
12
13
  /**
13
14
  * Hand the vendor CLI the workspace by its REAL path.
@@ -65,6 +66,24 @@ function stopAtTurnEnd(adapter, instanceId, options = {}) {
65
66
  () => instanceId;
66
67
  }
67
68
  const started = new Map();
69
+ /**
70
+ * BOUNDED. This runs between a turn's last part and the release of its
71
+ * lease; a vendor CLI that will not die must not hold that up.
72
+ */
73
+ const stopSession = async (sessionId, config) => {
74
+ try {
75
+ await Promise.race([
76
+ adapter.destroySession(sessionId, config),
77
+ new Promise((resolve) => {
78
+ const timer = setTimeout(resolve, exports.STOP_TIMEOUT_MS);
79
+ timer.unref();
80
+ })
81
+ ]);
82
+ }
83
+ catch {
84
+ // Teardown is best-effort; the next id still gets its turn.
85
+ }
86
+ };
68
87
  const realIds = new Map();
69
88
  const createSession = adapter.createSession.bind(adapter);
70
89
  const resumeSession = adapter.resumeSession.bind(adapter);
@@ -79,7 +98,20 @@ function stopAtTurnEnd(adapter, instanceId, options = {}) {
79
98
  // started the process.
80
99
  setRealWorkspacePath(config);
81
100
  started.set(sessionId, config);
82
- return resumeSession(sessionId, config);
101
+ try {
102
+ return await resumeSession(sessionId, config);
103
+ }
104
+ catch (error) {
105
+ /*
106
+ * A FAILED RESUME LEAVES NOTHING RUNNING. The runner answers a failed
107
+ * resume by starting a new session on this same adapter. Seen with
108
+ * Cursor: the first process was still alive when the second was asked
109
+ * for `session/new`, and that request timed out.
110
+ */
111
+ await stopSession(sessionId, config);
112
+ started.delete(sessionId);
113
+ throw error;
114
+ }
83
115
  };
84
116
  /*
85
117
  * A PART WITH NO ROLE IS THE ASSISTANT'S — the rule 20x applies where it
@@ -110,6 +142,46 @@ function stopAtTurnEnd(adapter, instanceId, options = {}) {
110
142
  parts: new Set(),
111
143
  lengths: new Map()
112
144
  };
145
+ /*
146
+ * AN ACCOUNT LIMIT THAT ARRIVES AS AN ANSWER IS A FAILED TURN.
147
+ *
148
+ * Cursor reports its plan limit by sending "Upgrade your plan to continue"
149
+ * as the assistant's reply and ending the turn normally, so the chat showed
150
+ * a completed turn whose answer was a billing notice. When the WHOLE reply
151
+ * is that notice it is kept out of the transcript and the turn ends with an
152
+ * error that names where it comes from.
153
+ *
154
+ * The reply arrives in pieces. While it could still become the notice it is
155
+ * held back, so no half of the notice is stored; as soon as it cannot, it
156
+ * goes through as usual. A turn that ends on a held reply that is not the
157
+ * notice (an answer that is only "Upgrade") gets one more poll to hand it
158
+ * over — nothing a model said is ever dropped.
159
+ */
160
+ // ONE TURN'S STATE. `createAdapterResolver` builds and wraps a new adapter
161
+ // for every turn, so what is held or answered here never carries over to
162
+ // the next turn of the same chat.
163
+ const accountLimit = options.accountLimit;
164
+ const held = new Map();
165
+ const answered = new Set();
166
+ let flushHeld = false;
167
+ const holdBack = (part) => {
168
+ if (!accountLimit || !part.id)
169
+ return false;
170
+ if (part.type !== adapters_1.MessagePartType.TEXT)
171
+ return false;
172
+ if (part.role !== adapters_1.MessageRole.ASSISTANT)
173
+ return false;
174
+ if (accountLimit.mayBecomeReply(part.text)) {
175
+ held.set(part.id, part);
176
+ return true;
177
+ }
178
+ // It grew past the notice: a real answer. `update` is cleared because the
179
+ // runner never saw the earlier pieces.
180
+ if (held.delete(part.id))
181
+ part.update = false;
182
+ answered.add(part.id);
183
+ return false;
184
+ };
113
185
  const pollMessages = adapter.pollMessages.bind(adapter);
114
186
  adapter.pollMessages = async (...args) => {
115
187
  const scoped = options.partIdsRestartEveryProcess === true;
@@ -117,6 +189,11 @@ function stopAtTurnEnd(adapter, instanceId, options = {}) {
117
189
  const parts = scoped
118
190
  ? await pollMessages(args[0], ownSeen.messages, ownSeen.parts, ownSeen.lengths, args[4])
119
191
  : await pollMessages(...args);
192
+ const kept = flushHeld ? [...held.values()] : [];
193
+ if (flushHeld) {
194
+ held.clear();
195
+ flushHeld = false;
196
+ }
120
197
  for (const part of parts) {
121
198
  if (scoped && turnId && part.id)
122
199
  part.id = `${turnId}:${part.id}`;
@@ -126,8 +203,25 @@ function stopAtTurnEnd(adapter, instanceId, options = {}) {
126
203
  // `createSession` returned. See `checkpointSessionId` below.
127
204
  if (part.realSessionId)
128
205
  realIds.set(args[0], part.realSessionId);
206
+ if (holdBack(part))
207
+ continue;
208
+ kept.push(part);
129
209
  }
130
- return parts;
210
+ return kept;
211
+ };
212
+ const getStatus = adapter.getStatus.bind(adapter);
213
+ adapter.getStatus = async (...args) => {
214
+ const status = await getStatus(...args);
215
+ if (status.type !== adapters_1.SessionStatusType.IDLE || held.size === 0) {
216
+ return status;
217
+ }
218
+ const onlyTheNotice = answered.size === 0 &&
219
+ [...held.values()].every((part) => accountLimit?.isReply(part.text));
220
+ if (onlyTheNotice) {
221
+ return { type: adapters_1.SessionStatusType.ERROR, message: accountLimit?.error };
222
+ }
223
+ flushHeld = true;
224
+ return { type: adapters_1.SessionStatusType.BUSY };
131
225
  };
132
226
  /*
133
227
  * The session id to SAVE for the next turn. Claude Code's real id arrives on
@@ -138,20 +232,7 @@ function stopAtTurnEnd(adapter, instanceId, options = {}) {
138
232
  adapter.stopServer =
139
233
  async () => {
140
234
  for (const [sessionId, config] of started) {
141
- try {
142
- // BOUNDED. This runs between a turn's last part and the release of
143
- // its lease; a vendor CLI that will not die must not hold that up.
144
- await Promise.race([
145
- adapter.destroySession(sessionId, config),
146
- new Promise((resolve) => {
147
- const timer = setTimeout(resolve, exports.STOP_TIMEOUT_MS);
148
- timer.unref();
149
- })
150
- ]);
151
- }
152
- catch {
153
- // Teardown is best-effort; the next id still gets its turn.
154
- }
235
+ await stopSession(sessionId, config);
155
236
  }
156
237
  started.clear();
157
238
  };
@@ -234,7 +315,16 @@ stateDir) {
234
315
  if (blocker) {
235
316
  throw new Error(`Backend "${backend}" cannot run here. ${blocker}`);
236
317
  }
237
- return stopAtTurnEnd(build(backend), instanceId ? `${backend}:${instanceId}` : null, { partIdsRestartEveryProcess: backend === 'cursor' });
318
+ return stopAtTurnEnd(build(backend), instanceId ? `${backend}:${instanceId}` : null, backend === 'cursor'
319
+ ? {
320
+ partIdsRestartEveryProcess: true,
321
+ accountLimit: {
322
+ isReply: cursor_acp_1.isCursorPlanLimitReply,
323
+ mayBecomeReply: cursor_acp_1.mayBecomeCursorPlanLimitReply,
324
+ error: cursor_acp_1.CURSOR_PLAN_LIMIT_ERROR
325
+ }
326
+ }
327
+ : {});
238
328
  }
239
329
  throw new Error(`Backend "${backend}" is not wired in this build. ` +
240
330
  'Available: pi, and on a desktop or self-hosted runner codex, ' +
@@ -7,6 +7,13 @@
7
7
  */
8
8
  import type { CodingAgentAdapter, SessionConfig, SessionMessage, SessionStatus, MessagePart } from './coding-agent-adapter';
9
9
  export type AcpAgentType = 'codex' | 'cursor';
10
+ /**
11
+ * How long the agent process may take to start and open a session. A cold
12
+ * `cursor-agent acp` took 26.7 s and then 34 s to answer `session/new` on a
13
+ * busy desktop; with the 30 s that every other request gets, the second one
14
+ * failed the turn with "Request timeout: session/new".
15
+ */
16
+ export declare const ACP_SESSION_START_TIMEOUT_MS = 120000;
10
17
  interface AcpPermissionRequest {
11
18
  requestId: string | number;
12
19
  toolCallId: string;
@@ -152,6 +159,14 @@ export declare class AcpAdapter implements CodingAgentAdapter {
152
159
  private sendRpcResponse;
153
160
  private sendRpcNotification;
154
161
  private handlePermissionRequest;
162
+ /**
163
+ * Put a Cursor session into the mode the agent's permission mode requires,
164
+ * and select its model. See `cursor-acp.ts` for both mappings.
165
+ *
166
+ * The mode is not best-effort: when a read-only mode was asked for and
167
+ * Cursor does not accept it, this throws and the turn does not run.
168
+ */
169
+ private applyCursorSessionOptions;
155
170
  private extractAcpSessionId;
156
171
  /**
157
172
  * Convert internal McpServerConfig map to ACP-spec McpServer array.
@@ -7,13 +7,23 @@
7
7
  * Spec: https://github.com/agentclientprotocol/typescript-sdk
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.AcpAdapter = void 0;
10
+ exports.AcpAdapter = exports.ACP_SESSION_START_TIMEOUT_MS = void 0;
11
11
  /**
12
12
  * Extracted verbatim from 20x `src/main/adapters/acp-adapter.ts`.
13
13
  * CHANGED: imports only — `../child-stream-guards` ->
14
14
  * `../runtime/child-stream-guards`. The `app.asar` -> `app.asar.unpacked`
15
15
  * path rewrite is a plain string replace with no Electron import and is left
16
16
  * exactly as it was. Source formatting (no semicolons) preserved.
17
+ *
18
+ * CHANGED SINCE, for Cursor on a runner (rules in `./cursor-acp.ts`):
19
+ * - starting the process and opening a session may take
20
+ * `ACP_SESSION_START_TIMEOUT_MS`, not the 30 s of other requests
21
+ * - a JSON-RPC error keeps `error.data.message` ("Session … not found")
22
+ * - a failed `session/load` always stops its process
23
+ * - the model is chosen from the session's own `model` option
24
+ * - an asking permission mode puts Cursor in a read-only mode, or the turn
25
+ * is refused; "always allow" is not offered to the person answering and
26
+ * is not what a bypass turn answers with
17
27
  */
18
28
  const child_process_1 = require("child_process");
19
29
  const crypto_1 = require("crypto");
@@ -23,11 +33,24 @@ const os_1 = require("os");
23
33
  const path_1 = require("path");
24
34
  const util_1 = require("util");
25
35
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
36
+ const cursor_acp_1 = require("./cursor-acp");
26
37
  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
- };
38
+ /**
39
+ * The ACP options known to answer ONE request (`allow_once`, `reject_once`).
40
+ * Anything else — "always", "for session", or a name nobody here has seen —
41
+ * is treated as a standing answer.
42
+ */
43
+ function isStandingAcpOption(option) {
44
+ const oneTime = ['allow_once', 'reject_once', 'allow-once', 'reject-once'];
45
+ return !oneTime.includes(option.kind) && !oneTime.includes(option.optionId);
46
+ }
47
+ /**
48
+ * How long the agent process may take to start and open a session. A cold
49
+ * `cursor-agent acp` took 26.7 s and then 34 s to answer `session/new` on a
50
+ * busy desktop; with the 30 s that every other request gets, the second one
51
+ * failed the turn with "Request timeout: session/new".
52
+ */
53
+ exports.ACP_SESSION_START_TIMEOUT_MS = 120_000;
31
54
  /**
32
55
  * Unified ACP adapter for all ACP-compatible agents
33
56
  */
@@ -338,7 +361,7 @@ class AcpAdapter {
338
361
  name: 'pf-desktop',
339
362
  version: '0.0.1'
340
363
  }
341
- });
364
+ }, exports.ACP_SESSION_START_TIMEOUT_MS);
342
365
  // Authenticate if required
343
366
  await this.authenticateSession(session, initResult);
344
367
  // Create ACP session (only accepts cwd and mcpServers per ACP spec)
@@ -347,7 +370,7 @@ class AcpAdapter {
347
370
  const result = await this.sendRpcRequest(session, 'session/new', {
348
371
  cwd: config.workspaceDir,
349
372
  mcpServers: convertedMcpServers
350
- });
373
+ }, exports.ACP_SESSION_START_TIMEOUT_MS);
351
374
  // Extract session ID from result
352
375
  const acpSessionId = this.extractAcpSessionId(result);
353
376
  if (acpSessionId) {
@@ -356,12 +379,23 @@ class AcpAdapter {
356
379
  this.sessions.delete(sessionId);
357
380
  this.sessions.set(acpSessionId, session);
358
381
  }
382
+ if (this.agentType === 'cursor' && acpSessionId) {
383
+ try {
384
+ await this.applyCursorSessionOptions(session, acpSessionId, config, result);
385
+ }
386
+ catch (error) {
387
+ // The turn must not run in a mode nobody chose. Stop the process too:
388
+ // a session that is refused here is never destroyed by its caller.
389
+ acpProcess.kill('SIGTERM');
390
+ this.sessions.delete(acpSessionId);
391
+ this.sessions.delete(sessionId);
392
+ throw error;
393
+ }
394
+ }
359
395
  // Set model if specified
360
- if (config.model && acpSessionId) {
396
+ if (config.model && acpSessionId && this.agentType !== 'cursor') {
361
397
  try {
362
- const modelValue = this.agentType === 'cursor'
363
- ? CURSOR_ACP_MODEL_VALUES[config.model] ?? config.model
364
- : config.model;
398
+ const modelValue = config.model;
365
399
  await this.sendRpcRequest(session, 'session/set_config_option', {
366
400
  sessionId: acpSessionId,
367
401
  configId: 'model',
@@ -490,17 +524,22 @@ class AcpAdapter {
490
524
  name: 'pf-desktop',
491
525
  version: '0.0.1'
492
526
  }
493
- });
527
+ }, exports.ACP_SESSION_START_TIMEOUT_MS);
494
528
  // Authenticate if required
495
529
  await this.authenticateSession(session, initResult);
496
530
  // Try to load existing session
497
531
  try {
498
- await this.sendRpcRequest(session, 'session/load', {
532
+ const loaded = await this.sendRpcRequest(session, 'session/load', {
499
533
  sessionId: sessionId,
500
534
  cwd: config.workspaceDir,
501
535
  mcpServers: this.convertMcpServers(config.mcpServers) || []
502
- });
536
+ }, exports.ACP_SESSION_START_TIMEOUT_MS);
503
537
  console.log(`[AcpAdapter/${this.agentType}] Session loaded successfully: ${sessionId}`);
538
+ if (this.agentType === 'cursor') {
539
+ // A loaded session starts in Cursor's own default mode and model, so
540
+ // both are set again for this turn.
541
+ await this.applyCursorSessionOptions(session, sessionId, config, loaded);
542
+ }
504
543
  // Convert replayed notifications (buffered during session/load) to SessionMessages.
505
544
  // Without this, the renderer sees status:'idle' + messages:[] and hides the panel.
506
545
  const messages = await this.getAllMessages(sessionId, config);
@@ -512,10 +551,12 @@ class AcpAdapter {
512
551
  catch (error) {
513
552
  // Check if session not found
514
553
  const errMsg = error instanceof Error ? error.message : String(error);
554
+ // Whatever the reason, this process is of no further use: the caller
555
+ // starts a new session in a new process. Left running, it kept the
556
+ // agent's store open while the replacement tried to start.
557
+ acpProcess.kill('SIGTERM');
558
+ this.sessions.delete(sessionId);
515
559
  if (errMsg.includes('not found') || errMsg.includes('does not exist')) {
516
- // Clean up process
517
- acpProcess.kill('SIGTERM');
518
- this.sessions.delete(sessionId);
519
560
  throw new Error(`INCOMPATIBLE_SESSION_ID: This ${this.agentType} session does not exist or has expired. Please start a new session.`);
520
561
  }
521
562
  // Re-throw other errors
@@ -860,7 +901,7 @@ class AcpAdapter {
860
901
  if (errorInfo) {
861
902
  this.handleQuotaError(session, errorInfo);
862
903
  }
863
- pending.reject(new Error(response.error.message));
904
+ pending.reject(new Error((0, cursor_acp_1.describeAcpRpcError)(response.error)));
864
905
  }
865
906
  else if ('result' in response) {
866
907
  pending.resolve(response.result);
@@ -1117,7 +1158,7 @@ class AcpAdapter {
1117
1158
  session.permanentMessages.splice(0, Math.ceil(AcpAdapter.MAX_PERMANENT_MESSAGES * 0.25));
1118
1159
  }
1119
1160
  }
1120
- async sendRpcRequest(session, method, params) {
1161
+ async sendRpcRequest(session, method, params, timeoutMs = 30000) {
1121
1162
  const id = session.nextRequestId++;
1122
1163
  const request = {
1123
1164
  jsonrpc: '2.0',
@@ -1142,7 +1183,7 @@ class AcpAdapter {
1142
1183
  session.pendingRequests.delete(id);
1143
1184
  reject(new Error(`Request timeout: ${method}`));
1144
1185
  }
1145
- }, 30000);
1186
+ }, timeoutMs);
1146
1187
  });
1147
1188
  }
1148
1189
  sendRpcRequestNoWait(session, method, params) {
@@ -1204,17 +1245,51 @@ class AcpAdapter {
1204
1245
  console.log(` Question: ${question}`);
1205
1246
  console.log(` Tool: ${toolCall?.kind} - ${toolCall?.toolCallId}`);
1206
1247
  console.log(` Options: ${options.map((o) => `${o.name} (${o.optionId})`).join(', ')}`);
1207
- const approvalOptions = options.map((o) => ({
1248
+ const allApprovalOptions = options.map((o) => ({
1208
1249
  optionId: o.optionId,
1209
1250
  name: o.name,
1210
1251
  kind: o.kind
1211
1252
  }));
1253
+ // "Always allow" answers more than this one request and Cursor saves it in
1254
+ // its own config on the runner. A person answering from the control plane
1255
+ // is offered the one-time answers only.
1256
+ const oneTimeOptions = allApprovalOptions.filter((option) => !isStandingAcpOption(option));
1257
+ const approvalOptions = this.agentType === 'cursor' && session.config.permissionMode !== 'allow' && oneTimeOptions.length > 0
1258
+ ? oneTimeOptions
1259
+ : allApprovalOptions;
1212
1260
  if (session.config.permissionMode === 'allow') {
1261
+ // Cursor saves "allow always" as a standing rule in the runner owner's
1262
+ // `~/.cursor/cli-config.json` (seen: one bypass turn added `Shell(echo)`
1263
+ // and `Shell(cat)`), which would then also apply to turns that are meant
1264
+ // to ask. Every request is approved once instead.
1265
+ if (this.agentType === 'cursor') {
1266
+ const once = approvalOptions.find((option) => option.optionId === 'allow-once')
1267
+ || approvalOptions.find((option) => option.kind === 'allow_once');
1268
+ if (!once) {
1269
+ // Only standing answers on offer. Bypass means "do not ask", not
1270
+ // "write rules into the owner's Cursor settings": refuse this one.
1271
+ const reject = approvalOptions.find((option) => option.kind === 'reject_once' || option.optionId === 'reject-once');
1272
+ console.warn(`[AcpAdapter/cursor] Permission request offers no one-time approval; refusing it`);
1273
+ this.sendRpcResponse(session, request.id, {
1274
+ result: {
1275
+ outcome: reject
1276
+ ? { outcome: 'selected', optionId: reject.optionId }
1277
+ : { outcome: 'cancelled' }
1278
+ }
1279
+ });
1280
+ return;
1281
+ }
1282
+ console.log(`[AcpAdapter/cursor] Auto-approving permission with: ${once.optionId}`);
1283
+ this.sendRpcResponse(session, request.id, {
1284
+ result: { outcome: { outcome: 'selected', optionId: once.optionId } }
1285
+ });
1286
+ return;
1287
+ }
1213
1288
  const autoApprovedOptionId = approvalOptions.find((option) => option.optionId === 'approved-for-session')?.optionId
1214
1289
  || approvalOptions.find((option) => option.optionId === 'allow-always')?.optionId
1215
1290
  || approvalOptions.find((option) => option.optionId === 'approved')?.optionId
1216
1291
  || approvalOptions.find((option) => option.optionId === 'allow-once')?.optionId
1217
- || (this.agentType === 'cursor' ? 'allow-once' : 'approved');
1292
+ || 'approved';
1218
1293
  console.log(`[AcpAdapter/${this.agentType}] Auto-approving permission with: ${autoApprovedOptionId}`);
1219
1294
  this.sendRpcResponse(session, request.id, {
1220
1295
  result: {
@@ -1237,6 +1312,53 @@ class AcpAdapter {
1237
1312
  session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
1238
1313
  console.log(`[AcpAdapter/${this.agentType}] Awaiting user approval...`);
1239
1314
  }
1315
+ /**
1316
+ * Put a Cursor session into the mode the agent's permission mode requires,
1317
+ * and select its model. See `cursor-acp.ts` for both mappings.
1318
+ *
1319
+ * The mode is not best-effort: when a read-only mode was asked for and
1320
+ * Cursor does not accept it, this throws and the turn does not run.
1321
+ */
1322
+ async applyCursorSessionOptions(session, acpSessionId, config, sessionResult) {
1323
+ const mode = (0, cursor_acp_1.cursorModeFor)(config);
1324
+ const modeValue = (0, cursor_acp_1.resolveCursorModeValue)(mode, sessionResult) ?? mode;
1325
+ try {
1326
+ await this.sendRpcRequest(session, 'session/set_config_option', {
1327
+ sessionId: acpSessionId,
1328
+ configId: 'mode',
1329
+ value: modeValue
1330
+ });
1331
+ console.log(`[AcpAdapter/cursor] Mode set to: ${modeValue}`);
1332
+ }
1333
+ catch (error) {
1334
+ const errMsg = error instanceof Error ? error.message : String(error);
1335
+ if (mode !== 'agent') {
1336
+ throw new Error(`Cursor did not accept its read-only "${mode}" mode (${errMsg}). The agent's ` +
1337
+ 'permission mode asks before actions, which Cursor can only honour in a ' +
1338
+ 'read-only mode, so this turn was not run.');
1339
+ }
1340
+ console.warn(`[AcpAdapter/cursor] Failed to set mode ${modeValue}: ${errMsg}`);
1341
+ }
1342
+ const modelValue = (0, cursor_acp_1.resolveCursorModelValue)(config.model, sessionResult);
1343
+ if (!modelValue) {
1344
+ if (config.model) {
1345
+ console.log(`[AcpAdapter/cursor] Model "${config.model}" is not offered by this session; Cursor keeps its default`);
1346
+ }
1347
+ return;
1348
+ }
1349
+ try {
1350
+ await this.sendRpcRequest(session, 'session/set_config_option', {
1351
+ sessionId: acpSessionId,
1352
+ configId: 'model',
1353
+ value: modelValue
1354
+ });
1355
+ console.log(`[AcpAdapter/cursor] Model set to: ${modelValue}`);
1356
+ }
1357
+ catch (error) {
1358
+ const errMsg = error instanceof Error ? error.message : String(error);
1359
+ console.warn(`[AcpAdapter/cursor] Failed to set model ${modelValue}: ${errMsg}`);
1360
+ }
1361
+ }
1240
1362
  extractAcpSessionId(result) {
1241
1363
  if (!result || typeof result !== 'object')
1242
1364
  return null;
@@ -90,6 +90,31 @@ export declare class ClaudeCodeAdapter implements CodingAgentAdapter {
90
90
  private loadSessionHistory;
91
91
  resumeSession(sessionId: string, config: SessionConfig): Promise<SessionMessage[]>;
92
92
  sendPrompt(sessionId: string, parts: MessagePart[], config: SessionConfig): Promise<void>;
93
+ /**
94
+ * Questions waiting for a person, oldest first, per session. Claude can ask
95
+ * about several tools at once (parallel tool use); they are answered one at
96
+ * a time, in the order they were asked, and none is answered by a reply
97
+ * that was meant for another.
98
+ */
99
+ private pendingToolApprovals;
100
+ private askToolApproval;
101
+ /**
102
+ * Refuse every question still waiting for this session. Called when the
103
+ * turn is aborted or the session is destroyed, so no tool is left waiting
104
+ * on an answer that can no longer come — whether or not the SDK passed an
105
+ * abort signal with the question.
106
+ */
107
+ private denyPendingToolApprovals;
108
+ getPendingApproval(sessionId: string): {
109
+ toolCallId: string;
110
+ question: string;
111
+ options: Array<{
112
+ optionId: string;
113
+ name: string;
114
+ kind: string;
115
+ }>;
116
+ } | null;
117
+ respondToApproval(sessionId: string, approved: boolean, optionId?: string): Promise<boolean>;
93
118
  getStatus(sessionId: string, _config: SessionConfig): Promise<SessionStatus>;
94
119
  /**
95
120
  * Exposes in-flight background subagents as "running tools" so agent-manager's
@@ -702,7 +702,18 @@ class ClaudeCodeAdapter {
702
702
  // the mode above decorative in exactly the way G1 was.
703
703
  ...(claudePermissionMode === 'bypassPermissions'
704
704
  ? { allowDangerouslySkipPermissions: true }
705
- : {}),
705
+ : {
706
+ /*
707
+ * ADDED, not in 20x. In every mode that asks, the SDK calls this
708
+ * before a tool that needs permission. Without it the SDK has
709
+ * nobody to ask and DENIES the tool: nothing unsafe runs, but
710
+ * "Ask before each action" asked nobody and refused everything.
711
+ * The question is held here until the runner brings the person's
712
+ * answer (`getPendingApproval` / `respondToApproval`, the same
713
+ * two methods the Codex and ACP adapters have).
714
+ */
715
+ canUseTool: (toolName, input, extra) => this.askToolApproval(sessionId, toolName, input, extra?.signal)
716
+ }),
706
717
  ...(secretHooks ? { hooks: secretHooks } : {}),
707
718
  };
708
719
  // Determine session continuation mode
@@ -804,6 +815,92 @@ class ClaudeCodeAdapter {
804
815
  // Start consuming stream
805
816
  session.streamTask = this.consumeStream(sessionId, session);
806
817
  }
818
+ /* ---------------------------------------------------------------------- */
819
+ /* Tool approvals (ADDED, not in 20x) — see `canUseTool` in startQuery */
820
+ /* ---------------------------------------------------------------------- */
821
+ /**
822
+ * Questions waiting for a person, oldest first, per session. Claude can ask
823
+ * about several tools at once (parallel tool use); they are answered one at
824
+ * a time, in the order they were asked, and none is answered by a reply
825
+ * that was meant for another.
826
+ */
827
+ pendingToolApprovals = new Map();
828
+ askToolApproval(sessionId, toolName, input, signal) {
829
+ return new Promise((resolve) => {
830
+ // The command, path or URL is shown in full: a person must not approve
831
+ // from a shortened view of what will run. Only the fallback, a dump of
832
+ // an unknown tool's whole input, is cut, and says so.
833
+ const dump = JSON.stringify(input ?? {});
834
+ const detail = typeof input?.command === 'string' ? input.command
835
+ : typeof input?.file_path === 'string' ? input.file_path
836
+ : typeof input?.url === 'string' ? input.url
837
+ : dump.length > 300 ? `${dump.slice(0, 300)}… (input shortened)` : dump;
838
+ const toolCallId = `claude-approval-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
839
+ const onAbort = () => entry.settle(false, 'The turn was stopped before this action was approved.');
840
+ const entry = {
841
+ toolCallId,
842
+ question: `${toolName}: ${detail}`,
843
+ settle: (approved, reason) => {
844
+ signal?.removeEventListener('abort', onAbort);
845
+ const queue = this.pendingToolApprovals.get(sessionId) ?? [];
846
+ const index = queue.indexOf(entry);
847
+ if (index < 0)
848
+ return; // already answered
849
+ queue.splice(index, 1);
850
+ if (queue.length === 0)
851
+ this.pendingToolApprovals.delete(sessionId);
852
+ if (approved)
853
+ resolve({ behavior: 'allow', updatedInput: input });
854
+ else
855
+ resolve({ behavior: 'deny', message: reason ?? 'The user did not approve this action.' });
856
+ // The next question in the queue, if any, is now the one to show.
857
+ if (queue.length > 0)
858
+ this.onDataAvailable?.(sessionId);
859
+ }
860
+ };
861
+ const queue = this.pendingToolApprovals.get(sessionId) ?? [];
862
+ queue.push(entry);
863
+ this.pendingToolApprovals.set(sessionId, queue);
864
+ if (signal?.aborted)
865
+ onAbort();
866
+ else
867
+ signal?.addEventListener('abort', onAbort, { once: true });
868
+ this.onDataAvailable?.(sessionId);
869
+ });
870
+ }
871
+ /**
872
+ * Refuse every question still waiting for this session. Called when the
873
+ * turn is aborted or the session is destroyed, so no tool is left waiting
874
+ * on an answer that can no longer come — whether or not the SDK passed an
875
+ * abort signal with the question.
876
+ */
877
+ denyPendingToolApprovals(sessionId, reason) {
878
+ for (const entry of [...(this.pendingToolApprovals.get(sessionId) ?? [])]) {
879
+ entry.settle(false, reason);
880
+ }
881
+ this.pendingToolApprovals.delete(sessionId);
882
+ }
883
+ getPendingApproval(sessionId) {
884
+ const pending = this.pendingToolApprovals.get(sessionId)?.[0];
885
+ if (!pending)
886
+ return null;
887
+ return {
888
+ toolCallId: pending.toolCallId,
889
+ question: pending.question,
890
+ options: [
891
+ { optionId: 'allow', name: 'Allow', kind: 'allow' },
892
+ { optionId: 'deny', name: 'Deny', kind: 'reject' }
893
+ ]
894
+ };
895
+ }
896
+ async respondToApproval(sessionId, approved, optionId) {
897
+ const pending = this.pendingToolApprovals.get(sessionId)?.[0];
898
+ if (!pending)
899
+ return false;
900
+ // Only an explicit allow allows. A reply naming any other option denies.
901
+ pending.settle(approved && (optionId === undefined || optionId === 'allow'));
902
+ return true;
903
+ }
807
904
  async getStatus(sessionId, _config) {
808
905
  const session = this.sessions.get(sessionId);
809
906
  if (!session) {
@@ -887,6 +984,7 @@ class ClaudeCodeAdapter {
887
984
  session.enqueuePrompt = null;
888
985
  session.backgroundTasks.clear();
889
986
  session.abortController?.abort();
987
+ this.denyPendingToolApprovals(sessionId, 'The turn was stopped before this action was approved.');
890
988
  // Wait for stream to finish cleanup
891
989
  if (session.streamTask) {
892
990
  console.log(`[ClaudeCodeAdapter] Waiting for stream cleanup...`);
@@ -912,6 +1010,7 @@ class ClaudeCodeAdapter {
912
1010
  session.enqueuePrompt = null;
913
1011
  session.backgroundTasks.clear();
914
1012
  session.abortController?.abort();
1013
+ this.denyPendingToolApprovals(sessionId, 'The session was closed before this action was approved.');
915
1014
  // Wait for stream task to complete
916
1015
  if (session.streamTask) {
917
1016
  try {
@@ -1221,6 +1320,9 @@ class ClaudeCodeAdapter {
1221
1320
  }
1222
1321
  finally {
1223
1322
  console.log('[ClaudeCodeAdapter] Stream consumption ended');
1323
+ // ADDED, not in 20x: the stream that asked is gone (ended, failed or the
1324
+ // process exited), so no answer can reach it. Nothing may stay queued.
1325
+ this.denyPendingToolApprovals(sessionId, 'The turn ended before this action was approved.');
1224
1326
  // Always reset queryIterator when the stream ends (process exited).
1225
1327
  // Previously only error paths did this, leaving a truthy-but-exhausted
1226
1328
  // iterator after normal completion. That caused the next sendPrompt to
@@ -6,6 +6,30 @@
6
6
  * 20x's CodingAgentAdapter contract.
7
7
  */
8
8
  import type { CodingAgentAdapter, SessionConfig, SessionStatus, SessionMessage, MessagePart } from './coding-agent-adapter';
9
+ /**
10
+ * `thread/items/list` returns each item inside an envelope:
11
+ * `{ turnId, item: { type: 'userMessage', … }, startedAtMs, completedAtMs }`.
12
+ * Read as an item, the envelope has no `type`, no role and no text, so it fell
13
+ * through to the "unknown item is a tool call" branch and the transcript showed
14
+ * a "tool / tool" card holding the echoed prompt as JSON after every reply.
15
+ */
16
+ export declare function unwrapListedThreadItem(entry: unknown): unknown;
17
+ /** A decision that approves more than the one request it answers, or may. */
18
+ export declare function isStandingApprovalDecision(optionId: string): boolean;
19
+ /**
20
+ * CHANGED from 20x, which sends `on-request` for every mode but "allow".
21
+ *
22
+ * `on-request` leaves it to the MODEL to ask, and it asks only when it wants
23
+ * to leave the sandbox. Inside a workspace-write sandbox that is never: Codex
24
+ * created files and ran shell commands with no prompt at all while the agent
25
+ * was set to "Ask before each action". Seen on staging and reproduced.
26
+ *
27
+ * `untrusted` is Codex's own "ask first" policy: commands on its short list of
28
+ * read-only ones (ls, cat, ...) run, and everything else — any other command,
29
+ * any file change — is sent to the client as an approval request before it
30
+ * runs. That is what "Ask before each action" says.
31
+ */
32
+ export declare function codexApprovalPolicy(config: Pick<SessionConfig, 'permissionMode'>): 'never' | 'untrusted';
9
33
  interface PendingApproval {
10
34
  requestId: string | number;
11
35
  toolCallId: string;
@@ -41,6 +41,9 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.CodexAppServerAdapter = void 0;
44
+ exports.unwrapListedThreadItem = unwrapListedThreadItem;
45
+ exports.isStandingApprovalDecision = isStandingApprovalDecision;
46
+ exports.codexApprovalPolicy = codexApprovalPolicy;
44
47
  /**
45
48
  * Extracted verbatim from 20x `src/main/adapters/codex-app-server-adapter.ts`.
46
49
  * CHANGED: imports only — `../child-stream-guards` ->
@@ -55,6 +58,60 @@ const path_1 = require("path");
55
58
  const util_1 = require("util");
56
59
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
57
60
  const DEFAULT_CODEX_APP_SERVER_MODEL = 'gpt-5.5';
61
+ /**
62
+ * `thread/items/list` returns each item inside an envelope:
63
+ * `{ turnId, item: { type: 'userMessage', … }, startedAtMs, completedAtMs }`.
64
+ * Read as an item, the envelope has no `type`, no role and no text, so it fell
65
+ * through to the "unknown item is a tool call" branch and the transcript showed
66
+ * a "tool / tool" card holding the echoed prompt as JSON after every reply.
67
+ */
68
+ function unwrapListedThreadItem(entry) {
69
+ if (!isObject(entry))
70
+ return entry;
71
+ if (asString(entry.type))
72
+ return entry;
73
+ return isObject(entry.item) ? entry.item : entry;
74
+ }
75
+ /**
76
+ * The decisions that answer ONE request and nothing more. A list of what is
77
+ * known to be safe, not of what is known to be unsafe: a decision Codex adds
78
+ * later (one that remembers a rule under a name nobody here has seen) is
79
+ * treated as standing until someone adds it here. The cost of that: a new
80
+ * ONE-TIME decision from Codex is also hidden until it is added; the withheld
81
+ * names are logged by `handleApprovalRequest` so that is visible.
82
+ */
83
+ const ONE_TIME_APPROVAL_DECISIONS = new Set([
84
+ 'accept',
85
+ 'approved',
86
+ 'decline',
87
+ 'denied',
88
+ 'cancel',
89
+ 'abort'
90
+ ]);
91
+ /** A decision that approves more than the one request it answers, or may. */
92
+ function isStandingApprovalDecision(optionId) {
93
+ return !ONE_TIME_APPROVAL_DECISIONS.has(optionId);
94
+ }
95
+ /**
96
+ * CHANGED from 20x, which sends `on-request` for every mode but "allow".
97
+ *
98
+ * `on-request` leaves it to the MODEL to ask, and it asks only when it wants
99
+ * to leave the sandbox. Inside a workspace-write sandbox that is never: Codex
100
+ * created files and ran shell commands with no prompt at all while the agent
101
+ * was set to "Ask before each action". Seen on staging and reproduced.
102
+ *
103
+ * `untrusted` is Codex's own "ask first" policy: commands on its short list of
104
+ * read-only ones (ls, cat, ...) run, and everything else — any other command,
105
+ * any file change — is sent to the client as an approval request before it
106
+ * runs. That is what "Ask before each action" says.
107
+ */
108
+ function codexApprovalPolicy(config) {
109
+ // `'allow'` is what `mapPermissionMode` makes of the agent's
110
+ // `bypassPermissions` ("Don't ask for permission"), and of nothing else.
111
+ // Only an explicit "allow" runs without asking. Anything else, including a
112
+ // value this build does not know, asks.
113
+ return config.permissionMode === 'allow' ? 'never' : 'untrusted';
114
+ }
58
115
  const MAX_IPC_TOOL_INPUT_CHARS = 20_000;
59
116
  const MAX_IPC_TOOL_OUTPUT_CHARS = 100_000;
60
117
  const MIN_THREAD_LEVEL_ASSISTANT_DEDUPE_CHARS = 40;
@@ -269,7 +326,7 @@ class CodexAppServerAdapter {
269
326
  const result = await this.sendRpcRequest(session, 'thread/start', {
270
327
  cwd: config.workspaceDir,
271
328
  model: config.model || DEFAULT_CODEX_APP_SERVER_MODEL,
272
- approvalPolicy: config.permissionMode === 'allow' ? 'never' : 'on-request',
329
+ approvalPolicy: codexApprovalPolicy(config),
273
330
  approvalsReviewer: 'user',
274
331
  sandbox: this.resolveSandboxMode(config),
275
332
  developerInstructions: config.systemPrompt || null,
@@ -295,7 +352,7 @@ class CodexAppServerAdapter {
295
352
  threadId: sessionId,
296
353
  cwd: config.workspaceDir,
297
354
  model: config.model || DEFAULT_CODEX_APP_SERVER_MODEL,
298
- approvalPolicy: config.permissionMode === 'allow' ? 'never' : 'on-request',
355
+ approvalPolicy: codexApprovalPolicy(config),
299
356
  approvalsReviewer: 'user',
300
357
  sandbox: this.resolveSandboxMode(config),
301
358
  runtimeWorkspaceRoots: this.buildRuntimeWorkspaceRoots(config.workspaceDir),
@@ -347,7 +404,7 @@ class CodexAppServerAdapter {
347
404
  cwd: config.workspaceDir,
348
405
  model: config.model || DEFAULT_CODEX_APP_SERVER_MODEL,
349
406
  effort: config.reasoningEffort && config.reasoningEffort !== 'max' ? config.reasoningEffort : null,
350
- approvalPolicy: config.permissionMode === 'allow' ? 'never' : 'on-request',
407
+ approvalPolicy: codexApprovalPolicy(config),
351
408
  approvalsReviewer: 'user',
352
409
  sandbox: this.resolveSandboxMode(config),
353
410
  sandboxPolicy: this.buildSandboxPolicy(config),
@@ -827,7 +884,17 @@ class CodexAppServerAdapter {
827
884
  }
828
885
  const toolCallId = asString(params.approvalId) || asString(params.itemId) || asString(params.callId) || String(request.id);
829
886
  const responseKind = this.getApprovalResponseKind(request.method);
830
- const rawDecisions = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
887
+ // A decision that also writes a standing rule ("…WithExecpolicyAmendment",
888
+ // "…ForSession") would let one click approve later commands nobody saw, and
889
+ // the amendment is saved into the Codex config on the runner. Offer only
890
+ // the decisions known to answer this one request; the rest are logged.
891
+ const offered = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
892
+ const rawDecisions = offered
893
+ .filter((decision) => !isStandingApprovalDecision(normalizeDecisionName(decision)));
894
+ if (rawDecisions.length !== offered.length) {
895
+ const withheld = offered.map(normalizeDecisionName).filter(isStandingApprovalDecision);
896
+ console.log(`[CodexAppServerAdapter] Not offering approval decisions: ${withheld.join(', ')}`);
897
+ }
831
898
  const approvalOptions = rawDecisions.length > 0
832
899
  ? rawDecisions.map((decision) => {
833
900
  const optionId = normalizeDecisionName(decision);
@@ -922,8 +989,9 @@ class CodexAppServerAdapter {
922
989
  }
923
990
  bufferThreadItems(session, result) {
924
991
  const items = isObject(result) && Array.isArray(result.data) ? result.data : [];
925
- for (const item of items) {
926
- this.bufferReconciledThreadItem(session, item, isObject(item) ? asString(item.turnId) : undefined);
992
+ for (const entry of items) {
993
+ const turnId = isObject(entry) ? asString(entry.turnId) : undefined;
994
+ this.bufferReconciledThreadItem(session, unwrapListedThreadItem(entry), turnId);
927
995
  }
928
996
  }
929
997
  /**
@@ -0,0 +1,84 @@
1
+ /**
2
+ * What the runner has to know about Cursor's ACP agent (`cursor-agent acp`)
3
+ * that the generic ACP adapter does not: how a model is named on the wire,
4
+ * which Cursor mode a permission mode maps to, and how the account's plan
5
+ * limit shows up.
6
+ *
7
+ * Kept out of `acp-adapter.ts`, which is a copy of the 20x adapter, so the
8
+ * copy stays close to its source and these rules can be tested on their own.
9
+ */
10
+ /** One entry of `configOptions` in a `session/new` or `session/load` result. */
11
+ interface AcpConfigOption {
12
+ id: string;
13
+ values: Array<{
14
+ value: string;
15
+ name: string;
16
+ }>;
17
+ }
18
+ /** The `configOptions` of a session result, in a shape that is safe to read. */
19
+ export declare function readAcpConfigOptions(sessionResult: unknown): AcpConfigOption[];
20
+ /**
21
+ * The value to send in `session/set_config_option { configId: 'model' }` for
22
+ * the model an agent names, or null when the session offers nothing that
23
+ * matches — in which case nothing is sent and Cursor keeps its own default.
24
+ *
25
+ * WHY THIS IS NOT THE ID ITSELF. Cursor's ACP agent accepts only the values it
26
+ * lists in the session's own `model` option, and those carry parameters:
27
+ * `default[]` (shown as "Auto"), `composer-2.5[fast=true]`,
28
+ * `gpt-5.6-sol[context=272k,reasoning=medium,fast=false]`. The ids an agent
29
+ * holds come from `cursor-agent models` (`auto`, `composer-2.5`,
30
+ * `gpt-5.6-sol-medium`), which is a different list. Sending such an id gave
31
+ * `Failed to set model: Invalid params` on every turn, "Auto" included.
32
+ *
33
+ * Tried in order: the exact value, the option's display name, the part before
34
+ * `[`, then the longest such part the id starts with (`gpt-5.6-sol-medium`
35
+ * → `gpt-5.6-sol[…]`). `auto` is Cursor's `default`.
36
+ */
37
+ export declare function resolveCursorModelValue(model: string | undefined, sessionResult: unknown): string | null;
38
+ export type CursorMode = 'agent' | 'plan' | 'ask';
39
+ /**
40
+ * The Cursor mode a turn must run in.
41
+ *
42
+ * FAIL CLOSED. In its `agent` mode Cursor asks only about commands that are
43
+ * not on the allow-list in the runner owner's `~/.cursor/cli-config.json`
44
+ * (seen: "Not in allowlist: cat, echo"). The runner must not read or change
45
+ * that list, so it cannot know which actions Cursor will run unasked, and a
46
+ * permission mode that promises a question before EACH action cannot be
47
+ * promised for Cursor. Every asking mode therefore runs
48
+ * Cursor in one of its own read-only modes — stricter than what was asked for,
49
+ * never looser:
50
+ *
51
+ * plan → Cursor `plan` (reads and proposes a plan)
52
+ * default, acceptEdits → Cursor `ask` (reads and answers, no changes)
53
+ * bypassPermissions → Cursor `agent` (the only mode that may act)
54
+ *
55
+ * A permission request Cursor does raise is still put to the person.
56
+ */
57
+ export declare function cursorModeFor(config: {
58
+ permissionMode?: string;
59
+ sandboxMode?: string;
60
+ }): CursorMode;
61
+ /**
62
+ * The exact value of the session's `mode` option for a Cursor mode, or null
63
+ * when the session does not offer it. The caller must refuse the turn on null
64
+ * for a read-only mode rather than run in whatever mode Cursor starts in.
65
+ */
66
+ export declare function resolveCursorModeValue(mode: CursorMode, sessionResult: unknown): string | null;
67
+ /**
68
+ * Is this whole assistant reply Cursor's plan-limit notice?
69
+ *
70
+ * Cursor does not report its account limit as an error. It sends the sentence
71
+ * "Upgrade your plan to continue" as the model's answer and ends the turn
72
+ * normally. Matched on the WHOLE reply only, so an answer that merely quotes
73
+ * the sentence is left alone.
74
+ */
75
+ export declare function isCursorPlanLimitReply(text: string | undefined): boolean;
76
+ /** Could this partial reply still grow into the plan-limit notice? */
77
+ export declare function mayBecomeCursorPlanLimitReply(text: string | undefined): boolean;
78
+ export declare const CURSOR_PLAN_LIMIT_ERROR: string;
79
+ /** `error.message`, plus `error.data.message` when the agent sent one. */
80
+ export declare function describeAcpRpcError(error: {
81
+ message?: unknown;
82
+ data?: unknown;
83
+ }): string;
84
+ export {};
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ /**
3
+ * What the runner has to know about Cursor's ACP agent (`cursor-agent acp`)
4
+ * that the generic ACP adapter does not: how a model is named on the wire,
5
+ * which Cursor mode a permission mode maps to, and how the account's plan
6
+ * limit shows up.
7
+ *
8
+ * Kept out of `acp-adapter.ts`, which is a copy of the 20x adapter, so the
9
+ * copy stays close to its source and these rules can be tested on their own.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.CURSOR_PLAN_LIMIT_ERROR = void 0;
13
+ exports.readAcpConfigOptions = readAcpConfigOptions;
14
+ exports.resolveCursorModelValue = resolveCursorModelValue;
15
+ exports.cursorModeFor = cursorModeFor;
16
+ exports.resolveCursorModeValue = resolveCursorModeValue;
17
+ exports.isCursorPlanLimitReply = isCursorPlanLimitReply;
18
+ exports.mayBecomeCursorPlanLimitReply = mayBecomeCursorPlanLimitReply;
19
+ exports.describeAcpRpcError = describeAcpRpcError;
20
+ function isRecord(value) {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ }
23
+ function readOptionValues(raw) {
24
+ if (!Array.isArray(raw))
25
+ return [];
26
+ const values = [];
27
+ for (const entry of raw) {
28
+ if (!isRecord(entry))
29
+ continue;
30
+ // A grouped option list: `{ group, name, options: [...] }`.
31
+ if (Array.isArray(entry.options)) {
32
+ values.push(...readOptionValues(entry.options));
33
+ continue;
34
+ }
35
+ if (typeof entry.value !== 'string')
36
+ continue;
37
+ values.push({
38
+ value: entry.value,
39
+ name: typeof entry.name === 'string' ? entry.name : entry.value
40
+ });
41
+ }
42
+ return values;
43
+ }
44
+ /** The `configOptions` of a session result, in a shape that is safe to read. */
45
+ function readAcpConfigOptions(sessionResult) {
46
+ if (!isRecord(sessionResult) || !Array.isArray(sessionResult.configOptions)) {
47
+ return [];
48
+ }
49
+ const options = [];
50
+ for (const entry of sessionResult.configOptions) {
51
+ if (!isRecord(entry) || typeof entry.id !== 'string')
52
+ continue;
53
+ options.push({ id: entry.id, values: readOptionValues(entry.options) });
54
+ }
55
+ return options;
56
+ }
57
+ function optionValues(sessionResult, id) {
58
+ return (readAcpConfigOptions(sessionResult).find((option) => option.id === id)
59
+ ?.values ?? []);
60
+ }
61
+ /** `composer-2.5[fast=true]` → `composer-2.5`. */
62
+ function baseOf(value) {
63
+ const bracket = value.indexOf('[');
64
+ return (bracket >= 0 ? value.slice(0, bracket) : value).trim().toLowerCase();
65
+ }
66
+ /**
67
+ * The value to send in `session/set_config_option { configId: 'model' }` for
68
+ * the model an agent names, or null when the session offers nothing that
69
+ * matches — in which case nothing is sent and Cursor keeps its own default.
70
+ *
71
+ * WHY THIS IS NOT THE ID ITSELF. Cursor's ACP agent accepts only the values it
72
+ * lists in the session's own `model` option, and those carry parameters:
73
+ * `default[]` (shown as "Auto"), `composer-2.5[fast=true]`,
74
+ * `gpt-5.6-sol[context=272k,reasoning=medium,fast=false]`. The ids an agent
75
+ * holds come from `cursor-agent models` (`auto`, `composer-2.5`,
76
+ * `gpt-5.6-sol-medium`), which is a different list. Sending such an id gave
77
+ * `Failed to set model: Invalid params` on every turn, "Auto" included.
78
+ *
79
+ * Tried in order: the exact value, the option's display name, the part before
80
+ * `[`, then the longest such part the id starts with (`gpt-5.6-sol-medium`
81
+ * → `gpt-5.6-sol[…]`). `auto` is Cursor's `default`.
82
+ */
83
+ function resolveCursorModelValue(model, sessionResult) {
84
+ const wanted = (model ?? '').trim().toLowerCase();
85
+ if (!wanted)
86
+ return null;
87
+ const values = optionValues(sessionResult, 'model');
88
+ if (values.length === 0)
89
+ return null;
90
+ const exact = values.find((entry) => entry.value.toLowerCase() === wanted);
91
+ if (exact)
92
+ return exact.value;
93
+ const byName = values.find((entry) => entry.name.trim().toLowerCase() === wanted);
94
+ if (byName)
95
+ return byName.value;
96
+ const wantedBase = wanted === 'auto' ? 'default' : baseOf(wanted);
97
+ const byBase = values.find((entry) => baseOf(entry.value) === wantedBase);
98
+ if (byBase)
99
+ return byBase.value;
100
+ let best = null;
101
+ for (const entry of values) {
102
+ const base = baseOf(entry.value);
103
+ if (!base || !wantedBase.startsWith(`${base}-`))
104
+ continue;
105
+ if (!best || base.length > best.base.length) {
106
+ best = { value: entry.value, base };
107
+ }
108
+ }
109
+ return best?.value ?? null;
110
+ }
111
+ /**
112
+ * The Cursor mode a turn must run in.
113
+ *
114
+ * FAIL CLOSED. In its `agent` mode Cursor asks only about commands that are
115
+ * not on the allow-list in the runner owner's `~/.cursor/cli-config.json`
116
+ * (seen: "Not in allowlist: cat, echo"). The runner must not read or change
117
+ * that list, so it cannot know which actions Cursor will run unasked, and a
118
+ * permission mode that promises a question before EACH action cannot be
119
+ * promised for Cursor. Every asking mode therefore runs
120
+ * Cursor in one of its own read-only modes — stricter than what was asked for,
121
+ * never looser:
122
+ *
123
+ * plan → Cursor `plan` (reads and proposes a plan)
124
+ * default, acceptEdits → Cursor `ask` (reads and answers, no changes)
125
+ * bypassPermissions → Cursor `agent` (the only mode that may act)
126
+ *
127
+ * A permission request Cursor does raise is still put to the person.
128
+ */
129
+ function cursorModeFor(config) {
130
+ // Only an explicit "allow" may act. An unknown value is treated as asking.
131
+ if (config.permissionMode === 'allow')
132
+ return 'agent';
133
+ return config.sandboxMode === 'read-only' ? 'plan' : 'ask';
134
+ }
135
+ /**
136
+ * The exact value of the session's `mode` option for a Cursor mode, or null
137
+ * when the session does not offer it. The caller must refuse the turn on null
138
+ * for a read-only mode rather than run in whatever mode Cursor starts in.
139
+ */
140
+ function resolveCursorModeValue(mode, sessionResult) {
141
+ const values = optionValues(sessionResult, 'mode');
142
+ const found = values.find((entry) => entry.value.toLowerCase() === mode);
143
+ if (found)
144
+ return found.value;
145
+ // Older agents list modes under `modes.availableModes` instead.
146
+ if (isRecord(sessionResult) && isRecord(sessionResult.modes)) {
147
+ const available = sessionResult.modes.availableModes;
148
+ if (Array.isArray(available)) {
149
+ for (const entry of available) {
150
+ if (isRecord(entry) &&
151
+ typeof entry.id === 'string' &&
152
+ entry.id.toLowerCase() === mode) {
153
+ return entry.id;
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+ const CURSOR_PLAN_LIMIT_TEXT = 'upgrade your plan to continue';
161
+ function normalise(text) {
162
+ return text
163
+ .trim()
164
+ .replace(/[.!]+$/, '')
165
+ .replace(/\s+/g, ' ')
166
+ .toLowerCase();
167
+ }
168
+ /**
169
+ * Is this whole assistant reply Cursor's plan-limit notice?
170
+ *
171
+ * Cursor does not report its account limit as an error. It sends the sentence
172
+ * "Upgrade your plan to continue" as the model's answer and ends the turn
173
+ * normally. Matched on the WHOLE reply only, so an answer that merely quotes
174
+ * the sentence is left alone.
175
+ */
176
+ function isCursorPlanLimitReply(text) {
177
+ return typeof text === 'string' && normalise(text) === CURSOR_PLAN_LIMIT_TEXT;
178
+ }
179
+ /** Could this partial reply still grow into the plan-limit notice? */
180
+ function mayBecomeCursorPlanLimitReply(text) {
181
+ if (typeof text !== 'string')
182
+ return false;
183
+ const partial = normalise(text);
184
+ return partial.length > 0 && CURSOR_PLAN_LIMIT_TEXT.startsWith(partial);
185
+ }
186
+ exports.CURSOR_PLAN_LIMIT_ERROR = 'Cursor answered "Upgrade your plan to continue" and did no work. This ' +
187
+ 'comes from the Cursor account that is signed in on the runner, not from ' +
188
+ 'Peakflo: its plan does not include the selected model or has reached its ' +
189
+ 'limit. Choose the "Auto" model for this agent, upgrade or change the ' +
190
+ 'Cursor account on the runner machine, or choose another harness.';
191
+ /** `error.message`, plus `error.data.message` when the agent sent one. */
192
+ function describeAcpRpcError(error) {
193
+ const message = typeof error.message === 'string' ? error.message : 'ACP error';
194
+ const detail = isRecord(error.data) && typeof error.data.message === 'string'
195
+ ? error.data.message
196
+ : typeof error.data === 'string'
197
+ ? error.data
198
+ : '';
199
+ return detail && !message.includes(detail)
200
+ ? `${message}: ${detail}`
201
+ : message;
202
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.9-rc.4",
3
+ "version": "0.2.9-rc.5",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",