@thincoder/core 0.9.1 → 0.9.3

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 (75) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/advisor/loop.mjs +2 -2
  5. package/advisor/run.mjs +1 -1
  6. package/agent/completion.mjs +3 -1
  7. package/agent/family-tools.mjs +24 -11
  8. package/agent/helpers.mjs +11 -2
  9. package/agent/run-stages.mjs +27 -5
  10. package/agent/setup-reminders.mjs +67 -11
  11. package/agent/setup.mjs +6 -0
  12. package/agent/write-gate.mjs +5 -5
  13. package/agent-tools/advisor-async.mjs +4 -4
  14. package/agent-tools/advisor.mjs +3 -3
  15. package/agent-tools/async-discard.mjs +1 -1
  16. package/agent-tools/audit-block.mjs +106 -0
  17. package/agent-tools/batch-lifecycle.mjs +301 -0
  18. package/agent-tools/batch-segment.mjs +16 -263
  19. package/agent-tools/batch-skeleton.mjs +156 -0
  20. package/agent-tools/batch.mjs +410 -0
  21. package/agent-tools/context.mjs +174 -0
  22. package/agent-tools/eng.mjs +4 -0
  23. package/agent-tools/goal.mjs +7 -0
  24. package/agent-tools/parent-channel.mjs +18 -1
  25. package/agent-tools/plan.mjs +39 -5
  26. package/agent-tools/read-history.mjs +122 -24
  27. package/agent-tools/settings.mjs +4 -2
  28. package/agent-tools/subagent-async.mjs +3 -3
  29. package/agent-tools/subagent-spawn.mjs +29 -101
  30. package/agent-tools/task.mjs +11 -0
  31. package/agent-tools.mjs +9 -2
  32. package/agent.mjs +10 -4
  33. package/config.mjs +1 -1
  34. package/context.mjs +66 -121
  35. package/fts-text.mjs +41 -0
  36. package/generate-title.mjs +6 -6
  37. package/i18n.mjs +4 -4
  38. package/ledger-cmd.mjs +30 -7
  39. package/ledger-db.mjs +22 -2
  40. package/ledger-executors.mjs +103 -0
  41. package/ledger-surface.mjs +15 -8
  42. package/ledger.mjs +15 -4
  43. package/manifest.mjs +172 -60
  44. package/memory/core.mjs +4 -18
  45. package/memory/schema.mjs +4 -11
  46. package/package.json +22 -1
  47. package/prompts/advisor-design.md +1 -1
  48. package/prompts/advisor-round2.md +1 -1
  49. package/prompts/advisor-round3.md +1 -1
  50. package/prompts/common.md +3 -3
  51. package/prompts/discipline-engineering.md +17 -2
  52. package/prompts/persona-eng-coder.md +1 -1
  53. package/prompts/persona-eng-designer.md +1 -1
  54. package/prompts/persona-engineering.md +9 -6
  55. package/session-gc.mjs +129 -74
  56. package/session-index-build.mjs +298 -0
  57. package/session-index-cmd.mjs +61 -0
  58. package/session-index-pass.mjs +95 -0
  59. package/session-index-query.mjs +102 -0
  60. package/session-index.mjs +285 -0
  61. package/session-lifecycle.mjs +18 -5
  62. package/session-slots-manifest.mjs +55 -3
  63. package/session-stale.mjs +247 -0
  64. package/token-window.mjs +188 -0
  65. package/tools/bash.mjs +4 -15
  66. package/tools/execute.mjs +5 -13
  67. package/tools/git-checkpoint.mjs +1 -1
  68. package/tools/git-ext.mjs +23 -20
  69. package/tools/git-run.mjs +141 -0
  70. package/tools/git.mjs +56 -45
  71. package/tools/index.mjs +3 -1
  72. package/tools/process-tree.mjs +20 -0
  73. package/tools/shared.mjs +8 -4
  74. package/traces/trace-cleanup.mjs +109 -0
  75. package/traces/trace-store.mjs +32 -36
package/agent.mjs CHANGED
@@ -26,8 +26,9 @@ import {
26
26
  DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
27
27
  MIN_REPORT_CHARS, REPORT_CONTINUATION,
28
28
  AUTO_TURN_DIGEST_DOMAIN,
29
+ AUTO_TURN_DIGEST_DOMAIN_ENG, // §6.15.3(F10 第三面):工程模式 digest 基座变体(task 指针改批次档 + 台账)
29
30
  UPSTREAM_TURN_DOMAIN, // §6.27.12.8:上行唤醒轮域文本(手动档——ask 轮不沿用 digest 域文本)
30
- restoreGuard, // §17 D-S6 读侧单点(P2 机制层端差批 §2.18——键清单归核)
31
+ restoreGuard, // digest D-S6 读侧单点(AGENT-LOOP-ASYNC-POOL.md §6.8;P2 机制层端差批 §2.18——键清单归核)
31
32
  } from "./agent/helpers.mjs"
32
33
  // ENG 提醒族 + auto-turn domain 2026-09-05 迁 agent/helpers.mjs(agent.mjs 530 > 500 硬限)
33
34
  // PROMPT-SYSTEM 施工② G1(2026-09-10):六件槽位常量装载收口 prompt-overlays.mjs
@@ -159,13 +160,15 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
159
160
  agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
160
161
  agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
161
162
  agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
163
+ // F-CC2(§6.16.2):模型主动压缩的排队槽也是回合级——上一回合末尾未被安全点消费的请求不得跨回合生效
164
+ agent._pendingCompact = null
162
165
  }
163
166
  }
164
- // §17 D-S6 manual tier: digest action-domain reminder (system-driven turn — organize only).
167
+ // digest D-S6 manual tier(AGENT-LOOP-ASYNC-POOL.md §6.8): action-domain reminder (system-driven turn — organize only).
165
168
  // §6.27.12.4 ②: an up-stream wake turn answers a RUNNING subagent waiting for the reply — it
