chatccc 0.2.286 → 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
+ }
@@ -88,6 +88,9 @@ export function updateAgentActivity(tracker, block, now = Date.now()) {
88
88
  return setActivity(tracker, { kind: "searching", startedAt: now });
89
89
  case "compact_boundary":
90
90
  return setActivity(tracker, { kind: "compacting", startedAt: now });
91
+ case "input_injected":
92
+ // 协作式让位:注入本身不是 agent 活动,不改变状态标题。
93
+ return false;
91
94
  }
92
95
  }
93
96
  function formatElapsed(startedAt, now) {
package/dist/src/cards.js CHANGED
@@ -394,6 +394,44 @@ export function buildQueueFullCard() {
394
394
  ],
395
395
  });
396
396
  }
397
+ // 协作式让位卡片(仅 ccc):消息进入注入队列,将在 step 边界吸收进本轮。
398
+ // 与上面的整轮队列卡片区分,强调“无需等待整轮结束”。
399
+ export function buildInjectionQueuedCard(text) {
400
+ const preview = text.length > 100 ? text.slice(0, 100) + "…" : text;
401
+ return JSON.stringify({
402
+ config: { wide_screen_mode: true },
403
+ header: { template: "blue", title: { content: "消息将注入本轮", tag: "plain_text" } },
404
+ elements: [
405
+ { tag: "div", text: { tag: "lark_md", content: `当前会话正在生成中,你的消息会在**当前步骤结束后立即注入本轮**,无需等待整轮结束。\n\n> ${preview}` } },
406
+ { tag: "hr" },
407
+ {
408
+ tag: "action",
409
+ actions: [
410
+ { tag: "button", text: { tag: "plain_text", content: "清空注入(/cancel)" }, type: "danger", value: { action: "cancel" } },
411
+ { tag: "button", text: { tag: "plain_text", content: "停止生成(/stop)" }, type: "default", value: { action: "stop" } },
412
+ ],
413
+ },
414
+ ],
415
+ });
416
+ }
417
+ // 注入队列满卡片(仅 ccc)
418
+ export function buildInjectionQueueFullCard() {
419
+ return JSON.stringify({
420
+ config: { wide_screen_mode: true },
421
+ header: { template: "yellow", title: { content: "待注入消息过多", tag: "plain_text" } },
422
+ elements: [
423
+ { tag: "div", text: { tag: "lark_md", content: "当前已有较多消息等待注入本轮,请稍候或发送指令:\n- **/stop** — 停止当前生成\n- **/cancel** — 清空待注入消息" } },
424
+ { tag: "hr" },
425
+ {
426
+ tag: "action",
427
+ actions: [
428
+ { tag: "button", text: { tag: "plain_text", content: "清空注入(/cancel)" }, type: "danger", value: { action: "cancel" } },
429
+ { tag: "button", text: { tag: "plain_text", content: "停止生成(/stop)" }, type: "default", value: { action: "stop" } },
430
+ ],
431
+ },
432
+ ],
433
+ });
434
+ }
397
435
  // 状态卡片(带关闭按钮)
