chatccc 0.2.291 → 0.2.293

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -25,6 +25,18 @@ const SEARCH_TIMEOUT_MS = 15_000;
25
25
  const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024;
26
26
  const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
27
27
  const MAX_COMMAND_TIMEOUT_MS = 900_000;
28
+ /**
29
+ * 让位注入的轮询间隔:run_command 执行期以此频率检查是否有待注入的用户消息。
30
+ * 取 1s 而非更小值:轮询本身不花 token(只读内存队列,无模型调用),但更密的
31
+ * 定时器唤醒有可测的 CPU 开销(实测 250ms 档约 0.27ms/s,1s 档低于计时精度)。
32
+ * 让位延迟从 250ms 放宽到 1s 对用户无感,却把长任务期间的定时器唤醒降低到
33
+ * 约 1/4,因此取 1s 作为响应速度与空转成本的平衡点。
34
+ */
35
+ const YIELD_POLL_INTERVAL_MS = 1000;
36
+ /** command_output 单次等待上限,避免代理用等待把 step 又堵死 */
37
+ const MAX_COMMAND_OUTPUT_WAIT_MS = 30_000;
38
+ /** 后台命令句柄上限;超出时优先回收已结束的句柄 */
39
+ const MAX_BACKGROUND_COMMANDS = 32;
28
40
  /** task 子代理工具:子任务结果回传主会话前的最大字符数(防止子代理长输出撑爆主上下文) */
29
41
  export const MAX_TASK_OUTPUT_CHARS = 32_000;
30
42
  const requireFromHere = createRequire(import.meta.url);
@@ -722,7 +734,41 @@ export async function searchCodeForTool(cwd, input, signal, runtimeOptions = {})
722
734
  : fallback.truncated,
723
735
  };
724
736
  }
725
- export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
737
+ const backgroundCommands = new Map();
738
+ let backgroundCommandSeq = 0;
739
+ function normalizeCommandOutputWaitMs(value) {
740
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
741
+ return 0;
742
+ return Math.min(Math.trunc(value), MAX_COMMAND_OUTPUT_WAIT_MS);
743
+ }
744
+ function snapshotBackgroundCommand(entry) {
745
+ return {
746
+ taskId: entry.taskId,
747
+ command: entry.command,
748
+ cwd: entry.cwd,
749
+ running: !entry.done,
750
+ exitCode: entry.exitCode,
751
+ signal: entry.signal,
752
+ stdout: entry.stdout.chunks.join(""),
753
+ stderr: entry.stderr.chunks.join(""),
754
+ timedOut: entry.timedOut,
755
+ truncated: entry.stdout.truncated || entry.stderr.truncated,
756
+ durationMs: Date.now() - entry.startedAt,
757
+ };
758
+ }
759
+ /** 句柄超限时优先回收已结束的条目(运行中的条目保留,避免破坏 agent 的跟踪)。 */
760
+ function pruneBackgroundCommands() {
761
+ if (backgroundCommands.size <= MAX_BACKGROUND_COMMANDS)
762
+ return;
763
+ for (const [id, entry] of backgroundCommands) {
764
+ if (!entry.done)
765
+ continue;
766
+ backgroundCommands.delete(id);
767
+ if (backgroundCommands.size <= MAX_BACKGROUND_COMMANDS)
768
+ return;
769
+ }
770
+ }
771
+ export async function runCommandForTool(cwd, input, abortSignal, coAuthor, shouldYield) {
726
772
  const command = withGitCoAuthor(input.command?.trim(), coAuthor);
727
773
  if (!command)
728
774
  throw new Error("command is required");
@@ -740,6 +786,8 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
740
786
  let timedOut = false;
741
787
  let timeout;
742
788
  let fallbackTimer;
789
+ let yieldPoll;
790
+ let backgroundEntry;
743
791
  const child = spawn(command, {
744
792
  cwd: commandCwd,
745
793
  shell: true,
@@ -751,6 +799,8 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
751
799
  clearTimeout(timeout);
752
800
  if (fallbackTimer)
753
801
  clearTimeout(fallbackTimer);
802
+ if (yieldPoll)
803
+ clearInterval(yieldPoll);
754
804
  abortSignal?.removeEventListener("abort", abort);
755
805
  };
756
806
  const finish = (exitCode, signal) => {
@@ -770,12 +820,32 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
770
820
  durationMs: Date.now() - startedAt,
771
821
  });
772
822
  };
