@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.
@@ -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
- // CHANGED from 20x: the SDK is not bundled with the runner (it would add
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.permissionMode === 'allow' ? 'never' : 'on-request',
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.permissionMode === 'allow' ? 'never' : 'on-request',
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.permissionMode === 'allow' ? 'never' : 'on-request',
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
- const rawDecisions = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
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 item of items) {
926
- this.bufferReconciledThreadItem(session, item, isObject(item) ? asString(item.turnId) : undefined);
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;