thincoder 0.12.59 → 0.12.61

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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -0,0 +1,265 @@
1
+ /**
2
+ * peer-domains.mjs — R10 多实例协作 L3 文件写域登记 + 冲突检测(MULTI-INSTANCE-COLLAB
3
+ * §2a.5——评审修正 #8:独立文件,防 300 行超限)。
4
+ *
5
+ * 登记存储(D-L3a):~/.thincoder/peers/{sessionId}.json——每实例单文件、单写者(本
6
+ * 进程——end marker 同型模式);内容 {sessionId, pid, end, cwd, domains[绝对路径],
7
+ * updatedAt}。写点 = 回合级:写工具钩子累积"本回合实际写过的文件"(D-L3b 同一钩子——
8
+ * 检测+记录一次完成)→ 回合结束 flush 整写一次(finalizeAgentTurn);本回合无写入则
9
+ * 不写(文件按自身 updatedAt 自然过期——hot 5 分钟窗口语义不被空回合提前清掉)。
10
+ *
11
+ * 冲突检测(D-L3b):结构化写工具(PEER_WRITE_TOOLS)执行前查 conflicts——命中他实例
12
+ * hot 域 → 工具结果附软提示(决策⑥ A:不阻止)。缓存(评审修正 #2):聚合结果按 peers
13
+ * 目录 mtime 惰性缓存——目录未变零扫描;单次写工具调用新增开销上限 = 目录 stat 一次
14
+ * (N3 度量)。崩溃残留:聚合时惰性清理(死 pid 文件删除——batchAlive 一次批量);
15
+ * 损坏文件按缺失降级(end marker NF2 同型——不删不炸,属主下次 flush 覆盖)。
16
+ */
17
+
18
+ import { statSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
19
+ import { join, resolve, sep } from "node:path"
20
+ import { configDir } from "./config.mjs"
21
+ import { normalizeCwd, getSessionId, END, writeSessionFile } from "./session-slots.mjs"
22
+ import { batchAlive } from "./peer-instances.mjs"
23
+ import { FILE_MUTATORS } from "./agent/helpers.mjs"
24
+
25
+ /** hot 窗口:登记(回合足迹 flush)后 5 分钟内视为 hot(决策⑤ A——"刚写过"语义) */
26
+ export const HOT_WINDOW_MS = 5 * 60 * 1000
27
+
28
+ /** L3 覆盖的结构化写工具(D-L3b 清单)——既有 FILE_MUTATORS(write/edit/insert_after/
29
+ * hashline_edit/apply_patch/delete)∪ file_ops。insert_after/hashline_edit 亦为结构化
30
+ * 写工具(同一文件写入面),file_ops 在 FILE_MUTATORS 之外故显式并入。 */
31
+ export const PEER_WRITE_TOOLS = new Set([...FILE_MUTATORS, "file_ops"])
32
+
33
+ export function peersDir() {
34
+ return join(configDir, "peers")
35
+ }
36
+
37
+ export function peerFilePath(sessionId) {
38
+ return join(peersDir(), `${sessionId}.json`)
39
+ }
40
+
41
+ // 模块级测试注入缝(default null = 生产实现;测试注入 + finally 恢复)
42
+ let _testAliveFn = null
43
+
44
+ /** 注入聚合判活实现(批量判活计数/去真实 exec——T-L3b 死清理用例可用真死 pid 亦可注入)。 */
45
+ export function _setPeerDomainsTestImpl({ aliveFn = undefined } = {}) {
46
+ const prev = _testAliveFn
47
+ _testAliveFn = aliveFn ?? null
48
+ peersDirCache = null // 注入即环境变更——目录缓存失效(测试间不串)
49
+ return prev
50
+ }
51
+
52
+ export function _resetPeerDomainsTestImpl() {
53
+ _testAliveFn = null
54
+ peersDirCache = null
55
+ }
56
+
57
+ // peers 目录 mtime 惰性缓存(评审修正 #2)——单全局项(peers 目录全局唯一)
58
+ let peersDirCache = null // { mtimeMs, peers: [...] }
59
+
60
+ function statDirMtimeMs(dir) {
61
+ try {
62
+ return statSync(dir).mtimeMs
63
+ } catch {
64
+ return null // 目录缺失
65
+ }
66
+ }
67
+
68
+ /** 目标路径解析(工具 touchedPaths 优先——apply_patch/edit-batch 多文件;file_ops 无
69
+ * touchedPaths——source/dest 双算;其余 path 单参兜底)。相对路径按 cwd 解析为绝对
70
+ * 路径(resolve:绝对入参原样保留)。解析失败/畸形入参跳过(零目标 = 无检测无登记)。 */
71
+ export function peerWriteTargets(tool, args, cwd) {
72
+ const raw = tool?.touchedPaths
73
+ ? tool.touchedPaths(args ?? {})
74
+ : tool?.name === "file_ops"
75
+ ? [args?.source, args?.dest]
76
+ : [args?.path]
77
+ const out = []
78
+ for (const p of raw) {
79
+ if (typeof p !== "string" || p.length === 0) continue
80
+ try {
81
+ out.push(resolve(cwd, p))
82
+ } catch {
83
+ /* 畸形路径跳过——检测/登记尽力而为 */
84
+ }
85
+ }
86
+ return out
87
+ }
88
+
89
+ /** 路径重叠:a 与 b 相等或互为目录包含(file_ops 目录级操作/delete 整目录语义——
90
+ * 区分大小写按平台(Windows 文件系统不区分);分隔符归一(resolve 产物为原生分隔符,
91
+ * 入参可能混用 / 与 \)。 */
92
+ export function pathsOverlap(a, b) {
93
+ const norm = (p) => {
94
+ const n = process.platform === "win32" ? p.toLowerCase() : p
95
+ return (process.platform === "win32" ? n.replace(/\//g, "\\") : n.replace(/\\/g, "/")).replace(/[\\/]+$/, "")
96
+ }
97
+ const pa = norm(a)
98
+ const pb = norm(b)
99
+ if (pa === pb) return true
100
+ const sepN = process.platform === "win32" ? "\\" : "/"
101
+ const prefix = (p, q) => p === q || p.startsWith(q + sepN)
102
+ return prefix(pa, pb) || prefix(pb, pa)
103
+ }
104
+
105
+ /** 扫描 peers 目录:解析每文件 + 一次批量判活全部 pid + 死登记惰性清理(unlink——
106
+ * 崩溃残留);损坏文件按缺失降级(不删——属主下次 flush 覆盖)。返回活登记
107
+ * [{ sessionId, pid, end, cwd, domains, updatedAt }]。目录缺失/不可读 → []。 */
108
+ function scanPeersDir() {
109
+ let files = []
110
+ try {
111
+ files = readdirSync(peersDir(), { withFileTypes: true })
112
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
113
+ .map((e) => join(peersDir(), e.name))
114
+ } catch {
115
+ return [] // 目录缺失(从未 flush)→ 无登记
116
+ }
117
+ const parsed = []
118
+ for (const file of files) {
119
+ try {
120
+ const rec = JSON.parse(readFileSync(file, "utf8"))
121
+ if (rec && typeof rec === "object" && typeof rec.sessionId === "string" && rec.sessionId.length > 0
122
+ && Number.isInteger(Number(rec.pid)) && Number(rec.pid) > 0
123
+ && typeof rec.cwd === "string" && Array.isArray(rec.domains)) {
124
+ parsed.push({ file, rec })
125
+ }
126
+ // 结构非法 = 损坏 → 按缺失降级(不删——NF2 同型)
127
+ } catch {
128
+ /* 解析失败同损坏 */
129
+ }
130
+ }
131
+ if (parsed.length === 0) return []
132
+ const aliveFn = _testAliveFn ?? batchAlive
133
+ const alive = aliveFn([...new Set(parsed.map((p) => Number(p.rec.pid)))])
134
+ // 判活探测失败(batchAlive → null)≠ 死:不得据此执行死清理(unlink 是破坏性副作用)
135
+ // ——返回 null 信号:调用方不缓存空聚合(下轮调用重试探测)
136
+ if (!(alive instanceof Set)) return null
137
+ const live = []
138
+ for (const { file, rec } of parsed) {
139
+ if (!alive.has(Number(rec.pid))) {
140
+ try {
141
+ // 死登记惰性清理(崩溃残留——聚合时一次批量判活顺带删除)
142
+ unlinkSync(file)
143
+ } catch { /* unlink 失败不影响聚合(下次再试) */ }
144
+ continue
145
+ }
146
+ live.push({
147
+ sessionId: rec.sessionId,
148
+ pid: Number(rec.pid),
149
+ end: typeof rec.end === "string" ? rec.end : undefined,
150
+ cwd: rec.cwd,
151
+ domains: rec.domains.filter((d) => typeof d === "string"),
152
+ updatedAt: Number(rec.updatedAt) || 0,
153
+ })
154
+ }
155
+ return live
156
+ }
157
+
158
+ /** peers 目录 mtime 惰性缓存聚合(评审修正 #2——目录未变不重扫)。返回当前目录下全部
159
+ * 活登记(含他 cwd 实例——死清理跨 cwd 一次批量;cwd 过滤在 peerDomains)。 */
160
+ function cachedScan() {
161
+ const dir = peersDir()
162
+ const mtime = statDirMtimeMs(dir)
163
+ if (mtime == null) {
164
+ peersDirCache = null
165
+ return []
166
+ }
167
+ if (peersDirCache && peersDirCache.mtimeMs === mtime) return peersDirCache.peers
168
+ const peers = scanPeersDir()
169
+ if (peers === null) {
170
+ peersDirCache = null // 探测失败——不缓存空聚合(下轮调用重试探测——失败 ≠ 无登记)
171
+ return []
172
+ }
173
+ // 死清理删文件会改目录 mtime——以清理后的 mtime 缓存(下次 stat 命中即不重扫)
174
+ peersDirCache = { mtimeMs: statDirMtimeMs(dir) ?? mtime, peers }
175
+ return peers
176
+ }
177
+
178
+ /**
179
+ * 聚合(D-L3a):本 cwd(normalizeCwd 同构比较)的其他活实例登记(self 排除——
180
+ * sessionId === getSessionId())。返回 [{ sessionId, pid, end, cwd, domains, updatedAt }]。
181
+ * 纯只读;缓存命中 = 目录 stat 一次(N3)。
182
+ */
183
+ export function peerDomains(cwd) {
184
+ const normCwd = normalizeCwd(cwd)
185
+ const myId = getSessionId()
186
+ return cachedScan().filter((p) => p.sessionId !== myId && normalizeCwd(p.cwd) === normCwd)
187
+ }
188
+
189
+ /**
190
+ * 冲突检测(D-L3b——写前查询):targets(绝对路径)命中他实例 hot 域(updatedAt 在
191
+ * HOT_WINDOW_MS 内且域含 target/互为包含)→ 返回 [{ target, by: [{end, pid, sessionId}] }]。
192
+ * 无冲突/无目标 → []。纯只读、不抛(内部全降级——失败按零冲突)。
193
+ */
194
+ export function conflicts(cwd, targets, { now = Date.now() } = {}) {
195
+ if (!Array.isArray(targets) || targets.length === 0) return []
196
+ let peers = []
197
+ try {
198
+ peers = peerDomains(cwd)
199
+ } catch {
200
+ return []
201
+ }
202
+ const hot = peers.filter((p) => p.domains.length > 0 && now - p.updatedAt <= HOT_WINDOW_MS)
203
+ if (hot.length === 0) return []
204
+ const hits = []
205
+ for (const target of targets) {
206
+ const by = hot.filter((p) => p.domains.some((d) => pathsOverlap(target, d)))
207
+ if (by.length > 0) hits.push({ target, by: by.map((p) => ({ end: p.end, pid: p.pid, sessionId: p.sessionId })) })
208
+ }
209
+ return hits
210
+ }
211
+
212
+ /** dispatch 写工具钩子预检:命中他实例 hot 域 → 软提示文案(null = 无冲突/无目标/
213
+ * 降级——调用方零附加)。一次目录 stat(缓存命中零扫描——N3 度量)。 */
214
+ export function peerCollabNote(cwd, tool, args) {
215
+ try {
216
+ const targets = peerWriteTargets(tool, args, cwd)
217
+ if (targets.length === 0) return null
218
+ const hits = conflicts(cwd, targets)
219
+ if (hits.length === 0) return null
220
+ return hits.map((h) => {
221
+ const who = h.by.map((p) => (p.end ? `${p.end} pid=${p.pid}` : `pid=${p.pid}`)).join(", ")
222
+ return `[peer-collab] ${h.target} — another live instance (${who}) registered writing it within the last 5 minutes; concurrent edits may overwrite each other. Write not blocked — coordinate before proceeding.`
223
+ }).join("\n")
224
+ } catch {
225
+ return null // 感知失败绝不打扰工具执行
226
+ }
227
+ }
228
+
229
+ /** 记录本回合写足迹(D-L3a 累积——D-L3b 同一钩子执行后调用;仅成功写计入"实际写过")。
230
+ * agent._peerWritten: Set<绝对路径>(回合级——finalizeAgentTurn flush 时整写并清空)。 */
231
+ export function recordPeerWrites(agent, tool, args) {
232
+ try {
233
+ const targets = peerWriteTargets(tool, args, agent.cwd)
234
+ if (targets.length === 0) return
235
+ agent._peerWritten ??= new Set()
236
+ for (const t of targets) agent._peerWritten.add(t)
237
+ } catch {
238
+ /* 记录失败不影响工具结果 */
239
+ }
240
+ }
241
+
242
+ /**
243
+ * 回合末登记 flush(D-L3a——finalizeAgentTurn 调用):本回合足迹整写一次本实例文件
244
+ * (单写者;.tmp+rename 原子——writeSessionFile)。本回合无写入 → 不写(文件按自身
245
+ * updatedAt 自然过期——hot 窗口不被空回合提前清掉;低频 N3)。写失败容忍(NF2——end
246
+ * marker 同型);成功 flush 后清空回合集合。不抛(调用方回合收尾零风险)。
247
+ */
248
+ export function flushPeerDomains(agent) {
249
+ try {
250
+ const set = agent._peerWritten
251
+ if (!set || set.size === 0) return
252
+ const sessionId = getSessionId()
253
+ writeSessionFile(peerFilePath(sessionId), {
254
+ sessionId,
255
+ pid: process.pid,
256
+ end: END,
257
+ cwd: normalizeCwd(agent.cwd),
258
+ domains: [...set],
259
+ updatedAt: Date.now(),
260
+ })
261
+ set.clear()
262
+ } catch {
263
+ /* NF2:失败容忍——下次 flush 重写 */
264
+ }
265
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * peer-instances.mjs — R10 多实例协作感知面 L1/L2(MULTI-INSTANCE-COLLAB §2a.4)。
3
+ *
4
+ * 目标:让同 cwd 的多个 thincoder 副本在 agent 层互相感知。数据源 = 既有 SESSION.md
5
+ * §10 基建(manifest slotSessions / getSessionId——不新建平行存储,N1),纯只读(N3
6
+ * ——不认领、不写 manifest——本模块结构化上没有任何 fs 写调用)。
7
+ *
8
+ * - peerInstances(cwd):读 manifest slotSessions → 按 sessionId 去重分组 → slots;
9
+ * 一次批量判活(batchAlive——修复 isProcessAlive 每 pid 一次 execSync 的成本);
10
+ * 端字段(决策③ A:批量 cmdline 探测)区分 Code.exe/扩展宿主(vscode)vs
11
+ * node/thincoder(cli);self = sessionId === getSessionId()。
12
+ * - 惰性:manifest mtime 缓存(变了才重查——活实例变化必伴随 manifest 写——仿
13
+ * agent._slotMtime 先例)。manifest 缺失 → 空清单(不缓存——stat 一次成本)。
14
+ * - 测试注入缝:_setPeerInstancesTestImpl({ aliveFn, cmdlineFn })——default null
15
+ * 生产行为不变;测试 restore in finally。
16
+ * - peerInstancesTool:L2 只读查询工具(schema description 逐字锚——评审修正 #7,
17
+ * 双端照抄);无参、去 self、字段白名单 {pid, end, sessionId, slots}(N4)。
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process"
21
+ import { statSync } from "node:fs"
22
+ import { manifestPath, loadManifest, getSessionId, END } from "./session-slots.mjs"
23
+
24
+ const ALIVE_EXEC_TIMEOUT_MS = 10_000
25
+ const CMDLINE_EXEC_TIMEOUT_MS = 15_000
26
+ const CACHE_MAX = 64
27
+
28
+ /** VS Code 扩展宿主判别标记(决策③ A——cmdline 探测):扩展宿主进程 argv 必带其一
29
+ * (Windows:Code.exe --type=extensionHost / --extensionDevelopmentPath;Unix 同)。 */
30
+ const VSC_END_RE = /--extensionDevelopmentPath|--type=extensionHost|extensionHostProcess/i
31
+
32
+ // 模块级测试注入缝(default null = 生产实现;测试注入 + finally 恢复——见测试文件)
33
+ let _testImpl = null
34
+
35
+ /** 注入测试实现。aliveFn(pids) → Set<pid>;cmdlineFn(pids) → Map<pid,cmdline>|null。
36
+ * 返回前值便于测试保存恢复;传 {} 只清空对应槽。 */
37
+ export function _setPeerInstancesTestImpl({ aliveFn = undefined, cmdlineFn = undefined } = {}) {
38
+ const prev = _testImpl
39
+ _testImpl = { aliveFn: aliveFn ?? null, cmdlineFn: cmdlineFn ?? null }
40
+ peerInstanceCache.clear() // 注入即环境变更——缓存必须失效(测试间不串)
41
+ return prev
42
+ }
43
+
44
+ export function _resetPeerInstancesTestImpl() {
45
+ _testImpl = null
46
+ peerInstanceCache.clear()
47
+ }
48
+
49
+ /** manifest mtime 惰性缓存:cwd → { mtimeMs, peers }。缓存有上限(CACHE_MAX——旧条目
50
+ * 先出);条目是纯函数结果快照,无锁无句柄——陈旧只影响新鲜度不影响正确性。 */
51
+ const peerInstanceCache = new Map()
52
+
53
+ function statMtimeMs(p) {
54
+ try {
55
+ const st = statSync(p)
56
+ return st.mtimeMs
57
+ } catch {
58
+ return null // 文件缺失/不可读
59
+ }
60
+ }
61
+
62
+ function cachePut(cwd, entry) {
63
+ if (peerInstanceCache.size >= CACHE_MAX) {
64
+ const oldest = peerInstanceCache.keys().next().value
65
+ if (oldest !== undefined) peerInstanceCache.delete(oldest)
66
+ }
67
+ peerInstanceCache.set(cwd, entry)
68
+ }
69
+
70
+ /**
71
+ * 批量判活:单次 tasklist(Windows 全量 CSV)/ ps(Unix)拿全量 PID 集合 → 一次 exec
72
+ * 比对(修复 isProcessAlive 每 pid 一次 execSync 的成本——每回合 N 次 = 贵,探索 §4)。
73
+ * 返回存活 pid 的 Set;exec/解析失败 → null(调用方区分"探测失败"与"全死"——只读面按
74
+ * 无活伴降级;域面(peer-domains 死清理)在 null 时不得执行删除——探测失败 ≠ 死)。
75
+ */
76
+ export function batchAlive(pids) {
77
+ const uniq = [...new Set(pids.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
78
+ if (uniq.length === 0) return new Set()
79
+ try {
80
+ if (process.platform === "win32") {
81
+ const output = execFileSync("tasklist", ["/FO", "CSV", "/NH"], {
82
+ encoding: "utf8", timeout: ALIVE_EXEC_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"],
83
+ })
84
+ const alive = new Set()
85
+ for (const line of output.split(/\r?\n/)) {
86
+ const m = line.match(/^"([^"]*)","(\d+)"/)
87
+ if (m) alive.add(Number(m[2]))
88
+ }
89
+ return new Set(uniq.filter((pid) => alive.has(pid)))
90
+ }
91
+ const output = execFileSync("ps", ["-eo", "pid="], {
92
+ encoding: "utf8", timeout: ALIVE_EXEC_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"],
93
+ })
94
+ const alive = new Set(output.split(/\r?\n/).map((l) => Number(l.trim())).filter((n) => Number.isInteger(n)))
95
+ return new Set(uniq.filter((pid) => alive.has(pid)))
96
+ } catch {
97
+ return null // 探测失败(区别于全死)——调用方不得据此执行删除/判死副作用
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 批量 cmdline 探测(决策③ A——一次 exec 拿全部 pid+cmdline):返回 Map<pid, cmdline>
103
+ * 或 null(exec 失败/解析失败)。Windows = 一次 Get-CimInstance(PowerShell);Unix =
104
+ * 一次 ps。仅在 manifest mtime 变化后跑一次(~百 ms 级——设计已接受)。
105
+ */
106
+ export function probeCmdlines(pids) {
107
+ const uniq = [...new Set(pids.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
108
+ if (uniq.length === 0) return new Map()
109
+ try {
110
+ let out
111
+ if (process.platform === "win32") {
112
+ // 一次 Get-CimInstance 拿全表 → node 侧过滤目标 pid(避免 shell 引号注入面)
113
+ out = execFileSync("powershell.exe",
114
+ ["-NoProfile", "-NonInteractive", "-Command",
115
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"],
116
+ { encoding: "utf8", timeout: CMDLINE_EXEC_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] })
117
+ const rows = JSON.parse(out.trim())
118
+ const map = new Map()
119
+ for (const r of Array.isArray(rows) ? rows : [rows]) {
120
+ if (r && Number.isInteger(r.ProcessId) && typeof r.CommandLine === "string") {
121
+ map.set(Number(r.ProcessId), r.CommandLine)
122
+ }
123
+ }
124
+ return map
125
+ }
126
+ out = execFileSync("ps", ["-eo", "pid=,args="], {
127
+ encoding: "utf8", timeout: CMDLINE_EXEC_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"],
128
+ })
129
+ const map = new Map()
130
+ for (const line of out.split(/\r?\n/)) {
131
+ const m = line.match(/^\s*(\d+)\s+(.*)$/)
132
+ if (m) map.set(Number(m[1]), m[2])
133
+ }
134
+ return map
135
+ } catch {
136
+ return null // 探测失败 → 端字段缺省(调用方降级)
137
+ }
138
+ }
139
+
140
+ /** cmdline → 端标签:扩展宿主标记 → vscode;其余(node/thincoder CLI)→ cli。 */
141
+ function classifyEnd(cmdline) {
142
+ if (typeof cmdline !== "string" || cmdline.length === 0) return undefined
143
+ return VSC_END_RE.test(cmdline) ? "vscode" : "cli"
144
+ }
145
+
146
+ /** manifest slotSessions → 按 sessionId 去重分组 [{ sessionId, pid, slots }]。
147
+ * sessionId 形如 "{pid}-{ts}-{rand}"(进程级——可去重分组,探索 §4);pid 不可解析
148
+ * 的条目跳过(存量清理归 saveManifest 的 cleanDeadOwners——本模块纯只读不写)。 */
149
+ function groupSlotSessions(m) {
150
+ const byId = new Map()
151
+ for (const [slot, sessionId] of Object.entries(m.slotSessions ?? {})) {
152
+ if (typeof sessionId !== "string" || sessionId.length === 0) continue
153
+ let g = byId.get(sessionId)
154
+ if (!g) {
155
+ const pid = Number.parseInt(sessionId.split("-")[0], 10)
156
+ g = { sessionId, pid: Number.isInteger(pid) ? pid : null, slots: [] }
157
+ byId.set(sessionId, g)
158
+ }
159
+ if (/^\d+$/.test(slot)) g.slots.push(Number(slot))
160
+ }
161
+ const groups = [...byId.values()]
162
+ for (const g of groups) g.slots.sort((a, b) => a - b)
163
+ return groups
164
+ }
165
+
166
+ /**
167
+ * 汇总当前 cwd 的活 thincoder 实例(含 self——由调用方过滤;self = 本进程 sessionId)。
168
+ * 返回 [{ pid, sessionId, slots, self, end? }]——end 仅在批量 cmdline 探测成功时给出
169
+ * (N4 白名单键之外零附加字段)。纯只读(N3);manifest 缺失/损坏 → [](按缺失降级)。
170
+ */
171
+ export function peerInstances(cwd) {
172
+ // 惰性:manifest mtime 未变 → 直接返回缓存快照(零 exec/零读——T-L1c)
173
+ const mp = manifestPath(cwd)
174
+ const mtime = statMtimeMs(mp)
175
+ if (mtime == null) {
176
+ peerInstanceCache.delete(cwd) // manifest 尚未诞生/被删——不缓存空结果(stat 一次成本)
177
+ return []
178
+ }
179
+ const hit = peerInstanceCache.get(cwd)
180
+ if (hit && hit.mtimeMs === mtime) return hit.peers
181
+
182
+ const m = loadManifest(cwd) // 损坏 → {slots:{}, sessionId:null}——按缺失降级
183
+ const groups = groupSlotSessions(m)
184
+ const myId = getSessionId()
185
+ const others = groups.filter((g) => g.sessionId !== myId && g.pid != null)
186
+ // 活判定:其余实例的 pid 去重后一次批量(self 恒活不查;无同伴 → 零 exec)
187
+ // 探测失败(batchAlive → null)→ 按"无活伴"降级——只读面不显示幽灵同伴
188
+ const aliveFn = _testImpl?.aliveFn ?? batchAlive
189
+ const othersAlive = others.length > 0 ? aliveFn([...new Set(others.map((g) => g.pid))]) : new Set()
190
+ const aliveSet = othersAlive instanceof Set ? othersAlive : new Set()
191
+ const liveOthers = others.filter((g) => aliveSet.has(g.pid))
192
+ // 端字段:一次批量 cmdline 探测(仅活同伴——决策③ A;探测失败 → end 缺省)
193
+ let cmdlines = null
194
+ if (liveOthers.length > 0) {
195
+ const probe = _testImpl?.cmdlineFn ?? probeCmdlines
196
+ cmdlines = probe([...new Set(liveOthers.map((g) => g.pid))])
197
+ }
198
+ const peers = []
199
+ const selfGroup = groups.find((g) => g.sessionId === myId)
200
+ if (selfGroup) {
201
+ peers.push({ pid: selfGroup.pid ?? process.pid, sessionId: myId, slots: selfGroup.slots, end: END, self: true })
202
+ }
203
+ for (const g of liveOthers) {
204
+ const end = cmdlines instanceof Map ? classifyEnd(cmdlines.get(g.pid)) : undefined
205
+ peers.push({ pid: g.pid, sessionId: g.sessionId, slots: g.slots, self: false, end })
206
+ }
207
+ peers.sort((a, b) => a.pid - b.pid || (a.self === b.self ? 0 : a.self ? -1 : 1))
208
+ cachePut(cwd, { mtimeMs: mtime, peers })
209
+ return peers
210
+ }
211
+
212
+ /**
213
+ * L2 只读查询工具(MULTI-INSTANCE-COLLAB §2a.4 D-L2b)——挂本模块导出。schema
214
+ * description 逐字锚(评审修正 #7——2026-09-06 定稿,双端照抄——禁止自行解释):
215
+ * "peer_instances — read-only: list other live ThinCoder instances sharing this
216
+ * workspace cwd. Returns [{ pid, end, sessionId, slots }]; never includes self;
217
+ * pure read — writes nothing."
218
+ */
219
+ export const peerInstancesTool = {
220
+ name: "peer_instances",
221
+ description:
222
+ "peer_instances — read-only: list other live ThinCoder instances sharing this workspace cwd. Returns [{ pid, end, sessionId, slots }]; never includes self; pure read — writes nothing.",
223
+ parameters: { type: "object", properties: {} },
224
+ readonly: true,
225
+ async execute(args, ctx) {
226
+ const list = peerInstances(ctx.cwd)
227
+ .filter((p) => !p.self)
228
+ .map((p) => ({ pid: p.pid, end: p.end, sessionId: p.sessionId, slots: p.slots }))
229
+ return JSON.stringify(list, null, 2)
230
+ },
231
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * prompt-overlays.mjs — prompt slot constants + the scenario→slot assembly (PROMPT-SYSTEM
3
+ * 施工② G1+G2, 2026-09-10). Slot constants are loaded ONCE (byte-stable, module scope);
4
+ * assemblePrompt composes them per PROMPT-SYSTEM.md §3.2 装配矩阵 (D1 内联表——设计锚).
5
+ *
6
+ * Slot model (PROMPT-SYSTEM.md §1/§2): persona → common → discipline → [4] other
7
+ * (AGENTS/skills ride the existing tail logic). explore/coder/plan reuse PERSONA_NORMAL
8
+ * as their persona slot (蓝图 §3.1 同槽位复用——变体差异归人格层覆写; design D1 G1 note).
9
+ */
10
+
11
+ import { readFileSync } from "node:fs"
12
+ import { join, dirname } from "node:path"
13
+ import { fileURLToPath } from "node:url"
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url))
16
+
17
+ function loadSlot(name) {
18
+ try { return readFileSync(join(__dirname, "prompts", name), "utf8") } catch { return "" }
19
+ }
20
+
21
+ // ── G1 槽位内容表(文件名 → 内容常量)──
22
+ const SLOT_CONTENTS = {
23
+ "persona-engineering.md": loadSlot("persona-engineering.md"),
24
+ "persona-normal.md": loadSlot("persona-normal.md"),
25
+ "persona-eng-coder.md": loadSlot("persona-eng-coder.md"),
26
+ "persona-eng-designer.md": loadSlot("persona-eng-designer.md"),
27
+ "persona-explore.md": loadSlot("persona-explore.md"),
28
+ "persona-coder.md": loadSlot("persona-coder.md"),
29
+ "persona-plan.md": loadSlot("persona-plan.md"),
30
+ "common.md": loadSlot("common.md"),
31
+ "discipline-engineering.md": loadSlot("discipline-engineering.md"),
32
+ "discipline-normal.md": loadSlot("discipline-normal.md"),
33
+ }
34
+
35
+ // ── G1 六件导出(评审 #1 计数口径:人格 2 + 公共 1 + 纪律 2 + consult 自含基底;
36
+ // persona-{role} 三件由 SLOT_CONTENTS 承载、不设独立常量导出——装配表唯一消费面)──
37
+ export const PERSONA_ENGINEERING = SLOT_CONTENTS["persona-engineering.md"]
38
+ export const PERSONA_NORMAL = SLOT_CONTENTS["persona-normal.md"]
39
+ export const COMMON = SLOT_CONTENTS["common.md"]
40
+ export const DISCIPLINE_ENGINEERING = SLOT_CONTENTS["discipline-engineering.md"]
41
+ export const DISCIPLINE_NORMAL = SLOT_CONTENTS["discipline-normal.md"]
42
+ export const CONSULT_BASE = loadSlot("consult-base.md")
43
+
44
+ // ── D1 场景→槽位文件表(PROMPT-SYSTEM.md §3.2 装配矩阵内联——①②并行契约锚:
45
+ // 槽文件路径字符串以本表为唯一权威——设计 §1.1 红线。explore/coder/plan 行 =
46
+ // persona-{role}(蓝图 §3.2 1:1——施工①已落地三份角色人格文件;advisor round1
47
+ // 修正:此前误按 G1 括注映射 persona-normal 使三文件永不被消费)。──
48
+ export const SCENARIO_SLOT_FILES = {
49
+ engineering: ["persona-engineering.md", "common.md", "discipline-engineering.md"],
50
+ normal: ["persona-normal.md", "common.md", "discipline-normal.md"],
51
+ "eng-coder": ["persona-eng-coder.md", "common.md", "discipline-engineering.md"],
52
+ "eng-designer": ["persona-eng-designer.md", "common.md", "discipline-engineering.md"],
53
+ explore: ["persona-explore.md", "common.md", "discipline-normal.md"],
54
+ coder: ["persona-coder.md", "common.md", "discipline-normal.md"],
55
+ plan: ["persona-plan.md", "common.md", "discipline-normal.md"],
56
+ consult: null, // §3.3 特殊模块——consult-base.md 自含基底,不入主装配链(CONSULT_BASE 直出)
57
+ }
58
+
59
+ // ── D2 警告文案(走既有 setup 警告通道 = history 注入——不新增机制;common 同款)──
60
+ export function slotWarning(fileName) {
61
+ return `[System reminder: prompt slot file ${fileName} missing — this slot is SKIPPED, no fallback from another slot (层间隔离). Prompt content may be degraded; check the prompts directory.]`
62
+ }
63
+
64
+ /**
65
+ * G2: the single prompt-assembly function — table-driven (D1), fixed order
66
+ * persona → common → discipline (§3.1 四槽位固定序;[4] AGENTS/skills 由既有尾部
67
+ * 逻辑承担). A missing slot file is SKIPPED with a prominent warning (蓝图 §3.4 降级链);
68
+ * AGENTS.md missing = silent skip in the caller's existing tail logic. Byte-stable
69
+ * per scenario (D3): fixed slot contents + fixed order — no timestamps here.
70
+ */
71
+ export function assemblePrompt(scenario) {
72
+ const files = SCENARIO_SLOT_FILES[scenario]
73
+ if (!files) return { prompt: CONSULT_BASE, warnings: [] }
74
+ const parts = []
75
+ const warnings = []
76
+ for (const file of files) {
77
+ const content = SLOT_CONTENTS[file]
78
+ if (content) parts.push(content)
79
+ else warnings.push(slotWarning(file))
80
+ }
81
+ return { prompt: parts.join("\n\n"), warnings }
82
+ }