@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 +27 -0
- package/dist/adapter-resolver.d.ts +10 -0
- package/dist/adapter-resolver.js +107 -17
- package/dist/adapters/acp-adapter.d.ts +15 -0
- package/dist/adapters/acp-adapter.js +144 -22
- package/dist/adapters/claude-code-adapter.d.ts +25 -0
- package/dist/adapters/claude-code-adapter.js +103 -1
- package/dist/adapters/codex-app-server-adapter.d.ts +24 -0
- package/dist/adapters/codex-app-server-adapter.js +74 -6
- package/dist/adapters/cursor-acp.d.ts +84 -0
- package/dist/adapters/cursor-acp.js +202 -0
- package/package.json +1 -1
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
|
package/dist/adapter-resolver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
|
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
|
-
},
|
|
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
|
|
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
|
-
||
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
926
|
-
|
|
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.
|
|
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",
|