@a9i5k4/dsh-auto-memory 2.2.3 → 2.2.5

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.
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - 路由:/api/dsh-auto-memory/{state,list,file,recall,config,reflect}(loopback-only)
18
18
  */
19
19
 
20
- import { readFile, writeFile, mkdir, readdir, stat, rm, copyFile, appendFile } from 'node:fs/promises'
20
+ import { readFile, writeFile, mkdir, readdir, stat, rm, copyFile, appendFile, rename } from 'node:fs/promises'
21
21
  import { createReadStream, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
22
22
  import { exec as cpExec } from 'node:child_process'
23
23
  import { promisify } from 'node:util'
@@ -43,6 +43,8 @@ import { createMemoryHubPre } from './memory-hub.js'
43
43
  import { pickConsolidationTextPre } from './intent-clean.js'
44
44
  import { createStorageManagerPre } from './storage-manage.js'
45
45
  import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
46
+ import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames } from './subagent-gc.js'
47
+ import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre } from './water-window.js'
46
48
  import { buildSourceCatalog, CorpusRegistry, canonicalize } from './m4-corpus.js'
47
49
  import * as nodeZlib from 'node:zlib'
48
50
  import { AsyncLocalStorage } from 'node:async_hooks'
@@ -159,6 +161,7 @@ export const API = {
159
161
  'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
160
162
  'handoff-state': '/api/dsh-auto-memory/handoff-state',
161
163
  'handoff-continue': '/api/dsh-auto-memory/handoff-continue',
164
+ 'subagent-gc': '/api/dsh-auto-memory/subagent-gc',
162
165
  'semantic-download': '/api/dsh-auto-memory/semantic-download',
163
166
  'semantic-emit': '/api/dsh-auto-memory/semantic-emit',
164
167
  'shadow-recent': '/api/dsh-auto-memory/shadow-recent',
@@ -218,12 +221,31 @@ const DEFAULT_CONFIG = {
218
221
  handoffLedgerChars: 800,
219
222
  /** M-CM4 水位感知:窗口 token 数。0=自动——从 settings.yaml 的 agent-default-model 解析对应模型的 contextWindow(失败回退 131072);手动设值即覆盖自动检测。 */
220
223
  waterLevelWindowTokens: 0,
221
- /** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。 */
222
- waterLevelThreshold: 0.8,
224
+ /** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。
225
+ * 2026-09-08 由 0.8 下调到 0.75:harness 官方自动压缩阈值是 80%,阈值贴着 80% 会在交接动作完成前先被官方压缩掉,
226
+ * 留 5% 余量(约 1M 窗口的 50K token)才来得及走完「写账本 → 建新会话 → 注入材料」。 */
227
+ waterLevelThreshold: 0.75,
223
228
  /** 水位建议注入(无人值守时始终静默)。 */
224
229
  waterLevelAdvisory: true,
225
230
  /** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
226
231
  waterLevelAutoHandoff: true,
232
+ /** 自动接续(默认开):水位≥阈值 且 harness 权威 running==false(轮次边界,取代旧"两轮水位持平")时弹确认卡。 */
233
+ autoContinueEnabled: true,
234
+ /** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
235
+ autoContinueThreshold: 0.75,
236
+ /** 确认卡倒计时秒数(30-40s 无操作 = 挂机 → 自动接续兜底)。 */
237
+ autoContinueConfirmSeconds: 35,
238
+ /** 接续前刷新仪式(默认开):先让旧 Agent 刷新 PLAN.md + 交接账本,host 再用最新材料组装交接。 */
239
+ autoContinueRefreshRitual: true,
240
+ /** 刷新仪式等待上限(秒);超时用现有材料 fail-soft 继续接续。 */
241
+ autoContinueRefreshTimeoutSeconds: 90,
242
+ /** 自动接续冷却(分钟):一次接续后多久内不再触发。 */
243
+ autoContinueCooldownMinutes: 30,
244
+ /** 子代理痕迹回收(2026-09-08,默认开):本插件 spawn 的一次性子代理(auto-memory-*)在任务结束后立即把其会话目录与投影缓存移入备份目录。
245
+ * 背景:实测全机 686 个子代理会话中 638 个来自本插件,数量上千后拖慢会话列表加载。 */
246
+ subagentGcEnabled: true,
247
+ /** 子代理痕迹兜底 GC 保留天数:超过该天数仍残留的本插件一次性痕迹会在每日巡检时回收(0=不按时间、只靠任务结束即删)。 */
248
+ subagentGcKeepDays: 3,
227
249
  /** 暂离阈值(分钟):距上次活动超过该值视为暂离,回归时自动弹出记忆窗口并欢迎。默认 60。 */
228
250
  awayMinutes: 60,
229
251
  /** 无人值守/托管模式(默认关):面向无人值守批量任务。开启后剥离所有"会话性/行为性"注入——
@@ -1623,14 +1645,20 @@ class MemoryEngine {
1623
1645
  }
1624
1646
 
1625
1647
  /** M-CM4·自动窗口:agent-default-model(provider+model)→ settings.yaml 对应模型的 contextWindow;失败回退保守值。60s 缓存。 */
1626
- async resolveWaterWindow() {
1648
+ /** M-CM4·自动窗口:会话真实模型(request/header 的 provider/model,优先)→ agent-default-model(兜底)→ settings.yaml 对应模型的 contextWindow;失败回退保守值。60s 缓存。 */
1649
+ async resolveWaterWindow(providerOverride = '', modelOverride = '') {
1627
1650
  try {
1628
1651
  const override = Number(this.config.waterLevelWindowTokens) || 0
1629
1652
  if (override > 0) return { window: override, source: 'manual' }
1630
1653
  const now = Date.now()
1631
- if (this._waterWindowCache && now - this._waterWindowCache.at < 60000) return this._waterWindowCache.value
1654
+ const cacheKey = String(providerOverride || '') + '/' + String(modelOverride || '')
1655
+ if (this._waterWindowCache && now - this._waterWindowCache.at < 60000 && this._waterWindowCache.key === cacheKey) return this._waterWindowCache.value
1632
1656
  const text = await this.readTextSafe(path.join(dshHome(), 'settings.yaml'))
1633
- let value = null, model = null, provider = null
1657
+ let value = 0
1658
+ let model = String(modelOverride || '')
1659
+ let provider = String(providerOverride || '')
1660
+ let defModel = ''
1661
+ let defProvider = ''
1634
1662
  if (text) {
1635
1663
  const lines = text.split(/\r?\n/)
1636
1664
  const ai = lines.findIndex((l) => /^agent-default-model:/.test(l))
@@ -1638,55 +1666,118 @@ class MemoryEngine {
1638
1666
  for (let i = ai + 1; i < Math.min(lines.length, ai + 6); i++) {
1639
1667
  const m = lines[i].match(/^ {2}(provider|model): *(.+?) *$/)
1640
1668
  if (!m) break
1641
- if (m[1] === 'provider') provider = m[2].trim()
1642
- if (m[1] === 'model') { model = m[2].trim(); break }
1669
+ if (m[1] === 'provider') defProvider = m[2].trim()
1670
+ if (m[1] === 'model') { defModel = m[2].trim(); break }
1643
1671
  }
1644
1672
  }
1645
- if (model) {
1646
- const windowsByRoute = {}
1647
- let currentProvider = ''
1648
- let currentId = null
1649
- for (let i = 0; i < lines.length; i++) {
1650
- const pm = lines[i].match(/^ {4}([^ #][^:]*): *$/)
1651
- if (pm) { currentProvider = pm[1]; currentId = null; continue }
1652
- const im = lines[i].match(/^ *- id: *(.+?) *$/)
1653
- if (im) { currentId = im[1]; continue }
1654
- const cm = lines[i].match(/^ +contextWindow: *([0-9]+) *$/)
1655
- if (cm && currentId) { const route = currentProvider + '/' + currentId; if (windowsByRoute[route] === undefined) windowsByRoute[route] = Number(cm[1]) }
1656
- }
1657
- if (windowsByRoute[(provider || '') + '/' + model] !== undefined) value = windowsByRoute[provider + '/' + model]
1658
- else { for (const route of Object.keys(windowsByRoute)) if (route.endsWith('/' + model)) { value = windowsByRoute[route]; break } }
1673
+ // 2.2.4 修复:settings.yaml 的 llm-deepseek 段是 flow 风格 YAML(`{ models: [ { id: x, contextWindow: 1000000, … } ] }`),
1674
+ // 旧解析器只认 block 风格 → 整段解析失败、窗口退化为 fallback。改为纯函数解析(block + flow 双支持)。
1675
+ const windows = parseModelWindowsPre(text)
1676
+ // 2026-09-08:优先按「会话自己的模型」查窗口——用户在 GUI 切过模型后,agent-default-model 与新会话默认值
1677
+ // 并不等于当前会话实际模型(实测会话跑 deepseek-official/deepseek-v4.1-flash,默认模型却是 opencode-go/deepseek-v4-flash)。
1678
+ const lookupProvider = provider || defProvider
1679
+ const lookupModel = model || defModel
1680
+ if (lookupModel) value = pickWindowPre(windows, lookupProvider, lookupModel)
1681
+ if (value > 0) { provider = lookupProvider; model = lookupModel }
1682
+ else if (defModel && defModel !== lookupModel) {
1683
+ const alt = pickWindowPre(windows, defProvider, defModel)
1684
+ if (alt > 0) { value = alt; provider = defProvider; model = defModel }
1659
1685
  }
1660
1686
  }
1661
- const result = value !== null
1687
+ const result = value > 0
1662
1688
  ? { window: value, source: 'auto' + (provider ? ':' + provider : '') + (model ? '/' + model : ''), model: model || '' }
1663
- : { window: 131072, source: 'fallback', model: model || '' }
1664
- this._waterWindowCache = { at: now, value: result }
1689
+ : { window: 131072, source: 'fallback', model: model || defModel || '' }
1690
+ this._waterWindowCache = { at: now, key: cacheKey, value: result }
1665
1691
  return result
1666
1692
  } catch (e) { return { window: 131072, source: 'fallback', error: String(e && e.message) } }
1667
1693
  }
1668
1694
 
1695
+ /** 会话级水位记录(2026-09-08):水位此前是**全局单值**,只在有 agent 轮次时更新;
1696
+ * 于是切到别的会话时,卡片显示的是「上一个会话的数」或重启后的 0,且模型名只能拿默认模型凑。
1697
+ * 现在:①checkWaterLevel 每次测量写入 per-session 表;②查询时按 sessionId 取;
1698
+ * ③没有记录就从该会话日志(磁盘)推导窗口与模型——切会话即可刷新,不需要先发消息。 */
1699
+ rememberWaterRecord(sid, rec) {
1700
+ if (!sid || !rec) return
1701
+ if (!this._waterBySession) this._waterBySession = new Map()
1702
+ this._waterBySession.set(this.waterKey(sid), rec)
1703
+ if (this._waterBySession.size > 40) {
1704
+ const first = this._waterBySession.keys().next()
1705
+ if (!first.done) this._waterBySession.delete(first.value)
1706
+ }
1707
+ }
1708
+
1709
+ /** 会话 id 归一化:agent.session.id 与 GUI 快照 current 可能带/不带 `session-` 前缀,统一去掉后作键。 */
1710
+ waterKey(sid) {
1711
+ return String(sid || '').replace(/^session-/, '')
1712
+ }
1713
+
1714
+ /** 取该会话最近 10 分钟内的实测记录(无则 null)。 */
1715
+ waterRecordFor(sid) {
1716
+ if (!sid) return null
1717
+ const m = this._waterBySession
1718
+ const rec = m && typeof m.get === 'function' ? m.get(this.waterKey(sid)) : null
1719
+ return rec && Date.now() - (Number(rec.at) || 0) < 600000 ? rec : null
1720
+ }
1721
+
1722
+ /** 从会话日志(磁盘)推导窗口与模型:不需要 agent,切换会话即可得到正确的模型名与窗口。 */
1723
+ async waterWindowForSession(sid) {
1724
+ const empty = { window: 0, source: '', model: '', provider: '', contextWindow: 0 }
1725
+ if (!sid) return empty
1726
+ if (!this._waterDeriveCache) this._waterDeriveCache = new Map()
1727
+ const cached = this._waterDeriveCache.get(String(sid))
1728
+ if (cached && Date.now() - cached.at < 300000) return cached.value
1729
+ let value = empty
1730
+ try {
1731
+ const dir = await this.locateSessionDir(String(sid))
1732
+ if (dir) {
1733
+ const raw = await readFile(path.join(dir, 'session.jsonl.zstd')).catch(() => null)
1734
+ const events = raw ? decodeZstdFrames(raw).split('\n').map((l) => { try { return JSON.parse(l) } catch (e) { return null } }).filter(Boolean) : []
1735
+ const info = findSessionModelPre(events)
1736
+ const wi = await this.resolveWaterWindow(info.provider, info.model)
1737
+ value = {
1738
+ window: info.contextWindow > 0 ? info.contextWindow : (Number(wi.window) || 0),
1739
+ source: info.contextWindow > 0 ? 'official-context' : (wi.source || ''),
1740
+ model: info.model || wi.model || '',
1741
+ provider: info.provider,
1742
+ contextWindow: info.contextWindow,
1743
+ }
1744
+ }
1745
+ } catch (e) {}
1746
+ this._waterDeriveCache.set(String(sid), { at: Date.now(), value })
1747
+ if (this._waterDeriveCache.size > 40) {
1748
+ const first = this._waterDeriveCache.keys().next()
1749
+ if (!first.done) this._waterDeriveCache.delete(first.value)
1750
+ }
1751
+ return value
1752
+ }
1753
+
1669
1754
  /** M-CM6-A·水位 v2:计量优先级=ctx.tokenMeter.measure(官方计量,与聊天框 context ring 同源,免疫重启/压缩/chunk 洪流)→ 启发式估算(降级);
1670
1755
  * 窗口优先级=waterLevelWindowTokens 手动覆盖 > 官方 request/context 容量 > settings.yaml auto/fallback。越阈值或检测到 compaction 事件→advisory+自动写抽取性骨架账本。 */
1671
1756
  async checkWaterLevel(agent) {
1672
1757
  try {
1673
1758
  if (this.config.handoffEnabled === false) return
1674
- const winInfo = await this.resolveWaterWindow()
1759
+ // 会话真实模型 + 官方容量:同一份事件扫描结果按会话缓存 5 分钟
1760
+ // (该事件只在请求头变化时追加,实测 2999 条事件里仅 2 条且位于会话开头,旧实现只扫最近 256 条 → 长会话永远扫不到)。
1761
+ let sessModel = { provider: '', model: '', contextWindow: 0 }
1762
+ try {
1763
+ const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
1764
+ const cached = this._officialWindowCache
1765
+ if (cached && sid && cached.sid === sid && Date.now() - cached.at < 300000 && cached.info) {
1766
+ sessModel = cached.info
1767
+ } else {
1768
+ sessModel = findSessionModelPre(sessionEventsOf(agent && agent.session))
1769
+ this._officialWindowCache = { sid, at: Date.now(), info: sessModel }
1770
+ }
1771
+ } catch (eWin) {}
1772
+ // 窗口解析按「会话真实模型」查表(2026-09-08):此前恒用 settings.yaml 的 agent-default-model,
1773
+ // 用户切过模型后卡片会标出另一个模型的名字(实测会话跑 deepseek-official/deepseek-v4.1-flash,却标 opencode-go/deepseek-v4-flash)。
1774
+ const winInfo = await this.resolveWaterWindow(sessModel.provider, sessModel.model)
1675
1775
  let win = Number(winInfo && winInfo.window) || 0
1676
1776
  let winSource = (winInfo && winInfo.source) || ''
1677
- if (win > 0 && !/^manual/.test(winSource)) {
1678
- // 官方容量:最新一条 request/context 事件的 contextWindow(路由容量,last-wins)
1679
- try {
1680
- const evts = sessionEventsOf(agent && agent.session)
1681
- for (let i = evts.length - 1; i >= Math.max(0, evts.length - 256); i--) {
1682
- const ev = evts[i]
1683
- if (ev && ev.type === 'request/context') {
1684
- const cw = Number(ev && ev.data && ev.data.contextWindow)
1685
- if (Number.isFinite(cw) && cw > 0) { win = cw; winSource = 'official-context' }
1686
- break
1687
- }
1688
- }
1689
- } catch (eWin) {}
1777
+ if (win > 0 && !/^manual/.test(winSource) && sessModel.contextWindow > 0) {
1778
+ // 官方容量:request/context 的 contextWindow(路由权威值,last-wins)
1779
+ win = sessModel.contextWindow
1780
+ winSource = 'official-context'
1690
1781
  }
1691
1782
  if (win <= 0) return
1692
1783
  const rt = this.runtimeFor(agent)
@@ -1719,7 +1810,9 @@ class MemoryEngine {
1719
1810
  }
1720
1811
  }
1721
1812
  } catch (e) {}
1722
- const ratio = Math.min(estTokens / win, 1.5)
1813
+ // 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
1814
+ // 现在如实上报,仅做防溢出的 99 倍上限。
1815
+ const ratio = win > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / win, 99) : 0
1723
1816
  rt.waterLevel = ratio
1724
1817
  rt.waterLevelTokens = estTokens
1725
1818
  this.state.waterLevelRatio = ratio
@@ -1727,9 +1820,14 @@ class MemoryEngine {
1727
1820
  this.state.waterLevelWindow = win
1728
1821
  this.state.waterLevelSource = winSource
1729
1822
  this.state.waterLevelMeter = meterLayer
1730
- this.state.waterLevelModel = winInfo.model || ''
1823
+ this.state.waterLevelModel = sessModel.model || winInfo.model || ''
1731
1824
  this.state.waterLevelAt = Date.now()
1732
- const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.8, 0.1)
1825
+ const threshold = Math.max(Number(this.config.waterLevelThreshold) || 0.75, 0.1)
1826
+ // 会话级记录:切会话时 /handoff-state?sessionId= 直接取这份,不再显示别的会话的数
1827
+ this.rememberWaterRecord(agent && agent.session && agent.session.id ? String(agent.session.id) : '', {
1828
+ ratio, tokens: estTokens, window: win, source: winSource, meter: meterLayer,
1829
+ model: this.state.waterLevelModel || '', threshold, at: Date.now(), live: true,
1830
+ })
1733
1831
  const over = ratio >= threshold || (ratio >= 0.5 && compacted)
1734
1832
  if (!over) return
1735
1833
  if (!rt.waterLevelAdvised || compacted) {
@@ -1774,10 +1872,44 @@ class MemoryEngine {
1774
1872
  return tokens
1775
1873
  }
1776
1874
 
1875
+ /** 当前(最近活跃)会话 id;无 agent 时返回 ''。 */
1876
+ currentSessionId() {
1877
+ try {
1878
+ const agent = this._lastAgent
1879
+ return agent && agent.session && agent.session.id ? String(agent.session.id) : ''
1880
+ } catch (e) { return '' }
1881
+ }
1882
+
1883
+ /** 修A(2026-09-08):解析会话所属 workspaceId。官方 api-session-controller create() 只在传 workspaceId 时
1884
+ * 调 workspace.attachSession()(index.js:566-588),只传 cwd 仅设工作目录 → 新会话永远落「未分组工作区」。
1885
+ * 归属判定走 workspaceRegistry(与官方 forkWorkspace index.js:874 同款),服务缺失/无归属返回 ''(client 回退 cwd)。 */
1886
+ resolveWorkspaceIdForSession(sessionId, cwd) {
1887
+ try {
1888
+ // 惰性解析:apply 时服务可能尚未就绪(strict get 会返回 undefined),每次调用再取一次兜底
1889
+ let reg = this._workspaceRegistry
1890
+ if (!reg && this._ctxRef && typeof this._ctxRef.get === 'function') {
1891
+ try { reg = this._ctxRef.get('workspaceRegistry') } catch (e) {}
1892
+ }
1893
+ if (!reg || typeof reg.list !== 'function') return ''
1894
+ return workspaceIdForSession(reg.list(), sessionId, cwd)
1895
+ } catch (e) { return '' }
1896
+ }
1897
+
1898
+ /** 接续前刷新仪式指令(③,2026-09-08):交给 client 用 remote.session.prompt 发回旧会话,
1899
+ * 让旧 Agent 在材料组装前把 PLAN.md 与交接账本刷到最新。 */
1900
+ refreshRitualPrompt() {
1901
+ return [
1902
+ '[接续前刷新仪式] 即将把本会话交接给新会话。请在本轮内只做下面两件事,不要执行其他操作、不要推进任务:',
1903
+ '1. 调用 memory_note(kind=plan, content=<项目白板的最新完整全貌:项目全貌/当前目标/下一步,旧版会自动归档>) 重写 PLAN.md;',
1904
+ '2. 调用 memory_note(kind=handoff, content=<四段式交接账本:## 任务状态 / ## 目标 / ## 已试方案与失败原因 / ## 进度与下一步,每段≤5行,下一步必须是可以直接执行的第一步,带文件路径或命令>) 写一篇新账本。',
1905
+ '完成后只回复一句「已刷新」,不要再调用其他工具。',
1906
+ ].join(String.fromCharCode(10))
1907
+ }
1908
+
1777
1909
  /** M-CM6-B v2·旧会话上下文包(2026-09-08):定位当前(旧)会话持久化文件(~/.dsh/sessions/<ws>/<sid>/session.jsonl[.zstd]),
1778
- * zstd 解压抽取 header(cwd/agentPreset)、最新 request/context(provider/model)、user+assistant 消息全量转写;
1779
- * 转写落盘 handoff/prev-session-*.md → 新会话 AI 用 read 工具即可随时读取旧会话全部内容(原始 .zstd AI 读不了,这是关键桥)。
1780
- * 返回 {sessionId, transcriptPath, provider, model, agentPreset, cwd, tailText} 或部分为空(任何一步失败都 fail-soft)。 */
1910
+ * zstd 解压抽取 header(cwd/agentPreset)、最新 request/header 的 data.header.config(provider/model/reasoningEffort)、
1911
+ * user+assistant+工具 消息全量转写;转写落盘 handoff/prev-session-*.md → 新会话 AI 用 read 工具即可随时读取旧会话全部内容。
1912
+ * 返回 {sessionId, transcriptPath, provider, model, reasoningEffort, agentPreset, cwd, tailText, msgCount} 或部分为空(fail-soft)。 */
1781
1913
  async buildPrevSessionPack() {
1782
1914
  try {
1783
1915
  const agent = this._lastAgent
@@ -1802,25 +1934,14 @@ class MemoryEngine {
1802
1934
  text = buf.toString('utf8')
1803
1935
  }
1804
1936
  const lines = text.split('\n').filter((l) => l.trim())
1805
- let preset = '', cwd = ''
1806
- try { const h = JSON.parse(lines[0]); if (h && typeof h === 'object') { preset = String(h.agentPreset || ''); cwd = String(h.cwd || '') } } catch (e) {}
1807
- let provider = '', model = ''
1808
- const msgs = []
1809
- for (const l of lines) {
1810
- let ev
1811
- try { ev = JSON.parse(l) } catch (e) { continue }
1812
- const t = ev && ev.type
1813
- if (t === 'request/context') {
1814
- const d = (ev && ev.data) || {}
1815
- if (d.model) { provider = String(d.provider || provider); model = String(d.model) }
1816
- } else if (t === 'user/message' || t === 'assistant/message') {
1817
- const m = messageOfEvent(ev)
1818
- if (m && m.role && Array.isArray(m.content)) {
1819
- const txt = textOfContent(m.content)
1820
- if (txt) msgs.push({ role: m.role, text: txt })
1821
- }
1822
- }
1823
- }
1937
+ // 修B(2026-09-08):模型/思考档位取自 request/header 的 data.header.config(官方投影同源),request/context 仅回退。
1938
+ const folded = foldSessionLogEvents(lines)
1939
+ const preset = folded.preset
1940
+ const cwd = folded.cwd
1941
+ const provider = folded.provider
1942
+ const model = folded.model
1943
+ const reasoningEffort = folded.reasoningEffort
1944
+ const msgs = folded.msgs
1824
1945
  const p = await this.resolvePaths(undefined)
1825
1946
  const NL = String.fromCharCode(10)
1826
1947
  const stamp = new Date().toISOString().slice(11, 19).replace(/:/g, '')
@@ -1843,9 +1964,9 @@ class MemoryEngine {
1843
1964
  }
1844
1965
  await mkdir(p.handoffDir, { recursive: true })
1845
1966
  await writeFile(outPath, body.join(NL), 'utf8')
1846
- const tail = msgs.slice(-8).map((m) => m.role + ': ' + m.text.slice(0, 300)).join(NL + '---' + NL)
1847
- diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' -> ' + outPath)
1848
- return { sessionId: sid, transcriptPath: outPath, provider, model, agentPreset: preset, cwd: cwd || p.ws, tailText: tail }
1967
+ const tail = msgs.slice(-20).map((m) => m.role + ': ' + m.text.slice(0, 700)).join(NL + '---' + NL)
1968
+ diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' -> ' + outPath)
1969
+ return { sessionId: sid, transcriptPath: outPath, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length }
1849
1970
  } catch (e) { diag('buildPrevSessionPack error: ' + (e && e.message)); return null }
1850
1971
  }
1851
1972
 
@@ -1867,13 +1988,32 @@ class MemoryEngine {
1867
1988
  }
1868
1989
  const pack = await this.buildPrevSessionPack()
1869
1990
  if (!plan && !ledger && !pack) return { ok: false, error: 'no handoff material (PLAN/账本/旧会话均为空)' }
1991
+ // 材料时间戳与新鲜度(分层压缩:过期提示)
1992
+ const fmtMt = (ms) => { try { return ms ? new Date(Number(ms)).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '(未知)' } catch (e) { return '(未知)' } }
1993
+ const planMt = await stat(p.planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
1994
+ let ledgerName = '', ledgerMt = 0
1995
+ try {
1996
+ const names = (await readdir(p.handoffDir).catch(() => [])).filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n))
1997
+ for (const n of names) {
1998
+ const st = await stat(path.join(p.handoffDir, n)).catch(() => null)
1999
+ const mt = st ? Number(st.mtimeMs) : 0
2000
+ if (mt >= ledgerMt) { ledgerMt = mt; ledgerName = n }
2001
+ }
2002
+ } catch (eL) {}
2003
+ const staleNote = planMt && ledgerMt && planMt < ledgerMt ? '(白板比账本旧——以账本为准)' : ''
2004
+ // 修A(2026-09-08):解析旧会话所属工作区,交给 client 用 workspaceId 建新会话(官方只有 workspaceId 分支会 attachSession)。
2005
+ const prevSid = (pack && pack.sessionId) || this.currentSessionId()
2006
+ const wsForSession = (pack && pack.cwd) || p.ws
2007
+ const workspaceId = this.resolveWorkspaceIdForSession(prevSid, wsForSession)
1870
2008
  const parts = [
1871
2009
  '接续上一会话的任务。请先读完下面的交接材料恢复上下文,然后直接继续推进未完成事项,不要重新开始。',
2010
+ '材料分层:第0层=指令+白板(全局图景);第1层=交接账本(四段式,最新);第2层=近期线程(最近 20 条,单条上限 700 字,保留角色与工具标记);第3层=完整转写(按需 read)。',
1872
2011
  ]
1873
- if (plan) parts.push('### 白板 PLAN.md(节选)' + NL + plan.slice(0, 3000))
1874
- if (ledger) parts.push('### 交接账本(' + ledgerFrom + ')' + NL + ledger.slice(0, 8000))
2012
+ if (plan) parts.push('【第0层 · 白板 PLAN.md(节选)】更新于 ' + fmtMt(planMt) + staleNote + NL + plan.slice(0, 3000))
2013
+ if (ledger) parts.push('【第1层 · 交接账本 ' + (ledgerName || ledgerFrom) + '】写于 ' + fmtMt(ledgerMt) + ' (' + ledgerFrom + ')' + NL + ledger.slice(0, 8000))
1875
2014
  if (pack) {
1876
- const guide = ['### 旧会话上下文包(主动读取指引)']
2015
+ if (pack.tailText) parts.push('【第2层 · 近期线程(最近 ' + Math.min(20, Number(pack.msgCount) || 20) + ' 条 / 共 ' + (pack.msgCount || 0) + ' 条)】' + NL + pack.tailText)
2016
+ const guide = ['【第3层 · 完整转写与检索(按需)】']
1877
2017
  if (pack.transcriptPath) {
1878
2018
  guide.push('- 旧会话(' + pack.sessionId + ')的完整对话转写已写入: ' + pack.transcriptPath)
1879
2019
  guide.push('- **接续前先用 read 工具读取该转写文件**,以完全理解旧会话的讨论、结论与未竟事项;任务推进中也可随时回读。')
@@ -1881,26 +2021,38 @@ class MemoryEngine {
1881
2021
  guide.push('- 旧会话 ID: ' + pack.sessionId + '(转写文件生成失败,可用 memory_recall 检索)')
1882
2022
  }
1883
2023
  guide.push('- 历史会话全文检索: memory_recall(scope=\'sessions\', query=\'关键词\') 可跨全部历史会话查证细节。')
1884
- if (pack.tailText) guide.push('#### 最近对话摘录(最后 8 条)' + NL + pack.tailText)
1885
- if (pack.model) guide.push('- 状态沿用: 模型 ' + pack.model + (pack.provider ? ' @ ' + pack.provider : '') + ' 已在新会话自动选回(失败则回退路由默认);工作区 cwd 与权限绑定沿用 ' + (pack.cwd || p.ws) + ';插件配置(无人值守/水位/交接等)为全局配置,自动继承。')
1886
- if (pack.agentPreset) guide.push('- 旧会话 agentPreset: ' + pack.agentPreset + '(preset 不随创建传递,如需同款预设请手动选择;注意 code→ptc 改名的历史会话需用户级兼容预设)。')
2024
+ if (pack.model) guide.push('- 状态沿用: 模型 ' + pack.model + (pack.provider ? ' @ ' + pack.provider : '') + (pack.reasoningEffort ? '(思考档位 ' + pack.reasoningEffort + ')' : '') + ' 已在新会话自动选回(失败则回退路由默认);工作区 ' + (workspaceId ? '按 workspaceId 绑定 ' + workspaceId : '按 cwd 绑定 ' + wsForSession) + ';插件配置(无人值守/水位/交接等)为全局配置,自动继承。')
2025
+ if (pack.agentPreset) guide.push('- 旧会话 agentPreset: ' + pack.agentPreset + '(已随创建传递;注意 code→ptc 改名的历史会话需用户级兼容预设)。')
1887
2026
  parts.push(guide.join(NL))
1888
2027
  }
2028
+ // 接续序号:统计 handoff 目录里已有的 prev-session 包数量 +1(每次一键/自动接续产出一个)
2029
+ let contSeq = 1
2030
+ try { const prevs = (await readdir(p.handoffDir).catch(() => [])).filter((f) => /^prev-session-.*\.md$/.test(f)); contSeq = prevs.length + 1 } catch (eS) {}
2031
+ const wsBase = String((pack && pack.cwd) || p.ws || '').split(/[\\/]/).filter(Boolean).pop() || ''
1889
2032
  return {
1890
2033
  ok: true,
1891
2034
  carryText: parts.join(NL + NL).slice(0, 18000),
1892
2035
  planPath: p.planPath,
1893
2036
  ws: (pack && pack.cwd) || p.ws,
2037
+ workspaceId: workspaceId,
1894
2038
  provider: (pack && pack.provider) || '',
1895
2039
  model: (pack && pack.model) || '',
2040
+ reasoningEffort: (pack && pack.reasoningEffort) || '',
2041
+ agentPreset: (pack && pack.agentPreset) || '',
2042
+ wsBase: wsBase,
2043
+ contSeq: contSeq,
1896
2044
  prevSessionId: (pack && pack.sessionId) || '',
1897
2045
  transcriptPath: (pack && pack.transcriptPath) || '',
2046
+ planMtime: planMt,
2047
+ ledgerName: ledgerName,
2048
+ ledgerMtime: ledgerMt,
2049
+ msgCount: (pack && pack.msgCount) || 0,
1898
2050
  }
1899
2051
  } catch (e) { return { ok: false, error: String(e && e.message ? e.message : e) } }
1900
2052
  }
1901
2053
 
1902
2054
  /** M-CM1 白板面板数据:PLAN 当前版+归档版本列表+账本列表;fileQ 非空时返回指定文件文本(严格白名单,限 handoff 目录内)。 */
1903
- async handoffPanelData(fileQ) {
2055
+ async handoffPanelData(fileQ, sessionId) {
1904
2056
  if (this.config.handoffEnabled === false) return { enabled: false }
1905
2057
  const handoffDir = this.state.handoffDir || (await this.resolvePaths(undefined)).handoffDir
1906
2058
  const planPath = this.state.planPath || path.join(handoffDir, 'PLAN.md')
@@ -1926,29 +2078,57 @@ class MemoryEngine {
1926
2078
  if (st && st.isFile()) ledgers.push({ name: n, mtime: Number(st.mtimeMs) })
1927
2079
  }
1928
2080
  ledgers.sort((a, b) => b.mtime - a.mtime)
1929
- if (!this.state.waterLevelWindow && this.config.handoffEnabled !== false) {
1930
- try {
1931
- const wi = await this.resolveWaterWindow()
1932
- this.state.waterLevelWindow = Number(wi && wi.window) || 0
1933
- this.state.waterLevelSource = (wi && wi.source) || ''
1934
- this.state.waterLevelModel = (wi && wi.model) || ''
1935
- } catch (e) {}
1936
- }
1937
- return {
1938
- enabled: true, planPath,
1939
- plan: await this.readTextSafe(planPath),
1940
- planVersions: versions.slice(0, 30),
1941
- ledgers: ledgers.slice(0, 60),
1942
- waterLevel: {
2081
+ // 水位:优先返回「该会话」的记录(实测 → 磁盘推导);未指定 sessionId 时退回全局单值(兼容旧调用方)
2082
+ let water = null
2083
+ if (sessionId) {
2084
+ const rec = this.waterRecordFor(sessionId)
2085
+ if (rec) {
2086
+ water = {
2087
+ ratio: Number(rec.ratio) || 0, tokens: Number(rec.tokens) || 0, window: Number(rec.window) || 0,
2088
+ source: rec.source || '', meter: rec.meter || '', model: rec.model || '',
2089
+ threshold: Number(this.config.waterLevelThreshold) || 0.75, at: Number(rec.at) || 0, live: true,
2090
+ }
2091
+ } else {
2092
+ const wi = await this.waterWindowForSession(sessionId)
2093
+ water = {
2094
+ ratio: 0, tokens: 0, window: Number(wi.window) || 0, source: wi.source || '', meter: '',
2095
+ model: wi.model || '', threshold: Number(this.config.waterLevelThreshold) || 0.75, at: 0, live: false,
2096
+ }
2097
+ }
2098
+ } else {
2099
+ if (!this.state.waterLevelWindow && this.config.handoffEnabled !== false) {
2100
+ try {
2101
+ const wi = await this.resolveWaterWindow()
2102
+ this.state.waterLevelWindow = Number(wi && wi.window) || 0
2103
+ this.state.waterLevelSource = (wi && wi.source) || ''
2104
+ this.state.waterLevelModel = (wi && wi.model) || ''
2105
+ } catch (e) {}
2106
+ }
2107
+ water = {
1943
2108
  ratio: Number(this.state.waterLevelRatio) || 0,
1944
2109
  tokens: Number(this.state.waterLevelTokens) || 0,
1945
2110
  window: Number(this.state.waterLevelWindow) || 0,
1946
2111
  source: this.state.waterLevelSource || '',
1947
2112
  meter: this.state.waterLevelMeter || '',
1948
2113
  model: this.state.waterLevelModel || '',
1949
- threshold: Number(this.config.waterLevelThreshold) || 0.8,
2114
+ threshold: Number(this.config.waterLevelThreshold) || 0.75,
1950
2115
  at: Number(this.state.waterLevelAt) || 0,
1951
- },
2116
+ live: Number(this.state.waterLevelAt) > 0,
2117
+ }
2118
+ }
2119
+ const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
2120
+ const sid = this.currentSessionId()
2121
+ return {
2122
+ enabled: true, planPath,
2123
+ plan: await this.readTextSafe(planPath),
2124
+ planMtime: planMt,
2125
+ planVersions: versions.slice(0, 30),
2126
+ ledgers: ledgers.slice(0, 60),
2127
+ // ③接续前刷新仪式(2026-09-08):client 拿到 sessionId+prompt 后先让旧 Agent 刷新白板/账本,再取接续材料。
2128
+ refresh: this.config.autoContinueRefreshRitual === false || !sid
2129
+ ? { sessionId: '', prompt: '' }
2130
+ : { sessionId: sid, prompt: this.refreshRitualPrompt() },
2131
+ waterLevel: water,
1952
2132
  }
1953
2133
  }
1954
2134
 
@@ -2707,7 +2887,7 @@ class MemoryEngine {
2707
2887
  lines.push('\n[交接续命] 当前会话未绑定工作区(项目记忆不可用)。全局最近交接账本: ' + s.globalLedgerPath + ' —— 需要续接上次任务时,直接读取该文件恢复上下文;工作区绑定后白板/账本即恢复正常注入。')
2708
2888
  }
2709
2889
  // M-CM4/M-CM5 水位建议:越阈注入交接+派子代理建议(advisory;10 分钟新鲜度;无人值守静默)
2710
- if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.8) && Date.now() - (s.waterLevelAt || 0) < 600000) {
2890
+ if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.75) && Date.now() - (s.waterLevelAt || 0) < 600000) {
2711
2891
  lines.push('\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
2712
2892
  }
2713
2893
  let used = 0
@@ -3843,6 +4023,93 @@ class MemoryEngine {
3843
4023
  if (controller.signal.aborted && run && typeof run.dispose === 'function') {
3844
4024
  try { await run.dispose() } catch (e2) {}
3845
4025
  }
4026
+ // 子代理痕迹回收(2026-09-08):本插件的一次性子代理(auto-memory-*)结束即把会话目录移入备份,
4027
+ // 避免 ~/.dsh/sessions 下插件痕迹堆积拖慢宿主会话列表。失败静默,由每日兜底巡检补收。
4028
+ if (run && run.agent) { try { await this.recycleSubagentSession(run.agent) } catch (e3) {} }
4029
+ }
4030
+ }
4031
+
4032
+ /** 子代理痕迹回收(2026-09-08):本插件 spawn 的一次性子代理结束后,把其会话目录与投影缓存移入备份目录。
4033
+ * 只动 origin=subagent 的会话;任何失败静默(留给每日兜底巡检),绝不影响子代理结果返回。 */
4034
+ async recycleSubagentSession(agent) {
4035
+ try {
4036
+ if (this.config.subagentGcEnabled === false) return { ok: false, reason: 'disabled' }
4037
+ const session = agent && agent.session
4038
+ const header = session && session.header
4039
+ const sid = session && (session.id || (header && header.id)) ? String(session.id || header.id) : ''
4040
+ if (!sid) return { ok: false, reason: 'no-id' }
4041
+ if (!header || header.origin !== 'subagent') return { ok: false, reason: 'not-subagent' }
4042
+ const dir = await this.locateSessionDir(sid, header)
4043
+ if (!dir) return { ok: false, reason: 'not-located' }
4044
+ const wsDir = path.basename(path.dirname(dir))
4045
+ const backupRoot = path.join(dshHome(), 'subagent-gc-backup')
4046
+ const dest = path.join(backupRoot, wsDir, sid)
4047
+ await mkdir(path.dirname(dest), { recursive: true })
4048
+ await rename(dir, dest)
4049
+ // 投影缓存同步移走,避免孤儿条目长期占用索引
4050
+ try {
4051
+ const cacheFile = path.join(dshHome(), 'storages', 'session_projcache', 'sessions', sid)
4052
+ if (existsSync(cacheFile)) {
4053
+ const cacheDest = path.join(backupRoot, '_projcache', sid)
4054
+ await mkdir(path.dirname(cacheDest), { recursive: true })
4055
+ await rename(cacheFile, cacheDest)
4056
+ }
4057
+ } catch (e2) {}
4058
+ diag('subagent gc: recycled session ' + sid)
4059
+ return { ok: true, sid }
4060
+ } catch (e) {
4061
+ diag('subagent gc failed: ' + (e && e.message ? e.message : String(e)))
4062
+ return { ok: false, reason: e && e.message ? e.message : String(e) }
4063
+ }
4064
+ }
4065
+
4066
+ /** 定位会话持久化目录:优先官方 sessionPersistence.locate,回退按会话 id 扫描 sessions 根。 */
4067
+ async locateSessionDir(sid, header) {
4068
+ try {
4069
+ const sp = this.ctx && typeof this.ctx.get === 'function' ? this.ctx.get('sessionPersistence') : null
4070
+ const loc = sp && typeof sp.locate === 'function' ? sp.locate(header) : null
4071
+ if (loc && loc.path) {
4072
+ const dir = path.dirname(String(loc.path))
4073
+ if (existsSync(dir)) return dir
4074
+ }
4075
+ } catch (e) {}
4076
+ try {
4077
+ const root = path.join(dshHome(), 'sessions')
4078
+ const wss = await readdir(root).catch(() => [])
4079
+ for (const ws of wss) {
4080
+ const dir = path.join(root, ws, sid)
4081
+ if (existsSync(dir)) return dir
4082
+ }
4083
+ } catch (e) {}
4084
+ return ''
4085
+ }
4086
+
4087
+ /** 子代理痕迹兜底巡检(每日一次):回收超过保留期仍残留的本插件一次性痕迹。 */
4088
+ async subagentGcSweep(force) {
4089
+ try {
4090
+ if (this.config.subagentGcEnabled === false) return { ok: false, reason: 'disabled' }
4091
+ const today = this.memToday()
4092
+ if (!force && this._subagentGcDay === today) return { ok: true, skipped: 'done-today' }
4093
+ this._subagentGcDay = today
4094
+ const sessionsRoot = path.join(dshHome(), 'sessions')
4095
+ const keepDays = Math.max(0, Number(this.config.subagentGcKeepDays) || 0)
4096
+ const scan = await scanPluginSubagentSessions({ sessionsRoot, labelPrefix: PLUGIN_LABEL_PREFIX, keepMs: 0 })
4097
+ const now = Date.now()
4098
+ // 无论保留期多长,最近 10 分钟的一律不动(可能与正在运行的子代理竞争)
4099
+ const floorMs = Math.max(keepDays * 24 * 3600 * 1000, 10 * 60 * 1000)
4100
+ const eligible = scan.candidates.filter((c) => now - c.mtimeMs >= floorMs)
4101
+ if (!eligible.length) return { ok: true, scanned: scan.scanned, recycled: 0 }
4102
+ const res = await recycleSessions({
4103
+ candidates: eligible,
4104
+ backupRoot: path.join(dshHome(), 'subagent-gc-backup'),
4105
+ projcacheRoot: path.join(dshHome(), 'storages', 'session_projcache', 'sessions'),
4106
+ apply: true,
4107
+ })
4108
+ diag('subagent gc sweep: recycled=' + res.moved.length + ' failed=' + res.failed.length + ' bytes=' + res.bytes)
4109
+ return { ok: true, scanned: scan.scanned, recycled: res.moved.length, failed: res.failed.length, bytes: res.bytes }
4110
+ } catch (e) {
4111
+ diag('subagent gc sweep failed: ' + (e && e.message ? e.message : String(e)))
4112
+ return { ok: false, reason: e && e.message ? e.message : String(e) }
3846
4113
  }
3847
4114
  }
3848
4115
 
@@ -4940,6 +5207,76 @@ function hasStutter(text) {
4940
5207
  if (/([\u4e00-\u9fff\u3040-\u30ff])(?:[^\u4e00-\u9fff\u3040-\u30ff]{0,2}\1){4,}/.test(t)) return true
4941
5208
  return false
4942
5209
  }
5210
+ /** 折叠会话日志事件流(纯函数,供 buildPrevSessionPack 与 smoke 共用)。
5211
+ * 2026-09-08 修B:模型/思考档位在 request/header 的 data.header.config.{provider,model,reasoningEffort}
5212
+ * (官方 api-session-controller/lib/index.js:2008-2012 applyModelSelectionProjection 同源),取最后一条;
5213
+ * request/context 只是旧日志的回退来源,单查它必然取不到(2.2.4 实测:新会话仍用默认模型)。
5214
+ * 消息序列保留角色标记与工具标记(user/assistant/tool_call/tool_result),供第2层近期线程还原结构。 */
5215
+ function foldSessionLogEvents(lines) {
5216
+ const out = { preset: '', cwd: '', provider: '', model: '', reasoningEffort: '', msgs: [] }
5217
+ const list = Array.isArray(lines) ? lines : []
5218
+ try {
5219
+ const h = JSON.parse(list[0] || '')
5220
+ if (h && typeof h === 'object') { out.preset = String(h.agentPreset || ''); out.cwd = String(h.cwd || '') }
5221
+ } catch (e) {}
5222
+ let hProvider = '', hModel = '', hEffort = '', cProvider = '', cModel = '', cEffort = ''
5223
+ for (const l of list) {
5224
+ let ev
5225
+ try { ev = JSON.parse(l) } catch (e) { continue }
5226
+ const t = ev && ev.type
5227
+ if (t === 'request/header') {
5228
+ const cfg = (ev.data && ev.data.header && ev.data.header.config) || {}
5229
+ if (cfg.model) {
5230
+ hProvider = String(cfg.provider || '')
5231
+ hModel = String(cfg.model)
5232
+ hEffort = cfg.reasoningEffort === undefined || cfg.reasoningEffort === null ? '' : String(cfg.reasoningEffort)
5233
+ }
5234
+ } else if (t === 'request/context') {
5235
+ const d = (ev && ev.data) || {}
5236
+ if (d.model) {
5237
+ cProvider = String(d.provider || '')
5238
+ cModel = String(d.model)
5239
+ const eff = d.reasoningEffort || d.reasoning || d.reasoningEffortLevel
5240
+ cEffort = eff ? String(eff) : ''
5241
+ }
5242
+ } else if (t === 'user/message' || t === 'assistant/message') {
5243
+ const m = messageOfEvent(ev)
5244
+ if (m && m.role && Array.isArray(m.content)) {
5245
+ const txt = textOfContent(m.content)
5246
+ if (txt) out.msgs.push({ role: m.role, text: txt })
5247
+ }
5248
+ } else if (t === 'tool/call') {
5249
+ const d = (ev && ev.data) || {}
5250
+ const nm = String(d.name || 'tool')
5251
+ const argsRaw = typeof d.arguments === 'string' ? d.arguments : JSON.stringify(d.arguments == null ? null : d.arguments)
5252
+ out.msgs.push({ role: 'tool_call', text: nm + '(' + String(argsRaw || '').slice(0, 400) + ')' })
5253
+ } else if (t === 'tool/result') {
5254
+ const m = messageOfEvent(ev)
5255
+ const txt = m && Array.isArray(m.content) ? textOfContent(m.content) : ''
5256
+ if (txt) out.msgs.push({ role: 'tool_result', text: txt.slice(0, 400) })
5257
+ }
5258
+ }
5259
+ if (hModel) { out.provider = hProvider; out.model = hModel; out.reasoningEffort = hEffort }
5260
+ else { out.provider = cProvider; out.model = cModel; out.reasoningEffort = cEffort }
5261
+ return out
5262
+ }
5263
+
5264
+ /** 从 workspaceRegistry.list() 的结果解析某会话所属 workspaceId(纯函数,供 smoke 驱动)。
5265
+ * 2026-09-08 修A:官方 create 只在传 workspaceId 时调 workspace.attachSession(),只传 cwd 仅设工作目录
5266
+ * → 新会话永远落「未分组」。归属判定与官方 forkWorkspace(index.js:874)同款:workspace.sessionIds 含该会话;
5267
+ * 再按目录兜底(该路径已被某工作区认领,官方 indexLiveSessions 也按路径补账)。返回 '' = 解析不到,调用方回退 cwd。 */
5268
+ function workspaceIdForSession(workspaces, sessionId, cwd) {
5269
+ if (!Array.isArray(workspaces) || !sessionId) return ''
5270
+ const direct = workspaces.find((w) => w && Array.isArray(w.sessionIds) && w.sessionIds.includes(sessionId))
5271
+ if (direct && direct.id) return String(direct.id)
5272
+ if (cwd) {
5273
+ const norm = (v) => String(v || '').replace(/[\\/]+$/, '').toLowerCase()
5274
+ const byPath = workspaces.find((w) => w && w.path && norm(w.path) === norm(cwd))
5275
+ if (byPath && byPath.id) return String(byPath.id)
5276
+ }
5277
+ return ''
5278
+ }
5279
+
4943
5280
  /** 从 session 提取消息。surface 不是完整可靠的 user 来源,因此失败时回退完整事件日志。 */
4944
5281
  function messageOfEvent(ev) {
4945
5282
  if (!ev) return null
@@ -5476,6 +5813,9 @@ export function apply(ctx, config) {
5476
5813
  engine._agentSvc = ctx.get('agent') || undefined
5477
5814
  // 模型目录(设置页「总结/问候默认模型」抽屉数据源);软获取,缺失时抽屉回退手动输入
5478
5815
  engine._llm = ctx.get('llm') || undefined
5816
+ // 工作区注册表(修A,2026-09-08):接续时解析旧会话所属 workspaceId——官方 create 只在传 workspaceId 时绑定工作区
5817
+ engine._workspaceRegistry = ctx.get('workspaceRegistry') || undefined
5818
+ engine._ctxRef = ctx // 惰性重取服务用(apply 时可能尚未就绪)
5479
5819
 
5480
5820
  // 生命周期刷新
5481
5821
  const refreshAll = (agent) => {
@@ -6008,7 +6348,7 @@ export function apply(ctx, config) {
6008
6348
  if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6009
6349
  try {
6010
6350
  const url = new URL(req.url, 'http://127.0.0.1')
6011
- return writeJson(res, 200, await engine.handoffPanelData(url.searchParams.get('file')))
6351
+ return writeJson(res, 200, await engine.handoffPanelData(url.searchParams.get('file'), url.searchParams.get('sessionId')))
6012
6352
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6013
6353
  },
6014
6354
  },
@@ -6026,6 +6366,40 @@ export function apply(ctx, config) {
6026
6366
  } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6027
6367
  },
6028
6368
  },
6369
+ {
6370
+ kind: 'exact',
6371
+ path: API['subagent-gc'],
6372
+ handler: async (req, res) => {
6373
+ // 子代理痕迹回收(loopback):GET=预览(只扫描,不移动文件),POST=执行回收(移入备份目录,可回滚)。
6374
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
6375
+ try {
6376
+ const method = req.method || 'GET'
6377
+ if (method === 'GET') {
6378
+ const scan = await scanPluginSubagentSessions({
6379
+ sessionsRoot: path.join(dshHome(), 'sessions'),
6380
+ labelPrefix: PLUGIN_LABEL_PREFIX,
6381
+ keepMs: 0,
6382
+ })
6383
+ const byLabel = {}
6384
+ for (const c of scan.candidates) byLabel[c.label] = (byLabel[c.label] || 0) + 1
6385
+ return writeJson(res, 200, {
6386
+ ok: true,
6387
+ mode: 'preview',
6388
+ enabled: engine.config.subagentGcEnabled !== false,
6389
+ keepDays: Number(engine.config.subagentGcKeepDays) || 0,
6390
+ scanned: scan.scanned,
6391
+ subagentSessions: scan.subagents,
6392
+ candidates: scan.candidates.length,
6393
+ bytes: scan.bytes,
6394
+ byLabel,
6395
+ })
6396
+ }
6397
+ if (method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
6398
+ const r = await engine.subagentGcSweep(true)
6399
+ return writeJson(res, r.ok === false ? 400 : 200, r)
6400
+ } catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
6401
+ },
6402
+ },
6029
6403
  {
6030
6404
  kind: 'exact',
6031
6405
  path: API['semantic-download'],
@@ -6792,7 +7166,7 @@ export function apply(ctx, config) {
6792
7166
  })()
6793
7167
  }, 5 * 60 * 1000)
6794
7168
  // 心跳独立 15 秒一次(对齐 polling-lease 心跳节奏),证明轮询机制活着
6795
- const heartbeatTimer = setInterval(() => { void writeHeartbeat(); try { engine.tickTime() } catch (e) {} }, 15000)
7169
+ const heartbeatTimer = setInterval(() => { void writeHeartbeat(); try { engine.tickTime() } catch (e) {}; try { void engine.subagentGcSweep() } catch (e) {} }, 15000)
6796
7170
  void writeHeartbeat() // 立即心跳一次:重启后马上可见轮询存活
6797
7171
 
6798
7172
  // ---------- 注册与清理 ----------
@@ -6831,4 +7205,4 @@ export function apply(ctx, config) {
6831
7205
  }
6832
7206
 
6833
7207
  /** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
6834
- export { sanitizeForWrite, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON }
7208
+ export { sanitizeForWrite, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession }