Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/adapters/cli/codex-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { resolveCommand } from './registry.js';
import type { CliAdapter, PtyHandle } from './types.js';
import { writeRunnerInput } from './runner-input.js';
import { writeRunnerInput, writeRunnerAnswer } from './runner-input.js';

function runnerPath(): string {
const here = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -43,7 +43,7 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter {
return [(cachedCodexBin ??= resolveCommand(rawCodexBin))];
},

buildArgs({ sessionId, resume, resumeSessionId, workingDir, botName, botOpenId, locale }) {
buildArgs({ sessionId, resume, resumeSessionId, workingDir, botName, botOpenId, locale, model, reasoningEffort }) {
const args = [
runnerPath(),
'--session-id', sessionId,
Expand All @@ -54,6 +54,10 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter {
pushOpt(args, '--bot-name', botName);
pushOpt(args, '--bot-open-id', botOpenId);
pushOpt(args, '--locale', locale);
// Per-turn overrides (async trigger API). The runner injects them into the
// app-server thread/start config (model + model_reasoning_effort).
pushOpt(args, '--model', model && model.trim() ? model.trim() : undefined);
pushOpt(args, '--reasoning-effort', reasoningEffort);
return args;
},

Expand All @@ -78,6 +82,12 @@ export function createCodexAppAdapter(pathOverride?: string): CliAdapter {
return writeRunnerInput(pty, '::botmux-codex-app:', content, codexAppInput);
},

async writeInteractionAnswer(pty, interactionId, text) {
// Resolve a held awaiting_input interaction (structured clarification /
// elicitation) — same control channel as input, distinct payload type.
return writeRunnerAnswer(pty, '::botmux-codex-app:', interactionId, text);
},

completionPattern: undefined,
readyPattern: /›/,
systemHints: [],
Expand Down
9 changes: 8 additions & 1 deletion src/adapters/cli/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
authPaths: ['~/.codex'],
get resolvedBin(): string { return (cachedBin ??= resolveCommand(rawBin)); },

buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) {
buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, reasoningEffort, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) {
// Hybrid RPC input mode: attach this TUI to the botmux-owned app-server
// thread. User input is delivered out-of-band via JSON-RPC (turn/start,
// see codex-rpc-engine + worker), so the pane is a pure viewer — no paste
Expand Down Expand Up @@ -193,6 +193,13 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
// Codex 接受 `--model <id>` / `-m <id>`,写全名最稳,错的会在 codex 自己启动时报。
baseArgs.push('--model', model.trim());
}
if (reasoningEffort) {
// Per-turn reasoning effort → codex 的 model_reasoning_effort 配置项。
// 走 `-c` 进程级覆盖(不动用户全局 config);codex 只接受 low/medium/high,
// botmux 的 'xhigh' 档位向下收敛到 'high'(codex 无更高档)。
const codexEffort = reasoningEffort === 'xhigh' ? 'high' : reasoningEffort;
baseArgs.push('-c', `model_reasoning_effort=${JSON.stringify(codexEffort)}`);
}
// Codex app-server can keep its own cwd at $HOME; -C pins fresh agent roots.
// NOTE: canonicalization of workingDir for the file sandbox is done ONCE in
// worker.ts (only when sandboxRequested), so off-sandbox spawns keep the
Expand Down
26 changes: 25 additions & 1 deletion src/adapters/cli/runner-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export function encodeRunnerInput(content: string, codexAppInput?: CodexAppTurnI
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
}

/** Encode a structured-interaction answer (resolves a held awaiting_input in the
* codex-app runner) rather than a new turn message. */
export function encodeRunnerAnswer(interactionId: string, text: string): string {
const payload = { type: 'answer', interactionId, text };
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
}

/** Split an ASCII string into <=maxBytes pieces. Safe because the caller only
* ever passes `marker + base64`, which is single-byte throughout. */
export function chunkAscii(line: string, maxBytes: number): string[] {
Expand Down Expand Up @@ -85,7 +92,24 @@ export async function writeRunnerInput(
content: string,
codexAppInput?: CodexAppTurnInput,
): Promise<{ submitted: boolean }> {
const line = `${markerPrefix}${encodeRunnerInput(content, codexAppInput)}`;
return writeRunnerEncodedLine(pty, `${markerPrefix}${encodeRunnerInput(content, codexAppInput)}`);
}

/** Send a pre-encoded runner control line (e.g. an interaction answer). Shares
* the same chunked/throttled + pre-flush submit path as writeRunnerInput. */
export async function writeRunnerAnswer(
pty: PtyHandle,
markerPrefix: string,
interactionId: string,
text: string,
): Promise<{ submitted: boolean }> {
return writeRunnerEncodedLine(pty, `${markerPrefix}${encodeRunnerAnswer(interactionId, text)}`);
}

async function writeRunnerEncodedLine(
pty: PtyHandle,
line: string,
): Promise<{ submitted: boolean }> {

// Non-tmux fallback (raw PTY): a single write is fine — there's no send-keys
// process to time out, and the PTY write isn't bounded the same way.
Expand Down
13 changes: 13 additions & 0 deletions src/adapters/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export interface CliAdapter {
* `--model` flag (or equivalent) inject it here; adapters whose CLI has no
* such concept simply ignore the field. Empty / undefined → CLI default. */
model?: string;
/** Optional per-turn reasoning effort (codex `model_reasoning_effort`).
* Only codex/codex-app adapters honor it; others ignore. Empty/undefined →
* CLI/config default. */
reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
/** When true, do not add adapter-default flags that bypass CLI approvals or disable sandboxing. */
disableCliBypass?: boolean;
/** Optional session-scoped skill plugin/root prepared by botmux. */
Expand Down Expand Up @@ -215,6 +219,15 @@ export interface CliAdapter {
recheck?: () => SubmitRecheckResult | Promise<SubmitRecheckResult>;
}>;

/** Optional: resolve a held awaiting_input interaction (structured
* clarification / elicitation) with answer text. Only adapters running a
* structured runner that can hold interactions open (codex-app) implement it. */
writeInteractionAnswer?(
pty: PtyHandle,
interactionId: string,
text: string,
): Promise<{ submitted: boolean }>;

/** Optional: absolute path (with ~ expansion handled by caller) to the CLI's
* skill directory. When set, `ensureSkills` will write/refresh skill files
* into `{skillsDir}/<skillName>/SKILL.md`. Undefined = this CLI does not
Expand Down
155 changes: 152 additions & 3 deletions src/codex-app-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ interface Args {
botName?: string;
botOpenId?: string;
locale?: string;
model?: string;
reasoningEffort?: string;
}

interface PendingRequest {
Expand Down Expand Up @@ -70,6 +72,8 @@ function parseArgs(argv: string[]): Args {
else if (key === '--bot-name' && val !== undefined) { out.botName = val; i++; }
else if (key === '--bot-open-id' && val !== undefined) { out.botOpenId = val; i++; }
else if (key === '--locale' && val !== undefined) { out.locale = val; i++; }
else if (key === '--model' && val !== undefined) { out.model = val; i++; }
else if (key === '--reasoning-effort' && val !== undefined) { out.reasoningEffort = val; i++; }
}
if (!out.sessionId) throw new Error('--session-id is required');
return out;
Expand Down Expand Up @@ -267,6 +271,114 @@ let codexVersionChecked = false;
let codexVersion: CodexVersion | undefined;
let cleanVersionWarningShown = false;

/** Structured interaction requests (requestUserInput / elicitation) that are
* held open awaiting a human/programmatic answer instead of being auto-declined.
* Keyed by a botmux-minted interactionId. `finish` maps the answer text to the
* codex-app protocol-shaped respond payload and replies to the app-server. */
interface PendingInteraction {
interactionId: string;
kind: 'clarification' | 'confirmation' | 'authentication';
finish: (answerText: string) => void;
}
const pendingInteractions = new Map<string, PendingInteraction>();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — pending map 没有协议级回收/恢复。 app-server 会在请求被响应或因 turn lifecycle 清理时发 serverRequest/resolved,但 runner 不消费它,也不在 turn completion/interrupt/restart 清理或重放。未答请求会留下 stale closure;daemon 的 awaiting 仅内存,daemon/worker 重连后 runner 可能仍 hold,但新 daemon 已丢 marker并永久报告 running;多个并发请求还会被 daemon 单槽覆盖。请用 requestId↔interactionId 关联消费 resolved,并定义 restart 的 replay 或 fail-closed 策略。

let interactionSeq = 0;

/** codex-app's requestUserInput answer schema (Codex 0.145 generated types) is
* `{ answers: { [questionId]: { answers: string[] } } }` — a per-question map,
* each holding a string array. v1 collapses the single free-text reply (per the
* botmux↔riff plain-text contract) into the FIRST question's answers array; a
* multi-question schema is logged so it surfaces rather than silently dropping. */
function mapAnswerToRequestUserInput(params: any, answerText: string): { answers: Record<string, { answers: string[] }> } {
const fieldIds: string[] = Array.isArray(params?.questions)
? params.questions.map((q: any) => q?.id).filter((x: any) => typeof x === 'string')
: [];
if (fieldIds.length > 1) {
writeLine(`[codex-app] requestUserInput has ${fieldIds.length} questions; single text answer maps to first (id=${fieldIds[0]})`);
}
const key = fieldIds[0] ?? 'answer';
return { answers: { [key]: { answers: [answerText] } } };
}

/** Best-effort question text from a codex-app interaction request. The exact
* param shape varies by codex version; try the common carriers and fall back
* to the caller's default. */
function extractInteractionQuestion(params: any): string | undefined {
if (!params || typeof params !== 'object') return undefined;
const candidates = [
params.message,
params.prompt,
params.question,
Array.isArray(params.questions) ? params.questions[0]?.prompt ?? params.questions[0]?.question ?? params.questions[0]?.label : undefined,
];
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) return c.trim();
}
return undefined;
}

function extractInteractionDetails(params: any): string | undefined {
if (!params || typeof params !== 'object') return undefined;
const d = params.details ?? params.description ?? params.context;
return typeof d === 'string' && d.trim() ? d.trim() : undefined;
}

/** Map an elicitation carrying OAuth/login links into the riff authChallenge
* shape. Returns undefined when no link-bearing structure is present (→ the
* interaction is treated as a plain clarification). */
function extractAuthChallenge(params: any): { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string } | undefined {
if (!params || typeof params !== 'object') return undefined;
const rawLinks = params.links ?? params.authLinks ?? (params.url ? [{ url: params.url }] : undefined);
if (!Array.isArray(rawLinks)) return undefined;
const links = rawLinks
.map((l: any) => (typeof l === 'string' ? { url: l } : (l && typeof l.url === 'string' ? { url: l.url, label: typeof l.label === 'string' ? l.label : undefined } : undefined)))
.filter((l: any): l is { url: string; label?: string } => !!l);
if (!links.length) return undefined;
return {
links,
userCode: typeof params.userCode === 'string' ? params.userCode : undefined,
instructions: typeof params.instructions === 'string' ? params.instructions : undefined,
expiresAt: typeof params.expiresAt === 'string' ? params.expiresAt : undefined,
};
}

/** Register a held interaction and emit the awaiting_input marker to the worker
* (→ daemon → trigger-result). turnId is the runner's active botmux turn so the
* caller can correlate its answer. */
function beginInteraction(input: {
kind: 'clarification' | 'confirmation' | 'authentication';
question: string;
details?: string;
authChallenge?: { links: { url: string; label?: string }[]; userCode?: string; instructions?: string; expiresAt?: string };
finish: (answerText: string) => void;
}): void {
const interactionId = `cai_${args.sessionId}_${++interactionSeq}`;
pendingInteractions.set(interactionId, { interactionId, kind: input.kind, finish: input.finish });
emitMarker('awaiting_input', {
interactionId,
turnId: activeTurn?.nativeTurnId ?? undefined,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — 这里发的是 app-server native turn UUID,不是 botmux/riff 的 turn identity。 同文件 final marker 已明确 native id 会破坏 wait map/回复路由,交互同样应携带本次 queued input 冻结的 clientUserMessageId/replyTurnId,并单独保留 appTurnId 仅用于协议匹配。现在 daemon 的 awaiting 又是 session 级单槽、lookup 不按 requested triggerId 过滤,所以 multi-turn/fold-in session 还会把后一个交互返回给前一个 trigger 查询。请绑定 replyTurnId/triggerId,并在 lookup 时匹配。

kind: input.kind,
question: input.question,
...(input.details ? { details: input.details } : {}),
...(input.authChallenge ? { authChallenge: input.authChallenge } : {}),
});
}

/** Resolve a held interaction with answer text (from the runner input channel).
* Unknown/stale ids are ignored (the turn may have moved on). */
function answerInteraction(interactionId: string, text: string): void {
const pending = pendingInteractions.get(interactionId);
if (!pending) {
writeLine(`[codex-app] answer for unknown interaction ${interactionId} (ignored)`);
return;
}
pendingInteractions.delete(interactionId);
try {
pending.finish(text);
} catch (err: any) {
writeLine(`[codex-app] failed to deliver interaction answer: ${err?.message ?? err}`);
}
}

function detectedCodexVersion(): CodexVersion | undefined {
if (codexVersionChecked) return codexVersion;
codexVersionChecked = true;
Expand Down Expand Up @@ -313,11 +425,35 @@ function handleServerRequest(msg: JsonObject): boolean {
return true;
}
if (method === 'item/tool/requestUserInput') {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — 这里会挂住所有 codex-app 会话,不只 async trigger。 同一个 runner 也服务普通飞书会话、同步 trigger 和其他非轮询调用方;当前没有任何 asyncReturnSessionId/capability gate,而 daemon 只把 marker 暴露到 async trigger-result,普通会话没有 UI/answer 消费者。模型一旦 requestUserInput,该 turn 会永久等待。请在每个 turn/input 上冻结“允许程序化 awaiting_input”的能力;未开启时保留旧自动响应,或同时实现普通会话的人机交互通道。

client.respond(msg.id, { answers: {} });
// Structured clarification: hold the request open and surface it as an
// awaiting_input interaction. The answer text arrives later via the runner
// input channel and is mapped back to codex-app's keyed `answers` shape.
const params: any = msg.params ?? {};
const question = extractInteractionQuestion(params)
?? 'The agent needs additional input to continue.';
const details = extractInteractionDetails(params);
beginInteraction({
kind: 'clarification',
question,
details,
finish: (text) => client.respond(msg.id, mapAnswerToRequestUserInput(params, text)),
});
return true;
}
if (method === 'mcpServer/elicitation/request') {
client.respond(msg.id, { action: 'cancel', content: null, _meta: null });
const params: any = msg.params ?? {};
const question = extractInteractionQuestion(params)
?? (typeof params.message === 'string' ? params.message : 'The agent is requesting input.');
const authChallenge = extractAuthChallenge(params);
beginInteraction({
kind: authChallenge ? 'authentication' : 'clarification',
question,
details: extractInteractionDetails(params),
authChallenge,
// v1: any answered text = accept + single-field content; cancel is only
// used on timeout/no-answer (handled daemon-side, not here).
finish: (text) => client.respond(msg.id, { action: 'accept', content: { answer: text }, _meta: null }),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — 这个映射对通用 elicitation 不成立。 Codex 0.145 的 params 是 tagged union:form/openai-form 携带 requestedSchema,url 模式携带 url + elicitationId。无条件回 {content:{answer:text}} 通常不满足表单 schema,URL mode 也不是该响应形态。请把 mode/schema 暴露给调用方并按 schema 映射/验证;无法表达的 form 应 decline/cancel,URL auth 单独处理,不能用一个自由文本宣称覆盖任意 elicitation。

});
return true;
}
if (method === 'item/tool/call') {
Expand Down Expand Up @@ -420,7 +556,14 @@ async function ensureThread(): Promise<string> {
cwd: args.cwd,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
config: { shell_environment_policy: { inherit: 'all' } },
config: {
shell_environment_policy: { inherit: 'all' },
// model_reasoning_effort is a codex config key (no top-level thread field).
// 'xhigh' collapses to codex's max 'high'.
...(args.reasoningEffort ? { model_reasoning_effort: args.reasoningEffort === 'xhigh' ? 'high' : args.reasoningEffort } : {}),
},
// Per-turn model override → thread-level model (app-server's documented spot).
...(args.model && args.model.trim() ? { model: args.model.trim() } : {}),
serviceName: 'botmux',
developerInstructions: appDeveloperInstructions(args),
ephemeral: false,
Expand Down Expand Up @@ -549,6 +692,12 @@ function enqueueLine(line: string): void {
const encoded = trimmed.slice('::botmux-codex-app:'.length);
try {
const decoded = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
if (decoded?.type === 'answer' && typeof decoded.interactionId === 'string') {
// Programmatic/human answer to a held structured interaction — resolve
// the codex-app request rather than enqueue a new turn.
answerInteraction(decoded.interactionId, typeof decoded.text === 'string' ? decoded.text : '');
return;
}
if (decoded?.type === 'message' && typeof decoded.content === 'string') {
const codexAppInput = isCodexAppTurnInput(decoded.codexAppInput)
? decoded.codexAppInput
Expand Down
1 change: 1 addition & 0 deletions src/core/cost-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ function usageKindForCli(cliId: SessionTokenUsageQuery['cliId']): UsageKind {
case 'relay':
return 'claude';
case 'codex':
case 'codex-app':
// TRAE rollouts are byte-identical to Codex (see traex-transcript.ts):
// token_count events carry the cumulative totals, and the active model
// rides on turn_context/session_meta payloads. The generic fold picked up
Expand Down
Loading