@peakflo/agent-harness 0.2.9-rc.5 → 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.
- package/README.md +5 -0
- package/dist/adapters/claude-code-adapter.js +14 -1
- package/dist/adapters/codex-app-server-adapter.d.ts +2 -0
- package/dist/adapters/codex-app-server-adapter.js +59 -53
- package/dist/adapters/codex-approval-text.d.ts +46 -0
- package/dist/adapters/codex-approval-text.js +314 -0
- package/dist/runtime/session-runner.d.ts +29 -0
- package/dist/runtime/session-runner.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -121,6 +121,11 @@ 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
|
+
|
|
124
129
|
Cursor reports an account limit as a normal reply ("Upgrade your plan to
|
|
125
130
|
continue"). The runner turns a reply that is only that sentence into a failed
|
|
126
131
|
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
|
-
|
|
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
|
}
|
|
@@ -40,6 +40,8 @@ interface PendingApproval {
|
|
|
40
40
|
kind: string;
|
|
41
41
|
}>;
|
|
42
42
|
responseKind: 'execCommand' | 'commandExecution' | 'fileChange' | 'permissions' | 'elicitation' | 'userInput' | 'generic';
|
|
43
|
+
/** What `item/permissions/requestApproval` asked for; echoed back on allow. */
|
|
44
|
+
requestedPermissions?: unknown;
|
|
43
45
|
}
|
|
44
46
|
export declare class CodexAppServerAdapter implements CodingAgentAdapter {
|
|
45
47
|
private sessions;
|
|
@@ -57,6 +57,7 @@ const os_1 = require("os");
|
|
|
57
57
|
const path_1 = require("path");
|
|
58
58
|
const util_1 = require("util");
|
|
59
59
|
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
60
|
+
const codex_approval_text_1 = require("./codex-approval-text");
|
|
60
61
|
const DEFAULT_CODEX_APP_SERVER_MODEL = 'gpt-5.5';
|
|
61
62
|
/**
|
|
62
63
|
* `thread/items/list` returns each item inside an envelope:
|
|
@@ -277,12 +278,6 @@ function isSubpath(parent, child) {
|
|
|
277
278
|
const rel = (0, path_1.relative)((0, path_1.resolve)(parent), (0, path_1.resolve)(child));
|
|
278
279
|
return rel === '' || (!!rel && !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel));
|
|
279
280
|
}
|
|
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
281
|
function normalizeDecisionName(decision) {
|
|
287
282
|
if (typeof decision === 'string')
|
|
288
283
|
return decision;
|
|
@@ -291,24 +286,6 @@ function normalizeDecisionName(decision) {
|
|
|
291
286
|
}
|
|
292
287
|
return 'accept';
|
|
293
288
|
}
|
|
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
289
|
class CodexAppServerAdapter {
|
|
313
290
|
sessions = new Map();
|
|
314
291
|
codexExecutablePath = null;
|
|
@@ -540,14 +517,24 @@ class CodexAppServerAdapter {
|
|
|
540
517
|
if (!approval)
|
|
541
518
|
return;
|
|
542
519
|
const selected = optionId || approval.options.find((option) => approved
|
|
543
|
-
? ['
|
|
544
|
-
: ['
|
|
545
|
-
|
|
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);
|
|
546
527
|
this.sendRpcResponse(session, approval.requestId, response);
|
|
547
528
|
session.pendingApproval = null;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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;
|
|
551
538
|
}
|
|
552
539
|
async getRunningTools(sessionId, _config) {
|
|
553
540
|
return Array.from(this.sessions.get(sessionId)?.runningTools.values() || []);
|
|
@@ -879,7 +866,7 @@ class CodexAppServerAdapter {
|
|
|
879
866
|
if (session.config.permissionMode === 'allow') {
|
|
880
867
|
const responseKind = this.getApprovalResponseKind(request.method);
|
|
881
868
|
const selected = responseKind === 'execCommand' ? 'approved' : 'accept';
|
|
882
|
-
this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true));
|
|
869
|
+
this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true, params.permissions));
|
|
883
870
|
return;
|
|
884
871
|
}
|
|
885
872
|
const toolCallId = asString(params.approvalId) || asString(params.itemId) || asString(params.callId) || String(request.id);
|
|
@@ -889,44 +876,63 @@ class CodexAppServerAdapter {
|
|
|
889
876
|
// the amendment is saved into the Codex config on the runner. Offer only
|
|
890
877
|
// the decisions known to answer this one request; the rest are logged.
|
|
891
878
|
const offered = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
if (rawDecisions.length !== offered.length) {
|
|
895
|
-
const withheld = offered.map(normalizeDecisionName).filter(isStandingApprovalDecision);
|
|
879
|
+
const withheld = offered.map(normalizeDecisionName).filter(isStandingApprovalDecision);
|
|
880
|
+
if (withheld.length > 0) {
|
|
896
881
|
console.log(`[CodexAppServerAdapter] Not offering approval decisions: ${withheld.join(', ')}`);
|
|
897
882
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
+
]
|
|
907
900
|
: [
|
|
908
901
|
{ optionId: 'accept', name: 'Allow', kind: 'allow' },
|
|
909
|
-
{ optionId: '
|
|
902
|
+
{ optionId: 'decline', name: 'Deny', kind: 'reject' }
|
|
910
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;
|
|
911
908
|
session.pendingApproval = {
|
|
912
909
|
requestId: request.id,
|
|
913
910
|
toolCallId,
|
|
914
|
-
question:
|
|
911
|
+
question: (0, codex_approval_text_1.describeCodexApproval)(request.method, params, item, session.config.workspaceDir),
|
|
915
912
|
options: approvalOptions,
|
|
916
|
-
responseKind
|
|
913
|
+
responseKind,
|
|
914
|
+
...(responseKind === 'permissions' ? { requestedPermissions: params.permissions } : {})
|
|
917
915
|
};
|
|
918
916
|
session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
|
|
919
917
|
this.onDataAvailable?.(session.threadId || session.sessionId);
|
|
920
918
|
}
|
|
921
|
-
buildApprovalResponse(responseKind, selected, approved) {
|
|
919
|
+
buildApprovalResponse(responseKind, selected, approved, requestedPermissions) {
|
|
922
920
|
switch (responseKind) {
|
|
923
921
|
case 'execCommand':
|
|
924
|
-
|
|
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') };
|
|
925
925
|
case 'commandExecution':
|
|
926
|
-
return { decision: selected };
|
|
927
926
|
case 'fileChange':
|
|
927
|
+
return { decision: approved ? 'accept' : (selected === 'cancel' ? 'cancel' : 'decline') };
|
|
928
928
|
case 'permissions':
|
|
929
|
-
|
|
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
|
+
};
|
|
930
936
|
case 'elicitation':
|
|
931
937
|
return approved
|
|
932
938
|
? { action: 'accept', content: {} }
|
|
@@ -936,11 +942,11 @@ class CodexAppServerAdapter {
|
|
|
936
942
|
? { response: selected }
|
|
937
943
|
: { response: null };
|
|
938
944
|
default:
|
|
939
|
-
return { decision: approved ?
|
|
945
|
+
return { decision: approved ? 'accept' : 'decline' };
|
|
940
946
|
}
|
|
941
947
|
}
|
|
942
948
|
getApprovalResponseKind(method) {
|
|
943
|
-
if (method === 'execCommandApproval')
|
|
949
|
+
if (method === 'execCommandApproval' || method === 'applyPatchApproval')
|
|
944
950
|
return 'execCommand';
|
|
945
951
|
if (method.includes('commandExecution'))
|
|
946
952
|
return 'commandExecution';
|
|
@@ -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 {};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a person is shown when `codex app-server` asks for an approval.
|
|
4
|
+
*
|
|
5
|
+
* The card used to show the protocol itself: `item/fileChange/requestApproval`
|
|
6
|
+
* and `id: exec-…`, with the file name only in the item above it. This module
|
|
7
|
+
* turns each kind of request the app-server can send into a first line that
|
|
8
|
+
* says what will happen ("Write: src/a.ts", "Run: npm test") and further lines
|
|
9
|
+
* with what helps to decide (where, why, which files).
|
|
10
|
+
*
|
|
11
|
+
* The first line is the title; the control plane's card shows it on its own.
|
|
12
|
+
* No ids: nobody can decide anything from one.
|
|
13
|
+
*
|
|
14
|
+
* Request kinds, from `codex app-server generate-json-schema` (ServerRequest):
|
|
15
|
+
* item/commandExecution/requestApproval a command, or input to a terminal
|
|
16
|
+
* item/fileChange/requestApproval a patch; the files are on the item
|
|
17
|
+
* item/permissions/requestApproval wider sandbox access for the turn
|
|
18
|
+
* mcpServer/elicitation/request an MCP server asks (incl. to run
|
|
19
|
+
* one of its tools)
|
|
20
|
+
* item/tool/requestUserInput the model asks a question
|
|
21
|
+
* execCommandApproval, applyPatchApproval the older forms of the first two
|
|
22
|
+
* Kept out of the adapter, which is a copy of 20x's, so it can be tested alone.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.readFileChanges = readFileChanges;
|
|
26
|
+
exports.describeCodexApproval = describeCodexApproval;
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function text(value) {
|
|
31
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
32
|
+
}
|
|
33
|
+
/** One line, however the value was written. */
|
|
34
|
+
function oneLine(value) {
|
|
35
|
+
return value.replace(/\s*\n\s*/g, ' ⏎ ').trim();
|
|
36
|
+
}
|
|
37
|
+
const MAX_LISTED_PATHS = 12;
|
|
38
|
+
/** `/work/space/src/a.ts` in `/work/space` → `src/a.ts`. Others unchanged. */
|
|
39
|
+
function relativeTo(workspaceDir, path) {
|
|
40
|
+
const root = (workspaceDir ?? '').replace(/\/+$/, '');
|
|
41
|
+
if (!root)
|
|
42
|
+
return path;
|
|
43
|
+
// macOS reports the same directory as /tmp/… and /private/tmp/….
|
|
44
|
+
for (const prefix of [
|
|
45
|
+
root,
|
|
46
|
+
`/private${root}`,
|
|
47
|
+
root.replace(/^\/private/, '')
|
|
48
|
+
]) {
|
|
49
|
+
if (prefix && path.startsWith(`${prefix}/`)) {
|
|
50
|
+
return path.slice(prefix.length + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return path;
|
|
54
|
+
}
|
|
55
|
+
function countDiff(diff) {
|
|
56
|
+
let added = 0;
|
|
57
|
+
let removed = 0;
|
|
58
|
+
for (const line of diff.split('\n')) {
|
|
59
|
+
if (line.startsWith('+++') || line.startsWith('---'))
|
|
60
|
+
continue;
|
|
61
|
+
if (line.startsWith('+'))
|
|
62
|
+
added += 1;
|
|
63
|
+
else if (line.startsWith('-'))
|
|
64
|
+
removed += 1;
|
|
65
|
+
}
|
|
66
|
+
return { added, removed };
|
|
67
|
+
}
|
|
68
|
+
function changeKind(value) {
|
|
69
|
+
if (typeof value === 'string')
|
|
70
|
+
return value;
|
|
71
|
+
if (isRecord(value)) {
|
|
72
|
+
return text(value.type) || Object.keys(value)[0] || 'update';
|
|
73
|
+
}
|
|
74
|
+
return 'update';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The files of a change, from either shape Codex uses:
|
|
78
|
+
* v2 item `changes: [{ path, kind: { type }, diff }]`
|
|
79
|
+
* older request `fileChanges: { [path]: { add | delete | update: {…} } }`
|
|
80
|
+
*/
|
|
81
|
+
function readFileChanges(source) {
|
|
82
|
+
if (Array.isArray(source)) {
|
|
83
|
+
const changes = [];
|
|
84
|
+
for (const entry of source) {
|
|
85
|
+
if (!isRecord(entry) || !text(entry.path))
|
|
86
|
+
continue;
|
|
87
|
+
const diff = text(entry.diff);
|
|
88
|
+
const kind = changeKind(entry.kind);
|
|
89
|
+
// A new file's "diff" is its whole content, without `+` marks.
|
|
90
|
+
const counted = kind === 'add' && diff && !/^[+-]/m.test(diff)
|
|
91
|
+
? { added: diff.split('\n').length, removed: 0 }
|
|
92
|
+
: countDiff(diff);
|
|
93
|
+
changes.push({ path: text(entry.path), kind, ...counted });
|
|
94
|
+
}
|
|
95
|
+
return changes;
|
|
96
|
+
}
|
|
97
|
+
if (isRecord(source)) {
|
|
98
|
+
return Object.entries(source).map(([path, change]) => {
|
|
99
|
+
const kind = changeKind(change);
|
|
100
|
+
const body = isRecord(change) && isRecord(change[kind]) ? change[kind] : {};
|
|
101
|
+
const diff = text(body.unified_diff) || text(body.diff);
|
|
102
|
+
const content = text(body.content);
|
|
103
|
+
return {
|
|
104
|
+
path,
|
|
105
|
+
kind,
|
|
106
|
+
...(diff
|
|
107
|
+
? countDiff(diff)
|
|
108
|
+
: { added: content ? content.split('\n').length : 0, removed: 0 })
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
function verbFor(kind) {
|
|
115
|
+
if (kind === 'add')
|
|
116
|
+
return 'Create';
|
|
117
|
+
if (kind === 'delete')
|
|
118
|
+
return 'Delete';
|
|
119
|
+
return 'Write';
|
|
120
|
+
}
|
|
121
|
+
function changeSummary(change) {
|
|
122
|
+
if (change.kind === 'delete')
|
|
123
|
+
return '';
|
|
124
|
+
const parts = [];
|
|
125
|
+
if (change.added)
|
|
126
|
+
parts.push(`+${change.added}`);
|
|
127
|
+
if (change.removed)
|
|
128
|
+
parts.push(`−${change.removed}`);
|
|
129
|
+
return parts.length ? ` (${parts.join(' ')})` : '';
|
|
130
|
+
}
|
|
131
|
+
function describeFileChanges(changes) {
|
|
132
|
+
if (changes.length === 0)
|
|
133
|
+
return ['Write: files in the workspace'];
|
|
134
|
+
if (changes.length === 1) {
|
|
135
|
+
const [only] = changes;
|
|
136
|
+
return [`${verbFor(only.kind)}: ${only.path}${changeSummary(only)}`];
|
|
137
|
+
}
|
|
138
|
+
const kinds = new Set(changes.map((change) => change.kind));
|
|
139
|
+
const verb = kinds.size === 1 ? verbFor(changes[0].kind) : 'Change';
|
|
140
|
+
const lines = [`${verb}: ${changes.length} files`];
|
|
141
|
+
for (const change of changes.slice(0, MAX_LISTED_PATHS)) {
|
|
142
|
+
// Said once in the first line when every file is the same kind of change.
|
|
143
|
+
const mark = kinds.size === 1
|
|
144
|
+
? ''
|
|
145
|
+
: change.kind === 'add'
|
|
146
|
+
? 'new'
|
|
147
|
+
: change.kind === 'delete'
|
|
148
|
+
? 'deleted'
|
|
149
|
+
: '';
|
|
150
|
+
lines.push(`• ${change.path}${changeSummary(change)}${mark ? ` — ${mark}` : ''}`);
|
|
151
|
+
}
|
|
152
|
+
if (changes.length > MAX_LISTED_PATHS) {
|
|
153
|
+
lines.push(`• and ${changes.length - MAX_LISTED_PATHS} more`);
|
|
154
|
+
}
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* `/bin/zsh -lc 'npm test'` → `npm test`. Codex runs every command through a
|
|
159
|
+
* login shell; the wrapper is the same every time and hides the command. Only
|
|
160
|
+
* the plain one-quoted form is unwrapped, so nothing is ever shown that is not
|
|
161
|
+
* literally what the shell will be given.
|
|
162
|
+
*/
|
|
163
|
+
function unwrapShell(command) {
|
|
164
|
+
const match = /^(?:\/(?:usr\/)?bin\/)?(?:zsh|bash|sh) -l?c (['"])([\s\S]*)\1$/.exec(command.trim());
|
|
165
|
+
if (!match)
|
|
166
|
+
return command;
|
|
167
|
+
const [, quote, inner] = match;
|
|
168
|
+
return inner.includes(quote) || inner.includes('\\') ? command : inner;
|
|
169
|
+
}
|
|
170
|
+
function commandText(value) {
|
|
171
|
+
if (Array.isArray(value)) {
|
|
172
|
+
return value
|
|
173
|
+
.filter((part) => typeof part === 'string')
|
|
174
|
+
.join(' ');
|
|
175
|
+
}
|
|
176
|
+
return text(value);
|
|
177
|
+
}
|
|
178
|
+
function describePermissions(permissions) {
|
|
179
|
+
const lines = [];
|
|
180
|
+
if (!isRecord(permissions))
|
|
181
|
+
return lines;
|
|
182
|
+
if (isRecord(permissions.network) && permissions.network.enabled === true) {
|
|
183
|
+
lines.push('• network access');
|
|
184
|
+
}
|
|
185
|
+
if (isRecord(permissions.fileSystem)) {
|
|
186
|
+
const fileSystem = permissions.fileSystem;
|
|
187
|
+
const list = (key, label) => {
|
|
188
|
+
const paths = Array.isArray(fileSystem[key])
|
|
189
|
+
? fileSystem[key].filter((path) => typeof path === 'string')
|
|
190
|
+
: [];
|
|
191
|
+
for (const path of paths)
|
|
192
|
+
lines.push(`• ${label} ${path}`);
|
|
193
|
+
};
|
|
194
|
+
list('write', 'write to');
|
|
195
|
+
list('read', 'read');
|
|
196
|
+
if (Array.isArray(fileSystem.entries)) {
|
|
197
|
+
for (const entry of fileSystem.entries) {
|
|
198
|
+
if (!isRecord(entry))
|
|
199
|
+
continue;
|
|
200
|
+
const path = isRecord(entry.path)
|
|
201
|
+
? text(entry.path.path) || JSON.stringify(entry.path)
|
|
202
|
+
: text(entry.path);
|
|
203
|
+
const access = text(entry.access) || 'access';
|
|
204
|
+
if (path)
|
|
205
|
+
lines.push(`• ${access} ${path}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
211
|
+
/** `item/somethingNew/requestApproval` → "something new". */
|
|
212
|
+
function humanise(method) {
|
|
213
|
+
const words = method
|
|
214
|
+
.replace(/^item\//, '')
|
|
215
|
+
.replace(/\/?(requestApproval|request)$/i, '')
|
|
216
|
+
.replace(/Approval$/i, '')
|
|
217
|
+
.replace(/[/_]/g, ' ')
|
|
218
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
219
|
+
.trim()
|
|
220
|
+
.toLowerCase();
|
|
221
|
+
return words || 'an action';
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The text of one approval request.
|
|
225
|
+
*
|
|
226
|
+
* @param item the thread item the request is about, when the adapter has seen
|
|
227
|
+
* it (`item/started` arrives before the request). A file change's paths are
|
|
228
|
+
* only there.
|
|
229
|
+
*/
|
|
230
|
+
function describeCodexApproval(method, params, item,
|
|
231
|
+
/** The session's workspace; paths inside it are shown relative to it. */
|
|
232
|
+
workspaceDir) {
|
|
233
|
+
const reason = text(params.reason);
|
|
234
|
+
const lines = [];
|
|
235
|
+
const shown = (path) => relativeTo(workspaceDir, path);
|
|
236
|
+
if (method === 'item/commandExecution/requestApproval' ||
|
|
237
|
+
method === 'execCommandApproval') {
|
|
238
|
+
const command = unwrapShell(commandText(params.command) || commandText(item?.command) || '');
|
|
239
|
+
const network = isRecord(params.networkApprovalContext)
|
|
240
|
+
? params.networkApprovalContext
|
|
241
|
+
: null;
|
|
242
|
+
if (params.kind === 'writeStdin') {
|
|
243
|
+
lines.push(`Send input to a running command: ${oneLine(command)}`);
|
|
244
|
+
}
|
|
245
|
+
else if (command) {
|
|
246
|
+
lines.push(`Run: ${oneLine(command)}`);
|
|
247
|
+
}
|
|
248
|
+
else if (network) {
|
|
249
|
+
lines.push(`Network access: ${text(network.host)}`);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
lines.push('Run: a command');
|
|
253
|
+
}
|
|
254
|
+
const cwd = text(params.cwd) || text(item?.cwd);
|
|
255
|
+
// The workspace itself goes without saying; anywhere else is worth a line.
|
|
256
|
+
const where = cwd
|
|
257
|
+
? shown(`${cwd.replace(/\/+$/, '')}/.`).replace(/\/?\.$/, '')
|
|
258
|
+
: '';
|
|
259
|
+
if (where)
|
|
260
|
+
lines.push(`In: ${where}`);
|
|
261
|
+
if (network && command) {
|
|
262
|
+
lines.push(`Network access: ${text(network.host)}${text(network.protocol) ? ` (${text(network.protocol)})` : ''}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else if (method === 'item/fileChange/requestApproval' ||
|
|
266
|
+
method === 'applyPatchApproval') {
|
|
267
|
+
lines.push(...describeFileChanges(readFileChanges(params.fileChanges ?? item?.changes).map((change) => ({
|
|
268
|
+
...change,
|
|
269
|
+
path: shown(change.path)
|
|
270
|
+
}))));
|
|
271
|
+
const root = text(params.grantRoot);
|
|
272
|
+
if (root)
|
|
273
|
+
lines.push(`Also asks to write under: ${root}`);
|
|
274
|
+
}
|
|
275
|
+
else if (method === 'item/permissions/requestApproval') {
|
|
276
|
+
const wanted = describePermissions(params.permissions);
|
|
277
|
+
lines.push('Grant more access for this turn');
|
|
278
|
+
lines.push(...(wanted.length ? wanted : ['• wider sandbox access']));
|
|
279
|
+
const cwd = text(params.cwd);
|
|
280
|
+
if (cwd)
|
|
281
|
+
lines.push(`In: ${cwd}`);
|
|
282
|
+
}
|
|
283
|
+
else if (method === 'mcpServer/elicitation/request') {
|
|
284
|
+
const server = text(params.serverName) || 'An MCP server';
|
|
285
|
+
const message = text(params.message);
|
|
286
|
+
lines.push(message
|
|
287
|
+
? `${server} asks: ${oneLine(message)}`
|
|
288
|
+
: `${server} asks to continue`);
|
|
289
|
+
const url = text(params.url);
|
|
290
|
+
if (url)
|
|
291
|
+
lines.push(`Opens: ${url}`);
|
|
292
|
+
}
|
|
293
|
+
else if (method === 'item/tool/requestUserInput') {
|
|
294
|
+
const first = Array.isArray(params.questions) ? params.questions[0] : null;
|
|
295
|
+
const question = isRecord(first)
|
|
296
|
+
? text(first.question) || text(first.header)
|
|
297
|
+
: '';
|
|
298
|
+
lines.push(question ? `Codex asks: ${oneLine(question)}` : 'Codex asks a question');
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// A kind this build does not know. Say what is asked in plain words and
|
|
302
|
+
// show what the request itself says about it, never the method name alone.
|
|
303
|
+
const command = commandText(params.command);
|
|
304
|
+
lines.push(`Codex asks to approve: ${humanise(method)}`);
|
|
305
|
+
if (command)
|
|
306
|
+
lines.push(`Command: ${oneLine(command)}`);
|
|
307
|
+
const cwd = text(params.cwd);
|
|
308
|
+
if (cwd)
|
|
309
|
+
lines.push(`In: ${cwd}`);
|
|
310
|
+
}
|
|
311
|
+
if (reason)
|
|
312
|
+
lines.push(`Reason: ${oneLine(reason)}`);
|
|
313
|
+
return lines.join('\n');
|
|
314
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.2.9-rc.6",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|