@peakflo/agent-harness 0.2.9-rc.3 → 0.2.9-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -4
- 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 +104 -6
- 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/dist/harness.js +8 -4
- package/dist/vendor-backends.d.ts +12 -20
- package/dist/vendor-backends.js +13 -82
- package/package.json +2 -2
|
@@ -46,7 +46,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
46
46
|
exports.ClaudeCodeAdapter = exports.ClaudeSystemSubtype = void 0;
|
|
47
47
|
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
48
48
|
const permission_mode_1 = require("../runtime/permission-mode");
|
|
49
|
-
const vendor_backends_1 = require("../vendor-backends");
|
|
50
49
|
let ClaudeAgentSDK = null;
|
|
51
50
|
/** Module-level cache for the resolved Claude executable path (persists across adapter instances) */
|
|
52
51
|
let resolvedClaudeExecutablePath = null;
|
|
@@ -271,10 +270,7 @@ class ClaudeCodeAdapter {
|
|
|
271
270
|
}
|
|
272
271
|
async loadSDK() {
|
|
273
272
|
try {
|
|
274
|
-
|
|
275
|
-
// ~190 MB to every install), so it is also looked for in the global
|
|
276
|
-
// node_modules. See `vendor-backends.ts`.
|
|
277
|
-
ClaudeAgentSDK = (await (0, vendor_backends_1.importClaudeSdk)());
|
|
273
|
+
ClaudeAgentSDK = await Promise.resolve().then(() => __importStar(require('@anthropic-ai/claude-agent-sdk')));
|
|
278
274
|
console.log('[ClaudeCodeAdapter] SDK loaded successfully');
|
|
279
275
|
}
|
|
280
276
|
catch (error) {
|
|
@@ -706,7 +702,18 @@ class ClaudeCodeAdapter {
|
|
|
706
702
|
// the mode above decorative in exactly the way G1 was.
|
|
707
703
|
...(claudePermissionMode === 'bypassPermissions'
|
|
708
704
|
? { allowDangerouslySkipPermissions: true }
|
|
709
|
-
: {
|
|
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
|
+
}),
|
|
710
717
|
...(secretHooks ? { hooks: secretHooks } : {}),
|
|
711
718
|
};
|
|
712
719
|
// Determine session continuation mode
|
|
@@ -808,6 +815,92 @@ class ClaudeCodeAdapter {
|
|
|
808
815
|
// Start consuming stream
|
|
809
816
|
session.streamTask = this.consumeStream(sessionId, session);
|
|
810
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
|
+
}
|
|
811
904
|
async getStatus(sessionId, _config) {
|
|
812
905
|
const session = this.sessions.get(sessionId);
|
|
813
906
|
if (!session) {
|
|
@@ -891,6 +984,7 @@ class ClaudeCodeAdapter {
|
|
|
891
984
|
session.enqueuePrompt = null;
|
|
892
985
|
session.backgroundTasks.clear();
|
|
893
986
|
session.abortController?.abort();
|
|
987
|
+
this.denyPendingToolApprovals(sessionId, 'The turn was stopped before this action was approved.');
|
|
894
988
|
// Wait for stream to finish cleanup
|
|
895
989
|
if (session.streamTask) {
|
|
896
990
|
console.log(`[ClaudeCodeAdapter] Waiting for stream cleanup...`);
|
|
@@ -916,6 +1010,7 @@ class ClaudeCodeAdapter {
|
|
|
916
1010
|
session.enqueuePrompt = null;
|
|
917
1011
|
session.backgroundTasks.clear();
|
|
918
1012
|
session.abortController?.abort();
|
|
1013
|
+
this.denyPendingToolApprovals(sessionId, 'The session was closed before this action was approved.');
|
|
919
1014
|
// Wait for stream task to complete
|
|
920
1015
|
if (session.streamTask) {
|
|
921
1016
|
try {
|
|
@@ -1225,6 +1320,9 @@ class ClaudeCodeAdapter {
|
|
|
1225
1320
|
}
|
|
1226
1321
|
finally {
|
|
1227
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.');
|
|
1228
1326
|
// Always reset queryIterator when the stream ends (process exited).
|
|
1229
1327
|
// Previously only error paths did this, leaving a truthy-but-exhausted
|
|
1230
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/dist/harness.js
CHANGED
|
@@ -1035,12 +1035,16 @@ class Harness {
|
|
|
1035
1035
|
blocked.set(harness.name, reason);
|
|
1036
1036
|
continue;
|
|
1037
1037
|
}
|
|
1038
|
+
/*
|
|
1039
|
+
* NAME AND VERSION ONLY. The harness's models are already in
|
|
1040
|
+
* `harnesses[]`, which is sent once and then by digest. Repeating them
|
|
1041
|
+
* here put them back into every heartbeat: about 15 KB each time on a
|
|
1042
|
+
* machine with Cursor's 200 models, seen on the first release candidate
|
|
1043
|
+
* that had this.
|
|
1044
|
+
*/
|
|
1038
1045
|
capabilities.backends.push({
|
|
1039
1046
|
name: harness.name,
|
|
1040
|
-
version: harness.version ?? null
|
|
1041
|
-
...(harness.models && harness.models.length > 0
|
|
1042
|
-
? { models: harness.models.map(({ id, name }) => ({ id, name })) }
|
|
1043
|
-
: {})
|
|
1047
|
+
version: harness.version ?? null
|
|
1044
1048
|
});
|
|
1045
1049
|
}
|
|
1046
1050
|
return blocked;
|