166
169
  // must not reuse the digest text ("no one is waiting" is the opposite of the truth).
167
170
  if ((autoTurn || upstreamTurn) && !agent.autoApprove) {
168
- agent.history.push({ role: "user", content: upstreamTurn ? UPSTREAM_TURN_DOMAIN : AUTO_TURN_DIGEST_DOMAIN, transient: true })
171
+ agent.history.push({ role: "user", content: upstreamTurn ? UPSTREAM_TURN_DOMAIN : (agent.config?.agent?.engineering === true ? AUTO_TURN_DIGEST_DOMAIN_ENG : AUTO_TURN_DIGEST_DOMAIN), transient: true })
169
172
  }
170
173
  // eng-coder authorization (_engDesignReviewed) is eng-coder-only: set by subagent-spawn.mjs
171
174
  // (spawn gate) / design-token.mjs (design review pass) BEFORE the child runAgent — the
@@ -192,8 +195,11 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
192
195
  // context.mjs compressIfNeeded 经 extras 透出到 logCtx)
193
196
  traceDepth: depth,
194
197
  }
198
+ // F-CC1(§6.16.4 阈值单源接线):本回合压缩判定所用的阈值 + 固定开销面暂存——
199
+ // `context` 工具的 stats 报**同一口径**(不自行重算第二口径;VSC checkAndCompact 同款暂存)。
200
+ agent._ctxBasis = { threshold, overhead: compactionOverhead }
195
201
 
196
- // SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-SUBAGENT.md §6.27.4 消费点):子 → 父在飞消息的
202
+ // SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-UPSTREAM.md §6.27.4 消费点):子 → 父在飞消息的
197
203
  // 回合边界注入单点取用一次(模块缓存 ⇒ 每 run 一次代价);动态 import = 零新增静态边
198
204
  // (先例 = 上方 injectAsyncResult :113-117)。
199
205
  const { drainChildUpstream } = await import("./agent-tools/parent-channel.mjs")
