@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.
@@ -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.4",
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",