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.
Files changed (83) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -2
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -27
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1069 -141
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +28 -0
  9. package/dist/aun/aid/store.js +1 -1
  10. package/dist/aun/storage/download.js +1 -1
  11. package/dist/aun/storage/upload.js +13 -1
  12. package/dist/channels/aun.js +406 -293
  13. package/dist/channels/dingtalk.js +77 -140
  14. package/dist/channels/feishu.js +97 -150
  15. package/dist/channels/qqbot.js +75 -138
  16. package/dist/channels/wechat.js +75 -136
  17. package/dist/channels/wecom.js +75 -138
  18. package/dist/cli/agent.js +8 -5
  19. package/dist/cli/index.js +177 -44
  20. package/dist/cli/init.js +33 -6
  21. package/dist/cli/model.js +1 -1
  22. package/dist/cli/stats.js +558 -0
  23. package/dist/cli/version.js +87 -0
  24. package/dist/cli/watch-msg.js +5 -2
  25. package/dist/config-store.js +12 -6
  26. package/dist/core/channel-loader.js +84 -82
  27. package/dist/core/command-handler.js +473 -114
  28. package/dist/core/evolagent-registry.js +1 -0
  29. package/dist/core/evolagent.js +1 -1
  30. package/dist/core/interaction-router.js +8 -0
  31. package/dist/core/message/command-handler-agent-control.js +63 -1
  32. package/dist/core/message/im-renderer.js +35 -13
  33. package/dist/core/message/items-formatter.js +9 -1
  34. package/dist/core/message/message-bridge.js +49 -21
  35. package/dist/core/message/message-log.js +1 -0
  36. package/dist/core/message/message-processor.js +295 -35
  37. package/dist/core/message/message-queue.js +2 -2
  38. package/dist/core/message/pending-hints.js +232 -0
  39. package/dist/core/message/response-depth.js +56 -0
  40. package/dist/core/model/model-catalog.js +1 -1
  41. package/dist/core/model/model-scope.js +2 -2
  42. package/dist/core/permission.js +9 -12
  43. package/dist/core/relation/peer-identity.js +16 -1
  44. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  45. package/dist/core/session/session-manager.js +27 -13
  46. package/dist/core/session/session-title.js +26 -0
  47. package/dist/core/stats/billing.js +151 -0
  48. package/dist/core/stats/budget.js +93 -0
  49. package/dist/core/stats/db.js +314 -0
  50. package/dist/core/stats/eck-vars.js +84 -0
  51. package/dist/core/stats/index.js +10 -0
  52. package/dist/core/stats/normalizer.js +78 -0
  53. package/dist/core/stats/query.js +760 -0
  54. package/dist/core/stats/writer.js +115 -0
  55. package/dist/core/trigger/manager.js +34 -0
  56. package/dist/core/trigger/parser.js +9 -3
  57. package/dist/core/trigger/scheduler.js +20 -17
  58. package/dist/{agents → eck}/manifest-engine.js +20 -1
  59. package/dist/{agents → eck}/message-renderer.js +24 -1
  60. package/dist/index.js +130 -8
  61. package/dist/ipc.js +17 -1
  62. package/dist/utils/cross-platform.js +23 -5
  63. package/dist/utils/ecweb-pair.js +20 -0
  64. package/dist/utils/stats.js +14 -0
  65. package/kits/docs/evolclaw/INDEX.md +3 -1
  66. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  67. package/kits/docs/evolclaw/fs.md +131 -0
  68. package/kits/docs/evolclaw/group-fs.md +209 -0
  69. package/kits/docs/evolclaw/stats.md +70 -0
  70. package/kits/docs/venues/aun-group.md +29 -6
  71. package/kits/docs/venues/group.md +5 -4
  72. package/kits/eck_manifest.json +12 -0
  73. package/kits/eck_message_manifest.json +30 -3
  74. package/kits/rules/05-venue.md +1 -1
  75. package/kits/templates/message-fragments/inject-default.md +2 -0
  76. package/kits/templates/system-fragments/response-depth.md +16 -0
  77. package/package.json +4 -4
  78. package/dist/agents/baseagent-normalize.js +0 -19
  79. package/dist/core/relation/peer-key.js +0 -16
  80. package/dist/evolclaw-config.js +0 -11
  81. package/dist/utils/channel-helpers.js +0 -46
  82. /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
  83. /package/dist/{agents → eck}/kit-renderer.js +0 -0
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * Codex Agent Runner
3
3
  *
