@peakflo/agent-harness 0.2.9-rc.3 → 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
@@ -57,10 +57,12 @@ cloud sandbox runs Pi only.
57
57
  through the Peakflo model gateway and are not metered by Peakflo.
58
58
  - When you sign out of a harness, or remove it, the runner stops taking work
59
59
  for it after its next check.
60
- - Claude Code also needs the Claude Agent SDK, which is not installed with the
61
- runner: `npm install -g @anthropic-ai/claude-agent-sdk`. Without it Peakflo
62
- shows Claude Code as "Detected, not runnable by this runner" with this
63
- instruction.
60
+ - What the runner brings and what it expects, the same split as the 20x app:
61
+ it brings the Claude Agent SDK (a dependency of this package, installed with
62
+ it); it expects the tools themselves — `codex`, `claude`, `cursor-agent` —
63
+ to be installed and signed in on the machine. The SDK includes a copy of the
64
+ Claude binary for your platform, which is most of this package's install
65
+ size (about 260 MB installed).
64
66
  - Each turn starts the harness's own program and stops it when the turn ends.
65
67
  The conversation continues from the session the harness saved on this
66
68
  machine.
@@ -96,6 +98,33 @@ about what a third-party program prints.
96
98
  `PF_HARNESS_DISCOVERY=0`. The runner then starts none of these programs and
97
99
  reports no list. Running turns with Pi is not affected.
98
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
+
99
128
  ## Troubleshooting
100
129
 
101
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