chatccc 0.2.287 → 0.2.288

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.
@@ -0,0 +1,280 @@
1
+ // =============================================================================
2
+ // codex-app-server.ts — Codex CLI app-server 进程管理 + JSON-RPC 客户端
3
+ // =============================================================================
4
+ // Codex CLI 的 app-server 模式:一个常驻进程托管多个 thread(会话),每个
5
+ // thread 内可有一个活跃 turn。相比 `codex exec` 一次性子进程,app-server 支持:
6
+ // - turn/steer : 运行中向当前 turn 注入新消息(协作式让位,对齐 ccc)
7
+ // - turn/interrupt : 打断当前 turn(不影响同进程内其他 thread)
8
+ // - thread/resume : 按 thread_id 从磁盘恢复持久化线程
9
+ //
10
+ // 本文件提供:
11
+ // - JsonRpcClient : WebSocket JSON-RPC 客户端
12
+ // - CodexAppServerManager : 单例常驻进程生命周期(懒启动 + 跨平台收尸)
13
+ //
14
+ // 协议字段名以 `codex app-server generate-ts` 导出的官方绑定为准:
15
+ // - thread/start 用 `sandbox: "danger-full-access"`(字符串枚举)
16
+ // - turn/start 用 `sandboxPolicy: { type: "dangerFullAccess" }`(对象)
17
+ // =============================================================================
18
+ import { spawn, spawnSync } from "node:child_process";
19
+ import { createServer } from "node:net";
20
+ import WebSocket from "ws";
21
+ import { killProcessTree } from "./proc-tree-kill.js";
22
+ // ---------------------------------------------------------------------------
23
+ // AsyncQueue — 把 WebSocket 通知流转换成 async iterable
24
+ // ---------------------------------------------------------------------------
25
+ export class AsyncQueue {
26
+ items = [];
27
+ waiters = [];
28
+ ended = false;
29
+ push(item) {
30
+ if (this.ended)
31
+ return;
32
+ const waiter = this.waiters.shift();
33
+ if (waiter)
34
+ waiter(item);
35
+ else
36
+ this.items.push(item);
37
+ }
38
+ end() {
39
+ if (this.ended)
40
+ return;
41
+ this.ended = true;
42
+ for (const waiter of this.waiters.splice(0))
43
+ waiter(null);
44
+ }
45
+ [Symbol.asyncIterator]() {
46
+ const queue = this;
47
+ return {
48
+ async next() {
49
+ if (queue.items.length > 0) {
50
+ return { value: queue.items.shift(), done: false };
51
+ }
52
+ if (queue.ended) {
53
+ return { value: undefined, done: true };
54
+ }
55
+ const value = await new Promise((resolve) => {
56
+ queue.waiters.push(resolve);
57
+ });
58
+ if (value === null) {
59
+ return { value: undefined, done: true };
60
+ }
61
+ return { value, done: false };
62
+ },
63
+ };
64
+ }
65
+ }
66
+ export class JsonRpcClient {
67
+ ws;
68
+ nextId = 1;
69
+ pending = new Map();
70
+ onNotification = null;
71
+ onServerRequest = null;
72
+ onClose = null;
73
+ constructor(ws) {
74
+ this.ws = ws;
75
+ ws.on("message", (data) => this.handleMessage(data.toString()));
76
+ ws.on("close", () => this.handleClose());
77
+ ws.on("error", () => {
78
+ /* 关闭路径统一走 close 事件 */
79
+ });
80
+ }
81
+ static async connect(url) {
82
+ const ws = new WebSocket(url);
83
+ await new Promise((resolve, reject) => {
84
+ ws.once("open", () => resolve());
85
+ ws.once("error", (err) => reject(new Error(`codex app-server 连接失败: ${err.message}`)));
86
+ });
87
+ return new JsonRpcClient(ws);
88
+ }
89
+ handleMessage(raw) {
90
+ let msg;
91
+ try {
92
+ msg = JSON.parse(raw);
93
+ }
94
+ catch {
95
+ return;
96
+ }
97
+ // 响应(有 id、无 method)→ resolve 对应 pending 请求
98
+ if (msg.id !== undefined && msg.method === undefined) {
99
+ const pendingReq = this.pending.get(msg.id);
100
+ if (pendingReq) {
101
+ this.pending.delete(msg.id);
102
+ clearTimeout(pendingReq.timer);
103
+ if (msg.error !== undefined && msg.error !== null) {
104
+ pendingReq.reject(new Error(JSON.stringify(msg.error)));
105
+ }
106
+ else {
107
+ pendingReq.resolve(msg.result);
108
+ }
109
+ }
110
+ return;
111
+ }
112
+ // 服务端请求(有 method + id)→ 需要客户端 respond
113
+ if (msg.method !== undefined && msg.id !== undefined) {
114
+ this.onServerRequest?.(msg.method, msg.params ?? {}, msg.id);
115
+ return;
116
+ }
117
+ // 服务端通知(有 method、无 id)
118
+ if (msg.method !== undefined) {
119
+ this.onNotification?.(msg.method, msg.params ?? {});
120
+ }
121
+ }
122
+ handleClose() {
123
+ const err = new Error("codex app-server 连接已关闭");
124
+ for (const [id, p] of this.pending) {
125
+ this.pending.delete(id);
126
+ clearTimeout(p.timer);
127
+ p.reject(err);
128
+ }
129
+ this.onClose?.();
130
+ }
131
+ request(method, params, timeoutMs = 120000) {
132
+ const id = this.nextId++;
133
+ this.ws.send(JSON.stringify({ method, id, params }));
134
+ return new Promise((resolve, reject) => {
135
+ const timer = setTimeout(() => {
136
+ this.pending.delete(id);
137
+ reject(new Error(`codex app-server 请求超时: ${method}`));
138
+ }, timeoutMs);
139
+ this.pending.set(id, { resolve, reject, timer });
140
+ });
141
+ }
142
+ respond(id, result) {
143
+ this.ws.send(JSON.stringify({ id, result }));
144
+ }
145
+ respondError(id, code, message) {
146
+ this.ws.send(JSON.stringify({ id, error: { code, message } }));
147
+ }
148
+ close() {
149
+ try {
150
+ this.ws.close();
151
+ }
152
+ catch {
153
+ /* ignore */
154
+ }
155
+ }
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // 端口选择 / 就绪等待(跨平台)
159
+ // ---------------------------------------------------------------------------
160
+ async function pickFreePort() {
161
+ const server = createServer();
162
+ await new Promise((resolve, reject) => {
163
+ server.once("error", reject);
164
+ server.listen(0, "127.0.0.1", () => resolve());
165
+ });
166
+ const address = server.address();
167
+ const port = typeof address === "object" && address !== null ? address.port : 0;
168
+ await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
169
+ return port;
170
+ }
171
+ async function waitForReady(port, timeoutMs = 20000) {
172
+ const deadline = Date.now() + timeoutMs;
173
+ while (Date.now() < deadline) {
174
+ try {
175
+ const r = await fetch(`http://127.0.0.1:${port}/readyz`);
176
+ if (r.ok)
177
+ return;
178
+ }
179
+ catch {
180
+ /* not ready yet */
181
+ }
182
+ await new Promise((r) => setTimeout(r, 150));
183
+ }
184
+ throw new Error("codex app-server 启动超时(readyz 未就绪)");
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // CodexAppServerManager — 单例常驻进程生命周期
188
+ // ---------------------------------------------------------------------------
189
+ export class CodexAppServerManager {
190
+ static instance = null;
191
+ proc = null;
192
+ port = null;
193
+ starting = null;
194
+ cleanupRegistered = false;
195
+ static get() {
196
+ if (!CodexAppServerManager.instance) {
197
+ CodexAppServerManager.instance = new CodexAppServerManager();
198
+ }
199
+ return CodexAppServerManager.instance;
200
+ }
201
+ ensureStarted(command) {
202
+ if (this.port !== null)
203
+ return Promise.resolve(this.port);
204
+ if (this.starting)
205
+ return this.starting;
206
+ this.starting = this.start(command);
207
+ return this.starting;
208
+ }
209
+ async start(command) {
210
+ const port = await pickFreePort();
211
+ const proc = spawn(command, ["app-server", "--listen", `ws://127.0.0.1:${port}`], {
212
+ stdio: ["ignore", "ignore", "pipe"],
213
+ windowsHide: true,
214
+ // Windows 下 npm 全局安装的 codex 是 .cmd shim,需经 shell 解析;
215
+ // Unix 下直接 exec 二进制,避免 shell:true 的转义风险与 deprecation 警告。
216
+ shell: process.platform === "win32",
217
+ });
218
+ let stderr = "";
219
+ proc.stderr?.on("data", (chunk) => {
220
+ stderr += chunk.toString();
221
+ });
222
+ proc.once("error", (err) => {
223
+ console.error(`[Codex app-server] spawn 失败: ${err.message}`);
224
+ });
225
+ proc.on("close", (code) => {
226
+ if (this.proc === proc) {
227
+ this.proc = null;
228
+ this.port = null;
229
+ }
230
+ if (code !== 0 && stderr.trim()) {
231
+ console.error(`[Codex app-server] 退出码=${code}: ${stderr.trim().slice(0, 2000)}`);
232
+ }
233
+ });
234
+ try {
235
+ await waitForReady(port);
236
+ }
237
+ catch (err) {
238
+ void killProcessTree(proc.pid);
239
+ throw err;
240
+ }
241
+ this.proc = proc;
242
+ this.port = port;
243
+ this.registerCleanup();
244
+ return port;
245
+ }
246
+ /** 主进程退出时尽力收尸(Windows taskkill / Unix SIGTERM),避免孤儿常驻进程。 */
247
+ registerCleanup() {
248
+ if (this.cleanupRegistered)
249
+ return;
250
+ this.cleanupRegistered = true;
251
+ const cleanup = () => {
252
+ const pid = this.proc?.pid;
253
+ if (pid === undefined)
254
+ return;
255
+ try {
256
+ if (process.platform === "win32") {
257
+ spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
258
+ }
259
+ else {
260
+ this.proc?.kill("SIGTERM");
261
+ }
262
+ }
263
+ catch {
264
+ /* best effort */
265
+ }
266
+ };
267
+ process.once("exit", cleanup);
268
+ process.once("SIGINT", cleanup);
269
+ process.once("SIGTERM", cleanup);
270
+ }
271
+ async shutdown() {
272
+ this.starting = null;
273
+ const proc = this.proc;
274
+ this.proc = null;
275
+ this.port = null;
276
+ if (proc) {
277
+ await killProcessTree(proc.pid);
278
+ }
279
+ }
280
+ }
@@ -147,3 +147,39 @@ export function readToolCliPath(raw, options) {
147
147
  }
148
148
  return "";
149
149
  }
150
+ // ---------------------------------------------------------------------------
151
+ // Codex CLI 版本检查(app-server 模式要求的最低版本)
152
+ // ---------------------------------------------------------------------------
153
+ /**
154
+ * app-server 模式(thread/start + turn 注入)官方推荐的最低 Codex CLI 版本。
155
+ * 版本不足时仅警告、不阻断启动——当前默认仍是 `codex exec` 模式,exec 不依赖
156
+ * 该最低版本。
157
+ */
158
+ export const CODEX_MIN_APP_SERVER_VERSION = "0.60.0";
159
+ /**
160
+ * 从 `codex --version` 的输出中解析三段式版本号。
161
+ * 实际输出形如 `codex-cli 0.153.4`(可能带换行或附加后缀)。
162
+ * 找不到形如 x.y.z 的版本号时返回 null。
163
+ */
164
+ export function parseCodexVersion(output) {
165
+ const match = output.match(/\d+\.\d+\.\d+/);
166
+ return match ? match[0] : null;
167
+ }
168
+ /**
169
+ * 轻量 semver 比较(按数值逐段比较,缺段按 0 补齐,忽略 prerelease/build)。
170
+ * 返回 -1(a < b)、0(a == b)、1(a > b)。
171
+ */
172
+ export function compareSemver(a, b) {
173
+ const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
174
+ const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
175
+ const len = Math.max(pa.length, pb.length);
176
+ for (let i = 0; i < len; i += 1) {
177
+ const x = pa[i] ?? 0;
178
+ const y = pb[i] ?? 0;
179
+ if (x < y)
180
+ return -1;
181
+ if (x > y)
182
+ return 1;
183
+ }
184
+ return 0;
185
+ }
@@ -8,7 +8,7 @@ import { AGENT_TOOLS } from "./agent-tool.js";
8
8
  import { CHATCCC_PACKAGE_ROOT } from "./package-root.js";
9
9
  import { printServiceDidNotStart } from "./exit-banner.js";
10
10
  import { appendStartupTrace, setupFileLogging } from "./shared.js";
11
- import { anthropicConfigDisplay, autoDetectCodexPath, autoDetectCursorPath, normalizeCccProviderOverride, normalizeOptionalConfigField, readToolCliPath, resolveCccEnabled, } from "./config-utils.js";
11
+ import { CODEX_MIN_APP_SERVER_VERSION, anthropicConfigDisplay, autoDetectCodexPath, autoDetectCursorPath, compareSemver, normalizeCccProviderOverride, normalizeOptionalConfigField, parseCodexVersion, readToolCliPath, resolveCccEnabled, } from "./config-utils.js";
12
12
  export { AGENT_TOOLS } from "./agent-tool.js";
13
13
  // 重新导出 config-utils 中的纯函数/常量,保持对外 API 不变
14
14
  // (历史上这些符号都从 ./config.ts 导入;新代码可直接从 ./config-utils.ts 导入以避免触发本文件的副作用)
@@ -795,6 +795,39 @@ export function reportEnvironmentVariableReadout() {
795
795
  console.log(` /git 命令超时: ${GIT_TIMEOUT_SECONDS}s`);
796
796
  console.log(" ------------------------------------------------------------------");
797
797
  }
798
+ /**
799
+ * 启动时检查 Codex CLI 版本是否满足 app-server 模式的最低要求。
800
+ *
801
+ * 版本不足 / 无法探测时仅打印 warning,不阻断启动:当前默认仍是 `codex exec`
802
+ * 模式,exec 并不依赖该最低版本。待未来全面切到 app-server 时,再把这里升级为
803
+ * spawn 前的硬校验。
804
+ */
805
+ export function reportCodexVersionCheck() {
806
+ const codexPath = config.codex.path.trim() || "codex";
807
+ let version = null;
808
+ try {
809
+ const out = execFileSync(codexPath, ["--version"], {
810
+ stdio: ["ignore", "pipe", "ignore"],
811
+ windowsHide: true,
812
+ timeout: 5000,
813
+ }).toString();
814
+ version = parseCodexVersion(out);
815
+ }
816
+ catch {
817
+ version = null;
818
+ }
819
+ if (!version) {
820
+ console.warn(` [警告] [可选] codex 版本:无法探测 Codex CLI 版本(命令: ${codexPath})。` +
821
+ `切换 app-server 模式前需 Codex CLI >= ${CODEX_MIN_APP_SERVER_VERSION}。`);
822
+ return;
823
+ }
824
+ if (compareSemver(version, CODEX_MIN_APP_SERVER_VERSION) < 0) {
825
+ console.warn(` [警告] [可选] codex 版本:当前 ${version} 低于 app-server 要求的最低 ${CODEX_MIN_APP_SERVER_VERSION}。` +
826
+ `现有 exec 模式不受影响;切换 app-server 模式前请升级 Codex CLI。`);
827
+ return;
828
+ }
829
+ console.log(` [成功] [可选] codex 版本:${version}(满足 app-server 最低 ${CODEX_MIN_APP_SERVER_VERSION})`);
830
+ }
798
831
  /** 飞书凭证缺失时打印可操作的说明并退出 */
799
832
  export function explainMissingFeishuCredentialsAndExit() {
800
833
  appendStartupTrace("explainMissingFeishuCredentialsAndExit: exiting", {
package/dist/src/index.js CHANGED
@@ -31,7 +31,7 @@ import { configureAgentTeamMainAgent } from "./agent-team/main-agent-bootstrap.j
31
31
  import { buildWebUiUrl, createServiceLifecycleGuard, announceInternalRestartReady, INTERNAL_RESTART_ENV_VAR, openWebUiInDefaultBrowser, shouldAutoOpenWebUi, } from "./startup-lifecycle.js";
32
32
  import { buildPlatformStartupPlan } from "./platform-startup.js";
33
33
  import { makeTraceId, logTrace } from "./trace.js";
34
- import { CHATCCC_PORT, config, APP_ID, APP_SECRET, FEISHU_ENABLED, FEISHU_PLATFORM_TYPE, ILINK_ENABLED, ILINK_REUSE_TOKEN_ON_START, BASE_URL, LOCAL_RELAY_URL, PID_FILE, PROJECT_ROOT, USE_LOCAL, USE_SIMULATE, appendChatLog, fileLog, reportEnvironmentVariableReadout, maskAppId, resolveDefaultAgentTool, toolDisplayName, ts, } from "./config.js";
34
+ import { CHATCCC_PORT, config, APP_ID, APP_SECRET, FEISHU_ENABLED, FEISHU_PLATFORM_TYPE, ILINK_ENABLED, ILINK_REUSE_TOKEN_ON_START, BASE_URL, LOCAL_RELAY_URL, PID_FILE, PROJECT_ROOT, USE_LOCAL, USE_SIMULATE, appendChatLog, fileLog, reportEnvironmentVariableReadout, reportCodexVersionCheck, maskAppId, resolveDefaultAgentTool, toolDisplayName, ts, } from "./config.js";
35
35
  import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.js";
36
36
  import { addReaction, createGroupChat, extractSessionInfo, formatDelayNotice, getChatInfo, getTenantAccessToken, recallMessage, sendCardReply, sendRawCard, sendTextReply, setChatAvatar, updateCardMessage, updateChatInfo, disbandChat, sendRestartCard, verifyAllPermissions, reportPermissionResults, setPlatform, consumeCodexRateLimitResetCredit, } from "./feishu-platform.js";
37
37
  import { SimulatedPlatform, SIM_DEFAULT_CHAT_ID } from "./sim-platform.js";
@@ -665,6 +665,7 @@ async function main() {
665
665
  });
666
666
  console.log(`[启动 2/7] 环境与凭证检查`);
667
667
  reportEnvironmentVariableReadout();
668
+ reportCodexVersionCheck();
668
669
  console.log(` 工作目录: ${process.cwd()}`);
669
670
  console.log(` 包根目录: ${PROJECT_ROOT}`);
670
671
  if (FEISHU_ENABLED) {
@@ -2239,15 +2239,16 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
2239
2239
  }
2240
2240
  // 并发检查:同一 session 只能有一个活跃 prompt,多余消息进入队列
2241
2241
  if (isSessionRunning(sessionId)) {
2242
- // ccc 内核支持协作式让位:运行期新消息进入注入队列,由 drainInput 在每个
2243
- // model step 边界逐条吸收进当前 turn(不新开 turn)。其他 agent 保持整轮队列。
2244
- if (descriptionTool === "ccc") {
2242
+ // ccc 内核与 codex app-server 支持协作式让位:运行期新消息进入注入队列,
2243
+ // 由 drainInput 在每个 model step 边界逐条吸收进当前 turn(不新开 turn)。
2244
+ // 其他 agent 保持整轮队列。
2245
+ if (descriptionTool === "ccc" || descriptionTool === "codex") {
2245
2246
  const injected = pushInjection(sessionId, {
2246
2247
  text: promptText, chatId, openId, msgTimestamp, chatType, traceId: tid,
2247
2248
  });
2248
2249
  if (injected) {
2249
2250
  logTrace(tid, "INJECT_QUEUED", { sessionId });
2250
- console.log(`[${ts()}] [INJECT_QUEUED] Session ${sessionId} (ccc) busy, message from chat ${chatId} queued for step-boundary injection`);
2251
+ console.log(`[${ts()}] [INJECT_QUEUED] Session ${sessionId} (${descriptionTool}) busy, message from chat ${chatId} queued for step-boundary injection`);
2251
2252
  if (platform.kind === "wechat") {
2252
2253
  await platform.sendText(chatId, "当前会话正在生成中,你的消息会在当前步骤结束后注入本轮处理。").catch(() => { });
2253
2254
  }
@@ -2257,7 +2258,7 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
2257
2258
  }
2258
2259
  else {
2259
2260
  logTrace(tid, "INJECT_QUEUE_FULL", { sessionId });
2260
- console.log(`[${ts()}] [INJECT_QUEUE_FULL] Session ${sessionId} (ccc) injection queue full, rejecting message from chat ${chatId}`);
2261
+ console.log(`[${ts()}] [INJECT_QUEUE_FULL] Session ${sessionId} (${descriptionTool}) injection queue full, rejecting message from chat ${chatId}`);
2261
2262
  if (platform.kind === "wechat") {
2262
2263
  await platform.sendText(chatId, "当前待注入消息过多,请等待或发送 /stop(停止生成)或 /cancel(清空注入)。").catch(() => { });
2263
2264
  }
@@ -163,10 +163,10 @@ export function hasQueuedMessage(sessionId) {
163
163
  return queuedMessages.has(sessionId);
164
164
  }
165
165
  // ---------------------------------------------------------------------------
166
- // pendingInjections: sessionId → 运行中待注入消息(仅 ccc 内核)
167
- // 与上面的 queuedMessages(整轮队列,深度 1)分离:ccc 运行期的新消息进入
168
- // 这里,由 drainInput 在每个 model step 边界逐条吸收进当前 turn;其他 agent
169
- // 继续走整轮队列。turn 结束后剩余未注入的消息转回普通队列消费。
166
+ // pendingInjections: sessionId → 运行中待注入消息(ccc 内核与 codex app-server)
167
+ // 与上面的 queuedMessages(整轮队列,深度 1)分离:ccc/codex 运行期的新消息
168
+ // 进入这里,由 drainInput 在每个 model step 边界逐条吸收进当前 turn;其他
169
+ // agent 继续走整轮队列。turn 结束后剩余未注入的消息转回普通队列消费。
170
170
  // ---------------------------------------------------------------------------
171
171
  export const MAX_PENDING_INJECTIONS = 50;
172
172
  export const pendingInjections = new Map();
@@ -187,6 +187,12 @@ export function shiftInjection(sessionId) {
187
187
  pendingInjections.delete(sessionId);
188
188
  return msg;
189
189
  }
190
+ /** 注入未能被运行中 turn 吸收时,把消息退回队列头部。 */
191
+ export function unshiftInjection(sessionId, msg) {
192
+ const list = pendingInjections.get(sessionId) ?? [];
193
+ list.unshift(msg);
194
+ pendingInjections.set(sessionId, list);
195
+ }
190
196
  export function drainRemainingInjections(sessionId) {
191
197
  const list = pendingInjections.get(sessionId);
192
198
  pendingInjections.delete(sessionId);
@@ -29,7 +29,7 @@ function compressWechatDisplayText(text) {
29
29
  }
30
30
  import { readStreamState, writeStreamState, createEmptyStreamState, isFinalReplySentForTurn, markFinalReplySent, } from "./stream-state.js";
31
31
  import { addCardToTurn, finalizeTurnCards, markCardDone } from "./turn-cards.js";
32
- import { bindChatToSession, unbindChatFromSession, getChatsForSession, activePrompts, displayCards, unifiedDisplayLoopHandle, setUnifiedDisplayLoopHandle, rebuildSessionChatsFromRegistry, recordLastActiveChat, getLastActiveChat, pickDisplayChat, dequeueMessage, consumeQueuedMessage, cancelQueuedMessage, shiftInjection, drainRemainingInjections, clearInjections, setQueuePreservedChat, consumeQueuePreservedChat, markSessionFinalizing, clearSessionFinalizing, reserveAutoRecovery, consumeAutoRecoveryReservation, cancelAutoRecoveryReservation, hasAutoRecoveryReservation, } from "./session-chat-binding.js";
32
+ import { bindChatToSession, unbindChatFromSession, getChatsForSession, activePrompts, displayCards, unifiedDisplayLoopHandle, setUnifiedDisplayLoopHandle, rebuildSessionChatsFromRegistry, recordLastActiveChat, getLastActiveChat, pickDisplayChat, dequeueMessage, consumeQueuedMessage, cancelQueuedMessage, shiftInjection, unshiftInjection, drainRemainingInjections, clearInjections, setQueuePreservedChat, consumeQueuePreservedChat, markSessionFinalizing, clearSessionFinalizing, reserveAutoRecovery, consumeAutoRecoveryReservation, cancelAutoRecoveryReservation, hasAutoRecoveryReservation, } from "./session-chat-binding.js";
33
33
  async function sendFinalReplyTextOnce(platform, chatId, sessionId, turnCount, text) {
34
34
  const sent = await platform.sendText(chatId, text).then((ok) => ok !== false).catch(() => false);
35
35
  if (sent)
@@ -1287,6 +1287,8 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1287
1287
  }
1288
1288
  }
1289
1289
  try {
1290
+ // 供 onInjectionRejected 退回的“最后一条已取出但未成功注入”的消息
1291
+ let lastShiftedInjection = null;
1290
1292
  for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1291
1293
  onProcessStart: (processInfo) => {
1292
1294
  startPromptProcessMonitor(sessionId, processInfo);
@@ -1303,16 +1305,24 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1303
1305
  if (prompt)
1304
1306
  prompt.closeSession = closeSession;
1305
1307
  },
1306
- // ccc 内核支持协作式让位:同步从注入队列取队首文本,供内核在每个
1307
- // model step 边界吸收。非 ccc adapter 忽略该字段。
1308
- drainInput: tool === "ccc" ? () => {
1308
+ // ccc 内核与 codex app-server 支持协作式让位:同步从注入队列取队首文本,
1309
+ // 供适配器在每个 model step 边界吸收。其他 adapter 忽略该字段。
1310
+ drainInput: tool === "ccc" || tool === "codex" ? () => {
1309
1311
  const injected = shiftInjection(sessionId);
1310
1312
  if (!injected)
1311
1313
  return undefined;
1314
+ lastShiftedInjection = injected;
1312
1315
  // 与首次消息保持一致的结构化包装;imSkillsPrompt 已在首次消息中,
1313
1316
  // 此处不重复注入,避免多轮注入导致上下文膨胀。
1314
1317
  return `[User message]\n${injected.text}\n[/User message]`;
1315
1318
  } : undefined,
1319
+ // 注入未被当前 turn 吸收(如 turn 恰好已结束)时,退回队列头部,避免丢消息。
1320
+ onInjectionRejected: tool === "ccc" || tool === "codex" ? () => {
1321
+ if (lastShiftedInjection) {
1322
+ unshiftInjection(sessionId, lastShiftedInjection);
1323
+ lastShiftedInjection = null;
1324
+ }
1325
+ } : undefined,
1316
1326
  })) {
1317
1327
  if (unifiedMsg.isFinalResponse) {
1318
1328
  const prompt = activePrompts.get(sessionId);
@@ -1653,7 +1663,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1653
1663
  // 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
1654
1664
  // finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
1655
1665
  queuedForConsumption = dequeueMessage(sessionId);
1656
- // ccc 注入队列剩余(turn 期间未注入完,如 non-streaming 或收尾窗口到达)
1666
+ // ccc/codex 注入队列剩余(turn 期间未注入完,如 non-streaming 或收尾窗口到达)
1657
1667
  // 取第一条转普通消费,其余留待下一轮结束后继续消费。
1658
1668
  if (!queuedForConsumption) {
1659
1669
  queuedForConsumption = shiftInjection(sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.287",
3
+ "version": "0.2.288",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",