package/config.mjs CHANGED
@@ -49,7 +49,7 @@ export const DEFAULTS = {
49
49
  advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
50
50
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
51
51
  engineering: false, // strict methodology enforcement — design-before-code (design review + user approval before code)
52
- // Async pool limits (AGENT-LOOP-SUBAGENT.md §6.10 D-24a/R14 + R13 — POOL-CONFIG-
52
+ // Async pool limits (AGENT-LOOP-ASYNC-POOL.md §6.10 D-24a/R14 + R13 — POOL-CONFIG-
53
53
  // UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
54
54
  // pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
55
55
  // 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
package/context.mjs CHANGED
@@ -7,56 +7,13 @@
7
7
  * typically a COMPLETED earlier task; preserving them verbatim anchored the model's attention on stale
8
8
  * work after compaction. The earliest messages now go into the summary (which distinguishes completed
9
9
  * vs in-progress work), so the post-compaction context anchors on the current task (recent tail) only.
10
+ *
11
+ * 2026-09-21(context-tool 批 · CONTEXT-COMPACTION.md §6.16.8):计量 / 尾族 / 切分三族**逐字迁出** `token-window.mjs`(495 → ≈428——硬限 500 内);`estimateTokens` 经本档**再导出**保既有 import 面;新增面 = 模型主动压缩接线(`force` / `focus` + 焦点块 + anchor)与 prune 应用面。
10
12
  */
11
13
 
12
14
  import { chat } from "./provider/index.mjs"
13
- import { estimateText } from "./provider/rate.mjs"
14
- import { providerSpec } from "./config.mjs"
15
15
  import { buildCompressMessages } from "./compress-form.mjs"
16
-
17
- const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
18
-
19
- /** Rough token count for a list of messages (body + reasoning + tool_calls params) */
20
- export function estimateTokens(messages) {
21
- let tokens = 0
22
- for (const m of messages) {
23
- if (typeof m.content === "string") tokens += estimateText(m.content)
24
- else if (Array.isArray(m.content)) {
25
- for (const part of m.content) {
26
- if (part.type === "text") tokens += estimateText(part.text)
27
- else if (part.type === "image_url") tokens += IMAGE_TOKEN_ESTIMATE
28
- }
29
- }
30
- if (typeof m.reasoning_content === "string") tokens += estimateText(m.reasoning_content)
31
- for (const tc of m.tool_calls ?? []) {
32
- tokens += estimateText(tc.function?.name ?? "") + estimateText(tc.function?.arguments ?? "")
33
- }
34
- }
35
- return tokens
36
- }
37
-
38
- const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED earlier task in multi-task
39
- // sessions — keeping them verbatim anchored attention on stale work. Everything before the tail is
40
- // summarized (the summary itself distinguishes completed vs in-progress work; see SUMMARIZE_PROMPT).
41
- // Tail count formula (D4): window-adaptive (~30 msgs per 100K — old fixed 10 too thin on 1M), capped
42
- // at 40% of history; §6.4④ D-T1/D-T2 make the count only a CANDIDATE — a token budget (TAIL_BUDGET_FRACTION
43
- // × window − SUMMARY_TOKEN_ESTIMATE ≈1K, §6.9) tightens it over pair-safe boundaries when compaction runs,
44
- // never below TAIL_FLOOR_MESSAGES; ordinary sessions never reach it (D-T4: trigger 0.6 untouched).
45
- const TAIL_BUDGET_FRACTION = 0.15
46
- const SUMMARY_TOKEN_ESTIMATE = 1000 // §6.9: summary output target ~1K tokens — reserved from the 15%
47
- const TAIL_FLOOR_MESSAGES = 10 // §6.4④ D-T2: the tail keeps ≥10 verbatim messages — floor beats budget
48
- function keepTailSize(provider, historyLen) {
49
- // provider is guaranteed at every call site (runAgent always builds one); providerSpec
50
- // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
51
- // because the 40% history cap still bounds the tail. providers[].context override
52
- // (K units) is honored here (PROVIDER.md §6.15 T-C2: tail formula follows the window).
53
- const ctxWindow = providerSpec(provider).context
54
- return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
55
- }
56
- // §6.4④ D-T1 tail token budget: window×15% − summary ~1K — the compressed history segment (summary + placeholder + tail) lands ≈ 15% (B 口径 §6.4④).
57
- function tailBudgetTokens(provider) {
58
- return Math.max(0, Math.floor(providerSpec(provider).context * TAIL_BUDGET_FRACTION) - SUMMARY_TOKEN_ESTIMATE)
59
- }
16
+ import { contextUsage, estimateTokens, collectStaleToolOutputs, keepTailSize, splitHistory, tailBudgetTokens } from "./token-window.mjs"
60
17
 
61
18
  export const SUMMARIZE_PROMPT = `The conversation above is our work log so far — summarize it into a compact summary for use as context in the ongoing conversation.
62
19
  Requirements:
@@ -93,72 +50,25 @@ const FALLBACK_NOTE =
93
50
  "Re-verify any state you need with tools before relying on it.]\n\n"
94
51
 
95
52
  /**
96
- * Split history into head / middle (to be summarized) / tail; return null if no middle to compress.
97
- * head is normally empty (KEEP_HEAD = 0 earliest messages go into the summary); the tool_calls-extension logic below is defensive for future KEEP_HEAD > 0.
98
- * The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle, the summary swallows it, leaving orphan tool results → protocol 400.
99
- * `budgetTokens` (optional, §6.4④ D-T1): when the candidate's estimate exceeds it, the boundary moves
100
- * forward until the tail fits — never below the D-T2 floor (10 msgs, or the candidate itself when
101
- * the 40% cap made it < 10 — short history).
53
+ * focus 指令块(F-CC2 · §6.16.2):模型主动压缩时追加在摘要指令**尾段**——摘要按它加权取舍。`anchor` = task/goal 状态行;
54
+ * `anchor == null`(无 task 且无 goal)⇒ **anchor 段省略**,focus 正文恒保留(不产空标题)。
102
55
  */
103
- function splitHistory(history, keepTail, budgetTokens = null) {
104
- if (history.length <= KEEP_HEAD + keepTail + 1) return null
105
- let headEnd = KEEP_HEAD
106
- // head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
107
- // Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
108
- if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
109
- while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
110
- }
111
- const candidate = repairedTailStart(history, headEnd, history.length - keepTail)
112
- if (candidate <= headEnd) return null
113
- let tailStart = candidate
114
- // §6.4④ D-T1: tighten only above the floor — a candidate ≤ 10 IS the floor (short history under the 40% cap must not tighten further, review #5); the floor is D5-repaired too.
115
- if (budgetTokens > 0 && keepTail > TAIL_FLOOR_MESSAGES) {
116
- const floor = repairedTailStart(history, headEnd, history.length - TAIL_FLOOR_MESSAGES)
117
- if (floor > candidate) tailStart = tightenTailByBudget(history, candidate, floor, budgetTokens)
118
- }
119
- return { headEnd, tailStart }
120
- }
56
+ const focusBlock = (focus, anchor) =>
57
+ `\n\nThis compaction happens at my own request and is weighted toward the work coming next:\n${focus}\n\n` +
58
+ `Keep what that work needs at full fidelity — files, decisions, constraints, open threads; compress ` +
59
+ `everything else harder.` +
60
+ (anchor ? ` Current task/goal state (attached automatically):\n${anchor}` : "")
121
61
 
122
62
  /**
123
- * D5 tail-side pairing repair for a raw cut at history.length − tailCount: pull into the tail any
124
- * assistant whose tool results are in the tail (the summary swallowing the owner leaves orphan tool
125
- * results → protocol 400), then skip orphan tool messages at the new boundary. Single-assistant
126
- * assumption (nearest owner only — a tail spans at most one assistant→tools cycle); bounds-guarded.
63
+ * anchor 取值(§6.16.2「自动附任务 / 目标(F-CC2 明文)」):取值源 = **工具面单源**——`task` / `goal` 工具写入的
64
+ * `agent.tasks` / `agent.goal`,不新增第二份状态;行格式沿用既有任务重注入形态(`- [status] title` + goal 一行)。两者皆空 null(anchor 段整体省略)。
127
65
  */
128
- function repairedTailStart(history, headEnd, tailStart) {
129
- const tailToolIds = new Set()
130
- for (let i = tailStart; i < history.length; i++) {
131
- if (history[i].role === "tool") tailToolIds.add(history[i].tool_call_id)
132
- }
133
- for (let i = tailStart - 1; i > headEnd; i--) {
134
- const m = history[i]
135
- if (m.role === "assistant" && m.tool_calls?.some((tc) => tailToolIds.has(tc.id))) {
136
- tailStart = i
137
- break
138
- }
139
- }
140
- while (tailStart < history.length && tailStart > headEnd && history[tailStart].role === "tool") {
141
- tailStart++
142
- }
143
- return tailStart
144
- }
145
-
146
- /**
147
- * §6.4④ D-T1 budget tightening (pair-safe, review #2): walk the boundary FORWARD (fewer tail messages —
148
- * the rest joins the summary) while the tail's estimated tokens exceed the budget. Only pair-safe
149
- * positions may stop the walk: a boundary ON a tool message would orphan its owner assistant into the
150
- * middle (D5); pairing is contiguous in the machine line (§6.4③) — every non-tool boundary is safe.
151
- * No fit before the floor → keep the floor, accept the overrun.
152
- */
153
- function tightenTailByBudget(history, start, floorStart, budgetTokens) {
154
- const suffixTokens = new Array(history.length + 1)
155
- suffixTokens[history.length] = 0
156
- for (let i = history.length - 1; i >= 0; i--) suffixTokens[i] = suffixTokens[i + 1] + estimateTokens([history[i]])
157
- if (suffixTokens[start] <= budgetTokens) return start // already fits — ordinary sessions stay untouched (D-T2)
158
- for (let p = start + 1; p <= floorStart; p++) { // first fit keeps the most recent verbatim context
159
- if (history[p].role !== "tool" && suffixTokens[p] <= budgetTokens) return p
160
- }
161
- return floorStart
66
+ function anchorText(agent) {
67
+ const lines = []
68
+ const g = agent?.goal
69
+ if (g?.objective) lines.push(`- goal [${g.status}] ${g.objective}${g.criteria ? ` — done when: ${g.criteria}` : ""}`)
70
+ for (const t of agent?.tasks ?? []) lines.push(`- [${t.status}] ${t.title}`)
71
+ return lines.length > 0 ? lines.join("\n") : null
162
72
  }
163
73
 
164
74
  /**
@@ -359,21 +269,16 @@ function applyCompression(agent, headEnd, tailStart, note) {
359
269
  * generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
360
270
  * internal mechanism, not a model reply); onCompressStart fires right before the summary call
361
271
  * (§6.8 D-C1, compression lifecycle visibility — panel start state)
362
- * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
363
- * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
272
+ * @param {object} extras - { systemPrompt?, tools?, force?, focus? } — 固定开销面(system + tools 估计)+
273
+ * 模型主动压缩面(§6.16.2):`force` **只跳过** `tokens <= threshold` 早退,其余全同;`focus` 追加在摘要指令尾段。
364
274
  */
365
275
  export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
366
276
  const history = agent.history
367
- // Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
368
- // Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
369
- const overhead =
370
- (extras.systemPrompt ? estimateText(extras.systemPrompt) : 0) +
371
- (extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0)
372
- const tokens =
373
- agent._lastPromptTokens != null
374
- ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
375
- : estimateTokens(history) + overhead
376
- if (tokens <= threshold) return false
277
+ // 单源(§6.16.4):total / overhead stats 面同一函数(既有内联式改调 contextUsage,判定语义零改)
278
+ const usage = contextUsage(agent, extras)
279
+ const tokens = usage.total
280
+ const overhead = usage.overhead
281
+ if (!extras.force && tokens <= threshold) return false
377
282
 
378
283
  const keepTail = keepTailSize(agent.provider, history.length)
379
284
  const split = splitHistory(history, keepTail, tailBudgetTokens(agent.provider))
@@ -398,8 +303,15 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
398
303
  // the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
399
304
  callbacks?.onCompressStart?.({ messages: middle.length })
400
305
  const startedAt = performance.now()
306
+ // F-CC2 焦点块(§6.16.2):仅模型主动面携带——追加在**指令末条尾段**(`SUMMARIZE_PROMPT` 文本零改,
307
+ // 只追加焦点块;无 focus ⇒ 请求体逐字节同修前——compress-form.test.mjs 零回归)。
308
+ const messages = buildCompressMessages(history, split.tailStart, extras?.systemPrompt, SUMMARIZE_PROMPT)
309
+ if (extras.focus) {
310
+ const last = messages.at(-1)
311
+ messages[messages.length - 1] = { ...last, content: last.content + focusBlock(String(extras.focus), anchorText(agent)) }
312
+ }
401
313
  const summary = await chat({ ...agent.provider, thinking: null }, {
402
- messages: buildCompressMessages(history, split.tailStart, extras?.systemPrompt, SUMMARIZE_PROMPT),
314
+ messages,
403
315
  tools: extras?.tools,
404
316
  signal,
405
317
  // §18.6 D-TR4:轨迹元数据增补——kind=compress(上下文构建面——agent 元数据透出;
@@ -430,6 +342,37 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
430
342
  return true
431
343
  }
432
344
 
345
+ /** prune stub 逐字(§6.16.3):只给「已清理 + 原长度 + 重跑路径」——prune 是**删**不是摘要,不声称可复原。 */
346
+ const pruneStub = (chars) => `[pruned: stale tool output dropped (${chars} chars) — re-run the tool if you need it again.]`
347
+
348
+ /**
349
+ * 陈旧工具输出清理(F-CC3 · §6.16.3):合格集 = `token-window.mjs` 单源(保护尾外 ∧ `role:"tool"` ∧ ≥ 门槛);
350
+ * 命中项**原位换「内容」**(`history[i] = { ...m, content: stub }`——数组引用 / 长度 / 索引 / `tool_call_id` 全不变
351
+ * ⇒ 配对**结构上不可能被拆**)。记录面零改(copy-on-write:消息对象与人读线共享);基线失效同 `shrinkOversized` 先例。
352
+ * @returns {{pruned:number, freed:number, candidates:number, tailKept:number, belowMin:number}}
353
+ */
354
+ export function pruneStaleToolOutputs(agent) {
355
+ const history = agent.history
356
+ const stale = collectStaleToolOutputs(history, agent.provider)
357
+ const counts = {
358
+ pruned: stale.indexes.length,
359
+ freed: stale.tokens,
360
+ candidates: stale.candidates,
361
+ tailKept: stale.tailKept,
362
+ belowMin: stale.belowMin,
363
+ }
364
+ if (counts.pruned === 0) return counts
365
+ for (const i of stale.indexes) {
366
+ const m = history[i]
367
+ // 多模态 tool 结果(content 数组)整体替换为 stub ⇒ 图像 part 丢弃(不可再取——prune 是删;回执只给重跑路径)
368
+ const chars = typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length
369
+ history[i] = { ...m, content: pruneStub(chars) }
370
+ }
371
+ agent._lastPromptTokens = null
372
+ agent._usageAtLen = null
373
+ return counts
374
+ }
375
+
433
376
  /**
434
377
  * Deterministic truncation fallback: called when the summary LLM fails repeatedly, no network call.
435
378
  * Drops the middle so the task can continue. Returns whether truncation happened.
@@ -488,6 +431,8 @@ function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
488
431
  return shrunk
489
432
  }
490
433
 
434
+ // ─── 迁出面(import 面保持:TUI / verify-compress / VSC 对拍经本档取——先例 = 下方 explore-distill 再导出)──
435
+ export { estimateTokens }
491
436
  // ─── End-of-run exploration distillation(2026-09-05 module-split:524 > 500 硬限——verbatim
492
437
  // 迁至 explore-distill.mjs,语义零变——VS Code compact.mjs 同款联动;cross-repo parity 锚改指
493
438
  // explore-distill.mjs——消费方 import 面不变(re-export))───────────────────────
package/fts-text.mjs ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * fts-text.mjs — FTS5 语言面单一来源(会话索引批 · SESSION.md §6.19 D-SE44)。
3
+ *
4
+ * `segmentCJK` / `buildFtsQuery` 自 `memory/schema.mjs` / `memory/core.mjs` **外提**为本叶子档
5
+ * (两档 re-export 保名面——零行为变更):理由 = 会话索引不得把 memory 模块链拉进
6
+ * `read_history` 的装配装载面(W8 契约②)+ 机制单源(D2——写入侧与查询侧同一处理)。
7
+ * 消费者 = memory 面(写入 / 检索)+ 会话索引面(`session-index.mjs` / `session-index-build.mjs`)。
8
+ *
9
+ * 零项目内依赖(仅 `node:`)——叶子档,可被任意装配面静态 import。
10
+ */
11
+
12
+ /** 查询词元上限(超限截断——`buildFtsQuery` 单源)。 */
13
+ export const FTS_TOKEN_MAX = 16
14
+
15
+ /**
16
+ * CJK 逐字间隔:让 unicode61 把汉字 / 假名 / 谚文逐字当作独立 token。
17
+ * 写入与查询两侧必须用同一处理才可召回(两字词如「分号」→「分 号」短语仍命中;ASCII 保持整词)。
18
+ */
19
+ export function segmentCJK(text) {
20
+ return text.replace(
21
+ /[぀-ヿ㐀-䶿一-鿿豈-﫿가-힯]+/g,
22
+ (run) => [...run].join(" "),
23
+ )
24
+ }
25
+
26
+ /**
27
+ * 构造 FTS5 查询:先按空白 / 标点切 token,再对每个 token 做 CJK 逐字分段。
28
+ * 多字 CJK 词保持为 FTS5 短语(「分号」→「分 号」→ 短语查询,邻接精确匹配),
29
+ * 不同 token 以 OR 连接(「命名 规范」→「命 名」OR「规 范」,各自短语要求自身邻接)。
30
+ * 无 token ⇒ 返回空串(消费侧据此退化——SESSION.md §6.19 边界情形表)。
31
+ */
32
+ export function buildFtsQuery(query) {
33
+ const terms = query
34
+ .split(/[\s,,。、;;!!??()()"`]+/)
35
+ .map((t) => t.trim())
36
+ .filter(Boolean)
37
+ .slice(0, FTS_TOKEN_MAX)
38
+ .map((t) => segmentCJK(t))
39
+ if (terms.length === 0) return ""
40
+ return terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(" OR ")
41
+ }
@@ -9,6 +9,9 @@
9
9
  */
