evolclaw 3.2.0 → 3.3.0
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 +17 -0
- package/README.md +1 -2
- package/dist/agents/{resolve.js → baseagent.js} +34 -5
- package/dist/agents/claude-runner.js +120 -27
- package/dist/agents/codex-app-server-client.js +364 -0
- package/dist/agents/codex-runner.js +1069 -141
- package/dist/agents/gemini-runner.js +2 -2
- package/dist/agents/runner-types.js +28 -0
- package/dist/aun/aid/store.js +1 -1
- package/dist/aun/storage/download.js +1 -1
- package/dist/aun/storage/upload.js +13 -1
- package/dist/channels/aun.js +406 -293
- package/dist/channels/dingtalk.js +77 -140
- package/dist/channels/feishu.js +97 -150
- package/dist/channels/qqbot.js +75 -138
- package/dist/channels/wechat.js +75 -136
- package/dist/channels/wecom.js +75 -138
- package/dist/cli/agent.js +8 -5
- package/dist/cli/index.js +177 -44
- package/dist/cli/init.js +33 -6
- package/dist/cli/model.js +1 -1
- package/dist/cli/stats.js +558 -0
- package/dist/cli/version.js +87 -0
- package/dist/cli/watch-msg.js +5 -2
- package/dist/config-store.js +12 -6
- package/dist/core/channel-loader.js +84 -82
- package/dist/core/command-handler.js +473 -114
- package/dist/core/evolagent-registry.js +1 -0
- package/dist/core/evolagent.js +1 -1
- package/dist/core/interaction-router.js +8 -0
- package/dist/core/message/command-handler-agent-control.js +63 -1
- package/dist/core/message/im-renderer.js +35 -13
- package/dist/core/message/items-formatter.js +9 -1
- package/dist/core/message/message-bridge.js +49 -21
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-processor.js +295 -35
- package/dist/core/message/message-queue.js +2 -2
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/message/response-depth.js +56 -0
- package/dist/core/model/model-catalog.js +1 -1
- package/dist/core/model/model-scope.js +2 -2
- package/dist/core/permission.js +9 -12
- package/dist/core/relation/peer-identity.js +16 -1
- package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
- package/dist/core/session/session-manager.js +27 -13
- package/dist/core/session/session-title.js +26 -0
- package/dist/core/stats/billing.js +151 -0
- package/dist/core/stats/budget.js +93 -0
- package/dist/core/stats/db.js +314 -0
- package/dist/core/stats/eck-vars.js +84 -0
- package/dist/core/stats/index.js +10 -0
- package/dist/core/stats/normalizer.js +78 -0
- package/dist/core/stats/query.js +760 -0
- package/dist/core/stats/writer.js +115 -0
- package/dist/core/trigger/manager.js +34 -0
- package/dist/core/trigger/parser.js +9 -3
- package/dist/core/trigger/scheduler.js +20 -17
- package/dist/{agents → eck}/manifest-engine.js +20 -1
- package/dist/{agents → eck}/message-renderer.js +24 -1
- package/dist/index.js +130 -8
- package/dist/ipc.js +17 -1
- package/dist/utils/cross-platform.js +23 -5
- package/dist/utils/ecweb-pair.js +20 -0
- package/dist/utils/stats.js +14 -0
- package/kits/docs/evolclaw/INDEX.md +3 -1
- package/kits/docs/evolclaw/fs-architecture.md +1215 -0
- package/kits/docs/evolclaw/fs.md +131 -0
- package/kits/docs/evolclaw/group-fs.md +209 -0
- package/kits/docs/evolclaw/stats.md +70 -0
- package/kits/docs/venues/aun-group.md +29 -6
- package/kits/docs/venues/group.md +5 -4
- package/kits/eck_manifest.json +12 -0
- package/kits/eck_message_manifest.json +30 -3
- package/kits/rules/05-venue.md +1 -1
- package/kits/templates/message-fragments/inject-default.md +2 -0
- package/kits/templates/system-fragments/response-depth.md +16 -0
- package/package.json +4 -4
- package/dist/agents/baseagent-normalize.js +0 -19
- package/dist/core/relation/peer-key.js +0 -16
- package/dist/evolclaw-config.js +0 -11
- package/dist/utils/channel-helpers.js +0 -46
- /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
- /package/dist/{agents → eck}/kit-renderer.js +0 -0
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codex Agent Runner
|
|
3
3
|
*
|
|
4
|
-
* Integrates
|
|
4
|
+
* Integrates Codex app-server as an agent backend.
|
|
5
5
|
* Implements the same interface surface as AgentRunner (claude-runner.ts)
|
|
6
6
|
* so MessageProcessor and CommandHandler can work with it transparently.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { checkBlacklist, checkReadonly } from '../core/permission.js';
|
|
9
|
+
import { CodexAppServerClient } from './codex-app-server-client.js';
|
|
10
|
+
import { resolveOpenaiConfig } from './baseagent.js';
|
|
9
11
|
import { logger } from '../utils/logger.js';
|
|
12
|
+
import { renderActionAsText } from '../core/interaction-router.js';
|
|
13
|
+
import { buildEnvelope, sendInteractionPayload } from '../core/message/message-processor.js';
|
|
14
|
+
import { compareVersions } from '../utils/npm-ops.js';
|
|
15
|
+
import { resolveRoot } from '../paths.js';
|
|
10
16
|
import { execFileSync } from 'child_process';
|
|
11
17
|
import fs from 'fs';
|
|
12
18
|
import path from 'path';
|
|
@@ -18,6 +24,42 @@ const MIME_EXT = {
|
|
|
18
24
|
'image/gif': '.gif',
|
|
19
25
|
'image/webp': '.webp',
|
|
20
26
|
};
|
|
27
|
+
class AsyncEventQueue {
|
|
28
|
+
queue = [];
|
|
29
|
+
done = false;
|
|
30
|
+
error = null;
|
|
31
|
+
waiting = null;
|
|
32
|
+
push(item) {
|
|
33
|
+
if (this.done)
|
|
34
|
+
return;
|
|
35
|
+
this.queue.push(item);
|
|
36
|
+
this.waiting?.();
|
|
37
|
+
}
|
|
38
|
+
end() {
|
|
39
|
+
this.done = true;
|
|
40
|
+
this.waiting?.();
|
|
41
|
+
}
|
|
42
|
+
fail(error) {
|
|
43
|
+
this.error = error;
|
|
44
|
+
this.done = true;
|
|
45
|
+
this.waiting?.();
|
|
46
|
+
}
|
|
47
|
+
async *[Symbol.asyncIterator]() {
|
|
48
|
+
while (true) {
|
|
49
|
+
while (this.queue.length > 0) {
|
|
50
|
+
yield this.queue.shift();
|
|
51
|
+
}
|
|
52
|
+
if (this.error)
|
|
53
|
+
throw this.error;
|
|
54
|
+
if (this.done)
|
|
55
|
+
return;
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
this.waiting = resolve;
|
|
58
|
+
});
|
|
59
|
+
this.waiting = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
21
63
|
const CODEX_CATALOG_FALLBACK = [
|
|
22
64
|
{ slug: 'gpt-5.5', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
23
65
|
{ slug: 'gpt-5.4', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
@@ -26,15 +68,55 @@ const CODEX_CATALOG_FALLBACK = [
|
|
|
26
68
|
{ slug: 'gpt-5.2', efforts: ['low', 'medium', 'high', 'xhigh'] },
|
|
27
69
|
];
|
|
28
70
|
let codexCatalogCache = null;
|
|
29
|
-
export
|
|
71
|
+
export const MIN_CODEX_CLI_VERSION = '0.117.0';
|
|
72
|
+
export function parseCodexCliVersion(output) {
|
|
73
|
+
const match = output.match(/\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b/);
|
|
74
|
+
return match?.[1] ?? null;
|
|
75
|
+
}
|
|
76
|
+
export function isCodexCliVersionSupported(version) {
|
|
77
|
+
return compareVersions(version, MIN_CODEX_CLI_VERSION) >= 0;
|
|
78
|
+
}
|
|
79
|
+
export function getCodexCliVersion() {
|
|
30
80
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
81
|
+
const output = execFileSync('codex', ['--version'], {
|
|
82
|
+
encoding: 'utf-8',
|
|
83
|
+
timeout: 3000,
|
|
84
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
|
+
});
|
|
86
|
+
return parseCodexCliVersion(output);
|
|
33
87
|
}
|
|
34
88
|
catch {
|
|
35
|
-
return
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function getCodexAppServerAvailability() {
|
|
93
|
+
const version = getCodexCliVersion();
|
|
94
|
+
const upgradeHint = '请升级 Codex CLI:npm install -g @openai/codex@latest';
|
|
95
|
+
if (!version) {
|
|
96
|
+
return { available: false, reason: `未检测到可用 Codex CLI。${upgradeHint}` };
|
|
97
|
+
}
|
|
98
|
+
if (!isCodexCliVersionSupported(version)) {
|
|
99
|
+
return {
|
|
100
|
+
available: false,
|
|
101
|
+
version,
|
|
102
|
+
reason: `Codex CLI ${version} 低于最低要求 ${MIN_CODEX_CLI_VERSION}。${upgradeHint}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('codex', ['app-server', '--help'], {
|
|
107
|
+
encoding: 'utf-8',
|
|
108
|
+
timeout: 3000,
|
|
109
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
110
|
+
});
|
|
111
|
+
return { available: true, version };
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return { available: false, version, reason: `Codex CLI ${version} 不支持 app-server。${upgradeHint}` };
|
|
36
115
|
}
|
|
37
116
|
}
|
|
117
|
+
export function isCodexAppServerAvailable() {
|
|
118
|
+
return getCodexAppServerAvailability().available;
|
|
119
|
+
}
|
|
38
120
|
function fetchCodexCatalog() {
|
|
39
121
|
if (codexCatalogCache)
|
|
40
122
|
return codexCatalogCache;
|
|
@@ -69,68 +151,116 @@ export function getCodexEfforts(model) {
|
|
|
69
151
|
// ── Codex Runner ──
|
|
70
152
|
export class CodexRunner {
|
|
71
153
|
name = 'codex';
|
|
72
|
-
capabilities
|
|
73
|
-
codexModule = null;
|
|
154
|
+
capabilities;
|
|
74
155
|
model;
|
|
75
156
|
effort;
|
|
76
157
|
activeAbortControllers = new Map();
|
|
77
158
|
activeStreams = new Map();
|
|
78
159
|
activeSessions = new Map(); // sessionId → threadId
|
|
160
|
+
activeTurns = new Map();
|
|
161
|
+
appServerClient = null;
|
|
79
162
|
onSessionIdUpdate;
|
|
163
|
+
onCompactStart;
|
|
164
|
+
permissionGateway;
|
|
165
|
+
sendPromptFn;
|
|
166
|
+
permissionContexts = new Map();
|
|
80
167
|
resolvedConfig;
|
|
81
168
|
constructor(config, callbacks) {
|
|
82
169
|
this.resolvedConfig = resolveOpenaiConfig(config);
|
|
170
|
+
this.capabilities = {
|
|
171
|
+
clear: false,
|
|
172
|
+
compact: true,
|
|
173
|
+
fork: true,
|
|
174
|
+
// Requires Codex CLI feature flag: default_mode_request_user_input.
|
|
175
|
+
askUserQuestion: this.resolvedConfig.enableRequestUserInput === true,
|
|
176
|
+
// Codex app-server exposes plan streaming, but not Claude-style ExitPlanMode approval.
|
|
177
|
+
planApproval: false,
|
|
178
|
+
// Current file rewind is intentionally degraded: it restores touched files from Git HEAD.
|
|
179
|
+
fileRewind: 'git-head',
|
|
180
|
+
};
|
|
83
181
|
this.model = this.resolvedConfig.model;
|
|
84
182
|
if (this.resolvedConfig.effort)
|
|
85
183
|
this.effort = this.resolvedConfig.effort;
|
|
86
184
|
this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
|
|
87
185
|
}
|
|
88
|
-
|
|
89
|
-
if (!this.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
186
|
+
getAppServerClient() {
|
|
187
|
+
if (!this.appServerClient) {
|
|
188
|
+
this.appServerClient = new CodexAppServerClient({
|
|
189
|
+
apiKey: this.resolvedConfig.apiKey,
|
|
190
|
+
baseUrl: this.resolvedConfig.baseUrl,
|
|
191
|
+
model: this.model,
|
|
192
|
+
effort: this.effort,
|
|
193
|
+
enableRequestUserInput: this.resolvedConfig.enableRequestUserInput,
|
|
194
|
+
approvalsReviewer: this.resolvedConfig.approvalsReviewer,
|
|
195
|
+
onServerRequest: request => this.handleAppServerRequest(request),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return this.appServerClient;
|
|
199
|
+
}
|
|
200
|
+
resetAppServerClient() {
|
|
201
|
+
const client = this.appServerClient;
|
|
202
|
+
this.appServerClient = null;
|
|
203
|
+
client?.close().catch(error => {
|
|
204
|
+
logger.debug(`[CodexRunner] Failed to close stale app-server client: ${error}`);
|
|
100
205
|
});
|
|
101
|
-
return { codex, mod: this.codexModule };
|
|
102
206
|
}
|
|
103
207
|
// ── ModelSwitcher ──
|
|
104
|
-
setModel(model) { this.model = model; }
|
|
208
|
+
setModel(model) { this.model = model; this.resetAppServerClient(); }
|
|
105
209
|
getModel() { return this.model; }
|
|
106
|
-
listModels() {
|
|
210
|
+
async listModels() {
|
|
211
|
+
try {
|
|
212
|
+
const response = await this.getAppServerClient().modelList(false);
|
|
213
|
+
const ids = (response.data ?? [])
|
|
214
|
+
.map(model => model.id || model.slug || model.name || model.model)
|
|
215
|
+
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
216
|
+
if (ids.length > 0)
|
|
217
|
+
return ids;
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
logger.debug(`[CodexRunner] app-server model/list failed, using catalog fallback: ${error}`);
|
|
221
|
+
}
|
|
222
|
+
return fetchCodexCatalog().map(m => m.slug);
|
|
223
|
+
}
|
|
107
224
|
// ── Effort ──
|
|
108
|
-
setEffort(effort) { this.effort = effort; }
|
|
225
|
+
setEffort(effort) { this.effort = effort; this.resetAppServerClient(); }
|
|
109
226
|
getEffort() { return this.effort; }
|
|
110
227
|
// ── Permission ──
|
|
111
228
|
currentMode = 'auto';
|
|
112
229
|
approvalPolicy = 'never';
|
|
230
|
+
sandboxMode = 'danger-full-access';
|
|
113
231
|
setMode(mode) {
|
|
114
232
|
const map = {
|
|
233
|
+
// Codex app-server also supports auto_review, but EvolClaw auto currently means:
|
|
234
|
+
// run local blacklist/readonly guards, then approve app-server requests without
|
|
235
|
+
// app-server reviewer escalation. Changing this requires a semantic decision.
|
|
115
236
|
'auto': 'never',
|
|
116
237
|
'bypass': 'never',
|
|
238
|
+
'readonly': 'on-request',
|
|
117
239
|
'request': 'on-request',
|
|
118
240
|
'noask': 'untrusted',
|
|
119
241
|
};
|
|
120
|
-
this.approvalPolicy = map[mode] || 'never';
|
|
121
242
|
this.currentMode = mode;
|
|
243
|
+
this.approvalPolicy = map[mode] || 'never';
|
|
244
|
+
this.sandboxMode = this.toSandboxMode(mode);
|
|
122
245
|
}
|
|
123
246
|
getMode() { return this.currentMode; }
|
|
124
247
|
listModes() {
|
|
125
248
|
return [
|
|
126
249
|
{ key: 'auto', nameZh: '自动', description: '全部自动(受 sandbox 约束)', available: true },
|
|
127
250
|
{ key: 'bypass', nameZh: '放行', description: '全部自动(受 sandbox 约束)', available: true },
|
|
251
|
+
{ key: 'readonly', nameZh: '只读', description: '允许读取和临时目录写入,拒绝项目文件修改', available: true },
|
|
128
252
|
{ key: 'request', nameZh: '审批', description: '需要审批时询问', available: true },
|
|
129
253
|
{ key: 'noask', nameZh: '静默', description: '只执行已知安全操作', available: true },
|
|
130
254
|
];
|
|
131
255
|
}
|
|
132
|
-
setSendPrompt(
|
|
133
|
-
|
|
256
|
+
setSendPrompt(fn) { this.sendPromptFn = fn; }
|
|
257
|
+
setPermissionContext(sessionId, context) { this.permissionContexts.set(sessionId, context); }
|
|
258
|
+
setPermissionGateway(gw) { this.permissionGateway = gw; }
|
|
259
|
+
toSandboxMode(mode) {
|
|
260
|
+
if (mode === 'request' || mode === 'readonly' || mode === 'noask')
|
|
261
|
+
return 'read-only';
|
|
262
|
+
return 'danger-full-access';
|
|
263
|
+
}
|
|
134
264
|
// ── Stream management (needed by MessageProcessor) ──
|
|
135
265
|
registerStream(key, stream) {
|
|
136
266
|
this.activeStreams.set(key, stream);
|
|
@@ -140,64 +270,113 @@ export class CodexRunner {
|
|
|
140
270
|
this.activeAbortControllers.delete(key);
|
|
141
271
|
}
|
|
142
272
|
hasActiveStream(key) {
|
|
143
|
-
return this.activeStreams.has(key);
|
|
273
|
+
return this.activeStreams.has(key) || this.activeAbortControllers.has(key) || this.activeTurns.has(key);
|
|
144
274
|
}
|
|
145
275
|
// ── Core: runQuery ──
|
|
146
|
-
async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager) {
|
|
147
|
-
const { codex } = await this.ensureCodex(sessionId);
|
|
276
|
+
async runQuery(sessionId, prompt, projectPath, initialAgentSessionId, images, systemPromptAppend, sessionManager, modelOverride) {
|
|
148
277
|
let agentSessionId = initialAgentSessionId || this.activeSessions.get(sessionId);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (systemPromptAppend && !agentSessionId) {
|
|
153
|
-
fullPrompt = prompt + '\n\n--- [SYSTEM_PROMPT_END] ---\n' + systemPromptAppend;
|
|
154
|
-
}
|
|
278
|
+
const callModel = modelOverride?.model || this.model;
|
|
279
|
+
const callEffort = modelOverride?.effort ?? this.effort;
|
|
280
|
+
const appServer = this.getAppServerClient();
|
|
155
281
|
const threadOptions = {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
skipGitRepoCheck: true,
|
|
159
|
-
sandboxMode: 'danger-full-access',
|
|
282
|
+
model: callModel,
|
|
283
|
+
effort: callEffort,
|
|
160
284
|
approvalPolicy: this.approvalPolicy,
|
|
161
|
-
|
|
285
|
+
approvalsReviewer: this.resolvedConfig.approvalsReviewer,
|
|
286
|
+
sandbox: this.sandboxMode,
|
|
287
|
+
config: this.buildEvolclawShellEnvironmentConfig(sessionId),
|
|
288
|
+
...(systemPromptAppend ? { developerInstructions: systemPromptAppend } : {}),
|
|
162
289
|
};
|
|
163
|
-
const
|
|
164
|
-
?
|
|
165
|
-
:
|
|
290
|
+
const threadResponse = agentSessionId
|
|
291
|
+
? await appServer.threadResume(agentSessionId, projectPath, threadOptions)
|
|
292
|
+
: await appServer.threadStart(projectPath, threadOptions);
|
|
293
|
+
const threadId = threadResponse.thread?.id || agentSessionId;
|
|
294
|
+
if (!threadId)
|
|
295
|
+
throw new Error('Codex app-server did not return a thread id');
|
|
296
|
+
agentSessionId = threadId;
|
|
297
|
+
this.activeSessions.set(sessionId, threadId);
|
|
298
|
+
this.onSessionIdUpdate?.(sessionId, threadId);
|
|
166
299
|
const controller = new AbortController();
|
|
167
300
|
this.activeAbortControllers.set(sessionId, controller);
|
|
168
|
-
// 构建输入:将 base64 图片写入临时文件,转换为 Codex SDK 的 local_image 格式
|
|
169
301
|
const tempFiles = [];
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
302
|
+
const input = this.buildAppServerInput(prompt, images, tempFiles);
|
|
303
|
+
const queue = new AsyncEventQueue();
|
|
304
|
+
controller.signal.addEventListener('abort', () => queue.end(), { once: true });
|
|
305
|
+
const state = {
|
|
306
|
+
threadId,
|
|
307
|
+
streamedAgentMessageIds: new Set(),
|
|
308
|
+
agentMessageDeltaText: new Map(),
|
|
309
|
+
completedItemIds: new Set(),
|
|
310
|
+
completedTurnIds: new Set(),
|
|
311
|
+
};
|
|
312
|
+
const unsubscribe = appServer.onNotification(notification => {
|
|
313
|
+
// 仅从 turn/started 锁定权威 turnId — resume 时会有上一轮 turn 的残留通知
|
|
314
|
+
// (如 thread/tokenUsage/updated)先于新 turn 到达,不能用它们 latch turnId
|
|
315
|
+
const params = notification.params || {};
|
|
316
|
+
const notifThreadId = params.threadId ?? params.thread_id;
|
|
317
|
+
if (notifThreadId !== undefined && notifThreadId !== threadId)
|
|
318
|
+
return;
|
|
319
|
+
if (notification.method === 'turn/started') {
|
|
320
|
+
const startedTurnId = this.extractTurnId(notification);
|
|
321
|
+
if (startedTurnId && !state.turnId) {
|
|
322
|
+
state.turnId = startedTurnId;
|
|
323
|
+
this.activeTurns.set(sessionId, { threadId, turnId: startedTurnId });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!this.isAppServerTurnNotification(notification, state))
|
|
327
|
+
return;
|
|
328
|
+
queue.push(notification);
|
|
329
|
+
// 仅在已锁定 turnId 后才允许 turn/completed 结束队列,避免残留的旧 turn/completed 误关
|
|
330
|
+
if (notification.method === 'turn/completed' && state.turnId)
|
|
331
|
+
queue.end();
|
|
332
|
+
});
|
|
333
|
+
try {
|
|
334
|
+
const turnResponse = await appServer.turnStart(threadId, input, {
|
|
335
|
+
cwd: projectPath,
|
|
336
|
+
model: callModel,
|
|
337
|
+
effort: callEffort,
|
|
338
|
+
approvalPolicy: this.approvalPolicy,
|
|
339
|
+
sandbox: this.sandboxMode,
|
|
340
|
+
});
|
|
341
|
+
const turnId = turnResponse.turn?.id;
|
|
342
|
+
if (turnId && !state.turnId) {
|
|
343
|
+
state.turnId = turnId;
|
|
344
|
+
this.activeTurns.set(sessionId, { threadId, turnId });
|
|
345
|
+
}
|
|
346
|
+
const status = turnResponse.turn?.status;
|
|
347
|
+
if (status === 'completed' || status === 'failed') {
|
|
348
|
+
queue.push({ method: 'turn/completed', params: { threadId, turn: turnResponse.turn } });
|
|
349
|
+
queue.end();
|
|
350
|
+
}
|
|
184
351
|
}
|
|
185
|
-
|
|
186
|
-
|
|
352
|
+
catch (error) {
|
|
353
|
+
unsubscribe();
|
|
354
|
+
this.activeAbortControllers.delete(sessionId);
|
|
355
|
+
this.activeTurns.delete(sessionId);
|
|
356
|
+
this.cleanupTempFiles(tempFiles);
|
|
357
|
+
throw error;
|
|
187
358
|
}
|
|
188
|
-
|
|
189
|
-
// 包装为 AgentEvent 流
|
|
190
|
-
return this.transformStream(events, sessionId, thread, tempFiles);
|
|
359
|
+
return this.transformAppServerStream(queue, sessionId, state, unsubscribe, tempFiles);
|
|
191
360
|
}
|
|
192
361
|
// ── Interrupt ──
|
|
193
362
|
async interrupt(sessionKey) {
|
|
194
363
|
const controller = this.activeAbortControllers.get(sessionKey);
|
|
195
|
-
|
|
364
|
+
const activeTurn = this.activeTurns.get(sessionKey);
|
|
365
|
+
const hadActiveState = !!controller || !!activeTurn || this.activeStreams.has(sessionKey);
|
|
366
|
+
const interruptTurn = activeTurn
|
|
367
|
+
? this.getAppServerClient().turnInterrupt(activeTurn.threadId, activeTurn.turnId).catch(error => {
|
|
368
|
+
logger.debug(`[CodexRunner] app-server turn interrupt failed: ${error}`);
|
|
369
|
+
})
|
|
370
|
+
: Promise.resolve();
|
|
371
|
+
if (controller)
|
|
196
372
|
controller.abort('User interrupt');
|
|
373
|
+
if (hadActiveState) {
|
|
197
374
|
this.activeAbortControllers.delete(sessionKey);
|
|
198
375
|
this.activeStreams.delete(sessionKey);
|
|
376
|
+
this.activeTurns.delete(sessionKey);
|
|
199
377
|
logger.info(`[CodexRunner] Interrupted session: ${sessionKey}`);
|
|
200
378
|
}
|
|
379
|
+
await interruptTurn;
|
|
201
380
|
}
|
|
202
381
|
// ── Session commands ──
|
|
203
382
|
updateSessionId(sessionId, agentSessionId) {
|
|
@@ -213,6 +392,8 @@ export class CodexRunner {
|
|
|
213
392
|
this.activeSessions.delete(sessionId);
|
|
214
393
|
this.activeStreams.delete(sessionId);
|
|
215
394
|
this.activeAbortControllers.delete(sessionId);
|
|
395
|
+
this.activeTurns.delete(sessionId);
|
|
396
|
+
this.permissionContexts.delete(sessionId);
|
|
216
397
|
}
|
|
217
398
|
resolveSessionFile(agentSessionId, _projectPath) {
|
|
218
399
|
// Codex session 文件: ~/.codex/sessions/YYYY/MM/DD/rollout-*-{threadId}.jsonl
|
|
@@ -235,113 +416,855 @@ export class CodexRunner {
|
|
|
235
416
|
};
|
|
236
417
|
return search(sessionsDir);
|
|
237
418
|
}
|
|
238
|
-
async clearSession(
|
|
419
|
+
async clearSession(sessionId, _agentSessionId, _projectPath) {
|
|
239
420
|
// Codex: 清空会话 = 下次 runQuery 不传 resumeId,自动创建新 thread
|
|
421
|
+
this.activeSessions.delete(sessionId);
|
|
422
|
+
this.onSessionIdUpdate?.(sessionId, '');
|
|
240
423
|
return true;
|
|
241
424
|
}
|
|
242
|
-
async compactSession(_sessionId,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
425
|
+
async compactSession(_sessionId, agentSessionId, _projectPath) {
|
|
426
|
+
try {
|
|
427
|
+
const appServer = this.getAppServerClient();
|
|
428
|
+
this.onCompactStart?.(_sessionId);
|
|
429
|
+
try {
|
|
430
|
+
return await this.startAndWaitForCompact(appServer, agentSessionId);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (!this.isThreadNotFoundError(error))
|
|
434
|
+
throw error;
|
|
435
|
+
logger.info(`[CodexRunner] Compact thread not loaded, resuming before compact: ${agentSessionId}`);
|
|
436
|
+
await appServer.threadResume(agentSessionId, _projectPath, {
|
|
437
|
+
model: this.model,
|
|
438
|
+
effort: this.effort,
|
|
439
|
+
approvalPolicy: this.approvalPolicy,
|
|
440
|
+
approvalsReviewer: this.resolvedConfig.approvalsReviewer,
|
|
441
|
+
sandbox: this.sandboxMode,
|
|
442
|
+
config: this.buildEvolclawShellEnvironmentConfig(_sessionId),
|
|
443
|
+
});
|
|
444
|
+
return await this.startAndWaitForCompact(appServer, agentSessionId);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
logger.error('[CodexRunner] Compact failed:', error);
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
246
451
|
}
|
|
247
|
-
async compact(
|
|
248
|
-
return this.compactSession(
|
|
452
|
+
async compact(sessionId, agentSessionId, projectPath) {
|
|
453
|
+
return this.compactSession(sessionId, agentSessionId, projectPath);
|
|
249
454
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
async *transformStream(events, sessionId, thread, tempFiles) {
|
|
455
|
+
async startAndWaitForCompact(appServer, threadId) {
|
|
456
|
+
const completion = this.waitForThreadCompacted(appServer, threadId, Date.now());
|
|
253
457
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
yield* this.mapEvent(event, sessionId, thread);
|
|
258
|
-
}
|
|
458
|
+
await appServer.threadCompactStart(threadId);
|
|
459
|
+
await completion.promise;
|
|
460
|
+
return true;
|
|
259
461
|
}
|
|
260
462
|
finally {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
463
|
+
completion.dispose();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
waitForThreadCompacted(appServer, threadId, startedAtMs) {
|
|
467
|
+
let unsubscribe;
|
|
468
|
+
let pollTimer;
|
|
469
|
+
let settled = false;
|
|
470
|
+
const settle = (resolve, source) => {
|
|
471
|
+
if (settled)
|
|
472
|
+
return;
|
|
473
|
+
settled = true;
|
|
474
|
+
if (pollTimer)
|
|
475
|
+
clearInterval(pollTimer);
|
|
476
|
+
logger.info(`[CodexRunner] Compact completed for thread: ${threadId} (${source})`);
|
|
477
|
+
unsubscribe?.();
|
|
478
|
+
resolve();
|
|
479
|
+
};
|
|
480
|
+
const promise = new Promise(resolve => {
|
|
481
|
+
unsubscribe = appServer.onNotification(notification => {
|
|
482
|
+
if (notification.method !== 'thread/compacted')
|
|
483
|
+
return;
|
|
484
|
+
const params = notification.params || {};
|
|
485
|
+
const notifThreadId = params.threadId ?? params.thread_id;
|
|
486
|
+
if (notifThreadId !== threadId)
|
|
487
|
+
return;
|
|
488
|
+
settle(resolve, 'notification');
|
|
489
|
+
});
|
|
490
|
+
pollTimer = setInterval(() => {
|
|
491
|
+
if (this.hasPersistedCompactCompletion(threadId, startedAtMs)) {
|
|
492
|
+
settle(resolve, 'session-log');
|
|
493
|
+
}
|
|
494
|
+
}, 1000);
|
|
495
|
+
pollTimer.unref?.();
|
|
496
|
+
});
|
|
497
|
+
return {
|
|
498
|
+
promise,
|
|
499
|
+
dispose: () => {
|
|
500
|
+
if (pollTimer)
|
|
501
|
+
clearInterval(pollTimer);
|
|
502
|
+
if (!settled)
|
|
503
|
+
unsubscribe?.();
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
hasPersistedCompactCompletion(threadId, startedAtMs) {
|
|
508
|
+
const sessionFile = this.findCodexSessionFile(threadId);
|
|
509
|
+
if (!sessionFile)
|
|
510
|
+
return false;
|
|
511
|
+
let text = '';
|
|
512
|
+
try {
|
|
513
|
+
text = fs.readFileSync(sessionFile, 'utf8');
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
const threshold = startedAtMs - 1000;
|
|
519
|
+
for (const line of text.trimEnd().split('\n').reverse()) {
|
|
520
|
+
if (!line.trim())
|
|
521
|
+
continue;
|
|
522
|
+
let entry;
|
|
523
|
+
try {
|
|
524
|
+
entry = JSON.parse(line);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
530
|
+
if (!Number.isFinite(ts))
|
|
531
|
+
continue;
|
|
532
|
+
if (ts < threshold)
|
|
533
|
+
break;
|
|
534
|
+
const payloadType = entry.payload?.type;
|
|
535
|
+
if (entry.type === 'compacted' || payloadType === 'context_compacted')
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
findCodexSessionFile(threadId) {
|
|
541
|
+
const root = process.env.CODEX_HOME
|
|
542
|
+
? path.join(process.env.CODEX_HOME, 'sessions')
|
|
543
|
+
: path.join(process.env.HOME || os.homedir(), '.codex', 'sessions');
|
|
544
|
+
const stack = [root];
|
|
545
|
+
let newest;
|
|
546
|
+
while (stack.length > 0) {
|
|
547
|
+
const dir = stack.pop();
|
|
548
|
+
let entries;
|
|
549
|
+
try {
|
|
550
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
for (const entry of entries) {
|
|
556
|
+
const fullPath = path.join(dir, entry.name);
|
|
557
|
+
if (entry.isDirectory()) {
|
|
558
|
+
stack.push(fullPath);
|
|
559
|
+
}
|
|
560
|
+
else if (entry.isFile() && entry.name.includes(threadId) && entry.name.endsWith('.jsonl')) {
|
|
561
|
+
let mtimeMs = 0;
|
|
265
562
|
try {
|
|
266
|
-
fs.
|
|
563
|
+
mtimeMs = fs.statSync(fullPath).mtimeMs;
|
|
267
564
|
}
|
|
268
|
-
catch {
|
|
565
|
+
catch {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (!newest || mtimeMs > newest.mtimeMs)
|
|
569
|
+
newest = { path: fullPath, mtimeMs };
|
|
269
570
|
}
|
|
270
571
|
}
|
|
271
572
|
}
|
|
573
|
+
return newest?.path;
|
|
272
574
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
575
|
+
async forkSession(agentSessionId, projectPath, title) {
|
|
576
|
+
const response = await this.getAppServerClient().threadFork(agentSessionId, projectPath, title);
|
|
577
|
+
const forkedThreadId = response.thread?.id;
|
|
578
|
+
if (!forkedThreadId)
|
|
579
|
+
throw new Error('Codex fork did not return a thread id');
|
|
580
|
+
return forkedThreadId;
|
|
581
|
+
}
|
|
582
|
+
async setSessionName(agentSessionId, name) {
|
|
583
|
+
return this.getAppServerClient().threadSetName(agentSessionId, name);
|
|
584
|
+
}
|
|
585
|
+
async updateSessionMetadata(agentSessionId, metadata) {
|
|
586
|
+
const gitInfo = metadata?.gitInfo && typeof metadata.gitInfo === 'object' ? metadata.gitInfo : undefined;
|
|
587
|
+
return this.getAppServerClient().threadMetadataUpdate(agentSessionId, gitInfo);
|
|
588
|
+
}
|
|
589
|
+
async getSessionMessages(agentSessionId, projectPath) {
|
|
590
|
+
const response = await this.getAppServerClient().threadRead(agentSessionId, true);
|
|
591
|
+
return this.mapThreadToSessionMessages(response, agentSessionId);
|
|
592
|
+
}
|
|
593
|
+
async handleAppServerRequest(request) {
|
|
594
|
+
const params = (request.params || {});
|
|
595
|
+
if (request.method === 'item/tool/requestUserInput') {
|
|
596
|
+
return this.handleToolRequestUserInput(params);
|
|
597
|
+
}
|
|
598
|
+
const sessionKey = this.findSessionKeyByThread(params.threadId || params.conversationId);
|
|
599
|
+
const toolName = request.method.includes('fileChange') || request.method === 'applyPatchApproval' ? 'FileChange' : 'Bash';
|
|
600
|
+
const toolInput = this.buildPermissionInput(request.method, params);
|
|
601
|
+
const summary = this.summarizeAppServerRequest(request.method, params);
|
|
602
|
+
const reason = params.reason || params.decisionReason || undefined;
|
|
603
|
+
const projectPath = this.resolvePermissionProjectPath(params);
|
|
604
|
+
logger.info(`[CodexRunner] app-server approval request id=${request.id} method=${request.method} session=${sessionKey} mode=${this.currentMode} tool=${toolName} summary=${summary}`);
|
|
605
|
+
try {
|
|
606
|
+
const decision = await this.resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, projectPath);
|
|
607
|
+
const response = this.toAppServerApprovalResponse(request.method, decision);
|
|
608
|
+
logger.info(`[CodexRunner] app-server approval response id=${request.id} method=${request.method} decision=${decision} response=${JSON.stringify(response)}`);
|
|
609
|
+
return response;
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
613
|
+
logger.warn(`[CodexRunner] app-server approval failed id=${request.id} method=${request.method}: ${message}`);
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async handleToolRequestUserInput(params) {
|
|
618
|
+
const sessionKey = this.findSessionKeyByThread(params.threadId);
|
|
619
|
+
const questions = Array.isArray(params.questions) ? params.questions : [];
|
|
620
|
+
const answers = {};
|
|
621
|
+
for (const question of questions) {
|
|
622
|
+
const questionId = typeof question.id === 'string' ? question.id : `q-${Object.keys(answers).length + 1}`;
|
|
623
|
+
answers[questionId] = {
|
|
624
|
+
answers: await this.collectUserInputAnswer(sessionKey, question),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return { answers };
|
|
628
|
+
}
|
|
629
|
+
async collectUserInputAnswer(sessionKey, question) {
|
|
630
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
631
|
+
const fallback = options[0]?.label ? [String(options[0].label)] : [''];
|
|
632
|
+
const context = this.permissionContexts.get(sessionKey);
|
|
633
|
+
const canFreeText = question.isOther !== false || options.length === 0;
|
|
634
|
+
const sendPrompt = context?.adapter && context.channelId
|
|
635
|
+
? async (text) => context.adapter.send(buildEnvelope({
|
|
636
|
+
channel: context.adapter.channelName,
|
|
637
|
+
channelId: context.channelId,
|
|
638
|
+
replyContext: context.replyContext,
|
|
639
|
+
}), { kind: 'result.text', text, isFinal: true })
|
|
640
|
+
: this.sendPromptFn;
|
|
641
|
+
if (!context?.interactionRouter || !sendPrompt) {
|
|
642
|
+
if (sendPrompt)
|
|
643
|
+
await sendPrompt(this.formatUserInputFallback(question, fallback));
|
|
644
|
+
return fallback;
|
|
645
|
+
}
|
|
646
|
+
const requestId = `codex-ask-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
647
|
+
const buttonArgMap = {};
|
|
648
|
+
const buttons = options.length > 0
|
|
649
|
+
? options.map((option, index) => {
|
|
650
|
+
const key = `opt-${index}`;
|
|
651
|
+
buttonArgMap[key] = String(index + 1);
|
|
652
|
+
return { key, label: String(option.label || `选项 ${index + 1}`), style: 'default' };
|
|
653
|
+
})
|
|
654
|
+
: [{ key: 'custom', label: '提交', style: 'primary' }];
|
|
655
|
+
const bodyLines = [String(question.question || '')];
|
|
656
|
+
if (options.some((option) => option.description)) {
|
|
657
|
+
bodyLines.push('', ...options.map((option, index) => `${index + 1}. ${String(option.label || `选项 ${index + 1}`)}${option.description ? ` — ${option.description}` : ''}`));
|
|
658
|
+
}
|
|
659
|
+
const interaction = {
|
|
660
|
+
type: 'interaction',
|
|
661
|
+
id: requestId,
|
|
662
|
+
channelId: context.channelId || '',
|
|
663
|
+
sessionId: sessionKey,
|
|
664
|
+
initiatorId: context.userId,
|
|
665
|
+
kind: {
|
|
666
|
+
kind: 'action',
|
|
667
|
+
title: String(question.header || '问题'),
|
|
668
|
+
body: bodyLines.join('\n'),
|
|
669
|
+
buttons,
|
|
670
|
+
allowCustomInput: canFreeText,
|
|
671
|
+
},
|
|
672
|
+
fallback: {
|
|
673
|
+
command: 'ask',
|
|
674
|
+
buttonArgMap,
|
|
675
|
+
acceptFreeText: canFreeText,
|
|
676
|
+
freeTextHint: canFreeText ? '或回复 /ask <自定义内容>' : undefined,
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
const router = context.interactionRouter;
|
|
680
|
+
router.markWaiting(sessionKey);
|
|
681
|
+
let waitMarked = true;
|
|
682
|
+
let sent = false;
|
|
683
|
+
try {
|
|
684
|
+
await context.flushPending?.();
|
|
685
|
+
if (context.adapter && context.channelId) {
|
|
686
|
+
const envelope = buildEnvelope({
|
|
687
|
+
taskId: context.taskId,
|
|
688
|
+
channel: context.channel ?? context.adapter.channelName,
|
|
689
|
+
channelId: context.channelId,
|
|
690
|
+
agentName: context.agentName,
|
|
691
|
+
chatmode: context.chatmode,
|
|
692
|
+
replyContext: context.replyContext,
|
|
693
|
+
});
|
|
694
|
+
sent = !!await sendInteractionPayload(context.adapter, envelope, interaction, undefined, context.replyContext);
|
|
695
|
+
}
|
|
696
|
+
if (!sent) {
|
|
697
|
+
await sendPrompt(renderActionAsText(interaction));
|
|
698
|
+
sent = true;
|
|
281
699
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
logger.warn('[CodexRunner] requestUserInput prompt send failed:', error);
|
|
703
|
+
}
|
|
704
|
+
if (!sent) {
|
|
705
|
+
router.unmarkWaiting(sessionKey);
|
|
706
|
+
return fallback;
|
|
707
|
+
}
|
|
708
|
+
return new Promise((resolve) => {
|
|
709
|
+
router.register(requestId, sessionKey, (action, values) => {
|
|
710
|
+
resolve(this.parseUserInputAction(action, values, options, fallback));
|
|
711
|
+
}, {
|
|
712
|
+
initiatorId: context.userId,
|
|
713
|
+
fallbackCommand: 'ask',
|
|
714
|
+
});
|
|
715
|
+
if (waitMarked) {
|
|
716
|
+
router.unmarkWaiting(sessionKey);
|
|
717
|
+
waitMarked = false;
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
parseUserInputAction(action, values, options, fallback) {
|
|
722
|
+
if (action === '_custom_input') {
|
|
723
|
+
const customText = typeof values?.custom_text === 'string' ? values.custom_text.trim() : '';
|
|
724
|
+
return customText ? [customText] : fallback;
|
|
725
|
+
}
|
|
726
|
+
if (action.startsWith('opt-')) {
|
|
727
|
+
const index = Number.parseInt(action.slice(4), 10);
|
|
728
|
+
const label = options[index]?.label;
|
|
729
|
+
return label ? [String(label)] : fallback;
|
|
730
|
+
}
|
|
731
|
+
const selected = action.split(',').map(part => part.trim()).filter(Boolean);
|
|
732
|
+
if (selected.length > 0 && selected.every(part => /^\d+$/.test(part))) {
|
|
733
|
+
const labels = selected
|
|
734
|
+
.map(part => options[Number.parseInt(part, 10) - 1]?.label)
|
|
735
|
+
.filter((label) => typeof label === 'string' && label.length > 0);
|
|
736
|
+
if (labels.length > 0)
|
|
737
|
+
return labels;
|
|
738
|
+
}
|
|
739
|
+
return action.trim() ? [action.trim()] : fallback;
|
|
740
|
+
}
|
|
741
|
+
formatUserInputFallback(question, fallback) {
|
|
742
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
743
|
+
const lines = [String(question.header || '问题'), String(question.question || '')].filter(Boolean);
|
|
744
|
+
if (options.length > 0) {
|
|
745
|
+
lines.push('', ...options.map((option, index) => `${index + 1}. ${String(option.label || `选项 ${index + 1}`)}${option.description ? ` — ${option.description}` : ''}`));
|
|
746
|
+
}
|
|
747
|
+
lines.push('', `自动选择:${fallback.join(', ')}`);
|
|
748
|
+
return lines.join('\n');
|
|
749
|
+
}
|
|
750
|
+
findSessionKeyByThread(threadId) {
|
|
751
|
+
if (threadId) {
|
|
752
|
+
for (const [sessionKey, activeThreadId] of this.activeSessions.entries()) {
|
|
753
|
+
if (activeThreadId === threadId)
|
|
754
|
+
return sessionKey;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return threadId || 'codex-app-server';
|
|
758
|
+
}
|
|
759
|
+
buildPermissionInput(method, params) {
|
|
760
|
+
if (method.includes('fileChange') || method === 'applyPatchApproval') {
|
|
761
|
+
return { fileChanges: params.fileChanges, grantRoot: params.grantRoot, reason: params.reason };
|
|
762
|
+
}
|
|
763
|
+
const command = Array.isArray(params.command) ? params.command.join(' ') : (params.command || '');
|
|
764
|
+
return { command, cwd: params.cwd, reason: params.reason, commandActions: params.commandActions || params.parsedCmd };
|
|
765
|
+
}
|
|
766
|
+
summarizeAppServerRequest(method, params) {
|
|
767
|
+
if (method.includes('fileChange') || method === 'applyPatchApproval') {
|
|
768
|
+
if (params.grantRoot)
|
|
769
|
+
return '允许写入:' + params.grantRoot;
|
|
770
|
+
const changes = params.fileChanges && typeof params.fileChanges === 'object' ? Object.keys(params.fileChanges) : [];
|
|
771
|
+
return changes.length ? changes.join(', ') : '文件变更审批';
|
|
772
|
+
}
|
|
773
|
+
const command = Array.isArray(params.command) ? params.command.join(' ') : params.command;
|
|
774
|
+
return command || '命令执行审批';
|
|
775
|
+
}
|
|
776
|
+
resolvePermissionProjectPath(params) {
|
|
777
|
+
const candidates = [params.cwd, params.projectPath, params.grantRoot]
|
|
778
|
+
.filter((value) => typeof value === 'string' && value.length > 0);
|
|
779
|
+
for (const candidate of candidates) {
|
|
780
|
+
if (path.isAbsolute(candidate))
|
|
781
|
+
return candidate;
|
|
782
|
+
}
|
|
783
|
+
return process.cwd();
|
|
784
|
+
}
|
|
785
|
+
checkCodexReadonly(toolName, input, projectPath) {
|
|
786
|
+
if (toolName === 'Bash')
|
|
787
|
+
return checkReadonly(toolName, input, projectPath);
|
|
788
|
+
if (toolName !== 'FileChange')
|
|
789
|
+
return { behavior: 'allow' };
|
|
790
|
+
const tmpDir = path.join(projectPath, '.evolclaw', 'tmp') + path.sep;
|
|
791
|
+
const isAllowedPath = (filePath) => {
|
|
792
|
+
const resolved = path.resolve(projectPath, filePath) + (filePath.endsWith(path.sep) ? path.sep : '');
|
|
793
|
+
return resolved.startsWith(tmpDir) || resolved === tmpDir.slice(0, -1);
|
|
794
|
+
};
|
|
795
|
+
const grantRoot = input.grantRoot;
|
|
796
|
+
if (typeof grantRoot === 'string' && grantRoot && !isAllowedPath(grantRoot)) {
|
|
797
|
+
return { behavior: 'deny', message: '🔒 只读模式:禁止修改项目文件。如需生成文件请写入 .evolclaw/tmp/ 目录' };
|
|
798
|
+
}
|
|
799
|
+
const fileChanges = input.fileChanges;
|
|
800
|
+
const paths = fileChanges && typeof fileChanges === 'object' ? Object.keys(fileChanges) : [];
|
|
801
|
+
if (paths.some(filePath => !isAllowedPath(filePath))) {
|
|
802
|
+
return { behavior: 'deny', message: '🔒 只读模式:禁止修改项目文件。如需生成文件请写入 .evolclaw/tmp/ 目录' };
|
|
803
|
+
}
|
|
804
|
+
return { behavior: 'allow' };
|
|
805
|
+
}
|
|
806
|
+
async resolvePermissionDecision(sessionKey, toolName, toolInput, summary, reason, projectPath = process.cwd()) {
|
|
807
|
+
const blacklist = await checkBlacklist(toolName, toolInput);
|
|
808
|
+
if (blacklist.behavior === 'deny')
|
|
809
|
+
return 'deny';
|
|
810
|
+
if (toolName === 'Bash' && this.isEvolclawCtlSendOrFile(blacklist.updatedInput)) {
|
|
811
|
+
return 'allow';
|
|
812
|
+
}
|
|
813
|
+
if (this.currentMode === 'readonly') {
|
|
814
|
+
const readonly = this.checkCodexReadonly(toolName, blacklist.updatedInput, projectPath);
|
|
815
|
+
if (readonly.behavior === 'deny')
|
|
816
|
+
return 'deny';
|
|
817
|
+
return 'allow';
|
|
818
|
+
}
|
|
819
|
+
if (this.currentMode === 'bypass' || this.currentMode === 'auto')
|
|
820
|
+
return 'allow';
|
|
821
|
+
if (this.currentMode === 'noask')
|
|
822
|
+
return 'deny';
|
|
823
|
+
if (!this.permissionGateway || !this.sendPromptFn)
|
|
824
|
+
return 'allow';
|
|
825
|
+
if (this.permissionGateway.isAlwaysAllowed(toolName))
|
|
826
|
+
return 'always';
|
|
827
|
+
return this.permissionGateway.requestPermission(sessionKey, toolName, toolInput, this.sendPromptFn, this.permissionContexts.get(sessionKey), summary, reason);
|
|
828
|
+
}
|
|
829
|
+
isEvolclawCtlSendOrFile(input) {
|
|
830
|
+
const command = typeof input.command === 'string' ? input.command.trim() : '';
|
|
831
|
+
if (!/^(?:ec|evolclaw)\s+ctl\s+(?:send|file)(?:\s|$)/.test(command))
|
|
832
|
+
return false;
|
|
833
|
+
// Keep the whitelist to a single CLI invocation. If text contains shell control
|
|
834
|
+
// syntax, fall back to the normal permission mode instead of silently approving.
|
|
835
|
+
return !/[;&|`]|[$][(]|\r|\n/.test(command);
|
|
836
|
+
}
|
|
837
|
+
toAppServerApprovalResponse(method, decision) {
|
|
838
|
+
if (method === 'execCommandApproval' || method === 'applyPatchApproval') {
|
|
839
|
+
return { decision: decision === 'deny' ? 'denied' : decision === 'always' ? 'approved_for_session' : 'approved' };
|
|
840
|
+
}
|
|
841
|
+
if (method === 'item/commandExecution/requestApproval') {
|
|
842
|
+
return { decision: decision === 'deny' ? 'decline' : decision === 'always' ? 'acceptForSession' : 'accept' };
|
|
843
|
+
}
|
|
844
|
+
if (method === 'item/fileChange/requestApproval') {
|
|
845
|
+
return { decision: decision === 'deny' ? 'decline' : decision === 'always' ? 'acceptForSession' : 'accept' };
|
|
846
|
+
}
|
|
847
|
+
if (method === 'item/permissions/requestApproval') {
|
|
848
|
+
if (decision === 'deny')
|
|
849
|
+
throw new Error('Permission request denied');
|
|
850
|
+
return { permissions: {}, scope: decision === 'always' ? 'session' : 'turn' };
|
|
851
|
+
}
|
|
852
|
+
throw new Error('Unsupported Codex app-server request: ' + method);
|
|
853
|
+
}
|
|
854
|
+
async rollbackSessionTurns(agentSessionId, _projectPath, numTurns) {
|
|
855
|
+
if (numTurns < 1)
|
|
856
|
+
return true;
|
|
857
|
+
const response = await this.getAppServerClient().threadRollback(agentSessionId, numTurns);
|
|
858
|
+
return !!response.thread;
|
|
859
|
+
}
|
|
860
|
+
async rewindFiles(agentSessionId, projectPath, userMessageId) {
|
|
861
|
+
const messages = await this.getSessionMessages(agentSessionId, projectPath);
|
|
862
|
+
const targetIndex = messages.findIndex(message => message.uuid === userMessageId);
|
|
863
|
+
if (targetIndex < 0)
|
|
864
|
+
return { canRewind: false, error: 'target turn not found' };
|
|
865
|
+
const changedFiles = new Set();
|
|
866
|
+
for (let i = targetIndex; i < messages.length; i++) {
|
|
867
|
+
const message = messages[i];
|
|
868
|
+
const content = Array.isArray(message.message?.content) ? message.message.content : [];
|
|
869
|
+
for (const part of content) {
|
|
870
|
+
if (part?.type === 'file_change' && typeof part.path === 'string')
|
|
871
|
+
changedFiles.add(part.path);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (changedFiles.size === 0) {
|
|
875
|
+
return { canRewind: false, error: 'no file changes recorded for target turn' };
|
|
876
|
+
}
|
|
877
|
+
const snapshotFiles = [...changedFiles];
|
|
878
|
+
for (const filePath of snapshotFiles) {
|
|
879
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath);
|
|
880
|
+
const content = this.readGitHeadFile(projectPath, filePath);
|
|
881
|
+
if (content === null) {
|
|
882
|
+
fs.rmSync(absolutePath, { force: true });
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
886
|
+
fs.writeFileSync(absolutePath, content);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return { canRewind: true, filesChanged: snapshotFiles };
|
|
890
|
+
}
|
|
891
|
+
readGitHeadFile(projectPath, filePath) {
|
|
892
|
+
try {
|
|
893
|
+
return execFileSync('git', ['show', `HEAD:${filePath.replace(/\\/g, '/')}`], { cwd: projectPath, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
mapThreadToSessionMessages(response, fallbackThreadId) {
|
|
900
|
+
const thread = response.thread;
|
|
901
|
+
const threadId = thread?.id || fallbackThreadId;
|
|
902
|
+
const messages = [];
|
|
903
|
+
for (const turn of thread?.turns ?? []) {
|
|
904
|
+
for (const item of this.getTurnItems(turn)) {
|
|
905
|
+
if (item.type === 'userMessage') {
|
|
906
|
+
messages.push({
|
|
907
|
+
type: 'user',
|
|
908
|
+
uuid: item.id || turn.id || (threadId + '-user-' + messages.length),
|
|
909
|
+
session_id: threadId,
|
|
910
|
+
message: { role: 'user', content: this.mapUserInputToContent(item.content) },
|
|
911
|
+
parent_tool_use_id: null,
|
|
912
|
+
});
|
|
286
913
|
}
|
|
287
|
-
else if (item.type === '
|
|
288
|
-
|
|
914
|
+
else if (item.type === 'agentMessage') {
|
|
915
|
+
messages.push({
|
|
916
|
+
type: 'assistant',
|
|
917
|
+
uuid: item.id || turn.id || (threadId + '-assistant-' + messages.length),
|
|
918
|
+
session_id: threadId,
|
|
919
|
+
message: { role: 'assistant', content: item.text || '' },
|
|
920
|
+
parent_tool_use_id: null,
|
|
921
|
+
});
|
|
289
922
|
}
|
|
290
923
|
else if (item.type === 'file_change') {
|
|
291
|
-
|
|
292
|
-
|
|
924
|
+
messages.push({
|
|
925
|
+
type: 'system',
|
|
926
|
+
uuid: item.id || turn.id || (threadId + '-file-' + messages.length),
|
|
927
|
+
session_id: threadId,
|
|
928
|
+
message: { role: 'system', content: this.mapFileChangeToContent(item) },
|
|
929
|
+
parent_tool_use_id: null,
|
|
930
|
+
});
|
|
293
931
|
}
|
|
294
|
-
|
|
295
|
-
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return messages;
|
|
935
|
+
}
|
|
936
|
+
getTurnItems(turn) {
|
|
937
|
+
const items = Array.isArray(turn?.items) ? turn.items : [];
|
|
938
|
+
const input = Array.isArray(turn?.input) ? turn.input : [];
|
|
939
|
+
const output = Array.isArray(turn?.output) ? turn.output : [];
|
|
940
|
+
return [...items, ...input, ...output];
|
|
941
|
+
}
|
|
942
|
+
mapUserInputToContent(content) {
|
|
943
|
+
if (!Array.isArray(content))
|
|
944
|
+
return [];
|
|
945
|
+
return content.map((part) => {
|
|
946
|
+
if (part?.type === 'text')
|
|
947
|
+
return { type: 'text', text: part.text || '' };
|
|
948
|
+
if (part?.type === 'localImage')
|
|
949
|
+
return { type: 'image', path: part.path };
|
|
950
|
+
if (part?.type === 'image')
|
|
951
|
+
return { type: 'image', url: part.url };
|
|
952
|
+
return { type: 'text', text: part?.text || part?.name || '' };
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
mapFileChangeToContent(item) {
|
|
956
|
+
const changes = this.normalizeFileChanges(item.changes);
|
|
957
|
+
return changes
|
|
958
|
+
.filter((change) => typeof change?.path === 'string')
|
|
959
|
+
.map((change) => {
|
|
960
|
+
const kind = this.normalizeFileChangeKind(change.kind ?? change.type);
|
|
961
|
+
return {
|
|
962
|
+
type: 'file_change',
|
|
963
|
+
path: change.path,
|
|
964
|
+
...(kind ? { kind } : {}),
|
|
965
|
+
};
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
normalizeFileChanges(changes) {
|
|
969
|
+
if (Array.isArray(changes))
|
|
970
|
+
return changes;
|
|
971
|
+
if (!changes || typeof changes !== 'object')
|
|
972
|
+
return [];
|
|
973
|
+
return Object.entries(changes).map(([filePath, change]) => ({
|
|
974
|
+
...(change && typeof change === 'object' ? change : {}),
|
|
975
|
+
path: filePath,
|
|
976
|
+
}));
|
|
977
|
+
}
|
|
978
|
+
describeFileChange(change) {
|
|
979
|
+
const kind = this.normalizeFileChangeKind(change?.kind ?? change?.type);
|
|
980
|
+
const filePath = typeof change?.path === 'string' ? change.path : '';
|
|
981
|
+
return [kind || 'change', filePath].filter(Boolean).join(' ');
|
|
982
|
+
}
|
|
983
|
+
normalizeFileChangeKind(kind) {
|
|
984
|
+
if (typeof kind === 'string')
|
|
985
|
+
return kind;
|
|
986
|
+
if (!kind || typeof kind !== 'object')
|
|
987
|
+
return undefined;
|
|
988
|
+
const data = kind;
|
|
989
|
+
for (const key of ['type', 'kind', 'action', 'operation', 'op']) {
|
|
990
|
+
if (typeof data[key] === 'string')
|
|
991
|
+
return data[key];
|
|
992
|
+
}
|
|
993
|
+
return undefined;
|
|
994
|
+
}
|
|
995
|
+
isThreadNotFoundError(error) {
|
|
996
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
997
|
+
return /thread\/compact\/start failed: thread not found:/i.test(message)
|
|
998
|
+
|| /thread not found:/i.test(message);
|
|
999
|
+
}
|
|
1000
|
+
setCompactStartCallback(callback) {
|
|
1001
|
+
this.onCompactStart = callback;
|
|
1002
|
+
}
|
|
1003
|
+
// ── Event stream transformation ──
|
|
1004
|
+
buildAppServerInput(prompt, images, tempFiles) {
|
|
1005
|
+
const input = [{ type: 'text', text: prompt, text_elements: [] }];
|
|
1006
|
+
if (!images?.length)
|
|
1007
|
+
return input;
|
|
1008
|
+
const tmpDir = os.tmpdir();
|
|
1009
|
+
for (let i = 0; i < images.length; i++) {
|
|
1010
|
+
const img = images[i];
|
|
1011
|
+
const ext = MIME_EXT[img.mimeType || ''] || '.jpg';
|
|
1012
|
+
const tmpPath = path.join(tmpDir, `evolclaw-img-${Date.now()}-${i}${ext}`);
|
|
1013
|
+
fs.writeFileSync(tmpPath, Buffer.from(img.data, 'base64'));
|
|
1014
|
+
tempFiles.push(tmpPath);
|
|
1015
|
+
input.push({ type: 'localImage', path: tmpPath });
|
|
1016
|
+
}
|
|
1017
|
+
logger.info(`[CodexRunner] Attached ${images.length} image(s) as localImage`);
|
|
1018
|
+
return input;
|
|
1019
|
+
}
|
|
1020
|
+
cleanupTempFiles(tempFiles) {
|
|
1021
|
+
if (!tempFiles?.length)
|
|
1022
|
+
return;
|
|
1023
|
+
for (const tempFile of tempFiles) {
|
|
1024
|
+
try {
|
|
1025
|
+
fs.unlinkSync(tempFile);
|
|
1026
|
+
}
|
|
1027
|
+
catch { /* ignore */ }
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
extractTurnId(notification) {
|
|
1031
|
+
const params = notification.params || {};
|
|
1032
|
+
return typeof params.turnId === 'string' ? params.turnId :
|
|
1033
|
+
typeof params.turn_id === 'string' ? params.turn_id :
|
|
1034
|
+
typeof params.turn?.id === 'string' ? params.turn.id : undefined;
|
|
1035
|
+
}
|
|
1036
|
+
isAppServerTurnNotification(notification, state) {
|
|
1037
|
+
const params = notification.params || {};
|
|
1038
|
+
const notifThreadId = params.threadId ?? params.thread_id;
|
|
1039
|
+
if (notifThreadId !== undefined && notifThreadId !== state.threadId)
|
|
1040
|
+
return false;
|
|
1041
|
+
const turnId = this.extractTurnId(notification);
|
|
1042
|
+
if (!state.turnId) {
|
|
1043
|
+
return notification.method === 'turn/started' || !turnId;
|
|
1044
|
+
}
|
|
1045
|
+
return !state.turnId || !turnId || turnId === state.turnId;
|
|
1046
|
+
}
|
|
1047
|
+
async *transformAppServerStream(notifications, sessionId, state, unsubscribe, tempFiles) {
|
|
1048
|
+
try {
|
|
1049
|
+
yield { type: 'session_id', sessionId: state.threadId };
|
|
1050
|
+
for await (const notification of notifications) {
|
|
1051
|
+
if (!this.activeAbortControllers.has(sessionId))
|
|
1052
|
+
break;
|
|
1053
|
+
yield* this.mapAppServerNotification(notification, sessionId, state);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
finally {
|
|
1057
|
+
unsubscribe();
|
|
1058
|
+
this.activeAbortControllers.delete(sessionId);
|
|
1059
|
+
this.activeTurns.delete(sessionId);
|
|
1060
|
+
this.cleanupTempFiles(tempFiles);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
*mapAppServerNotification(notification, sessionId, state) {
|
|
1064
|
+
const params = notification.params || {};
|
|
1065
|
+
switch (notification.method) {
|
|
1066
|
+
case 'turn/started': {
|
|
1067
|
+
const turnId = this.extractTurnId(notification);
|
|
1068
|
+
if (turnId) {
|
|
1069
|
+
state.turnId = turnId;
|
|
1070
|
+
this.activeTurns.set(sessionId, { threadId: state.threadId, turnId });
|
|
296
1071
|
}
|
|
1072
|
+
yield { type: 'state_changed', state: 'running' };
|
|
297
1073
|
break;
|
|
298
1074
|
}
|
|
299
|
-
case 'item
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
isError: item.exit_code !== 0,
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
else if (item.type === 'mcp_tool_call') {
|
|
313
|
-
yield {
|
|
314
|
-
type: 'tool_result',
|
|
315
|
-
name: `MCP:${item.server}/${item.tool}`,
|
|
316
|
-
result: item.result,
|
|
317
|
-
isError: item.status === 'failed',
|
|
318
|
-
error: item.error?.message,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
else if (item.type === 'error') {
|
|
322
|
-
yield { type: 'error', error: item.message, errorType: 'unknown' };
|
|
1075
|
+
case 'item/started': {
|
|
1076
|
+
yield* this.mapAppServerItemStarted(params.item);
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
case 'item/agentMessage/delta': {
|
|
1080
|
+
const itemId = typeof params.itemId === 'string' ? params.itemId : undefined;
|
|
1081
|
+
if (itemId)
|
|
1082
|
+
state.streamedAgentMessageIds.add(itemId);
|
|
1083
|
+
if (itemId && typeof params.delta === 'string' && params.delta) {
|
|
1084
|
+
state.agentMessageDeltaText.set(itemId, (state.agentMessageDeltaText.get(itemId) || '') + params.delta);
|
|
323
1085
|
}
|
|
324
1086
|
break;
|
|
325
1087
|
}
|
|
326
|
-
case '
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1088
|
+
case 'item/completed': {
|
|
1089
|
+
const item = params.item;
|
|
1090
|
+
if (item?.id)
|
|
1091
|
+
state.completedItemIds.add(item.id);
|
|
1092
|
+
yield* this.mapAppServerItemCompleted(item, state);
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
case 'turn/plan/updated': {
|
|
1096
|
+
const plan = Array.isArray(params.plan) ? params.plan : [];
|
|
1097
|
+
const completed = plan.filter((step) => step?.status === 'completed').length;
|
|
1098
|
+
const summary = plan.length ? `计划进度:${completed}/${plan.length}` : (params.explanation || '计划已更新');
|
|
1099
|
+
yield { type: 'task_progress', summary };
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
case 'thread/tokenUsage/updated': {
|
|
1103
|
+
state.tokenUsage = params.tokenUsage;
|
|
1104
|
+
break;
|
|
1105
|
+
}
|
|
1106
|
+
case 'thread/compacted': {
|
|
1107
|
+
logger.info(`[CodexRunner] Compact completed for thread: ${params.threadId || state.threadId}`);
|
|
1108
|
+
yield { type: 'compact', preTokens: 0 };
|
|
333
1109
|
break;
|
|
334
1110
|
}
|
|
335
|
-
case 'turn
|
|
336
|
-
|
|
1111
|
+
case 'turn/completed': {
|
|
1112
|
+
const turn = params.turn || {};
|
|
1113
|
+
const turnId = turn.id || params.turnId;
|
|
1114
|
+
if (turnId && state.completedTurnIds.has(turnId))
|
|
1115
|
+
break;
|
|
1116
|
+
if (turnId)
|
|
1117
|
+
state.completedTurnIds.add(turnId);
|
|
1118
|
+
this.activeTurns.delete(sessionId);
|
|
1119
|
+
if (turn.status === 'failed' && turn.error?.message) {
|
|
1120
|
+
yield { type: 'error', error: turn.error.message, errorType: 'unknown' };
|
|
1121
|
+
}
|
|
1122
|
+
yield this.mapAppServerTurnComplete(turn, state);
|
|
337
1123
|
break;
|
|
338
1124
|
}
|
|
339
1125
|
case 'error': {
|
|
340
|
-
yield { type: 'error', error:
|
|
1126
|
+
yield { type: 'error', error: params.message || 'Codex app-server error', errorType: 'unknown' };
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
*mapAppServerItemStarted(item) {
|
|
1132
|
+
if (!item)
|
|
1133
|
+
return;
|
|
1134
|
+
switch (item.type) {
|
|
1135
|
+
case 'commandExecution':
|
|
1136
|
+
yield { type: 'tool_use', name: 'Shell', input: { command: item.command, cwd: item.cwd }, callId: item.id };
|
|
1137
|
+
break;
|
|
1138
|
+
case 'mcpToolCall':
|
|
1139
|
+
yield { type: 'tool_use', name: `MCP:${item.server}/${item.tool}`, input: item.arguments, callId: item.id };
|
|
1140
|
+
break;
|
|
1141
|
+
case 'dynamicToolCall':
|
|
1142
|
+
yield { type: 'tool_use', name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool, input: item.arguments, callId: item.id };
|
|
1143
|
+
break;
|
|
1144
|
+
case 'fileChange': {
|
|
1145
|
+
const desc = this.normalizeFileChanges(item.changes).map((change) => this.describeFileChange(change)).join(', ');
|
|
1146
|
+
yield { type: 'tool_use', name: 'FileChange', input: { description: desc }, callId: item.id };
|
|
341
1147
|
break;
|
|
342
1148
|
}
|
|
1149
|
+
case 'webSearch':
|
|
1150
|
+
yield { type: 'tool_use', name: 'WebSearch', input: { query: item.query }, callId: item.id };
|
|
1151
|
+
break;
|
|
1152
|
+
case 'plan':
|
|
1153
|
+
yield { type: 'task_progress', summary: item.text || '计划已更新' };
|
|
1154
|
+
break;
|
|
343
1155
|
}
|
|
344
1156
|
}
|
|
1157
|
+
*mapAppServerItemCompleted(item, state) {
|
|
1158
|
+
if (!item)
|
|
1159
|
+
return;
|
|
1160
|
+
switch (item.type) {
|
|
1161
|
+
case 'agentMessage':
|
|
1162
|
+
{
|
|
1163
|
+
const buffered = item.id ? state.agentMessageDeltaText.get(item.id) : undefined;
|
|
1164
|
+
const text = typeof item.text === 'string' && item.text ? item.text : buffered;
|
|
1165
|
+
if (text)
|
|
1166
|
+
yield { type: 'text', text };
|
|
1167
|
+
if (item.id)
|
|
1168
|
+
state.agentMessageDeltaText.delete(item.id);
|
|
1169
|
+
}
|
|
1170
|
+
break;
|
|
1171
|
+
case 'commandExecution':
|
|
1172
|
+
yield {
|
|
1173
|
+
type: 'tool_result',
|
|
1174
|
+
name: 'Shell',
|
|
1175
|
+
result: item.aggregatedOutput ?? '',
|
|
1176
|
+
isError: item.exitCode !== null && item.exitCode !== undefined ? item.exitCode !== 0 : item.status === 'failed',
|
|
1177
|
+
callId: item.id,
|
|
1178
|
+
};
|
|
1179
|
+
break;
|
|
1180
|
+
case 'mcpToolCall':
|
|
1181
|
+
yield {
|
|
1182
|
+
type: 'tool_result',
|
|
1183
|
+
name: `MCP:${item.server}/${item.tool}`,
|
|
1184
|
+
result: item.result,
|
|
1185
|
+
isError: item.status === 'failed',
|
|
1186
|
+
error: item.error?.message,
|
|
1187
|
+
callId: item.id,
|
|
1188
|
+
};
|
|
1189
|
+
break;
|
|
1190
|
+
case 'dynamicToolCall':
|
|
1191
|
+
yield {
|
|
1192
|
+
type: 'tool_result',
|
|
1193
|
+
name: item.namespace ? `${item.namespace}:${item.tool}` : item.tool,
|
|
1194
|
+
result: item.contentItems,
|
|
1195
|
+
isError: item.success === false || item.status === 'failed',
|
|
1196
|
+
callId: item.id,
|
|
1197
|
+
};
|
|
1198
|
+
break;
|
|
1199
|
+
case 'fileChange':
|
|
1200
|
+
yield { type: 'tool_result', name: 'FileChange', result: item.changes, isError: item.status === 'failed', callId: item.id };
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
pickNumber(...values) {
|
|
1205
|
+
for (const value of values) {
|
|
1206
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
1207
|
+
return value;
|
|
1208
|
+
}
|
|
1209
|
+
return undefined;
|
|
1210
|
+
}
|
|
1211
|
+
mapCodexTokenUsage(raw) {
|
|
1212
|
+
if (!raw || typeof raw !== 'object')
|
|
1213
|
+
return undefined;
|
|
1214
|
+
const usage = {
|
|
1215
|
+
input_tokens: this.pickNumber(raw.inputTokens, raw.input_tokens),
|
|
1216
|
+
output_tokens: this.pickNumber(raw.outputTokens, raw.output_tokens),
|
|
1217
|
+
cache_read_input_tokens: this.pickNumber(raw.cachedInputTokens, raw.cache_read_input_tokens, raw.cached_input_tokens),
|
|
1218
|
+
cache_creation_input_tokens: this.pickNumber(raw.cacheCreationInputTokens, raw.cache_creation_input_tokens, raw.cache_creation_tokens),
|
|
1219
|
+
};
|
|
1220
|
+
return Object.values(usage).some(value => value !== undefined) ? usage : undefined;
|
|
1221
|
+
}
|
|
1222
|
+
mapCodexContextUsage(raw) {
|
|
1223
|
+
if (!raw || typeof raw !== 'object')
|
|
1224
|
+
return undefined;
|
|
1225
|
+
const totalTokens = this.pickNumber(raw.totalTokens, raw.total_tokens, raw.total);
|
|
1226
|
+
const maxTokens = this.pickNumber(raw.maxTokens, raw.max_tokens, raw.max);
|
|
1227
|
+
const model = typeof raw.model === 'string' ? raw.model : undefined;
|
|
1228
|
+
if (totalTokens === undefined || maxTokens === undefined || !model)
|
|
1229
|
+
return undefined;
|
|
1230
|
+
const percentage = this.pickNumber(raw.percentage) ?? Math.round((totalTokens / maxTokens) * 100);
|
|
1231
|
+
const effort = typeof raw.effort === 'string' ? raw.effort : undefined;
|
|
1232
|
+
return { totalTokens, maxTokens, percentage, model, effort };
|
|
1233
|
+
}
|
|
1234
|
+
mapAppServerTurnComplete(turn, state) {
|
|
1235
|
+
const status = turn.status || 'completed';
|
|
1236
|
+
const tokenUsage = this.mapCodexTokenUsage(state.tokenUsage?.last ?? turn.tokenUsage ?? turn.usage);
|
|
1237
|
+
const contextUsage = this.mapCodexContextUsage(turn.contextUsage ?? state.tokenUsage?.contextUsage ?? state.tokenUsage?.context);
|
|
1238
|
+
const terminalReason = status === 'completed'
|
|
1239
|
+
? undefined
|
|
1240
|
+
: status === 'interrupted'
|
|
1241
|
+
? 'aborted_streaming'
|
|
1242
|
+
: status;
|
|
1243
|
+
return {
|
|
1244
|
+
type: 'complete',
|
|
1245
|
+
subtype: status === 'completed' ? 'success' : status,
|
|
1246
|
+
isError: status === 'failed',
|
|
1247
|
+
errors: turn.error?.message ? [turn.error.message] : undefined,
|
|
1248
|
+
terminalReason,
|
|
1249
|
+
durationMs: typeof turn.durationMs === 'number' ? turn.durationMs : undefined,
|
|
1250
|
+
ttftMs: this.pickNumber(turn.ttftMs, turn.ttft_ms),
|
|
1251
|
+
costUsd: this.pickNumber(turn.costUsd, turn.totalCostUsd, turn.total_cost_usd),
|
|
1252
|
+
sessionTitle: typeof turn.sessionTitle === 'string' ? turn.sessionTitle : typeof turn.session_title === 'string' ? turn.session_title : undefined,
|
|
1253
|
+
numTurns: this.pickNumber(turn.numTurns, turn.num_turns),
|
|
1254
|
+
tokenUsage,
|
|
1255
|
+
contextUsage,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
buildEvolclawShellEnvironmentConfig(sessionId) {
|
|
1259
|
+
return {
|
|
1260
|
+
shell_environment_policy: {
|
|
1261
|
+
set: {
|
|
1262
|
+
EVOLCLAW_SESSION_ID: sessionId,
|
|
1263
|
+
EVOLCLAW_HOME: resolveRoot(),
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
345
1268
|
async dispose() {
|
|
346
1269
|
// Abort all active streams
|
|
347
1270
|
for (const [key, controller] of this.activeAbortControllers) {
|
|
@@ -350,6 +1273,10 @@ export class CodexRunner {
|
|
|
350
1273
|
this.activeAbortControllers.clear();
|
|
351
1274
|
this.activeStreams.clear();
|
|
352
1275
|
this.activeSessions.clear();
|
|
1276
|
+
this.activeTurns.clear();
|
|
1277
|
+
this.permissionContexts.clear();
|
|
1278
|
+
await this.appServerClient?.close();
|
|
1279
|
+
this.appServerClient = null;
|
|
353
1280
|
}
|
|
354
1281
|
}
|
|
355
1282
|
// ── Plugin ──
|
|
@@ -358,7 +1285,7 @@ export class CodexAgentPlugin {
|
|
|
358
1285
|
isEnabled(agent) {
|
|
359
1286
|
if (!agent.config.baseagents?.codex)
|
|
360
1287
|
return false;
|
|
361
|
-
if (!
|
|
1288
|
+
if (!isCodexAppServerAvailable())
|
|
362
1289
|
return false;
|
|
363
1290
|
try {
|
|
364
1291
|
const override = agent.config.baseagents.codex;
|
|
@@ -371,8 +1298,9 @@ export class CodexAgentPlugin {
|
|
|
371
1298
|
}
|
|
372
1299
|
}
|
|
373
1300
|
createAgent(agent, callbacks) {
|
|
374
|
-
|
|
375
|
-
|
|
1301
|
+
const availability = getCodexAppServerAvailability();
|
|
1302
|
+
if (!availability.available) {
|
|
1303
|
+
throw new Error(availability.reason || 'Missing codex CLI with app-server');
|
|
376
1304
|
}
|
|
377
1305
|
const override = agent.config.baseagents?.codex;
|
|
378
1306
|
const merged = {
|