@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4

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 (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,771 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // fang-session-catalog — 母体只读会话目录(v2.1.0,供门户「选中已有会话 → 继续开发」)。
4
+ //
5
+ // 产品语义(本轮用户需求):左导航「瓮城」→ 插件控制面板 → 在浏览器页里选中已有会话 &
6
+ // 任务继续开发。本插件只做**母体侧只读目录**:
7
+ // * 读 `$DSH_HOME/storages/workspace.json`(工作区索引:path/title/sessionIds/归档/置顶);
8
+ // * 只 stat `$DSH_HOME/sessions/<workspaceKey>/<sessionId>/session.v4.jsonl.zstd`;
9
+ // * 有界头读:只读前 256KiB、只解**第一帧** zstd(多帧日志绝不解整文件);
10
+ // * 返回的文本一律脱敏(token/Bearer/密码/邮箱/手机号/绝对路径);
11
+ // * **不写任何文件、不 spawn、不 import forker/approver**;前端只拿 sessionId,永不接受路径。
12
+ //
13
+ // 角色:仅 mother(roleGate('sessionCatalog')),fork 进程里 apply 首行直接 return。
14
+ // 服务:SERVICE.sessionCatalog = 'fangSessionCatalog'。
15
+ // 路由(exact,prefix 默认 /fang):
16
+ // GET /fang/api/sessions 列表(?limit=&includeArchived=&workspaceId=)
17
+ // GET /fang/api/sessions/item 单个会话元数据(?sessionId=)
18
+ // GET /fang/api/sessions/head 有界头读(?sessionId=&lines=&maxChars=)
19
+ //
20
+ // 空态语义(Lead e2e 验收回归):`$DSH_HOME/sessions` 不存在 = 全新环境「还没有会话」,
21
+ // 是**正常空态**而不是错误 —— GET /fang/api/sessions → 200 + `{ok:true, sessions:[],
22
+ // degraded:['sessions-root-missing']}`,面板照常渲染空态;只有显式非法 sessionId → 400、
23
+ // 单个会话不存在 → 404、digest 超限/数据损坏 → 409/ok:false。
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+ import zlib from 'node:zlib'
27
+
28
+ import {
29
+ z,
30
+ roleGate,
31
+ resolveDshHome,
32
+ resolveFangLayout,
33
+ SERVICE,
34
+ FANG_VERSION,
35
+ } from '../kernel/index.mjs'
36
+ import { createHttpGuard } from '../lib/http-auth.mjs'
37
+ import {
38
+ SESSION_ID_RE,
39
+ SESSION_LOG_FILE,
40
+ WORKSPACE_INDEX_FILE,
41
+ HEAD_DEFAULTS,
42
+ DIGEST_MAX_BYTES,
43
+ isValidSessionId,
44
+ emptyWorkspaceIndex,
45
+ parseWorkspaceIndex,
46
+ encodeWorkspaceKey,
47
+ scanSessionDirs,
48
+ readBoundedHead,
49
+ readBoundedEvents,
50
+ extractSessionTitle,
51
+ extractConversation,
52
+ TITLE_DEFAULTS,
53
+ SEED_DEFAULTS,
54
+ redact,
55
+ } from '../lib/session-format.mjs'
56
+
57
+ export const name = 'fang-session-catalog'
58
+ export const inject = []
59
+
60
+ const DEFAULTS = Object.freeze({
61
+ prefix: '/fang',
62
+ limit: 50,
63
+ maxFileBytes: 262144,
64
+ previewMaxChars: 2048,
65
+ titleMaxFrames: 64,
66
+ titleMaxCompressedBytes: 2 * 1024 * 1024,
67
+ allowArchived: true,
68
+ })
69
+
70
+ export const Config = z.object({
71
+ dataRoot: z.string().default('')
72
+ .description('Fang 数据根(保留键,本插件只读 $DSH_HOME,不写 dataRoot)'),
73
+ sessionsRoot: z.string().default('')
74
+ .description('会话目录;留空 = <DSH_HOME>/sessions'),
75
+ storagesRoot: z.string().default('')
76
+ .description('storages 目录;留空 = <DSH_HOME>/storages(读 workspace.json)'),
77
+ prefix: z.string().default('/fang')
78
+ .description('HTTP 路由前缀(与门户 / client 半保持一致)'),
79
+ limit: z.number().min(1).max(500).default(50)
80
+ .description('列表默认返回条数(请求 ?limit= 会被夹在 1..500)'),
81
+ maxFileBytes: z.number().min(1024).max(67108864).default(262144)
82
+ .description('单次头读的压缩字节预算;多帧日志只解第一帧'),
83
+ titleMaxFrames: z.number().min(1).max(4096).default(64)
84
+ .description('标题提取最多解码的 zstd 帧数(会话标题通常在前几十帧)'),
85
+ titleMaxCompressedBytes: z.number().min(4096).max(67108864).default(2097152)
86
+ .description('标题提取的压缩字节预算(有界读,绝不整文件解压)'),
87
+ previewMaxChars: z.number().min(0).max(65536).default(2048)
88
+ .description('digest 预览的最大字符数(超过 digest 32KiB 上限会 halt 而不是截断)'),
89
+ allowArchived: z.boolean().default(true)
90
+ .description('是否允许在请求 includeArchived 时列出已归档会话'),
91
+ authToken: z.string().default('')
92
+ .description('安装级鉴权 token;留空 = env DSH_FANG_AUTH_TOKEN 或 <dataDir>/fang-auth.json(0600,自动生成)'),
93
+ authEnabled: z.boolean().default(true)
94
+ .description('是否要求 /fang/api/sessions* 携带安装级鉴权 token(默认开启;仅测试/本地调试可关)'),
95
+ })
96
+
97
+ function clampInt(value, min, max, fallback) {
98
+ const n = Math.floor(Number(value))
99
+ if (!Number.isFinite(n)) return fallback
100
+ return Math.min(max, Math.max(min, n))
101
+ }
102
+
103
+ function normalizePrefix(prefix) {
104
+ const raw = String(prefix ?? '/fang').trim().replace(/\\/g, '/')
105
+ if (!raw) return '/fang'
106
+ const withSlash = raw.startsWith('/') ? raw : `/${raw}`
107
+ const trimmed = withSlash.replace(/\/+$/, '')
108
+ return trimmed || '/fang'
109
+ }
110
+
111
+ function isIsoFromMs(ms) {
112
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null
113
+ }
114
+
115
+ /** 目标路径必须严格落在 root 内(防御纵深:sessionId 已经过正则校验)。 */
116
+ function isInside(root, target) {
117
+ const rel = path.relative(path.resolve(root), path.resolve(target))
118
+ return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel)
119
+ }
120
+
121
+ export function apply(ctx, config) {
122
+ const gate = roleGate('sessionCatalog', { logger: ctx.logger })
123
+ if (!gate.active) return
124
+
125
+ let configDefaults = {}
126
+ try {
127
+ configDefaults = Config({}) ?? {}
128
+ } catch {
129
+ configDefaults = {}
130
+ }
131
+ const cfg = { ...DEFAULTS, ...configDefaults, ...(config ?? {}) }
132
+
133
+ const dshHome = resolveDshHome()
134
+ // v3.0.8/B1: these three routes read real session content; they must use the
135
+ // same install-level Host/Origin/token gate as /fang/api/status.
136
+ const authGuard = createHttpGuard({
137
+ dataDir: resolveFangLayout({ dataRoot: cfg.dataRoot }).dataDir,
138
+ token: cfg.authToken,
139
+ enabled: cfg.authEnabled !== false,
140
+ })
141
+ const sessionsRoot = cfg.sessionsRoot ? path.resolve(String(cfg.sessionsRoot)) : path.join(dshHome, 'sessions')
142
+ const storagesRoot = cfg.storagesRoot ? path.resolve(String(cfg.storagesRoot)) : path.join(dshHome, 'storages')
143
+ const workspaceIndexFile = path.join(storagesRoot, WORKSPACE_INDEX_FILE)
144
+ const prefix = normalizePrefix(cfg.prefix)
145
+ const defaultLimit = clampInt(cfg.limit, 1, 500, DEFAULTS.limit)
146
+ const maxFileBytes = clampInt(cfg.maxFileBytes, 1024, 64 * 1024 * 1024, DEFAULTS.maxFileBytes)
147
+ const previewMaxChars = clampInt(cfg.previewMaxChars, 0, 65536, DEFAULTS.previewMaxChars)
148
+ const allowArchived = cfg.allowArchived !== false
149
+ const titleMaxFrames = clampInt(cfg.titleMaxFrames, 1, 4096, DEFAULTS.titleMaxFrames)
150
+ const titleMaxCompressedBytes = clampInt(cfg.titleMaxCompressedBytes, 4096, 64 * 1024 * 1024, DEFAULTS.titleMaxCompressedBytes)
151
+
152
+ /** 注入给纯函数库的 fs 视图(只读方法;库本身零 node 依赖)。 */
153
+ const fsApi = {
154
+ statSync: fs.statSync,
155
+ readdirSync: fs.readdirSync,
156
+ openSync: fs.openSync,
157
+ readSync: fs.readSync,
158
+ closeSync: fs.closeSync,
159
+ }
160
+ const zstdDecompressSync = zlib.zstdDecompressSync
161
+
162
+ const audit = (kind, data) => {
163
+ try {
164
+ const auditor = ctx.get(SERVICE.auditor)
165
+ if (auditor && typeof auditor.append === 'function') auditor.append(kind, data)
166
+ } catch {
167
+ /* 审计失败不得阻塞只读目录 */
168
+ }
169
+ }
170
+
171
+ // ── 只读索引 ─────────────────────────────────────────────────────────────
172
+ const readIndex = () => {
173
+ let raw
174
+ try {
175
+ raw = fs.readFileSync(workspaceIndexFile, 'utf8')
176
+ } catch (e) {
177
+ const code = e?.code === 'ENOENT' ? 'index-not-found' : 'index-read-failed'
178
+ return { ok: false, code, ...emptyWorkspaceIndex([code]), file: workspaceIndexFile }
179
+ }
180
+ const parsed = parseWorkspaceIndex(raw)
181
+ return { ok: true, ...parsed, file: workspaceIndexFile }
182
+ }
183
+
184
+ const keyMapOf = (index) => {
185
+ const map = new Map()
186
+ for (const workspace of index.workspaces) map.set(encodeWorkspaceKey(workspace.path), workspace)
187
+ return map
188
+ }
189
+
190
+ /** 一次完整只读扫描:index + 目录(只 stat,不解压)。 */
191
+ const scan = () => {
192
+ const index = readIndex()
193
+ const keyMap = keyMapOf(index)
194
+ let workspaceDirs = []
195
+ try {
196
+ workspaceDirs = fs.readdirSync(sessionsRoot, { withFileTypes: true })
197
+ .filter((entry) => entry && typeof entry.isDirectory === 'function' && entry.isDirectory())
198
+ .map((entry) => entry.name)
199
+ } catch (e) {
200
+ const code = e?.code === 'ENOENT' ? 'sessions-root-missing' : 'sessions-root-read-failed'
201
+ return { ok: false, code, index, sessions: [], degraded: [...index.degraded, code] }
202
+ }
203
+ const rows = scanSessionDirs({
204
+ sessionsDir: sessionsRoot,
205
+ workspaceIds: workspaceDirs,
206
+ archivedSessionIds: index.global.archivedSessionIds,
207
+ pinnedSessionIds: index.global.pinnedSessionIds,
208
+ fsApi,
209
+ })
210
+ const sessions = rows.map((row) => {
211
+ const workspace = keyMap.get(row.workspaceId) ?? null
212
+ return {
213
+ sessionId: row.sessionId,
214
+ workspaceId: workspace ? workspace.id : null,
215
+ workspaceKey: row.workspaceId,
216
+ workspacePath: workspace ? workspace.path : null,
217
+ workspaceTitle: workspace ? workspace.title : row.workspaceId,
218
+ workspaceKnown: Boolean(workspace),
219
+ updatedAt: row.updatedAt,
220
+ bytes: row.bytes,
221
+ archived: row.archived,
222
+ pinned: row.pinned,
223
+ relPath: row.relPath,
224
+ }
225
+ })
226
+ return { ok: true, index, sessions, degraded: [...index.degraded] }
227
+ }
228
+
229
+ /** 对外视图:只给前端展示/选择所需字段(不暴露绝对日志路径)。 */
230
+ const sessionView = (session) => ({
231
+ sessionId: session.sessionId,
232
+ workspaceId: session.workspaceId,
233
+ workspaceKey: session.workspaceKey,
234
+ workspacePath: session.workspacePath,
235
+ workspaceTitle: session.workspaceTitle,
236
+ workspaceKnown: session.workspaceKnown,
237
+ updatedAt: session.updatedAt,
238
+ updatedAtIso: isIsoFromMs(session.updatedAt),
239
+ bytes: session.bytes,
240
+ archived: session.archived,
241
+ pinned: session.pinned,
242
+ })
243
+
244
+ const logFileFor = (session) => {
245
+ const file = path.resolve(sessionsRoot, session.workspaceKey, session.sessionId, SESSION_LOG_FILE)
246
+ return isInside(sessionsRoot, file) ? file : null
247
+ }
248
+
249
+ // ── 会话标题(有界多帧读 + mtime 缓存)──────────────────────────────
250
+ /** sessionId → { mtimeMs, size, value };日志没变就不重复解帧。 */
251
+ const titleCache = new Map()
252
+
253
+ const titleOf = (session) => {
254
+ const file = logFileFor(session)
255
+ if (!file) return { title: null, titleSource: null, preview: null, degraded: ['no-log-file'] }
256
+ let stat = null
257
+ try { stat = fs.statSync(file) } catch { /* 读不到就走解帧降级 */ }
258
+ const cached = titleCache.get(session.sessionId)
259
+ if (cached && stat && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.value
260
+ const decoded = readBoundedEvents({
261
+ file,
262
+ maxFrames: titleMaxFrames,
263
+ maxCompressedBytes: titleMaxCompressedBytes,
264
+ fsApi,
265
+ zstdDecompressSync,
266
+ })
267
+ const extracted = extractSessionTitle(decoded.events)
268
+ const header = decoded.events.find((event) => event?.type === 'session')
269
+ const userMessages = decoded.events.filter((event) => event?.type === 'user/message').length
270
+ const value = {
271
+ title: extracted.title,
272
+ titleSource: extracted.titleSource,
273
+ preview: extracted.preview,
274
+ messageSeq: extracted.messageSeq,
275
+ createdAt: Number.isFinite(header?.createdAt) ? header.createdAt : null,
276
+ userMessages,
277
+ empty: userMessages === 0,
278
+ framesDecoded: decoded.framesDecoded,
279
+ degraded: decoded.degraded,
280
+ }
281
+ if (stat) titleCache.set(session.sessionId, { mtimeMs: stat.mtimeMs, size: stat.size, value })
282
+ return value
283
+ }
284
+
285
+ const titleFields = (info, fallbackTitle = null) => ({
286
+ title: info.title ?? fallbackTitle ?? null,
287
+ titleSource: info.title ? (info.titleSource ?? 'unknown') : 'workspace',
288
+ preview: info.preview ?? null,
289
+ titleDegraded: info.degraded ?? [],
290
+ createdAt: info.createdAt ?? null,
291
+ createdAtIso: isIsoFromMs(info.createdAt),
292
+ userMessages: info.userMessages ?? 0,
293
+ empty: info.empty === true,
294
+ })
295
+
296
+ /** 按 sessionId 定位(前端只传 id;路径永远由扫描结果推出)。 */
297
+ const findSession = (sessionId) => {
298
+ const raw = typeof sessionId === 'string' ? sessionId : String(sessionId ?? '')
299
+ if (!isValidSessionId(raw)) {
300
+ return { ok: false, code: 'invalid-session-id', sessionId: raw, allowedPattern: String(SESSION_ID_RE) }
301
+ }
302
+ const scanned = scan()
303
+ // 「还没有会话目录」是全新环境的正常空态,不是服务错误:ok:true + readable:false + degraded。
304
+ if (!scanned.ok) {
305
+ return { ok: false, code: 'not-found', sessionId: raw, degraded: scanned.degraded }
306
+ }
307
+ const found = scanned.sessions.find((session) => session.sessionId === raw)
308
+ if (!found) return { ok: false, code: 'not-found', sessionId: raw, sessionsRoot }
309
+ const file = logFileFor(found)
310
+ if (!file) return { ok: false, code: 'path-escape', sessionId: raw }
311
+ return { ok: true, session: found, file, index: scanned.index, degraded: scanned.degraded }
312
+ }
313
+
314
+ // ── service 方法(只读)──────────────────────────────────────────────────
315
+
316
+ const status = () => {
317
+ const scanned = scan()
318
+ const sessions = scanned.sessions ?? []
319
+ const counts = {
320
+ sessions: sessions.length,
321
+ archived: sessions.filter((s) => s.archived).length,
322
+ pinned: sessions.filter((s) => s.pinned).length,
323
+ workspaces: scanned.index.workspaces.length,
324
+ workspaceDirs: new Set(sessions.map((s) => s.workspaceKey)).size,
325
+ }
326
+ return {
327
+ ok: true,
328
+ role: gate.role,
329
+ version: FANG_VERSION,
330
+ readOnly: true,
331
+ readable: scanned.ok,
332
+ sessionsRoot,
333
+ storagesRoot,
334
+ workspaceIndex: workspaceIndexFile,
335
+ workspaceIndexOk: scanned.index.ok !== false,
336
+ limit: defaultLimit,
337
+ maxFileBytes,
338
+ previewMaxChars,
339
+ titleBudget: { maxFrames: titleMaxFrames, maxCompressedBytes: titleMaxCompressedBytes },
340
+ allowArchived,
341
+ counts,
342
+ degraded: scanned.degraded,
343
+ }
344
+ }
345
+
346
+ const listSessions = (options = {}) => {
347
+ const opts = options && typeof options === 'object' ? options : {}
348
+ const scanned = scan()
349
+ const requestedLimit = clampInt(opts.limit ?? defaultLimit, 1, 500, defaultLimit)
350
+ const includeArchivedEffective = allowArchived && opts.includeArchived === true
351
+ // 「sessions 根不存在」= 全新环境 / 还没有任何会话:200 + 空列表 + degraded,不是错误。
352
+ if (!scanned.ok) {
353
+ audit('session-listed', { ok: true, readable: false, code: scanned.code, count: 0 })
354
+ return {
355
+ ok: true,
356
+ readable: false,
357
+ sessions: [],
358
+ total: 0,
359
+ returned: 0,
360
+ limit: requestedLimit,
361
+ includeArchived: includeArchivedEffective,
362
+ degraded: scanned.degraded,
363
+ sessionsRoot,
364
+ storagesRoot,
365
+ }
366
+ }
367
+ const workspaceFilter = typeof opts.workspaceId === 'string' && opts.workspaceId.trim() ? opts.workspaceId.trim() : null
368
+ // v3.1.17:房主 Router 的 Ticket→会话映射键(sessionKey/ticketId)原样带回,
369
+ // 便于 3_Sessions 把「工单」与「母体会话目录」对齐;不参与过滤,只做标注。
370
+ const sessionKey = typeof opts.sessionKey === 'string' && opts.sessionKey.trim() ? opts.sessionKey.trim() : null
371
+ const ticketId = typeof opts.ticketId === 'string' && opts.ticketId.trim() ? opts.ticketId.trim() : null
372
+
373
+ let items = scanned.sessions
374
+ if (!includeArchivedEffective) items = items.filter((session) => !session.archived)
375
+ if (workspaceFilter) {
376
+ items = items.filter((session) => session.workspaceId === workspaceFilter
377
+ || session.workspaceKey === workspaceFilter
378
+ || session.workspacePath === workspaceFilter)
379
+ }
380
+ const total = items.length
381
+ const page = items.slice(0, requestedLimit)
382
+ audit('session-listed', {
383
+ ok: true,
384
+ count: page.length,
385
+ total,
386
+ includeArchived: includeArchivedEffective,
387
+ workspaceId: workspaceFilter,
388
+ limit: requestedLimit,
389
+ })
390
+ return {
391
+ ok: true,
392
+ sessions: page.map((session) => ({
393
+ ...sessionView(session),
394
+ ...titleFields(titleOf(session), session.workspaceTitle),
395
+ ...(sessionKey ? { sessionKey } : {}),
396
+ ...(ticketId ? { ticketId } : {}),
397
+ })),
398
+ ...(sessionKey ? { sessionKey } : {}),
399
+ ...(ticketId ? { ticketId } : {}),
400
+ total,
401
+ returned: page.length,
402
+ limit: requestedLimit,
403
+ includeArchived: includeArchivedEffective,
404
+ degraded: scanned.degraded,
405
+ sessionsRoot,
406
+ }
407
+ }
408
+
409
+ const getSession = (sessionId) => {
410
+ const found = findSession(sessionId)
411
+ if (!found.ok) return found
412
+ const session = found.session
413
+ const head = readBoundedHead({
414
+ file: found.file,
415
+ maxCompressedBytes: maxFileBytes,
416
+ maxLines: 1,
417
+ maxChars: 4096,
418
+ fsApi,
419
+ zstdDecompressSync,
420
+ })
421
+ const degraded = [...new Set([...(found.degraded ?? []), ...head.degraded])]
422
+ let header = null
423
+ if (head.lines.length > 0) {
424
+ try {
425
+ const parsed = JSON.parse(head.lines[0])
426
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) header = parsed
427
+ else degraded.push('header-unparsable')
428
+ } catch {
429
+ degraded.push('header-unparsable')
430
+ }
431
+ } else if (!degraded.includes('decompress-failed')) {
432
+ degraded.push('header-missing')
433
+ }
434
+ const createdAt = Number.isFinite(header?.createdAt) ? header.createdAt : null
435
+ const view = {
436
+ ...sessionView(session),
437
+ createdAt,
438
+ createdAtIso: isIsoFromMs(createdAt),
439
+ cwd: typeof header?.cwd === 'string' ? header.cwd : null,
440
+ agentPreset: typeof header?.agentPreset === 'string' ? header.agentPreset : null,
441
+ isSeeded: header?.isSeeded === true,
442
+ delegationDepth: Number.isFinite(header?.delegationDepth) ? header.delegationDepth : null,
443
+ sessionVersion: Number.isFinite(header?.version) ? header.version : null,
444
+ header,
445
+ logFile: SESSION_LOG_FILE,
446
+ head: { lines: head.lines.length, bytesRead: head.bytesRead, degraded: head.degraded },
447
+ ...titleFields(titleOf(session), session.workspaceTitle),
448
+ }
449
+ audit('session-read', { ok: true, sessionId: session.sessionId, op: 'get', bytesRead: head.bytesRead })
450
+ return { ok: true, session: view, degraded }
451
+ }
452
+
453
+ const readHead = (sessionId, options = {}) => {
454
+ const found = findSession(sessionId)
455
+ if (!found.ok) return found
456
+ const opts = options && typeof options === 'object' ? options : {}
457
+ const maxLines = clampInt(opts.maxLines ?? HEAD_DEFAULTS.maxLines, 1, 1000, HEAD_DEFAULTS.maxLines)
458
+ const maxChars = clampInt(opts.maxChars ?? HEAD_DEFAULTS.maxChars, 0, 16 * 1024 * 1024, HEAD_DEFAULTS.maxChars)
459
+ const maxCompressedBytes = clampInt(opts.maxCompressedBytes ?? maxFileBytes, 4, 64 * 1024 * 1024, maxFileBytes)
460
+ const head = readBoundedHead({
461
+ file: found.file,
462
+ maxCompressedBytes,
463
+ maxLines,
464
+ maxChars,
465
+ fsApi,
466
+ zstdDecompressSync,
467
+ })
468
+ let redactions = 0
469
+ const lines = head.lines.map((line) => {
470
+ const redacted = redact(line)
471
+ redactions += redacted.redactions
472
+ return redacted.text
473
+ })
474
+ audit('session-read', {
475
+ ok: true,
476
+ sessionId: found.session.sessionId,
477
+ op: 'head',
478
+ lines: lines.length,
479
+ bytesRead: head.bytesRead,
480
+ redactions,
481
+ })
482
+ return {
483
+ ok: true,
484
+ sessionId: found.session.sessionId,
485
+ workspaceId: found.session.workspaceId,
486
+ lines,
487
+ redactions,
488
+ bytesRead: head.bytesRead,
489
+ degraded: [...new Set([...(found.degraded ?? []), ...head.degraded])],
490
+ }
491
+ }
492
+
493
+ const buildDigest = (session, lines, degraded) => {
494
+ const parts = []
495
+ parts.push(`# 会话 ${session.sessionId}`)
496
+ parts.push('')
497
+ parts.push(`- 工作区: ${session.workspaceTitle}${session.workspaceKnown ? ` (${session.workspacePath})` : '(未在 workspace.json 登记)'}`)
498
+ parts.push(`- sessionId: \`${session.sessionId}\``)
499
+ parts.push(`- 创建: ${session.createdAtIso ?? 'unknown'} / 最近更新: ${session.updatedAtIso ?? 'unknown'}`)
500
+ parts.push(`- 日志: ${session.bytes} bytes(头读 ${lines.bytesRead ?? 0} bytes,只解第一帧)`)
501
+ parts.push(`- 归档: ${session.archived ? 'yes' : 'no'} / 置顶: ${session.pinned ? 'yes' : 'no'}`)
502
+ if (session.agentPreset) parts.push(`- agentPreset: ${session.agentPreset}`)
503
+ if (Number.isFinite(session.sessionVersion)) parts.push(`- session format: v${session.sessionVersion}`)
504
+ if (session.cwd) parts.push(`- cwd: ${session.cwd}`)
505
+ parts.push('')
506
+ parts.push('## 头部事件(已脱敏)')
507
+ parts.push('')
508
+ if (lines.lines.length === 0) {
509
+ parts.push('_(头读没有解出任何行)_')
510
+ } else {
511
+ parts.push('```jsonl')
512
+ for (const line of lines.lines) parts.push(line)
513
+ parts.push('```')
514
+ }
515
+ if (degraded.length > 0) {
516
+ parts.push('')
517
+ parts.push(`> degraded: ${degraded.join(', ')}`)
518
+ }
519
+ return parts.join('\n')
520
+ }
521
+
522
+ const digest = (sessionId) => {
523
+ const got = getSession(sessionId)
524
+ if (!got.ok) return got
525
+ const session = got.session
526
+ const head = readHead(sessionId, { maxLines: 20, maxChars: previewMaxChars })
527
+ const lines = head.ok ? head : { lines: [], bytesRead: 0, degraded: [] }
528
+ const degraded = [...new Set([
529
+ ...(got.degraded ?? []),
530
+ ...(head.ok ? head.degraded : [head.code ?? 'head-failed']),
531
+ ])]
532
+ const markdown = buildDigest(session, lines, degraded)
533
+ const bytes = Buffer.byteLength(markdown, 'utf8')
534
+ if (bytes > DIGEST_MAX_BYTES) {
535
+ // 契约:超限 halt,不静默截断(调用方要么收紧 previewMaxChars,要么只读 head)。
536
+ return {
537
+ ok: false,
538
+ code: 'digest-too-large',
539
+ bytes,
540
+ limit: DIGEST_MAX_BYTES,
541
+ sessionId: session.sessionId,
542
+ degraded: [...degraded, 'digest-too-large'],
543
+ }
544
+ }
545
+ audit('session-read', { ok: true, sessionId: session.sessionId, op: 'digest', bytes })
546
+ return {
547
+ ok: true,
548
+ sessionId: session.sessionId,
549
+ // `markdown` 是 orchestrator.derive() 读取的字段(lineage seed);`digest` 为等价别名。
550
+ markdown,
551
+ digest: markdown,
552
+ bytes,
553
+ limit: DIGEST_MAX_BYTES,
554
+ redactions: lines.redactions ?? 0,
555
+ degraded,
556
+ }
557
+ }
558
+
559
+ /**
560
+ * 会话交接种子:有界多帧读取 + 对话摘录,供派生分身时写入 fang-seeds 并导入历史会话。
561
+ * 空会话(没有任何 user/message)显式返回 session-empty,不伪造交接内容。
562
+ */
563
+ const seed = (sessionId, options = {}) => {
564
+ const found = findSession(sessionId)
565
+ if (!found.ok) return found
566
+ const opts = options && typeof options === 'object' ? options : {}
567
+ const maxFrames = clampInt(opts.maxFrames ?? SEED_DEFAULTS.maxFrames, 8, 4096, SEED_DEFAULTS.maxFrames)
568
+ const maxCompressedBytes = clampInt(opts.maxCompressedBytes ?? SEED_DEFAULTS.maxCompressedBytes, 4096, 256 * 1024 * 1024, SEED_DEFAULTS.maxCompressedBytes)
569
+ const maxChars = clampInt(opts.maxChars ?? SEED_DEFAULTS.maxChars, 1024, 256 * 1024, SEED_DEFAULTS.maxChars)
570
+ const decoded = readBoundedEvents({ file: found.file, maxFrames, maxCompressedBytes, fsApi, zstdDecompressSync })
571
+ const meta = extractSessionTitle(decoded.events)
572
+ const conv = extractConversation(decoded.events, { maxChars })
573
+ if (conv.messages === 0) {
574
+ return {
575
+ ok: false,
576
+ code: 'session-empty',
577
+ sessionId: found.session.sessionId,
578
+ degraded: decoded.degraded,
579
+ framesDecoded: decoded.framesDecoded,
580
+ }
581
+ }
582
+ const header = decoded.events.find((event) => event.type === 'session')
583
+ const session = found.session
584
+ const title = meta.title ?? '未命名会话'
585
+ const lines = []
586
+ lines.push(`# 会话交接:${title}`)
587
+ lines.push('')
588
+ lines.push(`- sessionId: \`${session.sessionId}\``)
589
+ lines.push(`- 工作区: ${session.workspaceTitle}${session.workspacePath ? ` (${session.workspacePath})` : '(未在 workspace.json 登记)'}`)
590
+ if (Number.isFinite(header?.createdAt)) lines.push(`- 创建: ${new Date(header.createdAt).toISOString()}`)
591
+ if (Number.isFinite(session.updatedAt)) lines.push(`- 最近更新: ${new Date(session.updatedAt).toISOString()}`)
592
+ if (typeof header?.cwd === 'string') lines.push(`- cwd: ${header.cwd}`)
593
+ if (typeof header?.agentPreset === 'string') lines.push(`- agentPreset: ${header.agentPreset}`)
594
+ lines.push(`- 摘录: ${conv.messages} 条消息 / 最多 ${maxChars} 字符${conv.truncated ? '(已截断)' : ''}`)
595
+ lines.push('')
596
+ lines.push('## 对话摘录')
597
+ lines.push('')
598
+ lines.push(conv.markdown || '_(无对话内容)_')
599
+ if (decoded.degraded.length > 0) {
600
+ lines.push('')
601
+ lines.push(`> degraded: ${decoded.degraded.join(', ')}`)
602
+ }
603
+ const markdown = lines.join('\n')
604
+ return {
605
+ ok: true,
606
+ sessionId: session.sessionId,
607
+ title,
608
+ markdown,
609
+ bytes: Buffer.byteLength(markdown, 'utf8'),
610
+ messages: conv.messages,
611
+ truncated: conv.truncated,
612
+ framesDecoded: decoded.framesDecoded,
613
+ degraded: decoded.degraded,
614
+ sourceFile: found.file,
615
+ sourceBytes: session.bytes,
616
+ workspaceId: session.workspaceId,
617
+ workspacePath: session.workspacePath,
618
+ workspaceTitle: session.workspaceTitle,
619
+ createdAt: Number.isFinite(header?.createdAt) ? header.createdAt : null,
620
+ updatedAt: session.updatedAt,
621
+ cwd: typeof header?.cwd === 'string' ? header.cwd : null,
622
+ agentPreset: typeof header?.agentPreset === 'string' ? header.agentPreset : null,
623
+ }
624
+ }
625
+
626
+ const service = {
627
+ role: gate.role,
628
+ version: FANG_VERSION,
629
+ /** 只读能力自述:任何调用方都不得通过本服务写盘。 */
630
+ readOnly: true,
631
+ sessionsRoot,
632
+ storagesRoot,
633
+ routes: [],
634
+
635
+ status,
636
+ listSessions,
637
+ getSession,
638
+ readHead,
639
+ digest,
640
+ seed,
641
+ }
642
+
643
+ ctx.effect(() => ctx.provide(SERVICE.sessionCatalog, service))
644
+
645
+ // ── HTTP 路由(处理器与 service 共用同一实现)────────────────────────────
646
+ const json = (res, code, obj) => {
647
+ try {
648
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
649
+ } catch {
650
+ /* 假 res 不能把处理器打挂 */
651
+ }
652
+ try {
653
+ res.end(JSON.stringify(obj))
654
+ } catch {
655
+ /* 同上 */
656
+ }
657
+ }
658
+
659
+ const statusForCode = (code) => {
660
+ switch (String(code ?? '')) {
661
+ case 'invalid-session-id':
662
+ case 'bad-request':
663
+ return 400
664
+ case 'not-found':
665
+ return 404
666
+ case 'path-escape':
667
+ case 'sessions-root-missing':
668
+ case 'sessions-root-read-failed':
669
+ case 'index-read-failed':
670
+ case 'digest-too-large':
671
+ return 409
672
+ default:
673
+ return 500
674
+ }
675
+ }
676
+
677
+ const sendResult = (res, out) => json(res, out && out.ok !== false ? 200 : statusForCode(out?.code), out)
678
+
679
+ const parseQuery = (rawUrl) => {
680
+ try {
681
+ const url = new URL(String(rawUrl ?? '/'), 'http://127.0.0.1')
682
+ return url.searchParams
683
+ } catch {
684
+ return new URLSearchParams()
685
+ }
686
+ }
687
+ const paramNumber = (params, name) => {
688
+ if (!params.has(name)) return undefined
689
+ const value = Number(params.get(name))
690
+ return Number.isFinite(value) ? value : undefined
691
+ }
692
+
693
+ const registerRoutes = (webServer) => {
694
+ const route = (method, routePath, handler) => {
695
+ webServer.register({
696
+ kind: 'exact',
697
+ path: routePath,
698
+ method,
699
+ handler: async (req, res) => {
700
+ // Auth before method: a token-less POST must be 401, not 405 (fail-closed).
701
+ const decision = authGuard.check(req)
702
+ if (!decision.ok) {
703
+ return json(res, decision.status, { ok: false, error: decision.code, code: decision.code })
704
+ }
705
+ const actual = String(req?.method ?? 'GET').toUpperCase()
706
+ if (actual !== method) {
707
+ try { res.setHeader?.('allow', method) } catch { /* fake res */ }
708
+ return json(res, 405, { ok: false, code: 'method-not-allowed', error: 'method-not-allowed', path: routePath })
709
+ }
710
+ try {
711
+ return await handler(req, res)
712
+ } catch (e) {
713
+ return json(res, 500, { ok: false, code: 'handler-threw', error: String(e?.message ?? e) })
714
+ }
715
+ },
716
+ })
717
+ service.routes.push(routePath)
718
+ }
719
+
720
+ route('GET', `${prefix}/api/sessions`, (req, res) => {
721
+ const params = parseQuery(req?.url)
722
+ const includeRaw = params.get('includeArchived')
723
+ const includeArchived = includeRaw === '1' || includeRaw === 'true' || includeRaw === 'yes'
724
+ return sendResult(res, listSessions({
725
+ limit: paramNumber(params, 'limit'),
726
+ includeArchived,
727
+ workspaceId: params.get('workspaceId') ?? undefined,
728
+ }))
729
+ })
730
+
731
+ route('GET', `${prefix}/api/sessions/item`, (req, res) => {
732
+ const params = parseQuery(req?.url)
733
+ return sendResult(res, getSession(params.get('sessionId') ?? ''))
734
+ })
735
+
736
+ route('GET', `${prefix}/api/sessions/head`, (req, res) => {
737
+ const params = parseQuery(req?.url)
738
+ return sendResult(res, readHead(params.get('sessionId') ?? '', {
739
+ maxLines: paramNumber(params, 'lines'),
740
+ maxChars: paramNumber(params, 'maxChars'),
741
+ maxCompressedBytes: paramNumber(params, 'maxCompressedBytes'),
742
+ }))
743
+ })
744
+ }
745
+
746
+ try {
747
+ if (typeof ctx.inject === 'function') {
748
+ ctx.inject(['webServer'], (child) => {
749
+ let webServer = null
750
+ try {
751
+ webServer = child?.get?.('webServer') ?? child?.webServer ?? null
752
+ } catch {
753
+ webServer = null
754
+ }
755
+ if (!webServer || typeof webServer.register !== 'function') return
756
+ registerRoutes(webServer)
757
+ })
758
+ }
759
+ } catch {
760
+ /* 没有 webServer 时 service 仍可被宿主内其它插件(orchestrator seed)直接调用 */
761
+ }
762
+
763
+ return service
764
+ }
765
+
766
+ export const _internals = {
767
+ DEFAULTS,
768
+ normalizePrefix,
769
+ isInside,
770
+ clampInt,
771
+ }