4
- * Integrates OpenAI Codex SDK (@openai/codex-sdk) as an agent backend.
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 { resolveOpenaiConfig } from './resolve.js';
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 function isCodexSdkAvailable() {
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
- import.meta.resolve('@openai/codex-sdk');
32
- return true;
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 false;
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 = { clear: false, compact: false, fork: false };
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
- async ensureCodex(sessionId) {
89
- if (!this.codexModule) {
90
- const { requireOptional } = await import('../utils/npm-ops.js');
91
- this.codexModule = await requireOptional('@openai/codex-sdk');
92
- }
93
- const codex = new this.codexModule.Codex({
94
- apiKey: this.resolvedConfig.apiKey,
95
- baseUrl: this.resolvedConfig.baseUrl,
96
- env: {
97
- ...process.env,
98
- EVOLCLAW_SESSION_ID: sessionId,
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() { return fetchCodexCatalog().map(m => m.slug); }
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(_fn) { }
133
- setPermissionGateway(_gw) { }
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
- let fullPrompt = prompt;
150
- // Only inject system context on the first turn; resumed Codex threads already
151
- // have that context in history and repeating it will pollute the conversation.
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
- workingDirectory: projectPath,
157
- model: this.model,
158
- skipGitRepoCheck: true,
159
- sandboxMode: 'danger-full-access',
282
+ model: callModel,
283
+ effort: callEffort,
160
284
  approvalPolicy: this.approvalPolicy,
161
- ...(this.effort ? { modelReasoningEffort: this.effort } : {}),
285
+ approvalsReviewer: this.resolvedConfig.approvalsReviewer,
286
+ sandbox: this.sandboxMode,
287
+ config: this.buildEvolclawShellEnvironmentConfig(sessionId),
288
+ ...(systemPromptAppend ? { developerInstructions: systemPromptAppend } : {}),
162
289
  };
163
- const thread = agentSessionId
164
- ? codex.resumeThread(agentSessionId, threadOptions)
165
- : codex.startThread(threadOptions);
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
- let input;
171
- if (images?.length) {
172
- const tmpDir = os.tmpdir();
173
- const parts = [{ type: 'text', text: fullPrompt }];
174
- for (let i = 0; i < images.length; i++) {
175
- const img = images[i];
176
- const ext = MIME_EXT[img.mimeType || ''] || '.jpg';
177
- const tmpPath = path.join(tmpDir, `evolclaw-img-${Date.now()}-${i}${ext}`);
178
- fs.writeFileSync(tmpPath, Buffer.from(img.data, 'base64'));
179
- tempFiles.push(tmpPath);
180
- parts.push({ type: 'local_image', path: tmpPath });
181
- }
182
- input = parts;
183
- logger.info(`[CodexRunner] Attached ${images.length} image(s) as local_image`);
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
- else {
186
- input = fullPrompt;
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
- const { events } = await thread.runStreamed(input, { signal: controller.signal });
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
- if (controller) {
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(_sessionId, _agentSessionId, _projectPath) {
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, _agentSessionId, _projectPath) {
243
- // Codex CLI 内部处理 compaction,外部无法触发
244
- logger.info('[CodexRunner] Compact not supported, Codex handles context internally');
245
- return false;
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(_sessionId, _agentSessionId, _projectPath) {
248
- return this.compactSession(_sessionId, _agentSessionId, _projectPath);
452
+ async compact(sessionId, agentSessionId, projectPath) {
453
+ return this.compactSession(sessionId, agentSessionId, projectPath);
249
454
  }
250
- setCompactStartCallback(_callback) { }
251
- // ── Event stream transformation ──
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
- for await (const event of events) {
255
- if (!this.activeAbortControllers.has(sessionId))
256
- break;
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
- this.activeAbortControllers.delete(sessionId);
262
- // 清理临时图片文件
263
- if (tempFiles?.length) {
264
- for (const f of tempFiles) {
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.unlinkSync(f);
563
+ mtimeMs = fs.statSync(fullPath).mtimeMs;
267
564
  }
268
- catch { /* ignore */ }
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
- *mapEvent(event, sessionId, thread) {
274
- switch (event.type) {
275
- case 'thread.started': {
276
- const threadId = event.thread_id;
277
- this.activeSessions.set(sessionId, threadId);
278
- this.onSessionIdUpdate?.(sessionId, threadId);
279
- yield { type: 'session_id', sessionId: threadId };
280
- break;
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
- case 'item.started': {
283
- const item = event.item;
284
- if (item.type === 'command_execution') {
285
- yield { type: 'tool_use', name: 'Shell', input: { command: item.command } };
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 === 'mcp_tool_call') {
288
- yield { type: 'tool_use', name: `MCP:${item.server}/${item.tool}`, input: item.arguments };
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
- const desc = item.changes.map((c) => `${c.kind} ${c.path}`).join(', ');
292
- yield { type: 'tool_use', name: 'FileChange', input: { description: desc } };
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
- else if (item.type === 'web_search') {
295
- yield { type: 'tool_use', name: 'WebSearch', input: { query: item.query } };
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.completed': {
300
- const item = event.item;
301
- if (item.type === 'agent_message') {
302
- yield { type: 'text', text: item.text };
303
- }
304
- else if (item.type === 'command_execution') {
305
- yield {
306
- type: 'tool_result',
307
- name: 'Shell',
308
- result: item.aggregated_output,
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 'turn.completed': {
327
- yield {
328
- type: 'complete',
329
- result: undefined,
330
- costUsd: undefined,
331
- durationMs: undefined,
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.failed': {
336
- yield { type: 'error', error: event.error.message, errorType: 'unknown' };
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: event.message, errorType: 'unknown' };
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 (!isCodexSdkAvailable())
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
- if (!isCodexSdkAvailable()) {
375
- throw new Error('Missing optional dependency @openai/codex-sdk');
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 = {