@peakflo/agent-harness 0.2.9-rc.4 → 0.2.9-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -702,7 +702,18 @@ class ClaudeCodeAdapter {
702
702
  // the mode above decorative in exactly the way G1 was.
703
703
  ...(claudePermissionMode === 'bypassPermissions'
704
704
  ? { allowDangerouslySkipPermissions: true }
705
- : {}),
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
+ }),
706
717
  ...(secretHooks ? { hooks: secretHooks } : {}),
707
718
  };
708
719
  // Determine session continuation mode
@@ -804,6 +815,92 @@ class ClaudeCodeAdapter {
804
815
  // Start consuming stream
805
816
  session.streamTask = this.consumeStream(sessionId, session);
806
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
+ }
807
904
  async getStatus(sessionId, _config) {
808
905
  const session = this.sessions.get(sessionId);
809
906
  if (!session) {
@@ -887,6 +984,7 @@ class ClaudeCodeAdapter {
887
984
  session.enqueuePrompt = null;
888
985
  session.backgroundTasks.clear();
889
986
  session.abortController?.abort();
987
+ this.denyPendingToolApprovals(sessionId, 'The turn was stopped before this action was approved.');
890
988
  // Wait for stream to finish cleanup
891
989
  if (session.streamTask) {
892
990
  console.log(`[ClaudeCodeAdapter] Waiting for stream cleanup...`);
@@ -912,6 +1010,7 @@ class ClaudeCodeAdapter {
912
1010
  session.enqueuePrompt = null;
913
1011
  session.backgroundTasks.clear();
914
1012
  session.abortController?.abort();
1013
+ this.denyPendingToolApprovals(sessionId, 'The session was closed before this action was approved.');
915
1014
  // Wait for stream task to complete
916
1015
  if (session.streamTask) {
917
1016
  try {
@@ -1185,7 +1284,20 @@ class ClaudeCodeAdapter {
1185
1284
  const errName = error instanceof Error ? error.name : '';
1186
1285
  const errMsg = error instanceof Error ? error.message : String(error);
1187
1286
  const errStack = error instanceof Error ? error.stack : undefined;
1188
- 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')) {
1189
1301
  console.log('[ClaudeCodeAdapter] Stream aborted by user');
1190
1302
  // Don't set error status - this is normal when sending a new message
1191
1303
  }
@@ -1221,6 +1333,9 @@ class ClaudeCodeAdapter {
1221
1333
  }
1222
1334
  finally {
1223
1335
  console.log('[ClaudeCodeAdapter] Stream consumption ended');
1336
+ // ADDED, not in 20x: the stream that asked is gone (ended, failed or the
1337
+ // process exited), so no answer can reach it. Nothing may stay queued.
1338
+ this.denyPendingToolApprovals(sessionId, 'The turn ended before this action was approved.');
1224
1339
  // Always reset queryIterator when the stream ends (process exited).
1225
1340
  // Previously only error paths did this, leaving a truthy-but-exhausted
1226
1341
  // 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;
@@ -16,6 +40,8 @@ interface PendingApproval {
16
40
  kind: string;
17
41
  }>;
18
42
  responseKind: 'execCommand' | 'commandExecution' | 'fileChange' | 'permissions' | 'elicitation' | 'userInput' | 'generic';
43
+ /** What `item/permissions/requestApproval` asked for; echoed back on allow. */
44
+ requestedPermissions?: unknown;
19
45
  }
20
46
  export declare class CodexAppServerAdapter implements CodingAgentAdapter {
21
47
  private sessions;
@@ -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` ->
@@ -54,7 +57,62 @@ const os_1 = require("os");
54
57
  const path_1 = require("path");
55
58
  const util_1 = require("util");
56
59
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
60
+ const codex_approval_text_1 = require("./codex-approval-text");
57
61
  const DEFAULT_CODEX_APP_SERVER_MODEL = 'gpt-5.5';
62
+ /**
63
+ * `thread/items/list` returns each item inside an envelope:
64
+ * `{ turnId, item: { type: 'userMessage', … }, startedAtMs, completedAtMs }`.
65
+ * Read as an item, the envelope has no `type`, no role and no text, so it fell
66
+ * through to the "unknown item is a tool call" branch and the transcript showed
67
+ * a "tool / tool" card holding the echoed prompt as JSON after every reply.
68
+ */
69
+ function unwrapListedThreadItem(entry) {
70
+ if (!isObject(entry))
71
+ return entry;
72
+ if (asString(entry.type))
73
+ return entry;
74
+ return isObject(entry.item) ? entry.item : entry;
75
+ }
76
+ /**
77
+ * The decisions that answer ONE request and nothing more. A list of what is
78
+ * known to be safe, not of what is known to be unsafe: a decision Codex adds
79
+ * later (one that remembers a rule under a name nobody here has seen) is
80
+ * treated as standing until someone adds it here. The cost of that: a new
81
+ * ONE-TIME decision from Codex is also hidden until it is added; the withheld
82
+ * names are logged by `handleApprovalRequest` so that is visible.
83
+ */
84
+ const ONE_TIME_APPROVAL_DECISIONS = new Set([
85
+ 'accept',
86
+ 'approved',
87
+ 'decline',
88
+ 'denied',
89
+ 'cancel',
90
+ 'abort'
91
+ ]);
92
+ /** A decision that approves more than the one request it answers, or may. */
93
+ function isStandingApprovalDecision(optionId) {
94
+ return !ONE_TIME_APPROVAL_DECISIONS.has(optionId);
95
+ }
96
+ /**
97
+ * CHANGED from 20x, which sends `on-request` for every mode but "allow".
98
+ *
99
+ * `on-request` leaves it to the MODEL to ask, and it asks only when it wants
100
+ * to leave the sandbox. Inside a workspace-write sandbox that is never: Codex
101
+ * created files and ran shell commands with no prompt at all while the agent
102
+ * was set to "Ask before each action". Seen on staging and reproduced.
103
+ *
104
+ * `untrusted` is Codex's own "ask first" policy: commands on its short list of
105
+ * read-only ones (ls, cat, ...) run, and everything else — any other command,
106
+ * any file change — is sent to the client as an approval request before it
107
+ * runs. That is what "Ask before each action" says.
108
+ */
109
+ function codexApprovalPolicy(config) {
110
+ // `'allow'` is what `mapPermissionMode` makes of the agent's
111
+ // `bypassPermissions` ("Don't ask for permission"), and of nothing else.
112
+ // Only an explicit "allow" runs without asking. Anything else, including a
113
+ // value this build does not know, asks.
114
+ return config.permissionMode === 'allow' ? 'never' : 'untrusted';
115
+ }
58
116
  const MAX_IPC_TOOL_INPUT_CHARS = 20_000;
59
117
  const MAX_IPC_TOOL_OUTPUT_CHARS = 100_000;
60
118
  const MIN_THREAD_LEVEL_ASSISTANT_DEDUPE_CHARS = 40;
@@ -220,12 +278,6 @@ function isSubpath(parent, child) {
220
278
  const rel = (0, path_1.relative)((0, path_1.resolve)(parent), (0, path_1.resolve)(child));
221
279
  return rel === '' || (!!rel && !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel));
222
280
  }
223
- function summarizeApproval(params, fallback) {
224
- const command = asString(params.command);
225
- const reason = asString(params.reason);
226
- const itemId = asString(params.itemId) || asString(params.callId);
227
- return [command || fallback, reason, itemId ? `id: ${itemId}` : ''].filter(Boolean).join('\n');
228
- }
229
281
  function normalizeDecisionName(decision) {
230
282
  if (typeof decision === 'string')
231
283
  return decision;
@@ -234,24 +286,6 @@ function normalizeDecisionName(decision) {
234
286
  }
235
287
  return 'accept';
236
288
  }
237
- function decisionLabel(decision) {
238
- switch (decision) {
239
- case 'accept':
240
- case 'approved':
241
- return 'Allow';
242
- case 'acceptForSession':
243
- case 'approved_for_session':
244
- return 'Allow for Session';
245
- case 'decline':
246
- case 'denied':
247
- return 'Deny';
248
- case 'cancel':
249
- case 'abort':
250
- return 'Deny and Stop';
251
- default:
252
- return decision;
253
- }
254
- }
255
289
  class CodexAppServerAdapter {
256
290
  sessions = new Map();
257
291
  codexExecutablePath = null;
@@ -269,7 +303,7 @@ class CodexAppServerAdapter {
269
303
  const result = await this.sendRpcRequest(session, 'thread/start', {
270
304
  cwd: config.workspaceDir,
271
305
  model: config.model || DEFAULT_CODEX_APP_SERVER_MODEL,
272
- approvalPolicy: config.permissionMode === 'allow' ? 'never' : 'on-request',
306
+ approvalPolicy: codexApprovalPolicy(config),
273
307
  approvalsReviewer: 'user',
274
308
  sandbox: this.resolveSandboxMode(config),
275
309
  developerInstructions: config.systemPrompt || null,
@@ -295,7 +329,7 @@ class CodexAppServerAdapter {
295
329
  threadId: sessionId,
296
330
  cwd: config.workspaceDir,
297
331
  model: config.model || DEFAULT_CODEX_APP_SERVER_MODEL,
298
- approvalPolicy: config.permissionMode === 'allow' ? 'never' : 'on-request',
332
+ approvalPolicy: codexApprovalPolicy(config),
299
333
  approvalsReviewer: 'user',
300
334
  sandbox: this.resolveSandboxMode(config),
301
335
  runtimeWorkspaceRoots: this.buildRuntimeWorkspaceRoots(config.workspaceDir),
@@ -347,7 +381,7 @@ class CodexAppServerAdapter {
347
381
  cwd: config.workspaceDir,
348
382
  model: config.model || DEFAULT_CODEX_APP_SERVER_MODEL,
349
383
  effort: config.reasoningEffort && config.reasoningEffort !== 'max' ? config.reasoningEffort : null,
350
- approvalPolicy: config.permissionMode === 'allow' ? 'never' : 'on-request',
384
+ approvalPolicy: codexApprovalPolicy(config),
351
385
  approvalsReviewer: 'user',
352
386
  sandbox: this.resolveSandboxMode(config),
353
387
  sandboxPolicy: this.buildSandboxPolicy(config),
@@ -483,14 +517,24 @@ class CodexAppServerAdapter {
483
517
  if (!approval)
484
518
  return;
485
519
  const selected = optionId || approval.options.find((option) => approved
486
- ? ['acceptForSession', 'accept', 'approved_for_session', 'approved'].includes(option.optionId)
487
- : ['cancel', 'abort', 'decline', 'denied'].includes(option.optionId))?.optionId || (approved ? 'accept' : 'cancel');
488
- const response = this.buildApprovalResponse(approval.responseKind, selected, approved);
520
+ ? ['accept', 'approved'].includes(option.optionId)
521
+ : ['decline', 'denied'].includes(option.optionId))?.optionId || (
522
+ // The older requests take `approved` / `denied`.
523
+ approval.responseKind === 'execCommand'
524
+ ? (approved ? 'approved' : 'denied')
525
+ : (approved ? 'accept' : 'decline'));
526
+ const response = this.buildApprovalResponse(approval.responseKind, selected, approved, approval.requestedPermissions);
489
527
  this.sendRpcResponse(session, approval.requestId, response);
490
528
  session.pendingApproval = null;
491
- if (!approved) {
492
- session.status = coding_agent_adapter_1.SessionStatusType.IDLE;
493
- }
529
+ // CHANGED from 20x, which marked the session idle on every denial. A
530
+ // denial that continues the turn (`decline`, `denied`) is followed by the
531
+ // model's own answer; calling the session idle here ended the turn before
532
+ // that answer arrived. Only a denial that stops the turn is idle at once.
533
+ // (No button sends `cancel` or `abort` any more; a caller that names one
534
+ // explicitly still gets the stop it asked for.)
535
+ session.status = !approved && (selected === 'cancel' || selected === 'abort')
536
+ ? coding_agent_adapter_1.SessionStatusType.IDLE
537
+ : coding_agent_adapter_1.SessionStatusType.BUSY;
494
538
  }
495
539
  async getRunningTools(sessionId, _config) {
496
540
  return Array.from(this.sessions.get(sessionId)?.runningTools.values() || []);
@@ -822,44 +866,73 @@ class CodexAppServerAdapter {
822
866
  if (session.config.permissionMode === 'allow') {
823
867
  const responseKind = this.getApprovalResponseKind(request.method);
824
868
  const selected = responseKind === 'execCommand' ? 'approved' : 'accept';
825
- this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true));
869
+ this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true, params.permissions));
826
870
  return;
827
871
  }
828
872
  const toolCallId = asString(params.approvalId) || asString(params.itemId) || asString(params.callId) || String(request.id);
829
873
  const responseKind = this.getApprovalResponseKind(request.method);
830
- const rawDecisions = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
831
- const approvalOptions = rawDecisions.length > 0
832
- ? rawDecisions.map((decision) => {
833
- const optionId = normalizeDecisionName(decision);
834
- return {
835
- optionId,
836
- name: decisionLabel(optionId),
837
- kind: optionId.includes('accept') || optionId === 'approved' ? 'allow' : 'reject'
838
- };
839
- })
874
+ // A decision that also writes a standing rule ("…WithExecpolicyAmendment",
875
+ // "…ForSession") would let one click approve later commands nobody saw, and
876
+ // the amendment is saved into the Codex config on the runner. Offer only
877
+ // the decisions known to answer this one request; the rest are logged.
878
+ const offered = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
879
+ const withheld = offered.map(normalizeDecisionName).filter(isStandingApprovalDecision);
880
+ if (withheld.length > 0) {
881
+ console.log(`[CodexAppServerAdapter] Not offering approval decisions: ${withheld.join(', ')}`);
882
+ }
883
+ /*
884
+ * CHANGED from 20x: ONE "Allow" AND ONE "Deny", AND DENY LETS THE TURN GO ON.
885
+ *
886
+ * Codex has two denials. `cancel` ("abort" in the older form) also
887
+ * interrupts the turn: the chat went idle with no word from the agent, as
888
+ * if nothing had been asked. `decline` ("denied") refuses the action and
889
+ * lets the model carry on, so it can say that it was not allowed and what
890
+ * it will do instead. Both are in the protocol for every request that has
891
+ * a decision, whether or not `availableDecisions` lists them, so "Deny" is
892
+ * always the one that continues.
893
+ */
894
+ const legacy = responseKind === 'execCommand';
895
+ const approvalOptions = legacy
896
+ ? [
897
+ { optionId: 'approved', name: 'Allow', kind: 'allow' },
898
+ { optionId: 'denied', name: 'Deny', kind: 'reject' }
899
+ ]
840
900
  : [
841
901
  { optionId: 'accept', name: 'Allow', kind: 'allow' },
842
- { optionId: 'cancel', name: 'Deny', kind: 'reject' }
902
+ { optionId: 'decline', name: 'Deny', kind: 'reject' }
843
903
  ];
904
+ // The item the request is about, when it has been announced: a file
905
+ // change's paths are on the item, not on the request.
906
+ const itemId = asString(params.itemId) || asString(params.callId);
907
+ const item = itemId ? session.runningTools.get(`tool-${itemId}`)?.input : undefined;
844
908
  session.pendingApproval = {
845
909
  requestId: request.id,
846
910
  toolCallId,
847
- question: summarizeApproval(params, request.method),
911
+ question: (0, codex_approval_text_1.describeCodexApproval)(request.method, params, item, session.config.workspaceDir),
848
912
  options: approvalOptions,
849
- responseKind
913
+ responseKind,
914
+ ...(responseKind === 'permissions' ? { requestedPermissions: params.permissions } : {})
850
915
  };
851
916
  session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
852
917
  this.onDataAvailable?.(session.threadId || session.sessionId);
853
918
  }
854
- buildApprovalResponse(responseKind, selected, approved) {
919
+ buildApprovalResponse(responseKind, selected, approved, requestedPermissions) {
855
920
  switch (responseKind) {
856
921
  case 'execCommand':
857
- return { decision: approved ? (selected === 'approved_for_session' ? 'approved_for_session' : 'approved') : (selected === 'denied' ? 'denied' : 'abort') };
922
+ // The older requests (`execCommandApproval`, `applyPatchApproval`)
923
+ // take a ReviewDecision. `denied` continues the turn; `abort` stops it.
924
+ return { decision: approved ? 'approved' : (selected === 'abort' ? 'abort' : 'denied') };
858
925
  case 'commandExecution':
859
- return { decision: selected };
860
926
  case 'fileChange':
927
+ return { decision: approved ? 'accept' : (selected === 'cancel' ? 'cancel' : 'decline') };
861
928
  case 'permissions':
862
- return { decision: selected };
929
+ // CHANGED from 20x, which answered `{ decision }`. This request has no
930
+ // decision: the answer is the permissions granted, for this turn only.
931
+ // Granting nothing is the denial.
932
+ return {
933
+ permissions: approved && isObject(requestedPermissions) ? requestedPermissions : {},
934
+ scope: 'turn'
935
+ };
863
936
  case 'elicitation':
864
937
  return approved
865
938
  ? { action: 'accept', content: {} }
@@ -869,11 +942,11 @@ class CodexAppServerAdapter {
869
942
  ? { response: selected }
870
943
  : { response: null };
871
944
  default:
872
- return { decision: approved ? selected : 'cancel' };
945
+ return { decision: approved ? 'accept' : 'decline' };
873
946
  }
874
947
  }
875
948
  getApprovalResponseKind(method) {
876
- if (method === 'execCommandApproval')
949
+ if (method === 'execCommandApproval' || method === 'applyPatchApproval')
877
950
  return 'execCommand';
878
951
  if (method.includes('commandExecution'))
879
952
  return 'commandExecution';
@@ -922,8 +995,9 @@ class CodexAppServerAdapter {
922
995
  }
923
996
  bufferThreadItems(session, result) {
924
997
  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);
998
+ for (const entry of items) {
999
+ const turnId = isObject(entry) ? asString(entry.turnId) : undefined;
1000
+ this.bufferReconciledThreadItem(session, unwrapListedThreadItem(entry), turnId);
927
1001
  }
928
1002
  }
929
1003
  /**
@@ -0,0 +1,46 @@
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
+ interface FileChange {
25
+ path: string;
26
+ kind: string;
27
+ added: number;
28
+ removed: number;
29
+ }
30
+ /**
31
+ * The files of a change, from either shape Codex uses:
32
+ * v2 item `changes: [{ path, kind: { type }, diff }]`
33
+ * older request `fileChanges: { [path]: { add | delete | update: {…} } }`
34
+ */
35
+ export declare function readFileChanges(source: unknown): FileChange[];
36
+ /**
37
+ * The text of one approval request.
38
+ *
39
+ * @param item the thread item the request is about, when the adapter has seen
40
+ * it (`item/started` arrives before the request). A file change's paths are
41
+ * only there.
42
+ */
43
+ export declare function describeCodexApproval(method: string, params: Json, item?: Json,
44
+ /** The session's workspace; paths inside it are shown relative to it. */
45
+ workspaceDir?: string): string;
46
+ export {};