398
436
  export function buildStatusCard(statusText, template = "blue") {
399
437
  return JSON.stringify({
@@ -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", {
@@ -58,6 +58,9 @@ export function appendExecutionTranscriptBlock(block, state, at = new Date().toI
58
58
  // Heartbeats carry no content and can occur very frequently. Persisting them
59
59
  // would add noise without helping users reconstruct what happened.
60
60
  return;
61
+ case "input_injected":
62
+ appendEntry(state, { type: "notice", at, text: `已注入新消息:${block.text}` });
63
+ return;
61
64
  }
62
65
  }
63
66
  export function isExecutionTranscriptEntry(value) {
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) {
@@ -14,10 +14,10 @@ import { withGitCoAuthor } from "../deepccc-agent/src/file-tools.js";
14
14
  import { makeTraceId, logTrace } from "./trace.js";
15
15
  import { appendStartupTrace } from "./shared.js";
16
16
  import { CLAUDE_MODEL, GIT_TIMEOUT_MS, PROJECT_ROOT, anthropicConfigDisplay, config, fileLog, getAllEffortsForTool, getAllModelsForTool, getDefaultEffortForTool, getDefaultCwd, LOG_DIR, setDefaultCwd, getRecentDirs, addRecentDir, resolveDefaultAgentTool, sessionPrefixForTool, toolDisplayName, ts, } from "./config.js";
17
- import { buildHelpCard, buildEffortCard, buildFastModeCard, buildModelCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard, buildQueuedCard, buildQueueFullCard, buildCodexUsageCard, } from "./cards.js";
17
+ import { buildHelpCard, buildEffortCard, buildFastModeCard, buildModelCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard, buildQueuedCard, buildQueueFullCard, buildInjectionQueuedCard, buildInjectionQueueFullCard, buildCodexUsageCard, } from "./cards.js";
18
18
  import { formatGitResult, gitResultHeaderTemplate, runGitCommand, } from "./git-command.js";
19
19
  import { clearSessionModelOverride, clearSessionEffortOverride, getSessionStatus, getAllSessionsStatus, initClaudeSession, lastMsgTimestamps, resumeAndPrompt, sessionInfoMap, setSessionModelOverride, setSessionEffortOverride, switchChatBinding, recordSessionRegistry, getAdapterForTool, getEffectiveModelForTool, getEffectiveEffortForTool, getEffectiveFastModeForTool, setSessionFastModeOverride, stopSession, loadSessionRegistryForBinding, removeSessionRegistryRecord, saveSessionTool, saveSessionPresentation, recordChatPlatform, } from "./session.js";
20
- import { bindChatToSession, unbindChatFromSession, isSessionRunning, displayCards, enqueueMessage, cancelQueuedMessage, getSessionDrainSnapshot, } from "./session-chat-binding.js";
20
+ import { bindChatToSession, unbindChatFromSession, isSessionRunning, displayCards, enqueueMessage, cancelQueuedMessage, pushInjection, clearInjections, getSessionDrainSnapshot, } from "./session-chat-binding.js";
21
21
  import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.js";
22
22
  import { getCursorUsageSummary } from "./cursor-usage.js";
23
23
  import { getChatGptSubscriptionStatus } from "./chatgpt-subscription.js";
@@ -1675,9 +1675,11 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
1675
1675
  }
1676
1676
  if (isCommandText && textLower === "/cancel") {
1677
1677
  logTrace(tid, "BRANCH", { cmd: "/cancel" });
1678
- if (cancelQueuedMessage(sessionId)) {
1678
+ const cancelledQueue = cancelQueuedMessage(sessionId);
1679
+ const cancelledInjections = clearInjections(sessionId);
1680
+ if (cancelledQueue || cancelledInjections) {
1679
1681
  console.log(`[${ts()}] [CANCEL] Queue cancelled for session=${sessionId}`);
1680
- await platform.sendText(chatId, "已取消缓存队列中的消息。").catch(() => { });
1682
+ await platform.sendText(chatId, "已取消缓存队列与待注入的消息。").catch(() => { });
1681
1683
  logTrace(tid, "DONE", { outcome: "cancelled" });
1682
1684
  }
1683
1685
  else {
@@ -2237,6 +2239,35 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
2237
2239
  }
2238
2240
  // 并发检查:同一 session 只能有一个活跃 prompt,多余消息进入队列
2239
2241
  if (isSessionRunning(sessionId)) {
2242
+ // ccc 内核与 codex app-server 支持协作式让位:运行期新消息进入注入队列,
2243
+ // 由 drainInput 在每个 model step 边界逐条吸收进当前 turn(不新开 turn)。
2244
+ // 其他 agent 保持整轮队列。
2245
+ if (descriptionTool === "ccc" || descriptionTool === "codex") {
2246
+ const injected = pushInjection(sessionId, {
2247
+ text: promptText, chatId, openId, msgTimestamp, chatType, traceId: tid,
2248
+ });
2249
+ if (injected) {
2250
+ logTrace(tid, "INJECT_QUEUED", { sessionId });
2251
+ console.log(`[${ts()}] [INJECT_QUEUED] Session ${sessionId} (${descriptionTool}) busy, message from chat ${chatId} queued for step-boundary injection`);
2252
+ if (platform.kind === "wechat") {
2253
+ await platform.sendText(chatId, "当前会话正在生成中,你的消息会在当前步骤结束后注入本轮处理。").catch(() => { });
2254
+ }
2255
+ else {
2256
+ await platform.sendRawCard(chatId, buildInjectionQueuedCard(text)).catch(() => { });
2257
+ }
2258
+ }
2259
+ else {
2260
+ logTrace(tid, "INJECT_QUEUE_FULL", { sessionId });
2261
+ console.log(`[${ts()}] [INJECT_QUEUE_FULL] Session ${sessionId} (${descriptionTool}) injection queue full, rejecting message from chat ${chatId}`);
2262
+ if (platform.kind === "wechat") {
2263
+ await platform.sendText(chatId, "当前待注入消息过多,请等待或发送 /stop(停止生成)或 /cancel(清空注入)。").catch(() => { });
2264
+ }
2265
+ else {
2266
+ await platform.sendRawCard(chatId, buildInjectionQueueFullCard()).catch(() => { });
2267
+ }
2268
+ }
2269
+ return;
2270
+ }
2240
2271
  const queued = enqueueMessage(sessionId, {
2241
2272
  text: promptText, chatId, openId, msgTimestamp, chatType, traceId: tid,
2242
2273
  });
@@ -102,5 +102,11 @@ export function reduceProgress(prev, event) {
102
102
  case "compact":
103
103
  // 旧上下文压缩不影响当前过程展示
104
104
  return prev;
105
+ case "input_injected": {
106
+ // 协作式让位:在当前 turn 的 step 边界注入了新消息,仅在头部提示,
107
+ // 不改动正文与工具状态。
108
+ const preview = event.text.length > 40 ? `${event.text.slice(0, 40)}…` : event.text;
109
+ return withProgressView(prev, { headerTitle: `已注入新消息:${preview}` });
110
+ }
105
111
  }
106
112
  }
@@ -163,6 +163,48 @@ export function hasQueuedMessage(sessionId) {
163
163
  return queuedMessages.has(sessionId);
164
164
  }
165
165
  // ---------------------------------------------------------------------------
166
+ // pendingInjections: sessionId → 运行中待注入消息(ccc 内核与 codex app-server)
167
+ // 与上面的 queuedMessages(整轮队列,深度 1)分离:ccc/codex 运行期的新消息
168
+ // 进入这里,由 drainInput 在每个 model step 边界逐条吸收进当前 turn;其他
169
+ // agent 继续走整轮队列。turn 结束后剩余未注入的消息转回普通队列消费。
170
+ // ---------------------------------------------------------------------------
171
+ export const MAX_PENDING_INJECTIONS = 50;
172
+ export const pendingInjections = new Map();
173
+ export function pushInjection(sessionId, msg) {
174
+ const list = pendingInjections.get(sessionId) ?? [];
175
+ if (list.length >= MAX_PENDING_INJECTIONS)
176
+ return false;
177
+ list.push(msg);
178
+ pendingInjections.set(sessionId, list);
179
+ return true;
180
+ }
181
+ export function shiftInjection(sessionId) {
182
+ const list = pendingInjections.get(sessionId);
183
+ if (!list || list.length === 0)
184
+ return undefined;
185
+ const msg = list.shift();
186
+ if (list.length === 0)
187
+ pendingInjections.delete(sessionId);
188
+ return msg;
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
+ }
196
+ export function drainRemainingInjections(sessionId) {
197
+ const list = pendingInjections.get(sessionId);
198
+ pendingInjections.delete(sessionId);
199
+ return list ?? [];
200
+ }
201
+ export function hasPendingInjection(sessionId) {
202
+ return (pendingInjections.get(sessionId)?.length ?? 0) > 0;
203
+ }
204
+ export function clearInjections(sessionId) {
205
+ pendingInjections.delete(sessionId);
206
+ }
207
+ // ---------------------------------------------------------------------------
166
208
  // 队列消费回调(由 index.ts 注入,避免 session.ts → orchestrator.ts 循环依赖)
167
209
  // ---------------------------------------------------------------------------
168
210
  let onConsumeQueuedMessage = null;
@@ -187,6 +229,7 @@ export function resetBindingState() {
187
229
  finalizingSessions.clear();
188
230
  autoRecoveryReservations.clear();
189
231
  queuedMessages.clear();
232
+ pendingInjections.clear();
190
233
  displayCards.clear();
191
234
  if (unifiedDisplayLoopHandle !== null) {
192
235
  clearInterval(unifiedDisplayLoopHandle);