823
+ /**
824
+ * 统一收敛点:已转入后台则更新句柄并唤醒等待方,否则按前台语义 resolve。
825
+ * 让位后 timeout 与 abort 的既有定时器/监听继续生效,因此后台任务仍受
826
+ * timeoutMs 约束,也仍能被 /stop 终止。
827
+ */
828
+ const settle = (exitCode, signal) => {
829
+ if (!backgroundEntry) {
830
+ finish(exitCode, signal);
831
+ return;
832
+ }
833
+ backgroundEntry.done = true;
834
+ backgroundEntry.exitCode = exitCode;
835
+ backgroundEntry.signal = signal;
836
+ backgroundEntry.timedOut = timedOut;
837
+ cleanup();
838
+ const waiters = backgroundEntry.waiters;
839
+ backgroundEntry.waiters = [];
840
+ for (const wake of waiters)
841
+ wake();
842
+ };
773
843
  const requestKill = (reason) => {
774
844
  void killProcessTree(child.pid);
775
845
  fallbackTimer = setTimeout(() => {
776
846
  child.stdout?.destroy();
777
847
  child.stderr?.destroy();
778
- finish(null, reason === "timeout" ? "SIGTERM" : reason);
848
+ settle(null, reason === "timeout" ? "SIGTERM" : reason);
779
849
  }, 5_000);
780
850
  fallbackTimer.unref?.();
781
851
  };
@@ -787,6 +857,70 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
787
857
  requestKill("abort");
788
858
  };
789
859
  abortSignal?.addEventListener("abort", abort, { once: true });
860
+ /**
861
+ * 让位:命令既不杀也不等,转为后台句柄后立即返回。这样当前 step 尽快结束,
862
+ * 下一个 prepareStep 边界即可调用 drainInput 把用户新消息注入当前 turn。
863
+ */
864
+ const detachToBackground = () => {
865
+ if (settled || backgroundEntry)
866
+ return;
867
+ // close/error 重新挂到 settle:settle 会按 backgroundEntry 分流到后台句柄
868
+ child.removeAllListeners("close");
869
+ child.removeAllListeners("error");
870
+ child.once("close", (code, signal) => settle(code, signal));
871
+ child.once("error", () => settle(null, "error"));
872
+ const taskId = `cmd-${++backgroundCommandSeq}-${Math.random().toString(36).slice(2, 6)}`;
873
+ backgroundEntry = {
874
+ taskId,
875
+ command,
876
+ cwd: commandCwd,
877
+ startedAt,
878
+ child,
879
+ stdout,
880
+ stderr,
881
+ done: false,
882
+ exitCode: null,
883
+ signal: null,
884
+ timedOut: false,
885
+ waiters: [],
886
+ };
887
+ backgroundCommands.set(taskId, backgroundEntry);
888
+ pruneBackgroundCommands();
889
+ if (yieldPoll)
890
+ clearInterval(yieldPoll);
891
+ yieldPoll = undefined;
892
+ // 刻意不调 cleanup:超时定时器与 abort 监听继续为后台任务服务
893
+ settled = true;
894
+ resolvePromise({
895
+ command,
896
+ cwd: commandCwd,
897
+ exitCode: null,
898
+ signal: null,
899
+ stdout: stdout.chunks.join(""),
900
+ stderr: stderr.chunks.join(""),
901
+ timedOut: false,
902
+ truncated: stdout.truncated || stderr.truncated,
903
+ durationMs: Date.now() - startedAt,
904
+ backgrounded: true,
905
+ taskId,
906
+ });
907
+ };
908
+ if (shouldYield) {
909
+ yieldPoll = setInterval(() => {
910
+ if (settled || backgroundEntry)
911
+ return;
912
+ let yieldNow = false;
913
+ try {
914
+ yieldNow = shouldYield() === true;
915
+ }
916
+ catch {
917
+ yieldNow = false;
918
+ }
919
+ if (yieldNow)
920
+ detachToBackground();
921
+ }, YIELD_POLL_INTERVAL_MS);
922
+ yieldPoll.unref?.();
923
+ }
790
924
  child.stdout?.on("data", (chunk) => {
791
925
  appendLimitedOutput(stdout, chunk);
792
926
  });
@@ -794,6 +928,10 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
794
928
  appendLimitedOutput(stderr, chunk);
795
929
  });
796
930
  child.once("error", (err) => {
931
+ if (backgroundEntry) {
932
+ settle(null, "error");
933
+ return;
934
+ }
797
935
  if (settled)
798
936
  return;
799
937
  settled = true;
@@ -801,10 +939,75 @@ export async function runCommandForTool(cwd, input, abortSignal, coAuthor) {
801
939
  reject(err);
802
940
  });
803
941
  child.once("close", (code, signal) => {
804
- finish(code, signal);
942
+ settle(code, signal);
805
943
  });
806
944
  });