10
10
 
11
11
  import { proxyFetch } from "./proxy.mjs"
12
+ // D7(2026-09-21 块标题行对齐批——标题链单源):首条真实 user 消息谓词收核单源
13
+ // (`isRealUserMsg`——纯函数零依赖,无环)。
14
+ import { isRealUserMsg } from "./history-window.mjs"
12
15
 
13
16
  // Test seam (_-prefix, mirrors run.mjs seams): lets the proxy-branch regression
14
17
  // test swap the proxy fetch. The branch it exercises used to carry a dynamic
@@ -105,13 +108,10 @@ export async function ensureSessionTitle(agent) {
105
108
  if (agent.title) return agent.title
106
109
  try {
107
110
  // TUI-OOM-ROOTCAUSE 批(SESSION.md §6.14):绑定态首条 user 消息在记录存储(段 1)——
108
- // 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找。
111
+ // 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找
112
+ // (谓词单源 = `isRealUserMsg`——与 VSC 端壳同判据)。
109
113
  let firstUser = agent._recordStore?.firstUserMessage?.() ?? null
110
- if (!firstUser) {
111
- firstUser = (agent._fullHistory ?? agent.history).find(
112
- (m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
113
- )
114
- }
114
+ if (!firstUser) firstUser = (agent._fullHistory ?? agent.history).find(isRealUserMsg)
115
115
  if (firstUser) {
116
116
  const title = await generateTitle(firstUser.content, agent.provider)
117
117
  if (title) agent.title = title
package/i18n.mjs CHANGED
@@ -35,10 +35,10 @@ export const CORE_MESSAGES = Object.freeze({
35
35
  "digest.done": { en: "Digested ${n} background report(s) (${seconds}s)", zh: "已消化 ${n} 份后台报告(${seconds}s)" },
36
36
  "digest.aborted": { en: "Digestion interrupted (${seconds}s)", zh: "消化中断(${seconds}s)" },
37
37
  "digest.turnLabel": { en: "[auto-turn: digesting finished subagent reports…]", zh: "自动回合:消化已完成的子代理报告…" },
38
- // M4(2026-09-20 端差·显示面消差批):起跑标签三档(en = CLI `suspension-drive.mjs:175-176`
39
- // 逐字;zh = 与上方既有档同式译法)——VSC 本地档不重复定义(单一权威容器)。
40
- "digest.turnLabelAsk": { en: "[auto-turn: answering a subagent's in-flight message…]", zh: "自动回合:答复子代理的在飞提问…" },
41
- "digest.turnLabelAuto": { en: "[auto-turn: continuing background work…]", zh: "自动回合:继续后台推进…" },
38
+ // M4(2026-09-20 端差·显示面消差批)+ F-UC8(2026-09-21 信号提示行批 · §6.27.12.13 ①–②):起跑标签
39
+ // **两档**——ask 档携参「谁 + 啥」(`${from}` / `${msg}`——核单点 `upstreamAskLabelVars`)/ digest 档(下键);
40
+ // manual / AUTO **同判**(`auto` 泛句无生产者 键退场);VSC 本地档不重复定义(单一权威容器)。
41
+ "digest.turnLabelAsk": { en: "[auto-turn: answering ${from}: ${msg}]", zh: "自动回合:答复 ${from}:${msg}" },
42
42
  "digest.capAuto": { en: "[auto-turn: continuing past turn cap…]", zh: "自动回合:越过轮次上限,继续推进…" },
43
43
  "digest.capStop": { en: "[auto-turn stopped at ${turns} turns — partial digest; finished reports stay in history]", zh: "自动回合在 ${turns} 轮处停止——部分消化;已完成的报告保留在历史中" },
44
44
  // ── 限流 / 供应商状态行 ──
package/ledger-cmd.mjs CHANGED
@@ -37,7 +37,7 @@ export function ledgerCount({ cwd } = {}) {
37
37
  /** 路径 = 存在的**档**?(目录 / 缺失 → false——写门存在性判据用)。 */
38
38
  const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
39
39
 
40
- /** 写门·指针存在性(设计档 §6.1 · 台账 #38 · AC-M2-8):写命令落盘前判**结果行**——`status ∈
40
+ /** 写门·指针存在性(设计档 §6.1 · 台账 #38 · AC-M2-9):写命令落盘前判**结果行**——`status ∈
41
41
  * {在途, 待核销}` 且 `task_book` 非空 ⇒ 文件部分(首个 `§` 前子串,trim)须经基准 `resolve(base, …)`
42
42
  * 指向存在的档;缺文件部分(`§2` / 全空白)⇒ 拒;不在册 / 非文件 ⇒ 拒(throw,行不变)。
43
43
  * 判位与迁移表判同层(落盘前);文案前缀 = 调用函数名(与同函数既有两条文案同款)。
@@ -67,8 +67,22 @@ export function ledgerAdd({ cwd, row }) {
67
67
  } finally { db.close() }
68
68
  }
69
69
 
70
- /** 更新(写命令,仅主 agent):UPDATE——状态迁移前判允许迁移表(不在表内拒,行不变)。 */
71
- export function ledgerUpdate({ cwd, id, patch }) {
70
+ /** executor 目标值计算(设计档 docs/core/design/LEDGER.md §3.1——判位在 ledgerUpdate 体内:迁移表判 本语义 → 写门 → UPDATE)。
71
+ * 优先级 = patch 显式 > 自动语义(进在途 = executorSessionId / 出在途 = NULL)> 行现值兜底 > NULL。 */
72
+ function resolveExecutorTarget(row, to, patch, executorSessionId) {
73
+ if (row.status === "待设计" && to === "在途") {
74
+ return patch.executor ?? executorSessionId ?? row.executor ?? null
75
+ }
76
+ if (row.status === "在途" && (to === "待核销" || to === "已废弃")) {
77
+ return null // 出边自动语义压 patch——显式传 executor 也不复活
78
+ }
79
+ return patch.executor ?? row.executor ?? null
80
+ }
81
+
82
+ /** 更新(写命令,仅主 agent):UPDATE——状态迁移前判允许迁移表(不在表内 → 拒,行不变)。
83
+ * executor 目标值随同一 UPDATE 落列(LEDGER.md §3.1);executorSessionId = 调用会话(函数参数注入——K-LX1
84
+ * 纯函数可测;工具层动态 import getSessionId() 供值,零新静态边)。 */
85
+ export function ledgerUpdate({ cwd, id, patch, executorSessionId }) {
72
86
  const db = openLedger(cwd, { create: true })
73
87
  try {
74
88
  const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
@@ -79,9 +93,10 @@ export function ledgerUpdate({ cwd, id, patch }) {
79
93
  }
80
94
  const nextTaskBook = patch.task_book ?? row.task_book
81
95
  assertTaskBookGate(cwd, "ledgerUpdate", to, nextTaskBook) // 判序:迁移表判 → 本门 → UPDATE
96
+ const nextExecutor = resolveExecutorTarget(row, to, patch, executorSessionId)
82
97
  const now = nowIso()
83
- db.prepare(`UPDATE items SET status = ?, title = ?, board = ?, req_doc = ?, task_book = ?, evidence = ?, trigger = ?, updated_at = ? WHERE id = ?`)
84
- .run(to, patch.title ?? row.title, patch.board ?? row.board, patch.req_doc ?? row.req_doc, nextTaskBook, patch.evidence ?? row.evidence, patch.trigger ?? row.trigger, now, id)
98
+ db.prepare(`UPDATE items SET status = ?, title = ?, board = ?, req_doc = ?, task_book = ?, evidence = ?, trigger = ?, executor = ?, updated_at = ? WHERE id = ?`)
99
+ .run(to, patch.title ?? row.title, patch.board ?? row.board, patch.req_doc ?? row.req_doc, nextTaskBook, patch.evidence ?? row.evidence, patch.trigger ?? row.trigger, nextExecutor, now, id)
85
100
  return { id }
86
101
  } finally { db.close() }
87
102
  }
@@ -97,7 +112,8 @@ export function ledgerClose({ cwd, id, status }) {
97
112
  if (!row) throw new Error(`ledgerClose:行 ${id} 不存在`)
98
113
  if (status === "已核销" && row.status !== "待核销") throw new Error(`ledgerClose:勾销仅限待核销(现态 ${row.status})`)
99
114
  const now = nowIso()
100
- db.prepare("UPDATE items SET status = ?, closed_at = ?, updated_at = ? WHERE id = ?").run(status, now, now, id)
115
+ // 撤回(任意态 已废弃——含在途直撤)同步 executor = NULL(LEDGER.md §3.1 ④);勾销路径 executor 已在离场迁移清空,零触碰
116
+ db.prepare("UPDATE items SET status = ?, closed_at = ?, updated_at = ?, executor = ? WHERE id = ?").run(status, now, now, status === "已废弃" ? null : row.executor, id)
101
117
  db.exec("COMMIT")
102
118
  return { id, status }
103
119
  } catch (e) { db.exec("ROLLBACK"); throw e }
@@ -181,12 +197,19 @@ export const ledgerUpdateTool = {
181
197
  task_book: { type: "string", description: "任务书指针(缺省 = 不变)" },
182
198
  evidence: { type: "string", description: "证据(缺省 = 不变)" },
183
199
  trigger: { type: "string", enum: ["归批", "条件", "认账不排期"], description: "触发(缺省 = 不变)" },
200
+ executor: { type: "string", description: "执行者 sessionId(可选——接手改写归属用;缺省 = 按状态迁移语义:进在途自动写本会话 / 出在途自动清空 / 其余不变)" },
184
201
  },
185
202
  },
186
203
  readonly: false,
187
204
  async execute(args, ctx) {
188
205
  const { cwd, id, ...patch } = args
189
- return JSON.stringify(ledgerUpdate({ cwd: cwd ?? cwdOf(ctx), id, patch }))
206
+ let executorSessionId
207
+ if (patch.executor === undefined) {
208
+ // 动态 import——零新静态边(K-LX1);工具层供值,核心函数保持参数注入纯函数
209
+ const { getSessionId } = await import("./session-slots.mjs")
210
+ executorSessionId = getSessionId()
211
+ }
212
+ return JSON.stringify(ledgerUpdate({ cwd: cwd ?? cwdOf(ctx), id, patch, executorSessionId }))
190
213
  },
191
214
  }
192
215
 
package/ledger-db.mjs CHANGED
@@ -57,13 +57,30 @@ CREATE TABLE IF NOT EXISTS items (
57
57
  task_book TEXT,
58
58
  evidence TEXT,
59
59
  trigger TEXT CHECK(trigger IN ('归批','条件','认账不排期') OR trigger IS NULL),
60
+ executor TEXT,
60
61
  created_at TEXT,
61
62
  updated_at TEXT,
62
63
  closed_at TEXT,
63
64
  CHECK (status NOT IN ('在途','待核销') OR (task_book IS NOT NULL AND task_book <> ''))
64
65
  )`
65
66
 
66
- /** 开库(cwd = 项目根——仅作关联键)→ DatabaseSync 句柄 + 幂等建表。
67
+ /** 老库幂等迁移:items 表缺 executor 列(旧 DDL 建的库)→ ALTER 补列。
68
+ * 判据 = PRAGMA table_info 实查(不猜版本号);ALTER duplicate column 竞争 → 复核列在吞 / 不在抛真错。
69
+ * 列存在性谓词抽成单元(param exists),便于并发竞争拍直测。K-LX5:老库零删改、零重建。 */
70
+ export function ensureExecutorColumn(db, { exists } = {}) {
71
+ const columnExists = exists
72
+ ?? (() => db.prepare("SELECT 1 FROM pragma_table_info('items') WHERE name = 'executor'").get() !== undefined)
73
+ if (columnExists()) return false
74
+ try {
75
+ db.exec("ALTER TABLE items ADD COLUMN executor TEXT")
76
+ return true
77
+ } catch (e) {
78
+ if (columnExists()) return false // 并发竞争——另一连接已加列:吞
79
+ throw new Error(`台账库 executor 列迁移失败:${e.message}`)
80
+ }
81
+ }
82
+
83
+ /** 开库(cwd = 项目根——仅作关联键)→ DatabaseSync 句柄 + 幂等建表 + 老库幂等迁移(executor 列)。
67
84
  * 读面(create=false):库文件不存在 → 返回 null(空账——读面不建库、无副作用)。
68
85
  * 写面(create=true):库文件不存在自动建(台账目录随之创建);项目根目录不存在 → 抛友好错误。 */
69
86
  export function openLedger(cwd, { create = false } = {}) {
@@ -75,7 +92,10 @@ export function openLedger(cwd, { create = false } = {}) {
75
92
  if (!existsSync(file) && !create) return null
76
93
  try { mkdirSync(ledgerDir, { recursive: true }) } catch { /* 已存在 / 并发建目录竞争——忽略 */ }
77
94
  const db = new DatabaseSync(file)
78
- try { db.exec(DDL) } catch (e) {
95
+ try {
96
+ db.exec(DDL)
97
+ ensureExecutorColumn(db)
98
+ } catch (e) {
79
99
  db.close()
80
100
  throw new Error(`台账库打开失败:${file}(${e.message})`)
81
101
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * ledger-executors.mjs — 在途 executor 判活展示(F-LX1 · 设计档 LEDGER.md §7.3.1)。
3
+ *
4
+ * 红线:写径零探测——判活只落显示面。本档从 ledger.mjs 拆出(2026-09-21 executor 判活批,
5
+ * ledger.mjs 307 → ≤300 软线回落),语义零变:`resolveExecutorStates` async 单源 + `executorTail`
6
+ * 三态文案(双端同文——CLI L2 与 VSC tooltip 共此函数)+ TTL 缓存(与 peer-instances 同源口径)。
7
+ */
8
+ import { classifyEnd, ownerState, probeOwnersAsync } from "./process-probe.mjs"
9
+ import { PEER_PROBE_TTL_MS } from "./peer-instances.mjs"
10
+
11
+ /** 判活解析 TTL 缓存(模块级——与 peer-instances `PEER_PROBE_TTL_MS` 同源口径;TTL 内重复解析零 exec)。
12
+ * 键 = pid;值 = { probedAt, state, end }。`_setExecutorProbeTtlForTest` = 测试缝(裁定 #8——测试间不串;用后恢复)。 */
13
+ const executorProbeCache = new Map()
14
+ let _executorProbeTtlOverride = null
15
+
16
+ /** 测试缝:覆盖 TTL 毫秒数(0 = 禁缓存恒重探);返回前值,测试 finally 恢复。 */
17
+ export function _setExecutorProbeTtlForTest(ms) {
18
+ const prev = _executorProbeTtlOverride
19
+ _executorProbeTtlOverride = ms
20
+ executorProbeCache.clear()
21
+ return prev
22
+ }
23
+
24
+ const executorTtlMs = () => _executorProbeTtlOverride ?? PEER_PROBE_TTL_MS
25
+
26
+ /** sessionId → pid(首段整数——peer-instances `groupSlotSessions` 同款解析;不可解析 ⇒ null→unknown)。 */
27
+ function executorPid(sessionId) {
28
+ const n = Number.parseInt(String(sessionId).split("-")[0], 10)
29
+ return Number.isFinite(n) && n > 0 ? n : null
30
+ }
31
+
32
+ /** 行时间戳 → 距今天数(向下取整;缺 → null——「N 天未动」标注源,零新状态)。 */
33
+ function staleDaysOf(ts, now) {
34
+ if (ts == null) return null
35
+ const ms = Date.parse(ts)
36
+ if (!Number.isFinite(ms)) return null
37
+ return Math.floor(Math.max(0, now - ms) / 86400000)
38
+ }
39
+
40
+ /** 在途 executor 判活批量解析(async 单源——LEDGER.md §7.3.1):收集各 scan 的 `inflightExecutors`
41
+ * → pid 去重 → **一次 `probeOwnersAsync` 束**(≤1 判活 + ≤1 cmdline——禁止逐行 exec)→ 逐 executor
42
+ * `ownerState` → 每 scan 挂 `executors: [{executor, pid, end, state, staleDays}]` + `deadExecutors: n`。
43
+ * `end` = cmdline 可得时的 `classifyEnd` 值;`staleDays` = 该行 `updated_at ?? created_at` 距今天数。
44
+ * TTL 缓存按 pid(5s 同源口径);**无在途 executor ⇒ 零 exec 快返**(不探、不缓存)。
45
+ * 写径零探测——本函数仅供显示面调用(§7.3.1 红线)。 */
46
+ export async function resolveExecutorStates(scans, { now = Date.now() } = {}) {
47
+ const list = Array.isArray(scans) ? scans : []
48
+ const entries = []
49
+ for (const s of list) {
50
+ for (const e of Array.isArray(s?.inflightExecutors) ? s.inflightExecutors : []) entries.push({ scan: s, ...e })
51
+ }
52
+ // 每 scan 先挂空形(无在途 ⇒ 空数组 + 0——不触缓存不探测)
53
+ for (const s of list) { s.executors = []; s.deadExecutors = 0 }
54
+ if (entries.length === 0) return list
55
+
56
+ const pids = new Map() // pid → sessionId(去重——同 pid 多条只探一次)
57
+ for (const e of entries) {
58
+ const pid = executorPid(e.executor)
59
+ if (pid !== null && !pids.has(pid)) pids.set(pid, e.executor)
60
+ }
61
+
62
+ // TTL 命中/未命中分流:未命中的 pid 集成一次批量探测
63
+ const ttl = executorTtlMs()
64
+ const need = [...pids.keys()].filter((pid) => {
65
+ const hit = executorProbeCache.get(pid)
66
+ return !hit || now - hit.probedAt >= ttl
67
+ })
68
+ const bundle = need.length > 0 ? await probeOwnersAsync(need) : { aliveSet: null, cmds: null }
69
+ for (const pid of need) {
70
+ const cmdline = typeof bundle?.cmds?.get?.(pid) === "string" ? bundle.cmds.get(pid) : undefined
71
+ executorProbeCache.set(pid, {
72
+ probedAt: now,
73
+ state: ownerState(pid, bundle),
74
+ end: cmdline ? classifyEnd(cmdline) : undefined,
75
+ })
76
+ }
77
+ const deadByScan = new Map()
78
+ for (const e of entries) {
79
+ const pid = executorPid(e.executor)
80
+ const hit = pid !== null ? executorProbeCache.get(pid) : null
81
+ const state = hit ? hit.state : "unknown" // pid 不可解析 ⇒ unknown(不判死——保守)
82
+ const end = hit ? hit.end : undefined
83
+ const staleDays = staleDaysOf(e.updated_at ?? e.created_at, now)
84
+ e.scan.executors.push({ executor: e.executor, pid, end, state, staleDays })
85
+ if (state === "dead") deadByScan.set(e.scan, (deadByScan.get(e.scan) ?? 0) + 1)
86
+ }
87
+ for (const [scan, n] of deadByScan) scan.deadExecutors = n
88
+ return list
89
+ }
90
+
91
+ /** 判活尾段(三态文案单源——LEDGER.md §7.3.1;双端同文——CLI L2 与 VSC tooltip 共此函数):
92
+ * dead>0 → `(属主已死 <n>,可接手)`(dead 优先);死 0 · 在途 executor >0 · 最长 staleDays ≥1 →
93
+ * `(执行中 <n> · 最长 <d> 天未动)`;死 0 · staleDays <1 或 null → `(执行中 <n>)`;
94
+ * 在途 executor =0 → 空串(既有文案零破)。 */
95
+ export function executorTail(scan) {
96
+ const exs = Array.isArray(scan?.executors) ? scan.executors : []
97
+ if (exs.length === 0) return ""
98
+ const dead = exs.filter((x) => x.state === "dead").length
99
+ if (dead > 0) return `(属主已死 ${dead},可接手)`
100
+ const maxStale = Math.max(...exs.map((x) => (x.staleDays == null ? 0 : x.staleDays)))
101
+ if (maxStale >= 1) return `(执行中 ${exs.length} · 最长 ${maxStale} 天未动)`
102
+ return `(执行中 ${exs.length})`
103
+ }