c0de-agent 1.13.1 → 1.14.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 (155) hide show
  1. package/dist/cli/commands/serve.js +5 -1
  2. package/dist/cli/deps.d.ts +2 -4
  3. package/dist/cli/deps.js +22 -36
  4. package/dist/cli/modes/print.js +27 -17
  5. package/dist/core/agent.js +59 -36
  6. package/dist/core/agents/parallel.js +4 -2
  7. package/dist/core/config-path.d.ts +3 -1
  8. package/dist/core/config-path.js +33 -3
  9. package/dist/core/config.js +81 -0
  10. package/dist/core/context.js +6 -3
  11. package/dist/core/loop/persist.js +4 -1
  12. package/dist/core/loop/subagent.d.ts +19 -0
  13. package/dist/core/loop/subagent.js +73 -7
  14. package/dist/core/loop.d.ts +4 -1
  15. package/dist/core/loop.js +22 -6
  16. package/dist/{server/registry-config.d.ts → core/provider-registry.d.ts} +10 -2
  17. package/dist/core/provider-registry.js +66 -0
  18. package/dist/core/redact.d.ts +2 -1
  19. package/dist/core/redact.js +4 -2
  20. package/dist/core/secret.d.ts +5 -0
  21. package/dist/core/secret.js +14 -0
  22. package/dist/core/skills.d.ts +2 -0
  23. package/dist/core/skills.js +61 -0
  24. package/dist/core/slash.js +31 -18
  25. package/dist/core/title.js +4 -2
  26. package/dist/core/todo-tags.d.ts +4 -3
  27. package/dist/core/todo-tags.js +20 -6
  28. package/dist/core/workflow.d.ts +4 -13
  29. package/dist/core/workflow.js +6 -6
  30. package/dist/core/workflows/context.js +31 -59
  31. package/dist/core/workflows/runtime.js +10 -8
  32. package/dist/dap/index.d.ts +1 -0
  33. package/dist/dap/index.js +1 -0
  34. package/dist/dap/protocol.d.ts +8 -3
  35. package/dist/dap/protocol.js +54 -18
  36. package/dist/dap/session.js +71 -12
  37. package/dist/dap/spawn.d.ts +11 -0
  38. package/dist/dap/spawn.js +48 -0
  39. package/dist/dap/transport.js +18 -0
  40. package/dist/kanban/index.d.ts +1 -1
  41. package/dist/kanban/index.js +1 -1
  42. package/dist/kanban/store.d.ts +8 -1
  43. package/dist/kanban/store.js +61 -13
  44. package/dist/llm/protocols/openai-compat.d.ts +7 -0
  45. package/dist/llm/protocols/openai-compat.js +19 -2
  46. package/dist/llm/protocols/utils/tool-stream.d.ts +12 -3
  47. package/dist/llm/protocols/utils/tool-stream.js +47 -23
  48. package/dist/llm/provider.js +18 -3
  49. package/dist/llm/retry.d.ts +1 -0
  50. package/dist/llm/retry.js +20 -7
  51. package/dist/llm/routing.js +14 -2
  52. package/dist/llm/schema/messages.d.ts +9 -2
  53. package/dist/llm/token.d.ts +9 -0
  54. package/dist/llm/token.js +10 -6
  55. package/dist/llm/transport.js +7 -2
  56. package/dist/mcp/client.d.ts +11 -0
  57. package/dist/mcp/client.js +70 -0
  58. package/dist/mcp/index.d.ts +46 -0
  59. package/dist/mcp/index.js +106 -0
  60. package/dist/mcp/tool-adapter.d.ts +14 -0
  61. package/dist/mcp/tool-adapter.js +65 -0
  62. package/dist/mcp/transport.d.ts +35 -0
  63. package/dist/mcp/transport.js +243 -0
  64. package/dist/mcp/types.d.ts +55 -0
  65. package/dist/mcp/types.js +3 -0
  66. package/dist/plugins/init.d.ts +2 -0
  67. package/dist/plugins/init.js +10 -1
  68. package/dist/project/project.js +50 -3
  69. package/dist/project/resolve.d.ts +7 -0
  70. package/dist/project/resolve.js +52 -5
  71. package/dist/server/permission/interactive.js +9 -4
  72. package/dist/server/permission/store.js +8 -2
  73. package/dist/server/routes/auth.js +28 -13
  74. package/dist/server/routes/chat.js +92 -26
  75. package/dist/server/routes/config.js +38 -10
  76. package/dist/server/routes/files.js +75 -16
  77. package/dist/server/routes/filesystem.js +9 -2
  78. package/dist/server/routes/kanban.js +146 -9
  79. package/dist/server/routes/permissions.js +32 -0
  80. package/dist/server/routes/project.js +15 -6
  81. package/dist/server/routes/provider.js +10 -5
  82. package/dist/server/routes/session.js +72 -26
  83. package/dist/server/routes/terminal.js +10 -2
  84. package/dist/server/routes/todo.js +18 -0
  85. package/dist/server/routes/tool.js +13 -2
  86. package/dist/server/routes/update.js +5 -1
  87. package/dist/server/routes/workflows.js +17 -6
  88. package/dist/server/server.d.ts +1 -1
  89. package/dist/server/server.js +31 -17
  90. package/dist/server/terminal/pty-manager.d.ts +3 -0
  91. package/dist/server/terminal/pty-manager.js +8 -3
  92. package/dist/server/types.d.ts +2 -0
  93. package/dist/server/util/json-body.d.ts +31 -0
  94. package/dist/server/util/json-body.js +38 -0
  95. package/dist/session/archive.js +4 -4
  96. package/dist/session/branch.d.ts +10 -1
  97. package/dist/session/branch.js +54 -9
  98. package/dist/session/compaction.d.ts +5 -0
  99. package/dist/session/compaction.js +15 -10
  100. package/dist/session/context.d.ts +7 -3
  101. package/dist/session/context.js +35 -9
  102. package/dist/session/import.js +53 -6
  103. package/dist/session/index.d.ts +2 -2
  104. package/dist/session/index.js +2 -2
  105. package/dist/session/message.d.ts +12 -3
  106. package/dist/session/message.js +44 -4
  107. package/dist/session/session.d.ts +11 -5
  108. package/dist/session/session.js +99 -88
  109. package/dist/session/shake.js +35 -25
  110. package/dist/session/squash.js +3 -4
  111. package/dist/session/token.d.ts +7 -8
  112. package/dist/session/token.js +19 -16
  113. package/dist/session/usage.js +4 -2
  114. package/dist/shared/index.d.ts +1 -0
  115. package/dist/shared/index.js +1 -0
  116. package/dist/shared/round.d.ts +12 -0
  117. package/dist/shared/round.js +18 -0
  118. package/dist/shared/timer.d.ts +23 -0
  119. package/dist/shared/timer.js +27 -0
  120. package/dist/shared/types/tool.d.ts +4 -0
  121. package/dist/shared/utils/markdown-code.d.ts +15 -0
  122. package/dist/shared/utils/markdown-code.js +44 -0
  123. package/dist/shared/utils/path.d.ts +4 -1
  124. package/dist/shared/utils/path.js +5 -2
  125. package/dist/shared/utils/string.d.ts +5 -0
  126. package/dist/shared/utils/string.js +52 -0
  127. package/dist/tools/builtin/bash.js +79 -10
  128. package/dist/tools/builtin/edit.js +67 -8
  129. package/dist/tools/builtin/glob.js +150 -14
  130. package/dist/tools/builtin/grep.js +53 -11
  131. package/dist/tools/builtin/kanban.d.ts +14 -0
  132. package/dist/tools/builtin/kanban.js +4 -1
  133. package/dist/tools/builtin/read.js +15 -2
  134. package/dist/tools/builtin/resolvers.d.ts +9 -2
  135. package/dist/tools/builtin/resolvers.js +20 -6
  136. package/dist/tools/builtin/task.js +107 -25
  137. package/dist/tools/builtin/todo.js +84 -9
  138. package/dist/tools/builtin/write.js +7 -1
  139. package/dist/tools/builtin/yield.js +9 -1
  140. package/dist/tools/executor.js +53 -1
  141. package/dist/tools/hashline/patch.js +114 -38
  142. package/dist/tools/truncate.js +45 -16
  143. package/dist/tools/validate.js +66 -20
  144. package/dist/tools/websearch/index.js +8 -4
  145. package/dist/update/index.d.ts +1 -1
  146. package/dist/update/index.js +1 -1
  147. package/dist/update/scheduler.js +9 -2
  148. package/dist/update/snapshot.d.ts +14 -4
  149. package/dist/update/snapshot.js +22 -22
  150. package/dist/update/updater.d.ts +16 -2
  151. package/dist/update/updater.js +39 -8
  152. package/dist/update/version.d.ts +3 -1
  153. package/dist/update/version.js +63 -9
  154. package/package.json +1 -1
  155. package/dist/server/registry-config.js +0 -44
