@peakflo/agent-harness 0.2.9-rc.4 → 0.2.9-rc.6
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 +32 -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 +117 -2
- package/dist/adapters/codex-app-server-adapter.d.ts +26 -0
- package/dist/adapters/codex-app-server-adapter.js +129 -55
- package/dist/adapters/codex-approval-text.d.ts +46 -0
- package/dist/adapters/codex-approval-text.js +314 -0
- package/dist/adapters/cursor-acp.d.ts +84 -0
- package/dist/adapters/cursor-acp.js +202 -0
- package/dist/runtime/session-runner.d.ts +29 -0
- package/dist/runtime/session-runner.js +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a person is shown when `codex app-server` asks for an approval.
|
|
4
|
+
*
|
|
5
|
+
* The card used to show the protocol itself: `item/fileChange/requestApproval`
|
|
6
|
+
* and `id: exec-…`, with the file name only in the item above it. This module
|
|
7
|
+
* turns each kind of request the app-server can send into a first line that
|
|
8
|
+
* says what will happen ("Write: src/a.ts", "Run: npm test") and further lines
|
|
9
|
+
* with what helps to decide (where, why, which files).
|
|
10
|
+
*
|
|
11
|
+
* The first line is the title; the control plane's card shows it on its own.
|
|
12
|
+
* No ids: nobody can decide anything from one.
|
|
13
|
+
*
|
|
14
|
+
* Request kinds, from `codex app-server generate-json-schema` (ServerRequest):
|
|
15
|
+
* item/commandExecution/requestApproval a command, or input to a terminal
|
|
16
|
+
* item/fileChange/requestApproval a patch; the files are on the item
|
|
17
|
+
* item/permissions/requestApproval wider sandbox access for the turn
|
|
18
|
+
* mcpServer/elicitation/request an MCP server asks (incl. to run
|
|
19
|
+
* one of its tools)
|
|
20
|
+
* item/tool/requestUserInput the model asks a question
|
|
21
|
+
* execCommandApproval, applyPatchApproval the older forms of the first two
|
|
22
|
+
* Kept out of the adapter, which is a copy of 20x's, so it can be tested alone.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.readFileChanges = readFileChanges;
|
|
26
|
+
exports.describeCodexApproval = describeCodexApproval;
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function text(value) {
|
|
31
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
32
|
+
}
|
|
33
|
+
/** One line, however the value was written. */
|
|
34
|
+
function oneLine(value) {
|
|
35
|
+
return value.replace(/\s*\n\s*/g, ' ⏎ ').trim();
|
|
36
|
+
}
|
|
37
|
+
const MAX_LISTED_PATHS = 12;
|
|
38
|
+
/** `/work/space/src/a.ts` in `/work/space` → `src/a.ts`. Others unchanged. */
|
|
39
|
+
function relativeTo(workspaceDir, path) {
|
|
40
|
+
const root = (workspaceDir ?? '').replace(/\/+$/, '');
|
|
41
|
+
if (!root)
|
|
42
|
+
return path;
|
|
43
|
+
// macOS reports the same directory as /tmp/… and /private/tmp/….
|
|
44
|
+
for (const prefix of [
|
|
45
|
+
root,
|
|
46
|
+
`/private${root}`,
|
|
47
|
+
root.replace(/^\/private/, '')
|
|
48
|
+
]) {
|
|
49
|
+
if (prefix && path.startsWith(`${prefix}/`)) {
|
|
50
|
+
return path.slice(prefix.length + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return path;
|
|
54
|
+
}
|
|
55
|
+
function countDiff(diff) {
|
|
56
|
+
let added = 0;
|
|
57
|
+
let removed = 0;
|
|
58
|
+
for (const line of diff.split('\n')) {
|
|
59
|
+
if (line.startsWith('+++') || line.startsWith('---'))
|
|
60
|
+
continue;
|
|
61
|
+
if (line.startsWith('+'))
|
|
62
|
+
added += 1;
|
|
63
|
+
else if (line.startsWith('-'))
|
|
64
|
+
removed += 1;
|
|
65
|
+
}
|
|
66
|
+
return { added, removed };
|
|
67
|
+
}
|
|
68
|
+
function changeKind(value) {
|
|
69
|
+
if (typeof value === 'string')
|
|
70
|
+
return value;
|
|
71
|
+
if (isRecord(value)) {
|
|
72
|
+
return text(value.type) || Object.keys(value)[0] || 'update';
|
|
73
|
+
}
|
|
74
|
+
return 'update';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The files of a change, from either shape Codex uses:
|
|
78
|
+
* v2 item `changes: [{ path, kind: { type }, diff }]`
|
|
79
|
+
* older request `fileChanges: { [path]: { add | delete | update: {…} } }`
|
|
80
|
+
*/
|
|
81
|
+
function readFileChanges(source) {
|
|
82
|
+
if (Array.isArray(source)) {
|
|
83
|
+
const changes = [];
|
|
84
|
+
for (const entry of source) {
|
|
85
|
+
if (!isRecord(entry) || !text(entry.path))
|
|
86
|
+
continue;
|
|
87
|
+
const diff = text(entry.diff);
|
|
88
|
+
const kind = changeKind(entry.kind);
|
|
89
|
+
// A new file's "diff" is its whole content, without `+` marks.
|
|
90
|
+
const counted = kind === 'add' && diff && !/^[+-]/m.test(diff)
|
|
91
|
+
? { added: diff.split('\n').length, removed: 0 }
|
|
92
|
+
: countDiff(diff);
|
|
93
|
+
changes.push({ path: text(entry.path), kind, ...counted });
|
|
94
|
+
}
|
|
95
|
+
return changes;
|
|
96
|
+
}
|
|
97
|
+
if (isRecord(source)) {
|
|
98
|
+
return Object.entries(source).map(([path, change]) => {
|
|
99
|
+
const kind = changeKind(change);
|
|
100
|
+
const body = isRecord(change) && isRecord(change[kind]) ? change[kind] : {};
|
|
101
|
+
const diff = text(body.unified_diff) || text(body.diff);
|
|
102
|
+
const content = text(body.content);
|
|
103
|
+
return {
|
|
104
|
+
path,
|
|
105
|
+
kind,
|
|
106
|
+
...(diff
|
|
107
|
+
? countDiff(diff)
|
|
108
|
+
: { added: content ? content.split('\n').length : 0, removed: 0 })
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
function verbFor(kind) {
|
|
115
|
+
if (kind === 'add')
|
|
116
|
+
return 'Create';
|
|
117
|
+
if (kind === 'delete')
|
|
118
|
+
return 'Delete';
|
|
119
|
+
return 'Write';
|
|
120
|
+
}
|
|
121
|
+
function changeSummary(change) {
|
|
122
|
+
if (change.kind === 'delete')
|
|
123
|
+
return '';
|
|
124
|
+
const parts = [];
|
|
125
|
+
if (change.added)
|
|
126
|
+
parts.push(`+${change.added}`);
|
|
127
|
+
if (change.removed)
|
|
128
|
+
parts.push(`−${change.removed}`);
|
|
129
|
+
return parts.length ? ` (${parts.join(' ')})` : '';
|
|
130
|
+
}
|
|
131
|
+
function describeFileChanges(changes) {
|
|
132
|
+
if (changes.length === 0)
|
|
133
|
+
return ['Write: files in the workspace'];
|
|
134
|
+
if (changes.length === 1) {
|
|
135
|
+
const [only] = changes;
|
|
136
|
+
return [`${verbFor(only.kind)}: ${only.path}${changeSummary(only)}`];
|
|
137
|
+
}
|
|
138
|
+
const kinds = new Set(changes.map((change) => change.kind));
|
|
139
|
+
const verb = kinds.size === 1 ? verbFor(changes[0].kind) : 'Change';
|
|
140
|
+
const lines = [`${verb}: ${changes.length} files`];
|
|
141
|
+
for (const change of changes.slice(0, MAX_LISTED_PATHS)) {
|
|
142
|
+
// Said once in the first line when every file is the same kind of change.
|
|
143
|
+
const mark = kinds.size === 1
|
|
144
|
+
? ''
|
|
145
|
+
: change.kind === 'add'
|
|
146
|
+
? 'new'
|
|
147
|
+
: change.kind === 'delete'
|
|
148
|
+
? 'deleted'
|
|
149
|
+
: '';
|
|
150
|
+
lines.push(`• ${change.path}${changeSummary(change)}${mark ? ` — ${mark}` : ''}`);
|
|
151
|
+
}
|
|
152
|
+
if (changes.length > MAX_LISTED_PATHS) {
|
|
153
|
+
lines.push(`• and ${changes.length - MAX_LISTED_PATHS} more`);
|
|
154
|
+
}
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* `/bin/zsh -lc 'npm test'` → `npm test`. Codex runs every command through a
|
|
159
|
+
* login shell; the wrapper is the same every time and hides the command. Only
|
|
160
|
+
* the plain one-quoted form is unwrapped, so nothing is ever shown that is not
|
|
161
|
+
* literally what the shell will be given.
|
|
162
|
+
*/
|
|
163
|
+
function unwrapShell(command) {
|
|
164
|
+
const match = /^(?:\/(?:usr\/)?bin\/)?(?:zsh|bash|sh) -l?c (['"])([\s\S]*)\1$/.exec(command.trim());
|
|
165
|
+
if (!match)
|
|
166
|
+
return command;
|
|
167
|
+
const [, quote, inner] = match;
|
|
168
|
+
return inner.includes(quote) || inner.includes('\\') ? command : inner;
|
|
169
|
+
}
|
|
170
|
+
function commandText(value) {
|
|
171
|
+
if (Array.isArray(value)) {
|
|
172
|
+
return value
|
|
173
|
+
.filter((part) => typeof part === 'string')
|
|
174
|
+
.join(' ');
|
|
175
|
+
}
|
|
176
|
+
return text(value);
|
|
177
|
+
}
|
|
178
|
+
function describePermissions(permissions) {
|
|
179
|
+
const lines = [];
|
|
180
|
+
if (!isRecord(permissions))
|
|
181
|
+
return lines;
|
|
182
|
+
if (isRecord(permissions.network) && permissions.network.enabled === true) {
|
|
183
|
+
lines.push('• network access');
|
|
184
|
+
}
|
|
185
|
+
if (isRecord(permissions.fileSystem)) {
|
|
186
|
+
const fileSystem = permissions.fileSystem;
|
|
187
|
+
const list = (key, label) => {
|
|
188
|
+
const paths = Array.isArray(fileSystem[key])
|
|
189
|
+
? fileSystem[key].filter((path) => typeof path === 'string')
|
|
190
|
+
: [];
|
|
191
|
+
for (const path of paths)
|
|
192
|
+
lines.push(`• ${label} ${path}`);
|
|
193
|
+
};
|
|
194
|
+
list('write', 'write to');
|
|
195
|
+
list('read', 'read');
|
|
196
|
+
if (Array.isArray(fileSystem.entries)) {
|
|
197
|
+
for (const entry of fileSystem.entries) {
|
|
198
|
+
if (!isRecord(entry))
|
|
199
|
+
continue;
|
|
200
|
+
const path = isRecord(entry.path)
|
|
201
|
+
? text(entry.path.path) || JSON.stringify(entry.path)
|
|
202
|
+
: text(entry.path);
|
|
203
|
+
const access = text(entry.access) || 'access';
|
|
204
|
+
if (path)
|
|
205
|
+
lines.push(`• ${access} ${path}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
211
|
+
/** `item/somethingNew/requestApproval` → "something new". */
|
|
212
|
+
function humanise(method) {
|
|
213
|
+
const words = method
|
|
214
|
+
.replace(/^item\//, '')
|
|
215
|
+
.replace(/\/?(requestApproval|request)$/i, '')
|
|
216
|
+
.replace(/Approval$/i, '')
|
|
217
|
+
.replace(/[/_]/g, ' ')
|
|
218
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
219
|
+
.trim()
|
|
220
|
+
.toLowerCase();
|
|
221
|
+
return words || 'an action';
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The text of one approval request.
|
|
225
|
+
*
|
|
226
|
+
* @param item the thread item the request is about, when the adapter has seen
|
|
227
|
+
* it (`item/started` arrives before the request). A file change's paths are
|
|
228
|
+
* only there.
|
|
229
|
+
*/
|
|
230
|
+
function describeCodexApproval(method, params, item,
|
|
231
|
+
/** The session's workspace; paths inside it are shown relative to it. */
|
|
232
|
+
workspaceDir) {
|
|
233
|
+
const reason = text(params.reason);
|
|
234
|
+
const lines = [];
|
|
235
|
+
const shown = (path) => relativeTo(workspaceDir, path);
|
|
236
|
+
if (method === 'item/commandExecution/requestApproval' ||
|
|
237
|
+
method === 'execCommandApproval') {
|
|
238
|
+
const command = unwrapShell(commandText(params.command) || commandText(item?.command) || '');
|
|
239
|
+
const network = isRecord(params.networkApprovalContext)
|
|
240
|
+
? params.networkApprovalContext
|
|
241
|
+
: null;
|
|
242
|
+
if (params.kind === 'writeStdin') {
|
|
243
|
+
lines.push(`Send input to a running command: ${oneLine(command)}`);
|
|
244
|
+
}
|
|
245
|
+
else if (command) {
|
|
246
|
+
lines.push(`Run: ${oneLine(command)}`);
|
|
247
|
+
}
|
|
248
|
+
else if (network) {
|
|
249
|
+
lines.push(`Network access: ${text(network.host)}`);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
lines.push('Run: a command');
|
|
253
|
+
}
|
|
254
|
+
const cwd = text(params.cwd) || text(item?.cwd);
|
|
255
|
+
// The workspace itself goes without saying; anywhere else is worth a line.
|
|
256
|
+
const where = cwd
|
|
257
|
+
? shown(`${cwd.replace(/\/+$/, '')}/.`).replace(/\/?\.$/, '')
|
|
258
|
+
: '';
|
|
259
|
+
if (where)
|
|
260
|
+
lines.push(`In: ${where}`);
|
|
261
|
+
if (network && command) {
|
|
262
|
+
lines.push(`Network access: ${text(network.host)}${text(network.protocol) ? ` (${text(network.protocol)})` : ''}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else if (method === 'item/fileChange/requestApproval' ||
|
|
266
|
+
method === 'applyPatchApproval') {
|
|
267
|
+
lines.push(...describeFileChanges(readFileChanges(params.fileChanges ?? item?.changes).map((change) => ({
|
|
268
|
+
...change,
|
|
269
|
+
path: shown(change.path)
|
|
270
|
+
}))));
|
|
271
|
+
const root = text(params.grantRoot);
|
|
272
|
+
if (root)
|
|
273
|
+
lines.push(`Also asks to write under: ${root}`);
|
|
274
|
+
}
|
|
275
|
+
else if (method === 'item/permissions/requestApproval') {
|
|
276
|
+
const wanted = describePermissions(params.permissions);
|
|
277
|
+
lines.push('Grant more access for this turn');
|
|
278
|
+
lines.push(...(wanted.length ? wanted : ['• wider sandbox access']));
|
|
279
|
+
const cwd = text(params.cwd);
|
|
280
|
+
if (cwd)
|
|
281
|
+
lines.push(`In: ${cwd}`);
|
|
282
|
+
}
|
|
283
|
+
else if (method === 'mcpServer/elicitation/request') {
|
|
284
|
+
const server = text(params.serverName) || 'An MCP server';
|
|
285
|
+
const message = text(params.message);
|
|
286
|
+
lines.push(message
|
|
287
|
+
? `${server} asks: ${oneLine(message)}`
|
|
288
|
+
: `${server} asks to continue`);
|
|
289
|
+
const url = text(params.url);
|
|
290
|
+
if (url)
|
|
291
|
+
lines.push(`Opens: ${url}`);
|
|
292
|
+
}
|
|
293
|
+
else if (method === 'item/tool/requestUserInput') {
|
|
294
|
+
const first = Array.isArray(params.questions) ? params.questions[0] : null;
|
|
295
|
+
const question = isRecord(first)
|
|
296
|
+
? text(first.question) || text(first.header)
|
|
297
|
+
: '';
|
|
298
|
+
lines.push(question ? `Codex asks: ${oneLine(question)}` : 'Codex asks a question');
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// A kind this build does not know. Say what is asked in plain words and
|
|
302
|
+
// show what the request itself says about it, never the method name alone.
|
|
303
|
+
const command = commandText(params.command);
|
|
304
|
+
lines.push(`Codex asks to approve: ${humanise(method)}`);
|
|
305
|
+
if (command)
|
|
306
|
+
lines.push(`Command: ${oneLine(command)}`);
|
|
307
|
+
const cwd = text(params.cwd);
|
|
308
|
+
if (cwd)
|
|
309
|
+
lines.push(`In: ${cwd}`);
|
|
310
|
+
}
|
|
311
|
+
if (reason)
|
|
312
|
+
lines.push(`Reason: ${oneLine(reason)}`);
|
|
313
|
+
return lines.join('\n');
|
|
314
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -277,6 +277,35 @@ export declare function decodeQuestionAnswers(answer: string | undefined): Recor
|
|
|
277
277
|
* Codex's "Allow" read as a denial.
|
|
278
278
|
*/
|
|
279
279
|
export declare function isApprovingOptionId(optionId: string): boolean;
|
|
280
|
+
/**
|
|
281
|
+
* Decide what a user's answer actually means, and fail closed when it is not
|
|
282
|
+
* clear.
|
|
283
|
+
*
|
|
284
|
+
* THREE PATHS REACH HERE AND THEY DO NOT CARRY THE SAME INFORMATION:
|
|
285
|
+
*
|
|
286
|
+
* 1. An option BUTTON in the question card. Sends `optionId`. Unambiguous,
|
|
287
|
+
* and this is the ordinary case.
|
|
288
|
+
* 2. The card's Reject button. Sends no `optionId` and the fixed text
|
|
289
|
+
* "Rejected by the user."
|
|
290
|
+
* 3. The COMPOSER. When a question is open, typing a message posts it as an
|
|
291
|
+
* answer instead (composer.tsx:76-106) — with no `optionId` at all.
|
|
292
|
+
*
|
|
293
|
+
* So free text has to be interpreted, and the rule is: interpret it only when
|
|
294
|
+
* it plainly matches something the backend offered. Anything else DENIES, and
|
|
295
|
+
* says so in the transcript rather than silently doing the safer thing without
|
|
296
|
+
* telling the user why they are being asked again.
|
|
297
|
+
*/
|
|
298
|
+
export declare function resolveApproval(command: {
|
|
299
|
+
answer?: string;
|
|
300
|
+
optionId?: string;
|
|
301
|
+
}, open: {
|
|
302
|
+
knownOptionIds: Set<string>;
|
|
303
|
+
optionIdByLabel: Map<string, string>;
|
|
304
|
+
}): {
|
|
305
|
+
approved: boolean;
|
|
306
|
+
optionId?: string;
|
|
307
|
+
transcriptText: string;
|
|
308
|
+
};
|
|
280
309
|
export interface McpCheckState {
|
|
281
310
|
checked: Map<string, number>;
|
|
282
311
|
failed: Map<string, FailedMcpCheck>;
|
|
@@ -29,6 +29,7 @@ exports.modelGatewayRefusal = modelGatewayRefusal;
|
|
|
29
29
|
exports.permissionFingerprint = permissionFingerprint;
|
|
30
30
|
exports.decodeQuestionAnswers = decodeQuestionAnswers;
|
|
31
31
|
exports.isApprovingOptionId = isApprovingOptionId;
|
|
32
|
+
exports.resolveApproval = resolveApproval;
|
|
32
33
|
const node_crypto_1 = require("node:crypto");
|
|
33
34
|
const model_gateway_1 = require("../adapters/model-gateway");
|
|
34
35
|
const contract_1 = require("../contract");
|
|
@@ -1869,7 +1870,11 @@ class SessionRunner {
|
|
|
1869
1870
|
question: approval.question,
|
|
1870
1871
|
options: approval.options.map((option) => ({
|
|
1871
1872
|
id: option.optionId,
|
|
1872
|
-
label: option.name
|
|
1873
|
+
label: option.name,
|
|
1874
|
+
// Whether this answer says yes, decided HERE by the same rule
|
|
1875
|
+
// that will read the answer back. The card colours its buttons
|
|
1876
|
+
// from this and never from the words on them.
|
|
1877
|
+
kind: isApprovingOptionId(option.optionId) ? 'allow' : 'reject'
|
|
1873
1878
|
}))
|
|
1874
1879
|
}
|
|
1875
1880
|
]
|
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.6",
|
|
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",
|