@peakflo/agent-harness 0.2.9-rc.5 → 0.2.9-rc.7

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 CHANGED
@@ -121,6 +121,16 @@ The runner never answers a request with "always allow", and does not offer
121
121
  that answer to the person approving: Codex and Cursor would save it as a
122
122
  standing rule in their own configuration.
123
123
 
124
+ An approval request says what will happen in its first line ("Run: npm test",
125
+ "Write: src/a.ts", "Change: 3 files" with the list), for every kind of request
126
+ Codex can send. "Deny" refuses the action and lets the turn go on, so the agent
127
+ says that it was not allowed; the denied action never runs.
128
+
129
+ A command too long for the card keeps its start and its end, and the card
130
+ says it is not shown in full. A question Codex asks the person is shown as a
131
+ question to answer in words, not as Allow / Deny. An MCP form that needs values
132
+ is declined, because nobody can fill it in from the chat.
133
+
124
134
  Cursor reports an account limit as a normal reply ("Upgrade your plan to
125
135
  continue"). The runner turns a reply that is only that sentence into a failed
126
136
  turn that says it comes from the Cursor account on the runner.
@@ -1284,7 +1284,20 @@ class ClaudeCodeAdapter {
1284
1284
  const errName = error instanceof Error ? error.name : '';
1285
1285
  const errMsg = error instanceof Error ? error.message : String(error);
1286
1286
  const errStack = error instanceof Error ? error.stack : undefined;
1287
- if (errName === 'AbortError' || errMsg.includes('aborted by user')) {
1287
+ // CHANGED from 20x: the runner stops the session at the end of EVERY
1288
+ // turn (one process per turn), and the SDK reports that stop as a plain
1289
+ // `Error: Operation aborted`. That is the expected end of a finished
1290
+ // turn, not a failure: it used to be logged as "Stream error" with a
1291
+ // stack trace after every successful turn, and marked the session failed.
1292
+ // Both conditions: the adapter asked for the stop AND the error is the
1293
+ // SDK's own word for a stopped process. An unrelated failure that
1294
+ // happens to arrive after the stop is still reported below.
1295
+ const stoppedByUs = session.abortController?.signal.aborted === true &&
1296
+ /\babort(ed)?\b/i.test(errMsg);
1297
+ if (stoppedByUs) {
1298
+ console.log('[ClaudeCodeAdapter] Stream stopped at the end of the turn');
1299
+ }
1300
+ else if (errName === 'AbortError' || errMsg.includes('aborted by user')) {
1288
1301
  console.log('[ClaudeCodeAdapter] Stream aborted by user');
1289
1302
  // Don't set error status - this is normal when sending a new message
1290
1303
  }
@@ -29,6 +29,11 @@ export declare function isStandingApprovalDecision(optionId: string): boolean;
29
29
  * any file change — is sent to the client as an approval request before it
30
30
  * runs. That is what "Ask before each action" says.
31
31
  */
32
+ /**
33
+ * The fields an MCP form asks the person to fill in. Empty for a form with no
34
+ * fields, and for a request that is not a form (`mode: 'url'`).
35
+ */
36
+ export declare function elicitationFormFields(params: Record<string, unknown>): string[];
32
37
  export declare function codexApprovalPolicy(config: Pick<SessionConfig, 'permissionMode'>): 'never' | 'untrusted';
33
38
  interface PendingApproval {
34
39
  requestId: string | number;
@@ -40,6 +45,8 @@ interface PendingApproval {
40
45
  kind: string;
41
46
  }>;
42
47
  responseKind: 'execCommand' | 'commandExecution' | 'fileChange' | 'permissions' | 'elicitation' | 'userInput' | 'generic';
48
+ /** What `item/permissions/requestApproval` asked for; echoed back on allow. */
49
+ requestedPermissions?: unknown;
43
50
  }
44
51
  export declare class CodexAppServerAdapter implements CodingAgentAdapter {
45
52
  private sessions;
@@ -87,6 +94,19 @@ export declare class CodexAppServerAdapter implements CodingAgentAdapter {
87
94
  private setupStdoutParser;
88
95
  private handleRpcMessage;
89
96
  private handleServerRequest;
97
+ /**
98
+ * ADDED, not in 20x: `item/tool/requestUserInput` IS A QUESTION.
99
+ *
100
+ * It was shown as a permission card, and "Allow" sent the word "accept" to
101
+ * the model as the person's answer. It is now a question part, like the
102
+ * ones Pi asks: the person picks an option or types, and the answer goes
103
+ * back through `respondToQuestion` in the form this request takes
104
+ * (`{ answers: { <question id>: { answers: [text] } } }`). It is asked in
105
+ * every permission mode, bypass included: nobody can answer it but a person.
106
+ */
107
+ private handleUserInputRequest;
108
+ /** The answer to a question asked with `item/tool/requestUserInput`. */
109
+ respondToQuestion(sessionId: string, answers: Record<string, string>, _config?: SessionConfig, requestId?: string): Promise<boolean>;
90
110
  private handleNotification;
91
111
  private handleApprovalRequest;
92
112
  private buildApprovalResponse;
@@ -43,6 +43,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.CodexAppServerAdapter = void 0;
44
44
  exports.unwrapListedThreadItem = unwrapListedThreadItem;
45
45
  exports.isStandingApprovalDecision = isStandingApprovalDecision;
46
+ exports.elicitationFormFields = elicitationFormFields;
46
47
  exports.codexApprovalPolicy = codexApprovalPolicy;
47
48
  /**
48
49
  * Extracted verbatim from 20x `src/main/adapters/codex-app-server-adapter.ts`.
@@ -57,6 +58,7 @@ const os_1 = require("os");
57
58
  const path_1 = require("path");
58
59
  const util_1 = require("util");
59
60
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
61
+ const codex_approval_text_1 = require("./codex-approval-text");
60
62
  const DEFAULT_CODEX_APP_SERVER_MODEL = 'gpt-5.5';
61
63
  /**
62
64
  * `thread/items/list` returns each item inside an envelope:
@@ -105,6 +107,26 @@ function isStandingApprovalDecision(optionId) {
105
107
  * any file change — is sent to the client as an approval request before it
106
108
  * runs. That is what "Ask before each action" says.
107
109
  */
110
+ /**
111
+ * The fields an MCP form asks the person to fill in. Empty for a form with no
112
+ * fields, and for a request that is not a form (`mode: 'url'`).
113
+ */
114
+ function elicitationFormFields(params) {
115
+ // Only a request that says it is NOT a form is taken at its word. A missing
116
+ // or unknown mode is read from the schema it carries, like a form.
117
+ if (asString(params.mode) === 'url')
118
+ return [];
119
+ const schema = params.requestedSchema;
120
+ if (!isObject(schema)) {
121
+ // A form whose shape this build cannot read is treated as having fields.
122
+ return schema === undefined || schema === null ? [] : ['an unreadable form'];
123
+ }
124
+ const properties = isObject(schema.properties) ? Object.keys(schema.properties) : [];
125
+ const required = Array.isArray(schema.required)
126
+ ? schema.required.filter((name) => typeof name === 'string')
127
+ : [];
128
+ return [...new Set([...required, ...properties])];
129
+ }
108
130
  function codexApprovalPolicy(config) {
109
131
  // `'allow'` is what `mapPermissionMode` makes of the agent's
110
132
  // `bypassPermissions` ("Don't ask for permission"), and of nothing else.
@@ -277,12 +299,6 @@ function isSubpath(parent, child) {
277
299
  const rel = (0, path_1.relative)((0, path_1.resolve)(parent), (0, path_1.resolve)(child));
278
300
  return rel === '' || (!!rel && !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel));
279
301
  }
280
- function summarizeApproval(params, fallback) {
281
- const command = asString(params.command);
282
- const reason = asString(params.reason);
283
- const itemId = asString(params.itemId) || asString(params.callId);
284
- return [command || fallback, reason, itemId ? `id: ${itemId}` : ''].filter(Boolean).join('\n');
285
- }
286
302
  function normalizeDecisionName(decision) {
287
303
  if (typeof decision === 'string')
288
304
  return decision;
@@ -291,24 +307,6 @@ function normalizeDecisionName(decision) {
291
307
  }
292
308
  return 'accept';
293
309
  }
294
- function decisionLabel(decision) {
295
- switch (decision) {
296
- case 'accept':
297
- case 'approved':
298
- return 'Allow';
299
- case 'acceptForSession':
300
- case 'approved_for_session':
301
- return 'Allow for Session';
302
- case 'decline':
303
- case 'denied':
304
- return 'Deny';
305
- case 'cancel':
306
- case 'abort':
307
- return 'Deny and Stop';
308
- default:
309
- return decision;
310
- }
311
- }
312
310
  class CodexAppServerAdapter {
313
311
  sessions = new Map();
314
312
  codexExecutablePath = null;
@@ -540,14 +538,24 @@ class CodexAppServerAdapter {
540
538
  if (!approval)
541
539
  return;
542
540
  const selected = optionId || approval.options.find((option) => approved
543
- ? ['acceptForSession', 'accept', 'approved_for_session', 'approved'].includes(option.optionId)
544
- : ['cancel', 'abort', 'decline', 'denied'].includes(option.optionId))?.optionId || (approved ? 'accept' : 'cancel');
545
- const response = this.buildApprovalResponse(approval.responseKind, selected, approved);
541
+ ? ['accept', 'approved'].includes(option.optionId)
542
+ : ['decline', 'denied'].includes(option.optionId))?.optionId || (
543
+ // The older requests take `approved` / `denied`.
544
+ approval.responseKind === 'execCommand'
545
+ ? (approved ? 'approved' : 'denied')
546
+ : (approved ? 'accept' : 'decline'));
547
+ const response = this.buildApprovalResponse(approval.responseKind, selected, approved, approval.requestedPermissions);
546
548
  this.sendRpcResponse(session, approval.requestId, response);
547
549
  session.pendingApproval = null;
548
- if (!approved) {
549
- session.status = coding_agent_adapter_1.SessionStatusType.IDLE;
550
- }
550
+ // CHANGED from 20x, which marked the session idle on every denial. A
551
+ // denial that continues the turn (`decline`, `denied`) is followed by the
552
+ // model's own answer; calling the session idle here ended the turn before
553
+ // that answer arrived. Only a denial that stops the turn is idle at once.
554
+ // (No button sends `cancel` or `abort` any more; a caller that names one
555
+ // explicitly still gets the stop it asked for.)
556
+ session.status = !approved && (selected === 'cancel' || selected === 'abort')
557
+ ? coding_agent_adapter_1.SessionStatusType.IDLE
558
+ : coding_agent_adapter_1.SessionStatusType.BUSY;
551
559
  }
552
560
  async getRunningTools(sessionId, _config) {
553
561
  return Array.from(this.sessions.get(sessionId)?.runningTools.values() || []);
@@ -816,12 +824,113 @@ class CodexAppServerAdapter {
816
824
  this.handleApprovalRequest(session, request, params);
817
825
  return;
818
826
  }
819
- if (request.method === 'mcpServer/elicitation/request' || request.method === 'item/tool/requestUserInput') {
827
+ if (request.method === 'item/tool/requestUserInput') {
828
+ this.handleUserInputRequest(session, request, params);
829
+ return;
830
+ }
831
+ if (request.method === 'mcpServer/elicitation/request') {
832
+ /*
833
+ * CHANGED from 20x: A FORM THAT NEEDS VALUES IS DECLINED, NOT "ALLOWED".
834
+ *
835
+ * An elicitation in form mode asks the person to fill in fields. This
836
+ * runner can relay a yes or a no, not field values, and "Allow" answered
837
+ * with an empty form. A form with fields is therefore declined at once,
838
+ * in every permission mode: the MCP server is told no, rather than
839
+ * being handed values nobody entered. A form with no fields is a plain
840
+ * confirmation (how an MCP tool call asks to run) and is still put to
841
+ * the person.
842
+ */
843
+ const fields = elicitationFormFields(params);
844
+ if (fields.length > 0) {
845
+ console.warn(`[CodexAppServerAdapter] Declining an MCP form from ${asString(params.serverName) || 'a server'}: ` +
846
+ `it asks for ${fields.join(', ')}, which cannot be entered from here`);
847
+ this.sendRpcResponse(session, request.id, { action: 'decline' });
848
+ return;
849
+ }
820
850
  this.handleApprovalRequest(session, request, params);
821
851
  return;
822
852
  }
823
853
  this.sendRpcResponse(session, request.id, {});
824
854
  }
855
+ /**
856
+ * ADDED, not in 20x: `item/tool/requestUserInput` IS A QUESTION.
857
+ *
858
+ * It was shown as a permission card, and "Allow" sent the word "accept" to
859
+ * the model as the person's answer. It is now a question part, like the
860
+ * ones Pi asks: the person picks an option or types, and the answer goes
861
+ * back through `respondToQuestion` in the form this request takes
862
+ * (`{ answers: { <question id>: { answers: [text] } } }`). It is asked in
863
+ * every permission mode, bypass included: nobody can answer it but a person.
864
+ */
865
+ handleUserInputRequest(session, request, params) {
866
+ const asked = (Array.isArray(params.questions) ? params.questions : []).filter(isObject);
867
+ /*
868
+ * A SECRET IS NOT ASKED FOR IN THE CHAT. An answer typed here is stored in
869
+ * the transcript and shown to everyone who can read the chat; a question
870
+ * marked `isSecret` asks for something that must not be. The whole request
871
+ * is left unanswered instead.
872
+ */
873
+ if (asked.some((question) => question.isSecret === true)) {
874
+ console.warn('[CodexAppServerAdapter] Not asking a question marked secret; it is left unanswered');
875
+ this.sendRpcResponse(session, request.id, { answers: {} });
876
+ return;
877
+ }
878
+ // The card sends each answer back under its header, so headers are made
879
+ // unique here: two questions called the same would get one answer.
880
+ const usedHeaders = new Map();
881
+ const uniqueHeader = (wanted) => {
882
+ const count = (usedHeaders.get(wanted) ?? 0) + 1;
883
+ usedHeaders.set(wanted, count);
884
+ return count === 1 ? wanted : `${wanted} (${count})`;
885
+ };
886
+ const questions = asked
887
+ .map((question, index) => ({
888
+ id: asString(question.id) || `q${index + 1}`,
889
+ header: uniqueHeader(asString(question.header) || `Question ${index + 1}`),
890
+ question: asString(question.question) || '',
891
+ options: (Array.isArray(question.options) ? question.options : [])
892
+ .filter(isObject)
893
+ .map((option) => ({
894
+ label: asString(option.label) || '',
895
+ description: asString(option.description) || ''
896
+ }))
897
+ .filter((option) => option.label)
898
+ }));
899
+ if (questions.length === 0) {
900
+ // Nothing to ask. An empty answer set, never an invented one.
901
+ this.sendRpcResponse(session, request.id, { answers: {} });
902
+ return;
903
+ }
904
+ // An earlier question that was never answered gets no answer.
905
+ if (session.pendingQuestion) {
906
+ this.sendRpcResponse(session, session.pendingQuestion.requestId, { answers: {} });
907
+ }
908
+ const partId = `codex-question-${asString(params.itemId) || String(request.id)}`;
909
+ session.pendingQuestion = { requestId: request.id, partId, questions };
910
+ session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
911
+ this.addEvent(session, { method: 'pf/question', params: { partId, questions } });
912
+ }
913
+ /** The answer to a question asked with `item/tool/requestUserInput`. */
914
+ async respondToQuestion(sessionId, answers, _config, requestId) {
915
+ const session = this.sessions.get(sessionId);
916
+ const pending = session?.pendingQuestion;
917
+ if (!session || !pending)
918
+ return false;
919
+ if (requestId && requestId !== pending.partId)
920
+ return false;
921
+ // The card sends `Header: answer` per question, or one bare answer.
922
+ const result = {};
923
+ for (const question of pending.questions) {
924
+ const answer = (answers[question.header] ??
925
+ (pending.questions.length === 1 ? answers.answer : undefined) ??
926
+ '').trim();
927
+ result[question.id] = { answers: answer ? [answer] : [] };
928
+ }
929
+ this.sendRpcResponse(session, pending.requestId, { answers: result });
930
+ session.pendingQuestion = null;
931
+ session.status = coding_agent_adapter_1.SessionStatusType.BUSY;
932
+ return true;
933
+ }
825
934
  handleNotification(session, notification) {
826
935
  const params = isObject(notification.params) ? notification.params : {};
827
936
  if (notification.method === 'thread/status/changed') {
@@ -835,6 +944,9 @@ class CodexAppServerAdapter {
835
944
  }
836
945
  if (notification.method === 'turn/completed') {
837
946
  session.activeTurnId = null;
947
+ // The turn is over, so a question it asked can no longer be answered:
948
+ // the request is gone on Codex's side. A late answer finds nothing.
949
+ session.pendingQuestion = null;
838
950
  if (session.threadId) {
839
951
  session.pendingCompletionRefreshes += 1;
840
952
  void this.reconcileCompletedTurn(session);
@@ -866,6 +978,13 @@ class CodexAppServerAdapter {
866
978
  }
867
979
  if (notification.method === 'serverRequest/resolved') {
868
980
  const requestId = params.requestId;
981
+ // Codex settled the question itself (the turn was interrupted).
982
+ if (session.pendingQuestion && String(session.pendingQuestion.requestId) === String(requestId)) {
983
+ session.pendingQuestion = null;
984
+ if (session.status === coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL) {
985
+ session.status = coding_agent_adapter_1.SessionStatusType.BUSY;
986
+ }
987
+ }
869
988
  if (session.pendingApproval && String(session.pendingApproval.requestId) === String(requestId)) {
870
989
  session.pendingApproval = null;
871
990
  if (session.status === coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL) {
@@ -879,7 +998,7 @@ class CodexAppServerAdapter {
879
998
  if (session.config.permissionMode === 'allow') {
880
999
  const responseKind = this.getApprovalResponseKind(request.method);
881
1000
  const selected = responseKind === 'execCommand' ? 'approved' : 'accept';
882
- this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true));
1001
+ this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true, params.permissions));
883
1002
  return;
884
1003
  }
885
1004
  const toolCallId = asString(params.approvalId) || asString(params.itemId) || asString(params.callId) || String(request.id);
@@ -889,44 +1008,63 @@ class CodexAppServerAdapter {
889
1008
  // the amendment is saved into the Codex config on the runner. Offer only
890
1009
  // the decisions known to answer this one request; the rest are logged.
891
1010
  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);
1011
+ const withheld = offered.map(normalizeDecisionName).filter(isStandingApprovalDecision);
1012
+ if (withheld.length > 0) {
896
1013
  console.log(`[CodexAppServerAdapter] Not offering approval decisions: ${withheld.join(', ')}`);
897
1014
  }
898
- const approvalOptions = rawDecisions.length > 0
899
- ? rawDecisions.map((decision) => {
900
- const optionId = normalizeDecisionName(decision);
901
- return {
902
- optionId,
903
- name: decisionLabel(optionId),
904
- kind: optionId.includes('accept') || optionId === 'approved' ? 'allow' : 'reject'
905
- };
906
- })
1015
+ /*
1016
+ * CHANGED from 20x: ONE "Allow" AND ONE "Deny", AND DENY LETS THE TURN GO ON.
1017
+ *
1018
+ * Codex has two denials. `cancel` ("abort" in the older form) also
1019
+ * interrupts the turn: the chat went idle with no word from the agent, as
1020
+ * if nothing had been asked. `decline` ("denied") refuses the action and
1021
+ * lets the model carry on, so it can say that it was not allowed and what
1022
+ * it will do instead. Both are in the protocol for every request that has
1023
+ * a decision, whether or not `availableDecisions` lists them, so "Deny" is
1024
+ * always the one that continues.
1025
+ */
1026
+ const legacy = responseKind === 'execCommand';
1027
+ const approvalOptions = legacy
1028
+ ? [
1029
+ { optionId: 'approved', name: 'Allow', kind: 'allow' },
1030
+ { optionId: 'denied', name: 'Deny', kind: 'reject' }
1031
+ ]
907
1032
  : [
908
1033
  { optionId: 'accept', name: 'Allow', kind: 'allow' },
909
- { optionId: 'cancel', name: 'Deny', kind: 'reject' }
1034
+ { optionId: 'decline', name: 'Deny', kind: 'reject' }
910
1035
  ];
1036
+ // The item the request is about, when it has been announced: a file
1037
+ // change's paths are on the item, not on the request.
1038
+ const itemId = asString(params.itemId) || asString(params.callId);
1039
+ const item = itemId ? session.runningTools.get(`tool-${itemId}`)?.input : undefined;
911
1040
  session.pendingApproval = {
912
1041
  requestId: request.id,
913
1042
  toolCallId,
914
- question: summarizeApproval(params, request.method),
1043
+ question: (0, codex_approval_text_1.describeCodexApproval)(request.method, params, item, session.config.workspaceDir),
915
1044
  options: approvalOptions,
916
- responseKind
1045
+ responseKind,
1046
+ ...(responseKind === 'permissions' ? { requestedPermissions: params.permissions } : {})
917
1047
  };
918
1048
  session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
919
1049
  this.onDataAvailable?.(session.threadId || session.sessionId);
920
1050
  }
921
- buildApprovalResponse(responseKind, selected, approved) {
1051
+ buildApprovalResponse(responseKind, selected, approved, requestedPermissions) {
922
1052
  switch (responseKind) {
923
1053
  case 'execCommand':
924
- return { decision: approved ? (selected === 'approved_for_session' ? 'approved_for_session' : 'approved') : (selected === 'denied' ? 'denied' : 'abort') };
1054
+ // The older requests (`execCommandApproval`, `applyPatchApproval`)
1055
+ // take a ReviewDecision. `denied` continues the turn; `abort` stops it.
1056
+ return { decision: approved ? 'approved' : (selected === 'abort' ? 'abort' : 'denied') };
925
1057
  case 'commandExecution':
926
- return { decision: selected };
927
1058
  case 'fileChange':
1059
+ return { decision: approved ? 'accept' : (selected === 'cancel' ? 'cancel' : 'decline') };
928
1060
  case 'permissions':
929
- return { decision: selected };
1061
+ // CHANGED from 20x, which answered `{ decision }`. This request has no
1062
+ // decision: the answer is the permissions granted, for this turn only.
1063
+ // Granting nothing is the denial.
1064
+ return {
1065
+ permissions: approved && isObject(requestedPermissions) ? requestedPermissions : {},
1066
+ scope: 'turn'
1067
+ };
930
1068
  case 'elicitation':
931
1069
  return approved
932
1070
  ? { action: 'accept', content: {} }
@@ -936,11 +1074,11 @@ class CodexAppServerAdapter {
936
1074
  ? { response: selected }
937
1075
  : { response: null };
938
1076
  default:
939
- return { decision: approved ? selected : 'cancel' };
1077
+ return { decision: approved ? 'accept' : 'decline' };
940
1078
  }
941
1079
  }
942
1080
  getApprovalResponseKind(method) {
943
- if (method === 'execCommandApproval')
1081
+ if (method === 'execCommandApproval' || method === 'applyPatchApproval')
944
1082
  return 'execCommand';
945
1083
  if (method.includes('commandExecution'))
946
1084
  return 'commandExecution';
@@ -1178,6 +1316,29 @@ class CodexAppServerAdapter {
1178
1316
  if ((method === 'item/started' || method === 'item/completed') && item) {
1179
1317
  return this.convertThreadItem(method, item, params, seenMessageIds, seenPartIds, partContentLengths, session);
1180
1318
  }
1319
+ if (method === 'pf/question') {
1320
+ const partId = asString(params.partId) || 'codex-question';
1321
+ if (seenPartIds.has(partId))
1322
+ return [];
1323
+ seenPartIds.add(partId);
1324
+ const questions = Array.isArray(params.questions) ? params.questions.filter(isObject) : [];
1325
+ return [{
1326
+ id: partId,
1327
+ type: coding_agent_adapter_1.MessagePartType.QUESTION,
1328
+ role: coding_agent_adapter_1.MessageRole.ASSISTANT,
1329
+ text: questions.map((question) => asString(question.question) || '').join('\n'),
1330
+ tool: {
1331
+ name: 'question',
1332
+ status: 'running',
1333
+ title: asString(questions[0]?.header) || 'Question',
1334
+ questions: questions.map((question) => ({
1335
+ header: question.header,
1336
+ question: question.question,
1337
+ options: question.options
1338
+ }))
1339
+ }
1340
+ }];
1341
+ }
1181
1342
  if (method === 'error') {
1182
1343
  const partId = `error-${Date.now()}`;
1183
1344
  return [{
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What a person is shown when `codex app-server` asks for an approval.
3
+ *
4
+ * The card used to show the protocol itself: `item/fileChange/requestApproval`
5
+ * and `id: exec-…`, with the file name only in the item above it. This module
6
+ * turns each kind of request the app-server can send into a first line that
7
+ * says what will happen ("Write: src/a.ts", "Run: npm test") and further lines
8
+ * with what helps to decide (where, why, which files).
9
+ *
10
+ * The first line is the title; the control plane's card shows it on its own.
11
+ * No ids: nobody can decide anything from one.
12
+ *
13
+ * Request kinds, from `codex app-server generate-json-schema` (ServerRequest):
14
+ * item/commandExecution/requestApproval a command, or input to a terminal
15
+ * item/fileChange/requestApproval a patch; the files are on the item
16
+ * item/permissions/requestApproval wider sandbox access for the turn
17
+ * mcpServer/elicitation/request an MCP server asks (incl. to run
18
+ * one of its tools)
19
+ * item/tool/requestUserInput the model asks a question
20
+ * execCommandApproval, applyPatchApproval the older forms of the first two
21
+ * Kept out of the adapter, which is a copy of 20x's, so it can be tested alone.
22
+ */
23
+ type Json = Record<string, unknown>;
24
+ /** The longest command shown whole. Longer ones keep both ends. */
25
+ export declare const MAX_SHOWN_COMMAND = 2000;
26
+ /** The longest reason, message or question shown whole. */
27
+ export declare const MAX_SHOWN_NOTE = 500;
28
+ interface FileChange {
29
+ path: string;
30
+ kind: string;
31
+ added: number;
32
+ removed: number;
33
+ }
34
+ /**
35
+ * The files of a change, from either shape Codex uses:
36
+ * v2 item `changes: [{ path, kind: { type }, diff }]`
37
+ * older request `fileChanges: { [path]: { add | delete | update: {…} } }`
38
+ */
39
+ export declare function readFileChanges(source: unknown): FileChange[];
40
+ /**
41
+ * The text of one approval request.
42
+ *
43
+ * @param item the thread item the request is about, when the adapter has seen
44
+ * it (`item/started` arrives before the request). A file change's paths are
45
+ * only there.
46
+ */
47
+ export declare function describeCodexApproval(method: string, params: Json, item?: Json,
48
+ /** The session's workspace; paths inside it are shown relative to it. */
49
+ workspaceDir?: string): string;
50
+ export {};
@@ -0,0 +1,366 @@
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.MAX_SHOWN_NOTE = exports.MAX_SHOWN_COMMAND = void 0;
26
+ exports.readFileChanges = readFileChanges;
27
+ exports.describeCodexApproval = describeCodexApproval;
28
+ function isRecord(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+ function text(value) {
32
+ return typeof value === 'string' ? value.trim() : '';
33
+ }
34
+ /** One line, however the value was written. */
35
+ function oneLine(value) {
36
+ return value.replace(/\s*\n\s*/g, ' ⏎ ').trim();
37
+ }
38
+ const MAX_LISTED_PATHS = 12;
39
+ /** The longest command shown whole. Longer ones keep both ends. */
40
+ exports.MAX_SHOWN_COMMAND = 2000;
41
+ const COMMAND_HEAD = 1500;
42
+ const COMMAND_TAIL = 300;
43
+ /** The longest reason, message or question shown whole. */
44
+ exports.MAX_SHOWN_NOTE = 500;
45
+ /**
46
+ * A command that is too long for a card keeps its start, where the program
47
+ * and its first arguments are, and its end, where a redirect or a second
48
+ * command would be, and says how much is missing. The card then also says, on
49
+ * a line of its own, that this is not the whole command: a person must never
50
+ * approve a shortened command believing they have read it.
51
+ */
52
+ function shownCommand(command) {
53
+ const line = oneLine(command);
54
+ if (line.length <= exports.MAX_SHOWN_COMMAND)
55
+ return { text: line, hidden: 0 };
56
+ const hidden = line.length - COMMAND_HEAD - COMMAND_TAIL;
57
+ return {
58
+ text: `${line.slice(0, COMMAND_HEAD)} … [${hidden} characters not shown] … ${line.slice(-COMMAND_TAIL)}`,
59
+ hidden
60
+ };
61
+ }
62
+ function notShownInFull(command) {
63
+ return (`Not shown in full: this command is ${oneLine(command).length} characters ` +
64
+ 'long. Deny it unless you know what it runs.');
65
+ }
66
+ /** Words about the request (why, what a server says), not what will run. */
67
+ function note(value) {
68
+ const line = oneLine(value);
69
+ return line.length <= exports.MAX_SHOWN_NOTE
70
+ ? line
71
+ : `${line.slice(0, exports.MAX_SHOWN_NOTE)} … (shortened, ${line.length - exports.MAX_SHOWN_NOTE} more characters)`;
72
+ }
73
+ /** `/work/space/src/a.ts` in `/work/space` → `src/a.ts`. Others unchanged. */
74
+ function relativeTo(workspaceDir, path) {
75
+ const root = (workspaceDir ?? '').replace(/\/+$/, '');
76
+ if (!root)
77
+ return path;
78
+ // A path that climbs (`/work/space/../../etc/passwd`) is not inside the
79
+ // workspace whatever it starts with. Shown as it is.
80
+ if (/(^|\/)\.\.?(\/|$)/.test(path.replace(/\/\.$/, '')))
81
+ return path;
82
+ // macOS reports the same directory as /tmp/… and /private/tmp/….
83
+ for (const prefix of [
84
+ root,
85
+ `/private${root}`,
86
+ root.replace(/^\/private/, '')
87
+ ]) {
88
+ if (prefix && path.startsWith(`${prefix}/`)) {
89
+ return path.slice(prefix.length + 1);
90
+ }
91
+ }
92
+ return path;
93
+ }
94
+ function countDiff(diff) {
95
+ let added = 0;
96
+ let removed = 0;
97
+ for (const line of diff.split('\n')) {
98
+ if (line.startsWith('+++') || line.startsWith('---'))
99
+ continue;
100
+ if (line.startsWith('+'))
101
+ added += 1;
102
+ else if (line.startsWith('-'))
103
+ removed += 1;
104
+ }
105
+ return { added, removed };
106
+ }
107
+ function changeKind(value) {
108
+ if (typeof value === 'string')
109
+ return value;
110
+ if (isRecord(value)) {
111
+ return text(value.type) || Object.keys(value)[0] || 'update';
112
+ }
113
+ return 'update';
114
+ }
115
+ /**
116
+ * The files of a change, from either shape Codex uses:
117
+ * v2 item `changes: [{ path, kind: { type }, diff }]`
118
+ * older request `fileChanges: { [path]: { add | delete | update: {…} } }`
119
+ */
120
+ function readFileChanges(source) {
121
+ if (Array.isArray(source)) {
122
+ const changes = [];
123
+ for (const entry of source) {
124
+ if (!isRecord(entry) || !text(entry.path))
125
+ continue;
126
+ const diff = text(entry.diff);
127
+ const kind = changeKind(entry.kind);
128
+ // A new file's "diff" is its whole content, without `+` marks.
129
+ const counted = kind === 'add' && diff && !/^[+-]/m.test(diff)
130
+ ? { added: diff.split('\n').length, removed: 0 }
131
+ : countDiff(diff);
132
+ changes.push({ path: text(entry.path), kind, ...counted });
133
+ }
134
+ return changes;
135
+ }
136
+ if (isRecord(source)) {
137
+ return Object.entries(source).map(([path, change]) => {
138
+ const kind = changeKind(change);
139
+ const body = isRecord(change) && isRecord(change[kind]) ? change[kind] : {};
140
+ const diff = text(body.unified_diff) || text(body.diff);
141
+ const content = text(body.content);
142
+ return {
143
+ path,
144
+ kind,
145
+ ...(diff
146
+ ? countDiff(diff)
147
+ : { added: content ? content.split('\n').length : 0, removed: 0 })
148
+ };
149
+ });
150
+ }
151
+ return [];
152
+ }
153
+ function verbFor(kind) {
154
+ if (kind === 'add')
155
+ return 'Create';
156
+ if (kind === 'delete')
157
+ return 'Delete';
158
+ return 'Write';
159
+ }
160
+ function changeSummary(change) {
161
+ if (change.kind === 'delete')
162
+ return '';
163
+ const parts = [];
164
+ if (change.added)
165
+ parts.push(`+${change.added}`);
166
+ if (change.removed)
167
+ parts.push(`−${change.removed}`);
168
+ return parts.length ? ` (${parts.join(' ')})` : '';
169
+ }
170
+ function describeFileChanges(changes) {
171
+ if (changes.length === 0)
172
+ return ['Write: files in the workspace'];
173
+ if (changes.length === 1) {
174
+ const [only] = changes;
175
+ return [`${verbFor(only.kind)}: ${only.path}${changeSummary(only)}`];
176
+ }
177
+ const kinds = new Set(changes.map((change) => change.kind));
178
+ const verb = kinds.size === 1 ? verbFor(changes[0].kind) : 'Change';
179
+ const lines = [`${verb}: ${changes.length} files`];
180
+ for (const change of changes.slice(0, MAX_LISTED_PATHS)) {
181
+ // Said once in the first line when every file is the same kind of change.
182
+ const mark = kinds.size === 1
183
+ ? ''
184
+ : change.kind === 'add'
185
+ ? 'new'
186
+ : change.kind === 'delete'
187
+ ? 'deleted'
188
+ : '';
189
+ lines.push(`• ${change.path}${changeSummary(change)}${mark ? ` — ${mark}` : ''}`);
190
+ }
191
+ if (changes.length > MAX_LISTED_PATHS) {
192
+ lines.push(`• and ${changes.length - MAX_LISTED_PATHS} more`);
193
+ }
194
+ return lines;
195
+ }
196
+ /**
197
+ * `/bin/zsh -lc 'npm test'` → `npm test`. Codex runs every command through a
198
+ * login shell; the wrapper is the same every time and hides the command. Only
199
+ * the plain one-quoted form is unwrapped, so nothing is ever shown that is not
200
+ * literally what the shell will be given.
201
+ */
202
+ function unwrapShell(command) {
203
+ // SINGLE quotes only: inside them the shell changes nothing, so the inner
204
+ // text is exactly the command. Inside double quotes it would still expand
205
+ // `$(…)`, backticks and variables, and the card would not show what runs.
206
+ const match = /^(?:\/(?:usr\/)?bin\/)?(?:zsh|bash|sh) -l?c '([\s\S]*)'$/.exec(command.trim());
207
+ if (!match)
208
+ return command;
209
+ const inner = match[1];
210
+ return inner.includes("'") ? command : inner;
211
+ }
212
+ function commandText(value) {
213
+ if (Array.isArray(value)) {
214
+ return value
215
+ .filter((part) => typeof part === 'string')
216
+ .join(' ');
217
+ }
218
+ return text(value);
219
+ }
220
+ function describePermissions(permissions) {
221
+ const lines = [];
222
+ if (!isRecord(permissions))
223
+ return lines;
224
+ if (isRecord(permissions.network) && permissions.network.enabled === true) {
225
+ lines.push('• network access');
226
+ }
227
+ if (isRecord(permissions.fileSystem)) {
228
+ const fileSystem = permissions.fileSystem;
229
+ const list = (key, label) => {
230
+ const paths = Array.isArray(fileSystem[key])
231
+ ? fileSystem[key].filter((path) => typeof path === 'string')
232
+ : [];
233
+ for (const path of paths)
234
+ lines.push(`• ${label} ${path}`);
235
+ };
236
+ list('write', 'write to');
237
+ list('read', 'read');
238
+ if (Array.isArray(fileSystem.entries)) {
239
+ for (const entry of fileSystem.entries) {
240
+ if (!isRecord(entry))
241
+ continue;
242
+ const path = isRecord(entry.path)
243
+ ? text(entry.path.path) || JSON.stringify(entry.path)
244
+ : text(entry.path);
245
+ const access = text(entry.access) || 'access';
246
+ if (path)
247
+ lines.push(`• ${access} ${path}`);
248
+ }
249
+ }
250
+ }
251
+ return lines;
252
+ }
253
+ /** `item/somethingNew/requestApproval` → "something new". */
254
+ function humanise(method) {
255
+ const words = method
256
+ .replace(/^item\//, '')
257
+ .replace(/\/?(requestApproval|request)$/i, '')
258
+ .replace(/Approval$/i, '')
259
+ .replace(/[/_]/g, ' ')
260
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
261
+ .trim()
262
+ .toLowerCase();
263
+ return words || 'an action';
264
+ }
265
+ /**
266
+ * The text of one approval request.
267
+ *
268
+ * @param item the thread item the request is about, when the adapter has seen
269
+ * it (`item/started` arrives before the request). A file change's paths are
270
+ * only there.
271
+ */
272
+ function describeCodexApproval(method, params, item,
273
+ /** The session's workspace; paths inside it are shown relative to it. */
274
+ workspaceDir) {
275
+ const reason = text(params.reason);
276
+ const lines = [];
277
+ const shown = (path) => relativeTo(workspaceDir, path);
278
+ if (method === 'item/commandExecution/requestApproval' ||
279
+ method === 'execCommandApproval') {
280
+ const command = unwrapShell(commandText(params.command) || commandText(item?.command) || '');
281
+ const network = isRecord(params.networkApprovalContext)
282
+ ? params.networkApprovalContext
283
+ : null;
284
+ const capped = shownCommand(command);
285
+ if (params.kind === 'writeStdin') {
286
+ lines.push(`Send input to a running command: ${capped.text}`);
287
+ if (capped.hidden)
288
+ lines.push(notShownInFull(command));
289
+ }
290
+ else if (command) {
291
+ lines.push(`Run: ${capped.text}`);
292
+ // Straight under the command, before anything else that could push it
293
+ // out of sight.
294
+ if (capped.hidden)
295
+ lines.push(notShownInFull(command));
296
+ }
297
+ else if (network) {
298
+ lines.push(`Network access: ${text(network.host)}`);
299
+ }
300
+ else {
301
+ lines.push('Run: a command');
302
+ }
303
+ const cwd = text(params.cwd) || text(item?.cwd);
304
+ // The workspace itself goes without saying; anywhere else is worth a line.
305
+ const where = cwd
306
+ ? shown(`${cwd.replace(/\/+$/, '')}/.`).replace(/\/?\.$/, '')
307
+ : '';
308
+ if (where)
309
+ lines.push(`In: ${where}`);
310
+ if (network && command) {
311
+ lines.push(`Network access: ${text(network.host)}${text(network.protocol) ? ` (${text(network.protocol)})` : ''}`);
312
+ }
313
+ }
314
+ else if (method === 'item/fileChange/requestApproval' ||
315
+ method === 'applyPatchApproval') {
316
+ lines.push(...describeFileChanges(readFileChanges(params.fileChanges ?? item?.changes).map((change) => ({
317
+ ...change,
318
+ path: shown(change.path)
319
+ }))));
320
+ const root = text(params.grantRoot);
321
+ if (root)
322
+ lines.push(`Also asks to write under: ${root}`);
323
+ }
324
+ else if (method === 'item/permissions/requestApproval') {
325
+ const wanted = describePermissions(params.permissions);
326
+ lines.push('Grant more access for this turn');
327
+ lines.push(...(wanted.length ? wanted : ['• wider sandbox access']));
328
+ const cwd = text(params.cwd);
329
+ if (cwd)
330
+ lines.push(`In: ${cwd}`);
331
+ }
332
+ else if (method === 'mcpServer/elicitation/request') {
333
+ const server = text(params.serverName) || 'An MCP server';
334
+ const message = text(params.message);
335
+ lines.push(message
336
+ ? `${server} asks: ${note(message)}`
337
+ : `${server} asks to continue`);
338
+ const url = text(params.url);
339
+ if (url)
340
+ lines.push(`Opens: ${url}`);
341
+ }
342
+ else if (method === 'item/tool/requestUserInput') {
343
+ const first = Array.isArray(params.questions) ? params.questions[0] : null;
344
+ const question = isRecord(first)
345
+ ? text(first.question) || text(first.header)
346
+ : '';
347
+ lines.push(question ? `Codex asks: ${note(question)}` : 'Codex asks a question');
348
+ }
349
+ else {
350
+ // A kind this build does not know. Say what is asked in plain words and
351
+ // show what the request itself says about it, never the method name alone.
352
+ const command = commandText(params.command);
353
+ lines.push(`Codex asks to approve: ${humanise(method)}`);
354
+ if (command) {
355
+ lines.push(`Command: ${shownCommand(command).text}`);
356
+ if (shownCommand(command).hidden)
357
+ lines.push(notShownInFull(command));
358
+ }
359
+ const cwd = text(params.cwd);
360
+ if (cwd)
361
+ lines.push(`In: ${cwd}`);
362
+ }
363
+ if (reason)
364
+ lines.push(`Reason: ${note(reason)}`);
365
+ return lines.join('\n');
366
+ }
@@ -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.5",
3
+ "version": "0.2.9-rc.7",
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",