@@ -34,7 +34,9 @@ parentId,
34
34
  /** 会话工作目录(CLI 会话必填:Web 打开时 agent 工具在该目录执行,而非 serve cwd)。 */
35
35
  worktreePath,
36
36
  /** 初始 metadata(如工作流运行会话记录 workflowName,供中断恢复指引展示)。 */
37
- metadata) {
37
+ metadata,
38
+ /** 插件 hook runner:创建成功后广播 session:create(可选,纯广播不阻断)。 */
39
+ hooks) {
38
40
  const [row] = await handle.db
39
41
  .insert(sessions)
40
42
  .values({
@@ -49,7 +51,16 @@ metadata) {
49
51
  .returning();
50
52
  if (!row)
51
53
  throw new Error('Failed to insert session');
52
- return rowToSession(row);
54
+ const created = rowToSession(row);
55
+ if (hooks) {
56
+ try {
57
+ await hooks.fireHooks('session:create', { session: created });
58
+ }
59
+ catch {
60
+ // 广播失败绝不阻断会话创建(fireHooks 内部已吞 handler 异常,双保险)
61
+ }
62
+ }
63
+ return created;
53
64
  }
54
65
  /**
55
66
  * Web 会话树可见性条件:web 会话(source 为 null/web)或「持久的 CLI 会话」。
@@ -259,10 +270,18 @@ async function restoreSessionCore(handle, id, opts = {}) {
259
270
  // 祖先链(仅还原其中已软删除的节点):为保证恢复节点在会话树可达,
260
271
  // 已删除的祖先无论批次均需一并还原。记录还原的祖先数量与是否跨越删除批次,
261
272
  // 供前端提示「为保持会话树完整,同时还原了 N 个父会话」。
273
+ // 环守卫:parentId 成环数据(a↔b 互指/自引用,快照恢复或手改 DB 均可产生)
274
+ // 会让 while 循环在环上无限打转(每圈一条 DB 查询,恢复请求永不返回)——
275
+ // 已访问节点再次出现即终止,与 orderSessionsByParent/purgeDeletedSessions
276
+ // 的同型守卫一致。
262
277
  let restoredAncestorCount = 0;
263
278
  let crossedBatchAncestor = false;
264
279
  let parentId = row.parentId;
280
+ const visitedAncestors = new Set([id]);
265
281
  while (parentId) {
282
+ if (visitedAncestors.has(parentId))
283
+ break;
284
+ visitedAncestors.add(parentId);
266
285
  const [parent] = await handle.db.select().from(sessions).where(eq(sessions.id, parentId));
267
286
  if (!parent)
268
287
  break;
@@ -313,7 +332,8 @@ export const TRASH_PURGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
313
332
  * 回收站两阶段清理(启动时与每日定时调用):
314
333
  * 阶段一:进入保留期截止(首次看到 + 60 天)或绝对上限(删除 + 365 天,无论是否
315
334
  * 被看到)且未标记 → 标记 purgePendingAt(进入宽限期,可恢复)。
316
- * 阶段二:purgePendingAt 早于宽限截止 → 物理清除(子会话先于父,自引用 FK 要求)。
335
+ * 阶段二:purgePendingAt 早于宽限截止 → 物理清除(子会话先于父,自引用 FK 要求;
336
+ * 删除闭包含全部后代,未到期的子行随父行一并清除)。
317
337
  * 恢复后重删、尚未重新看到的会话按「首次看到 + 60 天」计(绝对上限兜底仍生效)。
318
338
  */
319
339
  async function purgeDeletedSessions(handle, retentionMs = TRASH_RETENTION_MS, graceMs = TRASH_PURGE_GRACE_MS) {
@@ -323,7 +343,6 @@ async function purgeDeletedSessions(handle, retentionMs = TRASH_RETENTION_MS, gr
323
343
  const rows = await handle.db
324
344
  .select({
325
345
  id: sessions.id,
326
- parentId: sessions.parentId,
327
346
  deletedAt: sessions.deletedAt,
328
347
  metadata: sessions.metadata,
329
348
  })
@@ -359,31 +378,11 @@ async function purgeDeletedSessions(handle, retentionMs = TRASH_RETENTION_MS, gr
359
378
  const p = meta.purgePendingAt;
360
379
  return typeof p === 'number' && p < graceCutoff;
361
380
  });
362
- let deleted = 0;
363
- if (pending.length > 0) {
364
- // 拓扑序:无子会话的先删
365
- const remaining = new Set(pending.map((r) => r.id));
366
- while (remaining.size > 0) {
367
- const hasChildParent = new Set(pending.filter((r) => r.parentId && remaining.has(r.parentId)).map((r) => r.parentId));
368
- const leaves = pending
369
- .filter((r) => remaining.has(r.id) && !hasChildParent.has(r.id))
370
- .map((r) => r.id);
371
- if (leaves.length === 0) {
372
- // 循环引用兜底:强制按 id 逐个删除(自引用环数据异常场景)
373
- for (const id of Array.from(remaining)) {
374
- await handle.db.delete(sessions).where(eq(sessions.id, id));
375
- remaining.delete(id);
376
- deleted += 1;
377
- }
378
- break;
379
- }
380
- for (const id of leaves) {
381
- await handle.db.delete(sessions).where(eq(sessions.id, id));
382
- remaining.delete(id);
383
- deleted += 1;
384
- }
385
- }
386
- }
381
+ if (pending.length === 0)
382
+ return { marked, deleted: 0 };
383
+ // 删除闭包含全部后代(子先父后)——pending 集合外的子行(未到期条目、跨项目
384
+ // 归属、未软删的 fork 后代)同样引用被删父行,漏掉即撞 FK 23503 让清理整体失败。
385
+ const deleted = await deleteSessionSubtrees(handle, pending.map((r) => r.id));
387
386
  return { marked, deleted };
388
387
  }
389
388
  /** C4:空会话 GC 保留期(7 天)。仅覆盖「从未写入任何消息/工具条目」的会话。 */
@@ -443,89 +442,101 @@ async function purgeEmptySession(handle, id) {
443
442
  return true;
444
443
  }
445
444
  /**
446
- * 彻底删除某个回收站会话及其全部后代(含未软删除的 fork 后代,防御数据异常)。
447
- * 子会话先于父会话删除(自引用 FK RESTRICT 要求);entries/archives 经 FK cascade 清理。
448
- * 返回删除数量。会话不存在或不在回收站 → 返回 0。
445
+ * 物理删除会话子树:rootIds + 其**全部后代**(不论删除状态与项目归属),子先父后。
446
+ * 返回实际删除行数。
447
+ *
448
+ * 自引用 FK(sessions.parentId,无 onDelete 级联)要求任何引用待删行的子行先删,
449
+ * 且子行可能不在调用方给定的清除集合内:跨项目归属(子会话经 rebind 留在另一
450
+ * 项目)、未到期/未进入宽限期的回收站条目、乃至未软删除的 fork 后代(数据异常,
451
+ * 与 permanentlyDeleteSession 既有防御同口径)。只按「待清除集合」排序会在这些
452
+ * 子行上撞 FK 23503——emptyTrash(projectId) 直接 500(该项目回收站永远清不掉)、
453
+ * purgeDeletedSessions 整体抛错(启动/每日清理静默失败,回收站永不清理)。
454
+ * 故删除闭包必须扩展到全部后代,集合外无人引用集合内行时断开父指针亦安全。
455
+ *
456
+ * 环数据(parentId 互指/自引用)无拓扑序可解:无叶可删时先断开集合内父指针
457
+ * (仅集合内引用,集合外不可能引用集合内行),再整体删除。
449
458
  */
450
- async function permanentlyDeleteSession(handle, id) {
451
- const [row] = await handle.db
452
- .select({ id: sessions.id, deletedAt: sessions.deletedAt })
453
- .from(sessions)
454
- .where(eq(sessions.id, id));
455
- if (!row?.deletedAt)
459
+ async function deleteSessionSubtrees(handle, rootIds) {
460
+ if (rootIds.length === 0)
456
461
  return 0;
457
462
  const all = await handle.db
458
463
  .select({ id: sessions.id, parentId: sessions.parentId })
459
464
  .from(sessions);
460
- const ids = new Set([id]);
461
- let frontier = [id];
465
+ const ids = new Set();
466
+ let frontier = [];
467
+ for (const id of rootIds) {
468
+ if (ids.has(id) || !all.some((r) => r.id === id))
469
+ continue;
470
+ ids.add(id);
471
+ frontier.push(id);
472
+ }
462
473
  while (frontier.length > 0) {
463
- const children = all
464
- .filter((r) => r.parentId && frontier.includes(r.parentId))
465
- .map((r) => r.id)
466
- .filter((cid) => !ids.has(cid));
467
- for (const cid of children)
468
- ids.add(cid);
469
- frontier = children;
474
+ const next = [];
475
+ for (const r of all) {
476
+ if (!r.parentId || ids.has(r.id) || !frontier.includes(r.parentId))
477
+ continue;
478
+ ids.add(r.id);
479
+ next.push(r.id);
480
+ }
481
+ frontier = next;
470
482
  }
471
- // 拓扑序:无子会话的先删(与 purgeDeletedSessions 同策略)
472
483
  const remaining = new Set(ids);
473
484
  let deleted = 0;
474
485
  while (remaining.size > 0) {
475
- const hasChildParent = new Set(Array.from(remaining).filter((rid) => {
476
- const r = all.find((x) => x.id === rid);
477
- return r?.parentId && remaining.has(r.parentId);
478
- }));
486
+ // 仍有子会话存活的父 id 集合(其子会话先删,避免自引用 FK 违反)。
487
+ const hasChildParent = new Set();
488
+ for (const r of all) {
489
+ if (r.parentId && remaining.has(r.parentId) && remaining.has(r.id)) {
490
+ hasChildParent.add(r.parentId);
491
+ }
492
+ }
479
493
  const leaves = Array.from(remaining).filter((rid) => !hasChildParent.has(rid));
480
494
  if (leaves.length === 0) {
481
- for (const rid of Array.from(remaining)) {
482
- await handle.db.delete(sessions).where(eq(sessions.id, rid));
483
- remaining.delete(rid);
484
- deleted += 1;
485
- }
495
+ // 环兜底:集合内成环(a↔b / 自引用),无叶可删——断开父指针后整体删除。
496
+ const cycleIds = Array.from(remaining);
497
+ await handle.db.update(sessions).set({ parentId: null }).where(inArray(sessions.id, cycleIds));
498
+ const rows = await handle.db
499
+ .delete(sessions)
500
+ .where(inArray(sessions.id, cycleIds))
501
+ .returning({ id: sessions.id });
502
+ deleted += rows.length;
486
503
  break;
487
504
  }
488
- for (const rid of leaves) {
489
- await handle.db.delete(sessions).where(eq(sessions.id, rid));
505
+ const rows = await handle.db
506
+ .delete(sessions)
507
+ .where(inArray(sessions.id, leaves))
508
+ .returning({ id: sessions.id });
509
+ deleted += rows.length;
510
+ for (const rid of leaves)
490
511
  remaining.delete(rid);
491
- deleted += 1;
492
- }
493
512
  }
494
513
  return deleted;
495
514
  }
496
- /** 清空回收站:物理删除所有已软删除会话(子先于父)。返回删除数量。
497
- * projectId 提供时仅清空该项目(P1-7)。 */
515
+ /**
516
+ * 彻底删除某个回收站会话及其全部后代(含未软删除的 fork 后代,防御数据异常)。
517
+ * 子会话先于父会话删除(自引用 FK RESTRICT 要求);entries/archives 经 FK cascade 清理。
518
+ * 返回删除数量。会话不存在或不在回收站 → 返回 0。
519
+ */
520
+ async function permanentlyDeleteSession(handle, id) {
521
+ const [row] = await handle.db
522
+ .select({ id: sessions.id, deletedAt: sessions.deletedAt })
523
+ .from(sessions)
524
+ .where(eq(sessions.id, id));
525
+ if (!row?.deletedAt)
526
+ return 0;
527
+ return deleteSessionSubtrees(handle, [id]);
528
+ }
529
+ /** 清空回收站:物理删除所有已软删除会话(子先于父,连同其全部后代)。
530
+ * projectId 提供时仅以该项目条目为根(P1-7)——后代闭包仍含其他项目的子行
531
+ * (它们引用被删父行,必须同批删除)。返回删除数量。 */
498
532
  async function emptyTrash(handle, projectId) {
499
533
  const rows = await handle.db
500
- .select({ id: sessions.id, parentId: sessions.parentId })
534
+ .select({ id: sessions.id })
501
535
  .from(sessions)
502
536
  .where(projectId
503
537
  ? and(gt(sessions.deletedAt, new Date(0)), eq(sessions.projectId, projectId))
504
538
  : gt(sessions.deletedAt, new Date(0)));
505
- if (rows.length === 0)
506
- return 0;
507
- const remaining = new Set(rows.map((r) => r.id));
508
- let deleted = 0;
509
- while (remaining.size > 0) {
510
- const hasChildParent = new Set(rows.filter((r) => r.parentId && remaining.has(r.parentId)).map((r) => r.parentId));
511
- const leaves = rows
512
- .filter((r) => remaining.has(r.id) && !hasChildParent.has(r.id))
513
- .map((r) => r.id);
514
- if (leaves.length === 0) {
515
- for (const id of Array.from(remaining)) {
516
- await handle.db.delete(sessions).where(eq(sessions.id, id));
517
- remaining.delete(id);
518
- deleted += 1;
519
- }
520
- break;
521
- }
522
- for (const id of leaves) {
523
- await handle.db.delete(sessions).where(eq(sessions.id, id));
524
- remaining.delete(id);
525
- deleted += 1;
526
- }
527
- }
528
- return deleted;
539
+ return deleteSessionSubtrees(handle, rows.map((r) => r.id));
529
540
  }
530
541
  /**
531
542
  * 清理过期临时会话(P2 → P1 收紧:仅处理显式标记的临时会话)。
@@ -1,4 +1,5 @@
1
- import { estimateTokens } from './token.js';
1
+ import { headChars } from '../shared/utils/string.js';
2
+ import { estimateMessageTokens, estimateTokens } from './token.js';
2
3
  /** Rough token cost of a placeholder line; used only for the savings gate. */
3
4
  const PLACEHOLDER_TOKEN_ESTIMATE = 16;
4
5
  /** 识别顶层 XML 元素(小写 tag,保守策略)。 */
@@ -20,26 +21,49 @@ export function scanTextForBlockRanges(text) {
20
21
  const ranges = [];
21
22
  let inFence = false;
22
23
  let fenceStart = -1;
24
+ // 开启围栏的 marker(字符 + 长度)。闭合必须同字符且长度 ≥ 开启长度
25
+ //(markdown 语义);异种/更短围栏行是块内内容——此前任意围栏行都切换
26
+ // inFence:``` 块内的 ~~~ 行把 range 拦腰截断、真实闭合行被当成新围栏
27
+ // 开端、后续顶层 XML 块被误吞。
28
+ let fenceChar = '';
29
+ let fenceLen = 0;
23
30
  const tagStack = [];
24
31
  let xmlStart = -1;
32
+ // CRLF 归一化:\r\n 文件的每行末尾携带 \r——XML 正则的 $ 锚定在 \r 上恒失败
33
+ // (顶层 XML 块永远不被识别),且 range 若含 \r,applyShakeRegions 替换后
34
+ // 该行变裸 \n,与其余 \r\n 行混排产出混合行尾。扫描行剥离 \r、range end
35
+ // 内收到 \r 之前,替换即无损。
36
+ const isCRLF = text.includes('\r\n');
25
37
  let lineStart = 0;
26
38
  for (let i = 0; i <= text.length; i++) {
27
39
  if (i !== text.length && text[i] !== '\n')
28
40
  continue;
29
- const line = text.slice(lineStart, i);
30
- const lineEnd = i;
41
+ let line = text.slice(lineStart, i);
42
+ let lineEnd = i;
43
+ if (isCRLF && line.endsWith('\r')) {
44
+ line = line.slice(0, -1);
45
+ lineEnd = i - 1;
46
+ }
31
47
  const trimmedStart = line.trimStart();
32
- const isFenceLine = trimmedStart.startsWith('```') || trimmedStart.startsWith('~~~');
33
- if (isFenceLine) {
48
+ const fenceMatch = /^(`{3,}|~{3,})/.exec(trimmedStart);
49
+ if (fenceMatch) {
50
+ const marker = fenceMatch[1] ?? '';
51
+ const markerChar = marker[0] ?? '';
52
+ const markerLen = marker.length;
34
53
  if (!inFence) {
35
54
  inFence = true;
36
55
  fenceStart = lineStart;
56
+ fenceChar = markerChar;
57
+ fenceLen = markerLen;
37
58
  }
38
- else {
59
+ else if (markerChar === fenceChar && markerLen >= fenceLen) {
39
60
  inFence = false;
40
61
  ranges.push({ start: fenceStart, end: lineEnd });
41
62
  fenceStart = -1;
63
+ fenceChar = '';
64
+ fenceLen = 0;
42
65
  }
66
+ // 围栏行(含块内异种/更短的围栏行)不参与 XML 检测
43
67
  lineStart = i + 1;
44
68
  continue;
45
69
  }
@@ -88,27 +112,13 @@ function mergeRanges(ranges) {
88
112
  }
89
113
  return kept;
90
114
  }
91
- /** 单条消息的 token 估算(优先用缓存的 tokenCount)。 */
115
+ /** 单条消息的 token 估算(优先用缓存的 tokenCount)。
116
+ * 委托 token.ts 的 estimateMessageTokens:此前本模块持有一份同型私有实现,
117
+ * 与权威实现同步漂移(image 部分两处同缺、恒计 0),收敛后口径单源。 */
92
118
  function messageTokens(m) {
93
119
  if (m.tokenCount > 0)
94
120
  return m.tokenCount;
95
- let total = 0;
96
- for (const part of m.content) {
97
- switch (part._tag) {
98
- case 'text':
99
- case 'thinking':
100
- case 'steering':
101
- total += estimateTokens(part.text);
102
- break;
103
- case 'tool_call':
104
- total += estimateTokens(JSON.stringify(part.input));
105
- break;
106
- case 'tool_result':
107
- total += estimateTokens(JSON.stringify(part.output));
108
- break;
109
- }
110
- }
111
- return total;
121
+ return estimateMessageTokens(m.content);
112
122
  }
113
123
  /** tool_result part 的输出文本。 */
114
124
  function toolResultText(output) {
@@ -294,7 +304,7 @@ export function toRegionViews(regions, config, messages) {
294
304
  partIndex: region.partIndex,
295
305
  tokens: region.tokens,
296
306
  label: region.label,
297
- preview: region.originalText.slice(0, 200),
307
+ preview: headChars(region.originalText, 200),
298
308
  placeholder: region.kind === 'toolResult'
299
309
  ? `[shaken: ${region.label}, ${region.tokens} tokens]`
300
310
  : '[shaken]',
@@ -1,7 +1,7 @@
1
1
  import { generateId } from '../shared/index.js';
2
2
  import { archiveOriginalEntries } from './archive.js';
3
3
  import { extractHotFiles } from './compaction.js';
4
- import { deleteEntriesByIds, getMessages, insertEntry } from './message.js';
4
+ import { deleteEntriesByIds, getMessages, insertEntry, summaryEntryTimestamp } from './message.js';
5
5
  import { upsertFileSnapshot } from './snapshot.js';
6
6
  import { estimateTokens } from './token.js';
7
7
  const DEFAULT_SQUASH_CONFIG = {
@@ -78,9 +78,8 @@ ${history}`;
78
78
  archiveId,
79
79
  },
80
80
  tokenCount: estimateTokens(summary),
81
- // Position at the first squashed message's timestamp so it sorts between
82
- // the prefix and the kept tail under createdAt-ascending order.
83
- createdAt: toSquash[0] ? new Date(toSquash[0].createdAt) : new Date(),
81
+ // 定位在首个被压缩消息处(并列时间戳时再早 1ms),保证摘要排在保留尾部之前。
82
+ createdAt: summaryEntryTimestamp(toSquash[0], keepTail[0]),
84
83
  });
85
84
  return { archiveId, fileSnapshotIds };
86
85
  });
@@ -1,10 +1,9 @@
1
+ import { estimateTokens } from '../llm/token.js';
1
2
  import type { MessageContent } from '../shared/types/message.js';
2
- /**
3
- * CJK-aware token estimate.
4
- * Chinese/CJK characters ≈ 2 tokens each (denser encoding).
5
- * Other characters ≈ 4 chars/token (standard heuristic).
6
- */
7
- declare const estimateTokens: (text: string) => number;
8
- /** Sum token estimates across all parts of a message's content array. */
3
+ export { estimateTokens };
4
+ /** Sum token estimates across all parts of a message's content array.
5
+ * 缺字段分片(import sanitizeContent 只校验 _tag,input/output 可能缺省)保守按
6
+ * 0 token 计:JSON.stringify(undefined) 返回 undefined(非字符串),直接传入
7
+ * estimateTokens 会抛 TypeError,把 agent run 整个击穿在预算拟合处。 */
9
8
  declare const estimateMessageTokens: (content: MessageContent[]) => number;
10
- export { estimateMessageTokens, estimateTokens };
9
+ export { estimateMessageTokens };
@@ -1,16 +1,14 @@
1
- /**
2
- * CJK-aware token estimate.
3
- * Chinese/CJK characters ≈ 2 tokens each (denser encoding).
4
- * Other characters ≈ 4 chars/token (standard heuristic).
5
- */
6
- const estimateTokens = (text) => {
7
- if (text.length === 0)
8
- return 0;
9
- const cjkCount = text.match(/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/g)?.length ?? 0;
10
- const otherCount = text.length - cjkCount;
11
- return Math.ceil(cjkCount * 2 + otherCount / 4);
12
- };
13
- /** Sum token estimates across all parts of a message's content array. */
1
+ import { estimateTokens } from '../llm/token.js';
2
+ export { estimateTokens };
3
+ /** 单张图片的固定 token 估算。
4
+ * provider 图片计费按像素维度而非字节(OpenAI high-detail 1024×1024 ≈ 1105),
5
+ * message 内容只携带 base64 数据、无尺寸信息——按字节长度估算会系统性偏差
6
+ * 数百倍(1MB 截图 ≈ 34 万「token」)。固定典型值保数量级正确。 */
7
+ const IMAGE_PART_TOKEN_ESTIMATE = 1100;
8
+ /** Sum token estimates across all parts of a message's content array.
9
+ * 缺字段分片(import sanitizeContent 只校验 _tag,input/output 可能缺省)保守按
10
+ * 0 token 计:JSON.stringify(undefined) 返回 undefined(非字符串),直接传入
11
+ * estimateTokens 会抛 TypeError,把 agent run 整个击穿在预算拟合处。 */
14
12
  const estimateMessageTokens = (content) => {
15
13
  let total = 0;
16
14
  for (const part of content) {
@@ -21,13 +19,18 @@ const estimateMessageTokens = (content) => {
21
19
  total += estimateTokens(part.text);
22
20
  break;
23
21
  case 'tool_call':
24
- total += estimateTokens(JSON.stringify(part.input));
22
+ total += part.input === undefined ? 0 : estimateTokens(JSON.stringify(part.input));
25
23
  break;
26
24
  case 'tool_result':
27
- total += estimateTokens(JSON.stringify(part.output));
25
+ total += part.output === undefined ? 0 : estimateTokens(JSON.stringify(part.output));
26
+ break;
27
+ case 'image':
28
+ // 图片轮次真实开销约千级 token——此前无 case 恒计 0,
29
+ // 预算护栏/压缩窗口把图片消息当免费。
30
+ total += IMAGE_PART_TOKEN_ESTIMATE;
28
31
  break;
29
32
  }
30
33
  }
31
34
  return total;
32
35
  };
33
- export { estimateMessageTokens, estimateTokens };
36
+ export { estimateMessageTokens };
@@ -11,6 +11,7 @@
11
11
  // 金额 + token 双口径超支判定(token 兜底价格未知的自建网关/未登记模型)。
12
12
  import { and, eq, gte } from 'drizzle-orm';
13
13
  import { appMeta, sessions, usageEvents } from '../db/schema.js';
14
+ import { roundTo } from '../shared/round.js';
14
15
  /**
15
16
  * 把 sessions.metadata.segments 中全部调用补齐进 usage_events(幂等)。
16
17
  * 覆盖:升级前历史数据、fork 复制的 segments(display 用途)、导入会话携带的
@@ -145,14 +146,15 @@ export async function budgetOverageParts(handle, usage, projectId, now = new Dat
145
146
  }
146
147
  const parts = [];
147
148
  // 实际超支轴中的最严格阻断动作:任一超支轴动作 === 'abort' 即整体 abort,否则 pause。
149
+ // 金额展示经 roundTo 十进制舍入(toFixed 二进制近似会把 1.005 显示成 1.00)。
148
150
  let action = 'pause';
149
151
  if (amountBlocks && globalBudgetUsd > 0 && global && global.cost > globalBudgetUsd) {
150
- parts.push(`全局预算 $${globalBudgetUsd.toFixed(2)}:本月全部项目已 $${global.cost.toFixed(2)}`);
152
+ parts.push(`全局预算 $${roundTo(globalBudgetUsd, 2).toFixed(2)}:本月全部项目已 $${roundTo(global.cost, 2).toFixed(2)}`);
151
153
  if (amountAction === 'abort')
152
154
  action = 'abort';
153
155
  }
154
156
  if (amountBlocks && projectBudgetUsd > 0 && project && project.cost > projectBudgetUsd) {
155
- parts.push(`项目预算 $${projectBudgetUsd.toFixed(2)}:本项目已 $${project.cost.toFixed(2)}`);
157
+ parts.push(`项目预算 $${roundTo(projectBudgetUsd, 2).toFixed(2)}:本项目已 $${roundTo(project.cost, 2).toFixed(2)}`);
156
158
  if (amountAction === 'abort')
157
159
  action = 'abort';
158
160
  }
@@ -1,2 +1,3 @@
1
+ export { MAX_TIMER_MS } from './timer.js';
1
2
  export type * from './types/index.js';
2
3
  export { generateId, now } from './utils.js';
@@ -1 +1,2 @@
1
+ export { MAX_TIMER_MS } from './timer.js';
1
2
  export { generateId, now } from './utils.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 按十进制语义舍入到指定小数位(half-up)。
3
+ *
4
+ * `x.toFixed(n)` 直接作用于二进制浮点近似值:1.005 在内存中是 1.004999…,
5
+ * toFixed 舍入为 "1.00"——少一分钱(0.015→"0.01"、2.675→"2.67" 同类)。
6
+ * Intl.NumberFormat 按最短十进制表示舍入,消除该系统性偏差;返回值仍为
7
+ * number(无分组符),调用方再以 toFixed 输出显示字符串。
8
+ *
9
+ * 无 node 依赖:web(vite tsconfig 无 node types)与服务端均可引用。
10
+ */
11
+ declare function roundTo(value: number, digits: number): number;
12
+ export { roundTo };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 按十进制语义舍入到指定小数位(half-up)。
3
+ *
4
+ * `x.toFixed(n)` 直接作用于二进制浮点近似值:1.005 在内存中是 1.004999…,
5
+ * toFixed 舍入为 "1.00"——少一分钱(0.015→"0.01"、2.675→"2.67" 同类)。
6
+ * Intl.NumberFormat 按最短十进制表示舍入,消除该系统性偏差;返回值仍为
7
+ * number(无分组符),调用方再以 toFixed 输出显示字符串。
8
+ *
9
+ * 无 node 依赖:web(vite tsconfig 无 node types)与服务端均可引用。
10
+ */
11
+ function roundTo(value, digits) {
12
+ return Number(new Intl.NumberFormat('en-US', {
13
+ minimumFractionDigits: digits,
14
+ maximumFractionDigits: digits,
15
+ useGrouping: false,
16
+ }).format(value));
17
+ }
18
+ export { roundTo };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Node timer 的 32 位安全上限:setTimeout/setInterval 的延迟超过
3
+ * 2^31-1(2147483647ms ≈ 24.8 天)会被引擎钳到 1ms(TimeoutOverflowWarning)。
4
+ *
5
+ * 任何把用户输入/配置/插件声明直接作为 timer 延迟的调用点都必须先经本常量
6
+ * 钳制或校验——否则「超长超时/超长间隔」会变成「立即触发」:命令刚 spawn 即
7
+ * 被杀、间隔变成每毫秒狂刷。同类值此前散落定义(llm/retry.ts 的
8
+ * RETRY_MAX_DELAY),现已收敛为单一来源。
9
+ */
10
+ export declare const MAX_TIMER_MS = 2147483647;
11
+ /**
12
+ * 归一化「用户输入/配置/插件声明」提供的 timer 时长(毫秒)。
13
+ *
14
+ * 引擎行为:非有限(NaN/±Infinity)或超 32 位上限的延迟都被钳到 1ms。
15
+ * 两种退化都必须拦截,否则语义反转——
16
+ * - 上界:写「超长超时/间隔」本意是「等很久/少检查」,实际立即触发/每毫秒狂刷;
17
+ * - 下界:0/负数(如「不检查」的配置写法)同样落到 1ms 轮询,比上界情形更糟。
18
+ *
19
+ * @param ms 外部提供的时长;undefined 视为未设置
20
+ * @param fallback 非法值(非有限或 < minInclusive)的回落值
21
+ * @param minInclusive 合法下界(一次性延迟可为 0=立即;间隔类传 1)
22
+ */
23
+ export declare function clampTimerDelay(ms: number | undefined, fallback: number, minInclusive?: number): number;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Node timer 的 32 位安全上限:setTimeout/setInterval 的延迟超过
3
+ * 2^31-1(2147483647ms ≈ 24.8 天)会被引擎钳到 1ms(TimeoutOverflowWarning)。
4
+ *
5
+ * 任何把用户输入/配置/插件声明直接作为 timer 延迟的调用点都必须先经本常量
6
+ * 钳制或校验——否则「超长超时/超长间隔」会变成「立即触发」:命令刚 spawn 即
7
+ * 被杀、间隔变成每毫秒狂刷。同类值此前散落定义(llm/retry.ts 的
8
+ * RETRY_MAX_DELAY),现已收敛为单一来源。
9
+ */
10
+ export const MAX_TIMER_MS = 2_147_483_647;
11
+ /**
12
+ * 归一化「用户输入/配置/插件声明」提供的 timer 时长(毫秒)。
13
+ *
14
+ * 引擎行为:非有限(NaN/±Infinity)或超 32 位上限的延迟都被钳到 1ms。
15
+ * 两种退化都必须拦截,否则语义反转——
16
+ * - 上界:写「超长超时/间隔」本意是「等很久/少检查」,实际立即触发/每毫秒狂刷;
17
+ * - 下界:0/负数(如「不检查」的配置写法)同样落到 1ms 轮询,比上界情形更糟。
18
+ *
19
+ * @param ms 外部提供的时长;undefined 视为未设置
20
+ * @param fallback 非法值(非有限或 < minInclusive)的回落值
21
+ * @param minInclusive 合法下界(一次性延迟可为 0=立即;间隔类传 1)
22
+ */
23
+ export function clampTimerDelay(ms, fallback, minInclusive = 0) {
24
+ if (ms === undefined || !Number.isFinite(ms) || ms < minInclusive)
25
+ return fallback;
26
+ return Math.min(ms, MAX_TIMER_MS);
27
+ }
@@ -69,6 +69,10 @@ type ToolContext = {
69
69
  /** Spawn a sub-agent and return its final text output (dependency-reversal hook
70
70
  * for the `task` tool; avoids tools→core circular import). */
71
71
  runSubAgent?: (input: SubAgentRequest) => Promise<SubAgentResult>;
72
+ /** 批量派发子 agent(task 工具的批量模式)。宿主负责并发池(按
73
+ * config.agents.subagentConcurrency)与逐任务错误隔离,结果与入参顺序一一对应;
74
+ * 未注入时 task 工具回退逐个调用 runSubAgent(串行)。 */
75
+ runSubAgents?: (input: SubAgentRequest[]) => Promise<SubAgentResult[]>;
72
76
  /** Spawn a debug adapter and return its stdio transport (dependency-reversal
73
77
  * hook for the `debug_*` tools; host wires real child_process spawn). */
74
78
  debugSpawn?: (config: unknown) => DebugTransport;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Markdown 代码剥离:围栏代码块与行内代码一律移除,供「只该命中人类文字」的
3
+ * prose 感知检测共用(workflowz 关键词、<todo:*> 标签、composer 的 @agent
4
+ * 提及提取等)。
5
+ *
6
+ * 此前各检测点自带 ad-hoc 剥离(```...``` 非贪婪配对 + `[^`]*` 单反引号)——
7
+ * 未闭合围栏、~~~ 围栏、长反引号围栏、多反引号行内代码全部漏网:代码内容上的
8
+ * 关键词/标签/提及被误触发(工作流通告注入 / todo 状态幽灵变更 / 误派发子 agent)。
9
+ *
10
+ * 语义(保守方向——宁可多剥,不可在代码上误判):
11
+ * - 围栏开启 = 行首(≤3 空格缩进)+ ≥3 个同字符反引号/波浪线;
12
+ * 闭合 = 同字符、长度 ≥ 开启、行内仅剩空白;未闭合围栏吞掉余文。
13
+ * - 行内代码 = 等长反引号对(``x`` 内可含单反引号,CommonMark 口径)。
14
+ */
15
+ export declare function stripMarkdownCode(text: string): string;