@peakflo/agent-harness 0.2.9-rc.6 → 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
@@ -126,6 +126,11 @@ An approval request says what will happen in its first line ("Run: npm test",
126
126
  Codex can send. "Deny" refuses the action and lets the turn go on, so the agent
127
127
  says that it was not allowed; the denied action never runs.
128
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
+
129
134
  Cursor reports an account limit as a normal reply ("Upgrade your plan to
130
135
  continue"). The runner turns a reply that is only that sentence into a failed
131
136
  turn that says it comes from the Cursor account on the runner.
@@ -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;
@@ -89,6 +94,19 @@ export declare class CodexAppServerAdapter implements CodingAgentAdapter {
89
94
  private setupStdoutParser;
90
95
  private handleRpcMessage;
91
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>;
92
110
  private handleNotification;
93
111
  private handleApprovalRequest;
94
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`.
@@ -106,6 +107,26 @@ function isStandingApprovalDecision(optionId) {
106
107
  * any file change — is sent to the client as an approval request before it
107
108
  * runs. That is what "Ask before each action" says.
108
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
+ }
109
130
  function codexApprovalPolicy(config) {
110
131
  // `'allow'` is what `mapPermissionMode` makes of the agent's
111
132
  // `bypassPermissions` ("Don't ask for permission"), and of nothing else.
@@ -803,12 +824,113 @@ class CodexAppServerAdapter {
803
824
  this.handleApprovalRequest(session, request, params);
804
825
  return;
805
826
  }
806
- 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
+ }
807
850
  this.handleApprovalRequest(session, request, params);
808
851
  return;
809
852
  }
810
853
  this.sendRpcResponse(session, request.id, {});
811
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
+ }
812
934
  handleNotification(session, notification) {
813
935
  const params = isObject(notification.params) ? notification.params : {};
814
936
  if (notification.method === 'thread/status/changed') {
@@ -822,6 +944,9 @@ class CodexAppServerAdapter {
822
944
  }
823
945
  if (notification.method === 'turn/completed') {
824
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;
825
950
  if (session.threadId) {
826
951
  session.pendingCompletionRefreshes += 1;
827
952
  void this.reconcileCompletedTurn(session);
@@ -853,6 +978,13 @@ class CodexAppServerAdapter {
853
978
  }
854
979
  if (notification.method === 'serverRequest/resolved') {
855
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
+ }
856
988
  if (session.pendingApproval && String(session.pendingApproval.requestId) === String(requestId)) {
857
989
  session.pendingApproval = null;
858
990
  if (session.status === coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL) {
@@ -1184,6 +1316,29 @@ class CodexAppServerAdapter {
1184
1316
  if ((method === 'item/started' || method === 'item/completed') && item) {
1185
1317
  return this.convertThreadItem(method, item, params, seenMessageIds, seenPartIds, partContentLengths, session);
1186
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
+ }
1187
1342
  if (method === 'error') {
1188
1343
  const partId = `error-${Date.now()}`;
1189
1344
  return [{
@@ -21,6 +21,10 @@
21
21
  * Kept out of the adapter, which is a copy of 20x's, so it can be tested alone.
22
22
  */
23
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;
24
28
  interface FileChange {
25
29
  path: string;
26
30
  kind: string;
@@ -22,6 +22,7 @@
22
22
  * Kept out of the adapter, which is a copy of 20x's, so it can be tested alone.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.MAX_SHOWN_NOTE = exports.MAX_SHOWN_COMMAND = void 0;
25
26
  exports.readFileChanges = readFileChanges;
26
27
  exports.describeCodexApproval = describeCodexApproval;
27
28
  function isRecord(value) {
@@ -35,11 +36,49 @@ function oneLine(value) {
35
36
  return value.replace(/\s*\n\s*/g, ' ⏎ ').trim();
36
37
  }
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
+ }
38
73
  /** `/work/space/src/a.ts` in `/work/space` → `src/a.ts`. Others unchanged. */
39
74
  function relativeTo(workspaceDir, path) {
40
75
  const root = (workspaceDir ?? '').replace(/\/+$/, '');
41
76
  if (!root)
42
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;
43
82
  // macOS reports the same directory as /tmp/… and /private/tmp/….
44
83
  for (const prefix of [
45
84
  root,
@@ -161,11 +200,14 @@ function describeFileChanges(changes) {
161
200
  * literally what the shell will be given.
162
201
  */
163
202
  function unwrapShell(command) {
164
- const match = /^(?:\/(?:usr\/)?bin\/)?(?:zsh|bash|sh) -l?c (['"])([\s\S]*)\1$/.exec(command.trim());
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());
165
207
  if (!match)
166
208
  return command;
167
- const [, quote, inner] = match;
168
- return inner.includes(quote) || inner.includes('\\') ? command : inner;
209
+ const inner = match[1];
210
+ return inner.includes("'") ? command : inner;
169
211
  }
170
212
  function commandText(value) {
171
213
  if (Array.isArray(value)) {
@@ -239,11 +281,18 @@ workspaceDir) {
239
281
  const network = isRecord(params.networkApprovalContext)
240
282
  ? params.networkApprovalContext
241
283
  : null;
284
+ const capped = shownCommand(command);
242
285
  if (params.kind === 'writeStdin') {
243
- lines.push(`Send input to a running command: ${oneLine(command)}`);
286
+ lines.push(`Send input to a running command: ${capped.text}`);
287
+ if (capped.hidden)
288
+ lines.push(notShownInFull(command));
244
289
  }
245
290
  else if (command) {
246
- lines.push(`Run: ${oneLine(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));
247
296
  }
248
297
  else if (network) {
249
298
  lines.push(`Network access: ${text(network.host)}`);
@@ -284,7 +333,7 @@ workspaceDir) {
284
333
  const server = text(params.serverName) || 'An MCP server';
285
334
  const message = text(params.message);
286
335
  lines.push(message
287
- ? `${server} asks: ${oneLine(message)}`
336
+ ? `${server} asks: ${note(message)}`
288
337
  : `${server} asks to continue`);
289
338
  const url = text(params.url);
290
339
  if (url)
@@ -295,20 +344,23 @@ workspaceDir) {
295
344
  const question = isRecord(first)
296
345
  ? text(first.question) || text(first.header)
297
346
  : '';
298
- lines.push(question ? `Codex asks: ${oneLine(question)}` : 'Codex asks a question');
347
+ lines.push(question ? `Codex asks: ${note(question)}` : 'Codex asks a question');
299
348
  }
300
349
  else {
301
350
  // A kind this build does not know. Say what is asked in plain words and
302
351
  // show what the request itself says about it, never the method name alone.
303
352
  const command = commandText(params.command);
304
353
  lines.push(`Codex asks to approve: ${humanise(method)}`);
305
- if (command)
306
- lines.push(`Command: ${oneLine(command)}`);
354
+ if (command) {
355
+ lines.push(`Command: ${shownCommand(command).text}`);
356
+ if (shownCommand(command).hidden)
357
+ lines.push(notShownInFull(command));
358
+ }
307
359
  const cwd = text(params.cwd);
308
360
  if (cwd)
309
361
  lines.push(`In: ${cwd}`);
310
362
  }
311
363
  if (reason)
312
- lines.push(`Reason: ${oneLine(reason)}`);
364
+ lines.push(`Reason: ${note(reason)}`);
313
365
  return lines.join('\n');
314
366
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.9-rc.6",
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",