807
945
  }
946
+ /** 读取后台命令的最新输出;waitMs > 0 时最多等待该时长(等待期间仍可让位)。 */
947
+ export async function commandOutputForTool(taskId, input, shouldYield) {
948
+ const entry = backgroundCommands.get(taskId);
949
+ if (!entry)
950
+ throw new Error(`未知的后台任务:${taskId}`);
951
+ const waitMs = normalizeCommandOutputWaitMs(input?.waitMs);
952
+ if (waitMs > 0 && !entry.done) {
953
+ await waitForBackgroundCommand(entry, waitMs, shouldYield);
954
+ }
955
+ return snapshotBackgroundCommand(entry);
956
+ }
957
+ /** 等待后台命令结束(或等待预算耗尽、或让位信号到来)。 */
958
+ function waitForBackgroundCommand(entry, waitMs, shouldYield) {
959
+ return new Promise((resolve) => {
960
+ let finished = false;
961
+ let poll;
962
+ const wake = () => {
963
+ if (finished)
964
+ return;
965
+ finished = true;
966
+ clearTimeout(timer);
967
+ if (poll)
968
+ clearInterval(poll);
969
+ const index = entry.waiters.indexOf(wake);
970
+ if (index >= 0)
971
+ entry.waiters.splice(index, 1);
972
+ resolve();
973
+ };
974
+ const timer = setTimeout(wake, waitMs);
975
+ timer.unref?.();
976
+ entry.waiters.push(wake);
977
+ if (shouldYield) {
978
+ poll = setInterval(() => {
979
+ let yieldNow = false;
980
+ try {
981
+ yieldNow = shouldYield() === true;
982
+ }
983
+ catch {
984
+ yieldNow = false;
985
+ }
986
+ if (yieldNow)
987
+ wake();
988
+ }, YIELD_POLL_INTERVAL_MS);
989
+ poll.unref?.();
990
+ }
991
+ });
992
+ }
993
+ /** 终止一个仍在运行的后台命令。 */
994
+ export async function commandKillForTool(taskId) {
995
+ const entry = backgroundCommands.get(taskId);
996
+ if (!entry)
997
+ throw new Error(`未知的后台任务:${taskId}`);
998
+ if (entry.done)
999
+ return { taskId, killed: false };
1000
+ await killProcessTree(entry.child.pid);
1001
+ return { taskId, killed: true };
1002
+ }
1003
+ /** 终止并清空全部后台命令句柄(进程退出与测试收尾),避免留下孤儿进程。 */
1004
+ export function killAllBackgroundCommands() {
1005
+ for (const entry of backgroundCommands.values()) {
1006
+ if (!entry.done)
1007
+ void killProcessTree(entry.child.pid);
1008
+ }
1009
+ backgroundCommands.clear();
1010
+ }
808
1011
  export async function editFileForTool(cwd, input) {
809
1012
  if (!Array.isArray(input.edits) || input.edits.length === 0) {
810
1013
  throw new Error("edits must contain at least one replacement");
@@ -1090,7 +1293,7 @@ export function createBuiltinFileTools(cwd, options = {}) {
1090
1293
  execute: (input) => presentFileForTool(cwd, input),
1091
1294
  }),
1092
1295
  run_command: tool({
1093
- description: "在本地工作区运行非交互式 shell 命令。用于测试、git 和包脚本。返回 stdout/stderr 和 exitCode;非零退出码不是工具错误。",
1296
+ description: "在本地工作区运行非交互式 shell 命令。用于测试、git 和包脚本。返回 stdout/stderr 和 exitCode;非零退出码不是工具错误。命令运行期间若用户发来新消息,会立即转入后台并返回 taskId(backgrounded:true),命令继续运行,用 command_output 取后续输出。",
1094
1297
  inputSchema: jsonSchema({
1095
1298
  type: "object",
1096
1299
  additionalProperties: false,
@@ -1108,9 +1311,34 @@ export function createBuiltinFileTools(cwd, options = {}) {
1108
1311
  reason: isDangerousCommand(input.command) ? "high-risk" : "rule",
1109
1312
  detail: `运行命令: ${input.command}`,
1110
1313
  });
1111
- return runCommandForTool(cwd, input, execOptions.abortSignal, options.gitCoAuthor);
1314
+ return runCommandForTool(cwd, input, execOptions.abortSignal, options.gitCoAuthor, options.shouldYieldToInjection);
1112
1315
  },
1113
1316
  }),
1317
+ command_output: tool({
1318
+ description: "读取被让位到后台的命令的最新输出。立即返回当前状态;waitMs 可最多等待 30 秒。任务结束后可反复读取完整 stdout/stderr。",
1319
+ inputSchema: jsonSchema({
1320
+ type: "object",
1321
+ additionalProperties: false,
1322
+ properties: {
1323
+ taskId: { type: "string", description: "run_command 返回的 taskId(backgrounded:true 时)。" },
1324
+ waitMs: { type: "number", description: `可选等待毫秒数;0 或省略立即返回,上限 ${MAX_COMMAND_OUTPUT_WAIT_MS}。` },
1325
+ },
1326
+ required: ["taskId"],
1327
+ }),
1328
+ execute: (input) => commandOutputForTool(input.taskId, input, options.shouldYieldToInjection),
1329
+ }),
1330
+ command_kill: tool({
1331
+ description: "终止一个仍在后台运行的命令(run_command 让位后转入后台的)。",
1332
+ inputSchema: jsonSchema({
1333
+ type: "object",
1334
+ additionalProperties: false,
1335
+ properties: {
1336
+ taskId: { type: "string", description: "run_command 返回的 taskId。" },
1337
+ },
1338
+ required: ["taskId"],
1339
+ }),
1340
+ execute: (input) => commandKillForTool(input.taskId),
1341
+ }),
1114
1342
  task: tool({
1115
1343
  description: "把独立子任务委派给子代理执行:子代理使用子模型、拥有独立上下文,不污染主对话上下文。适合边界清晰、可独立交付的调研/代码生成子任务(如扫描整个仓库、阅读长文档、生成独立模块)。子代理不能再次委派任务(禁止嵌套),结果会截断回传。",
1116
1344
  inputSchema: jsonSchema({
@@ -270,6 +270,8 @@ export class ChatSession {
270
270
  context;
271
271
  compactionTimeoutMs;
272
272
  maxSteps;
273
+ /** 透传给 run_command 的让位注入判定(见 ChatSessionOptions.shouldYieldToInjection)。 */
274
+ shouldYieldToInjection;
273
275
  effort;
274
276
  maxOutputTokens;
275
277
  streaming;
@@ -359,6 +361,7 @@ export class ChatSession {
359
361
  this.subModel = this.subModelId ? provider(this.subModelId) : this.model;
360
362
  this.cwd = options.cwd ?? process.cwd();
361
363
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
364
+ this.shouldYieldToInjection = options.shouldYieldToInjection;
362
365
  this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
363
366
  this.customSystemPrompt = options.systemPrompt ?? "";
364
367
  this.permissionMode = options.permissionMode ?? "ask";
@@ -493,6 +496,7 @@ export class ChatSession {
493
496
  tools: createBuiltinFileTools(this.cwd, {
494
497
  permissionGate: this.permissionGate,
495
498
  runTask: this.runTask,
499
+ shouldYieldToInjection: this.shouldYieldToInjection,
496
500
  gitCoAuthor: {
497
501
  ...appConfig.git.coAuthor,
498
502
  enabled: this.gitCoAuthorEnabled,
@@ -19,6 +19,9 @@ function toChatSessionOptions(sessionId, cwd, options) {
19
19
  // 高危命令不询问,全部放行(与独立 deepccc CLI 的 ask 模式不同)
20
20
  permissionMode: "bypass",
21
21
  ...(options.gitCoAuthor !== undefined ? { gitCoAuthor: options.gitCoAuthor } : {}),
22
+ ...(options.hasPendingInjection
23
+ ? { shouldYieldToInjection: () => options.hasPendingInjection(sessionId) === true }
24
+ : {}),
22
25
  };
23
26
  }
24
27
  export function createCccAdapter(options = {}) {
@@ -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, unshiftInjection, 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, hasPendingInjection, } 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)
@@ -550,6 +550,7 @@ export function getAdapterForTool(tool, sessionId) {
550
550
  ...(config.ccc.provider ? { provider: config.ccc.provider } : {}),
551
551
  ...(config.ccc.subModel ? { subModel: config.ccc.subModel } : {}),
552
552
  ...(config.ccc.gitCoAuthor !== null ? { gitCoAuthor: config.ccc.gitCoAuthor } : {}),
553
+ hasPendingInjection,
553
554
  });
554
555
  }
555
556
  else if (tool === "dsh") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.291",
3
+ "version": "0.2.293",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",