@harness-mix/cli 0.2.2 → 0.2.4
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/CHANGELOG.md +25 -0
- package/README.md +469 -467
- package/output/native-build/desktop-controller.mjs +1 -1
- package/output/native-build/renderer-extension.js +23 -4
- package/package.json +16 -9
- package/scripts/antigravity-adapter-test.cjs +647 -626
- package/scripts/codex-adapter-test.cjs +162 -127
- package/scripts/collaboration-test.cjs +274 -262
- package/scripts/core-review-test.cjs +68 -0
- package/scripts/delegation-await-test.cjs +76 -0
- package/scripts/jsonl-stdin-test.cjs +40 -0
- package/scripts/kiro-cursor-adapters-test.cjs +124 -100
- package/scripts/native-acp-depth-test.cjs +30 -5
- package/scripts/native-protocol-test.cjs +14 -1
- package/scripts/native-update-apply-test.cjs +269 -215
- package/scripts/native-update.cjs +78 -0
- package/scripts/native-vendor-adapters-test.cjs +196 -154
- package/scripts/salvage-rollout-writes.cjs +72 -0
- package/scripts/send-cancel-race-test.cjs +80 -0
- package/scripts/send-pre-turn-cancel-test.cjs +100 -0
- package/scripts/stuck-turn-test.cjs +6 -1
- package/scripts/zcode-adapter-test.cjs +329 -0
- package/scripts/zcode-live-probe.cjs +66 -0
- package/src/main/adapters/antigravity.js +1428 -1415
- package/src/main/adapters/codex.js +656 -649
- package/src/main/adapters/native-acp-command.js +51 -48
- package/src/main/adapters/native-acp.js +47 -12
- package/src/main/adapters/qoder.js +12 -8
- package/src/main/adapters/zcode.js +921 -10
- package/src/main/harness-adapter/event-normalizer.js +5 -2
- package/src/main/host/collaboration.js +723 -715
- package/src/main/host/jsonl.js +130 -116
- package/src/main/host/runtime.js +30 -14
- package/src/main/native/config.js +9 -9
- package/src/main/native/host.js +2 -0
- package/src/main/native/launcher.js +252 -237
- package/src/main/native/process-utils.js +157 -57
- package/src/main/native/protocol.js +1221 -1177
- package/src/main/native/secure-store.js +2 -0
- package/src/main/native/update-state.js +123 -110
- package/src/main/native/updater.js +460 -394
- package/src/main/workspace/core-review.js +13 -5
- package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3211 -3181
- package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
|
@@ -1,649 +1,656 @@
|
|
|
1
|
-
const { execFile } = require('node:child_process');
|
|
2
|
-
const { CodexAppServer } = require('./codex-app-server');
|
|
3
|
-
const { cliSpawn } = require('../host/jsonl');
|
|
4
|
-
const { recordNative } = require('../harness-adapter/fixture-recorder');
|
|
5
|
-
const { codexErrorInfoKey } = require('../harness-adapter/error-kind');
|
|
6
|
-
|
|
7
|
-
const manifest = {
|
|
8
|
-
id: 'codex',
|
|
9
|
-
name: 'Codex',
|
|
10
|
-
icon: 'codex-color.svg',
|
|
11
|
-
aliases: ['codex', 'codex-harness'],
|
|
12
|
-
capabilities: {
|
|
13
|
-
plan: true, streaming: true, thinking: true, tools: true,
|
|
14
|
-
approvals: true, questions: true, models: true, thinkingLevels: true,
|
|
15
|
-
permissionModes: true, resume: true, fork: true, forkFromMessage: true,
|
|
16
|
-
compaction: true, nativeDiff: true, nativePatch: true,
|
|
17
|
-
usage: true, contextUsage: true, cost: false, attachments: true,
|
|
18
|
-
collaborationTools: true,
|
|
19
|
-
},
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const MAX_TOOL_TEXT = 24_000;
|
|
23
|
-
|
|
24
|
-
function text(value) {
|
|
25
|
-
if (value == null) return undefined;
|
|
26
|
-
const result = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
27
|
-
return result.length > MAX_TOOL_TEXT ? `${result.slice(0, MAX_TOOL_TEXT)}\n[桌面预览已截断]` : result;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function modelView(model) {
|
|
31
|
-
return {
|
|
32
|
-
id: model.model ?? model.id,
|
|
33
|
-
name: model.displayName ?? model.model ?? model.id,
|
|
34
|
-
provider: 'openai',
|
|
35
|
-
description: model.description,
|
|
36
|
-
efforts: (model.supportedReasoningEfforts ?? []).map((entry) => ({
|
|
37
|
-
id: entry.reasoningEffort,
|
|
38
|
-
label: entry.reasoningEffort,
|
|
39
|
-
hint: entry.description,
|
|
40
|
-
})),
|
|
41
|
-
defaultEffort: model.defaultReasoningEffort,
|
|
42
|
-
contextWindow: model.contextWindow,
|
|
43
|
-
isDefault: model.isDefault === true,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function listAll(host, method, params = {}) {
|
|
48
|
-
const data = [];
|
|
49
|
-
let cursor = null;
|
|
50
|
-
do {
|
|
51
|
-
const page = await host.request(method, { ...params, ...(cursor ? { cursor } : {}) });
|
|
52
|
-
data.push(...(page?.data ?? []));
|
|
53
|
-
cursor = page?.nextCursor ?? null;
|
|
54
|
-
} while (cursor);
|
|
55
|
-
return data;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function toolTitle(item) {
|
|
59
|
-
if (!item) return 'Codex 工具';
|
|
60
|
-
if (item.type === 'commandExecution') return 'exec_command';
|
|
61
|
-
if (item.type === 'fileChange') return 'edit';
|
|
62
|
-
if (item.type === 'mcpToolCall') return `${item.server}/${item.tool}`;
|
|
63
|
-
if (item.type === 'dynamicToolCall') return [item.namespace, item.tool].filter(Boolean).join('/') || '工具';
|
|
64
|
-
if (item.type === 'collabAgentToolCall') return `Agent · ${item.tool}`;
|
|
65
|
-
if (item.type === 'webSearch') return '搜索网页';
|
|
66
|
-
if (item.type === 'imageView') return '查看图片';
|
|
67
|
-
if (item.type === 'imageGeneration') return '生成图片';
|
|
68
|
-
if (item.type === 'subAgentActivity') return `子 Agent · ${item.kind}`;
|
|
69
|
-
return item.type || 'Codex 工具';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function toolState(item) {
|
|
73
|
-
const status = item?.status;
|
|
74
|
-
if (['completed', 'success'].includes(status)) return 'done';
|
|
75
|
-
if (['failed', 'declined', 'error'].includes(status)) return 'error';
|
|
76
|
-
return 'running';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function toolInput(item) {
|
|
80
|
-
if (item?.type === 'commandExecution') return item.command;
|
|
81
|
-
if (item?.type === 'fileChange') return (item.changes ?? []).map(change => change.path).filter(Boolean).join('\n') || undefined;
|
|
82
|
-
if (item?.type === 'mcpToolCall' || item?.type === 'dynamicToolCall') return text(item.arguments);
|
|
83
|
-
if (item?.type === 'collabAgentToolCall') return item.prompt;
|
|
84
|
-
if (item?.type === 'imageView') return item.path;
|
|
85
|
-
return undefined;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function toolOutput(item, session) {
|
|
89
|
-
if (item?.type === 'commandExecution') return item.aggregatedOutput ?? session?.state?.toolOutput.get(item.id);
|
|
90
|
-
if (item?.type === 'mcpToolCall') return text(item.result ?? item.error);
|
|
91
|
-
if (item?.type === 'dynamicToolCall') return text(item.contentItems);
|
|
92
|
-
return session?.state?.toolOutput.get(item?.id);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function nativeChanges(item, complete = false) {
|
|
96
|
-
return (item?.changes ?? []).map((change) => ({
|
|
97
|
-
path: change.path,
|
|
98
|
-
patch: change.diff,
|
|
99
|
-
changeType: change.kind?.type ?? change.kind ?? 'modified',
|
|
100
|
-
complete,
|
|
101
|
-
nativeRef: { toolCallId: item.id },
|
|
102
|
-
}));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function usageView(tokenUsage) {
|
|
106
|
-
if (!tokenUsage) return undefined;
|
|
107
|
-
const last = tokenUsage.last ?? {};
|
|
108
|
-
const total = tokenUsage.total ?? {};
|
|
109
|
-
const tokens = Number.isFinite(last.totalTokens) ? last.totalTokens : null;
|
|
110
|
-
const contextWindow = Number.isFinite(tokenUsage.modelContextWindow) ? tokenUsage.modelContextWindow : 128_000;
|
|
111
|
-
return {
|
|
112
|
-
tokens,
|
|
113
|
-
contextWindow,
|
|
114
|
-
contextPercent: tokens != null && contextWindow ? 100 * tokens / contextWindow : null,
|
|
115
|
-
inputTokens: total.inputTokens ?? null,
|
|
116
|
-
outputTokens: total.outputTokens ?? null,
|
|
117
|
-
cachedInputTokens: total.cachedInputTokens ?? null,
|
|
118
|
-
reasoningOutputTokens: total.reasoningOutputTokens ?? null,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function emitTool(item, session, emit, state = toolState(item)) {
|
|
123
|
-
emit({
|
|
124
|
-
kind: 'tool', toolCallId: item.id, title: toolTitle(item), state,
|
|
125
|
-
input: toolInput(item), output: toolOutput(item, session),
|
|
126
|
-
nativeRef: { sessionId: session.nativeSessionId, turnId: session.state.nativeTurnId, itemId: item.id, toolCallId: item.id },
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function projectNotification(message, session, emit) {
|
|
131
|
-
recordNative(manifest.id, message);
|
|
132
|
-
const { method, params = {} } = message ?? {};
|
|
133
|
-
const nativeRef = {
|
|
134
|
-
sessionId: session.nativeSessionId,
|
|
135
|
-
...(params.turnId || params.turn?.id ? { turnId: params.turnId ?? params.turn.id } : {}),
|
|
136
|
-
...(params.itemId || params.item?.id ? { itemId: params.itemId ?? params.item.id } : {}),
|
|
137
|
-
};
|
|
138
|
-
switch (method) {
|
|
139
|
-
case 'turn/started':
|
|
140
|
-
session.state.nativeTurnId = params.turn?.id;
|
|
141
|
-
break;
|
|
142
|
-
case 'item/agentMessage/delta':
|
|
143
|
-
session.state.itemText.set(params.itemId, (session.state.itemText.get(params.itemId) ?? '') + params.delta);
|
|
144
|
-
emit({ kind: 'text-delta', text: params.delta, nativeRef });
|
|
145
|
-
break;
|
|
146
|
-
case 'item/reasoning/summaryTextDelta':
|
|
147
|
-
case 'item/reasoning/textDelta':
|
|
148
|
-
session.state.reasoningItems.add(params.itemId);
|
|
149
|
-
emit({ kind: 'thinking-delta', text: params.delta, nativeRef });
|
|
150
|
-
break;
|
|
151
|
-
case 'turn/plan/updated':
|
|
152
|
-
emit({ kind: 'plan', entries: (params.plan ?? []).map((entry) => ({
|
|
153
|
-
text: entry.step ?? entry.text ?? '', status: entry.status,
|
|
154
|
-
})), nativeRef });
|
|
155
|
-
break;
|
|
156
|
-
case 'item/started': {
|
|
157
|
-
const item = params.item;
|
|
158
|
-
if (['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'collabAgentToolCall', 'webSearch', 'imageView', 'imageGeneration', 'subAgentActivity'].includes(item?.type)) {
|
|
159
|
-
emitTool(item, session, emit, 'running');
|
|
160
|
-
}
|
|
161
|
-
break;
|
|
162
|
-
}
|
|
163
|
-
case 'item/commandExecution/outputDelta': {
|
|
164
|
-
const output = (session.state.toolOutput.get(params.itemId) ?? '') + (params.delta ?? '');
|
|
165
|
-
session.state.toolOutput.set(params.itemId, output.slice(-MAX_TOOL_TEXT));
|
|
166
|
-
emit({ kind: 'tool', toolCallId: params.itemId, state: 'running', output: session.state.toolOutput.get(params.itemId), nativeRef });
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
case 'item/fileChange/patchUpdated':
|
|
170
|
-
emit({ kind: 'file-change', source: 'native', changes: nativeChanges({ id: params.itemId, status: 'inProgress', changes: params.changes }), nativeRef });
|
|
171
|
-
break;
|
|
172
|
-
case 'item/completed': {
|
|
173
|
-
const item = params.item;
|
|
174
|
-
if (item?.type === 'agentMessage' && !session.state.itemText.has(item.id) && item.text) {
|
|
175
|
-
emit({ kind: 'text-delta', text: item.text, nativeRef });
|
|
176
|
-
} else if (item?.type === 'reasoning' && Array.isArray(item.summary) && item.summary.length) {
|
|
177
|
-
// Older app-server versions may send only the completed reasoning item.
|
|
178
|
-
if (!session.state.reasoningItems.has(item.id)) emit({ kind: 'thinking-delta', text: item.summary.join('\n'), nativeRef });
|
|
179
|
-
} else if (item?.type === 'fileChange') {
|
|
180
|
-
emitTool(item, session, emit, toolState(item));
|
|
181
|
-
emit({ kind: 'file-change', source: 'native', changes: nativeChanges(item, true), nativeRef });
|
|
182
|
-
} else if (item?.type === 'contextCompaction') {
|
|
183
|
-
emit({ kind: 'compaction', state: 'completed', outcome: 'succeeded', nativeRef });
|
|
184
|
-
session.state.compaction?.resolve();
|
|
185
|
-
} else if (item && !['userMessage', 'agentMessage', 'reasoning', 'plan', 'contextCompaction', 'hookPrompt'].includes(item.type)) {
|
|
186
|
-
emitTool(item, session, emit, toolState(item));
|
|
187
|
-
}
|
|
188
|
-
session.state.toolOutput.delete(item?.id);
|
|
189
|
-
break;
|
|
190
|
-
}
|
|
191
|
-
case 'thread/tokenUsage/updated': {
|
|
192
|
-
const usage = usageView(params.tokenUsage);
|
|
193
|
-
if (usage) {
|
|
194
|
-
session.state.usage = usage;
|
|
195
|
-
emit({ kind: 'usage', usage, nativeRef });
|
|
196
|
-
}
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
case 'thread/compacted':
|
|
200
|
-
emit({ kind: 'compaction', state: 'completed', outcome: 'succeeded', nativeRef });
|
|
201
|
-
session.state.compaction?.resolve();
|
|
202
|
-
break;
|
|
203
|
-
case 'warning':
|
|
204
|
-
case 'configWarning':
|
|
205
|
-
emit({ kind: 'notice', level: 'warning', text: params.message, nativeRef });
|
|
206
|
-
break;
|
|
207
|
-
case 'error': {
|
|
208
|
-
// Codex 原生 CodexErrorInfo 随错误透传:分类与桌面原生错误 UX 都以它为准
|
|
209
|
-
const codexErrorInfo = codexErrorInfoKey(params.error?.codexErrorInfo);
|
|
210
|
-
if (!params.willRetry) emit({ kind: 'error', message: params.error?.message ?? 'Codex 回合失败', ...(codexErrorInfo ? { codexErrorInfo } : {}), nativeRef });
|
|
211
|
-
else emit({ kind: 'status', text: `Codex 正在重试:${params.error?.message ?? '请求失败'}`, nativeRef });
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
case 'turn/completed': {
|
|
215
|
-
const turn = params.turn ?? {};
|
|
216
|
-
if (turn.usage || params.usage) {
|
|
217
|
-
const u = turn.usage || params.usage;
|
|
218
|
-
session.state.usage = {
|
|
219
|
-
inputTokens: u.inputTokens ?? u.input_tokens,
|
|
220
|
-
outputTokens: u.outputTokens ?? u.output_tokens,
|
|
221
|
-
cachedInputTokens: u.cachedInputTokens ?? u.cache_read_tokens,
|
|
222
|
-
totalTokens: u.totalTokens ?? u.total_tokens,
|
|
223
|
-
};
|
|
224
|
-
emit({ kind: 'usage', usage: session.state.usage, nativeRef: { ...nativeRef, checkpointId: turn.id } });
|
|
225
|
-
}
|
|
226
|
-
if (session.state.compaction) {
|
|
227
|
-
session.state.compaction.turnId = turn.id;
|
|
228
|
-
if (turn.status === 'failed') session.state.compaction.reject(new Error(turn.error?.message ?? 'Codex 压缩失败'));
|
|
229
|
-
else session.state.compaction.resolve();
|
|
230
|
-
break;
|
|
231
|
-
}
|
|
232
|
-
const failed = turn.status === 'failed';
|
|
233
|
-
if (failed) emit({ kind: 'error', message: turn.error?.message ?? 'Codex 回合失败', ...(codexErrorInfoKey(turn.error?.codexErrorInfo) ? { codexErrorInfo: codexErrorInfoKey(turn.error?.codexErrorInfo) } : {}), nativeRef: { ...nativeRef, checkpointId: turn.id } });
|
|
234
|
-
else emit({
|
|
235
|
-
kind: 'completed', finalAnswer: turn.status === 'completed',
|
|
236
|
-
stopReason: turn.status === 'interrupted' ? 'cancelled' : 'completed',
|
|
237
|
-
nativeRef: { ...nativeRef, checkpointId: turn.id },
|
|
238
|
-
});
|
|
239
|
-
const pending = session.state.turn;
|
|
240
|
-
session.state.turn = null;
|
|
241
|
-
session.state.nativeTurnId = null;
|
|
242
|
-
if (failed) pending?.reject(new Error(turn.error?.message ?? 'Codex 回合失败'));
|
|
243
|
-
else pending?.resolve();
|
|
244
|
-
break;
|
|
245
|
-
}
|
|
246
|
-
default:
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function approvalOptions(method, params) {
|
|
252
|
-
if (method === 'item/permissions/requestApproval') {
|
|
253
|
-
return [
|
|
254
|
-
{ id: 'accept', label: '允许本轮' },
|
|
255
|
-
{ id: 'acceptForSession', label: '本会话允许' },
|
|
256
|
-
{ id: 'decline', label: '拒绝', kind: 'reject' },
|
|
257
|
-
];
|
|
258
|
-
}
|
|
259
|
-
const available = params.availableDecisions ?? ['accept', 'acceptForSession', 'decline'];
|
|
260
|
-
const labels = { accept: '允许', acceptForSession: '本会话允许', decline: '拒绝', cancel: '取消' };
|
|
261
|
-
return available.filter((value) => typeof value === 'string').map((id) => ({ id, label: labels[id] ?? id, ...(id === 'decline' || id === 'cancel' ? { kind: 'reject' } : {}) }));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function queueRequest(message, session, emit) {
|
|
265
|
-
const { method, params = {}, id } = message;
|
|
266
|
-
if (method === 'mcpServer/elicitation/request') {
|
|
267
|
-
const requestId = `codex-${id}`;
|
|
268
|
-
return new Promise(resolve => {
|
|
269
|
-
session.pendingApprovals.set(requestId, { method, resolve, params });
|
|
270
|
-
const form = params.mode === 'form' && Object.keys(params.requestedSchema?.properties ?? {}).length > 0;
|
|
271
|
-
emit({ kind: 'approval', requestId, method: form ? 'input' : 'select',
|
|
272
|
-
title: `Codex MCP · ${params.serverName}`, message: [params.message, params.url, form ? JSON.stringify(params.requestedSchema) : ''].filter(Boolean).join('\n'),
|
|
273
|
-
...(form ? { placeholder: '按原生请求填写 JSON;取消可拒绝' } : { options: [{ id: 'accept', label: '允许' }, { id: 'decline', label: '拒绝', kind: 'reject' }] }),
|
|
274
|
-
nativeRef: { sessionId: session.nativeSessionId, turnId: params.turnId ?? undefined, interactionId: String(id) } });
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
if (method === 'item/tool/requestUserInput') {
|
|
278
|
-
const questions = params.questions ?? [];
|
|
279
|
-
return new Promise((resolve) => {
|
|
280
|
-
const group = { method, resolve, answers: {}, remaining: new Set() };
|
|
281
|
-
for (const question of questions) {
|
|
282
|
-
const requestId = `codex-${id}-${question.id}`;
|
|
283
|
-
group.remaining.add(requestId);
|
|
284
|
-
session.pendingApprovals.set(requestId, { group, question });
|
|
285
|
-
emit({
|
|
286
|
-
kind: 'approval', requestId, method: question.options?.length ? undefined : 'input',
|
|
287
|
-
title: question.header || 'Codex 提问', message: question.question,
|
|
288
|
-
options: (question.options ?? []).map((option) => ({ id: option.label, label: option.label })),
|
|
289
|
-
placeholder: question.isSecret ? '请输入(内容将发送给 Codex)' : '请输入…',
|
|
290
|
-
nativeRef: { sessionId: session.nativeSessionId, turnId: params.turnId, itemId: params.itemId, interactionId: String(id) },
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
if (!questions.length) resolve({ answers: {} });
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (method.endsWith('/requestApproval') || method.includes('requestApproval')) {
|
|
298
|
-
const requestId = `codex-${id}`;
|
|
299
|
-
return new Promise((resolve) => {
|
|
300
|
-
session.pendingApprovals.set(requestId, { method, resolve, params });
|
|
301
|
-
const title = method.includes('commandExecution') ? 'Codex 请求运行命令'
|
|
302
|
-
: method.includes('fileChange') ? 'Codex 请求修改文件'
|
|
303
|
-
: (method.includes('mcp') || method.includes('tool')) ? `Codex 请求调用工具 · ${params.tool ?? params.toolName ?? params.serverName ?? 'MCP'}`
|
|
304
|
-
: 'Codex 请求权限审批';
|
|
305
|
-
const messageText = params.reason ?? params.command ?? params.tool ?? (params.grantRoot ? `允许写入 ${params.grantRoot}` : text(params.permissions));
|
|
306
|
-
emit({
|
|
307
|
-
kind: 'approval', requestId, title, message: messageText,
|
|
308
|
-
options: approvalOptions(method, params),
|
|
309
|
-
nativeRef: { sessionId: session.nativeSessionId, turnId: params.turnId, itemId: params.itemId, interactionId: String(id) },
|
|
310
|
-
});
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
throw new Error(`Harness Mix 暂不处理 Codex 请求:${method}`);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function attachSession(host, nativeSessionId, { emit, diagnostic, model, effort, cwd } = {}) {
|
|
317
|
-
const session = {
|
|
318
|
-
host, nativeSessionId, model, cwd,
|
|
319
|
-
pendingApprovals: new Map(),
|
|
320
|
-
state: {
|
|
321
|
-
turn: null, compaction: null, nativeTurnId: null, usage: undefined, effort,
|
|
322
|
-
itemText: new Map(), reasoningItems: new Set(), toolOutput: new Map(),
|
|
323
|
-
},
|
|
324
|
-
};
|
|
325
|
-
session.unwatch = host.watch(nativeSessionId, {
|
|
326
|
-
onEvent: (message) => projectNotification(message, session, emit),
|
|
327
|
-
onRequest: (message) => queueRequest(message, session, emit),
|
|
328
|
-
onExit: (error) => {
|
|
329
|
-
diagnostic?.(error.message);
|
|
330
|
-
session.state.turn?.reject(error);
|
|
331
|
-
session.state.turn = null;
|
|
332
|
-
session.state.compaction?.reject(error);
|
|
333
|
-
session.state.compaction = null;
|
|
334
|
-
for (const pending of session.pendingApprovals.values()) pending.resolve?.(pending.method === 'mcpServer/elicitation/request' ? { action: 'cancel', content: null } : { decision: 'cancel' });
|
|
335
|
-
session.pendingApprovals.clear();
|
|
336
|
-
},
|
|
337
|
-
});
|
|
338
|
-
return session;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// 官方 app-server 的两种权限序列化(实测 26.9.x):
|
|
342
|
-
// thread/start 与 thread/resume 的 sandbox 只接受 kebab-case 纯字符串;
|
|
343
|
-
// turn/start 的 sandboxPolicy 接受 camelCase type 的对象;approvalsReviewer 枚举有限定。
|
|
344
|
-
const SANDBOX_TO_KEBAB = { dangerFullAccess: 'danger-full-access', readOnly: 'read-only', workspaceWrite: 'workspace-write', 'danger-full-access': 'danger-full-access', 'read-only': 'read-only', 'workspace-write': 'workspace-write' };
|
|
345
|
-
const SANDBOX_TO_CAMEL = { dangerFullAccess: 'dangerFullAccess', readOnly: 'readOnly', workspaceWrite: 'workspaceWrite', externalSandbox: 'externalSandbox', 'danger-full-access': 'dangerFullAccess', 'read-only': 'readOnly', 'workspace-write': 'workspaceWrite' };
|
|
346
|
-
const REVIEWER_TO_WIRE = { user: 'user', auto_review: 'auto_review', guardian_subagent: 'guardian_subagent', guardian: 'guardian_subagent' };
|
|
347
|
-
|
|
348
|
-
function sandboxToKebabString(sandbox) {
|
|
349
|
-
if (sandbox == null) return null;
|
|
350
|
-
const raw = typeof sandbox === 'string' ? sandbox : sandbox.type;
|
|
351
|
-
return SANDBOX_TO_KEBAB[raw] ?? null;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function sandboxPolicyToCamelObject(sandbox) {
|
|
355
|
-
if (!sandbox || typeof sandbox !== 'object') return null;
|
|
356
|
-
const type = SANDBOX_TO_CAMEL[sandbox.type];
|
|
357
|
-
return type ? { ...sandbox, type } : null;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function reviewerToWire(reviewer) {
|
|
361
|
-
return typeof reviewer === 'string' ? REVIEWER_TO_WIRE[reviewer] ?? null : null;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const APP_SERVER_BUSY = /^Agent is already processing(?:\.|$)/i;
|
|
365
|
-
|
|
366
|
-
async function startTurnAfterNativeSettlement(host, params) {
|
|
367
|
-
// A queue-start can arrive immediately after turn/completed, while app-server is still
|
|
368
|
-
// clearing its active-turn slot. Keep the same logical Core turn and retry only this
|
|
369
|
-
// narrow transient; other errors must remain visible and must never be duplicated.
|
|
370
|
-
for (let attempt = 0, delay = 25; ; attempt++, delay *= 2) {
|
|
371
|
-
try {
|
|
372
|
-
return await host.request('turn/start', params);
|
|
373
|
-
} catch (error) {
|
|
374
|
-
if (!APP_SERVER_BUSY.test(String(error?.message ?? error)) || attempt >= 5) throw error;
|
|
375
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function threadOptions(thread) {
|
|
381
|
-
// turnPermissions 来自 Desktop 权限菜单(thread/settings/update 或 thread/start 参数),
|
|
382
|
-
// 转发给原生 app-server 让显示选择与实际生效一致;permissions 优先级高于旧 permissionMode 档案
|
|
383
|
-
const perms = thread.options?.turnPermissions ?? {};
|
|
384
|
-
const sandbox = sandboxToKebabString(perms.sandboxPolicy);
|
|
385
|
-
const approvalsReviewer = reviewerToWire(perms.approvalsReviewer);
|
|
386
|
-
return {
|
|
387
|
-
cwd: thread.cwd,
|
|
388
|
-
...(thread.options?.model?.id ? { model: thread.options.model.id } : {}),
|
|
389
|
-
...(perms.approvalPolicy ? { approvalPolicy: perms.approvalPolicy } : {}),
|
|
390
|
-
...(approvalsReviewer ? { approvalsReviewer } : {}),
|
|
391
|
-
...(sandbox ? { sandbox } : {}),
|
|
392
|
-
...(perms.permissions ? { permissions: perms.permissions } : {}),
|
|
393
|
-
...(thread.options?.permissionMode && thread.options.permissionMode !== 'default' && !perms.permissions ? { permissions: thread.options.permissionMode } : {}),
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function create() {
|
|
398
|
-
return {
|
|
399
|
-
manifest,
|
|
400
|
-
|
|
401
|
-
async inspect() {
|
|
402
|
-
const result = await new Promise((resolve) => {
|
|
403
|
-
const { command, args } = cliSpawn('codex', ['--version']);
|
|
404
|
-
execFile(command, args, { windowsHide: true }, (error, stdout) => resolve({ error, stdout }));
|
|
405
|
-
});
|
|
406
|
-
return result.error
|
|
407
|
-
? { available: false, detail: '未找到 Codex CLI(npm i -g @openai/codex)' }
|
|
408
|
-
: { available: true, detail: String(result.stdout).trim() };
|
|
409
|
-
},
|
|
410
|
-
|
|
411
|
-
async open({ thread, emit, diagnostic, collaboration, managedMcp = [] }) {
|
|
412
|
-
const host = await CodexAppServer.acquire(diagnostic, thread.options?.codexHome);
|
|
413
|
-
try {
|
|
414
|
-
const servers = require('./managed-mcp').namedServers(managedMcp, collaboration);
|
|
415
|
-
const options = { ...threadOptions(thread), ...(Object.keys(servers).length ? { config: Object.fromEntries(Object.entries(servers).map(([name, value]) => [`mcp_servers.${name}`, value])) } : {}) };
|
|
416
|
-
let result;
|
|
417
|
-
if (thread.restore) {
|
|
418
|
-
// 旧版 app-server 可能拒绝 resume 上的权限覆盖字段:降级重试不阻塞会话恢复
|
|
419
|
-
try {
|
|
420
|
-
result = await host.request('thread/resume', { threadId: thread.nativeSessionId, ...options });
|
|
421
|
-
} catch (error) {
|
|
422
|
-
const { approvalPolicy, approvalsReviewer, sandbox, ...fallback } = options;
|
|
423
|
-
if (approvalPolicy === undefined && sandbox === undefined && approvalsReviewer === undefined) throw error;
|
|
424
|
-
result = await host.request('thread/resume', { threadId: thread.nativeSessionId, ...fallback });
|
|
425
|
-
}
|
|
426
|
-
} else {
|
|
427
|
-
result = await host.request('thread/start', options);
|
|
428
|
-
}
|
|
429
|
-
const model = { id: result.model, name: result.model, provider: result.modelProvider ?? 'openai' };
|
|
430
|
-
const session = attachSession(host, result.thread.id, { emit, diagnostic, model, effort: result.reasoningEffort, cwd: thread.cwd });
|
|
431
|
-
session.turnPermissions = thread.options?.turnPermissions ?? null;
|
|
432
|
-
session.collaborationEnabled = !!collaboration;
|
|
433
|
-
emit({ kind: 'session', nativeSessionId: result.thread.id, model });
|
|
434
|
-
return session;
|
|
435
|
-
} catch (error) {
|
|
436
|
-
host.release();
|
|
437
|
-
throw error;
|
|
438
|
-
}
|
|
439
|
-
},
|
|
440
|
-
|
|
441
|
-
async send(session, prompt, _hooks, attachments) {
|
|
442
|
-
if (session.state.turn) throw new Error('Codex 当前回合尚未结束');
|
|
443
|
-
session.state.itemText.clear();
|
|
444
|
-
session.state.reasoningItems.clear();
|
|
445
|
-
const settled = new Promise((resolve, reject) => { session.state.turn = { resolve, reject }; });
|
|
446
|
-
try {
|
|
447
|
-
// UserInput 原生项:text + image(data URL);文本附件由 Host 内联进 prompt
|
|
448
|
-
const input = [
|
|
449
|
-
...(prompt ? [{ type: 'text', text: prompt }] : []),
|
|
450
|
-
...(attachments?.images ?? []).map((a) => ({ type: 'image', url: `data:${a.mime};base64,${a.data}` })),
|
|
451
|
-
];
|
|
452
|
-
// 回合级权限覆盖优先于线程级(Desktop 每个回合都可能推送最新选择)
|
|
453
|
-
const perms = attachments?.turnPermissions ?? session.turnPermissions ?? {};
|
|
454
|
-
const sandboxPolicy = sandboxPolicyToCamelObject(perms.sandboxPolicy);
|
|
455
|
-
const approvalsReviewer = reviewerToWire(perms.approvalsReviewer);
|
|
456
|
-
const result = await startTurnAfterNativeSettlement(session.host, {
|
|
457
|
-
threadId: session.nativeSessionId,
|
|
458
|
-
input,
|
|
459
|
-
...(session.model?.id ? { model: session.model.id } : {}),
|
|
460
|
-
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
461
|
-
...(perms.approvalPolicy ? { approvalPolicy: perms.approvalPolicy } : {}),
|
|
462
|
-
...(approvalsReviewer ? { approvalsReviewer } : {}),
|
|
463
|
-
...(sandboxPolicy ? { sandboxPolicy } : {}),
|
|
464
|
-
});
|
|
465
|
-
session.state.nativeTurnId = result.turn.id;
|
|
466
|
-
} catch (error) {
|
|
467
|
-
session.state.turn = null;
|
|
468
|
-
throw error;
|
|
469
|
-
}
|
|
470
|
-
return settled;
|
|
471
|
-
},
|
|
472
|
-
|
|
473
|
-
async cancel(session) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if (session.state.
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
pending.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
pending.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
session.state.compaction
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
session.
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
session.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
1
|
+
const { execFile } = require('node:child_process');
|
|
2
|
+
const { CodexAppServer } = require('./codex-app-server');
|
|
3
|
+
const { cliSpawn } = require('../host/jsonl');
|
|
4
|
+
const { recordNative } = require('../harness-adapter/fixture-recorder');
|
|
5
|
+
const { codexErrorInfoKey } = require('../harness-adapter/error-kind');
|
|
6
|
+
|
|
7
|
+
const manifest = {
|
|
8
|
+
id: 'codex',
|
|
9
|
+
name: 'Codex',
|
|
10
|
+
icon: 'codex-color.svg',
|
|
11
|
+
aliases: ['codex', 'codex-harness'],
|
|
12
|
+
capabilities: {
|
|
13
|
+
plan: true, streaming: true, thinking: true, tools: true,
|
|
14
|
+
approvals: true, questions: true, models: true, thinkingLevels: true,
|
|
15
|
+
permissionModes: true, resume: true, fork: true, forkFromMessage: true,
|
|
16
|
+
compaction: true, nativeDiff: true, nativePatch: true,
|
|
17
|
+
usage: true, contextUsage: true, cost: false, attachments: true,
|
|
18
|
+
collaborationTools: true,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const MAX_TOOL_TEXT = 24_000;
|
|
23
|
+
|
|
24
|
+
function text(value) {
|
|
25
|
+
if (value == null) return undefined;
|
|
26
|
+
const result = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
27
|
+
return result.length > MAX_TOOL_TEXT ? `${result.slice(0, MAX_TOOL_TEXT)}\n[桌面预览已截断]` : result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function modelView(model) {
|
|
31
|
+
return {
|
|
32
|
+
id: model.model ?? model.id,
|
|
33
|
+
name: model.displayName ?? model.model ?? model.id,
|
|
34
|
+
provider: 'openai',
|
|
35
|
+
description: model.description,
|
|
36
|
+
efforts: (model.supportedReasoningEfforts ?? []).map((entry) => ({
|
|
37
|
+
id: entry.reasoningEffort,
|
|
38
|
+
label: entry.reasoningEffort,
|
|
39
|
+
hint: entry.description,
|
|
40
|
+
})),
|
|
41
|
+
defaultEffort: model.defaultReasoningEffort,
|
|
42
|
+
contextWindow: model.contextWindow,
|
|
43
|
+
isDefault: model.isDefault === true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function listAll(host, method, params = {}) {
|
|
48
|
+
const data = [];
|
|
49
|
+
let cursor = null;
|
|
50
|
+
do {
|
|
51
|
+
const page = await host.request(method, { ...params, ...(cursor ? { cursor } : {}) });
|
|
52
|
+
data.push(...(page?.data ?? []));
|
|
53
|
+
cursor = page?.nextCursor ?? null;
|
|
54
|
+
} while (cursor);
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toolTitle(item) {
|
|
59
|
+
if (!item) return 'Codex 工具';
|
|
60
|
+
if (item.type === 'commandExecution') return 'exec_command';
|
|
61
|
+
if (item.type === 'fileChange') return 'edit';
|
|
62
|
+
if (item.type === 'mcpToolCall') return `${item.server}/${item.tool}`;
|
|
63
|
+
if (item.type === 'dynamicToolCall') return [item.namespace, item.tool].filter(Boolean).join('/') || '工具';
|
|
64
|
+
if (item.type === 'collabAgentToolCall') return `Agent · ${item.tool}`;
|
|
65
|
+
if (item.type === 'webSearch') return '搜索网页';
|
|
66
|
+
if (item.type === 'imageView') return '查看图片';
|
|
67
|
+
if (item.type === 'imageGeneration') return '生成图片';
|
|
68
|
+
if (item.type === 'subAgentActivity') return `子 Agent · ${item.kind}`;
|
|
69
|
+
return item.type || 'Codex 工具';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toolState(item) {
|
|
73
|
+
const status = item?.status;
|
|
74
|
+
if (['completed', 'success'].includes(status)) return 'done';
|
|
75
|
+
if (['failed', 'declined', 'error'].includes(status)) return 'error';
|
|
76
|
+
return 'running';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toolInput(item) {
|
|
80
|
+
if (item?.type === 'commandExecution') return item.command;
|
|
81
|
+
if (item?.type === 'fileChange') return (item.changes ?? []).map(change => change.path).filter(Boolean).join('\n') || undefined;
|
|
82
|
+
if (item?.type === 'mcpToolCall' || item?.type === 'dynamicToolCall') return text(item.arguments);
|
|
83
|
+
if (item?.type === 'collabAgentToolCall') return item.prompt;
|
|
84
|
+
if (item?.type === 'imageView') return item.path;
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toolOutput(item, session) {
|
|
89
|
+
if (item?.type === 'commandExecution') return item.aggregatedOutput ?? session?.state?.toolOutput.get(item.id);
|
|
90
|
+
if (item?.type === 'mcpToolCall') return text(item.result ?? item.error);
|
|
91
|
+
if (item?.type === 'dynamicToolCall') return text(item.contentItems);
|
|
92
|
+
return session?.state?.toolOutput.get(item?.id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function nativeChanges(item, complete = false) {
|
|
96
|
+
return (item?.changes ?? []).map((change) => ({
|
|
97
|
+
path: change.path,
|
|
98
|
+
patch: change.diff,
|
|
99
|
+
changeType: change.kind?.type ?? change.kind ?? 'modified',
|
|
100
|
+
complete,
|
|
101
|
+
nativeRef: { toolCallId: item.id },
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function usageView(tokenUsage) {
|
|
106
|
+
if (!tokenUsage) return undefined;
|
|
107
|
+
const last = tokenUsage.last ?? {};
|
|
108
|
+
const total = tokenUsage.total ?? {};
|
|
109
|
+
const tokens = Number.isFinite(last.totalTokens) ? last.totalTokens : null;
|
|
110
|
+
const contextWindow = Number.isFinite(tokenUsage.modelContextWindow) ? tokenUsage.modelContextWindow : 128_000;
|
|
111
|
+
return {
|
|
112
|
+
tokens,
|
|
113
|
+
contextWindow,
|
|
114
|
+
contextPercent: tokens != null && contextWindow ? 100 * tokens / contextWindow : null,
|
|
115
|
+
inputTokens: total.inputTokens ?? null,
|
|
116
|
+
outputTokens: total.outputTokens ?? null,
|
|
117
|
+
cachedInputTokens: total.cachedInputTokens ?? null,
|
|
118
|
+
reasoningOutputTokens: total.reasoningOutputTokens ?? null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function emitTool(item, session, emit, state = toolState(item)) {
|
|
123
|
+
emit({
|
|
124
|
+
kind: 'tool', toolCallId: item.id, title: toolTitle(item), state,
|
|
125
|
+
input: toolInput(item), output: toolOutput(item, session),
|
|
126
|
+
nativeRef: { sessionId: session.nativeSessionId, turnId: session.state.nativeTurnId, itemId: item.id, toolCallId: item.id },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function projectNotification(message, session, emit) {
|
|
131
|
+
recordNative(manifest.id, message);
|
|
132
|
+
const { method, params = {} } = message ?? {};
|
|
133
|
+
const nativeRef = {
|
|
134
|
+
sessionId: session.nativeSessionId,
|
|
135
|
+
...(params.turnId || params.turn?.id ? { turnId: params.turnId ?? params.turn.id } : {}),
|
|
136
|
+
...(params.itemId || params.item?.id ? { itemId: params.itemId ?? params.item.id } : {}),
|
|
137
|
+
};
|
|
138
|
+
switch (method) {
|
|
139
|
+
case 'turn/started':
|
|
140
|
+
session.state.nativeTurnId = params.turn?.id;
|
|
141
|
+
break;
|
|
142
|
+
case 'item/agentMessage/delta':
|
|
143
|
+
session.state.itemText.set(params.itemId, (session.state.itemText.get(params.itemId) ?? '') + params.delta);
|
|
144
|
+
emit({ kind: 'text-delta', text: params.delta, nativeRef });
|
|
145
|
+
break;
|
|
146
|
+
case 'item/reasoning/summaryTextDelta':
|
|
147
|
+
case 'item/reasoning/textDelta':
|
|
148
|
+
session.state.reasoningItems.add(params.itemId);
|
|
149
|
+
emit({ kind: 'thinking-delta', text: params.delta, nativeRef });
|
|
150
|
+
break;
|
|
151
|
+
case 'turn/plan/updated':
|
|
152
|
+
emit({ kind: 'plan', entries: (params.plan ?? []).map((entry) => ({
|
|
153
|
+
text: entry.step ?? entry.text ?? '', status: entry.status,
|
|
154
|
+
})), nativeRef });
|
|
155
|
+
break;
|
|
156
|
+
case 'item/started': {
|
|
157
|
+
const item = params.item;
|
|
158
|
+
if (['commandExecution', 'fileChange', 'mcpToolCall', 'dynamicToolCall', 'collabAgentToolCall', 'webSearch', 'imageView', 'imageGeneration', 'subAgentActivity'].includes(item?.type)) {
|
|
159
|
+
emitTool(item, session, emit, 'running');
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case 'item/commandExecution/outputDelta': {
|
|
164
|
+
const output = (session.state.toolOutput.get(params.itemId) ?? '') + (params.delta ?? '');
|
|
165
|
+
session.state.toolOutput.set(params.itemId, output.slice(-MAX_TOOL_TEXT));
|
|
166
|
+
emit({ kind: 'tool', toolCallId: params.itemId, state: 'running', output: session.state.toolOutput.get(params.itemId), nativeRef });
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'item/fileChange/patchUpdated':
|
|
170
|
+
emit({ kind: 'file-change', source: 'native', changes: nativeChanges({ id: params.itemId, status: 'inProgress', changes: params.changes }), nativeRef });
|
|
171
|
+
break;
|
|
172
|
+
case 'item/completed': {
|
|
173
|
+
const item = params.item;
|
|
174
|
+
if (item?.type === 'agentMessage' && !session.state.itemText.has(item.id) && item.text) {
|
|
175
|
+
emit({ kind: 'text-delta', text: item.text, nativeRef });
|
|
176
|
+
} else if (item?.type === 'reasoning' && Array.isArray(item.summary) && item.summary.length) {
|
|
177
|
+
// Older app-server versions may send only the completed reasoning item.
|
|
178
|
+
if (!session.state.reasoningItems.has(item.id)) emit({ kind: 'thinking-delta', text: item.summary.join('\n'), nativeRef });
|
|
179
|
+
} else if (item?.type === 'fileChange') {
|
|
180
|
+
emitTool(item, session, emit, toolState(item));
|
|
181
|
+
emit({ kind: 'file-change', source: 'native', changes: nativeChanges(item, true), nativeRef });
|
|
182
|
+
} else if (item?.type === 'contextCompaction') {
|
|
183
|
+
emit({ kind: 'compaction', state: 'completed', outcome: 'succeeded', nativeRef });
|
|
184
|
+
session.state.compaction?.resolve();
|
|
185
|
+
} else if (item && !['userMessage', 'agentMessage', 'reasoning', 'plan', 'contextCompaction', 'hookPrompt'].includes(item.type)) {
|
|
186
|
+
emitTool(item, session, emit, toolState(item));
|
|
187
|
+
}
|
|
188
|
+
session.state.toolOutput.delete(item?.id);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'thread/tokenUsage/updated': {
|
|
192
|
+
const usage = usageView(params.tokenUsage);
|
|
193
|
+
if (usage) {
|
|
194
|
+
session.state.usage = usage;
|
|
195
|
+
emit({ kind: 'usage', usage, nativeRef });
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case 'thread/compacted':
|
|
200
|
+
emit({ kind: 'compaction', state: 'completed', outcome: 'succeeded', nativeRef });
|
|
201
|
+
session.state.compaction?.resolve();
|
|
202
|
+
break;
|
|
203
|
+
case 'warning':
|
|
204
|
+
case 'configWarning':
|
|
205
|
+
emit({ kind: 'notice', level: 'warning', text: params.message, nativeRef });
|
|
206
|
+
break;
|
|
207
|
+
case 'error': {
|
|
208
|
+
// Codex 原生 CodexErrorInfo 随错误透传:分类与桌面原生错误 UX 都以它为准
|
|
209
|
+
const codexErrorInfo = codexErrorInfoKey(params.error?.codexErrorInfo);
|
|
210
|
+
if (!params.willRetry) emit({ kind: 'error', message: params.error?.message ?? 'Codex 回合失败', ...(codexErrorInfo ? { codexErrorInfo } : {}), nativeRef });
|
|
211
|
+
else emit({ kind: 'status', text: `Codex 正在重试:${params.error?.message ?? '请求失败'}`, nativeRef });
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case 'turn/completed': {
|
|
215
|
+
const turn = params.turn ?? {};
|
|
216
|
+
if (turn.usage || params.usage) {
|
|
217
|
+
const u = turn.usage || params.usage;
|
|
218
|
+
session.state.usage = {
|
|
219
|
+
inputTokens: u.inputTokens ?? u.input_tokens,
|
|
220
|
+
outputTokens: u.outputTokens ?? u.output_tokens,
|
|
221
|
+
cachedInputTokens: u.cachedInputTokens ?? u.cache_read_tokens,
|
|
222
|
+
totalTokens: u.totalTokens ?? u.total_tokens,
|
|
223
|
+
};
|
|
224
|
+
emit({ kind: 'usage', usage: session.state.usage, nativeRef: { ...nativeRef, checkpointId: turn.id } });
|
|
225
|
+
}
|
|
226
|
+
if (session.state.compaction) {
|
|
227
|
+
session.state.compaction.turnId = turn.id;
|
|
228
|
+
if (turn.status === 'failed') session.state.compaction.reject(new Error(turn.error?.message ?? 'Codex 压缩失败'));
|
|
229
|
+
else session.state.compaction.resolve();
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
const failed = turn.status === 'failed';
|
|
233
|
+
if (failed) emit({ kind: 'error', message: turn.error?.message ?? 'Codex 回合失败', ...(codexErrorInfoKey(turn.error?.codexErrorInfo) ? { codexErrorInfo: codexErrorInfoKey(turn.error?.codexErrorInfo) } : {}), nativeRef: { ...nativeRef, checkpointId: turn.id } });
|
|
234
|
+
else emit({
|
|
235
|
+
kind: 'completed', finalAnswer: turn.status === 'completed',
|
|
236
|
+
stopReason: turn.status === 'interrupted' ? 'cancelled' : 'completed',
|
|
237
|
+
nativeRef: { ...nativeRef, checkpointId: turn.id },
|
|
238
|
+
});
|
|
239
|
+
const pending = session.state.turn;
|
|
240
|
+
session.state.turn = null;
|
|
241
|
+
session.state.nativeTurnId = null;
|
|
242
|
+
if (failed) pending?.reject(new Error(turn.error?.message ?? 'Codex 回合失败'));
|
|
243
|
+
else pending?.resolve();
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
default:
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function approvalOptions(method, params) {
|
|
252
|
+
if (method === 'item/permissions/requestApproval') {
|
|
253
|
+
return [
|
|
254
|
+
{ id: 'accept', label: '允许本轮' },
|
|
255
|
+
{ id: 'acceptForSession', label: '本会话允许' },
|
|
256
|
+
{ id: 'decline', label: '拒绝', kind: 'reject' },
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
const available = params.availableDecisions ?? ['accept', 'acceptForSession', 'decline'];
|
|
260
|
+
const labels = { accept: '允许', acceptForSession: '本会话允许', decline: '拒绝', cancel: '取消' };
|
|
261
|
+
return available.filter((value) => typeof value === 'string').map((id) => ({ id, label: labels[id] ?? id, ...(id === 'decline' || id === 'cancel' ? { kind: 'reject' } : {}) }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function queueRequest(message, session, emit) {
|
|
265
|
+
const { method, params = {}, id } = message;
|
|
266
|
+
if (method === 'mcpServer/elicitation/request') {
|
|
267
|
+
const requestId = `codex-${id}`;
|
|
268
|
+
return new Promise(resolve => {
|
|
269
|
+
session.pendingApprovals.set(requestId, { method, resolve, params });
|
|
270
|
+
const form = params.mode === 'form' && Object.keys(params.requestedSchema?.properties ?? {}).length > 0;
|
|
271
|
+
emit({ kind: 'approval', requestId, method: form ? 'input' : 'select',
|
|
272
|
+
title: `Codex MCP · ${params.serverName}`, message: [params.message, params.url, form ? JSON.stringify(params.requestedSchema) : ''].filter(Boolean).join('\n'),
|
|
273
|
+
...(form ? { placeholder: '按原生请求填写 JSON;取消可拒绝' } : { options: [{ id: 'accept', label: '允许' }, { id: 'decline', label: '拒绝', kind: 'reject' }] }),
|
|
274
|
+
nativeRef: { sessionId: session.nativeSessionId, turnId: params.turnId ?? undefined, interactionId: String(id) } });
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
if (method === 'item/tool/requestUserInput') {
|
|
278
|
+
const questions = params.questions ?? [];
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
const group = { method, resolve, answers: {}, remaining: new Set() };
|
|
281
|
+
for (const question of questions) {
|
|
282
|
+
const requestId = `codex-${id}-${question.id}`;
|
|
283
|
+
group.remaining.add(requestId);
|
|
284
|
+
session.pendingApprovals.set(requestId, { group, question });
|
|
285
|
+
emit({
|
|
286
|
+
kind: 'approval', requestId, method: question.options?.length ? undefined : 'input',
|
|
287
|
+
title: question.header || 'Codex 提问', message: question.question,
|
|
288
|
+
options: (question.options ?? []).map((option) => ({ id: option.label, label: option.label })),
|
|
289
|
+
placeholder: question.isSecret ? '请输入(内容将发送给 Codex)' : '请输入…',
|
|
290
|
+
nativeRef: { sessionId: session.nativeSessionId, turnId: params.turnId, itemId: params.itemId, interactionId: String(id) },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
if (!questions.length) resolve({ answers: {} });
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (method.endsWith('/requestApproval') || method.includes('requestApproval')) {
|
|
298
|
+
const requestId = `codex-${id}`;
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
session.pendingApprovals.set(requestId, { method, resolve, params });
|
|
301
|
+
const title = method.includes('commandExecution') ? 'Codex 请求运行命令'
|
|
302
|
+
: method.includes('fileChange') ? 'Codex 请求修改文件'
|
|
303
|
+
: (method.includes('mcp') || method.includes('tool')) ? `Codex 请求调用工具 · ${params.tool ?? params.toolName ?? params.serverName ?? 'MCP'}`
|
|
304
|
+
: 'Codex 请求权限审批';
|
|
305
|
+
const messageText = params.reason ?? params.command ?? params.tool ?? (params.grantRoot ? `允许写入 ${params.grantRoot}` : text(params.permissions));
|
|
306
|
+
emit({
|
|
307
|
+
kind: 'approval', requestId, title, message: messageText,
|
|
308
|
+
options: approvalOptions(method, params),
|
|
309
|
+
nativeRef: { sessionId: session.nativeSessionId, turnId: params.turnId, itemId: params.itemId, interactionId: String(id) },
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
throw new Error(`Harness Mix 暂不处理 Codex 请求:${method}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function attachSession(host, nativeSessionId, { emit, diagnostic, model, effort, cwd } = {}) {
|
|
317
|
+
const session = {
|
|
318
|
+
host, nativeSessionId, model, cwd,
|
|
319
|
+
pendingApprovals: new Map(),
|
|
320
|
+
state: {
|
|
321
|
+
turn: null, compaction: null, nativeTurnId: null, usage: undefined, effort,
|
|
322
|
+
itemText: new Map(), reasoningItems: new Set(), toolOutput: new Map(),
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
session.unwatch = host.watch(nativeSessionId, {
|
|
326
|
+
onEvent: (message) => projectNotification(message, session, emit),
|
|
327
|
+
onRequest: (message) => queueRequest(message, session, emit),
|
|
328
|
+
onExit: (error) => {
|
|
329
|
+
diagnostic?.(error.message);
|
|
330
|
+
session.state.turn?.reject(error);
|
|
331
|
+
session.state.turn = null;
|
|
332
|
+
session.state.compaction?.reject(error);
|
|
333
|
+
session.state.compaction = null;
|
|
334
|
+
for (const pending of session.pendingApprovals.values()) pending.resolve?.(pending.method === 'mcpServer/elicitation/request' ? { action: 'cancel', content: null } : { decision: 'cancel' });
|
|
335
|
+
session.pendingApprovals.clear();
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
return session;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 官方 app-server 的两种权限序列化(实测 26.9.x):
|
|
342
|
+
// thread/start 与 thread/resume 的 sandbox 只接受 kebab-case 纯字符串;
|
|
343
|
+
// turn/start 的 sandboxPolicy 接受 camelCase type 的对象;approvalsReviewer 枚举有限定。
|
|
344
|
+
const SANDBOX_TO_KEBAB = { dangerFullAccess: 'danger-full-access', readOnly: 'read-only', workspaceWrite: 'workspace-write', 'danger-full-access': 'danger-full-access', 'read-only': 'read-only', 'workspace-write': 'workspace-write' };
|
|
345
|
+
const SANDBOX_TO_CAMEL = { dangerFullAccess: 'dangerFullAccess', readOnly: 'readOnly', workspaceWrite: 'workspaceWrite', externalSandbox: 'externalSandbox', 'danger-full-access': 'dangerFullAccess', 'read-only': 'readOnly', 'workspace-write': 'workspaceWrite' };
|
|
346
|
+
const REVIEWER_TO_WIRE = { user: 'user', auto_review: 'auto_review', guardian_subagent: 'guardian_subagent', guardian: 'guardian_subagent' };
|
|
347
|
+
|
|
348
|
+
function sandboxToKebabString(sandbox) {
|
|
349
|
+
if (sandbox == null) return null;
|
|
350
|
+
const raw = typeof sandbox === 'string' ? sandbox : sandbox.type;
|
|
351
|
+
return SANDBOX_TO_KEBAB[raw] ?? null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sandboxPolicyToCamelObject(sandbox) {
|
|
355
|
+
if (!sandbox || typeof sandbox !== 'object') return null;
|
|
356
|
+
const type = SANDBOX_TO_CAMEL[sandbox.type];
|
|
357
|
+
return type ? { ...sandbox, type } : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function reviewerToWire(reviewer) {
|
|
361
|
+
return typeof reviewer === 'string' ? REVIEWER_TO_WIRE[reviewer] ?? null : null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const APP_SERVER_BUSY = /^Agent is already processing(?:\.|$)/i;
|
|
365
|
+
|
|
366
|
+
async function startTurnAfterNativeSettlement(host, params) {
|
|
367
|
+
// A queue-start can arrive immediately after turn/completed, while app-server is still
|
|
368
|
+
// clearing its active-turn slot. Keep the same logical Core turn and retry only this
|
|
369
|
+
// narrow transient; other errors must remain visible and must never be duplicated.
|
|
370
|
+
for (let attempt = 0, delay = 25; ; attempt++, delay *= 2) {
|
|
371
|
+
try {
|
|
372
|
+
return await host.request('turn/start', params);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (!APP_SERVER_BUSY.test(String(error?.message ?? error)) || attempt >= 5) throw error;
|
|
375
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function threadOptions(thread) {
|
|
381
|
+
// turnPermissions 来自 Desktop 权限菜单(thread/settings/update 或 thread/start 参数),
|
|
382
|
+
// 转发给原生 app-server 让显示选择与实际生效一致;permissions 优先级高于旧 permissionMode 档案
|
|
383
|
+
const perms = thread.options?.turnPermissions ?? {};
|
|
384
|
+
const sandbox = sandboxToKebabString(perms.sandboxPolicy);
|
|
385
|
+
const approvalsReviewer = reviewerToWire(perms.approvalsReviewer);
|
|
386
|
+
return {
|
|
387
|
+
cwd: thread.cwd,
|
|
388
|
+
...(thread.options?.model?.id ? { model: thread.options.model.id } : {}),
|
|
389
|
+
...(perms.approvalPolicy ? { approvalPolicy: perms.approvalPolicy } : {}),
|
|
390
|
+
...(approvalsReviewer ? { approvalsReviewer } : {}),
|
|
391
|
+
...(sandbox ? { sandbox } : {}),
|
|
392
|
+
...(perms.permissions ? { permissions: perms.permissions } : {}),
|
|
393
|
+
...(thread.options?.permissionMode && thread.options.permissionMode !== 'default' && !perms.permissions ? { permissions: thread.options.permissionMode } : {}),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function create() {
|
|
398
|
+
return {
|
|
399
|
+
manifest,
|
|
400
|
+
|
|
401
|
+
async inspect() {
|
|
402
|
+
const result = await new Promise((resolve) => {
|
|
403
|
+
const { command, args } = cliSpawn('codex', ['--version']);
|
|
404
|
+
execFile(command, args, { windowsHide: true }, (error, stdout) => resolve({ error, stdout }));
|
|
405
|
+
});
|
|
406
|
+
return result.error
|
|
407
|
+
? { available: false, detail: '未找到 Codex CLI(npm i -g @openai/codex)' }
|
|
408
|
+
: { available: true, detail: String(result.stdout).trim() };
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async open({ thread, emit, diagnostic, collaboration, managedMcp = [] }) {
|
|
412
|
+
const host = await CodexAppServer.acquire(diagnostic, thread.options?.codexHome);
|
|
413
|
+
try {
|
|
414
|
+
const servers = require('./managed-mcp').namedServers(managedMcp, collaboration);
|
|
415
|
+
const options = { ...threadOptions(thread), ...(Object.keys(servers).length ? { config: Object.fromEntries(Object.entries(servers).map(([name, value]) => [`mcp_servers.${name}`, value])) } : {}) };
|
|
416
|
+
let result;
|
|
417
|
+
if (thread.restore) {
|
|
418
|
+
// 旧版 app-server 可能拒绝 resume 上的权限覆盖字段:降级重试不阻塞会话恢复
|
|
419
|
+
try {
|
|
420
|
+
result = await host.request('thread/resume', { threadId: thread.nativeSessionId, ...options });
|
|
421
|
+
} catch (error) {
|
|
422
|
+
const { approvalPolicy, approvalsReviewer, sandbox, ...fallback } = options;
|
|
423
|
+
if (approvalPolicy === undefined && sandbox === undefined && approvalsReviewer === undefined) throw error;
|
|
424
|
+
result = await host.request('thread/resume', { threadId: thread.nativeSessionId, ...fallback });
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
result = await host.request('thread/start', options);
|
|
428
|
+
}
|
|
429
|
+
const model = { id: result.model, name: result.model, provider: result.modelProvider ?? 'openai' };
|
|
430
|
+
const session = attachSession(host, result.thread.id, { emit, diagnostic, model, effort: result.reasoningEffort, cwd: thread.cwd });
|
|
431
|
+
session.turnPermissions = thread.options?.turnPermissions ?? null;
|
|
432
|
+
session.collaborationEnabled = !!collaboration;
|
|
433
|
+
emit({ kind: 'session', nativeSessionId: result.thread.id, model });
|
|
434
|
+
return session;
|
|
435
|
+
} catch (error) {
|
|
436
|
+
host.release();
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
async send(session, prompt, _hooks, attachments) {
|
|
442
|
+
if (session.state.turn) throw new Error('Codex 当前回合尚未结束');
|
|
443
|
+
session.state.itemText.clear();
|
|
444
|
+
session.state.reasoningItems.clear();
|
|
445
|
+
const settled = new Promise((resolve, reject) => { session.state.turn = { resolve, reject }; });
|
|
446
|
+
try {
|
|
447
|
+
// UserInput 原生项:text + image(data URL);文本附件由 Host 内联进 prompt
|
|
448
|
+
const input = [
|
|
449
|
+
...(prompt ? [{ type: 'text', text: prompt }] : []),
|
|
450
|
+
...(attachments?.images ?? []).map((a) => ({ type: 'image', url: `data:${a.mime};base64,${a.data}` })),
|
|
451
|
+
];
|
|
452
|
+
// 回合级权限覆盖优先于线程级(Desktop 每个回合都可能推送最新选择)
|
|
453
|
+
const perms = attachments?.turnPermissions ?? session.turnPermissions ?? {};
|
|
454
|
+
const sandboxPolicy = sandboxPolicyToCamelObject(perms.sandboxPolicy);
|
|
455
|
+
const approvalsReviewer = reviewerToWire(perms.approvalsReviewer);
|
|
456
|
+
const result = await startTurnAfterNativeSettlement(session.host, {
|
|
457
|
+
threadId: session.nativeSessionId,
|
|
458
|
+
input,
|
|
459
|
+
...(session.model?.id ? { model: session.model.id } : {}),
|
|
460
|
+
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
461
|
+
...(perms.approvalPolicy ? { approvalPolicy: perms.approvalPolicy } : {}),
|
|
462
|
+
...(approvalsReviewer ? { approvalsReviewer } : {}),
|
|
463
|
+
...(sandboxPolicy ? { sandboxPolicy } : {}),
|
|
464
|
+
});
|
|
465
|
+
session.state.nativeTurnId = result.turn.id;
|
|
466
|
+
} catch (error) {
|
|
467
|
+
session.state.turn = null;
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
return settled;
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
async cancel(session) {
|
|
474
|
+
// 三类原生请求各有 wire 形状(与 close() 对齐);requestUserInput 条目没有自身 resolve,
|
|
475
|
+
// 只能经由 group 结算,直接调 pending.resolve 会 TypeError 并吞掉后续的 interrupt
|
|
476
|
+
for (const pending of session.pendingApprovals?.values() ?? []) {
|
|
477
|
+
if (pending.group) pending.group.resolve({ answers: pending.group.answers });
|
|
478
|
+
else if (pending.method === 'mcpServer/elicitation/request') pending.resolve({ action: 'cancel', content: null });
|
|
479
|
+
else if (pending.method === 'item/permissions/requestApproval') pending.resolve({ permissions: {}, scope: 'turn' });
|
|
480
|
+
else pending.resolve({ decision: 'decline' });
|
|
481
|
+
}
|
|
482
|
+
session.pendingApprovals?.clear();
|
|
483
|
+
if (session.state.nativeTurnId) {
|
|
484
|
+
await Promise.race([
|
|
485
|
+
session.host.request('turn/interrupt', { threadId: session.nativeSessionId, turnId: session.state.nativeTurnId }).catch(() => {}),
|
|
486
|
+
new Promise((r) => setTimeout(r, 2_000)),
|
|
487
|
+
]);
|
|
488
|
+
}
|
|
489
|
+
// turn/start 往返期间 nativeTurnId 还没写入,此时取消也必须结算本地回合,否则线程被永久卡住
|
|
490
|
+
if (session.state.turn) {
|
|
491
|
+
session.state.turn.resolve();
|
|
492
|
+
session.state.turn = null;
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
async respond(session, requestId, response) {
|
|
497
|
+
const pending = session.pendingApprovals.get(requestId);
|
|
498
|
+
if (!pending) throw new Error('Codex 原生请求已经结束');
|
|
499
|
+
if (pending.method === 'mcpServer/elicitation/request') {
|
|
500
|
+
const action = response?.cancelled ? 'cancel' : response?.optionId ?? (response?.confirmed === false ? 'decline' : 'accept');
|
|
501
|
+
if (!['accept', 'decline', 'cancel'].includes(action)) throw new Error('Invalid MCP elicitation response');
|
|
502
|
+
let content = null;
|
|
503
|
+
if (action === 'accept' && pending.params.mode === 'form') {
|
|
504
|
+
content = response?.value ? JSON.parse(response.value) : {};
|
|
505
|
+
require('zod').z.fromJSONSchema(pending.params.requestedSchema).parse(content);
|
|
506
|
+
}
|
|
507
|
+
session.pendingApprovals.delete(requestId);
|
|
508
|
+
pending.resolve({ action, content });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
session.pendingApprovals.delete(requestId);
|
|
512
|
+
if (pending.group) {
|
|
513
|
+
const answer = response?.cancelled ? [] : [String(response?.optionId ?? response?.value ?? '')];
|
|
514
|
+
pending.group.answers[pending.question.id] = { answers: answer };
|
|
515
|
+
pending.group.remaining.delete(requestId);
|
|
516
|
+
if (!pending.group.remaining.size) pending.group.resolve({ answers: pending.group.answers });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const decision = response?.cancelled ? 'cancel' : response?.optionId ?? (response?.confirmed === false ? 'decline' : 'accept');
|
|
520
|
+
if (pending.method === 'item/permissions/requestApproval') {
|
|
521
|
+
const requested = pending.params.permissions ?? {};
|
|
522
|
+
pending.resolve({
|
|
523
|
+
permissions: ['accept', 'acceptForSession'].includes(decision)
|
|
524
|
+
? { ...(requested.network ? { network: requested.network } : {}), ...(requested.fileSystem ? { fileSystem: requested.fileSystem } : {}) }
|
|
525
|
+
: {},
|
|
526
|
+
scope: decision === 'acceptForSession' ? 'session' : 'turn',
|
|
527
|
+
});
|
|
528
|
+
} else pending.resolve({ decision });
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
listCommands() {
|
|
532
|
+
return [{ id: 'compact', label: '压缩上下文', description: '由 Codex 原生 app-server 压缩当前 Thread', action: 'execute' }];
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
async executeCommand(session, id, { emit }) {
|
|
536
|
+
if (id !== 'compact') throw new Error('未知 Codex 指令');
|
|
537
|
+
if (session.state.compaction) throw new Error('Codex 正在压缩上下文');
|
|
538
|
+
const completed = new Promise((resolve, reject) => { session.state.compaction = { resolve, reject }; });
|
|
539
|
+
try {
|
|
540
|
+
await session.host.request('thread/compact/start', { threadId: session.nativeSessionId });
|
|
541
|
+
await completed;
|
|
542
|
+
const turnId = session.state.compaction?.turnId ?? session.state.nativeTurnId;
|
|
543
|
+
session.state.compaction = null;
|
|
544
|
+
session.state.nativeTurnId = null;
|
|
545
|
+
const nativeRef = { sessionId: session.nativeSessionId, ...(turnId ? { turnId, checkpointId: turnId } : {}) };
|
|
546
|
+
emit({ kind: 'text-delta', text: '上下文已由 Codex 压缩。', nativeRef });
|
|
547
|
+
emit({ kind: 'completed', finalAnswer: true, nativeRef });
|
|
548
|
+
} catch (error) {
|
|
549
|
+
session.state.compaction = null;
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
async describe() {
|
|
555
|
+
const host = await CodexAppServer.acquire();
|
|
556
|
+
try {
|
|
557
|
+
const [rawModels, profiles] = await Promise.all([
|
|
558
|
+
listAll(host, 'model/list', { includeHidden: false }),
|
|
559
|
+
listAll(host, 'permissionProfile/list').catch(() => []),
|
|
560
|
+
]);
|
|
561
|
+
const models = rawModels.map(modelView);
|
|
562
|
+
const selected = models.find((model) => model.isDefault) ?? models[0];
|
|
563
|
+
return {
|
|
564
|
+
models,
|
|
565
|
+
thinkingLevels: selected?.efforts ?? [],
|
|
566
|
+
permissionModes: [
|
|
567
|
+
{ id: 'default', label: '原生默认', description: '使用 Codex 当前配置的权限策略' },
|
|
568
|
+
...profiles.filter((profile) => profile.allowed).map((profile) => ({ id: profile.id, label: profile.id, description: profile.description })),
|
|
569
|
+
],
|
|
570
|
+
};
|
|
571
|
+
} finally { host.release(); }
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
async describeFor(session) {
|
|
575
|
+
const rawModels = await listAll(session.host, 'model/list', { includeHidden: false });
|
|
576
|
+
const models = rawModels.map(modelView);
|
|
577
|
+
const selected = models.find((model) => model.id === session.model?.id) ?? models.find((model) => model.isDefault) ?? models[0];
|
|
578
|
+
const profiles = await listAll(session.host, 'permissionProfile/list', { cwd: session.cwd }).catch(() => []);
|
|
579
|
+
return {
|
|
580
|
+
models,
|
|
581
|
+
thinkingLevels: selected?.efforts ?? [],
|
|
582
|
+
permissionModes: [
|
|
583
|
+
{ id: 'default', label: '原生默认', description: '使用 Codex 当前配置的权限策略' },
|
|
584
|
+
...profiles.filter((profile) => profile.allowed).map((profile) => ({ id: profile.id, label: profile.id, description: profile.description })),
|
|
585
|
+
],
|
|
586
|
+
};
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
async listModelsFor(session) {
|
|
590
|
+
return (await listAll(session.host, 'model/list', { includeHidden: false })).map(modelView);
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
async setModel(session, model) {
|
|
594
|
+
await session.host.request('thread/settings/update', { threadId: session.nativeSessionId, model: model.id });
|
|
595
|
+
session.model = { id: model.id, name: model.name ?? model.id, provider: model.provider ?? 'openai' };
|
|
596
|
+
return session.model;
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
async setThinkingLevel(session, level) {
|
|
600
|
+
await session.host.request('thread/settings/update', { threadId: session.nativeSessionId, effort: level });
|
|
601
|
+
session.state.effort = level;
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
async setPermissionMode(session, mode) {
|
|
605
|
+
await session.host.request('thread/settings/update', { threadId: session.nativeSessionId, permissions: mode === 'default' ? null : mode });
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
async getContextUsage(session) { return session.state.usage; },
|
|
609
|
+
|
|
610
|
+
async fork(source, { emit, diagnostic, message }) {
|
|
611
|
+
const lastTurnId = message?.coreTurn?.nativeTurnRef?.turnId ?? message?.coreTurn?.nativeTurnRef?.checkpointId;
|
|
612
|
+
if (message && !lastTurnId) throw new Error('这条回复缺少 Codex 原生 Turn ID,无法精确分支');
|
|
613
|
+
const host = await CodexAppServer.acquire(diagnostic, source.options?.codexHome);
|
|
614
|
+
try {
|
|
615
|
+
const result = await host.request('thread/fork', {
|
|
616
|
+
threadId: source.nativeSessionId,
|
|
617
|
+
...(lastTurnId ? { lastTurnId } : {}),
|
|
618
|
+
cwd: source.cwd,
|
|
619
|
+
...(source.model?.id ? { model: source.model.id } : {}),
|
|
620
|
+
});
|
|
621
|
+
const model = { id: result.model, name: result.model, provider: result.modelProvider ?? 'openai' };
|
|
622
|
+
return { session: attachSession(host, result.thread.id, { emit, diagnostic, model, effort: result.reasoningEffort, cwd: source.cwd }) };
|
|
623
|
+
} catch (error) {
|
|
624
|
+
host.release();
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
|
|
629
|
+
async close(session) {
|
|
630
|
+
session.unwatch?.();
|
|
631
|
+
if (session.state.nativeTurnId) {
|
|
632
|
+
await session.host.request('turn/interrupt', { threadId: session.nativeSessionId, turnId: session.state.nativeTurnId }).catch(() => {});
|
|
633
|
+
}
|
|
634
|
+
for (const pending of session.pendingApprovals.values()) {
|
|
635
|
+
if (pending.group) pending.group.resolve({ answers: pending.group.answers });
|
|
636
|
+
else if (pending.method === 'mcpServer/elicitation/request') pending.resolve({ action: 'cancel', content: null });
|
|
637
|
+
else if (pending.method === 'item/permissions/requestApproval') pending.resolve({ permissions: {}, scope: 'turn' });
|
|
638
|
+
else pending.resolve({ decision: 'cancel' });
|
|
639
|
+
}
|
|
640
|
+
session.pendingApprovals.clear();
|
|
641
|
+
session.state.compaction?.reject(new Error('Codex 会话已关闭'));
|
|
642
|
+
session.state.compaction = null;
|
|
643
|
+
session.host.release();
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Global discovery: ~/.agents/skills is the recommended location; $CODEX_HOME/skills
|
|
649
|
+
// (~/.codex/skills) is deprecated upstream but still loaded for backwards compatibility.
|
|
650
|
+
// https://developers.openai.com/codex/skills
|
|
651
|
+
manifest.integrations = { mcp: true, skills: {
|
|
652
|
+
global: ['.agents/skills', '.codex/skills'],
|
|
653
|
+
project: ['.agents/skills'],
|
|
654
|
+
overrides: { '.codex/skills': { env: 'CODEX_HOME', suffix: 'skills' } },
|
|
655
|
+
} };
|
|
656
|
+
module.exports = { manifest, create, projectNotification, queueRequest, usageView, modelView };
|